feat: add workspace runtime trust key management

This commit is contained in:
2026-09-06 05:06:37 +09:00
parent e7803d1aba
commit 78d571ed14
18 changed files with 3258 additions and 459 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 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 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 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",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -47,6 +47,7 @@ export type WorkspaceAuthConfig = {
export type WorkspacePermissionSummary = {
manage_repositories: boolean;
manage_secrets: boolean;
manage_runtimes: boolean;
};
export type DiagnosticSeverity = "info" | "warning" | "error";
@@ -221,6 +222,107 @@ export type RepositoryLogResponse = {
diagnostics: Array<Diagnostic>;
};
export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http";
export type RuntimeSourceStatus = "active" | "reserved";
export type RuntimeIdentityAuthority =
| "runtime_registry_projection"
| "server_runtime_configuration";
export type RuntimeSourceSummary = {
kind: RuntimeSourceKind;
status: RuntimeSourceStatus;
identity_authority: RuntimeIdentityAuthority;
note: string;
};
export type RuntimeSummary = {
runtime_id: string;
label: string;
kind: string;
status: string;
source: RuntimeSourceSummary;
host_ids: Array<string>;
worker_creation_available: boolean;
os: string;
arch: string;
diagnostics: Array<Diagnostic>;
};
export type RuntimeManagementSummary = {
built_in: boolean;
config_managed: boolean;
removable: boolean;
endpoint_configured: boolean;
token_ref_configured: boolean;
};
export type WorkspaceRuntimeResource = {
management: RuntimeManagementSummary;
runtime_id: string;
label: string;
kind: string;
status: string;
source: RuntimeSourceSummary;
host_ids: Array<string>;
worker_creation_available: boolean;
os: string;
arch: string;
diagnostics: Array<Diagnostic>;
};
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
export type RuntimeTrustKeyState = {
status: RuntimeTrustKeyStatus;
public_key?: string | null;
fingerprint?: string | null;
revision?: number | null;
created_at?: string | null;
updated_at?: string | null;
revoked_at?: string | null;
};
export type RuntimeTrustAuditAction =
| "created"
| "replaced"
| "reactivated"
| "revoked";
export type RuntimeTrustAuditEntry = {
action: RuntimeTrustAuditAction;
actor_account_id: string;
old_fingerprint?: string | null;
new_fingerprint?: string | null;
revision: number;
at: string;
};
export type WorkspaceRuntimeDetail = {
workspace_id: string;
runtime: WorkspaceRuntimeResource;
endpoint?: string | null;
trust_key: RuntimeTrustKeyState;
recent_audit: Array<RuntimeTrustAuditEntry>;
};
export type PutRuntimeTrustKeyRequest = {
public_key: string;
expected_revision: number | null;
};
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
export type RuntimeTrustConflictResponse = {
error: RuntimeTrustConflictKind;
message: string;
current_revision?: number;
current_fingerprint?: string | null;
};
export type RuntimeConnectionTestStatus = "compatible" | "failed";
export type RuntimeConnectionTestFailureKind =
@@ -0,0 +1,723 @@
import type {
Diagnostic,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeIdentityAuthority,
RuntimeManagementSummary,
RuntimeSourceKind,
RuntimeSourceStatus,
RuntimeSourceSummary,
RuntimeTrustAuditAction,
RuntimeTrustAuditEntry,
RuntimeTrustConflictKind,
RuntimeTrustConflictResponse,
RuntimeTrustKeyState,
RuntimeTrustKeyStatus,
WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
} from "$lib/generated/workspace-api.ts";
import type { ListResponse } from "$lib/workspace/sidebar/types";
import { workspaceApiPath } from "./http.ts";
export type WorkspaceRuntimeList = ListResponse<WorkspaceRuntimeResource>;
const LIMITS = {
runtimeItems: 200,
auditEntries: 20,
hostIds: 128,
diagnostics: 64,
idBytes: 256,
labelBytes: 512,
kindBytes: 128,
statusBytes: 128,
noteBytes: 2_048,
endpointBytes: 4_096,
publicKeyBytes: 16 * 1_024,
fingerprintBytes: 512,
timestampBytes: 128,
diagnosticCodeBytes: 128,
diagnosticMessageBytes: 2_048,
conflictMessageBytes: 1_024,
responseBytes: 512 * 1_024,
} as const;
const SOURCE_KINDS = new Set<RuntimeSourceKind>([
"embedded_worker_runtime",
"remote_http",
]);
const SOURCE_STATUSES = new Set<RuntimeSourceStatus>(["active", "reserved"]);
const IDENTITY_AUTHORITIES = new Set<RuntimeIdentityAuthority>([
"runtime_registry_projection",
"server_runtime_configuration",
]);
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
const TRUST_STATUSES = new Set<RuntimeTrustKeyStatus>([
"unconfigured",
"active",
"revoked",
]);
const AUDIT_ACTIONS = new Set<RuntimeTrustAuditAction>([
"created",
"replaced",
"reactivated",
"revoked",
]);
const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
"stale_revision",
"fingerprint_in_use",
]);
const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>;
export class RuntimeManagementValidationError extends Error {
constructor(message: string) {
super(message.slice(0, 256));
this.name = "RuntimeManagementValidationError";
}
}
export class RuntimeTrustConflictError extends Error {
readonly conflict: RuntimeTrustConflictResponse;
constructor(conflict: RuntimeTrustConflictResponse) {
super(conflict.message);
this.name = "RuntimeTrustConflictError";
this.conflict = conflict;
}
}
export class RuntimeTrustRequestError extends Error {
readonly field: "public_key" | null;
constructor(message: string, field: "public_key" | null = null) {
super(message.slice(0, 256));
this.name = "RuntimeTrustRequestError";
this.field = field;
}
}
function fail(path: string, message: string): never {
throw new RuntimeManagementValidationError(`${path} ${message}`);
}
function object(value: unknown, path: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return fail(path, "must be an object");
}
return value as JsonObject;
}
function exactKeys(
value: JsonObject,
required: readonly string[],
optional: readonly string[],
path: string,
): void {
const allowed = new Set([...required, ...optional]);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
fail(`${path}.${key}`, "is not part of the wire contract");
}
}
for (const key of required) {
if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required");
}
}
function array(value: unknown, path: string, max: number): unknown[] {
if (!Array.isArray(value)) return fail(path, "must be an array");
if (value.length > max) {
return fail(path, `must contain at most ${max} items`);
}
return value;
}
function boundedString(
value: unknown,
path: string,
maxBytes: number,
allowEmpty = false,
): string {
if (typeof value !== "string") return fail(path, "must be a string");
if (!allowEmpty && value.length === 0) return fail(path, "must not be empty");
if (encoder.encode(value).byteLength > maxBytes) {
return fail(path, `must be at most ${maxBytes} UTF-8 bytes`);
}
return value;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") return fail(path, "must be a boolean");
return value;
}
function safeInteger(value: unknown, path: string, minimum = 0): number {
if (
typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum
) {
return fail(path, `must be a safe integer of at least ${minimum}`);
}
return value;
}
function safeRevision(value: unknown, path: string): number {
return safeInteger(value, path, 1);
}
function optionalNullableString(
value: unknown,
path: string,
maxBytes: number,
allowEmpty = false,
): string | null | undefined {
if (value === undefined || value === null) return value;
return boundedString(value, path, maxBytes, allowEmpty);
}
function optionalRevision(
value: unknown,
path: string,
): number | undefined {
if (value === undefined || value === null) return undefined;
return safeRevision(value, path);
}
function optionalNullableRevision(
value: unknown,
path: string,
): number | null | undefined {
if (value === undefined || value === null) return value;
return safeRevision(value, path);
}
function timestamp(value: unknown, path: string): string {
const result = boundedString(value, path, LIMITS.timestampBytes);
if (
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
.test(result)
) {
return fail(path, "must be an RFC 3339 timestamp");
}
return result;
}
function optionalNullableTimestamp(
value: unknown,
path: string,
): string | null | undefined {
if (value === undefined || value === null) return value;
return timestamp(value, path);
}
function enumValue<T extends string>(
value: unknown,
path: string,
variants: ReadonlySet<T>,
): T {
const result = boundedString(value, path, LIMITS.kindBytes);
if (!variants.has(result as T)) {
return fail(path, "contains an unknown enum value");
}
return result as T;
}
function diagnostic(value: unknown, path: string): Diagnostic {
const item = object(value, path);
exactKeys(item, ["code", "severity", "message"], [], path);
const severity = enumValue(
item.severity,
`${path}.severity`,
DIAGNOSTIC_SEVERITIES,
) as Diagnostic["severity"];
return {
code: boundedString(item.code, `${path}.code`, LIMITS.diagnosticCodeBytes),
severity,
message: boundedString(
item.message,
`${path}.message`,
LIMITS.diagnosticMessageBytes,
true,
),
};
}
function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
const item = object(value, path);
exactKeys(item, ["kind", "status", "identity_authority", "note"], [], path);
return {
kind: enumValue(item.kind, `${path}.kind`, SOURCE_KINDS),
status: enumValue(item.status, `${path}.status`, SOURCE_STATUSES),
identity_authority: enumValue(
item.identity_authority,
`${path}.identity_authority`,
IDENTITY_AUTHORITIES,
),
note: boundedString(item.note, `${path}.note`, LIMITS.noteBytes, true),
};
}
function runtimeManagement(
value: unknown,
path: string,
): RuntimeManagementSummary {
const item = object(value, path);
exactKeys(
item,
[
"built_in",
"config_managed",
"removable",
"endpoint_configured",
"token_ref_configured",
],
[],
path,
);
return {
built_in: boolean(item.built_in, `${path}.built_in`),
config_managed: boolean(item.config_managed, `${path}.config_managed`),
removable: boolean(item.removable, `${path}.removable`),
endpoint_configured: boolean(
item.endpoint_configured,
`${path}.endpoint_configured`,
),
token_ref_configured: boolean(
item.token_ref_configured,
`${path}.token_ref_configured`,
),
};
}
function runtimeResource(
value: unknown,
path: string,
): WorkspaceRuntimeResource {
const item = object(value, path);
exactKeys(
item,
[
"management",
"runtime_id",
"label",
"kind",
"status",
"source",
"host_ids",
"worker_creation_available",
"os",
"arch",
"diagnostics",
],
[],
path,
);
const hostIds = array(item.host_ids, `${path}.host_ids`, LIMITS.hostIds).map(
(entry, index) =>
boundedString(
entry,
`${path}.host_ids[${index}]`,
LIMITS.idBytes,
),
);
if (new Set(hostIds).size !== hostIds.length) {
fail(`${path}.host_ids`, "must not contain duplicate IDs");
}
return {
management: runtimeManagement(item.management, `${path}.management`),
runtime_id: boundedString(
item.runtime_id,
`${path}.runtime_id`,
LIMITS.idBytes,
),
label: boundedString(item.label, `${path}.label`, LIMITS.labelBytes),
kind: boundedString(item.kind, `${path}.kind`, LIMITS.kindBytes),
status: boundedString(item.status, `${path}.status`, LIMITS.statusBytes),
source: runtimeSource(item.source, `${path}.source`),
host_ids: hostIds,
worker_creation_available: boolean(
item.worker_creation_available,
`${path}.worker_creation_available`,
),
os: boundedString(item.os, `${path}.os`, LIMITS.kindBytes, true),
arch: boundedString(item.arch, `${path}.arch`, LIMITS.kindBytes, true),
diagnostics: array(
item.diagnostics,
`${path}.diagnostics`,
LIMITS.diagnostics,
).map((entry, index) => diagnostic(entry, `${path}.diagnostics[${index}]`)),
};
}
function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
const item = object(value, path);
exactKeys(
item,
["status"],
[
"public_key",
"fingerprint",
"revision",
"created_at",
"updated_at",
"revoked_at",
],
path,
);
const result: RuntimeTrustKeyState = {
status: enumValue(item.status, `${path}.status`, TRUST_STATUSES),
public_key: optionalNullableString(
item.public_key,
`${path}.public_key`,
LIMITS.publicKeyBytes,
),
fingerprint: optionalNullableString(
item.fingerprint,
`${path}.fingerprint`,
LIMITS.fingerprintBytes,
),
revision: optionalNullableRevision(item.revision, `${path}.revision`),
created_at: optionalNullableTimestamp(
item.created_at,
`${path}.created_at`,
),
updated_at: optionalNullableTimestamp(
item.updated_at,
`${path}.updated_at`,
),
revoked_at: optionalNullableTimestamp(
item.revoked_at,
`${path}.revoked_at`,
),
};
const hasBinding = result.status !== "unconfigured";
if (
hasBinding &&
(result.fingerprint == null || result.revision == null ||
result.created_at == null || result.updated_at == null)
) {
fail(
path,
"must include fingerprint, revision, created_at, and updated_at",
);
}
if (
!hasBinding &&
Object.entries(result).some(([key, entry]) =>
key !== "status" && entry != null
)
) {
fail(path, "must not include binding values while unconfigured");
}
if (result.status === "revoked" && result.revoked_at == null) {
fail(`${path}.revoked_at`, "is required for a revoked key");
}
if (result.status === "active" && result.revoked_at != null) {
fail(`${path}.revoked_at`, "must be absent for an active key");
}
return result;
}
function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry {
const item = object(value, path);
exactKeys(
item,
["action", "actor_account_id", "revision", "at"],
["old_fingerprint", "new_fingerprint"],
path,
);
return {
action: enumValue(item.action, `${path}.action`, AUDIT_ACTIONS),
actor_account_id: boundedString(
item.actor_account_id,
`${path}.actor_account_id`,
LIMITS.idBytes,
),
old_fingerprint: optionalNullableString(
item.old_fingerprint,
`${path}.old_fingerprint`,
LIMITS.fingerprintBytes,
),
new_fingerprint: optionalNullableString(
item.new_fingerprint,
`${path}.new_fingerprint`,
LIMITS.fingerprintBytes,
),
revision: safeRevision(item.revision, `${path}.revision`),
at: timestamp(item.at, `${path}.at`),
};
}
export function parseWorkspaceRuntimeList(
value: unknown,
): WorkspaceRuntimeList {
const response = object(value, "Runtime list response");
exactKeys(
response,
["workspace_id", "limit", "items", "source", "diagnostics"],
[],
"Runtime list response",
);
const limit = safeInteger(response.limit, "Runtime list response.limit", 0);
if (limit > LIMITS.runtimeItems) {
fail(
"Runtime list response.limit",
`must not exceed ${LIMITS.runtimeItems}`,
);
}
const items = array(
response.items,
"Runtime list response.items",
LIMITS.runtimeItems,
).map((entry, index) =>
runtimeResource(entry, `Runtime list response.items[${index}]`)
);
if (items.length > limit) {
fail("Runtime list response.items", "must not exceed the declared limit");
}
return {
workspace_id: boundedString(
response.workspace_id,
"Runtime list response.workspace_id",
LIMITS.idBytes,
),
limit,
items,
source: boundedString(
response.source,
"Runtime list response.source",
LIMITS.kindBytes,
),
diagnostics: array(
response.diagnostics,
"Runtime list response.diagnostics",
LIMITS.diagnostics,
).map((entry, index) =>
diagnostic(entry, `Runtime list response.diagnostics[${index}]`)
),
};
}
export function parseWorkspaceRuntimeDetail(
value: unknown,
): WorkspaceRuntimeDetail {
const response = object(value, "Runtime detail response");
exactKeys(
response,
["workspace_id", "runtime", "trust_key", "recent_audit"],
["endpoint"],
"Runtime detail response",
);
return {
workspace_id: boundedString(
response.workspace_id,
"Runtime detail response.workspace_id",
LIMITS.idBytes,
),
runtime: runtimeResource(
response.runtime,
"Runtime detail response.runtime",
),
endpoint: optionalNullableString(
response.endpoint,
"Runtime detail response.endpoint",
LIMITS.endpointBytes,
),
trust_key: trustKey(
response.trust_key,
"Runtime detail response.trust_key",
),
recent_audit: array(
response.recent_audit,
"Runtime detail response.recent_audit",
LIMITS.auditEntries,
).map((entry, index) =>
auditEntry(entry, `Runtime detail response.recent_audit[${index}]`)
),
};
}
export function parseRuntimeTrustConflict(
value: unknown,
): RuntimeTrustConflictResponse {
const response = object(value, "Runtime trust conflict");
exactKeys(
response,
["error", "message"],
["current_revision", "current_fingerprint"],
"Runtime trust conflict",
);
return {
error: enumValue(
response.error,
"Runtime trust conflict.error",
CONFLICT_KINDS,
),
message: boundedString(
response.message,
"Runtime trust conflict.message",
LIMITS.conflictMessageBytes,
),
current_revision: optionalRevision(
response.current_revision,
"Runtime trust conflict.current_revision",
),
current_fingerprint: optionalNullableString(
response.current_fingerprint,
"Runtime trust conflict.current_fingerprint",
LIMITS.fingerprintBytes,
),
};
}
function revisionForJson(revision: number | null): number | null {
if (revision === null) return null;
if (!Number.isSafeInteger(revision) || revision < 1) {
throw new RuntimeTrustRequestError(
"Runtime trust revision is not a safe integer",
);
}
return revision;
}
async function readBoundedJson(response: Response): Promise<unknown> {
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const parsed = Number(contentLength);
if (Number.isFinite(parsed) && parsed > LIMITS.responseBytes) {
throw new RuntimeTrustRequestError(
"Runtime trust response exceeds its byte limit",
);
}
}
const text = await response.text();
if (encoder.encode(text).byteLength > LIMITS.responseBytes) {
throw new RuntimeTrustRequestError(
"Runtime trust response exceeds its byte limit",
);
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new RuntimeTrustRequestError(
"Runtime trust response is not valid JSON",
);
}
}
function requestErrorFrom(
value: unknown,
status: number,
): RuntimeTrustRequestError {
try {
const response = object(value, "Runtime trust error");
exactKeys(
response,
["error", "message", "diagnostics"],
[],
"Runtime trust error",
);
const diagnostics = array(
response.diagnostics,
"Runtime trust error.diagnostics",
LIMITS.diagnostics,
).map((entry, index) =>
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
);
const message = boundedString(
response.message,
"Runtime trust error.message",
LIMITS.conflictMessageBytes,
);
const field = diagnostics.some((entry) =>
entry.code.startsWith("runtime_public_key_")
)
? "public_key"
: null;
return new RuntimeTrustRequestError(message, field);
} catch {
return new RuntimeTrustRequestError(
`Runtime trust request failed (${status})`,
);
}
}
async function finishMutation(
response: Response,
workspaceId: string,
runtimeId: string,
): Promise<WorkspaceRuntimeDetail> {
const payload = await readBoundedJson(response);
if (response.status === 409) {
try {
throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload));
} catch (error) {
if (error instanceof RuntimeTrustConflictError) throw error;
throw new RuntimeTrustRequestError(
"Runtime trust conflict response was invalid",
);
}
}
if (!response.ok) throw requestErrorFrom(payload, response.status);
let detail: WorkspaceRuntimeDetail;
try {
detail = parseWorkspaceRuntimeDetail(payload);
} catch {
throw new RuntimeTrustRequestError("Runtime trust response was invalid");
}
if (
detail.workspace_id !== workspaceId ||
detail.runtime.runtime_id !== runtimeId
) {
throw new RuntimeTrustRequestError(
"Runtime trust response did not match the selected Runtime",
);
}
return detail;
}
export async function putRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: PutRuntimeTrustKeyRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
public_key: request.public_key,
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
export async function revokeRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: RevokeRuntimeTrustKeyRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
@@ -367,13 +367,18 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig {
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
const item = object(value, path);
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
exactKeys(
item,
["manage_repositories", "manage_secrets", "manage_runtimes"],
path,
);
return {
manage_repositories: boolean(
item.manage_repositories,
`${path}.manage_repositories`,
),
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`),
};
}
@@ -342,6 +342,196 @@
.settings-test-result.failed {
border-inline-start: 3px solid var(--danger);
}
.runtime-detail-page {
display: grid;
gap: var(--space-5);
}
.runtime-detail-section {
display: grid;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
.runtime-detail-section h2,
.runtime-detail-section p {
margin: 0;
}
.runtime-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: var(--space-3) var(--space-5);
margin: 0;
}
.runtime-detail-grid div {
min-width: 0;
}
.runtime-detail-grid dt {
margin-bottom: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.runtime-detail-grid dd {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.runtime-public-key-actions,
.runtime-revoke-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.runtime-public-key-actions {
justify-content: flex-start;
}
.runtime-public-key-actions button,
.runtime-revoke-row button,
.runtime-trust-form button {
border: 0;
border-radius: 0.6rem;
padding: 0.5rem 0.75rem;
background: var(--accent);
color: var(--bg);
font-weight: 700;
cursor: pointer;
}
.runtime-public-key-actions button.secondary {
border: 1px solid var(--line);
background: transparent;
color: var(--text-strong);
}
.runtime-public-key-actions button:disabled,
.runtime-revoke-row button:disabled,
.runtime-trust-form button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.runtime-public-key,
.runtime-trust-form textarea,
.runtime-trust-form input {
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg-raised);
color: var(--text-strong);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.runtime-public-key {
max-height: 14rem;
margin: 0;
padding: var(--space-3);
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.runtime-trust-form {
display: grid;
gap: var(--space-2);
max-width: 56rem;
}
.runtime-trust-form label {
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 700;
}
.runtime-trust-form textarea,
.runtime-trust-form input {
width: 100%;
padding: 0.65rem 0.75rem;
}
.runtime-trust-form textarea {
resize: vertical;
}
.runtime-trust-form small {
color: var(--text-muted);
}
.runtime-trust-form .field-error,
.runtime-detail-page .section-state.error {
color: var(--danger);
}
.runtime-detail-page .section-state.success {
color: var(--success);
}
.runtime-revoke-row {
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.runtime-revoke-row div {
display: grid;
gap: var(--space-1);
}
.runtime-revoke-row p {
color: var(--text-muted);
}
.runtime-revoke-row button.danger {
background: var(--danger);
}
.runtime-audit-table-wrap {
overflow-x: auto;
}
.runtime-audit-table {
width: 100%;
min-width: 48rem;
border-collapse: collapse;
}
.runtime-audit-table th,
.runtime-audit-table td {
padding: 0.7rem 0.5rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.runtime-audit-table th {
color: var(--text-muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.runtime-audit-table code {
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.runtime-revoke-row {
align-items: stretch;
}
}
.settings-page {
display: grid;
gap: var(--space-5);
@@ -1,9 +1,11 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { RuntimeConnectionTestResponse } from '$lib/generated/workspace-api';
import type {
RuntimeConnectionTestResponse,
WorkspaceRuntimeResource,
} from '$lib/generated/workspace-api';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { workspaceApiPath } from '$lib/workspace/api/http';
import type { Runtime } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -15,7 +17,7 @@
let requestError = $state<string | null>(null);
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
function runtimePlatform(runtime: Runtime): string {
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
}
@@ -38,7 +40,7 @@
}
}
function managementLabel(runtime: Runtime): string {
function managementLabel(runtime: WorkspaceRuntimeResource): string {
if (runtime.management?.built_in) return 'Built-in';
if (runtime.management?.config_managed) return 'Managed remote';
return 'Observed';
@@ -78,27 +80,7 @@
}
}
async function deleteRuntime(runtime: Runtime): Promise<void> {
requestError = null;
busyRuntimeId = runtime.runtime_id;
try {
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtime.runtime_id)}`),
{ method: 'DELETE' },
);
if (!response.ok) throw new Error(await responseError(response));
const nextResults = { ...testResults };
delete nextResults[runtime.runtime_id];
testResults = nextResults;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function testRuntime(runtime: Runtime): Promise<void> {
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
requestError = null;
busyRuntimeId = runtime.runtime_id;
try {
@@ -123,12 +105,14 @@
<h1 id="runtimes-heading">Runtimes</h1>
<p>Register and inspect the execution backends available to this Workspace.</p>
</div>
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
{showAddRuntime ? 'Close' : 'Add Runtime'}
</button>
{#if data.workspace.permissions.manage_runtimes}
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
{showAddRuntime ? 'Close' : 'Add Runtime'}
</button>
{/if}
</header>
{#if showAddRuntime}
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
<form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2>
<div class="settings-form-grid">
@@ -182,7 +166,11 @@
{#each data.runtimes.items as runtime}
<tr class:inactive={runtime.status !== 'active'}>
<td>
<strong>{runtime.label}</strong>
<strong>
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
{runtime.label}
</a>
</strong>
<small><code>{runtime.runtime_id}</code></small>
</td>
<td>{runtime.kind}</td>
@@ -203,15 +191,8 @@
onclick={() => testRuntime(runtime)}
>Test</button>
{/if}
{#if runtime.management?.removable}
<button
class="danger"
type="button"
disabled={busyRuntimeId !== null}
onclick={() => deleteRuntime(runtime)}
>Delete</button>
{:else}
<span class="settings-muted-action">Not removable</span>
{#if !runtime.management?.config_managed}
<span class="settings-muted-action">Test unavailable</span>
{/if}
</div>
</td>
@@ -1,11 +1,19 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ListResponse, Runtime } from "$lib/workspace/sidebar/types";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const runtimes = await loadJson<ListResponse<Runtime>>(
const runtimes = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/runtimes"),
undefined,
(value) => {
const response = parseWorkspaceRuntimeList(value);
if (response.workspace_id !== params.workspaceId) {
throw new Error("Runtime list Workspace did not match the route");
}
return response;
},
);
return {
@@ -0,0 +1,325 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type {
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api';
import {
putRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management';
import type { PageProps } from './$types';
type TrustAction = 'create' | 'replace' | 'reactivate';
let { data }: PageProps = $props();
let revealPublicKey = $state(false);
let publicKey = $state('');
let fingerprintConfirmation = $state('');
let busyAction = $state<'save' | 'revoke' | 'copy' | null>(null);
let fieldError = $state<string | null>(null);
let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
function trustAction(status: RuntimeTrustKeyStatus): TrustAction {
if (status === 'unconfigured') return 'create';
if (status === 'revoked') return 'reactivate';
return 'replace';
}
function actionLabel(action: TrustAction): string {
switch (action) {
case 'create': return 'Create Workspace trust';
case 'replace': return 'Replace trusted key';
case 'reactivate': return 'Reactivate with this key';
}
}
function formatTimestamp(value: string | null | undefined): string {
if (!value) return '—';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
async function reloadAuthority(): Promise<void> {
await invalidateAll();
}
async function saveTrustKey(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
fieldError = null;
requestError = null;
successMessage = null;
const key = publicKey.trim();
if (!key) {
fieldError = 'Enter the Runtime public key.';
return;
}
if (utf8Bytes(key) > 16 * 1024) {
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
return;
}
const trust = data.runtimeDetail.trust_key;
const action = trustAction(trust.status);
if (action !== 'create') {
if (!trust.fingerprint) {
requestError = 'The authoritative fingerprint is unavailable. Reload before changing trust.';
return;
}
if (fingerprintConfirmation.trim() !== trust.fingerprint) {
fieldError = 'Enter the current fingerprint exactly to confirm this change.';
return;
}
}
const request: PutRuntimeTrustKeyRequest = {
public_key: key,
expected_revision: trust.revision ?? null,
};
busyAction = 'save';
try {
await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
publicKey = '';
fingerprintConfirmation = '';
revealPublicKey = false;
successMessage = action === 'create'
? 'Workspace trust was created.'
: action === 'replace'
? 'The trusted Runtime key was replaced.'
: 'Workspace trust was reactivated.';
await reloadAuthority();
} catch (error) {
fingerprintConfirmation = '';
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
} else if (error instanceof RuntimeTrustRequestError && error.field === 'public_key') {
fieldError = error.message;
} else {
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
}
} finally {
busyAction = null;
}
}
async function revokeTrust(): Promise<void> {
if (busyAction !== null || !data.runtimeDetail) return;
const trust = data.runtimeDetail.trust_key;
if (trust.revision == null || trust.status !== 'active') {
requestError = 'Only active Workspace trust can be revoked.';
return;
}
fieldError = null;
requestError = null;
successMessage = null;
busyAction = 'revoke';
const request: RevokeRuntimeTrustKeyRequest = {
expected_revision: trust.revision,
};
try {
await revokeRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
publicKey = '';
fingerprintConfirmation = '';
revealPublicKey = false;
successMessage = 'Workspace trust was revoked.';
await reloadAuthority();
} catch (error) {
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
} else {
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
}
} finally {
busyAction = null;
}
}
async function copyPublicKey(): Promise<void> {
const key = data.runtimeDetail?.trust_key.public_key;
if (!key || busyAction !== null) return;
busyAction = 'copy';
requestError = null;
try {
await navigator.clipboard.writeText(key);
successMessage = 'Public key copied.';
} catch {
requestError = 'The browser could not copy the public key.';
} finally {
busyAction = null;
}
}
</script>
<svelte:head>
<title>{data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace</title>
<meta name="description" content="Runtime identity and Workspace trust settings" />
</svelte:head>
<section class="runtime-detail-page" aria-labelledby="runtime-detail-heading">
<header class="page-header-row">
<div>
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`}>Runtimes</a>
<h1 id="runtime-detail-heading">{data.runtimeDetail?.runtime.label ?? data.runtimeId}</h1>
<p><code>{data.runtimeId}</code></p>
</div>
<a class="button-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(data.runtimeId)}/workdirs`}>
Workdirs
</a>
</header>
{#if data.runtimeDetailError}
<p class="section-state error">{data.runtimeDetailError}</p>
{:else if !data.runtimeDetail}
<p class="section-state">Loading Runtime…</p>
{:else}
{@const detail = data.runtimeDetail}
{@const runtime = detail.runtime}
{@const trust = detail.trust_key}
{@const currentAction = trustAction(trust.status)}
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
<h2 id="runtime-identity-heading">Identity and binding</h2>
<dl class="runtime-detail-grid">
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
<div><dt>Binding status</dt><dd>{trust.status}</dd></div>
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
</dl>
{#if runtime.diagnostics.length > 0}
<ul class="settings-diagnostics-list">
{#each runtime.diagnostics as diagnostic}
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if data.workspace.permissions.manage_runtimes}
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
<h2 id="runtime-trust-heading">Workspace trust</h2>
{#if trust.public_key}
<div class="runtime-public-key-actions">
<button type="button" class="secondary" onclick={() => revealPublicKey = !revealPublicKey}>
{revealPublicKey ? 'Hide public key' : 'Reveal public key'}
</button>
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
</button>
</div>
{#if revealPublicKey}
<pre class="runtime-public-key"><code>{trust.public_key}</code></pre>
{/if}
{:else if trust.status !== 'unconfigured'}
<p class="section-state">The public key was not included in this authorized response.</p>
{/if}
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
<label for="runtime-public-key-input">Runtime public key</label>
<textarea
id="runtime-public-key-input"
bind:value={publicKey}
rows="5"
autocomplete="off"
spellcheck="false"
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
aria-invalid={fieldError ? 'true' : undefined}
placeholder="ssh-ed25519 …"
></textarea>
{#if currentAction !== 'create'}
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
<input
id="runtime-fingerprint-confirmation"
bind:value={fingerprintConfirmation}
autocomplete="off"
spellcheck="false"
placeholder={trust.fingerprint ?? ''}
/>
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly.</small>
{/if}
{#if fieldError}
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
{/if}
<div class="settings-action-row">
<button type="submit" disabled={busyAction !== null}>
{busyAction === 'save' ? 'Saving…' : actionLabel(currentAction)}
</button>
</div>
</form>
<div class="runtime-revoke-row">
<div>
<strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
</div>
<button
type="button"
class="danger"
disabled={busyAction !== null || trust.status !== 'active'}
onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div>
{#if requestError}
<p class="section-state error" role="alert">{requestError}</p>
{/if}
{#if successMessage}
<p class="section-state success" role="status">{successMessage}</p>
{/if}
</section>
{/if}
<section class="runtime-detail-section" aria-labelledby="runtime-audit-heading">
<h2 id="runtime-audit-heading">Recent trust audit</h2>
{#if detail.recent_audit.length === 0}
<p class="section-state">No trust changes are recorded.</p>
{:else}
<div class="runtime-audit-table-wrap">
<table class="runtime-audit-table">
<thead>
<tr><th>Action</th><th>Revision</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
</thead>
<tbody>
{#each detail.recent_audit as entry}
<tr>
<td>{entry.action}</td>
<td>{entry.revision.toString()}</td>
<td><code>{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}</code></td>
<td><code>{entry.actor_account_id}</code></td>
<td>{formatTimestamp(entry.at)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{/if}
</section>
@@ -0,0 +1,31 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeDetail } from "$lib/workspace/api/runtime-management";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const detail = await loadJson(
fetch,
workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(params.runtimeId)}`,
),
undefined,
(value) => {
const response = parseWorkspaceRuntimeDetail(value);
if (
response.workspace_id !== params.workspaceId ||
response.runtime.runtime_id !== params.runtimeId
) {
throw new Error("Runtime detail did not match the route");
}
return response;
},
);
return {
workspaceId: params.workspaceId,
runtimeId: params.runtimeId,
runtimeDetail: detail.data,
runtimeDetailError: detail.error,
};
};
@@ -0,0 +1,133 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
readTextFile(path: URL): Promise<string>;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Runtime Settings routes validate unknown JSON through the shared Runtime parser", async () => {
const [listLoader, detailLoader] = await Promise.all([
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/+page.ts",
import.meta.url,
),
),
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts",
import.meta.url,
),
),
]);
assert(
listLoader.includes("parseWorkspaceRuntimeList(value)"),
"Runtime list loader should validate unknown JSON",
);
assert(
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
"Runtime detail loader should validate unknown JSON",
);
for (const source of [listLoader, detailLoader]) {
assert(
!source.includes("loadJson<"),
"Runtime loaders must not cast response JSON to a handwritten DTO",
);
}
});
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte",
import.meta.url,
),
);
assert(
page.includes(
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}",
),
"Runtime name should link to canonical detail",
);
assert(
page.includes("testRuntime(runtime)"),
"connection Test should remain available",
);
assert(page.includes("Add Runtime"), "Add Runtime should remain available");
assert(
page.includes("data.workspace.permissions.manage_runtimes"),
"Add Runtime should be hidden from non-owners",
);
assert(
!page.includes("deleteRuntime"),
"inline Runtime delete logic must be removed",
);
assert(
!page.includes(">Delete</button>"),
"inline Runtime delete control must be removed",
);
});
Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
import.meta.url,
),
);
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
const reveal = page.indexOf("Reveal public key");
const mutation = page.indexOf('id="runtime-public-key-input"');
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
assert(
ownerGate < reveal && ownerGate < mutation,
"owner gate should wrap key controls",
);
for (
const token of [
"Create Workspace trust",
"Replace trusted key",
"Reactivate with this key",
"Confirm current fingerprint",
"Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
"RuntimeTrustConflictError",
"await reloadAuthority()",
"busyAction !== null",
"Workdirs",
"Recent trust audit",
]
) {
assert(page.includes(token), `Runtime detail should include ${token}`);
}
});
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
const [page, css] = await Promise.all([
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
import.meta.url,
),
),
Deno.readTextFile(
new URL("../src/lib/workspace/styles/settings.css", import.meta.url),
),
]);
assert(
!page.includes('class="card"') && !page.includes("settings-card"),
"Runtime detail should not add card nesting",
);
assert(
css.includes(".runtime-detail-section") &&
css.includes("border-top: 1px solid var(--line)"),
"Runtime detail hierarchy should use flat section separators",
);
});
@@ -0,0 +1,227 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
parseRuntimeTrustConflict,
parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList,
putRuntimeTrustKey,
RuntimeTrustConflictError,
} from "../src/lib/workspace/api/runtime-management.ts";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertThrows(operation: () => unknown, expected: string): void {
try {
operation();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes(expected)) return;
throw new Error(
`expected error containing ${expected}, received ${message}`,
);
}
throw new Error("expected operation to throw");
}
function runtime() {
return {
management: {
built_in: false,
config_managed: true,
removable: false,
endpoint_configured: true,
token_ref_configured: false,
},
runtime_id: "arcadia",
label: "Arcadia",
kind: "remote",
status: "started",
source: {
kind: "remote_http",
status: "active",
identity_authority: "server_runtime_configuration",
note: "Configured by Server authority",
},
host_ids: ["host-a"],
worker_creation_available: true,
os: "linux",
arch: "x86_64",
diagnostics: [],
};
}
function detail() {
return {
workspace_id: "workspace-a",
runtime: runtime(),
endpoint: "https://runtime.example.test",
trust_key: {
status: "active",
public_key: "ssh-ed25519 AAAA-test",
fingerprint: "SHA256:current",
revision: 3,
created_at: "2026-09-01T12:00:00Z",
updated_at: "2026-09-01T13:00:00Z",
revoked_at: null,
},
recent_audit: [{
action: "created",
actor_account_id: "account-a",
old_fingerprint: null,
new_fingerprint: "SHA256:current",
revision: 3,
at: "2026-09-01T13:00:00Z",
}],
};
}
Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes", () => {
const list = parseWorkspaceRuntimeList({
workspace_id: "workspace-a",
limit: 200,
items: [runtime()],
source: "workspace-control-plane",
diagnostics: [],
});
assert(
list.items[0]?.runtime_id === "arcadia",
"Runtime ID was not preserved",
);
const parsed = parseWorkspaceRuntimeDetail(detail());
assert(
parsed.trust_key.revision === 3,
"revision was not preserved as a safe integer",
);
assert(
parsed.recent_audit[0]?.revision === 3,
"audit revision was not normalized",
);
});
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
assertThrows(
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
"head_tree is not part",
);
const futureSource = structuredClone(detail());
futureSource.runtime.source.kind = "future_transport";
assertThrows(
() => parseWorkspaceRuntimeDetail(futureSource),
"contains an unknown enum value",
);
assertThrows(
() =>
parseRuntimeTrustConflict({
error: "future_conflict",
message: "conflict",
current_revision: 4,
current_fingerprint: "SHA256:new",
}),
"contains an unknown enum value",
);
});
Deno.test("Runtime validators reject unsafe revisions and bounded collection overflow", () => {
const unsafeRevision = structuredClone(detail());
unsafeRevision.trust_key.revision = Number.MAX_SAFE_INTEGER + 1;
assertThrows(
() => parseWorkspaceRuntimeDetail(unsafeRevision),
"must be a safe integer",
);
const tooMuchAudit = structuredClone(detail());
tooMuchAudit.recent_audit = Array.from(
{ length: 21 },
() => structuredClone(detail().recent_audit[0]),
);
assertThrows(
() => parseWorkspaceRuntimeDetail(tooMuchAudit),
"must contain at most 20 items",
);
const tooManyItems = Array.from({ length: 201 }, () => runtime());
assertThrows(
() =>
parseWorkspaceRuntimeList({
workspace_id: "workspace-a",
limit: 200,
items: tooManyItems,
source: "workspace-control-plane",
diagnostics: [],
}),
"must contain at most 200 items",
);
});
Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => {
const largeKey = structuredClone(detail());
largeKey.trust_key.public_key = "x".repeat(16 * 1024 + 1);
assertThrows(
() => parseWorkspaceRuntimeDetail(largeKey),
"must be at most 16384 UTF-8 bytes",
);
const activeWithoutFingerprint = structuredClone(detail()) as Record<
string,
unknown
>;
(activeWithoutFingerprint.trust_key as Record<string, unknown>).fingerprint =
null;
assertThrows(
() => parseWorkspaceRuntimeDetail(activeWithoutFingerprint),
"must include fingerprint",
);
});
Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => {
let sentBody: unknown = null;
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {
sentBody = JSON.parse(String(init?.body)) as unknown;
return Promise.resolve(
new Response(
JSON.stringify({
error: "stale_revision",
message: "Runtime trust changed",
current_revision: 4,
current_fingerprint: "SHA256:new",
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
try {
await putRuntimeTrustKey(
"workspace-a",
"arcadia",
{ public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 },
fetchImpl,
);
throw new Error("expected mutation to reject");
} catch (error) {
assert(
error instanceof RuntimeTrustConflictError,
"expected typed conflict",
);
assert(
error.conflict.current_revision === 4,
"authoritative revision was lost",
);
}
assert(
JSON.stringify(sentBody) ===
JSON.stringify({
public_key: "ssh-ed25519 AAAA-new",
expected_revision: 3,
}),
"request should serialize the generated bigint revision as a safe JSON integer",
);
});