diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index da9762da31..77efa49493 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -444,6 +444,40 @@ test('defers pairing finalization when reconnect does not complete in time', asy await manager.close(); }); +test('bounds an in-flight pairing finalization and preserves its unknown outcome', async () => { + const local = candidateHarness({ hostId: 'host-a' }); + const remote = candidateHarness({ + hostId: 'a'.repeat(64), + finalizeFailures: [ + new RuntimeHostRequestInterruptedError( + 'access.credential.finalize', + 'command', + 'dispatched', + 'timeout', + ), + ], + }); + let starts = 0; + const manager = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async () => ready(starts++ === 0 ? local.candidate : remote.candidate), + pairingFinalizationTimeoutMs: 25, + }, + ); + await manager.enable(remoteTarget('office')); + + await assert.rejects( + () => manager.finalizePairing('office'), + RuntimeHostPairingFinalizationInterruptedError, + ); + + assert.equal(remote.finalizeCalls, 1); + assert.equal(remote.finalizeTimeouts.length, 1); + assert.ok(remote.finalizeTimeouts[0]! > 0 && remote.finalizeTimeouts[0]! <= 25); + await manager.close(); +}); + test('coalesces concurrent enable requests for one remote profile', async () => { const local = candidateHarness({ hostId: 'host-a' }); const remote = candidateHarness({ hostId: 'host-b', lifecycleMode: 'remote' }); @@ -777,6 +811,7 @@ function candidateHarness( let lifecycleState: 'ready' | 'unavailable' = 'ready'; let prepareUpgradeCalls = 0; let finalizeCalls = 0; + const finalizeTimeouts: number[] = []; const prepareUpgradeAuthorities: boolean[] = []; const candidate = { closed, @@ -802,8 +837,9 @@ function candidateHarness( } return { kind: 'prepared' as const, pid: 42 }; }, - async finalizeAccessCredential() { + async finalizeAccessCredential(timeoutMs?: number) { finalizeCalls += 1; + if (timeoutMs !== undefined) finalizeTimeouts.push(timeoutMs); const failure = options.finalizeFailures?.shift(); if (failure) { if (options.disconnectOnFinalizeFailure) { @@ -854,6 +890,7 @@ function candidateHarness( get finalizeCalls() { return finalizeCalls; }, + finalizeTimeouts, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 85873acd11..70392ced1a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -1,16 +1,171 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { runtimeHostAccessCredentialFingerprint } from '@maka/runtime-host/operator'; import { createDesktopRuntimeHostManagement } from '../runtime-host-management.js'; import type { + DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshCleanupInput, DesktopRuntimeHostSshManagementInput, } from '../runtime-host-ssh-terminal.js'; +test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => { + const handlers = new Map unknown>(); + const profile = { + id: 'office', + name: 'Office', + kind: 'remote' as const, + rootId: 'a'.repeat(64), + transport: { + kind: 'ssh' as const, + destination: 'operator@example.com', + remotePort: 7443, + websocketPath: '/runtime-host', + }, + }; + const service = { + id: 'b'.repeat(64), + rootPath: '/srv/maka', + operatorPath: '/home/operator/.local/share/maka/operator', + }; + const principalId = 'desktop:original-installation'; + const replacement = 'maka_rh_replacement-secret'; + let profileEnabled = true; + let prepareCalls = 0; + let currentFingerprint = runtimeHostAccessCredentialFingerprint('maka_rh_current-secret'); + let credentials = [ + accessCredential('current', principalId, currentFingerprint), + accessCredential( + 'obsolete', + principalId, + runtimeHostAccessCredentialFingerprint('maka_rh_obsolete-secret'), + ), + ]; + + createDesktopRuntimeHostManagement({ + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + profiles: { + resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedAccess: async () => ({ + profile, + service, + state: 'active' as const, + credentialFingerprint: currentFingerprint, + enabled: profileEnabled, + }), + rotateManagedCredential: async (_profileId, credential) => { + assert.equal(credential, replacement); + currentFingerprint = runtimeHostAccessCredentialFingerprint(credential); + credentials = [accessCredential('replacement', principalId, currentFingerprint)]; + }, + markManagedServiceUninstalling: async (binding) => binding, + clearManagedServiceBinding: async () => undefined, + }, + runServiceManagement: async () => assert.fail('service management is not expected'), + runAccessManagement: async (input: DesktopRuntimeHostSshAccessInput) => { + if (input.action === 'list') { + return { schemaVersion: 1, kind: 'result', action: 'list', credentials }; + } + if (input.action === 'prepare') { + prepareCalls += 1; + assert.equal(input.currentCredentialFingerprint, currentFingerprint); + const pending = { + ...accessCredential( + 'replacement', + principalId, + runtimeHostAccessCredentialFingerprint(replacement), + ), + status: 'pending' as const, + expiresAt: '2026-08-21T01:15:00.000Z', + }; + return { + schemaVersion: 1, + kind: 'result', + action: 'prepare', + credential: replacement, + credentials: [...credentials, pending], + }; + } + assert.equal(input.currentCredentialFingerprint, currentFingerprint); + const target = credentials.find( + (credential) => credential.credentialId === input.credentialId, + ); + if (target?.credentialFingerprint === input.currentCredentialFingerprint) { + return { + schemaVersion: 1, + kind: 'error', + action: 'revoke', + error: { + code: 'credential_protected', + message: 'Rotate this Desktop credential instead of revoking it', + }, + }; + } + credentials = credentials.filter( + (credential) => credential.credentialId !== input.credentialId, + ); + return { + schemaVersion: 1, + kind: 'result', + action: 'revoke', + credentialId: input.credentialId!, + revoked: true, + credentials, + }; + }, + cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), + }); + + const list = handlers.get('runtime-host-management:list-credentials'); + const rotate = handlers.get('runtime-host-management:rotate-credential'); + const revoke = handlers.get('runtime-host-management:revoke-credential'); + assert.ok(list && rotate && revoke); + const initial = await list({}, profile.id); + assert.equal((initial as { canRotate: boolean }).canRotate, true); + assert.deepEqual( + (initial as { credentials: { credentialId: string; isCurrentDesktop: boolean }[] }).credentials + .map(({ credentialId, isCurrentDesktop }) => ({ credentialId, isCurrentDesktop })), + [ + { credentialId: 'current', isCurrentDesktop: true }, + { credentialId: 'obsolete', isCurrentDesktop: false }, + ], + ); + await assert.rejects( + revoke({}, profile.id, 'current') as Promise, + /Rotate this Desktop credential/u, + ); + const revoked = await revoke({}, profile.id, 'obsolete'); + assert.equal(JSON.stringify(revoked).includes('obsolete-secret'), false); + const rotated = await rotate({}, profile.id); + assert.equal(JSON.stringify(rotated).includes(replacement), false); + assert.deepEqual( + (rotated as { credentials: { credentialId: string; isCurrentDesktop: boolean }[] }).credentials, + [{ + credentialId: 'replacement', + principalKind: 'remote_owner', + principalId, + status: 'active', + createdAt: '2026-08-21T01:00:00.000Z', + isCurrentDesktop: true, + }], + ); + profileEnabled = false; + assert.equal((await list({}, profile.id) as { canRotate: boolean }).canRotate, false); + await assert.rejects( + rotate({}, profile.id) as Promise, + /Enable this Runtime Host before rotating/u, + ); + assert.equal(prepareCalls, 1); +}); + test('manages only the service identity bound by Desktop onboarding', async () => { const handlers = new Map unknown>(); const managementInputs: DesktopRuntimeHostSshManagementInput[] = []; const cleanupInputs: DesktopRuntimeHostSshCleanupInput[] = []; const uninstallOrder: string[] = []; + let operatorAccess = false; let cleared = 0; const managedProfile = { id: 'office', @@ -39,6 +194,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = profileId === managedProfile.id ? { profile: managedProfile, service: managedService, state: 'active' as const } : undefined, + resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { uninstallOrder.push('mark-uninstalling'); return { ...binding, state: 'uninstalling' as const }; @@ -47,14 +203,16 @@ test('manages only the service identity bound by Desktop onboarding', async () = cleared += 1; uninstallOrder.push('clear-binding'); }, + rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), }, runServiceManagement: async (input) => { managementInputs.push(input); if (input.action === 'uninstall') { uninstallOrder.push('uninstall-service'); } - return serviceResult(input.action); + return serviceResult(input.action, operatorAccess); }, + runAccessManagement: async () => assert.fail('access management is not expected'), cleanupManagedDeployment: async (input) => { cleanupInputs.push(input); uninstallOrder.push('cleanup-deployment'); @@ -67,7 +225,17 @@ test('manages only the service identity bound by Desktop onboarding', async () = run({}, 'manual', 'uninstall') as Promise, /not bound to a managed service/u, ); - await run({}, 'office', 'status'); + const legacyStatus = await run({}, 'office', 'status'); + assert.equal( + (legacyStatus as { accessManagementAvailable: boolean }).accessManagementAvailable, + false, + ); + operatorAccess = true; + const currentStatus = await run({}, 'office', 'status'); + assert.equal( + (currentStatus as { accessManagementAvailable: boolean }).accessManagementAvailable, + true, + ); const managementInput = managementInputs.at(-1); assert.deepEqual(managementInput && { destination: managementInput.destination, @@ -143,6 +311,7 @@ test('resumes deployment cleanup without repeating the committed service uninsta }, profiles: { resolveManagedService: async () => ({ profile, service, state }), + resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { state = 'uninstalling'; return { ...binding, state }; @@ -151,11 +320,13 @@ test('resumes deployment cleanup without repeating the committed service uninsta clearAttempts += 1; if (clearAttempts === 1) throw new Error('local metadata is unavailable'); }, + rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), }, runServiceManagement: async (input) => { calls.push(input); return serviceResult(input.action); }, + runAccessManagement: async () => assert.fail('access management is not expected'), cleanupManagedDeployment: async () => { cleanups += 1; }, @@ -206,11 +377,13 @@ test('does not commit uninstall until the remote service confirms it is removed' }, state: 'active' as const, }), + resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { marked = true; return { ...binding, state: 'uninstalling' as const }; }, clearManagedServiceBinding: async () => assert.fail('uninstall was not committed'), + rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), }, runServiceManagement: async () => { const result = serviceResult('uninstall'); @@ -219,6 +392,7 @@ test('does not commit uninstall until the remote service confirms it is removed' service: { ...result.service, state: 'running' as const, pid: 42 }, }; }, + runAccessManagement: async () => assert.fail('access management is not expected'), cleanupManagedDeployment: async () => assert.fail('cleanup must not start'), }); @@ -231,11 +405,17 @@ test('does not commit uninstall until the remote service confirms it is removed' assert.equal(marked, false); }); -function serviceResult(action: DesktopRuntimeHostSshManagementInput['action']) { +function serviceResult( + action: DesktopRuntimeHostSshManagementInput['action'], + operatorAccess = false, +) { return { schemaVersion: 1 as const, kind: 'result' as const, action, + ...(operatorAccess + ? { operatorCapabilities: ['access-management-v1' as const] } + : {}), service: { platform: 'linux', arch: 'x64', @@ -248,3 +428,21 @@ function serviceResult(action: DesktopRuntimeHostSshManagementInput['action']) { }, }; } + +function accessCredential( + credentialId: string, + principalId: string, + credentialFingerprint: string, +) { + return { + credentialId, + credentialFingerprint, + principalKind: 'remote_owner' as const, + principalId, + status: 'active' as const, + operationGrants: ['host.status', 'turn.start'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + createdAt: '2026-08-21T01:00:00.000Z', + }; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts index 02e8471743..25ea6c772e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts @@ -503,8 +503,13 @@ test("preserves a staged pairing when finalization is interrupted", async () => setDefault: () => undefined, finalizePairing: async () => undefined, }); + await assert.rejects( + recovered.resolveManagedAccess(MANAGED_PROFILE.id), + /unfinished pairing/u, + ); await recovered.startEnabledProfiles(); + assert.ok(await recovered.resolveManagedAccess(MANAGED_PROFILE.id)); assert.equal( (await recovered.getSnapshot()).entries.find( (entry) => entry.profile.id === MANAGED_PROFILE.id, diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 11eed00642..b6d46cf9d8 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -8,8 +8,10 @@ import { type RuntimeHostSshProcessFactory, } from '@maka/runtime-host/client'; import { + encodeRuntimeHostAccessManagementFrame, encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, + runtimeHostAccessCredentialFingerprint, RUNTIME_HOST_SETUP_FRAME_PREFIX, } from '@maka/runtime-host/operator'; import { createDesktopRuntimeHostSshTerminal } from '../runtime-host-ssh-terminal.js'; @@ -217,6 +219,8 @@ test('reads a framed service result without projecting it into the SSH terminal' await waitFor(() => harness.pty.hasDataListener()); const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; assert.match(remoteCommand, /\.local\/share\/maka\/operator/u); + assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u); + assert.match(remoteCommand, /access-management-v1/u); assert.doesNotMatch(remoteCommand, /npx|maka-agent@/u); harness.pty.emitData('Password: '); harness.pty.emitData( @@ -248,6 +252,81 @@ test('reads a framed service result without projecting it into the SSH terminal' await harness.terminal.close(); }); +test('keeps a prepared access credential out of the SSH terminal projection', async () => { + const harness = createHarness('pending'); + const credential = 'maka_rh_secret-replacement'; + const management = harness.terminal.runAccessManagement({ + destination: 'operator@example.com', + operatorPath: '/home/operator/.local/share/maka/operator', + rootPath: '/srv/maka', + expectedRootId: 'a'.repeat(64), + action: 'prepare', + currentCredentialFingerprint: 'b'.repeat(32), + }); + await waitFor(() => harness.pty.hasDataListener()); + harness.pty.emitData('Password: '); + harness.pty.emitData( + encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'prepare', + credential, + credentials: [{ + credentialId: 'credential-2', + credentialFingerprint: runtimeHostAccessCredentialFingerprint(credential), + principalKind: 'remote_owner', + principalId: 'desktop:stable-client', + status: 'pending', + operationGrants: ['host.status', 'access.credential.finalize'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + createdAt: '2026-08-21T01:00:00.000Z', + expiresAt: '2026-08-21T01:15:00.000Z', + }], + }), + ); + harness.pty.exit(0); + + const result = await management; + assert.equal(result.kind, 'result'); + assert.equal(result.kind === 'result' && result.action === 'prepare' ? result.credential : undefined, credential); + assert.doesNotMatch(JSON.stringify(harness.events), /secret-replacement|MAKA_RUNTIME/u); + const command = harness.launchArgs.at(-1)?.at(-1) ?? ''; + assert.match(command, /access.*prepare/u); + assert.match(command, /--current-fingerprint/u); + assert.match(command, new RegExp('b{32}', 'u')); + assert.doesNotMatch(command, /secret-replacement/u); + await harness.terminal.close(); +}); + +test('accepts the revoked result for an access revoke action', async () => { + const harness = createHarness('pending'); + const management = harness.terminal.runAccessManagement({ + destination: 'operator@example.com', + operatorPath: '/home/operator/.local/share/maka/operator', + rootPath: '/srv/maka', + expectedRootId: 'a'.repeat(64), + action: 'revoke', + credentialId: 'credential-1', + currentCredentialFingerprint: 'b'.repeat(32), + }); + await waitFor(() => harness.pty.hasDataListener()); + harness.pty.emitData( + encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'revoke', + credentialId: 'credential-1', + revoked: true, + credentials: [], + }), + ); + harness.pty.exit(0); + + assert.deepEqual((await management).action, 'revoke'); + await harness.terminal.close(); +}); + test('rejects a framed service result for a different action', async () => { const harness = createHarness('pending'); const management = harness.terminal.runServiceManagement({ diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index c9c4015913..38cbd6ce44 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -330,6 +330,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, + runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, }); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ae66a200b3..e8570fca9d 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -236,8 +236,10 @@ export class DesktopRuntimeHostClient { return this.#connectionClosed || this.#closeTask ? 'unavailable' : 'ready'; } - finalizeAccessCredential(): Promise> { - return this.request('access.credential.finalize', {}); + finalizeAccessCredential( + timeoutMs?: number, + ): Promise> { + return this.request('access.credential.finalize', {}, timeoutMs); } subscribeConfigurationChanges(listener: (revision: number) => void): () => void { @@ -1561,9 +1563,10 @@ export class DesktopRuntimeHostClient { request( operation: K, input: OperationInput, + timeoutMs?: number, ): Promise> { this.#assertOpen(); - return this.connection.request(operation, input); + return this.connection.request(operation, input, timeoutMs); } #assertOpen(): void { diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index ed8ee13f7a..59ba12dbf8 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -96,8 +96,8 @@ export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconn } export class RuntimeHostPairingFinalizationInterruptedError extends Error { - constructor() { - super('Runtime Host pairing finalization was deferred until the next startup'); + constructor(options?: ErrorOptions) { + super('Runtime Host pairing finalization was deferred until the next startup', options); this.name = 'RuntimeHostPairingFinalizationInterruptedError'; } } @@ -256,6 +256,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { throw new Error('Only remote Runtime Host profiles can finalize pairing'); } const lifecycle = this.#requireLifecycle(target); + const deadline = Date.now() + this.pairingFinalizationTimeoutMs; const timeout = new AbortController(); const timer = setTimeout( () => timeout.abort(new RuntimeHostPairingFinalizationInterruptedError()), @@ -273,9 +274,14 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { throw new Error('Runtime Host target changed before pairing was finalized'); } try { - await candidate.client.finalizeAccessCredential(); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw new RuntimeHostPairingFinalizationInterruptedError(); + await candidate.client.finalizeAccessCredential(remainingMs); return; } catch (error) { + if (pairingFinalizeTimedOut(error)) { + throw new RuntimeHostPairingFinalizationInterruptedError({ cause: error }); + } const retry = pairingFinalizeRetry(error); if (!retry) throw error; candidate = await this.#waitForReadyCandidate(lifecycle, candidate, signal); @@ -802,6 +808,14 @@ function pairingFinalizeRetry(error: unknown): boolean { return false; } +function pairingFinalizeTimedOut(error: unknown): boolean { + return ( + error instanceof RuntimeHostRequestInterruptedError && + error.operation === 'access.credential.finalize' && + error.reason === 'timeout' + ); +} + function withRuntimeHostTarget( input: DesktopRuntimeHostCandidateStartInput, remote: DesktopRuntimeHostCandidateStartInput['remote'], diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index d8f79fea2c..a22e9d2ce5 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -1,12 +1,19 @@ import type { IpcMain } from 'electron'; -import type { RuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; +import { + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + runtimeHostAccessCredentialFingerprint, + type RuntimeHostAccessManagementFrame, + type RuntimeHostServiceManagementFrame, +} from '@maka/runtime-host/operator'; import type { + DesktopRuntimeHostAccessSnapshot, DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResponse, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; import type { DesktopRuntimeHostSshCleanupInput, + DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshManagementInput, } from './runtime-host-ssh-terminal.js'; @@ -19,26 +26,39 @@ const MANAGEMENT_ACTIONS = new Set([ 'uninstall', ]); +type RuntimeHostAccessCredentialMetadata = Extract< + RuntimeHostAccessManagementFrame, + { kind: 'result'; action: 'list' } +>['credentials'][number]; + export function createDesktopRuntimeHostManagement(input: { readonly ipcMain: Pick; readonly profiles: Pick< DesktopRuntimeHostProfileService, | 'resolveManagedService' + | 'resolveManagedAccess' + | 'rotateManagedCredential' | 'markManagedServiceUninstalling' | 'clearManagedServiceBinding' >; readonly runServiceManagement: ( input: DesktopRuntimeHostSshManagementInput, ) => Promise; + readonly runAccessManagement: ( + input: DesktopRuntimeHostSshAccessInput, + ) => Promise; readonly cleanupManagedDeployment: ( input: DesktopRuntimeHostSshCleanupInput, ) => Promise; }): { close(): void } { - const resolveManagedService = async (value: unknown) => { + const requireProfileId = (value: unknown): string => { if (typeof value !== 'string' || value.length === 0 || value.length > 128) { throw new Error('Runtime Host profile ID is invalid'); } - const managed = await input.profiles.resolveManagedService(value); + return value; + }; + const resolveManagedService = async (value: unknown) => { + const managed = await input.profiles.resolveManagedService(requireProfileId(value)); if (!managed) throw new Error('This Runtime Host profile is not bound to a managed service'); return managed; }; @@ -78,7 +98,16 @@ export function createDesktopRuntimeHostManagement(input: { : {}), }; if (managementAction !== 'uninstall') { - return input.runServiceManagement(managementInput); + const response = await input.runServiceManagement(managementInput); + return response.kind === 'result' + ? { + ...response, + accessManagementAvailable: + response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false, + } + : response; } let pending = managed; @@ -102,17 +131,189 @@ export function createDesktopRuntimeHostManagement(input: { return { kind: 'uninstalled', retainedStateRoot: service.rootPath }; }; - const channel = 'runtime-host-management:run'; - input.ipcMain.handle(channel, (_event, profileId: unknown, action: unknown) => + const resolveAccess = async (value: unknown) => { + const profileId = requireProfileId(value); + const managed = await input.profiles.resolveManagedAccess(profileId); + if (!managed) { + throw new Error('This Runtime Host profile does not have managed credential access'); + } + if (managed.state === 'uninstalling') { + throw new Error('Finish uninstalling this Runtime Host service before managing access'); + } + if (managed.profile.transport.kind !== 'ssh') { + throw new Error('This Runtime Host profile does not have an SSH management channel'); + } + return { + profileId, + managed, + canRotate: managed.enabled, + currentCredentialFingerprint: managed.credentialFingerprint, + target: { + destination: managed.profile.transport.destination, + ...(managed.profile.transport.sshPort === undefined + ? {} + : { sshPort: managed.profile.transport.sshPort }), + operatorPath: managed.service.operatorPath, + rootPath: managed.service.rootPath, + expectedRootId: managed.profile.rootId, + }, + }; + }; + + const accessSnapshot = ( + credentials: Extract< + RuntimeHostAccessManagementFrame, + { kind: 'result'; action: 'list' } + >['credentials'], + currentFingerprint: string, + canRotate: boolean, + ): DesktopRuntimeHostAccessSnapshot => ({ + canRotate, + credentials: credentials.map((credential) => ({ + credentialId: credential.credentialId, + principalKind: credential.principalKind, + principalId: credential.principalId, + status: credential.status, + createdAt: credential.createdAt, + ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}), + isCurrentDesktop: credential.credentialFingerprint === currentFingerprint, + })), + }); + + const listCredentials = async ( + profileId: unknown, + ): Promise => { + const access = await resolveAccess(profileId); + const response = await input.runAccessManagement({ + ...access.target, + action: 'list', + }); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== 'list') { + throw new Error('Remote Runtime Host did not return its access credentials'); + } + return accessSnapshot( + response.credentials, + access.currentCredentialFingerprint, + access.canRotate, + ); + }; + + const rotateCredential = async ( + profileId: unknown, + ): Promise => { + const access = await resolveAccess(profileId); + if (!access.canRotate) { + throw new Error('Enable this Runtime Host before rotating its access credential'); + } + const response = await input.runAccessManagement({ + ...access.target, + action: 'prepare', + currentCredentialFingerprint: access.currentCredentialFingerprint, + }); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== 'prepare') { + throw new Error('Remote Runtime Host did not prepare a replacement credential'); + } + const replacementFingerprint = runtimeHostAccessCredentialFingerprint(response.credential); + const current = response.credentials.find( + (credential) => + credential.credentialFingerprint === access.currentCredentialFingerprint, + ); + const replacement = response.credentials.find( + (credential) => credential.credentialFingerprint === replacementFingerprint, + ); + if ( + !current || + current.status !== 'active' || + current.principalKind !== 'remote_owner' || + !current.canPublishClientCapabilities || + current.canUseHostPaths || + !replacement || + replacement.status !== 'pending' || + !sameCredentialAuthority(current, replacement) + ) { + throw new Error('Remote Runtime Host returned an invalid Desktop credential replacement'); + } + await input.profiles.rotateManagedCredential(access.profileId, response.credential); + const finalized = response.credentials.flatMap((credential) => { + if (credential.credentialId === replacement.credentialId) { + const { expiresAt: _expiresAt, ...active } = credential; + return [{ ...active, status: 'active' as const }]; + } + return credential.status === 'active' && + credential.principalKind === replacement.principalKind && + credential.principalId === replacement.principalId + ? [] + : [credential]; + }); + return accessSnapshot(finalized, replacementFingerprint, true); + }; + + const revokeCredential = async ( + profileId: unknown, + credentialId: unknown, + ): Promise => { + if (typeof credentialId !== 'string' || credentialId.length === 0 || credentialId.length > 128) { + throw new Error('Runtime Host access credential ID is invalid'); + } + const access = await resolveAccess(profileId); + const response = await input.runAccessManagement({ + ...access.target, + action: 'revoke', + credentialId, + currentCredentialFingerprint: access.currentCredentialFingerprint, + }); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== 'revoke') { + throw new Error('Remote Runtime Host did not confirm credential revocation'); + } + return accessSnapshot( + response.credentials, + access.currentCredentialFingerprint, + access.canRotate, + ); + }; + + const channels = [ + 'runtime-host-management:run', + 'runtime-host-management:list-credentials', + 'runtime-host-management:rotate-credential', + 'runtime-host-management:revoke-credential', + ] as const; + input.ipcMain.handle(channels[0], (_event, profileId: unknown, action: unknown) => run(profileId, action)); + input.ipcMain.handle(channels[1], (_event, profileId: unknown) => + listCredentials(profileId)); + input.ipcMain.handle(channels[2], (_event, profileId: unknown) => + rotateCredential(profileId)); + input.ipcMain.handle( + channels[3], + (_event, profileId: unknown, credentialId: unknown) => + revokeCredential(profileId, credentialId), + ); return { close() { - input.ipcMain.removeHandler(channel); + for (const channel of channels) input.ipcMain.removeHandler(channel); }, }; } +function sameCredentialAuthority( + current: RuntimeHostAccessCredentialMetadata, + replacement: RuntimeHostAccessCredentialMetadata, +): boolean { + return ( + current.principalKind === replacement.principalKind && + current.principalId === replacement.principalId && + current.canPublishClientCapabilities === replacement.canPublishClientCapabilities && + current.canUseHostPaths === replacement.canUseHostPaths && + current.operationGrants.length === replacement.operationGrants.length && + current.operationGrants.every((grant) => replacement.operationGrants.includes(grant)) + ); +} + function assertUninstalled( frame: Extract, ): void { diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 40e26cf6dc..d98edf0991 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -14,6 +14,7 @@ import { type ResolvedRuntimeHostProfile, type RuntimeHostProfileCatalog, } from "@maka/runtime-host/client"; +import { runtimeHostAccessCredentialFingerprint } from "@maka/runtime-host/operator"; import type { CredentialStore } from "@maka/storage/credential-store"; import { withFileUpdateLock } from "@maka/storage/file-update-lock"; import type { @@ -75,10 +76,20 @@ export interface DesktopRuntimeHostProfileService { resolveManagedService( profileId: string, ): Promise; + resolveManagedAccess( + profileId: string, + ): Promise< + | (DesktopRuntimeHostManagedServiceBinding & { + readonly credentialFingerprint: string; + readonly enabled: boolean; + }) + | undefined + >; clearManagedServiceBinding(expected: DesktopRuntimeHostManagedServiceBinding): Promise; markManagedServiceUninstalling( expected: DesktopRuntimeHostManagedServiceBinding, ): Promise; + rotateManagedCredential(profileId: string, credential: string): Promise; startEnabledProfiles(): Promise; resolvePairingRecovery(): Promise; setEnabled(profileId: string, enabled: boolean): Promise; @@ -579,6 +590,39 @@ export function createDesktopRuntimeHostProfileService(input: { } }); }, + rotateManagedCredential(profileId, credential) { + return mutateProfiles(async () => { + if (!preferences.enabledRemoteProfileIds.includes(profileId)) { + throw new Error('Enable this Runtime Host before rotating its access credential'); + } + const previous = await catalog.resolve(profileId); + if (previous.profile.kind !== 'remote') { + throw new Error('Only a remote Runtime Host credential can be rotated'); + } + const target = { profile: previous.profile, credential } as const; + const intent = createDesktopRuntimeHostPairingIntent({ + target, + previous, + wasEnabled: true, + }); + await beginPairingIntent(intent); + try { + const rebound = await catalog.rebindIfCurrent( + previous, + previous.profile, + credential, + ); + if (!rebound.rebound) { + throw new Error('Runtime Host profile changed before its credential could be rotated'); + } + await finishPairingIntent(intent); + } catch (failure) { + if (failure instanceof RuntimeHostPairingFinalizationInterruptedError) throw failure; + await rollbackPairingIntent(intent, failure); + throw failure; + } + }); + }, resolveManagedService(profileId) { return mutate(async () => { const profile = (await catalog.read()).profiles.find( @@ -592,6 +636,29 @@ export function createDesktopRuntimeHostProfileService(input: { return binding; }); }, + resolveManagedAccess(profileId) { + return mutate(async () => { + if (pairingReadFailure || pairingIntents.has(profileId)) { + throw new Error( + "Resolve this Runtime Host's unfinished pairing before managing its access", + pairingReadFailure ? { cause: pairingReadFailure } : undefined, + ); + } + const resolved = await catalog.resolve(profileId).catch(() => undefined); + if (!resolved?.credential || resolved.profile.kind !== "remote") return undefined; + const binding = findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + resolved.profile, + ); + return binding + ? { + ...binding, + credentialFingerprint: runtimeHostAccessCredentialFingerprint(resolved.credential), + enabled: preferences.enabledRemoteProfileIds.includes(profileId), + } + : undefined; + }); + }, markManagedServiceUninstalling(expected) { return mutateProfiles(async () => { const current = (await catalog.read()).profiles.find( diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 24c02c71a5..d67c7c8226 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -13,10 +13,15 @@ import { type RuntimeHostSshTunnelInput, } from '@maka/runtime-host/client'; import { + decodeRuntimeHostAccessManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, + RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, + type RuntimeHostAccessManagementFrame, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, type RuntimeHostSetupFrame, @@ -41,6 +46,7 @@ const TERMINAL_REVEAL_DELAY_MS = 500; const TERMINAL_OUTPUT_MAX = 64 * 1024; const SETUP_FRAME_PENDING_MAX = 20 * 1024; const MANAGEMENT_FRAME_PENDING_MAX = 128 * 1024; +const ACCESS_MANAGEMENT_FRAME_PENDING_MAX = 768 * 1024; const SETUP_TIMEOUT_MS = 10 * 60_000; const MANAGEMENT_TIMEOUT_MS = 2 * 60_000; const PROCESS_STOP_GRACE_MS = 2_000; @@ -77,6 +83,26 @@ export interface DesktopRuntimeHostSshCleanupInput { readonly signal?: AbortSignal; } +interface DesktopRuntimeHostSshAccessTarget { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly rootPath: string; + readonly expectedRootId: string; + readonly signal?: AbortSignal; +} + +export type DesktopRuntimeHostSshAccessInput = DesktopRuntimeHostSshAccessTarget & + ( + | { readonly action: 'list' } + | { readonly action: 'prepare'; readonly currentCredentialFingerprint: string } + | { + readonly action: 'revoke'; + readonly credentialId: string; + readonly currentCredentialFingerprint: string; + } + ); + export type DesktopRuntimeHostSetupPackage = | { readonly kind: 'npm'; readonly specifier: string } | { readonly kind: 'development_archive'; readonly path: string }; @@ -104,6 +130,9 @@ export function createDesktopRuntimeHostSshTerminal(input: { runServiceManagement( input: DesktopRuntimeHostSshManagementInput, ): Promise; + runAccessManagement( + input: DesktopRuntimeHostSshAccessInput, + ): Promise; cleanupManagedDeployment(input: DesktopRuntimeHostSshCleanupInput): Promise; close(): Promise; } { @@ -303,6 +332,75 @@ export function createDesktopRuntimeHostSshTerminal(input: { await terminateActiveTerminal(terminal, input.processStopGraceMs); }); + const runFramedManagement = async (options: { + readonly destination: string; + readonly sshPort?: number; + readonly signal?: AbortSignal; + readonly remoteCommand: string; + readonly prefix: string; + readonly pendingMaxBytes: number; + readonly decode: (line: string) => Frame | undefined; + readonly action: string; + readonly frameAction: (frame: Frame) => string; + readonly label: string; + }): Promise => { + if (closed) throw new Error('Runtime Host SSH terminal is closed'); + options.signal?.throwIfAborted(); + const destination = normalizeRuntimeHostSshDestination(options.destination); + const sshPort = options.sshPort === undefined ? undefined : requireSetupPort(options.sshPort); + let frame: Frame | undefined; + let failure: Error | undefined; + let activeTerminal: ActiveTerminal | undefined; + const filter = createFramedOutputFilter({ + prefix: options.prefix, + pendingMaxBytes: options.pendingMaxBytes, + decode: options.decode, + label: options.label, + onFrame: (next) => { + const action = options.frameAction(next); + if (action !== options.action) { + failure = new Error(`${options.label} returned ${action} for ${options.action}`); + return; + } + if (frame) { + failure = new Error(`${options.label} returned multiple results`); + return; + } + frame = next; + if (activeTerminal) completePresentation(activeTerminal); + }, + onError: (error) => { + failure = error; + }, + }); + const { process, terminal } = startTerminalProcess( + 'ssh', + sshRemoteCommandArgs(destination, sshPort, options.remoteCommand), + filter.push, + true, + ); + activeTerminal = terminal; + if (frame) completePresentation(terminal); + const result = await waitForTerminalProcess(process, { + signal: options.signal, + timeoutMs: MANAGEMENT_TIMEOUT_MS, + timeoutMessage: `${options.label} timed out`, + stopGraceMs: input.processStopGraceMs, + onAbort: () => dismissPresentation(terminal), + }); + filter.finish(); + if (failure) throw failure; + if (!frame) { + throw new Error( + result.code === 0 + ? `${options.label} ended without a result` + : `${options.label} exited with code ${String(result.code)}`, + ); + } + completePresentation(terminal); + return frame; + }; + return { openSshTunnel: async (tunnelInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); @@ -388,72 +486,28 @@ export function createDesktopRuntimeHostSshTerminal(input: { cancellation.close(); } }, - runServiceManagement: async (managementInput) => { - if (closed) throw new Error('Runtime Host SSH terminal is closed'); - managementInput.signal?.throwIfAborted(); - const destination = normalizeRuntimeHostSshDestination(managementInput.destination); - const sshPort = managementInput.sshPort === undefined - ? undefined - : requireSetupPort(managementInput.sshPort); - let frame: RuntimeHostServiceManagementFrame | undefined; - let frameFailure: Error | undefined; - let managementTerminal: ActiveTerminal | undefined; - const filter = createFramedOutputFilter({ + runServiceManagement: (managementInput) => + runFramedManagement({ + ...managementInput, + remoteCommand: runtimeHostServiceManagementRemoteCommand(managementInput), prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, pendingMaxBytes: MANAGEMENT_FRAME_PENDING_MAX, decode: decodeRuntimeHostServiceManagementFrame, + action: managementInput.action, + frameAction: (frame) => frame.action, label: 'Remote Runtime Host service management', - onFrame: (next) => { - if (next.action !== managementInput.action) { - frameFailure = new Error( - `Remote Runtime Host service management returned ${next.action} for ${managementInput.action}`, - ); - return; - } - if (frame) { - frameFailure = new Error( - 'Remote Runtime Host service management returned multiple results', - ); - return; - } - frame = next; - if (managementTerminal) completePresentation(managementTerminal); - }, - onError: (error) => { - frameFailure = error; - }, - }); - const { process, terminal } = startTerminalProcess( - 'ssh', - sshRemoteCommandArgs( - destination, - sshPort, - runtimeHostServiceManagementRemoteCommand(managementInput), - ), - filter.push, - true, - ); - managementTerminal = terminal; - if (frame) completePresentation(terminal); - const result = await waitForTerminalProcess(process, { - signal: managementInput.signal, - timeoutMs: MANAGEMENT_TIMEOUT_MS, - timeoutMessage: 'Remote Runtime Host service management timed out', - stopGraceMs: input.processStopGraceMs, - onAbort: () => dismissPresentation(terminal), - }); - filter.finish(); - if (frameFailure) throw frameFailure; - if (!frame) { - throw new Error( - result.code === 0 - ? 'Remote Runtime Host service management ended without a result' - : `Remote Runtime Host service management exited with code ${String(result.code)}`, - ); - } - completePresentation(terminal); - return frame; - }, + }), + runAccessManagement: (accessInput) => + runFramedManagement({ + ...accessInput, + remoteCommand: runtimeHostAccessManagementRemoteCommand(accessInput), + prefix: RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: ACCESS_MANAGEMENT_FRAME_PENDING_MAX, + decode: decodeRuntimeHostAccessManagementFrame, + action: accessInput.action, + frameAction: (frame) => frame.action, + label: 'Remote Runtime Host access management', + }), cleanupManagedDeployment: async (cleanupInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); cleanupInput.signal?.throwIfAborted(); @@ -772,6 +826,34 @@ function runtimeHostServiceManagementRemoteCommand( ...(input.retainManagedDeployment ? ['--retain-managed-deployment'] : []), ...managedServiceTargetArgs(input.expectedTarget), ].map(quotePosix).join(' '); + const invocation = + `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + + `${quotePosix(RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)} exec ${command}`; + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; +} + +function runtimeHostAccessManagementRemoteCommand( + input: DesktopRuntimeHostSshAccessInput, +): string { + const actionArgs = input.action === 'prepare' + ? ['--current-fingerprint', input.currentCredentialFingerprint] + : input.action === 'revoke' + ? [ + '--credential', input.credentialId, + '--current-fingerprint', input.currentCredentialFingerprint, + ] + : []; + const command = [ + input.operatorPath, + 'access', + input.action, + '--framed', + '--root', + input.rootPath, + '--expected-root', + input.expectedRootId, + ...actionArgs, + ].map(quotePosix).join(' '); return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 87a072a13d..022d9a876c 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -374,15 +374,33 @@ export type DesktopRuntimeHostManagementAction = export type DesktopRuntimeHostManagementResult = Extract< RuntimeHostServiceManagementFrame, { kind: 'result' } ->; +> & { + readonly accessManagementAvailable: boolean; +}; export type DesktopRuntimeHostManagementResponse = - | RuntimeHostServiceManagementFrame + | DesktopRuntimeHostManagementResult + | Extract | { readonly kind: 'uninstalled'; readonly retainedStateRoot: string; }; +export interface DesktopRuntimeHostAccessCredential { + readonly credentialId: string; + readonly principalKind: 'remote_owner' | 'capability_provider'; + readonly principalId: string; + readonly status: 'active' | 'pending'; + readonly createdAt: string; + readonly expiresAt?: string; + readonly isCurrentDesktop: boolean; +} + +export interface DesktopRuntimeHostAccessSnapshot { + readonly canRotate: boolean; + readonly credentials: readonly DesktopRuntimeHostAccessCredential[]; +} + export interface DesktopProjectCapabilities { readonly chooseClientDirectory: boolean; readonly chooseHostDirectory: boolean; @@ -485,6 +503,12 @@ export interface MakaBridge { profileId: string, action: DesktopRuntimeHostManagementAction, ): Promise; + listCredentials(profileId: string): Promise; + rotateCredential(profileId: string): Promise; + revokeCredential( + profileId: string, + credentialId: string, + ): Promise; }; newTasks: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d7455c6c51..54727ed949 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -26,6 +26,7 @@ import type { DesktopRuntimeHostOnboardingSnapshot, DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResponse, + DesktopRuntimeHostAccessSnapshot, DesktopNewTaskCatalog, DesktopNewTaskHost, DesktopNewTaskHostRef, @@ -1102,6 +1103,22 @@ const makaBridge = { ): Promise { return ipcRenderer.invoke('runtime-host-management:run', profileId, action); }, + listCredentials(profileId: string): Promise { + return ipcRenderer.invoke('runtime-host-management:list-credentials', profileId); + }, + rotateCredential(profileId: string): Promise { + return ipcRenderer.invoke('runtime-host-management:rotate-credential', profileId); + }, + revokeCredential( + profileId: string, + credentialId: string, + ): Promise { + return ipcRenderer.invoke( + 'runtime-host-management:revoke-credential', + profileId, + credentialId, + ); + }, }, newTasks: { getCatalog(): Promise { diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 0d8c180dc4..6d4619cf46 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -80,6 +80,25 @@ export type SettingsProjectsCopy = { uninstallConfirm: string; uninstallRetained(path: string): string; managementActionFailed: string; + manageAccess: string; + accessTitle: string; + noAccessCredentials: string; + currentDesktop: string; + accessKind: { + owner: string; + capabilityProvider: string; + }; + accessPending: string; + accessCreated(date: string): string; + rotateCredential: string; + rotateCredentialConfirmTitle: string; + rotateCredentialConfirmBody: string; + rotateCredentialConfirm: string; + enableBeforeRotate: string; + revokeCredential: string; + revokeCredentialConfirm(name: string): string; + accessActionFailed: string; + back: string; remove: string; empty: string; loadFailed: string; @@ -223,6 +242,25 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { uninstallConfirm: '卸载服务', uninstallRetained: (path: string) => `服务已卸载,数据保留在 ${path}`, managementActionFailed: '无法管理 Runtime Host 服务', + manageAccess: '管理访问权限', + accessTitle: '访问权限', + noAccessCredentials: '没有访问凭据', + currentDesktop: '当前 Desktop', + accessKind: { + owner: '客户端访问', + capabilityProvider: 'Capability Provider', + }, + accessPending: '等待确认', + accessCreated: (date: string) => `创建于 ${date}`, + rotateCredential: '轮换凭据', + rotateCredentialConfirmTitle: '轮换当前 Desktop 的凭据?', + rotateCredentialConfirmBody: '轮换会重新连接这个 Runtime Host,并可能中断正在进行的工作。请先完成或暂停活跃任务。', + rotateCredentialConfirm: '继续轮换', + enableBeforeRotate: '请先启用这个 Runtime Host,再轮换当前 Desktop 的凭据。', + revokeCredential: '撤销', + revokeCredentialConfirm: (name: string) => `撤销 ${name} 的访问权限?`, + accessActionFailed: '无法管理访问权限', + back: '返回', remove: '移除', empty: '还没有远程 Host', loadFailed: '无法读取 Runtime Host profiles', @@ -364,6 +402,25 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { uninstallConfirm: 'Uninstall service', uninstallRetained: (path: string) => `Service uninstalled. Data was retained at ${path}`, managementActionFailed: 'Unable to manage the Runtime Host service', + manageAccess: 'Manage access', + accessTitle: 'Access', + noAccessCredentials: 'No active access credentials', + currentDesktop: 'This Desktop', + accessKind: { + owner: 'Client access', + capabilityProvider: 'Capability provider', + }, + accessPending: 'Pending confirmation', + accessCreated: (date: string) => `Created ${date}`, + rotateCredential: 'Rotate credential', + rotateCredentialConfirmTitle: 'Rotate this Desktop credential?', + rotateCredentialConfirmBody: 'Rotation reconnects this Runtime Host and may interrupt active work. Finish or pause active tasks before continuing.', + rotateCredentialConfirm: 'Continue rotation', + enableBeforeRotate: 'Enable this Runtime Host before rotating this Desktop credential.', + revokeCredential: 'Revoke', + revokeCredentialConfirm: (name: string) => `Revoke access for ${name}?`, + accessActionFailed: 'Unable to manage access', + back: 'Back', remove: 'Remove', empty: 'No remote Hosts yet', loadFailed: 'Could not load Runtime Host profiles', diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx index 19d8718aef..63215993f6 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -2,15 +2,26 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Text } from '@astryxdesign/core/Text'; -import { Banner, Button, Spinner, useToast, useUiLocale } from '@maka/ui'; +import { Badge, Banner, Button, Spinner, useToast, useUiLocale } from '@maka/ui'; +import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale'; import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResult, + DesktopRuntimeHostAccessCredential, + DesktopRuntimeHostAccessSnapshot, } from '../../preload/bridge-contract.js'; import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js'; import { settingsActionErrorMessage } from './settings-error-copy.js'; +type RuntimeHostManagementConfirmation = + | { readonly kind: 'uninstall' } + | { readonly kind: 'rotate' } + | { + readonly kind: 'revoke'; + readonly credential: DesktopRuntimeHostAccessCredential; + }; + export function RuntimeHostManagementDialog(props: { readonly profile: RemoteRuntimeHostProfile | undefined; readonly onClose: () => void; @@ -22,7 +33,8 @@ export function RuntimeHostManagementDialog(props: { const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [uninstalledRoot, setUninstalledRoot] = useState(); - const [confirmingUninstall, setConfirmingUninstall] = useState(false); + const [access, setAccess] = useState(); + const [confirmation, setConfirmation] = useState(); const logsRef = useRef(null); const profile = props.profile; @@ -32,7 +44,8 @@ export function RuntimeHostManagementDialog(props: { setResult(undefined); setError(undefined); setUninstalledRoot(undefined); - setConfirmingUninstall(false); + setAccess(undefined); + setConfirmation(undefined); setLoading(true); void window.maka.runtimeHostManagement.run(profile.id, 'status').then( (response) => { @@ -84,6 +97,60 @@ export function RuntimeHostManagementDialog(props: { } } + async function loadAccess(): Promise { + if (!profile) return; + setLoading(true); + setError(undefined); + try { + setAccess(await window.maka.runtimeHostManagement.listCredentials(profile.id)); + } catch (failure) { + const message = settingsActionErrorMessage(failure, locale); + setError(message); + toast.error(copy.accessActionFailed, message); + } finally { + setLoading(false); + } + } + + async function rotateCredential(): Promise { + if (!profile) return; + setLoading(true); + setError(undefined); + try { + setAccess(await window.maka.runtimeHostManagement.rotateCredential(profile.id)); + } catch (failure) { + const message = settingsActionErrorMessage(failure, locale); + setError(message); + toast.error(copy.accessActionFailed, message); + } finally { + setLoading(false); + } + } + + async function revokeCredential(): Promise { + const revokeTarget = confirmation?.kind === 'revoke' + ? confirmation.credential + : undefined; + if (!profile || !revokeTarget) return; + setLoading(true); + setError(undefined); + try { + setAccess( + await window.maka.runtimeHostManagement.revokeCredential( + profile.id, + revokeTarget.credentialId, + ), + ); + setConfirmation(undefined); + } catch (failure) { + const message = settingsActionErrorMessage(failure, locale); + setError(message); + toast.error(copy.accessActionFailed, message); + } finally { + setLoading(false); + } + } + const service = result?.service; const uninstalled = uninstalledRoot !== undefined; const serviceInstalled = service !== undefined && service.state !== 'not_installed'; @@ -117,20 +184,27 @@ export function RuntimeHostManagementDialog(props: { ) : null} {error ? : null} - {confirmingUninstall ? ( + {confirmation?.kind === 'uninstall' ? ( ) : null} + {confirmation?.kind === 'rotate' ? ( + + ) : null} {uninstalledRoot ? ( ) : null} - {service ? ( + {!access && service ? ( <>
@@ -170,19 +244,107 @@ export function RuntimeHostManagementDialog(props: { ) : null} ) : null} + {access ? ( +
+ {copy.accessTitle} + {!access.canRotate ? ( + + {copy.enableBeforeRotate} + + ) : null} + {confirmation?.kind === 'revoke' ? ( + + ) : null} + {access.credentials.length === 0 ? ( + + {copy.noAccessCredentials} + + ) : ( +
    + {access.credentials.map((credential) => ( +
  • +
    +
    + {credential.principalId} + + {credential.principalKind === 'capability_provider' + ? copy.accessKind.capabilityProvider + : copy.accessKind.owner} + +
    +
    + {credential.isCurrentDesktop ? ( + + ) : null} + {credential.status === 'pending' ? ( + + ) : null} +
    +
    +
    + {copy.accessCreated(formatCredentialDate(credential.createdAt, locale))} + {credential.isCurrentDesktop ? ( +
    +
  • + ))} +
+ )} +
+ ) : null} )} footer={(
- {confirmingUninstall ? ( + {confirmation?.kind === 'revoke' ? ( + <> +
); } + +function formatCredentialDate(value: string, locale: UiLocale): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + return new Intl.DateTimeFormat(uiLocaleToIntlLocale(locale), { + dateStyle: 'medium', + }).format(timestamp); +} diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css index a5a6a1523b..32571cfb9b 100644 --- a/apps/desktop/src/renderer/styles/settings/runtime-host.css +++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css @@ -127,6 +127,58 @@ font: var(--maka-text-code); } +.settingsRuntimeHostAccess { + display: grid; + gap: var(--space-3); +} + +.settingsRuntimeHostAccessList { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.settingsRuntimeHostAccessList li { + display: grid; + gap: var(--space-3); + padding: var(--space-3); + border: var(--border-width-hairline) solid var(--border-soft); + border-radius: var(--radius-container); + background: var(--background-secondary); +} + +.settingsRuntimeHostAccessIdentity, +.settingsRuntimeHostAccessMeta, +.settingsRuntimeHostAccessBadges { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.settingsRuntimeHostAccessIdentity, +.settingsRuntimeHostAccessMeta { + justify-content: space-between; +} + +.settingsRuntimeHostAccessIdentity > div:first-child { + display: grid; + min-width: 0; +} + +.settingsRuntimeHostAccessIdentity strong { + overflow-wrap: anywhere; + color: var(--foreground-primary); + font: var(--maka-text-body); +} + +.settingsRuntimeHostAccessIdentity span, +.settingsRuntimeHostAccessMeta > span { + color: var(--foreground-secondary); + font: var(--maka-text-supporting); +} + .settingsRuntimeHostManagementLogs { max-height: 240px; overflow: auto; diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index ed030af035..34cd181fd0 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -88,7 +88,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/settings/provider-oauth-section.tsx` | settings-module | Banner, Button, HStack, Text, VStack | aligned — uses Astryx (Banner, Button, HStack, Text, VStack) | aligned | | `apps/desktop/src/renderer/settings/providers-panel.tsx` | settings-module | Badge, Banner, Button, EmptyState, HStack, Heading, List, ListItem, Text, VStack | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, Heading, List, ListItem) | aligned | | `apps/desktop/src/renderer/settings/request-customization-editor.tsx` | settings-module | Button, HStack, IconButton, Text, VStack | aligned — uses Astryx (Button, HStack, IconButton, Text, VStack) | aligned | -| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text) | aligned | +| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner) | aligned | | `apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text) | aligned | | `apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx` | settings-module | Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem, Switch | aligned — uses Astryx (Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem) | aligned | | `apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index ad3e51d432..44f02928ee 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -127,6 +127,50 @@ describe('Runtime Host operator commands', () => { ]).kind, 'error', ); + assert.deepEqual( + parseRuntimeHostCommand([ + 'access', + 'prepare', + '--current-fingerprint', + 'a'.repeat(32), + '--root', + '/srv/maka', + '--expected-root', + 'a'.repeat(64), + '--framed', + ]), + { + kind: 'runtime-host-access-prepare', + rootPath: '/srv/maka', + expectedRootId: 'a'.repeat(64), + currentCredentialFingerprint: 'a'.repeat(32), + }, + ); + assert.equal( + parseRuntimeHostCommand(['access', 'prepare', '--current-fingerprint', 'a'.repeat(32)]).kind, + 'error', + ); + assert.deepEqual(parseRuntimeHostCommand(['access', 'list', '--framed']), { + kind: 'runtime-host-access-list', + framed: true, + }); + assert.deepEqual( + parseRuntimeHostCommand([ + 'access', + 'revoke', + '--credential', + 'credential-1', + '--current-fingerprint', + 'a'.repeat(32), + '--framed', + ]), + { + kind: 'runtime-host-access-revoke', + credentialId: 'credential-1', + currentCredentialFingerprint: 'a'.repeat(32), + framed: true, + }, + ); assert.deepEqual( Object.keys(HOST_OPERATION_SPECS) .filter( diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index cff9760f6f..d74c72591e 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -13,7 +13,11 @@ import { import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { describe, it } from 'node:test'; -import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; +import { + decodeRuntimeHostServiceManagementFrame, + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, +} from '@maka/runtime-host/operator'; import { resolveStorageRoot } from '@maka/storage/root-authority'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; import { @@ -27,6 +31,7 @@ import { resolveRuntimeHostManagedServiceId, RuntimeHostServiceManagerError, type RuntimeHostManagedServiceConfig, + type RuntimeHostManagedServiceResult, type RuntimeHostServiceBackend, } from '../runtime-host-service-manager.js'; import { @@ -489,52 +494,73 @@ describe('managed Runtime Host service', () => { }); }); - it('projects a framed service summary without launch configuration', async () => { - let output = ''; - const exitCode = await runManagedRuntimeHostServiceCli( - { - action: 'status', - json: false, - framed: true, - clientDataRoot: '/config/Maka', - defaultRootPath: '/config/Maka/workspaces/default', - nodePath: '/usr/bin/node', - cliPath: '/opt/maka/cli.js', + it('projects requested operator capabilities without launch configuration', async (t) => { + const previousCapabilityRequest = process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]; + delete process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]; + t.after(() => { + if (previousCapabilityRequest === undefined) { + delete process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]; + } else { + process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = previousCapabilityRequest; + } + }); + const options = { + action: 'status' as const, + json: false, + framed: true, + clientDataRoot: '/config/Maka', + defaultRootPath: '/config/Maka/workspaces/default', + nodePath: '/usr/bin/node', + cliPath: '/opt/maka/cli.js', + }; + const manage = async (): Promise => ({ + schemaVersion: 1 as const, + action: 'status' as const, + service: { + manager: 'systemd_user' as const, + installed: true, + enabled: true, + active: true, + state: 'running' as const, + pid: 42, + lastExitCode: 0, + installedVersion: '1.2.3', + config: { + schemaVersion: 1 as const, + rootPath: '/srv/maka', + projectDirectoryRoots: [{ label: 'Home', path: '/home/ada' }], + websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' }, + launch: { nodePath: '/secret/node', cliPath: '/secret/cli.js' }, + }, }, - { - manage: async () => ({ - schemaVersion: 1, - action: 'status', - service: { - manager: 'systemd_user', - installed: true, - enabled: true, - active: true, - state: 'running', - pid: 42, - lastExitCode: 0, - installedVersion: '1.2.3', - config: { - schemaVersion: 1, - rootPath: '/srv/maka', - projectDirectoryRoots: [{ label: 'Home', path: '/home/ada' }], - websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' }, - launch: { nodePath: '/secret/node', cliPath: '/secret/cli.js' }, - }, - }, - }), + }); + const run = async () => { + let output = ''; + const exitCode = await runManagedRuntimeHostServiceCli(options, { + manage, createBackend: createUnusedBackend, writeOutput: (value) => { output += value; }, - }, + }); + assert.equal(exitCode, 0); + return output; + }; + + const legacyFrame = decodeRuntimeHostServiceManagementFrame(await run()); + assert.equal(legacyFrame?.kind, 'result'); + assert.equal( + legacyFrame?.kind === 'result' ? legacyFrame.operatorCapabilities : undefined, + undefined, ); - assert.equal(exitCode, 0); - const frame = decodeRuntimeHostServiceManagementFrame(output); + process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY; + const frame = decodeRuntimeHostServiceManagementFrame(await run()); assert.equal(frame?.kind, 'result'); if (frame?.kind !== 'result') assert.fail('Expected a service result frame'); assert.equal(frame.service.installedVersion, '1.2.3'); + assert.deepEqual(frame.operatorCapabilities, ['access-management-v1']); assert.equal(frame.service.stateRoot, '/srv/maka'); assert.doesNotMatch(JSON.stringify(frame), /secret/u); }); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 7afa7a984b..f97efe4c63 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -106,6 +106,7 @@ function helpText(cliCommand: string): string { ` ${cliCommand} runtime-host service status|start|stop|restart|logs|uninstall [--json]`, ` ${cliCommand} runtime-host access issue --principal --grant `, ` ${cliCommand} runtime-host access issue --principal --preset `, + ` ${cliCommand} runtime-host access list`, ` ${cliCommand} runtime-host access issue --kind capability-provider --principal `, ` ${cliCommand} runtime-host access revoke --credential `, ` ${cliCommand} runtime-host project list [--root ]`, @@ -266,6 +267,7 @@ export async function runMakaCli( const { runRuntimeHostAccessIssueCli } = await import('./runtime-host-access-command.js'); return runRuntimeHostAccessIssueCli({ rootPath: command.rootPath ?? dataRoots.workspaceRoot, + ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}), principalKind: command.principalKind, principalId: command.principalId, operationGrants: command.operationGrants, @@ -274,13 +276,38 @@ export async function runMakaCli( ...(command.preset ? { preset: command.preset } : {}), }); } - case 'runtime-host-access-revoke': { - const { runRuntimeHostAccessRevokeCli } = await import('./runtime-host-access-command.js'); - return runRuntimeHostAccessRevokeCli({ + case 'runtime-host-access-prepare': { + const { runRuntimeHostAccessPrepareCli } = await import('./runtime-host-access-command.js'); + return runRuntimeHostAccessPrepareCli({ rootPath: command.rootPath ?? dataRoots.workspaceRoot, - credentialId: command.credentialId, + ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}), + currentCredentialFingerprint: command.currentCredentialFingerprint, }); } + case 'runtime-host-access-list': { + const { runRuntimeHostAccessListCli } = await import('./runtime-host-access-command.js'); + return runRuntimeHostAccessListCli( + { + rootPath: command.rootPath ?? dataRoots.workspaceRoot, + ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}), + }, + command.framed, + ); + } + case 'runtime-host-access-revoke': { + const { runRuntimeHostAccessRevokeCli } = await import('./runtime-host-access-command.js'); + return runRuntimeHostAccessRevokeCli( + { + rootPath: command.rootPath ?? dataRoots.workspaceRoot, + ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}), + credentialId: command.credentialId, + ...(command.currentCredentialFingerprint + ? { currentCredentialFingerprint: command.currentCredentialFingerprint } + : {}), + }, + command.framed, + ); + } case 'runtime-host-project-list': case 'runtime-host-project-add': { const { runRuntimeHostProjectCli } = await import('./runtime-host-project-command.js'); diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts index 768547dbfd..ae7b0d0207 100644 --- a/packages/cli/src/runtime-host-access-command.ts +++ b/packages/cli/src/runtime-host-access-command.ts @@ -6,9 +6,19 @@ import { isOperationKey, REMOTE_OWNER_OPERATION_GRANTS, RUNTIME_HOST_PROTOCOL_VERSION, + type AccessCredentialRevokeInput, type AccessCredentialPrincipalKind, type OperationKey, } from '@maka/runtime-host/protocol'; +import { + readRuntimeHostAccessCredentialMetadata, + type RuntimeHostAccessCredentialMetadata, +} from '@maka/runtime-host/server'; +import { + encodeRuntimeHostAccessManagementFrame, + RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES, + type RuntimeHostAccessManagementAction, +} from '@maka/runtime-host/operator'; const PROTOCOL = { min: RUNTIME_HOST_PROTOCOL_VERSION, @@ -17,6 +27,7 @@ const PROTOCOL = { export interface RuntimeHostAccessIssueOptions { readonly rootPath: string; + readonly expectedRootId?: string; readonly principalKind: AccessCredentialPrincipalKind; readonly principalId: string; readonly operationGrants: readonly string[]; @@ -39,9 +50,18 @@ const CLIENT_CAPABILITY_PUBLICATION_OPERATIONS = new Set([ 'client.capability.unregister', ]); -export interface RuntimeHostAccessRevokeOptions { +export interface RuntimeHostAccessListOptions { readonly rootPath: string; + readonly expectedRootId?: string; +} + +export interface RuntimeHostAccessRevokeOptions extends RuntimeHostAccessListOptions { readonly credentialId: string; + readonly currentCredentialFingerprint?: string; +} + +export interface RuntimeHostAccessPrepareOptions extends RuntimeHostAccessListOptions { + readonly currentCredentialFingerprint: string; } export interface IssuedRuntimeHostAccessCredential { @@ -64,6 +84,65 @@ export async function runRuntimeHostAccessIssueCli( return 0; } +export async function runRuntimeHostAccessListCli( + options: RuntimeHostAccessListOptions, + framed = false, +): Promise { + try { + const result = await listRuntimeHostAccessCredentials(options); + process.stdout.write( + framed + ? encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'list', + credentials: mutableCredentialMetadata(result.credentials), + }) + : `${JSON.stringify(result, null, 2)}\n`, + ); + return 0; + } catch (error) { + if (!framed) throw error; + writeAccessManagementError('list', error); + return 1; + } +} + +export async function runRuntimeHostAccessPrepareCli( + options: RuntimeHostAccessPrepareOptions, +): Promise { + try { + const before = await listRuntimeHostAccessCredentials(options); + const current = requireCurrentDesktopCredential( + before.credentials, + options.currentCredentialFingerprint, + ); + const prepared = await prepareRuntimeHostAccessCredentialReplacement( + options, + current.credentialId, + ); + const listed = await listRuntimeHostAccessCredentials(options); + if ( + !listed.credentials.some((credential) => credential.credentialId === prepared.credentialId) + ) { + throw new Error('Prepared Runtime Host credential metadata is unavailable'); + } + process.stdout.write( + encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'prepare', + credential: prepared.credential, + credentials: mutableCredentialMetadata(listed.credentials), + }), + ); + return 0; + } catch (error) { + writeAccessManagementError('prepare', error); + return 1; + } +} + export function issueRuntimeHostAccessCredential( options: RuntimeHostAccessIssueOptions, ): Promise { @@ -76,7 +155,7 @@ export function prepareRuntimeHostAccessCredential( return mutateRuntimeHostAccessCredential(options, 'access.credential.prepare'); } -export async function replaceRuntimeHostAccessCredential( +export function replaceRuntimeHostAccessCredential( options: RuntimeHostAccessIssueOptions, ): Promise { return mutateRuntimeHostAccessCredential(options, 'access.credential.replace'); @@ -84,21 +163,41 @@ export async function replaceRuntimeHostAccessCredential( export type ReplacedRuntimeHostAccessCredential = IssuedRuntimeHostAccessCredential; +async function prepareRuntimeHostAccessCredentialReplacement( + options: RuntimeHostAccessListOptions, + replacementOfCredentialId: string, +): Promise { + const connection = await connectLocalOwner(options.rootPath, options.expectedRootId); + try { + const result = await connection.request('access.credential.prepare', { + replacementOfCredentialId, + }); + const credential = await consumeAccessCredentialDelivery( + options.rootPath, + result.deliveryId, + result.credentialId, + ); + const { deliveryId: _deliveryId, ...metadata } = result; + return { rootId: connection.rootId, credential, ...metadata }; + } finally { + await connection.close(); + } +} + async function mutateRuntimeHostAccessCredential( options: RuntimeHostAccessIssueOptions, operation: 'access.credential.issue' | 'access.credential.prepare' | 'access.credential.replace', ): Promise { const resolved = resolveRuntimeHostAccessIssue(options); - const connection = await connectLocalOwner(options.rootPath); + const connection = await connectLocalOwner(options.rootPath, options.expectedRootId); try { - const credentialInput = { + const result = await connection.request(operation, { principalKind: resolved.principalKind, principalId: options.principalId, operationGrants: resolved.operationGrants, canPublishClientCapabilities: resolved.canPublishClientCapabilities, canUseHostPaths: resolved.canUseHostPaths, - }; - const result = await connection.request(operation, credentialInput); + }); const credential = await consumeAccessCredentialDelivery( options.rootPath, result.deliveryId, @@ -111,6 +210,12 @@ async function mutateRuntimeHostAccessCredential( } } +export async function listRuntimeHostAccessCredentials( + options: RuntimeHostAccessListOptions, +): Promise<{ readonly credentials: readonly RuntimeHostAccessCredentialMetadata[] }> { + return readRuntimeHostAccessCredentialMetadata(options.rootPath, options.expectedRootId); +} + export function resolveRuntimeHostAccessIssue( options: RuntimeHostAccessIssueOptions, ): ResolvedRuntimeHostAccessIssue { @@ -137,24 +242,90 @@ export function resolveRuntimeHostAccessIssue( export async function runRuntimeHostAccessRevokeCli( options: RuntimeHostAccessRevokeOptions, + framed = false, ): Promise { - const result = await revokeRuntimeHostAccessCredential(options); - process.stdout.write(`${JSON.stringify(result)}\n`); - return result.revoked ? 0 : 1; + try { + const before = await listRuntimeHostAccessCredentials(options); + const target = before.credentials.find( + (credential) => credential.credentialId === options.credentialId, + ); + const current = options.currentCredentialFingerprint + ? requireCurrentDesktopCredential(before.credentials, options.currentCredentialFingerprint) + : undefined; + if (target?.credentialFingerprint === options.currentCredentialFingerprint) { + throw new Error('Rotate this Desktop credential instead of revoking it'); + } + const result = await revokeRuntimeHostAccessCredential( + options, + current + ? { + credentialId: options.credentialId, + requiredActiveCredentialId: current.credentialId, + } + : undefined, + ); + const listed = await listRuntimeHostAccessCredentials(options); + process.stdout.write( + framed + ? encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'revoke', + ...result, + credentials: mutableCredentialMetadata(listed.credentials), + }) + : `${JSON.stringify(result)}\n`, + ); + return result.revoked ? 0 : 1; + } catch (error) { + if (!framed) throw error; + writeAccessManagementError('revoke', error); + return 1; + } } -export async function revokeRuntimeHostAccessCredential(options: RuntimeHostAccessRevokeOptions) { - const connection = await connectLocalOwner(options.rootPath); +function requireCurrentDesktopCredential( + credentials: readonly RuntimeHostAccessCredentialMetadata[], + fingerprint: string, +): RuntimeHostAccessCredentialMetadata { + const current = credentials.find( + (credential) => credential.credentialFingerprint === fingerprint, + ); + if ( + !current || + current.status !== 'active' || + current.principalKind !== 'remote_owner' || + !current.canPublishClientCapabilities || + current.canUseHostPaths + ) { + throw new Error('The current Desktop credential is not active on this Runtime Host'); + } + return current; +} + +function mutableCredentialMetadata(credentials: readonly RuntimeHostAccessCredentialMetadata[]) { + return credentials.map((credential) => ({ + ...credential, + operationGrants: [...credential.operationGrants], + })); +} + +export async function revokeRuntimeHostAccessCredential( + options: RuntimeHostAccessRevokeOptions, + guardedInput?: Extract, +) { + const connection = await connectLocalOwner(options.rootPath, options.expectedRootId); try { - return await connection.request('access.credential.revoke', { - credentialId: options.credentialId, - }); + return await connection.request( + 'access.credential.revoke', + guardedInput ?? { credentialId: options.credentialId }, + ); } finally { await connection.close(); } } -async function connectLocalOwner(rootPath: string) { +async function connectLocalOwner(rootPath: string, expectedRootId?: string) { const result = await connectExistingRuntimeHost({ rootPath, protocol: PROTOCOL, @@ -162,9 +333,42 @@ async function connectLocalOwner(rootPath: string) { if (result.kind !== 'connected') { throw new Error(`Runtime Host service is not available (${result.kind})`); } + if (expectedRootId && result.connection.rootId !== expectedRootId) { + await result.connection.close(); + throw new Error('Runtime Host service is bound to a different State Root'); + } return result.connection; } +function writeAccessManagementError( + action: RuntimeHostAccessManagementAction, + error: unknown, +): void { + process.stdout.write( + encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'error', + action, + error: { + code: 'access_management_failed', + message: bounded( + error instanceof Error ? error.message : String(error), + RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES, + ), + }, + }), + ); +} + +function bounded(value: string, maxBytes: number): string { + const fallback = 'Runtime Host access management failed'; + if (value.length === 0) return fallback; + if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value; + let end = Math.min(value.length, maxBytes); + while (end > 0 && Buffer.byteLength(value.slice(0, end), 'utf8') > maxBytes) end -= 1; + return value.slice(0, end) || fallback; +} + function requireOperationGrants(values: readonly string[]): readonly OperationKey[] { const grants = values.flatMap((value) => value.split(',')).filter((value) => value.length > 0); if (grants.length === 0) throw new Error('At least one --grant is required'); diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index f0a8648146..8bb3b21d1a 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -52,6 +52,7 @@ export type RuntimeHostCliCommand = | { kind: 'runtime-host-access-issue'; rootPath?: string; + expectedRootId?: string; principalKind: 'remote_owner' | 'capability_provider'; principalId: string; operationGrants: string[]; @@ -59,7 +60,26 @@ export type RuntimeHostCliCommand = canUseHostPaths: boolean; preset?: 'desktop-client' | 'terminal-client'; } - | { kind: 'runtime-host-access-revoke'; rootPath?: string; credentialId: string } + | { + kind: 'runtime-host-access-prepare'; + rootPath?: string; + expectedRootId?: string; + currentCredentialFingerprint: string; + } + | { + kind: 'runtime-host-access-list'; + rootPath?: string; + expectedRootId?: string; + framed: boolean; + } + | { + kind: 'runtime-host-access-revoke'; + rootPath?: string; + expectedRootId?: string; + credentialId: string; + currentCredentialFingerprint?: string; + framed: boolean; + } | { kind: 'runtime-host-project-list'; rootPath?: string } | { kind: 'runtime-host-project-add'; rootPath?: string; path: string } | { @@ -700,24 +720,32 @@ function parseProjectRoot(value: string): { label: string; path: string } | Runt function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { const action = argv[0]; - if (action !== 'issue' && action !== 'revoke') { + if (action !== 'list' && action !== 'issue' && action !== 'prepare' && action !== 'revoke') { return error( action ? `Unexpected runtime-host access command: ${action}` - : 'runtime-host access requires the issue or revoke command', + : 'runtime-host access requires list, issue, prepare, or revoke', ); } let rootPath: string | undefined; + let expectedRootId: string | undefined; + let framed = false; let principalId: string | undefined; let principalKind: 'remote_owner' | 'capability_provider' = 'remote_owner'; let principalKindSpecified = false; let credentialId: string | undefined; + let currentCredentialFingerprint: string | undefined; const operationGrants: string[] = []; let canPublishClientCapabilities = false; let canUseHostPaths = false; let preset: 'desktop-client' | 'terminal-client' | undefined; for (let index = 1; index < argv.length; index += 1) { const argument = argv[index]; + if (argument === '--framed') { + if (framed) return error('Duplicate --framed'); + framed = true; + continue; + } if (argument === '--publish-client-capabilities') { canPublishClientCapabilities = true; continue; @@ -728,15 +756,18 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { } if ( argument === '--root' || + argument === '--expected-root' || argument === '--kind' || argument === '--preset' || argument === '--principal' || argument === '--grant' || - argument === '--credential' + argument === '--credential' || + argument === '--current-fingerprint' ) { const parsed = optionValue(argv, index, argument); if (typeof parsed !== 'string') return parsed; if (argument === '--root') rootPath = parsed; + if (argument === '--expected-root') expectedRootId = parsed; if (argument === '--kind') { if (parsed !== 'remote-owner' && parsed !== 'capability-provider') { return error('--kind must be remote-owner or capability-provider'); @@ -753,14 +784,65 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { if (argument === '--principal') principalId = parsed; if (argument === '--grant') operationGrants.push(parsed); if (argument === '--credential') credentialId = parsed; + if (argument === '--current-fingerprint') currentCredentialFingerprint = parsed; index += 1; continue; } return error(`Unexpected argument: ${argument ?? ''}`); } + if (expectedRootId && !/^[a-f0-9]{64}$/u.test(expectedRootId)) { + return error('--expected-root must be a Runtime Host root identity'); + } + if (action === 'list') { + if ( + principalId || + principalKindSpecified || + operationGrants.length > 0 || + canPublishClientCapabilities || + canUseHostPaths || + preset || + credentialId || + currentCredentialFingerprint + ) { + return error('Credential mutation options are not valid for access list'); + } + return { + kind: 'runtime-host-access-list', + ...(rootPath ? { rootPath } : {}), + ...(expectedRootId ? { expectedRootId } : {}), + framed, + }; + } + if (action === 'prepare') { + if (!framed) return error('access prepare is reserved for framed operator management'); + if (!currentCredentialFingerprint) return error('--current-fingerprint is required'); + if (!/^[a-f0-9]{32}$/u.test(currentCredentialFingerprint)) { + return error('--current-fingerprint must be a Runtime Host credential fingerprint'); + } + if ( + principalId || + principalKindSpecified || + operationGrants.length > 0 || + canPublishClientCapabilities || + canUseHostPaths || + preset || + credentialId + ) { + return error('Credential issue options are not valid for access prepare'); + } + return { + kind: 'runtime-host-access-prepare', + ...(rootPath ? { rootPath } : {}), + ...(expectedRootId ? { expectedRootId } : {}), + currentCredentialFingerprint, + }; + } if (action === 'issue') { + if (framed) return error('--framed is only valid for access management'); if (!principalId) return error('--principal is required'); - if (credentialId) return error('--credential is only valid for access revoke'); + if (credentialId || currentCredentialFingerprint) { + return error('Credential target options are only valid for access revoke'); + } if ( preset && (principalKindSpecified || @@ -774,6 +856,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { return { kind: 'runtime-host-access-issue', ...(rootPath ? { rootPath } : {}), + ...(expectedRootId ? { expectedRootId } : {}), principalKind: 'remote_owner', principalId, operationGrants, @@ -799,6 +882,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { return { kind: 'runtime-host-access-issue', ...(rootPath ? { rootPath } : {}), + ...(expectedRootId ? { expectedRootId } : {}), principalKind, principalId, operationGrants, @@ -807,6 +891,12 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { }; } if (!credentialId) return error('--credential is required'); + if (framed && !currentCredentialFingerprint) { + return error('--current-fingerprint is required for framed access revoke'); + } + if (currentCredentialFingerprint && !/^[a-f0-9]{32}$/u.test(currentCredentialFingerprint)) { + return error('--current-fingerprint must be a Runtime Host credential fingerprint'); + } if ( principalId || principalKindSpecified || @@ -820,7 +910,10 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { return { kind: 'runtime-host-access-revoke', ...(rootPath ? { rootPath } : {}), + ...(expectedRootId ? { expectedRootId } : {}), credentialId, + ...(currentCredentialFingerprint ? { currentCredentialFingerprint } : {}), + framed, }; } diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index 20f4353c4b..295ca6d1d7 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -278,6 +278,10 @@ async function writeOperatorLauncher( ` rmdir -- ${quotePosix(deploymentRoot)} || exit 1`, ' exit 0', 'fi', + 'if [ "$#" -ge 1 ] && [ "$1" = "access" ]; then', + ' shift', + ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host access "$@"`, + 'fi', `exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service "$@" --client-data-root ${quotePosix(clientDataRoot)}`, '', ].join('\n'); diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index cc3ced0ef9..a2a71f0377 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -2,6 +2,8 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { release } from 'node:os'; import { encodeRuntimeHostServiceManagementFrame, + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, type RuntimeHostServiceManagementFrame, @@ -121,6 +123,10 @@ function successFrame(result: RuntimeHostManagedServiceResult): RuntimeHostServi kind: 'result', action: result.action, service, + ...(process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] === + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY + ? { operatorCapabilities: [RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY] } + : {}), ...(result.retainedStateRoot ? { retainedStateRoot: result.retainedStateRoot } : {}), ...(result.logs !== undefined ? { logs: result.logs } : {}), }; diff --git a/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts b/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts new file mode 100644 index 0000000000..d59e8009fa --- /dev/null +++ b/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { readRuntimeHostAccessCredentialMetadata } from '../server/access-credential-metadata.js'; + +test('credential metadata inspection does not create missing State Roots', async (t) => { + const parent = await mkdtemp(join(tmpdir(), 'maka-access-metadata-')); + t.after(() => rm(parent, { recursive: true, force: true })); + + for (const expectedRootId of [undefined, 'a'.repeat(64)]) { + const root = join(parent, expectedRootId ? 'expected' : 'discovered'); + await assert.rejects(readRuntimeHostAccessCredentialMetadata(root, expectedRootId)); + await assert.rejects(access(root)); + } +}); diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index b96a7a594e..fe055be495 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -577,6 +577,75 @@ test('access credentials persist only as hashes and stay revoked after reload', } }); +test('credential rotation preserves authority and cannot outlive its active source', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-rotation-')); + const authority = await openRuntimeHostAccessAuthority(directory); + try { + const source = await authority.issue({ + principalKind: 'remote_owner', + principalId: 'desktop-client', + operationGrants: ['access.credential.finalize', 'session.catalog.query'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + }); + const replacement = await authority.prepare({ + replacementOfCredentialId: source.credentialId, + }); + assert.deepEqual(replacement.operationGrants, source.operationGrants); + assert.equal(replacement.principalId, source.principalId); + assert.equal(replacement.canPublishClientCapabilities, source.canPublishClientCapabilities); + + await authority.revoke({ credentialId: source.credentialId }); + await assert.rejects(authority.finalize(replacement.credentialId), /no longer active/u); + await assert.rejects( + authority.prepare({ replacementOfCredentialId: source.credentialId }), + /no longer active/u, + ); + } finally { + await authority.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test('guarded credential revocation requires its active credential', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-guarded-revoke-')); + const authority = await openRuntimeHostAccessAuthority(directory); + try { + const desktop = await authority.issue({ + principalKind: 'remote_owner', + principalId: 'desktop-client', + operationGrants: ['access.credential.finalize'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + }); + const target = await authority.issue({ + principalKind: 'remote_owner', + principalId: 'other-client', + operationGrants: ['access.credential.finalize'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + }); + assert.deepEqual( + await authority.revoke({ + credentialId: 'already-absent', + requiredActiveCredentialId: desktop.credentialId, + }), + { credentialId: 'already-absent', revoked: false }, + ); + await authority.revoke({ credentialId: desktop.credentialId }); + await assert.rejects( + authority.revoke({ + credentialId: target.credentialId, + requiredActiveCredentialId: desktop.credentialId, + }), + /required credential is no longer active/u, + ); + } finally { + await authority.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + test('keeps published credential state authoritative when directory sync is uncertain', async () => { const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-unknown-commit-')); let failNextCommit = false; diff --git a/packages/runtime-host/src/access-credential-identity.ts b/packages/runtime-host/src/access-credential-identity.ts new file mode 100644 index 0000000000..8d8e3220c3 --- /dev/null +++ b/packages/runtime-host/src/access-credential-identity.ts @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto'; + +const CREDENTIAL_FINGERPRINT_HEX_LENGTH = 32; + +export function runtimeHostAccessCredentialHash(credential: string): Buffer { + return createHash('sha256').update(credential, 'utf8').digest(); +} + +export function runtimeHostAccessCredentialFingerprintFromHash(hash: string): string { + return hash.slice(0, CREDENTIAL_FINGERPRINT_HEX_LENGTH); +} + +export function runtimeHostAccessCredentialFingerprint(credential: string): string { + return runtimeHostAccessCredentialFingerprintFromHash( + runtimeHostAccessCredentialHash(credential).toString('hex'), + ); +} diff --git a/packages/runtime-host/src/operator/access-management-frame.ts b/packages/runtime-host/src/operator/access-management-frame.ts new file mode 100644 index 0000000000..09283bec8e --- /dev/null +++ b/packages/runtime-host/src/operator/access-management-frame.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +export const RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX = + 'MAKA_RUNTIME_HOST_ACCESS_MANAGEMENT_V1 '; +export const RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_CODE_MAX_BYTES = 128; +export const RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES = 2 * 1024; + +const FRAME_MAX_BYTES = 768 * 1024; +const CREDENTIAL_MAX_BYTES = 8 * 1024; +const ACCESS_ACTIONS = ['list', 'prepare', 'revoke'] as const; + +const boundedString = (maxBytes: number) => + z + .string() + .min(1) + .refine((value) => Buffer.byteLength(value, 'utf8') <= maxBytes); +const CREDENTIAL_METADATA_SCHEMA = z + .object({ + credentialId: boundedString(128), + credentialFingerprint: z.string().regex(/^[a-f0-9]{32}$/u), + principalKind: z.enum(['remote_owner', 'capability_provider']), + principalId: boundedString(128), + status: z.enum(['active', 'pending']), + operationGrants: z.array(boundedString(128)).max(256), + canPublishClientCapabilities: z.boolean(), + canUseHostPaths: z.boolean(), + createdAt: boundedString(64), + expiresAt: boundedString(64).optional(), + }) + .strict(); + +const ACCESS_MANAGEMENT_FRAME_SCHEMA = z.union([ + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('result'), + action: z.literal('list'), + credentials: z.array(CREDENTIAL_METADATA_SCHEMA), + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('result'), + action: z.literal('prepare'), + credential: boundedString(CREDENTIAL_MAX_BYTES), + credentials: z.array(CREDENTIAL_METADATA_SCHEMA), + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('result'), + action: z.literal('revoke'), + credentialId: boundedString(128), + revoked: z.boolean(), + credentials: z.array(CREDENTIAL_METADATA_SCHEMA), + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('error'), + action: z.enum(ACCESS_ACTIONS), + error: z + .object({ + code: boundedString(RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_CODE_MAX_BYTES), + message: boundedString(RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES), + }) + .strict(), + }) + .strict(), +]); + +export type RuntimeHostAccessManagementAction = (typeof ACCESS_ACTIONS)[number]; +export type RuntimeHostAccessManagementFrame = z.infer; +export type RuntimeHostAccessCredentialMetadata = z.infer; + +export function encodeRuntimeHostAccessManagementFrame( + frame: RuntimeHostAccessManagementFrame, +): string { + const encoded = Buffer.from(JSON.stringify(ACCESS_MANAGEMENT_FRAME_SCHEMA.parse(frame))).toString( + 'base64url', + ); + if (Buffer.byteLength(encoded, 'utf8') > FRAME_MAX_BYTES) { + throw new RangeError('Runtime Host access management frame exceeds the encoded size limit'); + } + return `${RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX}${encoded}\n`; +} + +export function decodeRuntimeHostAccessManagementFrame( + line: string, +): RuntimeHostAccessManagementFrame | undefined { + const marker = line.indexOf(RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX); + if (marker === -1) return undefined; + try { + const encoded = line.slice(marker + RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX.length).trim(); + if (encoded.length === 0 || Buffer.byteLength(encoded, 'utf8') > FRAME_MAX_BYTES) { + return undefined; + } + const value: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + const decoded = ACCESS_MANAGEMENT_FRAME_SCHEMA.safeParse(value); + return decoded.success ? decoded.data : undefined; + } catch { + return undefined; + } +} diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index ef31b612b3..6576eb5caa 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -1,4 +1,17 @@ export { + RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_CODE_MAX_BYTES, + RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES, + RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, + decodeRuntimeHostAccessManagementFrame, + encodeRuntimeHostAccessManagementFrame, + type RuntimeHostAccessCredentialMetadata, + type RuntimeHostAccessManagementAction, + type RuntimeHostAccessManagementFrame, +} from './access-management-frame.js'; +export { runtimeHostAccessCredentialFingerprint } from '../access-credential-identity.js'; +export { + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, RUNTIME_HOST_SERVICE_LOG_MAX_BYTES, @@ -7,6 +20,7 @@ export { encodeRuntimeHostServiceManagementFrame, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, + type RuntimeHostOperatorCapability, type RuntimeHostServiceSummary, } from './service-management-frame.js'; export { diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index cd6342fb36..8015387788 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -5,6 +5,9 @@ export const RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX = export const RUNTIME_HOST_SERVICE_LOG_MAX_BYTES = 48 * 1024; export const RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES = 128; export const RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES = 2 * 1024; +export const RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY = 'access-management-v1'; +export const RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV = + 'MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST'; const FRAME_MAX_BYTES = 96 * 1024; const PATH_MAX_BYTES = 4 * 1024; @@ -19,6 +22,7 @@ const SERVICE_ACTIONS = [ 'uninstall', ] as const; const SERVICE_STATES = ['not_installed', 'stopped', 'starting', 'running', 'failed'] as const; +const OPERATOR_CAPABILITIES = [RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY] as const; const boundedString = (maxBytes: number) => z.string().refine((value) => Buffer.byteLength(value, 'utf8') <= maxBytes); @@ -58,6 +62,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.discriminatedUnion('kind', [ kind: z.literal('result'), action: z.enum(SERVICE_ACTIONS), service: SERVICE_SUMMARY_SCHEMA, + operatorCapabilities: z.array(z.enum(OPERATOR_CAPABILITIES)).max(16).optional(), retainedStateRoot: boundedString(PATH_MAX_BYTES).optional(), logs: boundedString(RUNTIME_HOST_SERVICE_LOG_MAX_BYTES).optional(), }) @@ -79,6 +84,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.discriminatedUnion('kind', [ export type RuntimeHostServiceManagementAction = (typeof SERVICE_ACTIONS)[number]; export type RuntimeHostServiceManagementFrame = z.infer; +export type RuntimeHostOperatorCapability = (typeof OPERATOR_CAPABILITIES)[number]; export type RuntimeHostServiceSummary = z.infer; export function encodeRuntimeHostServiceManagementFrame( diff --git a/packages/runtime-host/src/protocol/access-authority.ts b/packages/runtime-host/src/protocol/access-authority.ts index fcd7ff05ca..df741b5831 100644 --- a/packages/runtime-host/src/protocol/access-authority.ts +++ b/packages/runtime-host/src/protocol/access-authority.ts @@ -1,5 +1,11 @@ import { invalidProtocolFrame } from './errors.js'; -import { requireExactRecord, requireId, requireString, requireUtf8String } from './codec.js'; +import { + requireExactRecord, + requireId, + requireRecord, + requireString, + requireUtf8String, +} from './codec.js'; import { defineOperation } from './operation-spec.js'; import type { OperationKey } from './operations.js'; @@ -37,12 +43,17 @@ export interface AccessCredentialIssueResult { export type AccessCredentialReplaceInput = AccessCredentialIssueInput; export type AccessCredentialReplaceResult = AccessCredentialIssueResult; -export type AccessCredentialPrepareInput = AccessCredentialIssueInput; +export type AccessCredentialPrepareInput = + | AccessCredentialIssueInput + | { readonly replacementOfCredentialId: string }; export type AccessCredentialPrepareResult = AccessCredentialIssueResult; -export interface AccessCredentialRevokeInput { - readonly credentialId: string; -} +export type AccessCredentialRevokeInput = + | { readonly credentialId: string } + | { + readonly credentialId: string; + readonly requiredActiveCredentialId: string; + }; export interface AccessCredentialRevokeResult { readonly credentialId: string; @@ -83,7 +94,7 @@ export const ACCESS_AUTHORITY_OPERATION_SPECS = { mode: 'command', availability: 'ready', errors: ACCESS_ERRORS, - decodeInput: decodeAccessCredentialIssueInput, + decodeInput: decodeAccessCredentialPrepareInput, decodeOutput: decodeAccessCredentialIssueResult, }), 'access.credential.revoke': defineOperation< @@ -154,6 +165,22 @@ export function decodeAccessCredentialIssueResult(value: unknown): AccessCredent }; } +export function decodeAccessCredentialPrepareInput(value: unknown): AccessCredentialPrepareInput { + const record = requireRecord(value, 'access credential prepare input'); + if (Object.hasOwn(record, 'replacementOfCredentialId')) { + const replacement = requireExactRecord(record, 'access credential replacement input', [ + 'replacementOfCredentialId', + ]); + return { + replacementOfCredentialId: requireId( + replacement.replacementOfCredentialId, + 'replacementOfCredentialId', + ), + }; + } + return decodeAccessCredentialIssueInput(record); +} + function principalKind(value: unknown): AccessCredentialPrincipalKind { if (value !== 'remote_owner' && value !== 'capability_provider') { throw invalidProtocolFrame('Invalid access credential principalKind'); @@ -162,8 +189,22 @@ function principalKind(value: unknown): AccessCredentialPrincipalKind { } export function decodeAccessCredentialRevokeInput(value: unknown): AccessCredentialRevokeInput { - const record = requireExactRecord(value, 'access credential revoke input', ['credentialId']); - return { credentialId: requireId(record.credentialId, 'credentialId') }; + const record = requireRecord(value, 'access credential revoke input'); + if (Object.hasOwn(record, 'requiredActiveCredentialId')) { + const guarded = requireExactRecord(record, 'guarded access credential revoke input', [ + 'credentialId', + 'requiredActiveCredentialId', + ]); + return { + credentialId: requireId(guarded.credentialId, 'credentialId'), + requiredActiveCredentialId: requireId( + guarded.requiredActiveCredentialId, + 'requiredActiveCredentialId', + ), + }; + } + const unguarded = requireExactRecord(record, 'access credential revoke input', ['credentialId']); + return { credentialId: requireId(unguarded.credentialId, 'credentialId') }; } export function decodeAccessCredentialRevokeResult(value: unknown): AccessCredentialRevokeResult { diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts index 5b9f29e9be..cf28891ad5 100644 --- a/packages/runtime-host/src/server/access-authority.ts +++ b/packages/runtime-host/src/server/access-authority.ts @@ -1,5 +1,6 @@ -import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { join } from 'node:path'; +import { runtimeHostAccessCredentialHash } from '../access-credential-identity.js'; import { type AccessCredentialIssueInput, type AccessCredentialIssueResult, @@ -94,7 +95,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { authenticate(credential: string): RuntimeHostConnectionAuthority | undefined { if (this.#closed) return undefined; - const candidate = hashCredential(credential); + const candidate = runtimeHostAccessCredentialHash(credential); let match: StoredAccessCredential | undefined; for (const stored of this.#file.credentials) { const storedHash = Buffer.from(stored.credentialHash, 'hex'); @@ -129,95 +130,122 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { } prepare(input: AccessCredentialPrepareInput): Promise { - return this.#issue(input, 'prepare'); + if (!('replacementOfCredentialId' in input)) return this.#issue(input, 'prepare'); + return this.#mutate(async () => { + const current = this.#file.credentials.find( + (credential) => + credential.credentialId === input.replacementOfCredentialId && + credential.status === 'active', + ); + if (!current) { + throw new RuntimeHostAccessInputError('The credential being rotated is no longer active'); + } + return this.#createCredential(current, 'prepare', current.operationGrants); + }); } #issue( input: AccessCredentialIssueInput, mode: 'issue' | 'replace' | 'prepare', ): Promise { - return this.#mutate(async () => { - const operationGrants = issuedAccessGrants(input.operationGrants); - assertCredentialAuthority(input, operationGrants); - if ( - mode === 'prepare' && - (input.principalKind !== 'remote_owner' || - !operationGrants.includes('access.credential.finalize')) - ) { - throw new RuntimeHostAccessInputError( - 'A pairing candidate must be a remote owner that can finalize its pairing', - ); - } - const credentialId = randomUUID(); - createRuntimeHostConnectionAuthority({ - principalKind: input.principalKind, - principalId: input.principalId, - credentialId, - operationGrants, - canPublishClientCapabilities: input.canPublishClientCapabilities, - canUseHostPaths: input.canUseHostPaths, - }); - const credential = `${ACCESS_CREDENTIAL_PREFIX}${randomBytes(32).toString('base64url')}`; - const createdAt = new Date(); - const stored: StoredAccessCredential = { - credentialId, - credentialHash: hashCredential(credential).toString('hex'), - principalId: input.principalId, - principalKind: input.principalKind, - status: mode === 'prepare' ? 'pending' : 'active', - operationGrants, - canPublishClientCapabilities: input.canPublishClientCapabilities, - canUseHostPaths: input.canUseHostPaths, - createdAt: createdAt.toISOString(), - ...(mode === 'prepare' - ? { - expiresAt: new Date( - createdAt.getTime() + PENDING_CREDENTIAL_LIFETIME_MS, - ).toISOString(), - } - : {}), - }; - const replaced = this.#file.credentials.filter( - (candidate) => - candidate.principalKind === input.principalKind && - candidate.principalId === input.principalId && - ((mode === 'replace' && candidate.status !== 'revoked') || - (mode === 'prepare' && candidate.status === 'pending')), - ); - const retained = - replaced.length === 0 - ? this.#file.credentials - : this.#file.credentials.filter((candidate) => !replaced.includes(candidate)); - const nextFile = createAccessCredentialFile([...retained, stored]); - assertAccessCredentialFileCapacity(nextFile); - const deliveryId = await createAccessCredentialDelivery( - this.#controlDirectory, - credentialId, - credential, + return this.#mutate(() => this.#createCredential(input, mode)); + } + + async #createCredential( + input: AccessCredentialIssueInput, + mode: 'issue' | 'replace' | 'prepare', + operationGrants = issuedAccessGrants(input.operationGrants), + ): Promise { + assertCredentialAuthority(input, operationGrants); + if ( + mode === 'prepare' && + (input.principalKind !== 'remote_owner' || + !operationGrants.includes('access.credential.finalize')) + ) { + throw new RuntimeHostAccessInputError( + 'A pairing candidate must be a remote owner that can finalize its pairing', ); - try { - await this.#commit( - nextFile, - replaced.map((credential) => credential.credentialId), - ); - } catch (error) { - await discardAccessCredentialDelivery(this.#controlDirectory, deliveryId); - throw error; - } - return { - credentialId, - deliveryId, - principalId: stored.principalId, - principalKind: stored.principalKind, - operationGrants, - canPublishClientCapabilities: stored.canPublishClientCapabilities, - canUseHostPaths: stored.canUseHostPaths, - }; + } + const credentialId = randomUUID(); + createRuntimeHostConnectionAuthority({ + principalKind: input.principalKind, + principalId: input.principalId, + credentialId, + operationGrants, + canPublishClientCapabilities: input.canPublishClientCapabilities, + canUseHostPaths: input.canUseHostPaths, }); + const credential = `${ACCESS_CREDENTIAL_PREFIX}${randomBytes(32).toString('base64url')}`; + const createdAt = new Date(); + const stored: StoredAccessCredential = { + credentialId, + credentialHash: runtimeHostAccessCredentialHash(credential).toString('hex'), + principalId: input.principalId, + principalKind: input.principalKind, + status: mode === 'prepare' ? 'pending' : 'active', + operationGrants, + canPublishClientCapabilities: input.canPublishClientCapabilities, + canUseHostPaths: input.canUseHostPaths, + createdAt: createdAt.toISOString(), + ...(mode === 'prepare' + ? { + expiresAt: new Date(createdAt.getTime() + PENDING_CREDENTIAL_LIFETIME_MS).toISOString(), + } + : {}), + }; + const replaced = this.#file.credentials.filter( + (candidate) => + candidate.principalKind === input.principalKind && + candidate.principalId === input.principalId && + ((mode === 'replace' && candidate.status !== 'revoked') || + (mode === 'prepare' && candidate.status === 'pending')), + ); + const retained = + replaced.length === 0 + ? this.#file.credentials + : this.#file.credentials.filter((candidate) => !replaced.includes(candidate)); + const nextFile = createAccessCredentialFile([...retained, stored]); + assertAccessCredentialFileCapacity(nextFile); + const deliveryId = await createAccessCredentialDelivery( + this.#controlDirectory, + credentialId, + credential, + ); + try { + await this.#commit( + nextFile, + replaced.map((credential) => credential.credentialId), + ); + } catch (error) { + await discardAccessCredentialDelivery(this.#controlDirectory, deliveryId); + throw error; + } + return { + credentialId, + deliveryId, + principalId: stored.principalId, + principalKind: stored.principalKind, + operationGrants, + canPublishClientCapabilities: stored.canPublishClientCapabilities, + canUseHostPaths: stored.canUseHostPaths, + }; } revoke(input: AccessCredentialRevokeInput): Promise { return this.#mutate(async () => { + if ('requiredActiveCredentialId' in input) { + const requiredActiveCredential = this.#file.credentials.find( + (credential) => credential.credentialId === input.requiredActiveCredentialId, + ); + if (!requiredActiveCredential || requiredActiveCredential.status !== 'active') { + throw new RuntimeHostAccessInputError( + 'The required credential is no longer active on this Runtime Host', + ); + } + if (input.credentialId === input.requiredActiveCredentialId) { + throw new RuntimeHostAccessInputError('A credential cannot revoke itself'); + } + } const index = this.#file.credentials.findIndex( (credential) => credential.credentialId === input.credentialId, ); @@ -225,15 +253,33 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { return { credentialId: input.credentialId, revoked: false }; } const current = this.#file.credentials[index]!; + const pendingForPrincipal = + current.status === 'active' + ? this.#file.credentials.filter( + (credential) => + credential.status === 'pending' && + credential.principalKind === current.principalKind && + credential.principalId === current.principalId, + ) + : []; const credentials = current.status === 'pending' ? this.#file.credentials.filter((credential) => credential !== current) - : this.#file.credentials.map((credential, candidateIndex) => - candidateIndex === index - ? { ...credential, status: 'revoked' as const, revokedAt: new Date().toISOString() } - : credential, - ); - await this.#commit(createAccessCredentialFile(credentials), [input.credentialId]); + : this.#file.credentials + .filter((credential) => !pendingForPrincipal.includes(credential)) + .map((credential) => + credential === current + ? { + ...credential, + status: 'revoked' as const, + revokedAt: new Date().toISOString(), + } + : credential, + ); + await this.#commit( + createAccessCredentialFile(credentials), + [current, ...pendingForPrincipal].map((credential) => credential.credentialId), + ); return { credentialId: input.credentialId, revoked: true }; }); } @@ -463,6 +509,9 @@ export async function revokeAccessCredential( try { return { ok: true, result: await authority.revoke(input) }; } catch (error) { + if (error instanceof RuntimeHostAccessInputError) { + return { ok: false, error: { code: 'invalid_request', message: error.message } }; + } return accessPersistenceFailure( error, 'Access credential revocation outcome is unknown', @@ -527,7 +576,3 @@ function unavailable( }, }; } - -function hashCredential(credential: string): Buffer { - return createHash('sha256').update(credential, 'utf8').digest(); -} diff --git a/packages/runtime-host/src/server/access-credential-metadata.ts b/packages/runtime-host/src/server/access-credential-metadata.ts new file mode 100644 index 0000000000..891464ff7f --- /dev/null +++ b/packages/runtime-host/src/server/access-credential-metadata.ts @@ -0,0 +1,64 @@ +import { join } from 'node:path'; +import { runtimeHostAccessCredentialFingerprintFromHash } from '../access-credential-identity.js'; +import { + discoverMarkedStorageRoot, + resolveExistingStorageRootControlDirectory, + resolveExistingStorageRoot, +} from '@maka/storage/root-authority'; +import type { OperationKey } from '../protocol/index.js'; +import { ACCESS_FILE_NAME, readAccessCredentialFile } from './access-credential-store.js'; + +export interface RuntimeHostAccessCredentialMetadata { + readonly credentialId: string; + readonly credentialFingerprint: string; + readonly principalKind: 'remote_owner' | 'capability_provider'; + readonly principalId: string; + readonly status: 'active' | 'pending'; + readonly operationGrants: readonly OperationKey[]; + readonly canPublishClientCapabilities: boolean; + readonly canUseHostPaths: boolean; + readonly createdAt: string; + readonly expiresAt?: string; +} + +export async function readRuntimeHostAccessCredentialMetadata( + rootPath: string, + expectedRootId?: string, +): Promise<{ readonly credentials: readonly RuntimeHostAccessCredentialMetadata[] }> { + const capability = expectedRootId + ? await resolveExistingStorageRoot({ + path: rootPath, + kind: 'interactive', + expectedRootId, + }) + : await discoverMarkedStorageRoot({ path: rootPath }); + const { controlDirectory } = await resolveExistingStorageRootControlDirectory(capability); + const file = await readAccessCredentialFile(join(controlDirectory, ACCESS_FILE_NAME)); + const now = Date.now(); + return { + credentials: file.credentials.flatMap((credential) => { + if ( + credential.status !== 'active' && + !(credential.status === 'pending' && Date.parse(credential.expiresAt!) > now) + ) { + return []; + } + return [ + { + credentialId: credential.credentialId, + credentialFingerprint: runtimeHostAccessCredentialFingerprintFromHash( + credential.credentialHash, + ), + principalKind: credential.principalKind, + principalId: credential.principalId, + status: credential.status, + operationGrants: credential.operationGrants, + canPublishClientCapabilities: credential.canPublishClientCapabilities, + canUseHostPaths: credential.canUseHostPaths, + createdAt: credential.createdAt, + ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}), + }, + ]; + }), + }; +} diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts index 2c6c753f74..29cdc62477 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -7,3 +7,7 @@ export { createUnavailableDomainOperationHandlers } from './operation-dispatcher export { startExecutionRuntimeHostService } from './execution-service.js'; export { runRuntimeHostProcessLifecycle } from './process-lifecycle.js'; export { installRuntimeHostLogCapture } from '../process-diagnostics.js'; +export { + readRuntimeHostAccessCredentialMetadata, + type RuntimeHostAccessCredentialMetadata, +} from './access-credential-metadata.js';