feat: support workspace-managed SSH repository access

This commit is contained in:
2026-09-11 22:55:06 +09:00
parent 9d7ddcc04a
commit f6ce1df766
27 changed files with 3511 additions and 369 deletions
+1 -1
View File
@@ -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"
);
}
@@ -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"));
});