From e1602af2c546fd2bcc6eb67c19364b06da2e8880 Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 21 Aug 2026 19:06:34 +0800 Subject: [PATCH 1/6] feat(desktop): manage remote runtime host credentials Add Host-local credential metadata, safe Desktop credential rotation, and explicit revocation through the existing SSH management plane. Credential mutation remains owned by Runtime Host access authority, while secrets stay out of renderer and terminal projections. Generated-by: Codex --- .../__tests__/runtime-host-management.test.ts | 165 ++++++++++++++ .../runtime-host-ssh-terminal.test.ts | 73 +++++++ apps/desktop/src/main/runtime-host-boot.ts | 2 + .../src/main/runtime-host-management.ts | 168 ++++++++++++++- .../src/main/runtime-host-profile-service.ts | 49 +++++ .../src/main/runtime-host-ssh-terminal.ts | 202 ++++++++++++------ apps/desktop/src/preload/bridge-contract.d.ts | 20 ++ apps/desktop/src/preload/preload.ts | 17 ++ .../locales/settings-projects-copy.ts | 45 ++++ .../runtime-host-management-dialog.tsx | 177 ++++++++++++++- .../renderer/styles/settings/runtime-host.css | 52 +++++ docs/astryx-surface-file-inventory.md | 2 +- .../runtime-host-operator-command.test.ts | 49 +++++ packages/cli/src/cli-core.ts | 39 +++- .../cli/src/runtime-host-access-command.ts | 166 +++++++++++++- packages/cli/src/runtime-host-cli.ts | 78 ++++++- .../src/runtime-host-managed-deployment.ts | 4 + .../src/operator/access-management-frame.ts | 113 ++++++++++ packages/runtime-host/src/operator/index.ts | 11 + .../src/server/access-credential-metadata.ts | 57 +++++ packages/runtime-host/src/server/index.ts | 4 + 21 files changed, 1397 insertions(+), 96 deletions(-) create mode 100644 packages/runtime-host/src/operator/access-management-frame.ts create mode 100644 packages/runtime-host/src/server/access-credential-metadata.ts 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..a34878e7b4 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -1,11 +1,146 @@ 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:desktop-client'; + const replacement = 'maka_rh_replacement-secret'; + 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), + }, + clientInstanceId: 'desktop-client', + profiles: { + resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedCredentialFingerprint: async () => currentFingerprint, + 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: 'list', credentials }; + } + if (input.action === 'prepare') { + const pending = { + ...accessCredential( + 'replacement', + principalId, + runtimeHostAccessCredentialFingerprint(replacement), + ), + status: 'pending' as const, + expiresAt: '2026-08-21T01:15:00.000Z', + }; + return { + schemaVersion: 1, + kind: 'prepared', + credential: replacement, + credentials: [...credentials, pending], + }; + } + assert.equal(input.protectedCredentialFingerprint, currentFingerprint); + const target = credentials.find( + (credential) => credential.credentialId === input.credentialId, + ); + if (target?.credentialFingerprint === input.protectedCredentialFingerprint) { + 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: 'revoked', + 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.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, + }], + ); +}); + test('manages only the service identity bound by Desktop onboarding', async () => { const handlers = new Map unknown>(); const managementInputs: DesktopRuntimeHostSshManagementInput[] = []; @@ -34,11 +169,13 @@ test('manages only the service identity bound by Desktop onboarding', async () = handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), removeHandler: (channel) => handlers.delete(channel), }, + clientInstanceId: 'desktop-client', profiles: { resolveManagedService: async (profileId) => profileId === managedProfile.id ? { profile: managedProfile, service: managedService, state: 'active' as const } : undefined, + resolveManagedCredentialFingerprint: async () => undefined, markManagedServiceUninstalling: async (binding) => { uninstallOrder.push('mark-uninstalling'); return { ...binding, state: 'uninstalling' as const }; @@ -47,6 +184,7 @@ 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); @@ -55,6 +193,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = } return serviceResult(input.action); }, + runAccessManagement: async () => assert.fail('access management is not expected'), cleanupManagedDeployment: async (input) => { cleanupInputs.push(input); uninstallOrder.push('cleanup-deployment'); @@ -141,8 +280,10 @@ test('resumes deployment cleanup without repeating the committed service uninsta handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), removeHandler: (channel) => handlers.delete(channel), }, + clientInstanceId: 'desktop-client', profiles: { resolveManagedService: async () => ({ profile, service, state }), + resolveManagedCredentialFingerprint: async () => undefined, markManagedServiceUninstalling: async (binding) => { state = 'uninstalling'; return { ...binding, state }; @@ -151,11 +292,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; }, @@ -185,6 +328,7 @@ test('does not commit uninstall until the remote service confirms it is removed' handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), removeHandler: (channel) => handlers.delete(channel), }, + clientInstanceId: 'desktop-client', profiles: { resolveManagedService: async () => ({ profile: { @@ -206,11 +350,13 @@ test('does not commit uninstall until the remote service confirms it is removed' }, state: 'active' as const, }), + resolveManagedCredentialFingerprint: 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 +365,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'), }); @@ -248,3 +395,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-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 11eed00642..22ce4a54de 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'; @@ -248,6 +250,77 @@ 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', + principalId: 'desktop:stable-client', + }); + await waitFor(() => harness.pty.hasDataListener()); + harness.pty.emitData('Password: '); + harness.pty.emitData( + encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'prepared', + 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, 'prepared'); + assert.equal(result.kind === 'prepared' ? 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, /desktop:stable-client/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', + }); + await waitFor(() => harness.pty.hasDataListener()); + harness.pty.emitData( + encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'revoked', + credentialId: 'credential-1', + revoked: true, + credentials: [], + }), + ); + harness.pty.exit(0); + + assert.equal((await management).kind, 'revoked'); + 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..75ccee2cd1 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -328,8 +328,10 @@ const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({ }); const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, + clientInstanceId: runtimeHostClientInstanceId, profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, + runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, }); diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index d8f79fea2c..07102b14e6 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -1,12 +1,18 @@ import type { IpcMain } from 'electron'; -import type { RuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; +import { + 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'; @@ -21,24 +27,33 @@ const MANAGEMENT_ACTIONS = new Set([ export function createDesktopRuntimeHostManagement(input: { readonly ipcMain: Pick; + readonly clientInstanceId: string; readonly profiles: Pick< DesktopRuntimeHostProfileService, | 'resolveManagedService' + | 'resolveManagedCredentialFingerprint' + | '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; }; @@ -102,13 +117,154 @@ 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 principalId = `desktop:${input.clientInstanceId}`; + const accessInput = async ( + profileId: unknown, + action: DesktopRuntimeHostSshAccessInput['action'], + detail: { + readonly credentialId?: string; + readonly principalId?: string; + readonly protectedCredentialFingerprint?: string; + } = {}, + ): Promise => { + const managed = await resolveManagedService(profileId); + 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 { + 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, + action, + ...detail, + }; + }; + + const accessSnapshot = ( + credentials: Extract['credentials'], + currentFingerprint: string | undefined, + ): DesktopRuntimeHostAccessSnapshot => ({ + 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 resolvedProfileId = requireProfileId(profileId); + const response = await input.runAccessManagement( + await accessInput(resolvedProfileId, 'list'), + ); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.kind !== 'list') { + throw new Error('Remote Runtime Host did not return its access credentials'); + } + const currentFingerprint = await input.profiles.resolveManagedCredentialFingerprint( + resolvedProfileId, + ); + return accessSnapshot(response.credentials, currentFingerprint); + }; + + const rotateCredential = async ( + profileId: unknown, + ): Promise => { + const managed = await resolveManagedService(profileId); + const response = await input.runAccessManagement( + await accessInput(profileId, 'prepare', { principalId }), + ); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.kind !== 'prepared') { + throw new Error('Remote Runtime Host did not prepare a replacement credential'); + } + const replacementFingerprint = runtimeHostAccessCredentialFingerprint(response.credential); + const replacement = response.credentials.find( + (credential) => credential.credentialFingerprint === replacementFingerprint, + ); + if ( + !replacement || + replacement.status !== 'pending' || + replacement.principalKind !== 'remote_owner' || + replacement.principalId !== principalId || + !replacement.canPublishClientCapabilities || + replacement.canUseHostPaths + ) { + throw new Error('Remote Runtime Host returned an invalid Desktop credential replacement'); + } + await input.profiles.rotateManagedCredential(managed.profile.id, 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); + }; + + 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 resolvedProfileId = requireProfileId(profileId); + const currentFingerprint = await input.profiles.resolveManagedCredentialFingerprint( + resolvedProfileId, + ); + if (!currentFingerprint) throw new Error('The current Desktop credential is unavailable'); + const response = await input.runAccessManagement( + await accessInput(resolvedProfileId, 'revoke', { + credentialId, + protectedCredentialFingerprint: currentFingerprint, + }), + ); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.kind !== 'revoked') { + throw new Error('Remote Runtime Host did not confirm credential revocation'); + } + return accessSnapshot(response.credentials, currentFingerprint); + }; + + 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); }, }; } diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 40e26cf6dc..1cbbd965b4 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,12 @@ export interface DesktopRuntimeHostProfileService { resolveManagedService( profileId: string, ): Promise; + resolveManagedCredentialFingerprint(profileId: string): Promise; 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 +582,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 +628,19 @@ export function createDesktopRuntimeHostProfileService(input: { return binding; }); }, + resolveManagedCredentialFingerprint(profileId) { + return mutate(async () => { + 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 + ? runtimeHostAccessCredentialFingerprint(resolved.credential) + : 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..34bb9a04f6 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -13,10 +13,14 @@ import { type RuntimeHostSshTunnelInput, } from '@maka/runtime-host/client'; import { + decodeRuntimeHostAccessManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, + RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, + type RuntimeHostAccessManagementAction, + type RuntimeHostAccessManagementFrame, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, type RuntimeHostSetupFrame, @@ -41,6 +45,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 +82,19 @@ export interface DesktopRuntimeHostSshCleanupInput { readonly signal?: AbortSignal; } +export interface DesktopRuntimeHostSshAccessInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly rootPath: string; + readonly expectedRootId: string; + readonly action: RuntimeHostAccessManagementAction; + readonly principalId?: string; + readonly credentialId?: string; + readonly protectedCredentialFingerprint?: string; + readonly signal?: AbortSignal; +} + export type DesktopRuntimeHostSetupPackage = | { readonly kind: 'npm'; readonly specifier: string } | { readonly kind: 'development_archive'; readonly path: string }; @@ -104,6 +122,9 @@ export function createDesktopRuntimeHostSshTerminal(input: { runServiceManagement( input: DesktopRuntimeHostSshManagementInput, ): Promise; + runAccessManagement( + input: DesktopRuntimeHostSshAccessInput, + ): Promise; cleanupManagedDeployment(input: DesktopRuntimeHostSshCleanupInput): Promise; close(): Promise; } { @@ -303,6 +324,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 +478,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: accessManagementFrameAction, + label: 'Remote Runtime Host access management', + }), cleanupManagedDeployment: async (cleanupInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); cleanupInput.signal?.throwIfAborted(); @@ -775,6 +821,38 @@ function runtimeHostServiceManagementRemoteCommand( return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; } +function runtimeHostAccessManagementRemoteCommand( + input: DesktopRuntimeHostSshAccessInput, +): string { + const command = [ + input.operatorPath, + 'access', + input.action, + '--framed', + '--root', + input.rootPath, + '--expected-root', + input.expectedRootId, + ...(input.principalId + ? ['--principal', input.principalId, '--preset', 'desktop-client'] + : []), + ...(input.credentialId ? ['--credential', input.credentialId] : []), + ...(input.protectedCredentialFingerprint + ? ['--protect-fingerprint', input.protectedCredentialFingerprint] + : []), + ].map(quotePosix).join(' '); + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; +} + +function accessManagementFrameAction( + frame: RuntimeHostAccessManagementFrame, +): RuntimeHostAccessManagementAction { + if (frame.kind === 'error') return frame.action; + if (frame.kind === 'prepared') return 'prepare'; + if (frame.kind === 'revoked') return 'revoke'; + return 'list'; +} + function runtimeHostManagedDeploymentCleanupRemoteCommand(operatorPath: string): string { const operator = quotePosix(operatorPath); const invocation = diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 87a072a13d..ac850d3dbc 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -383,6 +383,20 @@ export type DesktopRuntimeHostManagementResponse = 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 credentials: readonly DesktopRuntimeHostAccessCredential[]; +} + export interface DesktopProjectCapabilities { readonly chooseClientDirectory: boolean; readonly chooseHostDirectory: boolean; @@ -485,6 +499,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..f340a142e7 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -80,6 +80,21 @@ 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; + revokeCredential: string; + revokeCredentialConfirm(name: string): string; + accessActionFailed: string; + back: string; remove: string; empty: string; loadFailed: string; @@ -223,6 +238,21 @@ 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: '轮换凭据', + revokeCredential: '撤销', + revokeCredentialConfirm: (name: string) => `撤销 ${name} 的访问权限?`, + accessActionFailed: '无法管理访问权限', + back: '返回', remove: '移除', empty: '还没有远程 Host', loadFailed: '无法读取 Runtime Host profiles', @@ -364,6 +394,21 @@ 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', + 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..3479ea1b61 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -2,11 +2,14 @@ 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'; @@ -23,6 +26,9 @@ export function RuntimeHostManagementDialog(props: { const [error, setError] = useState(); const [uninstalledRoot, setUninstalledRoot] = useState(); const [confirmingUninstall, setConfirmingUninstall] = useState(false); + const [view, setView] = useState<'service' | 'access'>('service'); + const [access, setAccess] = useState(); + const [revokeTarget, setRevokeTarget] = useState(); const logsRef = useRef(null); const profile = props.profile; @@ -33,6 +39,9 @@ export function RuntimeHostManagementDialog(props: { setError(undefined); setUninstalledRoot(undefined); setConfirmingUninstall(false); + setView('service'); + setAccess(undefined); + setRevokeTarget(undefined); setLoading(true); void window.maka.runtimeHostManagement.run(profile.id, 'status').then( (response) => { @@ -84,6 +93,58 @@ export function RuntimeHostManagementDialog(props: { } } + async function loadAccess(): Promise { + if (!profile) return; + setLoading(true); + setError(undefined); + try { + setAccess(await window.maka.runtimeHostManagement.listCredentials(profile.id)); + setView('access'); + } 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 { + if (!profile || !revokeTarget) return; + setLoading(true); + setError(undefined); + try { + setAccess( + await window.maka.runtimeHostManagement.revokeCredential( + profile.id, + revokeTarget.credentialId, + ), + ); + setRevokeTarget(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'; @@ -130,7 +191,7 @@ export function RuntimeHostManagementDialog(props: { title={copy.uninstallRetained(uninstalledRoot)} /> ) : null} - {service ? ( + {view === 'service' && service ? ( <>
@@ -170,13 +231,89 @@ export function RuntimeHostManagementDialog(props: { ) : null} ) : null} + {view === 'access' && access ? ( +
+ {copy.accessTitle} + {revokeTarget ? ( + + ) : 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 ? ( + {revokeTarget ? ( + <> +
@@ -302,13 +323,13 @@ export function RuntimeHostManagementDialog(props: { footer={(
- {revokeTarget ? ( + {confirmation?.kind === 'revoke' ? ( <>