diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index a21c272c8c..1fbc1844b5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -23,8 +23,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client'; -import { resolveRuntimeHostManagedServiceId } from '@maka/runtime-host/operator'; import type { RuntimeHostDesktopManager } from '../runtime-host-desktop-manager.js'; + +const RECOVERY_DEPLOYMENT_ID = '33333333-3333-4333-8333-333333333333'; import { createDesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js'; import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js'; @@ -33,7 +34,6 @@ test('enabling remote access hands the same root to one managed service before D t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); const rootPath = join(clientDataRoot, 'workspaces', 'default'); - const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); await mkdir(rootPath, { recursive: true }); const handlers = new Map[1]>(); let retired = false; @@ -49,16 +49,27 @@ test('enabling remote access hands the same root to one managed service before D routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'], coordinationRelays: [], }; + const deploymentId = '11111111-1111-4111-8111-111111111111'; const operator = { - async runSetup(input: { readonly rootPath: string; readonly principalId: string }) { + async runSetup(input: { + readonly rootPath: string; + readonly principalId: string; + readonly expectedTarget: { readonly serviceId: string; readonly rootId: string }; + }) { assert.equal(retired, true); assert.equal(input.rootPath, rootPath); assert.equal(input.principalId, 'desktop-owner:local-runtime-host-sharing'); + assert.deepEqual(input.expectedTarget, { + serviceId: 'a'.repeat(64), + rootPath, + rootId: 'a'.repeat(64), + }); return { - serviceId, + serviceId: 'a'.repeat(64), operatorPath: join(base, 'operator'), rootPath, rootId: 'a'.repeat(64), + deploymentId, credential: 'pending-credential', directPeer: peer, }; @@ -109,11 +120,12 @@ test('enabling remote access hands the same root to one managed service before D transport: { kind: 'libp2p-direct', ...peer }, credential: 'pending-credential', }); - assert.equal( - JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) - .state, - 'managed', - ); + const lifecycle = JSON.parse( + await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'), + ) as { readonly state: string; readonly deploymentId: string }; + assert.equal(lifecycle.state, 'managed'); + assert.equal((lifecycle as { serviceId?: string }).serviceId, 'a'.repeat(64)); + assert.equal(lifecycle.deploymentId, deploymentId); }); test('revokes the one Local sharing authority without changing peer connectivity', async (t) => { @@ -192,14 +204,12 @@ test('an interrupted Local Host handoff converges to its exact managed service', const clientDataRoot = join(base, 'client'); const rootPath = join(clientDataRoot, 'workspaces', 'default'); const rootId = 'a'.repeat(64); - const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); await mkdir(rootPath, { recursive: true }); await writeFile( join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ schemaVersion: 1, state: 'handoff', - serviceId, rootPath, rootId, coordinationRelays: [], @@ -225,10 +235,11 @@ test('an interrupted Local Host handoff converges to its exact managed service', async runSetup() { setupCalls += 1; return { - serviceId, + serviceId: rootId, operatorPath: join(base, 'operator'), rootPath, rootId, + deploymentId: '22222222-2222-4222-8222-222222222222', credential: 'unused-pending-credential', directPeer: { peerId: '12D3KooWpeer', @@ -268,6 +279,7 @@ test('startup replays the persisted peer intent instead of gating recovery on st operatorPath: join(clientDataRoot, 'operator'), rootPath, rootId, + deploymentId: RECOVERY_DEPLOYMENT_ID, peerEnabled: true, coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'], allowInterruptActiveTasks: false, @@ -401,10 +413,12 @@ test('startup completes an exact persisted uninstall intent after Desktop interr operatorPath: join(base, 'operator'), rootPath, rootId, + deploymentId: RECOVERY_DEPLOYMENT_ID, allowInterruptActiveTasks: false, })}\n`, ); const actions: string[] = []; + const cleanupPhases: boolean[] = []; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, clientDataRoot, @@ -433,8 +447,9 @@ test('startup completes an exact persisted uninstall intent after Desktop interr service: { state: 'not_installed' }, }; }, - async cleanupManagedDeployment() { + async cleanupManagedDeployment(input: { readonly finalize?: boolean }) { actions.push('cleanup'); + cleanupPhases.push(input.finalize ?? false); }, async close() {}, } as unknown as ReturnType, @@ -442,7 +457,8 @@ test('startup completes an exact persisted uninstall intent after Desktop interr t.after(() => service.close()); await service.recover(); - assert.deepEqual(actions, ['uninstall', 'cleanup']); + assert.deepEqual(actions, ['uninstall', 'cleanup', 'cleanup']); + assert.deepEqual(cleanupPhases, [false, true]); await assert.rejects(readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'), { code: 'ENOENT', }); @@ -463,6 +479,7 @@ test('startup resumes deployment cleanup without repeating a completed uninstall operatorPath: join(base, 'operator'), rootPath, rootId: 'a'.repeat(64), + deploymentId: RECOVERY_DEPLOYMENT_ID, allowInterruptActiveTasks: false, })}\n`, ); @@ -508,6 +525,7 @@ async function writeManagedLifecycle( operatorPath: join(clientDataRoot, 'operator'), rootPath, rootId, + deploymentId: RECOVERY_DEPLOYMENT_ID, })}\n`, ); } diff --git a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts index 321ca55bc8..fc044de4d0 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts @@ -18,7 +18,7 @@ */ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, test } from "node:test"; @@ -27,6 +27,10 @@ import { createDesktopRuntimeHostManagedServiceStore, findDesktopRuntimeHostManagedServiceBinding, } from "../runtime-host-managed-services.js"; +import { + createDesktopRuntimeHostProfileService, + resolveDesktopRuntimeHostStartup, +} from "../runtime-host-profile-service.js"; const roots: string[] = []; const profile = { @@ -46,24 +50,75 @@ const service = { rootPath: "/srv/maka", operatorPath: "/home/operator/.local/share/maka/operator", }; +const deploymentId = "11111111-1111-4111-8111-111111111111"; +const deployedService = { + deployment: { id: service.id, rootPath: service.rootPath, deploymentId }, + control: { kind: "ssh_operator" as const, operatorPath: service.operatorPath }, +}; afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true }))); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); }); test("keeps Desktop service bindings outside the shared profile catalog", async () => { const root = await mkdtemp(join(tmpdir(), "maka-managed-host-services-")); roots.push(root); const catalog = createClientRuntimeHostProfileCatalog(root); + const legacyPath = join(root, "runtime-host-managed-services.json"); + const legacyDocument = `${JSON.stringify({ + schemaVersion: 1, + bindings: [{ profile, service, state: "uninstalling" }], + })}\n`; + await writeFile( + legacyPath, + legacyDocument, + ); const managedServices = createDesktopRuntimeHostManagedServiceStore(root); const concurrentStore = createDesktopRuntimeHostManagedServiceStore(root); await catalog.create(profile, "secret"); + assert.equal((await managedServices.read()).bindings[0]?.deployment.id, service.id); + await assert.rejects(readFile(legacyPath, "utf8"), { + code: "ENOENT", + }); + await writeFile(legacyPath, legacyDocument); + await managedServices.read(); + await assert.rejects(readFile(legacyPath, "utf8"), { code: "ENOENT" }); + + const profileService = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup: await resolveDesktopRuntimeHostStartup(root, { catalog }), + catalog, + managedServices, + states: () => [], + enable: async () => undefined, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + const legacyUninstall = await profileService.resolveManagedService(profile.id); + assert.ok(legacyUninstall); + assert.equal(legacyUninstall.deployment.deploymentId, undefined); + assert.equal(legacyUninstall.state, "uninstalling"); + assert.equal( + (await profileService.markManagedServiceUninstalling(legacyUninstall)).state, + "uninstalling", + ); + await Promise.all([ - managedServices.save(profile, service), + managedServices.save(profile, deployedService), concurrentStore.save( { ...profile, id: "lab", rootId: "d".repeat(64) }, - { ...service, id: "e".repeat(64) }, + { + ...deployedService, + deployment: { + ...deployedService.deployment, + id: "e".repeat(64), + deploymentId: "22222222-2222-4222-8222-222222222222", + }, + }, ), ]); @@ -72,28 +127,62 @@ test("keeps Desktop service bindings outside the shared profile catalog", async /managedService/u, ); assert.deepEqual( - findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile), - { profile: { ...profile, transport: { ...profile.transport } }, service, state: "active" }, + findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, + ), + { + profile: { ...profile, transport: { ...profile.transport } }, + deployment: { id: service.id, rootPath: service.rootPath, deploymentId }, + control: { kind: "ssh_operator", operatorPath: service.operatorPath }, + state: "active", + }, ); assert.equal((await managedServices.read()).bindings.length, 2); assert.equal( findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), { ...profile, - transport: { ...profile.transport, destination: "operator@new.example.com" }, + transport: { + ...profile.transport, + destination: "operator@new.example.com", + }, }), undefined, ); - assert.equal(await managedServices.markUninstallingIfCurrent(profile, service), true); + const binding = findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, + ); + assert.ok(binding); + assert.equal(await managedServices.markUninstallingIfCurrent(binding), true); assert.equal( - findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile)?.state, + findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, + )?.state, "uninstalling", ); - assert.equal(await managedServices.removeCleanupPendingIfCurrent(profile, service), false); - assert.equal(await managedServices.markCleanupPendingIfCurrent(profile, service), true); assert.equal( - findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile)?.state, + await managedServices.removeCleanupPendingIfCurrent(binding), + false, + ); + assert.equal( + await managedServices.markCleanupPendingIfCurrent(binding), + true, + ); + assert.equal( + findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, + )?.state, "cleanup_pending", ); - assert.equal(await managedServices.markUninstallingIfCurrent(profile, service), false); - assert.equal(await managedServices.removeCleanupPendingIfCurrent(profile, service), true); + assert.equal( + await managedServices.markUninstallingIfCurrent(binding), + false, + ); + assert.equal( + await managedServices.removeCleanupPendingIfCurrent(binding), + true, + ); }); 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 ce7731ea0a..b82385efd2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -34,6 +34,8 @@ import type { DesktopRuntimeHostSshUpdateReconciliationInput, } from '../runtime-host-ssh-terminal.js'; +const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111'; + test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => { const handlers = new Map unknown>(); const profile = { @@ -75,17 +77,23 @@ test('identifies, rotates, and revokes managed credentials without exposing secr }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => ({ - profile, - service, - state: 'active' as const, + ...managedBinding(profile, service, 'active'), credentialFingerprint: currentFingerprint, enabled: profileEnabled, }), rotateManagedCredential: async (expected, credential) => { assert.equal(expected.profile, profile); - assert.equal(expected.service, service); + assert.deepEqual(expected.deployment, { + id: service.id, + rootPath: service.rootPath, + deploymentId: DEPLOYMENT_ID, + }); + assert.deepEqual(expected.control, { + kind: 'ssh_operator', + operatorPath: service.operatorPath, + }); assert.equal(expected.credentialFingerprint, currentFingerprint); assert.equal(credential, replacement); }, @@ -220,7 +228,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = ...unusedDirectPeerProfileDependencies(), resolveManagedService: async (profileId) => profileId === managedProfile.id - ? { profile: managedProfile, service: managedService, state: 'active' as const } + ? managedBinding(managedProfile, managedService, 'active') : undefined, resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { @@ -294,6 +302,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = serviceId: managedService.id, rootPath: managedService.rootPath, rootId: managedProfile.rootId, + deploymentId: DEPLOYMENT_ID, }, }); @@ -318,17 +327,32 @@ test('manages only the service identity bound by Desktop onboarding', async () = 'uninstall-service', 'mark-cleanup-pending', 'cleanup-deployment', + 'cleanup-deployment', 'clear-binding', ]); - assert.deepEqual(cleanupInputs, [{ - destination: managedProfile.transport.destination, - operatorPath: managedService.operatorPath, - expectedTarget: { - serviceId: managedService.id, - rootPath: managedService.rootPath, - rootId: managedProfile.rootId, + assert.deepEqual(cleanupInputs, [ + { + destination: managedProfile.transport.destination, + operatorPath: managedService.operatorPath, + expectedTarget: { + serviceId: managedService.id, + rootPath: managedService.rootPath, + rootId: managedProfile.rootId, + deploymentId: DEPLOYMENT_ID, + }, }, - }]); + { + destination: managedProfile.transport.destination, + operatorPath: managedService.operatorPath, + expectedTarget: { + serviceId: managedService.id, + rootPath: managedService.rootPath, + rootId: managedProfile.rootId, + deploymentId: DEPLOYMENT_ID, + }, + finalize: true, + }, + ]); management.close(); assert.equal(handlers.size, 0); }); @@ -366,7 +390,7 @@ test('publishes update progress and waits for the managed profile to reconnect', profiles: { ...unusedDirectPeerProfileDependencies(), resolveManagedService: async () => - bindingPresent ? { profile, service, state: 'active' as const } : undefined, + bindingPresent ? managedBinding(profile, service, 'active') : undefined, resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), markManagedServiceUninstalling: async (binding) => binding, @@ -423,6 +447,7 @@ test('publishes update progress and waits for the managed profile to reconnect', serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }, }]); assert.deepEqual(progress, [ @@ -488,7 +513,7 @@ test('configures Project roots with CAS and reconnects only after a committed cu }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), markManagedServiceUninstalling: async (binding) => binding, @@ -596,7 +621,7 @@ test('manages one Host update policy and reconciles it through the bound operato }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), markManagedServiceUninstalling: async (binding) => binding, @@ -632,6 +657,7 @@ test('manages one Host update policy and reconciles it through the bound operato serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }, }, service: serviceSummary('1.3.0'), @@ -668,6 +694,7 @@ test('manages one Host update policy and reconciles it through the bound operato serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }, schedulingState: 'ready', }, @@ -681,6 +708,7 @@ test('manages one Host update policy and reconciles it through the bound operato serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }); } assert.deepEqual(reconciliationInputs, []); @@ -705,13 +733,14 @@ test('manages one Host update policy and reconciles it through the bound operato serviceId: service.id, rootPath: service.rootPath, rootId: profile.rootId, + deploymentId: DEPLOYMENT_ID, }, }]); assert.deepEqual(progress, [{ profileId: profile.id, phase: 'replacing' }]); assert.deepEqual(connections, [[profile.id, profile.rootId, 'host-before-update', true]]); }); -test('resumes deployment cleanup without invoking the removed operator', async () => { +test('retries acknowledged deployment cleanup without repeating uninstall', async () => { const handlers = new Map unknown>(); const profile = { id: 'office', @@ -731,8 +760,6 @@ test('resumes deployment cleanup without invoking the removed operator', async ( operatorPath: '/home/operator/.local/share/maka/operator', }; const calls: DesktopRuntimeHostSshManagementInput[] = []; - let cleanups = 0; - let state: 'active' | 'uninstalling' | 'cleanup_pending' = 'active'; let clearAttempts = 0; createDesktopRuntimeHostManagement({ ...unusedUpdateDependencies(), @@ -742,16 +769,21 @@ test('resumes deployment cleanup without invoking the removed operator', async ( }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state }), - resolveManagedAccess: async () => undefined, - markManagedServiceUninstalling: async (binding) => { - state = 'uninstalling'; - return { ...binding, state }; - }, - markManagedServiceCleanupPending: async (binding) => { - state = 'cleanup_pending'; - return { ...binding, state }; + resolveManagedService: async () => { + const binding = managedBinding(profile, service, 'cleanup_pending'); + return { + ...binding, + deployment: { + id: binding.deployment.id, + rootPath: binding.deployment.rootPath, + }, + }; }, + resolveManagedAccess: async () => undefined, + markManagedServiceUninstalling: async () => + assert.fail('remote uninstall must not repeat'), + markManagedServiceCleanupPending: async () => + assert.fail('cleanup intent is already acknowledged'), clearManagedServiceBinding: async () => { clearAttempts += 1; if (clearAttempts === 1) throw new Error('local metadata is unavailable'); @@ -763,9 +795,7 @@ test('resumes deployment cleanup without invoking the removed operator', async ( return serviceResult(input.action); }, runAccessManagement: async () => assert.fail('access management is not expected'), - cleanupManagedDeployment: async () => { - cleanups += 1; - }, + cleanupManagedDeployment: async () => undefined, }); const run = handlers.get('runtime-host-management:run'); @@ -774,14 +804,12 @@ test('resumes deployment cleanup without invoking the removed operator', async ( run({}, profile.id, 'uninstall') as Promise, /local metadata is unavailable/u, ); - assert.equal(calls.length, 1); - assert.equal(calls[0]?.retainManagedDeployment, true); + assert.equal(calls.length, 0); assert.deepEqual(await run({}, profile.id, 'uninstall'), { kind: 'uninstalled', retainedStateRoot: service.rootPath, }); - assert.equal(calls.length, 1); - assert.equal(cleanups, 2); + assert.equal(calls.length, 0); }); test('rechecks uninstall intent before retrying the remote service', async () => { @@ -795,26 +823,35 @@ test('rechecks uninstall intent before retrying the remote service', async () => }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ - 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', + resolveManagedService: async () => { + const binding = managedBinding( + { + 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', + }, }, - }, - service: { - id: 'b'.repeat(64), - rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', - }, - state: 'uninstalling' as const, - }), + { + id: 'b'.repeat(64), + rootPath: '/srv/maka', + operatorPath: '/home/operator/.local/share/maka/operator', + }, + 'uninstalling', + ); + return { + ...binding, + deployment: { + id: binding.deployment.id, + rootPath: binding.deployment.rootPath, + }, + }; + }, resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { marked = true; @@ -874,7 +911,7 @@ test('keeps the SSH profile while adding and removing its managed Direct peer', }, profiles: { ...unusedDirectPeerProfileDependencies(), - resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), markManagedServiceUninstalling: async (binding) => binding, @@ -1049,8 +1086,8 @@ test('does not invoke peer management when the remote operator lacks its capabil }); function managedSshBinding() { - return { - profile: { + return managedBinding( + { id: 'office', name: 'Office', kind: 'remote' as const, @@ -1062,12 +1099,25 @@ function managedSshBinding() { websocketPath: '/runtime-host', }, }, - service: { + { id: 'b'.repeat(64), rootPath: '/srv/maka', operatorPath: '/home/operator/.local/share/maka/operator', }, - state: 'active' as const, + 'active', + ); +} + +function managedBinding< + Profile, + Service extends { readonly id: string; readonly rootPath: string; readonly operatorPath: string }, + State extends 'active' | 'uninstalling' | 'cleanup_pending', +>(profile: Profile, service: Service, state: State) { + return { + profile, + deployment: { id: service.id, rootPath: service.rootPath, deploymentId: DEPLOYMENT_ID }, + control: { kind: 'ssh_operator' as const, operatorPath: service.operatorPath }, + state, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts index 6f3c4c9f46..d90583a9ce 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -20,14 +20,14 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { DesktopRuntimeHostProfileAddInput } from '../../preload/bridge-contract.js'; -import type { DesktopRuntimeHostManagedService } from '../runtime-host-managed-services.js'; +import type { DesktopRuntimeHostManagedServiceTarget } from '../runtime-host-managed-services.js'; import { createDesktopRuntimeHostOnboarding } from '../runtime-host-onboarding.js'; test('persists a verified on-demand SSH profile without endpoint or credential projection', async () => { let setupInput: unknown; let saved: | (DesktopRuntimeHostProfileAddInput & { - readonly managedService?: DesktopRuntimeHostManagedService; + readonly managedService?: DesktopRuntimeHostManagedServiceTarget; }) | undefined; const harness = createHarness({ @@ -42,6 +42,7 @@ test('persists a verified on-demand SSH profile without endpoint or credential p onProgress({ phase: 'installing_service' }); return { serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', rootPath: '/home/operator/.config/Maka/workspaces/default', operatorPath: '/home/operator/.local/share/maka/operator', rootId: 'a'.repeat(64), @@ -67,7 +68,17 @@ test('persists a verified on-demand SSH profile without endpoint or credential p operatorPath: '/home/operator/.local/share/maka/operator', }, }); - assert.equal(saved?.managedService, undefined); + assert.deepEqual(saved?.managedService, { + deployment: { + id: 'b'.repeat(64), + rootPath: '/home/operator/.config/Maka/workspaces/default', + deploymentId: '00000000-0000-4000-8000-000000000001', + }, + control: { + kind: 'ssh_operator', + operatorPath: '/home/operator/.local/share/maka/operator', + }, + }); assert.equal(saved?.credential, 'secret-access-token'); assert.deepEqual( (setupInput as { projectDirectoryRoots?: unknown }).projectDirectoryRoots, @@ -122,6 +133,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn let completeReceived = false; let finishSetup!: (value: { serviceId: string; + deploymentId: string; rootPath: string; operatorPath: string; rootId: string; @@ -130,6 +142,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn }) => void; const setupDrain = new Promise<{ serviceId: string; + deploymentId: string; rootPath: string; operatorPath: string; rootId: string; @@ -160,6 +173,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn finishSetup({ serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', rootPath: '/home/operator/.config/Maka/workspaces/default', operatorPath: '/home/operator/.local/share/maka/operator', rootId: 'a'.repeat(64), 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 219cbded36..8af1443e71 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 @@ -69,9 +69,15 @@ const MANAGED_PROFILE = { }, }; const MANAGED_SERVICE = { - id: "c".repeat(64), - rootPath: "/srv/maka", - operatorPath: "/home/operator/.local/share/maka/operator", + deployment: { + id: "c".repeat(64), + rootPath: "/srv/maka", + deploymentId: "11111111-1111-4111-8111-111111111111", + }, + control: { + kind: "ssh_operator" as const, + operatorPath: "/home/operator/.local/share/maka/operator", + }, }; const READY_PROFILE = { id: "backup", @@ -782,8 +788,10 @@ test("does not rotate a managed credential after its profile target changes", as }; const replacementService = { ...MANAGED_SERVICE, - id: "e".repeat(64), - rootPath: "/srv/other-maka", + deployment: { + id: "e".repeat(64), + rootPath: "/srv/other-maka", + }, }; await catalog.remove(MANAGED_PROFILE.id); await catalog.create(replacementProfile, "other-token"); 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 93e52e761b..3bbc6c5649 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 @@ -118,6 +118,7 @@ test('keeps setup credentials out of the interactive terminal projection', async kind: 'complete', version: '0.1.0-beta.1', serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', operatorPath: '/home/operator/.local/share/maka/operator', rootPath: '/home/operator/.config/Maka/workspaces/default', rootId: 'a'.repeat(64), @@ -183,6 +184,7 @@ test('keeps a completed setup process owned until it exits', async () => { kind: 'complete', version: '1.2.3', serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', operatorPath: '/home/operator/.local/share/maka/operator', rootPath: '/home/operator/.config/Maka/workspaces/default', rootId: 'a'.repeat(64), @@ -417,6 +419,7 @@ test('runs an exact update package and reports progress before an active-work re serviceId: 'b'.repeat(64), rootPath: '/srv/maka', rootId: 'a'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', }, }, (phase) => phases.push(phase), @@ -425,6 +428,11 @@ test('runs an exact update package and reports progress before an active-work re const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u); assert.match(remoteCommand, /runtime-host.*service.*update/u); + assert.match(remoteCommand, /--managed-root-id.*a{64}/u); + assert.match( + remoteCommand, + /--operator-deployment-id.*00000000-0000-4000-8000-000000000001/u, + ); assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u); harness.pty.emitData('Password: '); harness.pty.emitData( @@ -635,7 +643,7 @@ test('rejects a framed service result for a different action', async () => { await harness.terminal.close(); }); -test('requires an absent operator deployment root to be absent or empty', async () => { +test('requires an absent operator deployment root to be absent', async () => { const harness = createHarness('pending'); const cleanup = harness.terminal.cleanupManagedDeployment({ destination: 'operator@example.com', @@ -649,7 +657,7 @@ test('requires an absent operator deployment root to be absent or empty', async await waitFor(() => harness.pty.hasDataListener()); const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; assert.match(remoteCommand, /if \[ ! -e/u); - assert.match(remoteCommand, /rmdir --/u); + assert.doesNotMatch(remoteCommand, /rmdir --/u); assert.match(remoteCommand, /home\/operator\/\.local\/share\/maka/u); assert.match(remoteCommand, /__cleanup-managed-deployment/u); assert.match(remoteCommand, /--expected-service-id/u); diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index a6dd2c6d25..d4e03524eb 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -56,6 +56,7 @@ export interface DesktopRuntimeHostLocalServiceTarget { readonly serviceId: string; readonly rootPath: string; readonly rootId: string; + readonly deploymentId?: string; } export interface DesktopRuntimeHostLocalSetupInput { @@ -160,6 +161,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { cleanupManagedDeployment(input: { readonly operatorPath: string; readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly finalize?: boolean; readonly signal?: AbortSignal; }): Promise; close(): Promise; @@ -293,7 +295,11 @@ export function createDesktopRuntimeHostLocalOperator(input: { await runExitProcess({ command: { executable: command.operatorPath, - args: ['__cleanup-managed-deployment', ...managedTargetArgs(command.target)], + args: [ + '__cleanup-managed-deployment', + ...(command.finalize ? ['--finalize'] : []), + ...managedTargetArgs(command.target), + ], }, label: 'Local Runtime Host deployment cleanup', environment: input.environment ?? process.env, @@ -381,6 +387,9 @@ function managedTargetArgs(target: DesktopRuntimeHostLocalServiceTarget): string target.rootPath, '--expected-root-id', target.rootId, + ...(target.deploymentId + ? ['--expected-deployment-id', target.deploymentId] + : []), ]; } diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 5a1117a980..0496147dae 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -26,7 +26,6 @@ import { consumeAccessCredentialDelivery, encodeRuntimeHostOwnerConnectionCode, } from '@maka/runtime-host/client'; -import { resolveRuntimeHostManagedServiceId } from '@maka/runtime-host/operator'; import { REMOTE_OWNER_OPERATION_GRANTS } from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, @@ -43,6 +42,8 @@ import type { DesktopRuntimeHostSetupPackage } from './runtime-host-ssh-terminal const LIFECYCLE_FILE = 'runtime-host-local-service.json'; const SERVICE_ID_PATTERN = /^[a-f0-9]{64}$/u; const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; +const DEPLOYMENT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; const ADDRESS_MAX_BYTES = 2 * 1024; const ADDRESS_MAX_COUNT = 16; const LOCAL_REMOTE_ACCESS_PRINCIPAL_ID = 'desktop-owner:local-runtime-host-sharing'; @@ -50,12 +51,12 @@ const LOCAL_REMOTE_ACCESS_PRINCIPAL_ID = 'desktop-owner:local-runtime-host-shari interface LocalServiceTarget extends DesktopRuntimeHostLocalServiceTarget { readonly schemaVersion: 1; readonly operatorPath: string; + readonly deploymentId: string; } interface LocalServiceHandoff { readonly schemaVersion: 1; readonly state: 'handoff'; - readonly serviceId: string; readonly rootPath: string; readonly rootId: string; readonly coordinationRelays: readonly string[]; @@ -201,7 +202,6 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const handoff: LocalServiceHandoff = { schemaVersion: 1, state: 'handoff', - serviceId: resolveRuntimeHostManagedServiceId(input.clientDataRoot), rootPath: input.rootPath, rootId: input.rootId, coordinationRelays: request.coordinationRelays, @@ -254,15 +254,20 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { rootPath: handoff.rootPath, principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, coordinationRelays: handoff.coordinationRelays, - expectedTarget: handoff, + expectedTarget: { + serviceId: handoff.rootId, + rootPath: handoff.rootPath, + rootId: handoff.rootId, + }, signal: closing.signal, }, () => undefined, ); if ( - complete.serviceId !== handoff.serviceId || + complete.serviceId !== handoff.rootId || complete.rootPath !== handoff.rootPath || complete.rootId !== handoff.rootId || + !DEPLOYMENT_ID_PATTERN.test(complete.deploymentId) || !complete.directPeer ) { throw new Error('Local Runtime Host setup returned an unrelated service'); @@ -274,6 +279,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { operatorPath: complete.operatorPath, rootPath: complete.rootPath, rootId: complete.rootId, + deploymentId: complete.deploymentId, }, handoff.rootPath, ); @@ -435,6 +441,12 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { target: intent, signal: closing.signal, }); + await input.operator.cleanupManagedDeployment({ + operatorPath: intent.operatorPath, + target: intent, + finalize: true, + signal: closing.signal, + }); await removeDocument(lifecyclePath); return { kind: 'uninstalled' }; }; @@ -657,6 +669,8 @@ function requireServiceTarget(value: unknown, rootPath: string): LocalServiceTar !SERVICE_ID_PATTERN.test(value.serviceId) || typeof value.rootId !== 'string' || !ROOT_ID_PATTERN.test(value.rootId) || + typeof value.deploymentId !== 'string' || + !DEPLOYMENT_ID_PATTERN.test(value.deploymentId) || value.rootPath !== rootPath || typeof value.operatorPath !== 'string' || !isAbsolute(value.operatorPath) @@ -669,6 +683,7 @@ function requireServiceTarget(value: unknown, rootPath: string): LocalServiceTar rootPath, rootId: value.rootId, operatorPath: value.operatorPath, + deploymentId: value.deploymentId, }; } @@ -687,6 +702,7 @@ function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged { operatorPath: intent.operatorPath, rootPath: intent.rootPath, rootId: intent.rootId, + deploymentId: intent.deploymentId, }; } @@ -714,15 +730,12 @@ async function readLifecycle( assertExactKeys(value, [ 'schemaVersion', 'state', - 'serviceId', 'rootPath', 'rootId', 'coordinationRelays', 'allowInterruptActiveTasks', ]); if ( - typeof value.serviceId !== 'string' || - !SERVICE_ID_PATTERN.test(value.serviceId) || typeof value.allowInterruptActiveTasks !== 'boolean' ) { throw new Error('Local Runtime Host handoff intent is invalid'); @@ -730,7 +743,6 @@ async function readLifecycle( return { schemaVersion: 1, state: 'handoff', - serviceId: value.serviceId, rootPath, rootId, coordinationRelays: requireAddresses(value.coordinationRelays), @@ -738,36 +750,28 @@ async function readLifecycle( }; } const target = requireServiceTarget(value, rootPath); + const targetKeys = [ + 'schemaVersion', + 'state', + 'serviceId', + 'operatorPath', + 'rootPath', + 'rootId', + 'deploymentId', + ]; assertExactKeys( value, value.state === 'managed' - ? [ - 'schemaVersion', - 'state', - 'serviceId', - 'operatorPath', - 'rootPath', - 'rootId', - ] + ? targetKeys : value.state === 'peerChanging' ? [ - 'schemaVersion', - 'state', - 'serviceId', - 'operatorPath', - 'rootPath', - 'rootId', + ...targetKeys, 'peerEnabled', 'coordinationRelays', 'allowInterruptActiveTasks', ] : [ - 'schemaVersion', - 'state', - 'serviceId', - 'operatorPath', - 'rootPath', - 'rootId', + ...targetKeys, 'allowInterruptActiveTasks', ], ); diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts index 33b24de6fa..2c9202543d 100644 --- a/apps/desktop/src/main/runtime-host-managed-services.ts +++ b/apps/desktop/src/main/runtime-host-managed-services.ts @@ -34,15 +34,26 @@ const DOCUMENT_MAX_BYTES = 256 * 1024; const BINDING_COUNT_MAX = 32; const PATH_MAX_BYTES = 4 * 1024; -export interface DesktopRuntimeHostManagedService { +export interface DesktopRuntimeHostDeploymentBinding { readonly id: string; readonly rootPath: string; + readonly deploymentId?: string; +} + +export interface DesktopRuntimeHostControlRoute { + readonly kind: "ssh_operator"; readonly operatorPath: string; } +export interface DesktopRuntimeHostManagedServiceTarget { + readonly deployment: DesktopRuntimeHostDeploymentBinding; + readonly control: DesktopRuntimeHostControlRoute; +} + export interface DesktopRuntimeHostManagedServiceBinding { readonly profile: RemoteRuntimeHostProfile; - readonly service: DesktopRuntimeHostManagedService; + readonly deployment: DesktopRuntimeHostDeploymentBinding; + readonly control: DesktopRuntimeHostControlRoute; readonly state: "active" | "uninstalling" | "cleanup_pending"; } @@ -55,24 +66,22 @@ export interface DesktopRuntimeHostManagedServiceStore { read(): Promise; save( profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + target: DesktopRuntimeHostManagedServiceTarget, ): Promise; removeIfCurrent( + binding: DesktopRuntimeHostManagedServiceBinding, + ): Promise; + removeForProfileIfCurrent( profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, ): Promise; - removeForProfileIfCurrent(profile: RemoteRuntimeHostProfile): Promise; markUninstallingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise; markCleanupPendingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise; removeCleanupPendingIfCurrent( - profile: RemoteRuntimeHostProfile, - service: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise; } @@ -80,6 +89,7 @@ export function createDesktopRuntimeHostManagedServiceStore( clientDataRoot: string, ): DesktopRuntimeHostManagedServiceStore { return new FileDesktopRuntimeHostManagedServiceStore( + join(clientDataRoot, "runtime-host-deployments.json"), join(clientDataRoot, "runtime-host-managed-services.json"), ); } @@ -88,7 +98,9 @@ export function findDesktopRuntimeHostManagedServiceBinding( document: DesktopRuntimeHostManagedServiceDocument, profile: RemoteRuntimeHostProfile, ): DesktopRuntimeHostManagedServiceBinding | undefined { - const binding = document.bindings.find((candidate) => candidate.profile.id === profile.id); + const binding = document.bindings.find( + (candidate) => candidate.profile.id === profile.id, + ); return binding && sameRemoteRuntimeHostProfileTarget(binding.profile, profile) ? binding : undefined; @@ -102,119 +114,135 @@ export function sameDesktopRuntimeHostManagedServiceBinding( left.state === right.state && left.profile.id === right.profile.id && sameRemoteRuntimeHostProfileTarget(left.profile, right.profile) && - sameService(left.service, right.service) + sameBindingTarget(left, right) ); } -class FileDesktopRuntimeHostManagedServiceStore - implements DesktopRuntimeHostManagedServiceStore -{ +class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostManagedServiceStore { readonly #path: string; + readonly #legacyPath: string; - constructor(path: string) { + constructor(path: string, legacyPath: string) { this.#path = path; + this.#legacyPath = legacyPath; } async read(): Promise { + return this.#exclusive(() => this.#readUnlocked()); + } + + async #readUnlocked(): Promise { let contents: string; try { contents = await readFile(this.#path, "utf8"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyDocument(); - throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { + contents = await readFile(this.#legacyPath, "utf8"); + } catch (legacyError) { + if ((legacyError as NodeJS.ErrnoException).code === "ENOENT") + return emptyDocument(); + throw legacyError; + } + const migrated = decodeLegacyDocument(JSON.parse(contents)); + await writeDocument(this.#path, migrated); + await removeLegacyDocument(this.#legacyPath); + return migrated; } if (Buffer.byteLength(contents, "utf8") > DOCUMENT_MAX_BYTES) { throw new Error("Runtime Host managed service document is too large"); } - return decodeDocument(JSON.parse(contents)); + const document = decodeDocument(JSON.parse(contents)); + await removeLegacyDocument(this.#legacyPath); + return document; } save( value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + managedTarget: DesktopRuntimeHostManagedServiceTarget, ): Promise { const profile = decodeRemoteRuntimeHostProfile(value); if (profile.transport.kind !== "ssh") { - return Promise.reject(new Error("A managed Runtime Host service requires SSH")); + return Promise.reject( + new Error("A managed Runtime Host service requires SSH"), + ); } - const service = decodeService(managedService); + const deployment = decodeDeployment(managedTarget.deployment); + const control = decodeControlRoute(managedTarget.control); return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const bindings = current.bindings.filter( (binding) => binding.profile.id !== profile.id, ); if (bindings.length >= BINDING_COUNT_MAX) { - throw new Error("Too many managed Runtime Host services are configured"); + throw new Error( + "Too many managed Runtime Host services are configured", + ); } await writeDocument(this.#path, { schemaVersion: SCHEMA_VERSION, - bindings: [...bindings, { profile, service, state: "active" }], + bindings: [ + ...bindings, + { + profile, + deployment, + control, + state: "active", + }, + ], }); }); } markUninstallingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise { return this.#setStateIfCurrent( - value, - managedService, + binding, ["active", "uninstalling"], "uninstalling", ); } markCleanupPendingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise { return this.#setStateIfCurrent( - value, - managedService, + binding, ["uninstalling", "cleanup_pending"], "cleanup_pending", ); } removeCleanupPendingIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + binding: DesktopRuntimeHostManagedServiceBinding, ): Promise { - return this.#remove( - decodeRemoteRuntimeHostProfile(value), - decodeService(managedService), - "cleanup_pending", - ); + return this.#remove(binding, "cleanup_pending"); } - removeIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, - ): Promise { - const profile = decodeRemoteRuntimeHostProfile(value); - const service = decodeService(managedService); - return this.#remove(profile, service); + removeIfCurrent(binding: DesktopRuntimeHostManagedServiceBinding): Promise { + return this.#remove(binding); } removeForProfileIfCurrent(value: RemoteRuntimeHostProfile): Promise { - return this.#remove(decodeRemoteRuntimeHostProfile(value)); + return this.#remove(undefined, undefined, decodeRemoteRuntimeHostProfile(value)); } #remove( - profile: RemoteRuntimeHostProfile, - service?: DesktopRuntimeHostManagedService, + expected?: DesktopRuntimeHostManagedServiceBinding, state?: DesktopRuntimeHostManagedServiceBinding["state"], + profileOverride?: RemoteRuntimeHostProfile, ): Promise { + const profile = expected?.profile ?? profileOverride!; return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const binding = current.bindings.find( (candidate) => candidate.profile.id === profile.id, ); if ( !binding || !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || - (service && !sameService(binding.service, service)) || + (expected && !sameBindingTarget(binding, expected)) || (state && binding.state !== state) ) { return false; @@ -230,22 +258,20 @@ class FileDesktopRuntimeHostManagedServiceStore } #setStateIfCurrent( - value: RemoteRuntimeHostProfile, - managedService: DesktopRuntimeHostManagedService, + expected: DesktopRuntimeHostManagedServiceBinding, allowedStates: readonly DesktopRuntimeHostManagedServiceBinding["state"][], state: DesktopRuntimeHostManagedServiceBinding["state"], ): Promise { - const profile = decodeRemoteRuntimeHostProfile(value); - const service = decodeService(managedService); + const profile = expected.profile; return this.#exclusive(async () => { - const current = await this.read(); + const current = await this.#readUnlocked(); const binding = current.bindings.find( (candidate) => candidate.profile.id === profile.id, ); if ( !binding || !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || - !sameService(binding.service, service) || + !sameBindingTarget(binding, expected) || !allowedStates.includes(binding.state) ) { return false; @@ -267,23 +293,31 @@ class FileDesktopRuntimeHostManagedServiceStore } } -function decodeDocument(value: unknown): DesktopRuntimeHostManagedServiceDocument { - const record = requireExactRecord(value, "Runtime Host managed service document", [ - "schemaVersion", - "bindings", - ]); - if (record.schemaVersion !== SCHEMA_VERSION || !Array.isArray(record.bindings)) { +function decodeDocument( + value: unknown, +): DesktopRuntimeHostManagedServiceDocument { + const record = requireExactRecord( + value, + "Runtime Host managed service document", + ["schemaVersion", "bindings"], + ); + if ( + record.schemaVersion !== SCHEMA_VERSION || + !Array.isArray(record.bindings) + ) { throw new Error("Runtime Host managed service document is invalid"); } if (record.bindings.length > BINDING_COUNT_MAX) { - throw new Error("Runtime Host managed service document has too many bindings"); + throw new Error( + "Runtime Host managed service document has too many bindings", + ); } const bindings = record.bindings.map((candidate) => { - const binding = requireExactRecord(candidate, "Runtime Host managed service binding", [ - "profile", - "service", - "state", - ]); + const binding = requireExactRecord( + candidate, + "Runtime Host managed service binding", + ["control", "deployment", "profile", "state"], + ); const profile = decodeRemoteRuntimeHostProfile(binding.profile); if (profile.transport.kind !== "ssh") { throw new Error("A managed Runtime Host service requires SSH"); @@ -297,24 +331,112 @@ function decodeDocument(value: unknown): DesktopRuntimeHostManagedServiceDocumen } return Object.freeze({ profile, - service: decodeService(binding.service), + deployment: decodeDeployment(binding.deployment), + control: decodeControlRoute(binding.control), state: binding.state, }); }); - if (new Set(bindings.map((binding) => binding.profile.id)).size !== bindings.length) { - throw new Error("Runtime Host managed service bindings must have unique profile IDs"); + if ( + new Set(bindings.map((binding) => binding.profile.id)).size !== + bindings.length + ) { + throw new Error( + "Runtime Host managed service bindings must have unique profile IDs", + ); + } + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + bindings: Object.freeze(bindings), + }); +} + +function decodeLegacyDocument( + value: unknown, +): DesktopRuntimeHostManagedServiceDocument { + const record = requireExactRecord( + value, + "Legacy Runtime Host managed service document", + ["schemaVersion", "bindings"], + ); + if (record.schemaVersion !== 1 || !Array.isArray(record.bindings)) { + throw new Error("Legacy Runtime Host managed service document is invalid"); + } + return decodeDocument({ + schemaVersion: SCHEMA_VERSION, + bindings: record.bindings.map((candidate) => { + const binding = requireExactRecord( + candidate, + "Legacy Runtime Host service binding", + ["profile", "service", "state"], + ); + const service = decodeLegacyService(binding.service); + return { + profile: binding.profile, + deployment: { id: service.id, rootPath: service.rootPath }, + control: { kind: "ssh_operator", operatorPath: service.operatorPath }, + state: binding.state, + }; + }), + }); +} + +function decodeDeployment(value: unknown): DesktopRuntimeHostDeploymentBinding { + const hasDeploymentId = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.hasOwn(value, "deploymentId"); + const record = requireExactRecord( + value, + "Managed Runtime Host deployment", + hasDeploymentId ? ["deploymentId", "id", "rootPath"] : ["id", "rootPath"], + ); + return Object.freeze({ + id: requireHostRootId(record.id), + rootPath: requirePath(record.rootPath, "Managed Runtime Host State Root"), + ...(record.deploymentId === undefined + ? {} + : { deploymentId: requireDeploymentId(record.deploymentId) }), + }); +} + +function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute { + const record = requireExactRecord( + value, + "Managed Runtime Host control route", + ["kind", "operatorPath"], + ); + if (record.kind !== "ssh_operator") { + throw new Error("Managed Runtime Host control route is invalid"); + } + const operatorPath = requirePath( + record.operatorPath, + "Managed Runtime Host operator path", + ); + if (!operatorPath.startsWith("/")) { + throw new Error("Managed Runtime Host operator path must be absolute"); } - return Object.freeze({ schemaVersion: SCHEMA_VERSION, bindings: Object.freeze(bindings) }); + return Object.freeze({ kind: "ssh_operator", operatorPath }); } -function decodeService(value: unknown): DesktopRuntimeHostManagedService { - const record = requireExactRecord(value, "Managed Runtime Host service", [ - "id", - "rootPath", - "operatorPath", - ]); - const rootPath = requirePath(record.rootPath, "Managed Runtime Host State Root"); - const operatorPath = requirePath(record.operatorPath, "Managed Runtime Host operator path"); +function decodeLegacyService(value: unknown): { + readonly id: string; + readonly rootPath: string; + readonly operatorPath: string; +} { + const record = requireExactRecord( + value, + "Managed Runtime Host service", + ["id", "operatorPath", "rootPath"], + ); + const rootPath = requirePath( + record.rootPath, + "Managed Runtime Host State Root", + ); + const operatorPath = requirePath( + record.operatorPath, + "Managed Runtime Host operator path", + ); if (!operatorPath.startsWith("/")) { throw new Error("Managed Runtime Host operator path must be absolute"); } @@ -325,6 +447,18 @@ function decodeService(value: unknown): DesktopRuntimeHostManagedService { }); } +function requireDeploymentId(value: unknown): string { + if ( + typeof value !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test( + value, + ) + ) { + throw new Error("Managed Runtime Host deployment identity is invalid"); + } + return value; +} + function requirePath(value: unknown, label: string): string { if ( typeof value !== "string" || @@ -348,25 +482,33 @@ function requireExactRecord( const record = value as Record; const actual = Object.keys(record).sort(); const expected = [...keys].sort(); - if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { throw new Error(`${label} has unexpected fields`); } return record; } -function sameService( - left: DesktopRuntimeHostManagedService, - right: DesktopRuntimeHostManagedService, +function sameBindingTarget( + left: DesktopRuntimeHostManagedServiceBinding, + right: DesktopRuntimeHostManagedServiceBinding, ): boolean { return ( - left.id === right.id && - left.rootPath === right.rootPath && - left.operatorPath === right.operatorPath + left.deployment.id === right.deployment.id && + left.deployment.rootPath === right.deployment.rootPath && + left.deployment.deploymentId === right.deployment.deploymentId && + left.control.kind === right.control.kind && + left.control.operatorPath === right.control.operatorPath ); } function emptyDocument(): DesktopRuntimeHostManagedServiceDocument { - return Object.freeze({ schemaVersion: SCHEMA_VERSION, bindings: Object.freeze([]) }); + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + bindings: Object.freeze([]), + }); } async function writeDocument( @@ -374,7 +516,10 @@ async function writeDocument( document: DesktopRuntimeHostManagedServiceDocument, ): Promise { const validated = decodeDocument(document); - const temporaryPath = join(dirname(path), `.runtime-host-managed-services-${randomUUID()}.tmp`); + const temporaryPath = join( + dirname(path), + `.runtime-host-deployments-${randomUUID()}.tmp`, + ); const handle = await open(temporaryPath, "wx", 0o600); try { try { @@ -389,3 +534,13 @@ async function writeDocument( await rm(temporaryPath, { force: true }); } } + +async function removeLegacyDocument(path: string): Promise { + try { + await rm(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + await syncDirectory(dirname(path)); +} diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 33bc920d7f..3b82eb0b9f 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -137,26 +137,37 @@ export function createDesktopRuntimeHostManagement(input: { managementAction: DesktopRuntimeHostManagementAction, ): Promise => { const managed = await resolveManagedService(profileId); - const { profile, service } = managed; + const { profile, deployment, control } = managed; if (profile.transport.kind !== 'ssh') { throw new Error('This Runtime Host profile is not bound to a managed service'); } if (managed.state !== 'active' && managementAction !== 'uninstall') { throw new Error('Finish uninstalling this Runtime Host service before managing it'); } + if ( + managementAction !== 'status' && + managementAction !== 'logs' && + !deployment.deploymentId && + !(managementAction === 'uninstall' && managed.state !== 'active') + ) { + throw new Error( + 'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation', + ); + } const managementInput: DesktopRuntimeHostSshManagementInput = { destination: profile.transport.destination, ...(profile.transport.sshPort === undefined ? {} : { sshPort: profile.transport.sshPort }), - operatorPath: service.operatorPath, + operatorPath: control.operatorPath, action: managementAction, expectedTarget: { - serviceId: service.id, - rootPath: service.rootPath, + serviceId: deployment.id, + rootPath: deployment.rootPath, rootId: profile.rootId, + ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}), }, ...(managementAction === 'install' ? { - rootPath: service.rootPath, + rootPath: deployment.rootPath, websocketPort: profile.transport.remotePort, websocketPath: profile.transport.websocketPath, } @@ -201,8 +212,17 @@ export function createDesktopRuntimeHostManagement(input: { operatorPath: managementInput.operatorPath, expectedTarget: managementInput.expectedTarget, }); + await input.cleanupManagedDeployment({ + destination: managementInput.destination, + ...(managementInput.sshPort === undefined + ? {} + : { sshPort: managementInput.sshPort }), + operatorPath: managementInput.operatorPath, + expectedTarget: managementInput.expectedTarget, + finalize: true, + }); await input.profiles.clearManagedServiceBinding(pending); - return { kind: 'uninstalled', retainedStateRoot: service.rootPath }; + return { kind: 'uninstalled', retainedStateRoot: deployment.rootPath }; }; const run = ( profileIdValue: unknown, @@ -246,8 +266,8 @@ export function createDesktopRuntimeHostManagement(input: { ...(managed.profile.transport.sshPort === undefined ? {} : { sshPort: managed.profile.transport.sshPort }), - operatorPath: managed.service.operatorPath, - rootPath: managed.service.rootPath, + operatorPath: managed.control.operatorPath, + rootPath: managed.deployment.rootPath, expectedRootId: managed.profile.rootId, }, }; @@ -260,14 +280,20 @@ export function createDesktopRuntimeHostManagement(input: { if (managed.state !== 'active' || transport.kind !== 'ssh') { throw new Error('This Runtime Host profile is not available for managed service changes'); } + if (!managed.deployment.deploymentId) { + throw new Error( + 'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation', + ); + } return { profileId, managed, transport, expectedTarget: { - serviceId: managed.service.id, - rootPath: managed.service.rootPath, + serviceId: managed.deployment.id, + rootPath: managed.deployment.rootPath, rootId: managed.profile.rootId, + deploymentId: managed.deployment.deploymentId, }, }; }; @@ -296,7 +322,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(target.transport.sshPort === undefined ? {} : { sshPort: target.transport.sshPort }), - operatorPath: target.managed.service.operatorPath, + operatorPath: target.managed.control.operatorPath, action: 'status', expectedTarget: target.expectedTarget, capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, @@ -337,7 +363,7 @@ export function createDesktopRuntimeHostManagement(input: { const response = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, action: 'status', expectedTarget, }); @@ -372,7 +398,7 @@ export function createDesktopRuntimeHostManagement(input: { const response = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, action: enabledValue ? 'enable' : 'disable', ...(enabledValue ? { coordinationRelays } : {}), expectedTarget, @@ -404,7 +430,7 @@ export function createDesktopRuntimeHostManagement(input: { const rollback = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, action: 'disable', expectedTarget, }); @@ -494,7 +520,7 @@ export function createDesktopRuntimeHostManagement(input: { const response = await input.runServiceManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, action: 'configure', expectedTarget, projectDirectoryRoots: roots, @@ -564,7 +590,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, expectedTarget, }; if (policy && policy.kind !== 'manual') { @@ -596,7 +622,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.service.operatorPath, + operatorPath: managed.control.operatorPath, expectedTarget, }, (phase) => input.sendProgress({ profileId, phase }), diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index 6a97f2684a..a3518fbe85 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -52,6 +52,7 @@ export function createDesktopRuntimeHostOnboarding(input: { readonly rootId: string; readonly rootPath: string; readonly serviceId: string; + readonly deploymentId: string; readonly operatorPath: string; readonly endpoint: string; readonly credential: string; @@ -169,15 +170,17 @@ export function createDesktopRuntimeHostOnboarding(input: { }, }, credential: complete.credential, - ...(lifecycle === 'supervised' - ? { - managedService: { - id: complete.serviceId, - rootPath: complete.rootPath, - operatorPath: complete.operatorPath, - }, - } - : {}), + managedService: { + deployment: { + id: complete.serviceId, + rootPath: complete.rootPath, + deploymentId: complete.deploymentId, + }, + control: { + kind: 'ssh_operator', + operatorPath: complete.operatorPath, + }, + }, }); return publish({ kind: 'complete', diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 0d6fe86db7..e40ee34917 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -61,7 +61,7 @@ import { createDesktopRuntimeHostManagedServiceStore, findDesktopRuntimeHostManagedServiceBinding, sameDesktopRuntimeHostManagedServiceBinding, - type DesktopRuntimeHostManagedService, + type DesktopRuntimeHostManagedServiceTarget, type DesktopRuntimeHostManagedServiceBinding, type DesktopRuntimeHostManagedServiceStore, } from "./runtime-host-managed-services.js"; @@ -93,7 +93,7 @@ export interface DesktopRuntimeHostProfileService { addAndEnableVerified( input: DesktopRuntimeHostProfileAddInput & { readonly credential: string; - readonly managedService?: DesktopRuntimeHostManagedService; + readonly managedService?: DesktopRuntimeHostManagedServiceTarget; }, ): Promise<{ readonly profileId: string }>; importConnectionCode(code: string): Promise; @@ -659,7 +659,7 @@ export function createDesktopRuntimeHostProfileService(input: { const addAndEnableVerified = ( value: DesktopRuntimeHostProfileAddInput & { readonly credential: string; - readonly managedService?: DesktopRuntimeHostManagedService; + readonly managedService?: DesktopRuntimeHostManagedServiceTarget; }, ): Promise<{ readonly profileId: string }> => { requireSaveInput(value); @@ -929,6 +929,14 @@ export function createDesktopRuntimeHostProfileService(input: { }, markManagedServiceUninstalling(expected) { return mutateProfiles(async () => { + if ( + !expected.deployment.deploymentId && + expected.state !== 'uninstalling' + ) { + throw new Error( + 'Re-onboard this Runtime Host before uninstalling it; its legacy binding has no deployment generation', + ); + } assertPairingComplete(expected.profile.id); const document = await catalog.read(); const current = document.profiles.find( @@ -948,10 +956,7 @@ export function createDesktopRuntimeHostProfileService(input: { throw new Error('Disable and remove the Direct peer profile before uninstalling this service'); } if ( - !(await managedServices.markUninstallingIfCurrent( - expected.profile, - expected.service, - )) + !(await managedServices.markUninstallingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -967,10 +972,7 @@ export function createDesktopRuntimeHostProfileService(input: { if ( !current || !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || - !(await managedServices.markCleanupPendingIfCurrent( - expected.profile, - expected.service, - )) + !(await managedServices.markCleanupPendingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -986,10 +988,7 @@ export function createDesktopRuntimeHostProfileService(input: { if ( !current || !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || - !(await managedServices.removeCleanupPendingIfCurrent( - expected.profile, - expected.service, - )) + !(await managedServices.removeCleanupPendingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -1127,7 +1126,7 @@ export function createDesktopRuntimeHostProfileService(input: { await catalog.remove(profileId); if (managedBinding) { await managedServices - .removeIfCurrent(profile, managedBinding.service) + .removeIfCurrent(managedBinding) .catch((error) => console.error("[runtime-host] removed Profile left stale service metadata:", error), ); diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index d0a7e6ab46..1b2d82e21d 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -117,6 +117,7 @@ export interface DesktopRuntimeHostSshManagementInput { readonly serviceId: string; readonly rootPath: string; readonly rootId: string; + readonly deploymentId?: string; }; readonly rootPath?: string; readonly websocketPort?: number; @@ -170,6 +171,7 @@ export interface DesktopRuntimeHostSshCleanupInput { readonly sshPort?: number; readonly operatorPath: string; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly finalize?: boolean; readonly signal?: AbortSignal; } @@ -1112,6 +1114,10 @@ function runtimeHostUpdateRemoteCommand( setupPackage: PreparedSetupPackage, input: DesktopRuntimeHostSshUpdateInput, ): string { + const deploymentId = input.expectedTarget.deploymentId; + if (!deploymentId) { + throw new Error('Runtime Host update requires a deployment generation'); + } return runtimeHostPackageRemoteCommand( setupPackage, [ @@ -1119,6 +1125,10 @@ function runtimeHostUpdateRemoteCommand( 'service', 'update', '--framed', + '--managed-root-id', + input.expectedTarget.rootId, + '--operator-deployment-id', + deploymentId, ...managedServiceTargetArgs(input.expectedTarget), ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ], @@ -1219,12 +1229,13 @@ function runtimeHostManagedDeploymentCleanupRemoteCommand( const cleanup = [ input.operatorPath, '__cleanup-managed-deployment', + ...(input.finalize ? ['--finalize'] : []), ...managedServiceTargetArgs(input.expectedTarget), ].map(quotePosix).join(' '); const invocation = `if [ ! -e ${operator} ]; then ` + `if [ ! -e ${deploymentRoot} ]; then exit 0; fi; ` + - `exec rmdir -- ${deploymentRoot}; fi; ` + + `exit 1; fi; ` + `exec ${cleanup}`; return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; } @@ -1233,11 +1244,13 @@ function managedServiceTargetArgs(input: { readonly serviceId: string; readonly rootPath: string; readonly rootId: string; + readonly deploymentId?: string; }): string[] { return [ '--expected-service-id', input.serviceId, '--expected-root-path', input.rootPath, '--expected-root-id', input.rootId, + ...(input.deploymentId ? ['--expected-deployment-id', input.deploymentId] : []), ]; } 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 95585c395e..951d391bea 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -533,6 +533,7 @@ export function RuntimeHostManagementDialog(props: { const uninstalled = uninstalledRoot !== undefined; const serviceInstalled = service !== undefined && service.state !== 'not_installed'; const serviceActive = service?.state === 'running'; + const supervised = service?.lifecycle?.mode === 'supervised'; const savedPolicyChoice = updatePolicy ? updatePolicyChoiceOf(updatePolicy) : undefined; const updatePolicyDirty = savedPolicyChoice !== updatePolicyChoice || (updatePolicyChoice === 'fixed' && @@ -1157,9 +1158,6 @@ export function RuntimeHostManagementDialog(props: { size="sm" isDisabled={loading} items={[ - ...(profile.transport.kind === 'ssh' - ? [{ label: copy.repairService, onClick: () => void run('install') }] - : []), ...(serviceInstalled && result?.accessManagementAvailable ? [{ label: copy.manageAccess, onClick: () => void loadAccess() }] : []), @@ -1184,14 +1182,14 @@ export function RuntimeHostManagementDialog(props: { isDisabled={loading} onClick={() => void run('status')} /> - {serviceInstalled && serviceActive ? ( + {serviceInstalled && supervised && serviceActive ? (