feat: support workspace-managed SSH repository access
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -22,6 +22,20 @@ export type CreateRepositorySshCredentialRequest = {
|
||||
passphrase: string | null;
|
||||
};
|
||||
|
||||
export type GenerateRepositorySshCredentialRequest = {
|
||||
operation_id: string;
|
||||
credential_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type RepositorySshPublicKey = {
|
||||
credential_id: string;
|
||||
current_revision: number;
|
||||
public_key_algorithm: string;
|
||||
public_key_fingerprint: string;
|
||||
public_key: string;
|
||||
};
|
||||
|
||||
export type RotateRepositorySshCredentialRequest = {
|
||||
operation_id: string;
|
||||
expected_revision: number;
|
||||
|
||||
@@ -307,6 +307,38 @@ export type RepositoryDetailResponse = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type RepositorySshConnectionProbeRequest = { runtime_id: string };
|
||||
|
||||
export type RepositorySshHostKeyCandidate = {
|
||||
algorithm: string;
|
||||
host_key: string;
|
||||
fingerprint: string;
|
||||
};
|
||||
|
||||
export type RepositorySshConnectionTrustState =
|
||||
| "untrusted"
|
||||
| "verified"
|
||||
| "changed";
|
||||
|
||||
export type RepositorySshConnectionProbeResponse = {
|
||||
workspace_id: string;
|
||||
repository_key: string;
|
||||
runtime_id: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
trust_state: RepositorySshConnectionTrustState;
|
||||
host_trust_id: string;
|
||||
expected_host_trust_revision: number | null;
|
||||
candidates: Array<RepositorySshHostKeyCandidate>;
|
||||
};
|
||||
|
||||
export type ConfirmRepositorySshHostTrustRequest = {
|
||||
operation_id: string;
|
||||
runtime_id: string;
|
||||
host_key: string;
|
||||
expected_host_trust_revision: number | null;
|
||||
};
|
||||
|
||||
export type RepositoryLogResponse = {
|
||||
workspace_id: string;
|
||||
repository_key: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
RepositoryAccessProjection,
|
||||
RepositorySshCredential,
|
||||
RepositorySshHostTrust,
|
||||
RepositorySshPublicKey,
|
||||
} from "../../generated/repository-access-api.ts";
|
||||
|
||||
export class RepositoryAccessSchemaError extends Error {
|
||||
@@ -50,6 +51,25 @@ export function parseRepositorySshCredential(
|
||||
return record as RepositorySshCredential;
|
||||
}
|
||||
|
||||
export function parseRepositorySshPublicKey(
|
||||
value: unknown,
|
||||
path = "public_key",
|
||||
): RepositorySshPublicKey {
|
||||
const record = readRecord(value, path, [
|
||||
"credential_id",
|
||||
"current_revision",
|
||||
"public_key_algorithm",
|
||||
"public_key_fingerprint",
|
||||
"public_key",
|
||||
]);
|
||||
readString(record, "credential_id", path);
|
||||
readRevision(record, "current_revision", path);
|
||||
readString(record, "public_key_algorithm", path);
|
||||
readString(record, "public_key_fingerprint", path);
|
||||
readString(record, "public_key", path);
|
||||
return record as RepositorySshPublicKey;
|
||||
}
|
||||
|
||||
export function parseRepositorySshHostTrusts(
|
||||
value: unknown,
|
||||
): RepositorySshHostTrust[] {
|
||||
|
||||
@@ -10,6 +10,9 @@ import type {
|
||||
RepositoryLogResponse,
|
||||
RepositorySource,
|
||||
RepositorySourceKind,
|
||||
RepositorySshConnectionProbeResponse,
|
||||
RepositorySshConnectionTrustState,
|
||||
RepositorySshHostKeyCandidate,
|
||||
RepositorySummary,
|
||||
WorkspaceAuthConfig,
|
||||
WorkspaceCatalogListResponse,
|
||||
@@ -35,6 +38,8 @@ export type {
|
||||
RepositoryDetailResponse,
|
||||
RepositoryListResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositorySshConnectionProbeResponse,
|
||||
RepositorySshHostKeyCandidate,
|
||||
RepositorySummary,
|
||||
WorkspaceCatalogListResponse,
|
||||
WorkspaceCreateResponse,
|
||||
@@ -583,6 +588,98 @@ export function parseRepositoryDetailResponse(
|
||||
};
|
||||
}
|
||||
|
||||
const SSH_CONNECTION_TRUST_STATES = new Set<RepositorySshConnectionTrustState>([
|
||||
"untrusted",
|
||||
"verified",
|
||||
"changed",
|
||||
]);
|
||||
|
||||
function repositorySshHostKeyCandidate(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): RepositorySshHostKeyCandidate {
|
||||
const candidate = object(value, path);
|
||||
exactKeys(candidate, ["algorithm", "host_key", "fingerprint"], path);
|
||||
return {
|
||||
algorithm: string(candidate.algorithm, `${path}.algorithm`),
|
||||
host_key: string(candidate.host_key, `${path}.host_key`),
|
||||
fingerprint: string(candidate.fingerprint, `${path}.fingerprint`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRepositorySshConnectionProbeResponse(
|
||||
value: unknown,
|
||||
): RepositorySshConnectionProbeResponse {
|
||||
const response = object(value, "repository SSH connection probe response");
|
||||
exactKeys(
|
||||
response,
|
||||
[
|
||||
"workspace_id",
|
||||
"repository_key",
|
||||
"runtime_id",
|
||||
"hostname",
|
||||
"port",
|
||||
"trust_state",
|
||||
"host_trust_id",
|
||||
"expected_host_trust_revision",
|
||||
"candidates",
|
||||
],
|
||||
"repository SSH connection probe response",
|
||||
);
|
||||
const trustState = string(
|
||||
response.trust_state,
|
||||
"repository SSH connection probe response.trust_state",
|
||||
) as RepositorySshConnectionTrustState;
|
||||
if (!SSH_CONNECTION_TRUST_STATES.has(trustState)) {
|
||||
throw new Error(
|
||||
"repository SSH connection probe response.trust_state is invalid",
|
||||
);
|
||||
}
|
||||
return {
|
||||
workspace_id: string(
|
||||
response.workspace_id,
|
||||
"repository SSH connection probe response.workspace_id",
|
||||
),
|
||||
repository_key: string(
|
||||
response.repository_key,
|
||||
"repository SSH connection probe response.repository_key",
|
||||
),
|
||||
runtime_id: string(
|
||||
response.runtime_id,
|
||||
"repository SSH connection probe response.runtime_id",
|
||||
),
|
||||
hostname: string(
|
||||
response.hostname,
|
||||
"repository SSH connection probe response.hostname",
|
||||
),
|
||||
port: integer(
|
||||
response.port,
|
||||
"repository SSH connection probe response.port",
|
||||
),
|
||||
trust_state: trustState,
|
||||
host_trust_id: string(
|
||||
response.host_trust_id,
|
||||
"repository SSH connection probe response.host_trust_id",
|
||||
),
|
||||
expected_host_trust_revision: response.expected_host_trust_revision === null
|
||||
? null
|
||||
: integer(
|
||||
response.expected_host_trust_revision,
|
||||
"repository SSH connection probe response.expected_host_trust_revision",
|
||||
),
|
||||
candidates: array(
|
||||
response.candidates,
|
||||
"repository SSH connection probe response.candidates",
|
||||
).map(
|
||||
(candidate, index) =>
|
||||
repositorySshHostKeyCandidate(
|
||||
candidate,
|
||||
`repository SSH connection probe response.candidates[${index}]`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128;
|
||||
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
|
||||
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api";
|
||||
|
||||
export function repositorySshProbeRuntimes(
|
||||
runtimes: readonly WorkspaceRuntimeResource[],
|
||||
): WorkspaceRuntimeResource[] {
|
||||
return runtimes.filter((runtime) =>
|
||||
runtime.kind === "remote_worker_runtime" &&
|
||||
runtime.management.endpoint_configured &&
|
||||
runtime.management.binding !== undefined &&
|
||||
runtime.management.binding !== null &&
|
||||
runtime.management.binding.state !== "revoked"
|
||||
);
|
||||
}
|
||||
+122
-1
@@ -1,8 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import type {
|
||||
ConfirmRepositorySshHostTrustRequest,
|
||||
RepositorySshConnectionProbeRequest,
|
||||
RepositorySshConnectionProbeResponse
|
||||
} from '$lib/generated/workspace-api';
|
||||
import { parseRepositorySshHostTrust } from '$lib/workspace/api/repository-access';
|
||||
import { formatDate, workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import { parseRepositorySshConnectionProbeResponse } from '$lib/workspace/api/workspace-model';
|
||||
import { repositorySshProbeRuntimes } from '$lib/workspace/repositories/ssh-connection';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let selectedRuntimeId = $state('');
|
||||
let probe = $state<RepositorySshConnectionProbeResponse | null>(null);
|
||||
let selectedHostKey = $state('');
|
||||
let pending = $state(false);
|
||||
let connectionMessage = $state<string | null>(null);
|
||||
const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []);
|
||||
|
||||
$effect(() => {
|
||||
if (!selectedRuntimeId) {
|
||||
selectedRuntimeId = probeRuntimes[0]?.runtime_id ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
async function requestConnectionTest(method: 'POST' | 'PUT', body: unknown): Promise<unknown> {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(
|
||||
data.repository?.workspace_id ?? '',
|
||||
`/repositories/${encodeURIComponent(data.repositoryKey)}/ssh-connection-test`
|
||||
),
|
||||
{
|
||||
method,
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
const value = await response.json();
|
||||
if (!response.ok) {
|
||||
const record = value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||
throw new Error(typeof record?.message === 'string' ? record.message : `SSH connection test failed with status ${response.status}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function runConnectionTest() {
|
||||
pending = true;
|
||||
connectionMessage = null;
|
||||
probe = null;
|
||||
selectedHostKey = '';
|
||||
try {
|
||||
const body: RepositorySshConnectionProbeRequest = { runtime_id: selectedRuntimeId };
|
||||
probe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body));
|
||||
selectedHostKey = probe.candidates[0]?.host_key ?? '';
|
||||
connectionMessage = probe.trust_state === 'verified'
|
||||
? 'The observed SSH host key matches the Workspace trust record.'
|
||||
: 'Review the observed fingerprint before trusting this SSH host.';
|
||||
} catch (error) {
|
||||
connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmHostTrust() {
|
||||
if (!probe || !selectedHostKey) return;
|
||||
pending = true;
|
||||
connectionMessage = null;
|
||||
try {
|
||||
const body: ConfirmRepositorySshHostTrustRequest = {
|
||||
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
|
||||
runtime_id: probe.runtime_id,
|
||||
host_key: selectedHostKey,
|
||||
expected_host_trust_revision: probe.expected_host_trust_revision
|
||||
};
|
||||
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
|
||||
probe = { ...probe, trust_state: 'verified' };
|
||||
connectionMessage = 'SSH host trust saved. Future connections must present this key.';
|
||||
} catch (error) {
|
||||
connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -78,6 +158,47 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if data.repository?.item.source.kind === 'ssh'}
|
||||
<section class="card repository-detail-card">
|
||||
<h2>SSH connection test</h2>
|
||||
<p>Observe the SSH host key from the same Runtime that will clone this Repository. Nothing is trusted until you confirm a fingerprint below.</p>
|
||||
{#if data.runtimesError}
|
||||
<p class="section-state error">{data.runtimesError}</p>
|
||||
{:else if data.runtimes}
|
||||
<label>
|
||||
<span>Runtime</span>
|
||||
<select bind:value={selectedRuntimeId} disabled={pending}>
|
||||
{#each probeRuntimes as runtime}
|
||||
<option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if probeRuntimes.length === 0}
|
||||
<p class="section-state error">No configured remote Runtime is available for this connection test.</p>
|
||||
{/if}
|
||||
<button type="button" disabled={pending || !selectedRuntimeId} onclick={() => void runConnectionTest()}>
|
||||
{pending ? 'Checking…' : 'Check SSH connection'}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if probe}
|
||||
<p><strong>{probe.hostname}:{probe.port}</strong> · {probe.trust_state}</p>
|
||||
{#each probe.candidates as candidate}
|
||||
<label class="repository-host-key-candidate">
|
||||
<input type="radio" name="repository-host-key" bind:group={selectedHostKey} value={candidate.host_key} />
|
||||
<span><code>{candidate.algorithm}</code> <code>{candidate.fingerprint}</code></span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if probe.trust_state !== 'verified'}
|
||||
<button type="button" class="danger" disabled={pending || !selectedHostKey} onclick={() => void confirmHostTrust()}>
|
||||
Confirm and trust selected host key
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if connectionMessage}<p class="section-state" class:error={probe === null}>{connectionMessage}</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="card repository-log-card">
|
||||
<h2>Recent commits</h2>
|
||||
{#if data.repositoryLog}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
|
||||
import {
|
||||
parseRepositoryDetailResponse,
|
||||
parseRepositoryLogResponse,
|
||||
@@ -8,7 +9,7 @@ import type { PageLoad } from "./$types";
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const repositoryKey = params.repositoryKey;
|
||||
const [repositoryResult, logResult] = await Promise.all([
|
||||
const [repositoryResult, logResult, runtimesResult] = await Promise.all([
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
@@ -23,6 +24,10 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
`/repositories/${encodeURIComponent(repositoryKey)}/log`,
|
||||
),
|
||||
),
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/runtimes"),
|
||||
),
|
||||
]);
|
||||
|
||||
let repository = null;
|
||||
@@ -49,11 +54,25 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
}
|
||||
}
|
||||
|
||||
let runtimes = null;
|
||||
let runtimesError = runtimesResult.error;
|
||||
if (runtimesResult.data !== null) {
|
||||
try {
|
||||
runtimes = parseWorkspaceRuntimeList(runtimesResult.data);
|
||||
} catch (cause) {
|
||||
runtimesError = cause instanceof Error
|
||||
? cause.message
|
||||
: "invalid Runtime summary response";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repositoryKey,
|
||||
repository,
|
||||
repositoryError,
|
||||
repositoryLog: log,
|
||||
repositoryLogError: logError,
|
||||
runtimes,
|
||||
runtimesError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,24 +4,33 @@
|
||||
CreateRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshHostTrustRequest,
|
||||
GenerateRepositorySshCredentialRequest,
|
||||
PutRepositorySshHostTrustRequest,
|
||||
RepositorySshCredential,
|
||||
RepositorySshHostTrust,
|
||||
RepositorySshPublicKey,
|
||||
RotateRepositorySshCredentialRequest,
|
||||
} from '$lib/generated/repository-access-api';
|
||||
import {
|
||||
parseRepositorySshCredential,
|
||||
parseRepositorySshHostTrust,
|
||||
parseRepositorySshPublicKey,
|
||||
} from '$lib/workspace/api/repository-access';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||
let publicKeys = $state<Record<string, RepositorySshPublicKey>>(
|
||||
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
|
||||
);
|
||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||
const accessProjection = untrack(() => data.accessProjection);
|
||||
let message = $state<string | null>(null);
|
||||
let pending = $state(false);
|
||||
let copiedCredentialId = $state<string | null>(null);
|
||||
|
||||
let generateCredentialId = $state('');
|
||||
let generateCredentialName = $state('');
|
||||
let credentialId = $state('');
|
||||
let credentialName = $state('');
|
||||
let privateKey = $state('');
|
||||
@@ -37,6 +46,7 @@
|
||||
let hostExpectedRevision = $state<number | null>(null);
|
||||
|
||||
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
|
||||
const workspaceDefaultCredentialId = 'workspace-default';
|
||||
|
||||
function operationId(prefix: string): string {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
@@ -71,6 +81,55 @@
|
||||
return parse(payload);
|
||||
}
|
||||
|
||||
async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> {
|
||||
const response = await fetch(
|
||||
`${base}/credentials/${encodeURIComponent(credentialId)}/public-key`,
|
||||
{ headers: { accept: 'application/json' } }
|
||||
);
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Repository Access request failed with status ${response.status}.`);
|
||||
}
|
||||
return parseRepositorySshPublicKey(body);
|
||||
}
|
||||
|
||||
async function generateCredential() {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const body: GenerateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-generate'),
|
||||
credential_id: generateCredentialId,
|
||||
name: generateCredentialName
|
||||
};
|
||||
const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential);
|
||||
const publicKey = await loadPublicKey(created.credential_id);
|
||||
credentials = [...credentials.filter((item) => item.credential_id !== created.credential_id), created];
|
||||
publicKeys = { ...publicKeys, [created.credential_id]: publicKey };
|
||||
generateCredentialId = '';
|
||||
generateCredentialName = '';
|
||||
message = `Generated SSH credential ${created.credential_id}`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Failed to generate SSH credential';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPublicKey(credentialId: string) {
|
||||
const publicKey = publicKeys[credentialId]?.public_key;
|
||||
if (!publicKey) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicKey);
|
||||
copiedCredentialId = credentialId;
|
||||
window.setTimeout(() => {
|
||||
if (copiedCredentialId === credentialId) copiedCredentialId = null;
|
||||
}, 1500);
|
||||
} catch {
|
||||
message = 'Failed to copy the public key';
|
||||
}
|
||||
}
|
||||
|
||||
async function createCredential() {
|
||||
pending = true;
|
||||
message = null;
|
||||
@@ -88,7 +147,9 @@
|
||||
body,
|
||||
parseRepositorySshCredential
|
||||
);
|
||||
const publicKey = await loadPublicKey(created.credential_id);
|
||||
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
||||
publicKeys = { ...publicKeys, [created.credential_id]: publicKey };
|
||||
credentialId = '';
|
||||
credentialName = '';
|
||||
message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`;
|
||||
@@ -117,7 +178,9 @@
|
||||
body,
|
||||
parseRepositorySshCredential
|
||||
);
|
||||
const publicKey = await loadPublicKey(rotated.credential_id);
|
||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||
publicKeys = { ...publicKeys, [rotated.credential_id]: publicKey };
|
||||
rotateCredentialId = null;
|
||||
message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`;
|
||||
} catch (error) {
|
||||
@@ -145,6 +208,9 @@
|
||||
null
|
||||
);
|
||||
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
||||
const remainingPublicKeys = { ...publicKeys };
|
||||
delete remainingPublicKeys[credential.credential_id];
|
||||
publicKeys = remainingPublicKeys;
|
||||
message = `Credential ${credential.credential_id} deleted.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Credential deletion failed';
|
||||
@@ -227,7 +293,7 @@
|
||||
<div><p class="eyebrow">owner only</p><h2>Repository Access</h2></div>
|
||||
<span class="badge success">encrypted</span>
|
||||
</header>
|
||||
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
||||
<p>The Workspace default SSH key is generated separately from the Runtime authentication identity and is always offered during SSH clone. Without an explicit Repository binding, a unique pinned host trust matching the Repository URI is used with this default key. A binding can add one dedicated credential; OpenSSH receives both candidates and tries them through one operation-scoped agent. Private keys and passphrases remain write-only.</p>
|
||||
{#if message}<p class="status-message">{message}</p>{/if}
|
||||
|
||||
<div class="settings-runtime-list">
|
||||
@@ -237,7 +303,7 @@
|
||||
{#each accessProjection.bindings as binding (binding.repository_key)}
|
||||
<div class="card">
|
||||
<strong>{binding.repository_key}</strong>
|
||||
<p>{binding.access} · credential <code>{binding.credential_id}</code> · host trust <code>{binding.host_trust_id}</code></p>
|
||||
<p>{binding.access} · additional credential <code>{binding.credential_id}</code> · always includes <code>{workspaceDefaultCredentialId}</code> · host trust <code>{binding.host_trust_id}</code></p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -248,12 +314,19 @@
|
||||
{#each credentials as credential (credential.credential_id)}
|
||||
<div class="card">
|
||||
<strong>{credential.name}</strong> <code>{credential.credential_id}</code>
|
||||
{#if credential.credential_id === workspaceDefaultCredentialId}<span class="badge success">Workspace default</span>{/if}
|
||||
<p>{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}</p>
|
||||
<p>References: {credential.referenced_repositories.join(', ') || 'none'}</p>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button>
|
||||
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||
</div>
|
||||
{#if publicKeys[credential.credential_id]}
|
||||
<label><span>Public key</span><textarea readonly rows="3" value={publicKeys[credential.credential_id].public_key}></textarea></label>
|
||||
<button type="button" onclick={() => void copyPublicKey(credential.credential_id)}>{copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}</button>
|
||||
{/if}
|
||||
<p>References: {credential.credential_id === workspaceDefaultCredentialId ? 'all SSH repository operations' : credential.referenced_repositories.join(', ') || 'none'}</p>
|
||||
{#if credential.credential_id !== workspaceDefaultCredentialId}
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button>
|
||||
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if rotateCredentialId === credential.credential_id}
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void rotateCredential(credential); }}>
|
||||
<label><span>New private key</span><textarea bind:value={rotatePrivateKey} required rows="8" autocomplete="off"></textarea></label>
|
||||
@@ -264,8 +337,16 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void generateCredential(); }}>
|
||||
<h3>Generate Repository SSH credential</h3>
|
||||
<p>Create an additional Ed25519 key for a Repository binding. The Workspace default SSH key is already generated automatically and is included separately.</p>
|
||||
<label><span>Credential id</span><input bind:value={generateCredentialId} placeholder="repository-deploy" required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||
<label><span>Name</span><input bind:value={generateCredentialName} placeholder="Repository deploy key" required maxlength="200" /></label>
|
||||
<button type="submit" disabled={pending}>Generate credential</button>
|
||||
</form>
|
||||
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
|
||||
<h3>Add SSH credential</h3>
|
||||
<h3>Import existing SSH credential</h3>
|
||||
<label><span>Credential id</span><input bind:value={credentialId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||
<label><span>Name</span><input bind:value={credentialName} required maxlength="200" /></label>
|
||||
<label><span>OpenSSH private key (ssh-ed25519)</span><textarea bind:value={privateKey} required rows="10" autocomplete="off"></textarea></label>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
parseRepositoryAccessProjection,
|
||||
parseRepositorySshCredentials,
|
||||
parseRepositorySshHostTrusts,
|
||||
parseRepositorySshPublicKey,
|
||||
} from "$lib/workspace/api/repository-access";
|
||||
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
|
||||
import type { PageLoad } from "./$types";
|
||||
@@ -27,9 +28,25 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
),
|
||||
]);
|
||||
|
||||
const publicKeys = await Promise.all(
|
||||
credentials.map((credential) =>
|
||||
loadRepositoryAccessJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/settings/repository-access/credentials/${
|
||||
encodeURIComponent(credential.credential_id)
|
||||
}/public-key`,
|
||||
),
|
||||
parseRepositorySshPublicKey,
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
credentials,
|
||||
publicKeys,
|
||||
hostTrusts,
|
||||
accessProjection,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
parseRepositoryAccessProjection,
|
||||
parseRepositorySshCredentials,
|
||||
parseRepositorySshHostTrusts,
|
||||
parseRepositorySshPublicKey,
|
||||
RepositoryAccessSchemaError,
|
||||
} from "../../src/lib/workspace/api/repository-access.ts";
|
||||
|
||||
@@ -59,6 +60,22 @@ const hostTrust = {
|
||||
|
||||
Deno.test("Repository Access parsers accept generated response contracts", () => {
|
||||
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
|
||||
assertEquals(
|
||||
parseRepositorySshPublicKey({
|
||||
credential_id: "deploy-key",
|
||||
current_revision: 2,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
public_key: "ssh-ed25519 AAAA",
|
||||
}),
|
||||
{
|
||||
credential_id: "deploy-key",
|
||||
current_revision: 2,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
public_key: "ssh-ed25519 AAAA",
|
||||
},
|
||||
);
|
||||
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
|
||||
assertEquals(
|
||||
parseRepositoryAccessProjection({
|
||||
|
||||
@@ -28,6 +28,7 @@ test("Repository Access Web code consumes workspace-api generated DTOs", () => {
|
||||
assert(
|
||||
loaderSource.includes("parseRepositorySshCredentials") &&
|
||||
loaderSource.includes("parseRepositorySshHostTrusts") &&
|
||||
loaderSource.includes("parseRepositorySshPublicKey") &&
|
||||
loaderSource.includes("parseRepositoryAccessProjection"),
|
||||
"loader should validate unknown JSON before exposing generated DTOs to Svelte",
|
||||
);
|
||||
@@ -66,6 +67,29 @@ test("Repository Access renders the shared access projection fields", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Repository Access generates and copies selectable public keys", () => {
|
||||
for (
|
||||
const token of [
|
||||
"/credentials/generate",
|
||||
"/public-key",
|
||||
"Generate Repository SSH credential",
|
||||
"navigator.clipboard.writeText",
|
||||
"publicKeys[credential.credential_id]",
|
||||
"workspace-default",
|
||||
"always offered during SSH clone",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
source.includes(token),
|
||||
`missing generated public key flow ${token}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
source.includes("binding.credential_id"),
|
||||
"Repository bindings should identify the selected credential",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||
const createStart = source.indexOf("async function createCredential()");
|
||||
const rotateStart = source.indexOf("async function rotateCredential(");
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert";
|
||||
import type { WorkspaceRuntimeResource } from "../src/lib/generated/workspace-api.ts";
|
||||
import { parseRepositorySshConnectionProbeResponse } from "../src/lib/workspace/api/workspace-model.ts";
|
||||
import { repositorySshProbeRuntimes } from "../src/lib/workspace/repositories/ssh-connection.ts";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
const pageSource = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte",
|
||||
root,
|
||||
),
|
||||
);
|
||||
const loaderSource = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts",
|
||||
root,
|
||||
),
|
||||
);
|
||||
|
||||
Deno.test("Repository SSH probe parser preserves the confirmation contract", () => {
|
||||
const response = {
|
||||
workspace_id: "workspace-a",
|
||||
repository_key: "main",
|
||||
runtime_id: "runtime-a",
|
||||
hostname: "example.test",
|
||||
port: 22,
|
||||
trust_state: "untrusted" as const,
|
||||
host_trust_id: "tofu-example.test-22",
|
||||
expected_host_trust_revision: null,
|
||||
candidates: [
|
||||
{
|
||||
algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA",
|
||||
fingerprint: "SHA256:host",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assertEquals(parseRepositorySshConnectionProbeResponse(response), response);
|
||||
});
|
||||
|
||||
Deno.test("Repository SSH probe offers configured remote Runtimes regardless of worker-style status", () => {
|
||||
const configured = {
|
||||
runtime_id: "arcadia",
|
||||
label: "Arcadia",
|
||||
kind: "remote_worker_runtime",
|
||||
status: "idle",
|
||||
diagnostics: [],
|
||||
management: {
|
||||
endpoint_configured: true,
|
||||
endpoint_display: "https://arcadia.example",
|
||||
binding: {
|
||||
state: "verified",
|
||||
},
|
||||
},
|
||||
} as unknown as WorkspaceRuntimeResource;
|
||||
const embedded = {
|
||||
...configured,
|
||||
runtime_id: "embedded-worker-runtime",
|
||||
kind: "embedded_worker_runtime",
|
||||
} as unknown as WorkspaceRuntimeResource;
|
||||
const revoked = {
|
||||
...configured,
|
||||
runtime_id: "revoked",
|
||||
management: {
|
||||
...configured.management,
|
||||
binding: { state: "revoked" },
|
||||
},
|
||||
} as unknown as WorkspaceRuntimeResource;
|
||||
const unbound = {
|
||||
...configured,
|
||||
runtime_id: "unbound",
|
||||
management: {
|
||||
...configured.management,
|
||||
binding: undefined,
|
||||
},
|
||||
} as unknown as WorkspaceRuntimeResource;
|
||||
|
||||
assertEquals(
|
||||
repositorySshProbeRuntimes([embedded, configured, revoked, unbound]).map((
|
||||
runtime,
|
||||
) => runtime.runtime_id),
|
||||
["arcadia"],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository SSH connection test requires an explicit host-key confirmation", () => {
|
||||
for (
|
||||
const token of [
|
||||
"Check SSH connection",
|
||||
"selectedRuntimeId",
|
||||
"candidate.fingerprint",
|
||||
"Confirm and trust selected host key",
|
||||
"expected_host_trust_revision",
|
||||
"requestConnectionTest('POST'",
|
||||
"requestConnectionTest('PUT'",
|
||||
]
|
||||
) {
|
||||
assert(pageSource.includes(token), `missing SSH connection flow ${token}`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository detail loads configured Workspace Runtimes for the connection test", () => {
|
||||
assert(loaderSource.includes('workspaceApiPath(workspaceId, "/runtimes")'));
|
||||
assert(loaderSource.includes("parseWorkspaceRuntimeList"));
|
||||
});
|
||||
Reference in New Issue
Block a user