diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index fb700c51ee..5ffd068e0f 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -75,6 +75,7 @@ on: - 'packages/runtime-host/src/__tests__/skill-catalog-two-client-uds.test.ts' - 'packages/runtime-host/src/client/client-instance-identity.ts' - 'packages/runtime-host/src/client/host-profile.ts' + - 'packages/runtime-host/src/client/ssh-operator-activation.ts' - 'packages/runtime-host/src/client/ssh-tunnel.ts' - 'packages/runtime-host/src/client/wsl-control.ts' - 'packages/runtime-host/src/control/access-credential-delivery.ts' @@ -83,6 +84,7 @@ on: - 'packages/runtime-host/src/control/startup-diagnostic.ts' - 'packages/runtime-host/src/operator/local-deployment-owner.ts' - 'packages/runtime-host/src/operator/managed-deployment.ts' + - 'packages/runtime-host/src/operator/operator-command.ts' - 'packages/runtime-host/src/peer-mesh/store.ts' - 'packages/runtime-host/src/peer-reachability/owner.ts' - 'packages/runtime-host/src/peer-reachability/publisher.ts' diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts index 89ef5a27bc..7663076a0e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -35,6 +35,13 @@ import { runtimeHostLocalSetupCommand, } from '../runtime-host-local-operator.js'; +const OPERATOR = { + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath: '/tmp/maka/operator.mjs', +}; + test('local setup installs one managed service for the Desktop root with Direct peer enabled', () => { assert.deepEqual( runtimeHostLocalSetupCommand({ @@ -90,7 +97,7 @@ test('local setup forwards the exact development archive evidence', async (t) => version: '0.2.0-development', serviceId: 'b'.repeat(64), deploymentId: '00000000-0000-4000-8000-000000000001', - operatorPath: '/tmp/maka/operator', + operator: OPERATOR, rootPath: '/tmp/maka/root', rootId: 'a'.repeat(64), endpoint: 'ws://127.0.0.1:7443/runtime-host', @@ -308,7 +315,7 @@ test('local Peer Mesh join keeps invitations off argv and accepts bounded large const invitation = JSON.stringify({ secret: 'one-time-mesh-secret' }); const result = await operator.runPeerMesh({ - operatorPath: '/tmp/maka/operator', + operator: OPERATOR, action: 'join', target: { serviceId: 'b'.repeat(64), @@ -320,6 +327,7 @@ test('local Peer Mesh join keeps invitations off argv and accepts bounded large }); assert.deepEqual(args, [ + OPERATOR.modulePath, 'mesh', 'join', '--framed', '--expected-service-id', 'b'.repeat(64), '--expected-root-path', '/tmp/maka/root', 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 5f48a4c904..faa04a1a05 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 @@ -35,6 +35,13 @@ 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'; +const testOperator = (modulePath: string) => ({ + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath, +}); + test('enabling remote access hands the same root to one managed service before Desktop resumes', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-')); t.after(() => rm(base, { recursive: true, force: true })); @@ -92,7 +99,7 @@ test('enabling remote access hands the same root to one managed service before D }); return { serviceId: 'a'.repeat(64), - operatorPath: join(base, 'operator'), + operator: testOperator(join(base, 'operator.mjs')), rootPath, rootId: 'a'.repeat(64), deploymentId, @@ -398,11 +405,11 @@ test('replaces a conflicting supervised Host with the requested active-work poli kind: 'active', lifecycleMode: 'supervised', target: { - schemaVersion: 1, + schemaVersion: 2, serviceId: rootId, rootPath, rootId, - operatorPath: join(base, 'operator'), + operator: testOperator(join(base, 'operator.mjs')), deploymentId: RECOVERY_DEPLOYMENT_ID, }, }), @@ -506,122 +513,121 @@ test('does not persist recoverable setup authority before Desktop ownership comm assert.equal(setupCalls, 0); }); -test('adopts committed managed authority for every pending receipt without replaying setup', async (t) => { +test('adopts committed managed authority from a released handoff without replaying setup', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-prestart-')); t.after(() => rm(base, { recursive: true, force: true })); - for (const state of ['handoff', 'setupPending'] as const) { - const clientDataRoot = join(base, state); - const rootPath = join(clientDataRoot, 'workspaces', 'default'); - const rootId = 'a'.repeat(64); - const deploymentId = '22222222-2222-4222-8222-222222222222'; - const operatorPath = join(base, 'installed', 'operator'); - await mkdir(rootPath, { recursive: true }); - await writeFile( - join(clientDataRoot, 'runtime-host-local-service.json'), - `${JSON.stringify({ - schemaVersion: 1, - state, - rootPath, - rootId, - coordinationRelays: [], - allowInterruptActiveTasks: true, - })}\n`, - ); - const service = createDesktopLocalRuntimeHostRemoteAccess({ - ipcMain: { handle() {}, removeHandler() {} }, - clientDataRoot, + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + const deploymentId = '22222222-2222-4222-8222-222222222222'; + const installedOperator = testOperator(join(base, 'installed', 'operator.mjs')); + await mkdir(rootPath, { recursive: true }); + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state: 'handoff', rootPath, rootId, - directPeerAvailable: false, - manager: () => assert.fail('pre-start reconciliation must not require the Local manager'), - resolveManagedDeploymentAuthority: async () => ({ - kind: 'active', - lifecycleMode: 'supervised', - target: { - schemaVersion: 1, - serviceId: rootId, - operatorPath, - rootPath, - rootId, - deploymentId, - }, - }), - resolveSetupPackage: async () => - assert.fail('committed authority must not resolve a package'), - operator: { - async runSetup() { - assert.fail('committed authority must not replay setup'); - }, - async close() {}, - } as unknown as ReturnType, - }); - t.after(() => service.close()); - - assert.equal(await service.recoverBeforeLocalHostStart(), true); - assert.deepEqual( - JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')), - { - schemaVersion: 1, - state: 'managed', + coordinationRelays: [], + allowInterruptActiveTasks: true, + })}\n`, + ); + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: false, + manager: () => assert.fail('pre-start reconciliation must not require the Local manager'), + resolveManagedDeploymentAuthority: async () => ({ + kind: 'active', + lifecycleMode: 'supervised', + target: { + schemaVersion: 2, serviceId: rootId, - operatorPath, + operator: installedOperator, rootPath, rootId, deploymentId, }, - ); - } + }), + resolveSetupPackage: async () => + assert.fail('committed authority must not resolve a package'), + operator: { + async runSetup() { + assert.fail('committed authority must not replay setup'); + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + assert.equal(await service.recoverBeforeLocalHostStart(), true); + assert.deepEqual( + JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')), + { + schemaVersion: 2, + state: 'managed', + serviceId: rootId, + operator: installedOperator, + rootPath, + rootId, + deploymentId, + }, + ); }); -test('discards a legacy handoff that belongs to an externally managed Host', async (t) => { - const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-legacy-external-')); +test('migrates a released managed receipt before exposing it to lifecycle operations', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-migration-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); const rootPath = join(clientDataRoot, 'workspaces', 'default'); const rootId = 'a'.repeat(64); + const operatorPath = join(base, 'installed', 'operator'); const lifecyclePath = join(clientDataRoot, 'runtime-host-local-service.json'); await mkdir(rootPath, { recursive: true }); await writeFile( lifecyclePath, `${JSON.stringify({ schemaVersion: 1, - state: 'handoff', + state: 'managed', + serviceId: rootId, + operatorPath, rootPath, rootId, - coordinationRelays: [], - allowInterruptActiveTasks: false, + deploymentId: RECOVERY_DEPLOYMENT_ID, })}\n`, ); - let setupCalls = 0; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, clientDataRoot, rootPath, rootId, directPeerAvailable: true, - manager: () => - ({ - async retireOwnedLocalHost() { - return { kind: 'not_owned' as const }; - }, - }) as unknown as RuntimeHostDesktopManager, - resolveManagedDeploymentAuthority: async () => undefined, + manager: () => undefined, resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), operator: { - async runSetup() { - setupCalls += 1; - throw new Error('setup must not replace an externally managed Host'); - }, async close() {}, } as unknown as ReturnType, }); t.after(() => service.close()); - assert.equal(await service.recoverBeforeLocalHostStart(), false); - await service.recover(); - - assert.equal(setupCalls, 0); - await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' }); + const target = await service.inspectManaged(async (managed) => managed); + const expected = { + schemaVersion: 2, + state: 'managed', + serviceId: rootId, + operator: { + kind: 'legacy_posix_executable', + executablePath: operatorPath, + }, + rootPath, + rootId, + deploymentId: RECOVERY_DEPLOYMENT_ID, + }; + assert.deepEqual(target, expected); + assert.deepEqual(JSON.parse(await readFile(lifecyclePath, 'utf8')), expected); }); test('interrupted Local Host setup converges to its exact managed service', async (t) => { @@ -634,7 +640,7 @@ test('interrupted Local Host setup converges to its exact managed service', asyn await writeFile( join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, state: 'setupPending', rootPath, rootId, @@ -668,7 +674,7 @@ test('interrupted Local Host setup converges to its exact managed service', asyn setupCalls += 1; return { serviceId: rootId, - operatorPath: join(base, 'operator'), + operator: testOperator(join(base, 'operator.mjs')), rootPath, rootId, deploymentId: '22222222-2222-4222-8222-222222222222', @@ -708,10 +714,10 @@ test('startup replays the persisted peer intent instead of gating recovery on st await writeFile( join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, state: 'peerChanging', serviceId: 'b'.repeat(64), - operatorPath: join(clientDataRoot, 'operator'), + operator: testOperator(join(clientDataRoot, 'operator.mjs')), rootPath, rootId, deploymentId: RECOVERY_DEPLOYMENT_ID, @@ -835,10 +841,10 @@ test('pre-start recovery cleans a committed uninstall before an ephemeral Host c await writeFile( join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, state: 'uninstalling', serviceId: 'b'.repeat(64), - operatorPath: join(base, 'operator'), + operator: testOperator(join(base, 'operator.mjs')), rootPath, rootId, deploymentId: RECOVERY_DEPLOYMENT_ID, @@ -897,10 +903,10 @@ test('pre-start recovery settles a canonical uninstall transition through its ex await writeFile( join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, state: 'uninstalling', serviceId: 'b'.repeat(64), - operatorPath: join(base, 'operator'), + operator: testOperator(join(base, 'operator.mjs')), rootPath, rootId, deploymentId: RECOVERY_DEPLOYMENT_ID, @@ -955,10 +961,10 @@ async function writeManagedLifecycle( await writeFile( join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, state: 'managed', serviceId: 'b'.repeat(64), - operatorPath: join(clientDataRoot, 'operator'), + operator: testOperator(join(clientDataRoot, 'operator.mjs')), rootPath, rootId, deploymentId: RECOVERY_DEPLOYMENT_ID, 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 e6f0045d0c..91003ebb3d 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 @@ -28,10 +28,6 @@ import { findDesktopRuntimeHostManagedServiceBinding, isDesktopRuntimeHostManagedSshServiceBinding, } from "../runtime-host-managed-services.js"; -import { - createDesktopRuntimeHostProfileService, - resolveDesktopRuntimeHostStartup, -} from "../runtime-host-profile-service.js"; const roots: string[] = []; const profile = { @@ -49,12 +45,17 @@ const profile = { const service = { id: "b".repeat(64), rootPath: "/srv/maka", - operatorPath: "/home/operator/.local/share/maka/operator", +}; +const operator = { + kind: "node" as const, + platform: "posix" as const, + nodePath: "/usr/bin/node", + modulePath: "/home/operator/.local/share/maka/operator.mjs", }; 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 }, + control: { kind: "ssh_operator" as const, operator }, }; afterEach(async () => { @@ -63,51 +64,65 @@ afterEach(async () => { ); }); +test("migrates released WSL deployment bindings to the legacy operator route", async () => { + const root = await mkdtemp(join(tmpdir(), "maka-managed-wsl-migration-")); + roots.push(root); + const path = join(root, "runtime-host-deployments.json"); + await writeFile( + path, + `${JSON.stringify({ + schemaVersion: 1, + bindings: [{ + profile: { + id: "ubuntu", + name: "Ubuntu", + kind: "environment", + provider: { kind: "wsl", distribution: "Ubuntu-24.04" }, + rootId: "a".repeat(64), + operatorPath: "/home/operator/.local/share/maka/operator", + }, + deployment: { + id: "a".repeat(64), + rootPath: "/home/operator/.config/Maka/workspaces/default", + deploymentId, + }, + state: "active", + }], + })}\n`, + ); + + const document = await createDesktopRuntimeHostManagedServiceStore(root).read(); + const binding = document.bindings[0]; + assert.equal(binding?.profile.kind, "environment"); + assert.deepEqual(binding?.profile.kind === "environment" ? binding.profile.operator : null, { + kind: "legacy_posix_executable", + executablePath: "/home/operator/.local/share/maka/operator", + }); + const stored = await readFile(path, "utf8"); + assert.match(stored, /"schemaVersion": 2/u); + assert.doesNotMatch(stored, /operatorPath/u); + + const currentProfile = { + id: "ubuntu", + name: "Ubuntu", + kind: "environment" as const, + provider: { kind: "wsl" as const, distribution: "Ubuntu-24.04" }, + rootId: "a".repeat(64), + operator, + }; + const resolved = findDesktopRuntimeHostManagedServiceBinding(document, currentProfile); + assert.equal(resolved?.profile.kind, "environment"); + assert.equal(resolved?.profile.kind === "environment" ? resolved.profile.operator : null, operator); +}); + 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, deployedService), concurrentStore.save( @@ -135,7 +150,7 @@ test("keeps Desktop service bindings outside the shared profile catalog", async { profile: { ...profile, transport: { ...profile.transport } }, deployment: { id: service.id, rootPath: service.rootPath, deploymentId }, - control: { kind: "ssh_operator", operatorPath: service.operatorPath }, + control: { kind: "ssh_operator", operator }, state: "active", }, ); @@ -199,7 +214,7 @@ test("persists a WSL deployment through its environment control route", async () kind: "environment" as const, provider: { kind: "wsl" as const, distribution: "Ubuntu-24.04" }, rootId: "a".repeat(64), - operatorPath: "/home/operator/.local/share/maka/operator", + operator, }; await store.save(environment, { deployment: { 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 572dfe59da..be45c0213a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -43,6 +43,12 @@ import type { import type { DesktopRuntimeHostWslManagementInput } from '../runtime-host-wsl-controller.js'; const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111'; +const OPERATOR = { + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath: '/home/operator/.local/share/maka/operator.mjs', +}; test('cancels a live Runtime Host Mesh status query', async () => { const handlers = new Map unknown>(); @@ -194,7 +200,7 @@ test('routes WSL status and directory configuration through the persisted operat kind: 'environment' as const, provider: { kind: 'wsl' as const, distribution: 'Ubuntu-24.04' }, rootId: 'a'.repeat(64), - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; const binding = { profile, @@ -247,16 +253,16 @@ test('routes WSL status and directory configuration through the persisted operat false, ); - assert.deepEqual(calls.map(({ action, distribution, operatorPath, expectedTarget }) => ({ + assert.deepEqual(calls.map(({ action, distribution, operator, expectedTarget }) => ({ action, distribution, - operatorPath, + operator, expectedTarget, })), [ { action: 'status', distribution: 'Ubuntu-24.04', - operatorPath: profile.operatorPath, + operator: profile.operator, expectedTarget: { serviceId: 'a'.repeat(64), rootPath: '/home/operator/.config/Maka/workspaces/default', @@ -267,7 +273,7 @@ test('routes WSL status and directory configuration through the persisted operat { action: 'configure', distribution: 'Ubuntu-24.04', - operatorPath: profile.operatorPath, + operator: profile.operator, expectedTarget: { serviceId: 'a'.repeat(64), rootPath: '/home/operator/.config/Maka/workspaces/default', @@ -296,7 +302,7 @@ test('identifies, rotates, and revokes managed credentials without exposing secr const service = { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; const principalId = 'desktop:original-installation'; const replacement = 'maka_rh_replacement-secret'; @@ -349,7 +355,7 @@ test('identifies, rotates, and revokes managed credentials without exposing secr }); assert.deepEqual(expected.control, { kind: 'ssh_operator', - operatorPath: service.operatorPath, + operator: service.operator, }); assert.equal(expected.credentialFingerprint, currentFingerprint); assert.equal(credential, replacement); @@ -515,7 +521,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = const managedService = { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; const management = createDesktopRuntimeHostManagement({ ...unusedUpdateDependencies(), @@ -592,11 +598,11 @@ test('manages only the service identity bound by Desktop onboarding', async () = const managementInput = managementInputs.at(-1); assert.deepEqual(managementInput && { destination: managementInput.destination, - operatorPath: managementInput.operatorPath, + operator: managementInput.operator, expectedTarget: managementInput.expectedTarget, }, { destination: 'operator@example.com', - operatorPath: managedService.operatorPath, + operator: managedService.operator, expectedTarget: { serviceId: managedService.id, rootPath: managedService.rootPath, @@ -632,7 +638,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = assert.deepEqual(cleanupInputs, [ { destination: managedProfile.transport.destination, - operatorPath: managedService.operatorPath, + operator: managedService.operator, expectedTarget: { serviceId: managedService.id, rootPath: managedService.rootPath, @@ -642,7 +648,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = }, { destination: managedProfile.transport.destination, - operatorPath: managedService.operatorPath, + operator: managedService.operator, expectedTarget: { serviceId: managedService.id, rootPath: managedService.rootPath, @@ -679,7 +685,7 @@ test('publishes update progress and waits for the managed profile to reconnect', const service = { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; createDesktopRuntimeHostManagement({ ipcMain: { @@ -726,7 +732,7 @@ test('publishes update progress and waits for the managed profile to reconnect', runUpdateReconciliation: async () => assert.fail('update reconciliation is not expected'), setupPackageMode: 'published', - resolveSshDevelopmentPeerTarget: async () => + resolveSshNodeIdentity: async () => assert.fail('published update must not inspect the development target'), resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }), currentHostEpoch: () => 'host-before-update', @@ -746,6 +752,7 @@ test('publishes update progress and waits for the managed profile to reconnect', assert.deepEqual(updates, [{ destination: profile.transport.destination, setupPackage: { kind: 'npm', specifier: 'maka-agent@1.3.0' }, + operator: service.operator, expectedTarget: { serviceId: service.id, rootPath: service.rootPath, @@ -802,7 +809,7 @@ test('configures Project roots with CAS and reconnects only after a committed cu const service = { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; const fingerprint = `sha256:${'c'.repeat(64)}`; const inputs: DesktopRuntimeHostSshManagementInput[] = []; @@ -914,7 +921,7 @@ test('manages one Host update policy and reconciles it through the bound operato const service = { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; createDesktopRuntimeHostManagement({ ...unusedUpdateDependencies(), @@ -1031,7 +1038,7 @@ test('manages one Host update policy and reconciles it through the bound operato ); assert.deepEqual(reconciliationInputs, [{ destination: profile.transport.destination, - operatorPath: service.operatorPath, + operator: service.operator, expectedTarget: { serviceId: service.id, rootPath: service.rootPath, @@ -1060,7 +1067,7 @@ test('retries acknowledged deployment cleanup without repeating uninstall', asyn const service = { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; const calls: DesktopRuntimeHostSshManagementInput[] = []; let clearAttempts = 0; @@ -1143,7 +1150,7 @@ test('rechecks uninstall intent before retrying the remote service', async () => { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }, 'uninstalling', ); @@ -1201,7 +1208,7 @@ test('keeps the SSH profile while adding and removing its managed Direct peer', const service = { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; let peerProfileExists = false; const actions: string[] = []; @@ -1415,7 +1422,7 @@ function managedSshBinding() { { id: 'b'.repeat(64), rootPath: '/srv/maka', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }, 'active', ); @@ -1423,13 +1430,13 @@ function managedSshBinding() { function managedBinding< Profile, - Service extends { readonly id: string; readonly rootPath: string; readonly operatorPath: string }, + Service extends { readonly id: string; readonly rootPath: string; readonly operator: typeof OPERATOR }, 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 }, + control: { kind: 'ssh_operator' as const, operator: service.operator }, state, }; } @@ -1491,7 +1498,7 @@ function unusedUpdateDependencies() { assert.fail('direct peer management is not expected'), directPeerClientAvailable: false, setupPackageMode: 'published' as const, - resolveSshDevelopmentPeerTarget: async (): Promise => + resolveSshNodeIdentity: async (): Promise => assert.fail('published update must not inspect the development target'), resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const), currentHostEpoch: () => undefined, 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 8d2b3302c0..7f21b797eb 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -27,6 +27,13 @@ import type { } from '../runtime-host-managed-services.js'; import { createDesktopRuntimeHostOnboarding } from '../runtime-host-onboarding.js'; +const OPERATOR = { + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath: '/home/operator/.local/share/maka/operator.mjs', +}; + test('persists a verified on-demand SSH profile without endpoint or credential projection', async () => { let setupInput: unknown; let saved: @@ -48,7 +55,7 @@ test('persists a verified on-demand SSH profile without endpoint or credential p serviceId: 'b'.repeat(64), deploymentId: '00000000-0000-4000-8000-000000000001', rootPath: '/home/operator/.config/Maka/workspaces/default', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, rootId: 'a'.repeat(64), endpoint: 'ws://127.0.0.1:7443/runtime-host', credential: 'secret-access-token', @@ -71,7 +78,7 @@ test('persists a verified on-demand SSH profile without endpoint or credential p destination: 'operator@example.com', activation: { kind: 'ssh_operator', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }, }); assert.deepEqual(saved?.managedService, { @@ -82,7 +89,7 @@ test('persists a verified on-demand SSH profile without endpoint or credential p }, control: { kind: 'ssh_operator', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }, }); assert.equal(saved?.credential, 'secret-access-token'); @@ -91,6 +98,7 @@ test('persists a verified on-demand SSH profile without endpoint or credential p [{ label: 'Work', path: '/srv/work' }], ); assert.equal((setupInput as { lifecycle?: unknown }).lifecycle, 'on_demand'); + assert.equal((setupInput as { remotePlatform?: unknown }).remotePlatform, 'posix'); assert.doesNotMatch(JSON.stringify(harness.events), /secret-access-token/u); await harness.onboarding.close(); assert.equal(harness.handlers.size, 0); @@ -120,7 +128,7 @@ test('onboards WSL as a credential-free environment profile', async () => { deploymentId: '00000000-0000-4000-8000-000000000001', rootPath: '/home/operator/.config/Maka/workspaces/default', rootId: 'a'.repeat(64), - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }; }, resolveSetupPackage: (peerTarget) => { @@ -141,7 +149,7 @@ test('onboards WSL as a credential-free environment profile', async () => { kind: 'environment', provider: { kind: 'wsl', distribution: 'Ubuntu-24.04' }, rootId: 'a'.repeat(64), - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }); assert.deepEqual(saved?.managedService, { deployment: { @@ -222,7 +230,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn serviceId: string; deploymentId: string; rootPath: string; - operatorPath: string; + operator: typeof OPERATOR; rootId: string; endpoint: string; credential: string; @@ -231,7 +239,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn serviceId: string; deploymentId: string; rootPath: string; - operatorPath: string; + operator: typeof OPERATOR; rootId: string; endpoint: string; credential: string; @@ -263,7 +271,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn serviceId: 'b'.repeat(64), deploymentId: '00000000-0000-4000-8000-000000000001', rootPath: '/home/operator/.config/Maka/workspaces/default', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, rootId: 'a'.repeat(64), endpoint: 'ws://127.0.0.1:7443/runtime-host', credential: 'candidate-token', @@ -271,7 +279,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn while (!pairingStarted) await Promise.resolve(); finishPairing({ profileId: 'office' }); - assert.deepEqual(await setup, { kind: 'complete', profileId: 'office', revision: 4 }); + assert.deepEqual(await setup, { kind: 'complete', profileId: 'office', revision: 6 }); await harness.onboarding.close(); }); @@ -297,7 +305,7 @@ test('resolves the setup package only when onboarding starts', async () => { { kind: 'failed', message: 'Desktop does not declare an exact Runtime Host setup package', - revision: 2, + revision: 4, }, ); assert.equal(resolutions, 1); @@ -321,8 +329,7 @@ function createHarness(overrides: HarnessOverrides = {}) { ...profiles, }, setupPackageMode: 'published', - resolveSshDevelopmentPeerTarget: async () => - assert.fail('published setup must not inspect the development target'), + resolveSshNodeIdentity: async () => ({ platform: 'darwin', architecture: 'x64' }), resolveSetupPackage: () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), runSetup: async () => assert.fail('SSH must not start'), runWslSetup: async () => assert.fail('WSL must not start'), 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 cd71890006..14aed85c9f 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 @@ -45,6 +45,7 @@ import { createDesktopRuntimeHostManagedServiceStore, findDesktopRuntimeHostManagedServiceBinding, isDesktopRuntimeHostManagedSshServiceBinding, + type DesktopRuntimeHostManagedServiceStore, } from "../runtime-host-managed-services.js"; import { createDesktopRuntimeHostPairingIntent, @@ -56,6 +57,12 @@ import { } from "../runtime-host-profile-service.js"; const ROOT_ID = "a".repeat(64); +const OPERATOR = { + kind: "node" as const, + platform: "posix" as const, + nodePath: "/usr/bin/node", + modulePath: "/home/operator/.local/share/Maka/runtime-host-services/operator.mjs", +}; const PROFILE = { id: "office", name: "Office", @@ -81,7 +88,7 @@ const MANAGED_SERVICE = { }, control: { kind: "ssh_operator" as const, - operatorPath: "/home/operator/.local/share/maka/operator", + operator: OPERATOR, }, }; const READY_PROFILE = { @@ -289,7 +296,10 @@ test("reuses the existing WSL profile when the same managed Host is added again" kind: "environment" as const, provider: { kind: "wsl" as const, distribution: "Ubuntu-24.04" }, rootId: ROOT_ID, - operatorPath: "/home/operator/.local/share/Maka/runtime-host-services/operator", + operator: { + kind: "legacy_posix_executable" as const, + executablePath: "/home/operator/.local/share/Maka/runtime-host-services/operator", + }, }; await catalog.create(existing); const enabled: string[] = []; @@ -315,22 +325,65 @@ test("reuses the existing WSL profile when the same managed Host is added again" }; const result = await service.addManagedEnvironmentAndEnable({ - profile: { ...existing, id: "replacement", name: "Replacement" }, + profile: { ...existing, id: "replacement", name: "Replacement", operator: OPERATOR }, managedService, }); + const upgraded = { ...existing, operator: OPERATOR }; assert.equal(result.profileId, existing.id); - assert.deepEqual((await catalog.read()).profiles, [existing]); + assert.deepEqual((await catalog.read()).profiles, [upgraded]); assert.deepEqual(enabled, [existing.id]); assert.deepEqual( findDesktopRuntimeHostManagedServiceBinding( await managedServices.read(), - existing, + upgraded, ), - { profile: existing, ...managedService, state: "active" }, + { profile: upgraded, ...managedService, state: "active" }, ); }); +test("rolls back a new WSL profile when its managed binding cannot be saved", async () => { + const root = await clientRoot(); + const catalog = createClientRuntimeHostProfileCatalog(root); + const service = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup: await resolveDesktopRuntimeHostStartup(root, { catalog }), + catalog, + managedServices: { + async save() { + throw new Error("binding rejected"); + }, + } as unknown as DesktopRuntimeHostManagedServiceStore, + states: () => [connectingLocal()], + enable: async () => assert.fail("an unbound profile must not be enabled"), + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + + await assert.rejects( + service.addManagedEnvironmentAndEnable({ + profile: { + id: "ubuntu", + name: "Ubuntu", + kind: "environment", + provider: { kind: "wsl", distribution: "Ubuntu-24.04" }, + rootId: ROOT_ID, + operator: OPERATOR, + }, + managedService: { + deployment: { + id: ROOT_ID, + rootPath: "/home/operator/.config/Maka/workspaces/default", + deploymentId: "11111111-1111-4111-8111-111111111111", + }, + }, + }), + /binding rejected/u, + ); + assert.deepEqual((await catalog.read()).profiles, []); +}); + test("reconnects an enabled remote Host with interactive SSH", async () => { const root = await clientRoot(); const catalog = createClientRuntimeHostProfileCatalog(root); @@ -1466,6 +1519,52 @@ test("keeps existing Hosts available while corrupt pairing recovery awaits resol assert.equal((await service.setEnabled(PROFILE.id, false)).entries[1]?.enabled, false); }); +test("migrates an interrupted SSH pairing from the released operator path", async () => { + const root = await clientRoot(); + const credentialStore = createClientRuntimeHostCredentialStore(root); + await credentialStore.setSecret( + "runtime-host-pairing-recovery", + "runtime_host_access", + JSON.stringify({ + schemaVersion: 1, + intents: [ + { + target: { + profile: { + ...MANAGED_PROFILE, + transport: { + kind: "ssh", + destination: "operator@example.com", + activation: { + kind: "ssh_operator", + operatorPath: "/home/operator/.local/share/maka/operator", + }, + }, + }, + credential: "new-token", + }, + wasEnabled: true, + }, + ], + }), + ); + + const startup = await resolveDesktopRuntimeHostStartup(root, { credentialStore }); + + assert.equal(startup.pairingReadFailure, undefined); + assert.deepEqual(startup.pairingIntents[0]?.target.profile.transport, { + kind: "ssh", + destination: "operator@example.com", + activation: { + kind: "ssh_operator", + operator: { + kind: "legacy_posix_executable", + executablePath: "/home/operator/.local/share/maka/operator", + }, + }, + }); +}); + test("retries pairing recovery before discarding it", async () => { const root = await clientRoot(); const catalog = await stageInterruptedPairing(root); 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 7e9c87fc06..107f3ebb87 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 @@ -38,37 +38,83 @@ import { } from '@maka/runtime-host/operator'; import { createDesktopRuntimeHostSshTerminal, - runtimeHostDevelopmentPeerTargetFromUname, + runtimeHostPeerTargetFromNode, } from '../runtime-host-ssh-terminal.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; -test('maps supported SSH uname identities to development peer targets', () => { - assert.equal(runtimeHostDevelopmentPeerTargetFromUname('Linux', 'x86_64'), 'linux-x64'); - assert.equal(runtimeHostDevelopmentPeerTargetFromUname('Linux', 'aarch64'), 'linux-arm64'); - assert.equal(runtimeHostDevelopmentPeerTargetFromUname('Darwin', 'arm64'), 'darwin-arm64'); +const OPERATOR = { + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath: '/home/operator/.local/share/maka/operator.mjs', +}; + +const WINDOWS_OPERATOR = { + kind: 'node' as const, + platform: 'win32' as const, + nodePath: 'C:\\Program Files\\nodejs\\node.exe', + modulePath: 'C:\\Users\\operator\\AppData\\Local\\Maka\\operator.mjs', +}; + +test('maps supported SSH Node identities to peer targets', () => { + assert.equal(runtimeHostPeerTargetFromNode('linux', 'x64'), 'linux-x64'); + assert.equal(runtimeHostPeerTargetFromNode('linux', 'arm64'), 'linux-arm64'); + assert.equal(runtimeHostPeerTargetFromNode('darwin', 'arm64'), 'darwin-arm64'); + assert.equal(runtimeHostPeerTargetFromNode('win32', 'x64'), 'win32-x64'); assert.throws( - () => runtimeHostDevelopmentPeerTargetFromUname('Linux', 'riscv64'), + () => runtimeHostPeerTargetFromNode('linux', 'riscv64'), /not available/u, ); }); -test('detects the development peer target through the bounded SSH preflight', async () => { +test('detects the peer target through the bounded SSH preflight', async () => { const harness = createHarness('pending'); - const detection = harness.terminal.resolveDevelopmentPeerTarget({ + const detection = harness.terminal.resolveNodeIdentity({ destination: 'operator@example.com', }); await waitFor(() => harness.pty.hasDataListener()); const command = harness.launchArgs[0]?.at(-1) ?? ''; const marker = command.match(/__MAKA_RUNTIME_HOST_TARGET_[0-9a-f]+__/u)?.[0]; assert.ok(marker); - harness.pty.emitData(`${marker}Linux:x86_64\r\n`); + harness.pty.emitData(`${marker}linux:x64\r\n`); harness.pty.exit(0); - assert.equal(await detection, 'linux-x64'); + assert.deepEqual(await detection, { platform: 'linux', architecture: 'x64' }); assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_TARGET/u); await harness.terminal.close(); }); +test('retries target detection through the POSIX login shell when Node is not on the default PATH', async (t) => { + const handlers = new Map unknown>(); + const launches: Array<{ args: string[]; pty: FakePty }> = []; + const terminal = createDesktopRuntimeHostSshTerminal({ + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + send: () => undefined, + spawnPty: ((_file: string, args: string[]) => { + const pty = new FakePty(); + launches.push({ args, pty }); + return pty as unknown as IPty; + }) as typeof import('node-pty').spawn, + }); + t.after(() => terminal.close()); + + const detection = terminal.resolveNodeIdentity({ destination: 'operator@example.com' }); + await waitFor(() => launches.length === 1); + launches[0]?.pty.exit(127); + await waitFor(() => launches.length === 2); + const command = launches[1]?.args.at(-1) ?? ''; + const marker = command.match(/__MAKA_RUNTIME_HOST_TARGET_[0-9a-f]+__/u)?.[0]; + assert.ok(marker); + assert.match(command, /\$\{SHELL:-\/bin\/sh\}.*-lic/u); + launches[1]?.pty.emitData(`${marker}linux:x64\r\n`); + launches[1]?.pty.exit(0); + + assert.deepEqual(await detection, { platform: 'linux', architecture: 'x64' }); +}); + test('keeps a connecting SSH prompt observable across renderer presentation changes', async () => { const harness = createHarness('pending'); const opening = openTunnel(harness); @@ -131,6 +177,7 @@ test('keeps setup credentials out of the interactive terminal projection', async { destination: 'operator@example.com', setupPackage: { kind: 'npm', specifier: 'maka-agent@1.2.3+desktop.1' }, + remotePlatform: 'posix', principalId: 'desktop:stable-client', signal: controller.signal, }, @@ -153,7 +200,7 @@ test('keeps setup credentials out of the interactive terminal projection', async version: '0.1.0-beta.1', serviceId: 'b'.repeat(64), deploymentId: '00000000-0000-4000-8000-000000000001', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, rootPath: '/home/operator/.config/Maka/workspaces/default', rootId: 'a'.repeat(64), endpoint: 'ws://127.0.0.1:7443/runtime-host', @@ -179,12 +226,54 @@ test('keeps setup credentials out of the interactive terminal projection', async await harness.terminal.close(); }); +test('runs Windows setup through one encoded PowerShell command', async () => { + const harness = createHarness('pending'); + const setup = harness.terminal.runSetup( + { + destination: 'operator@example.com', + setupPackage: { kind: 'npm', specifier: 'maka-agent@1.2.3' }, + remotePlatform: 'win32', + principalId: 'desktop:stable-client', + lifecycle: 'on_demand', + }, + () => undefined, + ); + await waitFor(() => harness.pty.hasDataListener()); + const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; + assert.match(remoteCommand, /^powershell\.exe .* -EncodedCommand [A-Za-z0-9+/=]+$/u); + assert.doesNotMatch(remoteCommand, /\/bin\/sh|maka-agent/u); + const encoded = remoteCommand.split(' ').at(-1); + assert.ok(encoded); + const script = Buffer.from(encoded, 'base64').toString('utf16le'); + assert.match(script, /npx\.cmd/u); + assert.match(script, /Remove-Item/u); + + harness.pty.emitData(encodeRuntimeHostSetupFrame({ + schemaVersion: 1, + sequence: 0, + kind: 'complete', + version: '1.2.3', + serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', + operator: WINDOWS_OPERATOR, + rootPath: 'C:\\Users\\operator\\AppData\\Local\\Maka\\workspaces\\default', + rootId: 'a'.repeat(64), + endpoint: 'ws://127.0.0.1:7443/runtime-host', + credentialId: 'credential-1', + credential: 'secret-access-token', + })); + harness.pty.exit(0); + assert.deepEqual((await setup).operator, WINDOWS_OPERATOR); + await harness.terminal.close(); +}); + test('discards an oversized reserved setup line instead of projecting its tail', async () => { const harness = createHarness('pending'); const setup = harness.terminal.runSetup( { destination: 'operator@example.com', setupPackage: { kind: 'npm', specifier: 'maka-agent@1.2.3' }, + remotePlatform: 'posix', principalId: 'desktop:stable-client', }, () => undefined, @@ -209,6 +298,7 @@ test('keeps a completed setup process owned until it exits', async () => { { destination: 'operator@example.com', setupPackage: { kind: 'npm', specifier: 'maka-agent@1.2.3' }, + remotePlatform: 'posix', principalId: 'desktop:stable-client', }, () => undefined, @@ -221,7 +311,7 @@ test('keeps a completed setup process owned until it exits', async () => { version: '1.2.3', serviceId: 'b'.repeat(64), deploymentId: '00000000-0000-4000-8000-000000000001', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, rootPath: '/home/operator/.config/Maka/workspaces/default', rootId: 'a'.repeat(64), endpoint: 'ws://127.0.0.1:7443/runtime-host', @@ -244,6 +334,7 @@ test('force-stops a cancelled setup when SSH ignores graceful termination', asyn { destination: 'operator@example.com', setupPackage: { kind: 'npm', specifier: 'maka-agent@1.2.3' }, + remotePlatform: 'posix', principalId: 'desktop:stable-client', signal: controller.signal, }, @@ -272,6 +363,7 @@ test('does not signal a reused process identity when cancellation races SSH exit { destination: 'operator@example.com', setupPackage: { kind: 'npm', specifier: 'maka-agent@1.2.3' }, + remotePlatform: 'posix', principalId: 'desktop:stable-client', signal: controller.signal, }, @@ -292,7 +384,7 @@ test('reads a framed service result without projecting it into the SSH terminal' const harness = createHarness('pending'); const management = harness.terminal.runServiceManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, action: 'status', capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, expectedTarget: { @@ -344,7 +436,7 @@ test('applies the complete remote Project root policy through the managed operat const fingerprint = `sha256:${'c'.repeat(64)}`; const management = harness.terminal.runServiceManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, action: 'configure', expectedTarget: { serviceId: 'b'.repeat(64), @@ -370,7 +462,7 @@ test('applies the complete remote Project root policy through the managed operat assert.match(remoteCommand, /--allow-interrupt-active-tasks/u); assert.match( remoteCommand, - /MAKA_RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST=1/u, + /MAKA_RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST='1'/u, ); harness.pty.emitData( encodeRuntimeHostServiceManagementFrame({ @@ -413,7 +505,7 @@ test('keeps a received management result when SSH teardown times out', async () harness.pty.exitOnForceKill = true; const management = harness.terminal.runServiceManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, action: 'status', expectedTarget: { serviceId: 'b'.repeat(64), @@ -451,6 +543,7 @@ test('runs an exact update package and reports progress before an active-work re { destination: 'operator@example.com', setupPackage: { kind: 'npm', specifier: 'maka-agent@1.3.0' }, + operator: OPERATOR, expectedTarget: { serviceId: 'b'.repeat(64), rootPath: '/srv/maka', @@ -521,7 +614,7 @@ test('uses the managed operator for update policy and one-shot reconciliation', const policyHarness = createHarness('pending'); const policy = policyHarness.terminal.runUpdatePolicy({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, policy: { kind: 'channel', channel: 'latest' }, expectedTarget: target, }); @@ -549,7 +642,7 @@ test('uses the managed operator for update policy and one-shot reconciliation', const reconciliation = reconcileHarness.terminal.runUpdateReconciliation( { destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, expectedTarget: target, }, (phase) => phases.push(phase), @@ -599,7 +692,7 @@ test('keeps a prepared access credential out of the SSH terminal projection', as const credential = 'maka_rh_secret-replacement'; const management = harness.terminal.runAccessManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, rootPath: '/srv/maka', expectedRootId: 'a'.repeat(64), action: 'prepare', @@ -646,7 +739,7 @@ test('creates an owner connection code through the framed SSH operator channel', const connectionCode = 'maka-runtime-host:connect:v1:secret-code'; const management = harness.terminal.runAccessManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, rootPath: '/srv/maka', expectedRootId: 'a'.repeat(64), action: 'connection-code', @@ -680,7 +773,7 @@ test('requests adaptive-connectivity status only on the peer-management frame', const harness = createHarness('pending'); const management = harness.terminal.runPeerManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, action: 'status', webRtcStunStatus: true, expectedTarget: { @@ -729,7 +822,7 @@ test('sends a Mesh invitation only after the authenticated remote operator reque const invitation = JSON.stringify({ secret: 'one-time-mesh-secret' }); const management = harness.terminal.runPeerMeshManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, action: 'join', invitation, expectedTarget: { @@ -795,7 +888,7 @@ test('rejects a framed service result for a different action', async () => { const harness = createHarness('pending'); const management = harness.terminal.runServiceManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, action: 'uninstall', expectedTarget: { serviceId: 'b'.repeat(64), @@ -831,7 +924,7 @@ test('requires an absent operator deployment root to be absent', async () => { const harness = createHarness('pending'); const cleanup = harness.terminal.cleanupManagedDeployment({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, expectedTarget: { serviceId: 'b'.repeat(64), rootPath: '/srv/maka', @@ -867,7 +960,7 @@ test('does not launch a management process after the terminal owner closes', asy await assert.rejects( terminal.runServiceManagement({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, action: 'status', expectedTarget: { serviceId: 'b'.repeat(64), @@ -885,7 +978,7 @@ test('runs interactive operator activation as one strict framed SSH command', as const rootId = 'a'.repeat(64); const activation = harness.terminal.activateSshOperator({ destination: 'operator@example.com', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, rootId, interaction: 'terminal', }); @@ -942,6 +1035,7 @@ test('uploads a development release archive before running the same remote setup path: archive, integrity, } as const, + remotePlatform: 'posix' as const, principalId: 'desktop:stable-client', }; const setup = terminal.runSetup(setupInput, () => undefined); diff --git a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts index 71023c8549..f2ed128d4a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts @@ -35,6 +35,13 @@ import { runDesktopRuntimeHostWslSetup, } from '../runtime-host-wsl-controller.js'; +const OPERATOR = { + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath: '/home/operator/.local/share/maka/operator.mjs', +}; + test('WSL management invokes the stable operator directly with the exact deployment target', async () => { let launch: | { readonly executable: string; readonly args: readonly string[]; readonly environment: NodeJS.ProcessEnv } @@ -58,7 +65,7 @@ test('WSL management invokes the stable operator directly with the exact deploym }); const result = await runDesktopRuntimeHostWslManagement({ distribution: 'Ubuntu', - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, action: 'configure', expectedTarget: { serviceId: 'a'.repeat(64), @@ -90,11 +97,12 @@ test('WSL management invokes the stable operator directly with the exact deploym }); assert.equal(launch?.executable, 'wsl.exe'); - assert.deepEqual(launch?.args.slice(0, 5), [ + assert.deepEqual(launch?.args.slice(0, 6), [ '--distribution', 'Ubuntu', '--exec', - '/home/operator/.local/share/maka/operator', + '/usr/bin/node', + '/home/operator/.local/share/maka/operator.mjs', 'configure', ]); assert.ok(launch?.args.includes('--expected-deployment-id')); @@ -136,7 +144,7 @@ test('WSL setup forwards the development archive and its exact evidence', async version: '0.2.0-development', serviceId: 'b'.repeat(64), deploymentId: '00000000-0000-4000-8000-000000000001', - operatorPath: '/tmp/maka/operator', + operator: { ...OPERATOR, modulePath: '/tmp/maka/operator.mjs' }, rootPath: '/tmp/maka/root', rootId: 'a'.repeat(64), endpoint: 'ws://127.0.0.1:7443/runtime-host', diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 74e68af3ee..1e10e24142 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -634,7 +634,7 @@ const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({ runWslSetup: runDesktopRuntimeHostWslSetup, listWslDistributions: listRuntimeHostWslDistributions, setupPackageMode: runtimeHostSetupPackage.mode, - resolveSshDevelopmentPeerTarget: runtimeHostSshTerminal.resolveDevelopmentPeerTarget, + resolveSshNodeIdentity: runtimeHostSshTerminal.resolveNodeIdentity, resolveSetupPackage: runtimeHostSetupPackage.resolve, send: (snapshot) => mainWindowController.send("runtime-host-onboarding:changed", snapshot), @@ -666,7 +666,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ runUpdatePolicy: runtimeHostSshTerminal.runUpdatePolicy, runUpdateReconciliation: runtimeHostSshTerminal.runUpdateReconciliation, setupPackageMode: runtimeHostSetupPackage.mode, - resolveSshDevelopmentPeerTarget: runtimeHostSshTerminal.resolveDevelopmentPeerTarget, + resolveSshNodeIdentity: runtimeHostSshTerminal.resolveNodeIdentity, resolveUpdatePackage: runtimeHostSetupPackage.resolve, currentHostEpoch: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch, diff --git a/apps/desktop/src/main/runtime-host-local-management.ts b/apps/desktop/src/main/runtime-host-local-management.ts index 933f7bd6e8..c6a1f90090 100644 --- a/apps/desktop/src/main/runtime-host-local-management.ts +++ b/apps/desktop/src/main/runtime-host-local-management.ts @@ -48,7 +48,7 @@ export function createDesktopRuntimeHostLocalManagement(input: { run: (action, allowInterruptActiveTasks) => { const execute = (target: DesktopRuntimeHostLocalManagementTarget) => input.operator.runService({ - operatorPath: target.operatorPath, + operator: target.operator, action, target, ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), @@ -83,7 +83,7 @@ export function createDesktopRuntimeHostLocalManagement(input: { input.remoteAccess.changeManaged( (target) => input.operator.runService({ - operatorPath: target.operatorPath, + operator: target.operator, action: 'configure', target, projectDirectoryRoots: roots, @@ -94,14 +94,14 @@ export function createDesktopRuntimeHostLocalManagement(input: { updatePolicy: (policy) => input.remoteAccess.inspectManaged((target) => input.operator.runUpdatePolicy({ - operatorPath: target.operatorPath, + operator: target.operator, target, ...(policy ? { policy } : {}), }).then((frame) => requireLocalFrame(frame, 'update_policy'))), reconcileUpdate: (onProgress) => input.remoteAccess.changeManaged((target) => input.operator.runUpdateReconciliation( - { operatorPath: target.operatorPath, target }, + { operator: target.operator, target }, onProgress, ).then((frame) => requireLocalFrame(frame, 'reconcile_update'))), currentHostEpoch: input.currentHostEpoch, diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index 6e0613ab60..3bda1aef6b 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -42,6 +42,7 @@ import { RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, + runtimeHostOperatorInvocation, type RuntimeHostAccessManagementFrame, type RuntimeHostManagedUpdatePolicy, type RuntimeHostPeerManagementFrame, @@ -50,6 +51,7 @@ import { type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, type RuntimeHostSetupFrame, + type RuntimeHostOperatorCommand, } from '@maka/runtime-host/operator'; import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; import { @@ -87,7 +89,7 @@ export interface DesktopRuntimeHostLocalSetupCommand { } export interface DesktopRuntimeHostLocalServiceManagementInput { - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly action: | 'status' | 'start' @@ -168,7 +170,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { onProgress: (frame: Extract) => void, ): Promise; runPeer(input: { - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly action: 'enable' | 'disable' | 'status'; readonly target: DesktopRuntimeHostLocalServiceTarget; readonly coordinationRelays?: readonly string[]; @@ -176,7 +178,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { readonly signal?: AbortSignal; }): Promise; runPeerMesh(input: { - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly action: RuntimeHostPeerMeshManagementAction; readonly target: DesktopRuntimeHostLocalServiceTarget; readonly meshId?: string | null; @@ -186,7 +188,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { readonly signal?: AbortSignal; }): Promise>; runAccess(input: { - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly target: DesktopRuntimeHostLocalServiceTarget; readonly signal?: AbortSignal; }): Promise; @@ -205,21 +207,21 @@ export function createDesktopRuntimeHostLocalOperator(input: { onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, ): Promise; runUpdatePolicy(input: { - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly target: DesktopRuntimeHostLocalServiceTarget; readonly policy?: RuntimeHostManagedUpdatePolicy; readonly signal?: AbortSignal; }): Promise; runUpdateReconciliation( input: { - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly target: DesktopRuntimeHostLocalServiceTarget; readonly signal?: AbortSignal; }, onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, ): Promise; cleanupManagedDeployment(input: { - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly target: DesktopRuntimeHostLocalServiceTarget; readonly finalize?: boolean; readonly signal?: AbortSignal; @@ -271,9 +273,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { runPeer(command) { if (closed) throw new Error('Local Runtime Host operator is closed'); return runSingleFrameProcess({ - command: { - executable: command.operatorPath, - args: [ + command: runtimeHostOperatorInvocation(command.operator, [ 'peer', command.action, '--framed', @@ -287,8 +287,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { : []), ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ...managedTargetArgs(command.target), - ], - }, + ]), prefix: RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, decode: decodeRuntimeHostPeerManagementFrame, label: 'Local Runtime Host peer management', @@ -303,9 +302,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { runPeerMesh(command) { if (closed) throw new Error('Local Runtime Host operator is closed'); return runPeerMeshFrameProcess({ - command: { - executable: command.operatorPath, - args: [ + command: runtimeHostOperatorInvocation(command.operator, [ 'mesh', command.action, '--framed', @@ -321,8 +318,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { ? ['--name', command.displayName] : []), ...managedTargetArgs(command.target), - ], - }, + ]), environment: input.environment ?? process.env, spawnProcess: input.spawnProcess ?? spawn, timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, @@ -336,9 +332,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { runAccess(command) { if (closed) throw new Error('Local Runtime Host operator is closed'); return runSingleFrameProcess({ - command: { - executable: command.operatorPath, - args: [ + command: runtimeHostOperatorInvocation(command.operator, [ 'access', 'list', '--framed', @@ -346,8 +340,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { command.target.rootPath, '--expected-root', command.target.rootId, - ], - }, + ]), prefix: RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, decode: decodeRuntimeHostAccessManagementFrame, label: 'Local Runtime Host access management', @@ -362,9 +355,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { runService(command) { if (closed) throw new Error('Local Runtime Host operator is closed'); return runSingleFrameProcess({ - command: { - executable: command.operatorPath, - args: [ + command: runtimeHostOperatorInvocation(command.operator, [ command.action, '--framed', ...(command.projectDirectoryRoots === undefined @@ -381,8 +372,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ...(command.retainManagedDeployment ? ['--retain-managed-deployment'] : []), ...managedTargetArgs(command.target), - ], - }, + ]), prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, decode: decodeRuntimeHostServiceManagementFrame, label: 'Local Runtime Host service management', @@ -449,9 +439,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { if (closed) throw new Error('Local Runtime Host operator is closed'); const policy = command.policy; return runServiceFrameProcess({ - command: { - executable: command.operatorPath, - args: [ + command: runtimeHostOperatorInvocation(command.operator, [ 'update-policy', '--framed', ...(policy @@ -465,8 +453,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { ] : []), ...managedTargetArgs(command.target), - ], - }, + ]), environment: { ...(input.environment ?? process.env), [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: @@ -483,10 +470,11 @@ export function createDesktopRuntimeHostLocalOperator(input: { runUpdateReconciliation(command, onProgress) { if (closed) throw new Error('Local Runtime Host operator is closed'); return runServiceFrameProcess({ - command: { - executable: command.operatorPath, - args: ['reconcile-update', '--framed', ...managedTargetArgs(command.target)], - }, + command: runtimeHostOperatorInvocation(command.operator, [ + 'reconcile-update', + '--framed', + ...managedTargetArgs(command.target), + ]), environment: { ...(input.environment ?? process.env), [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', @@ -504,26 +492,26 @@ export function createDesktopRuntimeHostLocalOperator(input: { }, async cleanupManagedDeployment(command) { if (closed) throw new Error('Local Runtime Host operator is closed'); + const operatorPath = command.operator.kind === 'node' + ? command.operator.modulePath + : command.operator.executablePath; try { - await stat(command.operatorPath); + await stat(operatorPath); } catch (error) { if (!isNodeError(error, 'ENOENT')) throw error; try { - await rmdir(dirname(command.operatorPath)); + await rmdir(dirname(operatorPath)); } catch (directoryError) { if (!isNodeError(directoryError, 'ENOENT')) throw directoryError; } return; } await runExitProcess({ - command: { - executable: command.operatorPath, - args: [ + command: runtimeHostOperatorInvocation(command.operator, [ '__cleanup-managed-deployment', ...(command.finalize ? ['--finalize'] : []), ...managedTargetArgs(command.target), - ], - }, + ]), label: 'Local Runtime Host deployment cleanup', environment: input.environment ?? process.env, spawnProcess: input.spawnProcess ?? spawn, 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 c0d8926795..06bcf4e414 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -20,13 +20,19 @@ import { randomUUID } from 'node:crypto'; import { open, readFile, rename, rm } from 'node:fs/promises'; import { hostname } from 'node:os'; -import { dirname, isAbsolute, join } from 'node:path'; +import { dirname, join } from 'node:path'; import type { IpcMain } from 'electron'; import { encodeRuntimeHostOwnerConnectionCode, issueRuntimeHostOwnerConnectionCode, } from '@maka/runtime-host/client'; -import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator'; +import { + createRuntimeHostLegacyPosixOperatorCommand, + runtimeHostManagedOperatorCommand, + decodeRuntimeHostOperatorCommand, + resolveRuntimeHostManagedDeploymentAuthority, + type RuntimeHostOperatorCommand, +} from '@maka/runtime-host/operator'; import type { HostPeerEndpoint, HostRegistration } from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, @@ -53,13 +59,13 @@ const ADDRESS_MAX_COUNT = 16; const LOCAL_REMOTE_ACCESS_PRINCIPAL_ID = 'desktop-owner:local-runtime-host-sharing'; interface LocalServiceTarget extends DesktopRuntimeHostLocalServiceTarget { - readonly schemaVersion: 1; - readonly operatorPath: string; + readonly schemaVersion: 2; + readonly operator: RuntimeHostOperatorCommand; readonly deploymentId: string; } interface LocalServiceSetupPending { - readonly schemaVersion: 1; + readonly schemaVersion: 2; /** Persisted only after the Desktop-owned Host has retired. */ readonly state: 'setupPending'; readonly rootPath: string; @@ -68,7 +74,7 @@ interface LocalServiceSetupPending { readonly allowInterruptActiveTasks: boolean; } -/** Schema-v1 setup intent written by Desktop releases before ownership was established. */ +/** Released schema-v1 setup intent written before managed ownership was established. */ interface LocalServiceLegacyHandoff { readonly schemaVersion: 1; readonly state: 'handoff'; @@ -92,7 +98,7 @@ type LocalManagedDeploymentAuthority = export interface DesktopRuntimeHostLocalManagementTarget extends DesktopRuntimeHostLocalServiceTarget { - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly deploymentId: string; } @@ -201,9 +207,12 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { lifecycleMode: authority.record.lifecycle.mode, target: requireServiceTarget( { - schemaVersion: 1, + schemaVersion: 2, serviceId: authority.record.root.id, - operatorPath: join(authority.record.deploymentRoot, 'operator'), + operator: runtimeHostManagedOperatorCommand( + authority.record, + process.platform === 'win32' ? 'win32' : 'posix', + ), rootPath: authority.record.root.path, rootId: authority.record.root.id, deploymentId: authority.record.deploymentId, @@ -329,7 +338,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } const setup: LocalServiceSetupPending = { - schemaVersion: 1, + schemaVersion: 2, state: 'setupPending', rootPath: input.rootPath, rootId: input.rootId, @@ -427,9 +436,9 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } target = requireServiceTarget( { - schemaVersion: 1, + schemaVersion: 2, serviceId: complete.serviceId, - operatorPath: complete.operatorPath, + operator: complete.operator, rootPath: complete.rootPath, rootId: complete.rootId, deploymentId: complete.deploymentId, @@ -581,7 +590,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } const managed = requireManagementTarget(lifecycle); const intent: LocalServiceUninstalling = { - schemaVersion: 1, + schemaVersion: 2, ...managed, state: 'uninstalling', allowInterruptActiveTasks, @@ -599,7 +608,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { > => { const change = () => input.operator.runPeer({ - operatorPath: intent.operatorPath, + operator: intent.operator, action: intent.peerEnabled ? 'enable' : 'disable', target: intent, coordinationRelays: intent.coordinationRelays, @@ -632,7 +641,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (authority) { const change = () => input.operator.runService({ - operatorPath: intent.operatorPath, + operator: intent.operator, action: 'uninstall', target: intent, allowInterruptActiveTasks: intent.allowInterruptActiveTasks, @@ -654,12 +663,12 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { await writeDocument(lifecyclePath, intent); } await input.operator.cleanupManagedDeployment({ - operatorPath: intent.operatorPath, + operator: intent.operator, target: intent, signal, }); await input.operator.cleanupManagedDeployment({ - operatorPath: intent.operatorPath, + operator: intent.operator, target: intent, finalize: true, signal, @@ -800,7 +809,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (frame.update.kind === 'already_current') { const restarted = await input.operator.runService({ - operatorPath: lifecycle.operatorPath, + operator: lifecycle.operator, action: 'restart', target: lifecycle, ...(options.allowInterruptActiveTasks @@ -847,13 +856,13 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { operationSignal.throwIfAborted(); const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); if (pending?.state !== 'setupPending' && pending?.state !== 'handoff') return false; - const committed = pendingSetup(pending); - const authority = await adoptCommittedSetup(committed); + const current = pendingSetup(pending); + const authority = await adoptCommittedSetup(current); if (authority.kind === 'absent') return false; if (authority.kind === 'managed') return true; - if (pending.state === 'handoff') await writeDocument(lifecyclePath, committed); + if (pending.state === 'handoff') await writeDocument(lifecyclePath, current); const setupPackage = await input.resolveSetupPackage(operationSignal); - await reconcileSetup(committed, setupPackage, operationSignal); + await reconcileSetup(current, setupPackage, operationSignal); return true; }); }, @@ -929,7 +938,7 @@ async function readPeer( receipt: LocalServiceTarget, ): Promise { const response = await operator.runPeer({ - operatorPath: receipt.operatorPath, + operator: receipt.operator, action: 'status', target: receipt, }); @@ -997,7 +1006,7 @@ async function hasSharedAccess( target: LocalServiceTarget, ): Promise { const response = await operator.runAccess({ - operatorPath: target.operatorPath, + operator: target.operator, target, }); if (response.kind === 'error') throw new Error(response.error.message); @@ -1039,25 +1048,23 @@ function hostName(): string { function requireServiceTarget(value: unknown, rootPath: string): LocalServiceTarget { if ( !isRecord(value) || - value.schemaVersion !== 1 || + value.schemaVersion !== 2 || typeof value.serviceId !== 'string' || !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) + value.rootPath !== rootPath ) { throw new Error('Local Runtime Host service receipt is invalid'); } return { - schemaVersion: 1, + schemaVersion: 2, serviceId: value.serviceId, rootPath, rootId: value.rootId, - operatorPath: value.operatorPath, + operator: decodeRuntimeHostOperatorCommand(value.operator), deploymentId: value.deploymentId, }; } @@ -1082,7 +1089,7 @@ function pendingSetup( intent: LocalServiceSetupPending | LocalServiceLegacyHandoff, ): LocalServiceSetupPending { return { - schemaVersion: 1, + schemaVersion: 2, state: 'setupPending', rootPath: intent.rootPath, rootId: intent.rootId, @@ -1093,10 +1100,10 @@ function pendingSetup( function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged { return { - schemaVersion: 1, + schemaVersion: 2, state: 'managed', serviceId: intent.serviceId, - operatorPath: intent.operatorPath, + operator: intent.operator, rootPath: intent.rootPath, rootId: intent.rootId, deploymentId: intent.deploymentId, @@ -1124,14 +1131,15 @@ async function readLifecycle( } if ( !isRecord(value) || - value.schemaVersion !== 1 || + (value.schemaVersion !== 1 && value.schemaVersion !== 2) || value.rootPath !== rootPath || value.rootId !== rootId ) { throw new Error('Local Runtime Host service lifecycle is invalid'); } - if (value.state === 'setupPending' || value.state === 'handoff') { - assertExactKeys(value, [ + let record: Record = value; + if (record.schemaVersion === 1 && record.state === 'handoff') { + assertExactKeys(record, [ 'schemaVersion', 'state', 'rootPath', @@ -1139,35 +1147,75 @@ async function readLifecycle( 'coordinationRelays', 'allowInterruptActiveTasks', ]); - if ( - typeof value.allowInterruptActiveTasks !== 'boolean' - ) { + if (typeof record.allowInterruptActiveTasks !== 'boolean') { throw new Error('Local Runtime Host setup intent is invalid'); } return { schemaVersion: 1, - state: value.state, + state: 'handoff', rootPath, rootId, - coordinationRelays: requireAddresses(value.coordinationRelays), - allowInterruptActiveTasks: value.allowInterruptActiveTasks, + coordinationRelays: requireAddresses(record.coordinationRelays), + allowInterruptActiveTasks: record.allowInterruptActiveTasks, }; } - const target = requireServiceTarget(value, rootPath); + let migrated = false; + if (record.schemaVersion === 1) { + if (record.state === 'setupPending') { + record = { ...record, schemaVersion: 2 }; + } else { + if (typeof record.operatorPath !== 'string') { + throw new Error('Local Runtime Host service receipt is invalid'); + } + const { operatorPath, ...legacy } = record; + record = { + ...legacy, + schemaVersion: 2, + operator: createRuntimeHostLegacyPosixOperatorCommand(operatorPath), + }; + } + migrated = true; + } + const finish = async (lifecycle: T): Promise => { + if (migrated) await writeDocument(path, lifecycle); + return lifecycle; + }; + if (record.state === 'setupPending') { + assertExactKeys(record, [ + 'schemaVersion', + 'state', + 'rootPath', + 'rootId', + 'coordinationRelays', + 'allowInterruptActiveTasks', + ]); + if (typeof record.allowInterruptActiveTasks !== 'boolean') { + throw new Error('Local Runtime Host setup intent is invalid'); + } + return finish({ + schemaVersion: 2, + state: record.state, + rootPath, + rootId, + coordinationRelays: requireAddresses(record.coordinationRelays), + allowInterruptActiveTasks: record.allowInterruptActiveTasks, + }); + } + const target = requireServiceTarget(record, rootPath); const targetKeys = [ 'schemaVersion', 'state', 'serviceId', - 'operatorPath', + 'operator', 'rootPath', 'rootId', 'deploymentId', ]; assertExactKeys( - value, - value.state === 'managed' + record, + record.state === 'managed' ? targetKeys - : value.state === 'peerChanging' + : record.state === 'peerChanging' ? [ ...targetKeys, 'peerEnabled', @@ -1180,34 +1228,34 @@ async function readLifecycle( ], ); if ( - value.state !== 'managed' && - value.state !== 'peerChanging' && - value.state !== 'uninstalling' && - value.state !== 'cleanupPending' + record.state !== 'managed' && + record.state !== 'peerChanging' && + record.state !== 'uninstalling' && + record.state !== 'cleanupPending' ) { throw new Error('Local Runtime Host service lifecycle is invalid'); } - if (value.state === 'managed') return { ...target, state: 'managed' }; - if (typeof value.allowInterruptActiveTasks !== 'boolean') { + if (record.state === 'managed') return finish({ ...target, state: 'managed' }); + if (typeof record.allowInterruptActiveTasks !== 'boolean') { throw new Error('Local Runtime Host service intent is invalid'); } - if (value.state === 'peerChanging') { - if (typeof value.peerEnabled !== 'boolean') { + if (record.state === 'peerChanging') { + if (typeof record.peerEnabled !== 'boolean') { throw new Error('Local Runtime Host peer intent is invalid'); } - return { + return finish({ ...target, state: 'peerChanging', - peerEnabled: value.peerEnabled, - coordinationRelays: requireAddresses(value.coordinationRelays), - allowInterruptActiveTasks: value.allowInterruptActiveTasks, - }; + peerEnabled: record.peerEnabled, + coordinationRelays: requireAddresses(record.coordinationRelays), + allowInterruptActiveTasks: record.allowInterruptActiveTasks, + }); } - return { + return finish({ ...target, - state: value.state, - allowInterruptActiveTasks: value.allowInterruptActiveTasks, - }; + state: record.state, + allowInterruptActiveTasks: record.allowInterruptActiveTasks, + }); } function assertExactKeys(value: Record, keys: readonly string[]): void { @@ -1256,7 +1304,7 @@ async function uninstallExactService( receipt: LocalServiceTarget, ): Promise { const response = await operator.runService({ - operatorPath: receipt.operatorPath, + operator: receipt.operator, action: 'uninstall', target: receipt, }); diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts index 665c52724b..07ab66773c 100644 --- a/apps/desktop/src/main/runtime-host-managed-services.ts +++ b/apps/desktop/src/main/runtime-host-managed-services.ts @@ -22,6 +22,8 @@ import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; import { decodePersistedRuntimeHostProfile, + migrateRuntimeHostProfileOperatorCommand, + sameEnvironmentRuntimeHostDeployment, sameResolvedRuntimeHostProfileTarget, type EnvironmentRuntimeHostProfile, type PersistedRuntimeHostProfile, @@ -29,10 +31,15 @@ import { type RuntimeHostRemoteTransport, } from "@maka/runtime-host/client"; import { requireHostRootId } from "@maka/runtime-host/protocol"; +import { + createRuntimeHostLegacyPosixOperatorCommand, + decodeRuntimeHostOperatorCommand, + type RuntimeHostOperatorCommand, +} from "@maka/runtime-host/operator"; import { withFileUpdateLock } from "@maka/storage/file-update-lock"; import { syncDirectory } from "@maka/storage/stable-storage"; -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; const DOCUMENT_MAX_BYTES = 256 * 1024; const BINDING_COUNT_MAX = 32; const PATH_MAX_BYTES = 4 * 1024; @@ -49,7 +56,7 @@ type ManagedSshRuntimeHostProfile = RemoteRuntimeHostProfile & { interface DesktopRuntimeHostSshControlRoute { readonly kind: "ssh_operator"; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; } interface DesktopRuntimeHostManagedServiceTargetBase { @@ -157,9 +164,13 @@ export function findDesktopRuntimeHostManagedServiceBinding( const binding = document.bindings.find( (candidate) => candidate.profile.id === profile.id, ); - return binding && sameManagedProfileTarget(binding.profile, profile) - ? binding - : undefined; + if (!binding || !sameManagedProfileTarget(binding.profile, profile)) return undefined; + // The profile catalog owns a WSL control route. The deployment binding owns only + // its stable environment identity, so a crash between their writes cannot hide it. + if (binding.profile.kind === "environment" && profile.kind === "environment") { + return { profile, deployment: binding.deployment, state: "active" }; + } + return binding; } export function sameDesktopRuntimeHostManagedServiceBinding( @@ -196,8 +207,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan try { contents = await readFile(this.#legacyPath, "utf8"); } catch (legacyError) { - if ((legacyError as NodeJS.ErrnoException).code === "ENOENT") - return emptyDocument(); + if ((legacyError as NodeJS.ErrnoException).code === "ENOENT") return emptyDocument(); throw legacyError; } const migrated = decodeLegacyDocument(JSON.parse(contents)); @@ -208,9 +218,13 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan if (Buffer.byteLength(contents, "utf8") > DOCUMENT_MAX_BYTES) { throw new Error("Runtime Host managed service document is too large"); } - const document = decodeDocument(JSON.parse(contents)); + const value: unknown = JSON.parse(contents); + const migrated = decodeDocument(value); + if ((value as { readonly schemaVersion?: unknown }).schemaVersion === 1) { + await writeDocument(this.#path, migrated); + } await removeLegacyDocument(this.#legacyPath); - return document; + return migrated; } save( @@ -373,10 +387,11 @@ function decodeDocument( "Runtime Host managed service document", ["schemaVersion", "bindings"], ); - if ( - record.schemaVersion !== SCHEMA_VERSION || - !Array.isArray(record.bindings) - ) { + if (!Array.isArray(record.bindings)) { + throw new Error("Runtime Host managed service document is invalid"); + } + if (record.schemaVersion === 1) return migrateVersionOneDocument(record.bindings); + if (record.schemaVersion !== SCHEMA_VERSION) { throw new Error("Runtime Host managed service document is invalid"); } if (record.bindings.length > BINDING_COUNT_MAX) { @@ -455,30 +470,75 @@ function decodeDocument( }); } -function decodeLegacyDocument( - value: unknown, -): DesktopRuntimeHostManagedServiceDocument { - const record = requireExactRecord( - value, - "Legacy Runtime Host managed service document", - ["schemaVersion", "bindings"], - ); +function migrateVersionOneDocument(bindings: unknown[]): DesktopRuntimeHostManagedServiceDocument { + return decodeDocument({ + schemaVersion: SCHEMA_VERSION, + bindings: bindings.map((candidate) => { + const profile = migrateRuntimeHostProfileOperatorCommand( + (candidate as { readonly profile?: unknown } | null)?.profile, + ); + const decodedProfile = decodePersistedRuntimeHostProfile(profile); + const binding = requireExactRecord( + candidate, + "Runtime Host managed service binding", + decodedProfile.kind === "environment" + ? ["deployment", "profile", "state"] + : ["control", "deployment", "profile", "state"], + ); + if (decodedProfile.kind === "environment") return { ...binding, profile }; + const control = requireExactRecord(binding.control, "Managed Runtime Host control route", [ + "kind", + "operatorPath", + ]); + if (control.kind !== "ssh_operator") { + throw new Error("Managed Runtime Host control route is invalid"); + } + return { + ...binding, + profile, + control: { + kind: "ssh_operator", + operator: createRuntimeHostLegacyPosixOperatorCommand( + requirePosixOperatorPath(control.operatorPath), + ), + }, + }; + }), + }); +} + +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); + const binding = requireExactRecord(candidate, "Legacy Runtime Host service binding", [ + "profile", + "service", + "state", + ]); + const service = requireExactRecord(binding.service, "Managed Runtime Host service", [ + "id", + "operatorPath", + "rootPath", + ]); + const operatorPath = requirePosixOperatorPath(service.operatorPath); return { - profile: binding.profile, - deployment: { id: service.id, rootPath: service.rootPath }, - control: { kind: "ssh_operator", operatorPath: service.operatorPath }, + profile: migrateRuntimeHostProfileOperatorCommand(binding.profile), + deployment: { + id: requireHostRootId(service.id), + rootPath: requirePath(service.rootPath, "Managed Runtime Host State Root"), + }, + control: { + kind: "ssh_operator", + operator: createRuntimeHostLegacyPosixOperatorCommand(operatorPath), + }, state: binding.state, }; }), @@ -509,19 +569,15 @@ function decodeSshControlRoute(value: unknown): DesktopRuntimeHostSshControlRout const record = requireExactRecord( value, "Managed Runtime Host control route", - ["kind", "operatorPath"], + ["kind", "operator"], ); 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({ kind: "ssh_operator", operatorPath }); + return Object.freeze({ + kind: "ssh_operator", + operator: decodeRuntimeHostOperatorCommand(record.operator), + }); } function decodeBinding( @@ -544,34 +600,6 @@ function decodeBinding( return Object.freeze({ profile, deployment, control }); } -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"); - } - return Object.freeze({ - id: requireHostRootId(record.id), - rootPath, - operatorPath, - }); -} - function requireDeploymentId(value: unknown): string { if ( typeof value !== "string" || @@ -596,6 +624,14 @@ function requirePath(value: unknown, label: string): string { return value; } +function requirePosixOperatorPath(value: unknown): string { + const path = requirePath(value, "Managed Runtime Host operator path"); + if (!path.startsWith("/")) { + throw new Error("Managed Runtime Host operator path must be absolute"); + } + return path; +} + function requireExactRecord( value: unknown, label: string, @@ -632,7 +668,7 @@ function sameBindingTarget( } return ( isDesktopRuntimeHostManagedSshServiceBinding(right) && - left.control.operatorPath === right.control.operatorPath + JSON.stringify(left.control.operator) === JSON.stringify(right.control.operator) ); } @@ -642,7 +678,9 @@ function sameManagedProfileTarget( ): boolean { return ( left.id === right.id && - sameResolvedRuntimeHostProfileTarget({ profile: left }, { profile: right }) + (left.kind === "environment" && right.kind === "environment" + ? sameEnvironmentRuntimeHostDeployment(left, right) + : sameResolvedRuntimeHostProfileTarget({ profile: left }, { profile: right })) ); } diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index d31fcad04e..dfd8ae7803 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -49,17 +49,19 @@ import { type DesktopRuntimeHostManagedSshServiceBinding, } from './runtime-host-managed-services.js'; import { requireProjectDirectoryRoots } from '../shared/runtime-host-project-directory-policy.js'; -import type { - DesktopRuntimeHostSshCleanupInput, - DesktopRuntimeHostSshAccessInput, - DesktopRuntimeHostSshManagementInput, - DesktopRuntimeHostSshPeerManagementInput, - DesktopRuntimeHostSshUpdateInput, - DesktopRuntimeHostSshUpdatePolicyInput, - DesktopRuntimeHostSshUpdateReconciliationInput, - RuntimeHostServiceUpdatePolicyTerminalFrame, - RuntimeHostServiceUpdateReconciliationTerminalFrame, - RuntimeHostServiceUpdateTerminalFrame, +import { + runtimeHostPeerTargetFromNode, + type DesktopRuntimeHostSshCleanupInput, + type DesktopRuntimeHostSshAccessInput, + type DesktopRuntimeHostSshManagementInput, + type DesktopRuntimeHostSshNodeIdentity, + type DesktopRuntimeHostSshPeerManagementInput, + type DesktopRuntimeHostSshUpdateInput, + type DesktopRuntimeHostSshUpdatePolicyInput, + type DesktopRuntimeHostSshUpdateReconciliationInput, + type RuntimeHostServiceUpdatePolicyTerminalFrame, + type RuntimeHostServiceUpdateReconciliationTerminalFrame, + type RuntimeHostServiceUpdateTerminalFrame, } from './runtime-host-ssh-terminal.js'; import type { DesktopRuntimeHostDevelopmentPeerTarget, @@ -125,11 +127,11 @@ export function createDesktopRuntimeHostManagement(input: { onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void, ) => Promise; readonly setupPackageMode: 'published' | 'development'; - readonly resolveSshDevelopmentPeerTarget: (input: { + readonly resolveSshNodeIdentity: (input: { readonly destination: string; readonly sshPort?: number; readonly signal?: AbortSignal; - }) => Promise>; + }) => Promise; readonly resolveUpdatePackage: ( peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, ) => @@ -213,7 +215,7 @@ export function createDesktopRuntimeHostManagement(input: { } const response = await input.runWslManagement({ distribution: managed.profile.provider.distribution, - operatorPath: managed.profile.operatorPath, + operator: managed.profile.operator, action: managementAction, expectedTarget, }); @@ -224,7 +226,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(managed.profile.transport.sshPort === undefined ? {} : { sshPort: managed.profile.transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, action: managementAction, expectedTarget, ...(managementAction === 'install' @@ -272,7 +274,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(managementInput.sshPort === undefined ? {} : { sshPort: managementInput.sshPort }), - operatorPath: managementInput.operatorPath, + operator: managementInput.operator, expectedTarget: managementInput.expectedTarget, }); await input.cleanupManagedDeployment({ @@ -280,7 +282,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(managementInput.sshPort === undefined ? {} : { sshPort: managementInput.sshPort }), - operatorPath: managementInput.operatorPath, + operator: managementInput.operator, expectedTarget: managementInput.expectedTarget, finalize: true, }); @@ -357,7 +359,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(managed.profile.transport.sshPort === undefined ? {} : { sshPort: managed.profile.transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, rootPath: managed.deployment.rootPath, expectedRootId: managed.profile.rootId, }, @@ -443,7 +445,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(target.transport.sshPort === undefined ? {} : { sshPort: target.transport.sshPort }), - operatorPath: target.managed.control.operatorPath, + operator: target.managed.control.operator, action: 'status', expectedTarget: target.expectedTarget, capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_WEBRTC_STUN_CAPABILITY, @@ -462,7 +464,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(target.transport.sshPort === undefined ? {} : { sshPort: target.transport.sshPort }), - operatorPath: target.managed.control.operatorPath, + operator: target.managed.control.operator, action: 'status', expectedTarget: target.expectedTarget, capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY, @@ -505,7 +507,7 @@ export function createDesktopRuntimeHostManagement(input: { const response = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, action: 'status', ...(webRtcStunAvailable ? { webRtcStunStatus: true } : {}), expectedTarget, @@ -560,7 +562,7 @@ export function createDesktopRuntimeHostManagement(input: { const response = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, action: enabledValue ? 'enable' : 'disable', ...(enabledValue ? { coordinationRelays } : {}), ...(enabledValue ? { automaticRelayDiscovery: automaticRelayDiscoveryValue } : {}), @@ -600,7 +602,7 @@ export function createDesktopRuntimeHostManagement(input: { const rollback = await input.runPeerManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, action: 'disable', expectedTarget, }); @@ -649,18 +651,21 @@ export function createDesktopRuntimeHostManagement(input: { const { managed, transport, expectedTarget } = await managedMutationTarget(profileId); const previousHostEpoch = input.currentHostEpoch(profileId); input.sendProgress({ profileId, phase: 'preparing_cli' }); - const peerTarget = input.setupPackageMode === 'development' - ? await input.resolveSshDevelopmentPeerTarget({ + let peerTarget: DesktopRuntimeHostDevelopmentPeerTarget = 'none'; + if (input.setupPackageMode === 'development') { + const identity = await input.resolveSshNodeIdentity({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - }) - : 'none'; + }); + peerTarget = runtimeHostPeerTargetFromNode(identity.platform, identity.architecture); + } const setupPackage = await input.resolveUpdatePackage(peerTarget); execute = () => input.runUpdate( { destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), setupPackage, + operator: managed.control.operator, expectedTarget, ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), }, @@ -723,7 +728,7 @@ export function createDesktopRuntimeHostManagement(input: { if (!isDesktopRuntimeHostManagedSshServiceBinding(managed)) { execute = () => input.runWslManagement({ distribution: managed.profile.provider.distribution, - operatorPath: managed.profile.operatorPath, + operator: managed.profile.operator, action: 'configure', expectedTarget, projectDirectoryRoots: roots, @@ -735,7 +740,7 @@ export function createDesktopRuntimeHostManagement(input: { execute = () => input.runServiceManagement({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, action: 'configure', expectedTarget, projectDirectoryRoots: roots, @@ -800,7 +805,7 @@ export function createDesktopRuntimeHostManagement(input: { ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, expectedTarget, }; return async (next?: RuntimeHostManagedUpdatePolicy) => @@ -843,7 +848,7 @@ export function createDesktopRuntimeHostManagement(input: { { destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, 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 68d59f5adb..14dac44db2 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -21,6 +21,8 @@ import { randomUUID } from 'node:crypto'; import type { IpcMain } from 'electron'; import { parseRuntimeHostSetupEndpoint, + type RuntimeHostNodeOperatorCommand, + type RuntimeHostOperatorCommand, type RuntimeHostSetupPhase, } from '@maka/runtime-host/operator'; import type { @@ -28,7 +30,11 @@ import type { DesktopRuntimeHostOnboardingSnapshot, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; -import type { DesktopRuntimeHostSshSetupInput } from './runtime-host-ssh-terminal.js'; +import { + runtimeHostPeerTargetFromNode, + type DesktopRuntimeHostSshNodeIdentity, + type DesktopRuntimeHostSshSetupInput, +} from './runtime-host-ssh-terminal.js'; import type { DesktopRuntimeHostWslSetupInput } from './runtime-host-wsl-controller.js'; import type { DesktopRuntimeHostDevelopmentPeerTarget, @@ -58,7 +64,7 @@ export function createDesktopRuntimeHostOnboarding(input: { readonly rootPath: string; readonly serviceId: string; readonly deploymentId: string; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly endpoint: string; readonly credential: string; }>; @@ -71,16 +77,16 @@ export function createDesktopRuntimeHostOnboarding(input: { readonly rootPath: string; readonly serviceId: string; readonly deploymentId: string; - readonly operatorPath: string; + readonly operator: RuntimeHostNodeOperatorCommand<'posix'>; }>; readonly listWslDistributions: () => Promise; readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void; readonly setupPackageMode: 'published' | 'development'; - readonly resolveSshDevelopmentPeerTarget: (input: { + readonly resolveSshNodeIdentity: (input: { readonly destination: string; readonly sshPort?: number; readonly signal?: AbortSignal; - }) => Promise>; + }) => Promise; readonly resolveSetupPackage: ( peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, signal?: AbortSignal, @@ -134,10 +140,14 @@ export function createDesktopRuntimeHostOnboarding(input: { const setupPackage = await input.resolveSetupPackage('none', signal); return await runWsl(request, setupPackage, signal); } + const nodeIdentity = await resolveSshNodeIdentity(request, signal); const peerTarget = input.setupPackageMode === 'development' - ? await resolveSshDevelopmentPeerTarget(request, signal) + ? runtimeHostPeerTargetFromNode(nodeIdentity.platform, nodeIdentity.architecture) : 'none'; - const setupPackage = await input.resolveSetupPackage(peerTarget, signal); + const setupPackage = await input.resolveSetupPackage( + peerTarget, + signal, + ); const lifecycle = setupPackage.kind === 'npm' ? 'on_demand' : 'supervised'; signal.throwIfAborted(); publish({ kind: 'running', phase: 'connecting_ssh' }); @@ -156,6 +166,7 @@ export function createDesktopRuntimeHostOnboarding(input: { destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), setupPackage, + remotePlatform: nodeIdentity.platform === 'win32' ? 'win32' : 'posix', lifecycle, principalId: `desktop:${input.clientInstanceId}`, ...(request.projectDirectoryRoots @@ -191,7 +202,7 @@ export function createDesktopRuntimeHostOnboarding(input: { ? { activation: { kind: 'ssh_operator' as const, - operatorPath: complete.operatorPath, + operator: complete.operator, }, } : { @@ -209,7 +220,7 @@ export function createDesktopRuntimeHostOnboarding(input: { }, control: { kind: 'ssh_operator', - operatorPath: complete.operatorPath, + operator: complete.operator, }, }, }); @@ -226,18 +237,18 @@ export function createDesktopRuntimeHostOnboarding(input: { } }; - const resolveSshDevelopmentPeerTarget = async ( + const resolveSshNodeIdentity = async ( request: Extract, signal: AbortSignal, - ): Promise> => { + ): Promise => { publish({ kind: 'running', phase: 'connecting_ssh' }); - const target = await input.resolveSshDevelopmentPeerTarget({ + const identity = await input.resolveSshNodeIdentity({ destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), signal, }); publish({ kind: 'running', phase: 'preparing_cli' }); - return target; + return identity; }; const runWsl = async ( @@ -276,7 +287,7 @@ export function createDesktopRuntimeHostOnboarding(input: { kind: 'environment' as const, provider: { kind: 'wsl' as const, distribution: request.distribution }, rootId: complete.rootId, - operatorPath: complete.operatorPath, + operator: complete.operator, }; const connected = await input.profiles.addManagedEnvironmentAndEnable({ profile, diff --git a/apps/desktop/src/main/runtime-host-pairing-journal.ts b/apps/desktop/src/main/runtime-host-pairing-journal.ts index 38169122e3..d5868e4eda 100644 --- a/apps/desktop/src/main/runtime-host-pairing-journal.ts +++ b/apps/desktop/src/main/runtime-host-pairing-journal.ts @@ -19,6 +19,7 @@ import { decodeRemoteRuntimeHostProfile, + migrateRuntimeHostProfileOperatorCommand, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, sameResolvedRuntimeHostProfileTarget, type RemoteRuntimeHostProfile, @@ -186,7 +187,9 @@ function decodePairingTarget( ): DesktopRuntimeHostPairingIntent['target'] { const record = requireExactRecord(value, ['profile', 'credential']); return { - profile: decodeRemoteRuntimeHostProfile(record.profile), + profile: decodeRemoteRuntimeHostProfile( + migrateRuntimeHostProfileOperatorCommand(record.profile), + ), credential: requireCredential(record.credential), }; } diff --git a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts index 675962f0fb..4fa6b568ba 100644 --- a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts +++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts @@ -155,7 +155,7 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { const { invitation, ...rest } = command; return runFramedPeerMeshCommand(command.action, () => input.runLocal({ - operatorPath: managed.operatorPath, + operator: managed.operator, target: managedTarget(managed), ...rest, ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), @@ -203,7 +203,7 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { input.runRemote({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, + operator: managed.control.operator, expectedTarget: { serviceId: managed.deployment.id, rootPath: managed.deployment.rootPath, @@ -428,7 +428,7 @@ async function reconcileDesktopTarget( const run: RunManagedPeerMeshCommand = async (command) => { const { invitation, ...rest } = command; const response = await runLocal({ - operatorPath: managed.operatorPath, + operator: managed.operator, target: managedTarget(managed), ...rest, ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), @@ -600,7 +600,7 @@ function requireTarget(value: unknown): DesktopRuntimeHostPeerMeshTarget { function managedTarget( target: DesktopRuntimeHostLocalManagementTarget, -): Omit { +): Omit { return { serviceId: target.serviceId, rootPath: target.rootPath, diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 8fe56cc7d6..fdc3c01407 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -31,6 +31,7 @@ import { RuntimeHostOperationError, RuntimeHostPermanentReconnectError, RuntimeHostRemoteCompatibilityError, + sameEnvironmentRuntimeHostDeployment, sameRemoteRuntimeHostProfileTarget, sameResolvedRuntimeHostProfileTarget, type EnvironmentRuntimeHostProfile, @@ -809,22 +810,37 @@ export function createDesktopRuntimeHostProfileService(input: { const existing = currentDocument.profiles.find( (candidate): candidate is EnvironmentRuntimeHostProfile => candidate.kind === "environment" && - sameResolvedRuntimeHostProfileTarget( - { profile: candidate }, - { profile: requestedProfile }, - ), + sameEnvironmentRuntimeHostDeployment(candidate, requestedProfile), ); - const profile = existing ?? requestedProfile; - if (!existing) { - const document = await catalog.create(profile); - const persisted = document.profiles.find( - (candidate) => candidate.id === profile.id, - ); - if (!persisted || persisted.kind !== "environment") { - throw new Error("Runtime Host profile creation did not persist"); + const profile = existing + ? { ...requestedProfile, id: existing.id, name: existing.name } + : requestedProfile; + const document = existing + ? await catalog.save(profile) + : await catalog.create(profile); + const persisted = document.profiles.find( + (candidate) => candidate.id === profile.id, + ); + if (!persisted || persisted.kind !== "environment") { + throw new Error("Runtime Host profile creation did not persist"); + } + try { + await managedServices.save(profile, value.managedService); + } catch (failure) { + if (existing) { + try { + await catalog.save(existing); + } catch (rollbackFailure) { + throw new AggregateError( + [failure, rollbackFailure], + "Runtime Host managed environment could not be saved and its profile could not be restored", + ); + } + } else { + await rollbackCreatedProfile(catalog, { profile }, failure); } + throw failure; } - await managedServices.save(profile, value.managedService); const error = await enable(profile.id); if (error) throw error; return { profileId: profile.id }; diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index bf8fe7ae9f..2784df6093 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -19,7 +19,6 @@ import { homedir } from 'node:os'; import { createHash, randomUUID } from 'node:crypto'; -import { posix as pathPosix } from 'node:path'; import type { IpcMain } from 'electron'; import type { IPty } from 'node-pty'; import { spawn as spawnPty } from 'node-pty'; @@ -28,6 +27,7 @@ import { activateRuntimeHostSshOperator, normalizeRuntimeHostSshDestination, openRuntimeHostSshTunnel, + runtimeHostSshOperatorRemoteCommand, type RuntimeHostSshOperatorActivationInput, type RuntimeHostSshProcess, type RuntimeHostSshProcessFactory, @@ -62,6 +62,8 @@ import { type RuntimeHostPeerMeshManagementAction, type RuntimeHostPeerMeshManagementFrame, type RuntimeHostOperatorCapability, + type RuntimeHostOperatorCommand, + type RuntimeHostOperatorPlatform, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, @@ -110,6 +112,7 @@ export interface DesktopRuntimeHostSshSetupInput { readonly destination: string; readonly sshPort?: number; readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly remotePlatform: RuntimeHostOperatorPlatform; readonly principalId: string; readonly lifecycle?: 'supervised' | 'on_demand'; readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; @@ -122,10 +125,15 @@ export interface DesktopRuntimeHostSshTargetInput { readonly signal?: AbortSignal; } +export interface DesktopRuntimeHostSshNodeIdentity { + readonly platform: string; + readonly architecture: string; +} + export interface DesktopRuntimeHostSshManagementInput { readonly destination: string; readonly sshPort?: number; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly action: Exclude< RuntimeHostServiceManagementAction, 'check_update' | 'update' | 'update_policy' | 'reconcile_update' @@ -151,6 +159,7 @@ export interface DesktopRuntimeHostSshUpdateInput { readonly destination: string; readonly sshPort?: number; readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly operator: RuntimeHostOperatorCommand; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; readonly allowInterruptActiveTasks?: boolean; readonly signal?: AbortSignal; @@ -159,7 +168,7 @@ export interface DesktopRuntimeHostSshUpdateInput { export interface DesktopRuntimeHostSshUpdatePolicyInput { readonly destination: string; readonly sshPort?: number; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly policy?: RuntimeHostManagedUpdatePolicy; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; readonly signal?: AbortSignal; @@ -168,7 +177,7 @@ export interface DesktopRuntimeHostSshUpdatePolicyInput { export interface DesktopRuntimeHostSshUpdateReconciliationInput { readonly destination: string; readonly sshPort?: number; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; readonly signal?: AbortSignal; } @@ -176,7 +185,7 @@ export interface DesktopRuntimeHostSshUpdateReconciliationInput { export interface DesktopRuntimeHostSshPeerManagementInput { readonly destination: string; readonly sshPort?: number; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly action: Extract; readonly coordinationRelays?: readonly string[]; readonly automaticRelayDiscovery?: boolean; @@ -189,7 +198,7 @@ export interface DesktopRuntimeHostSshPeerManagementInput { export interface DesktopRuntimeHostSshPeerMeshManagementInput { readonly destination: string; readonly sshPort?: number; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly action: RuntimeHostPeerMeshManagementAction; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; readonly meshId?: string | null; @@ -202,7 +211,7 @@ export interface DesktopRuntimeHostSshPeerMeshManagementInput { export interface DesktopRuntimeHostSshCleanupInput { readonly destination: string; readonly sshPort?: number; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; readonly finalize?: boolean; readonly signal?: AbortSignal; @@ -211,7 +220,7 @@ export interface DesktopRuntimeHostSshCleanupInput { interface DesktopRuntimeHostSshAccessTarget { readonly destination: string; readonly sshPort?: number; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly rootPath: string; readonly expectedRootId: string; readonly signal?: AbortSignal; @@ -262,9 +271,9 @@ export function createDesktopRuntimeHostSshTerminal(input: { input: RuntimeHostSshOperatorActivationInput, ): Promise; openSshTunnel(input: RuntimeHostSshTunnelInput): Promise; - resolveDevelopmentPeerTarget( + resolveNodeIdentity( input: DesktopRuntimeHostSshTargetInput, - ): Promise>; + ): Promise; runSetup( input: DesktopRuntimeHostSshSetupInput, onProgress: (frame: Extract) => void, @@ -644,7 +653,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { } return tunnel; }, - resolveDevelopmentPeerTarget: async (targetInput) => { + resolveNodeIdentity: async (targetInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); targetInput.signal?.throwIfAborted(); const destination = normalizeRuntimeHostSshDestination(targetInput.destination); @@ -652,57 +661,70 @@ export function createDesktopRuntimeHostSshTerminal(input: { ? undefined : requireSetupPort(targetInput.sshPort); const marker = `__MAKA_RUNTIME_HOST_TARGET_${randomUUID().replaceAll('-', '')}__`; - const remoteCommand = `printf '${marker}%s:%s\\n' "$(uname -s)" "$(uname -m)"`; - let target: Exclude | undefined; - let failure: Error | undefined; - const filter = createRuntimeHostFramedOutputFilter({ - prefix: marker, - pendingMaxBytes: 256, - decode: (line) => line.slice(marker.length).replaceAll('\r', '').trimEnd(), - label: 'Remote Runtime Host target detection', - onFrame: (identity) => { - if (target) { - failure = new Error('Remote Runtime Host target detection returned multiple results'); - return; - } - const [system, machine, ...extra] = identity.split(':'); - if (!system || !machine || extra.length > 0) { - failure = new Error('Remote Runtime Host target detection returned an invalid result'); - return; - } - try { - target = runtimeHostDevelopmentPeerTargetFromUname(system, machine); - } catch (error) { - failure = error instanceof Error ? error : new Error(String(error)); - } - }, - onError: (error) => { - failure = error; - }, - }); - const { process, terminal } = startTerminalProcess( - 'ssh', - sshRemoteCommandArgs(destination, sshPort, remoteCommand), - filter.push, - true, - ); - const wait = await waitForTerminalProcess(process, { - signal: targetInput.signal, - timeoutMs: input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, - stopGraceMs: input.processStopGraceMs, - onAbort: () => dismissPresentation(terminal), - }, input.terminateProcessTree); - if (wait.timedOut) throw new Error('Remote Runtime Host target detection timed out'); - if (wait.exit.code !== 0) { + const nodeProbe = `node -e "process.stdout.write('${marker}'+process.platform+':'+process.arch+'\\n')"`; + const detect = async (remoteCommand: string) => { + let identity: DesktopRuntimeHostSshNodeIdentity | undefined; + let failure: Error | undefined; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: marker, + pendingMaxBytes: 256, + decode: (line) => line.slice(marker.length).replaceAll('\r', '').trimEnd(), + label: 'Remote Runtime Host target detection', + onFrame: (value) => { + if (identity) { + failure = new Error('Remote Runtime Host target detection returned multiple results'); + return; + } + const [platform, architecture, ...extra] = value.split(':'); + if (!platform || !architecture || extra.length > 0) { + failure = new Error('Remote Runtime Host target detection returned an invalid result'); + return; + } + identity = { platform, architecture }; + }, + onError: (error) => { + failure = error; + }, + }); + const { process, terminal } = startTerminalProcess( + 'ssh', + sshRemoteCommandArgs(destination, sshPort, remoteCommand), + filter.push, + true, + ); + const wait = await waitForTerminalProcess(process, { + signal: targetInput.signal, + timeoutMs: input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, + stopGraceMs: input.processStopGraceMs, + onAbort: () => dismissPresentation(terminal), + }, input.terminateProcessTree); + if (wait.timedOut) throw new Error('Remote Runtime Host target detection timed out'); + filter.finish(); + if (failure) throw failure; + completePresentation(terminal); + return { identity, exitCode: wait.exit.code }; + }; + const direct = await detect(nodeProbe); + if (direct.exitCode === 0) { + if (!direct.identity) + throw new Error('Remote Runtime Host target detection returned no result'); + return direct.identity; + } + if (direct.exitCode !== 127) { throw new Error( - `Remote Runtime Host target detection exited with code ${String(wait.exit.code)}`, + `Remote Runtime Host target detection exited with code ${String(direct.exitCode)}`, ); } - filter.finish(); - if (failure) throw failure; - completePresentation(terminal); - if (!target) throw new Error('Remote Runtime Host target detection returned no result'); - return target; + const loginProbe = `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${nodeProbe}`)}`; + const login = await detect(loginProbe); + if (login.exitCode !== 0) { + throw new Error( + `Remote Runtime Host target detection exited with code ${String(login.exitCode)}`, + ); + } + if (!login.identity) + throw new Error('Remote Runtime Host target detection returned no result'); + return login.identity; }, runSetup: async (setupInput, onProgress, onComplete) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); @@ -961,19 +983,18 @@ export function createDesktopRuntimeHostSshTerminal(input: { }; } -export function runtimeHostDevelopmentPeerTargetFromUname( - system: string, - machine: string, +export function runtimeHostPeerTargetFromNode( + platform: string, + arch: string, ): Exclude { - const normalizedMachine = machine.toLowerCase(); - if (system === 'Darwin' && normalizedMachine === 'arm64') return 'darwin-arm64'; - if (system === 'Linux') { - if (normalizedMachine === 'x86_64') return 'linux-x64'; - if (normalizedMachine === 'aarch64' || normalizedMachine === 'arm64') { - return 'linux-arm64'; - } - } - throw new Error(`Direct peer is not available on ${system}/${machine}`); + const target = `${platform}-${arch}`; + if ( + target === 'darwin-arm64' || + target === 'linux-arm64' || + target === 'linux-x64' || + target === 'win32-x64' + ) return target; + throw new Error(`Direct peer is not available on ${target}`); } function cancellableUntilComplete(signal: AbortSignal | undefined): { @@ -1169,7 +1190,7 @@ function runtimeHostSetupRemoteCommand( setupPackage: PreparedSetupPackage, input: Pick< DesktopRuntimeHostSshSetupInput, - 'principalId' | 'projectDirectoryRoots' | 'lifecycle' + 'principalId' | 'projectDirectoryRoots' | 'lifecycle' | 'remotePlatform' >, ): string { if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) { @@ -1198,29 +1219,24 @@ function runtimeHostSetupRemoteCommand( JSON.stringify({ label, path }), ])), '--json', - ]); + ], {}, input.remotePlatform); } function runtimeHostActivationRemoteCommand( input: RuntimeHostSshOperatorActivationInput, ): string { - if (!pathPosix.isAbsolute(input.operatorPath)) { - throw new Error('Runtime Host operator path must be absolute'); - } - return [ - input.operatorPath, + return runtimeHostSshOperatorRemoteCommand(input.operator, [ 'activate', '--framed', '--root-id', input.rootId, - ].map(quotePosix).join(' '); + ]); } function runtimeHostServiceManagementRemoteCommand( input: DesktopRuntimeHostSshManagementInput, ): string { - const command = [ - input.operatorPath, + const args = [ input.action, '--framed', ...(input.rootPath ? ['--root', input.rootPath] : []), @@ -1242,12 +1258,12 @@ function runtimeHostServiceManagementRemoteCommand( ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ...(input.retainManagedDeployment ? ['--retain-managed-deployment'] : []), ...managedServiceTargetArgs(input.expectedTarget), - ].map(quotePosix).join(' '); - const invocation = - `${RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV}=1 ` + - `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + - `${quotePosix(input.capabilityRequest ?? RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)} exec ${command}`; - return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; + ]; + return runtimeHostSshOperatorRemoteCommand(input.operator, args, { + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', + [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: + input.capabilityRequest ?? RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + }); } function runtimeHostUpdateRemoteCommand( @@ -1276,6 +1292,7 @@ function runtimeHostUpdateRemoteCommand( RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', }, + input.operator.kind === 'legacy_posix_executable' ? 'posix' : input.operator.platform, ); } @@ -1286,35 +1303,31 @@ function runtimeHostUpdatePolicyRemoteCommand( const target = policy === undefined ? [] : ['--target', policy.kind === 'channel' ? policy.channel : policy.kind === 'fixed' ? policy.version : 'manual']; - const command = [ - input.operatorPath, + const args = [ 'update-policy', '--framed', ...target, ...managedServiceTargetArgs(input.expectedTarget), - ].map(quotePosix).join(' '); - const invocation = - `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + - `${quotePosix(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY)} exec ${command}`; - return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; + ]; + return runtimeHostSshOperatorRemoteCommand(input.operator, args, { + [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + }); } function runtimeHostUpdateReconciliationRemoteCommand( input: DesktopRuntimeHostSshUpdateReconciliationInput, ): string { - const command = [ - input.operatorPath, + const args = [ 'reconcile-update', '--framed', ...managedServiceTargetArgs(input.expectedTarget), - ] - .map(quotePosix) - .join(' '); - const invocation = - `${RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV}=1 ` + - `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + - `${quotePosix(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY)} exec ${command}`; - return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; + ]; + return runtimeHostSshOperatorRemoteCommand(input.operator, args, { + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', + [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + }); } function runtimeHostAccessManagementRemoteCommand( @@ -1330,8 +1343,7 @@ function runtimeHostAccessManagementRemoteCommand( : input.action === 'connection-code' ? ['--name', input.name] : []; - const command = [ - input.operatorPath, + const args = [ 'access', input.action, '--framed', @@ -1340,15 +1352,14 @@ function runtimeHostAccessManagementRemoteCommand( '--expected-root', input.expectedRootId, ...actionArgs, - ].map(quotePosix).join(' '); - return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; + ]; + return runtimeHostSshOperatorRemoteCommand(input.operator, args); } function runtimeHostPeerManagementRemoteCommand( input: DesktopRuntimeHostSshPeerManagementInput, ): string { - const command = [ - input.operatorPath, + const args = [ 'peer', input.action, '--framed', @@ -1372,15 +1383,14 @@ function runtimeHostPeerManagementRemoteCommand( : input.webRtcStunPolicy.urls.flatMap((url) => ['--webrtc-stun', url]) : []), ...managedServiceTargetArgs(input.expectedTarget), - ].map(quotePosix).join(' '); - return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; + ]; + return runtimeHostSshOperatorRemoteCommand(input.operator, args); } function runtimeHostPeerMeshManagementRemoteCommand( input: DesktopRuntimeHostSshPeerMeshManagementInput, ): string { - const command = [ - input.operatorPath, + const args = [ 'mesh', input.action, '--framed', @@ -1396,27 +1406,21 @@ function runtimeHostPeerMeshManagementRemoteCommand( ? ['--name', input.displayName] : []), ...managedServiceTargetArgs(input.expectedTarget), - ].map(quotePosix).join(' '); - return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; + ]; + return runtimeHostSshOperatorRemoteCommand(input.operator, args); } function runtimeHostManagedDeploymentCleanupRemoteCommand( input: DesktopRuntimeHostSshCleanupInput, ): string { - const operator = quotePosix(input.operatorPath); - const deploymentRoot = quotePosix(pathPosix.dirname(input.operatorPath)); - const cleanup = [ - input.operatorPath, + const args = [ '__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; ` + - `exit 1; fi; ` + - `exec ${cleanup}`; - return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; + ]; + return runtimeHostSshOperatorRemoteCommand(input.operator, args, {}, { + missingOperatorIsSuccess: true, + }); } function managedServiceTargetArgs(input: { @@ -1437,14 +1441,36 @@ function runtimeHostPackageRemoteCommand( setupPackage: PreparedSetupPackage, args: readonly string[], environment: Readonly> = {}, + platform: RuntimeHostOperatorPlatform = 'posix', ): string { - const commandArgs = ['maka', ...args].map(quotePosix).join(' '); - const environmentPrefix = Object.entries({ + const completeEnvironment = { ...environment, ...(setupPackage.kind === 'development_archive' ? { [RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV]: setupPackage.integrity } : {}), - }) + }; + if (platform === 'win32') { + const payload = Buffer.from(JSON.stringify({ + kind: setupPackage.kind, + specifier: setupPackage.specifier, + args: ['maka', ...args], + environment: completeEnvironment, + ...(setupPackage.kind === 'development_archive' + ? { removeAfterSetup: setupPackage.removeAfterSetup } + : {}), + }), 'utf8').toString('base64'); + const script = [ + `$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}'))|ConvertFrom-Json`, + `foreach($e in $p.environment.psobject.Properties){[Environment]::SetEnvironmentVariable($e.Name,[string]$e.Value,'Process')}`, + `$prefix=Join-Path ([IO.Path]::GetTempPath()) ('maka-runtime-host-command-'+[guid]::NewGuid().ToString('N'))`, + `$code=1`, + `try{if($p.kind -eq 'npm'){New-Item -ItemType Directory -Path $prefix|Out-Null;$n=@('--yes','--prefix',$prefix,'--package',[string]$p.specifier)}else{$n=@('--yes','--package',[string]$p.specifier)};$n+=@($p.args|ForEach-Object {[string]$_});& 'npx.cmd' @n;$code=if($null -eq $LASTEXITCODE){1}else{$LASTEXITCODE}}finally{if($p.kind -eq 'npm'){Remove-Item -LiteralPath $prefix -Recurse -Force -ErrorAction SilentlyContinue}elseif($p.removeAfterSetup){Remove-Item -LiteralPath ([string]$p.removeAfterSetup) -Force -ErrorAction SilentlyContinue}}`, + `exit $code`, + ].join(';'); + return `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`; + } + const commandArgs = ['maka', ...args].map(quotePosix).join(' '); + const environmentPrefix = Object.entries(completeEnvironment) .map(([name, value]) => `${name}=${quotePosix(value)}`) .join(' '); const invocationPrefix = environmentPrefix ? `${environmentPrefix} ` : ''; diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts index c8ff168b4e..8d0611496e 100644 --- a/apps/desktop/src/main/runtime-host-wsl-controller.ts +++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts @@ -21,7 +21,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import type { Readable } from 'node:stream'; import { normalizeRuntimeHostWslDistribution, - normalizeRuntimeHostWslOperatorPath, resolveSystemRuntimeHostWslExecutable, type RuntimeHostWslProcessFactory, } from '@maka/runtime-host/client'; @@ -32,6 +31,11 @@ import { RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, + createRuntimeHostOperatorCommand, + decodeRuntimeHostPosixOperatorCommand, + runtimeHostOperatorInvocation, + type RuntimeHostNodeOperatorCommand, + type RuntimeHostPosixOperatorCommand, type RuntimeHostSetupFrame, type RuntimeHostSetupPhase, type RuntimeHostServiceManagementFrame, @@ -44,6 +48,9 @@ const WSL_SETUP_OUTPUT_MAX_BYTES = 64 * 1024; const WSL_SETUP_STDERR_MAX_BYTES = 8 * 1024; type RuntimeHostSetupCompleteFrame = Extract; +type RuntimeHostWslSetupCompleteFrame = Omit & { + readonly operator: RuntimeHostNodeOperatorCommand<'posix'>; +}; type RuntimeHostManagementTerminalFrame = Exclude< RuntimeHostServiceManagementFrame, { readonly kind: 'progress' } @@ -51,7 +58,7 @@ type RuntimeHostManagementTerminalFrame = Exclude< export interface DesktopRuntimeHostWslManagementInput { readonly distribution: string; - readonly operatorPath: string; + readonly operator: RuntimeHostPosixOperatorCommand; readonly action: 'status' | 'configure'; readonly expectedTarget: { readonly serviceId: string; @@ -83,12 +90,8 @@ export async function runDesktopRuntimeHostWslManagement( ): Promise { input.signal?.throwIfAborted(); const distribution = normalizeRuntimeHostWslDistribution(input.distribution); - const operatorPath = normalizeRuntimeHostWslOperatorPath(input.operatorPath); - const args = [ - '--distribution', - distribution, - '--exec', - operatorPath, + const operator = decodeRuntimeHostPosixOperatorCommand(input.operator); + const invocation = runtimeHostOperatorInvocation(operator, [ input.action, '--framed', ...(input.projectDirectoryRoots === undefined @@ -112,6 +115,13 @@ export async function runDesktopRuntimeHostWslManagement( ...(input.expectedTarget.deploymentId ? ['--expected-deployment-id', input.expectedTarget.deploymentId] : []), + ]); + const args = [ + '--distribution', + distribution, + '--exec', + invocation.executable, + ...invocation.args, ]; const environment = passEnvironmentToWsl( process.env, @@ -161,7 +171,7 @@ export async function runDesktopRuntimeHostWslSetup( readonly processFactory?: RuntimeHostWslProcessFactory; readonly wslExecutable?: string; } = {}, -): Promise { +): Promise { input.signal?.throwIfAborted(); const distribution = normalizeRuntimeHostWslDistribution(input.distribution); const processFactory = overrides.processFactory ?? spawnWsl; @@ -186,7 +196,17 @@ export async function runDesktopRuntimeHostWslSetup( return undefined; } if (frame.kind === 'error') throw new Error(frame.error.message); - return frame; + if (frame.operator.platform !== 'posix') { + throw new Error('WSL Runtime Host setup returned a non-POSIX operator'); + } + return { + ...frame, + operator: createRuntimeHostOperatorCommand({ + platform: 'posix', + nodePath: frame.operator.nodePath, + modulePath: frame.operator.modulePath, + }), + }; }, onResult: () => onComplete?.(), }); diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index 8c4ca7183f..8d39fbf148 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -373,7 +373,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p }, profileCatalog: { read: async () => ({ - schemaVersion: 4, + schemaVersion: 5, profiles: [ { id: 'office', @@ -677,7 +677,7 @@ function incompatibleRemoteHandshake(overrides: Partial = {}): function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog { return { - read: async () => ({ schemaVersion: 4, profiles: [profile] }), + read: async () => ({ schemaVersion: 5, profiles: [profile] }), resolve: async (profileId) => { assert.equal(profileId, profile.id); return { diff --git a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts index 68d4b4d21d..b404ccecfe 100644 --- a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts @@ -155,6 +155,33 @@ describe('Runtime Host profile CLI', () => { parseRuntimeHostCommand(['profile', 'set', '--credential', 'secret']).kind, 'error', ); + assert.deepEqual( + parseRuntimeHostCommand([ + 'profile', + 'set', + '--id', + 'wsl', + '--name', + 'Ubuntu', + '--wsl-distribution', + 'Ubuntu', + '--operator-path', + '/home/operator/.local/share/maka/operator', + '--expected-root', + ROOT_ID, + ]), + { + kind: 'runtime-host-profile-set-environment', + id: 'wsl', + name: 'Ubuntu', + distribution: 'Ubuntu', + operator: { + kind: 'legacy_posix_executable', + executablePath: '/home/operator/.local/share/maka/operator', + }, + expectedRootId: ROOT_ID, + }, + ); }); test('passes the credential to the catalog without writing it to command output', async () => { @@ -204,7 +231,7 @@ function createProfileCatalogCapture(): { saved: Array<{ profile: RemoteRuntimeHostProfile; credential?: string }>; } { const state: { document: RuntimeHostProfileDocument } = { - document: { schemaVersion: 4, profiles: [] }, + document: { schemaVersion: 5, profiles: [] }, }; const saved: Array<{ profile: RemoteRuntimeHostProfile; credential?: string }> = []; const catalog: RuntimeHostProfileCatalog = { @@ -214,7 +241,7 @@ function createProfileCatalogCapture(): { save: async (profile: RemoteRuntimeHostProfile, credential?: string) => { saved.push({ profile, credential }); state.document = { - schemaVersion: 4, + schemaVersion: 5, profiles: [ ...state.document.profiles.filter((candidate) => candidate.id !== profile.id), profile, diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index e82e7b0642..abfde366c9 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -2549,7 +2549,12 @@ describe('managed Runtime Host service', () => { version, root: deploymentRoot, cliPath, - operatorPath: join(deploymentRoot, 'operator'), + operator: { + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath: join(deploymentRoot, 'operator.mjs'), + }, activate: async () => { assert.equal(insideLifecycle, true); order.push('activate'); @@ -2611,13 +2616,17 @@ describe('managed Runtime Host service', () => { ), ), runOperator: async ( - _operatorPath: string, + operator: import('@maka/runtime-host/operator').RuntimeHostOperatorCommand, args: readonly string[], invocation?: { readonly inheritedFds?: readonly number[]; readonly capabilityRequest?: RuntimeHostOperatorCapability; }, ) => { + assert.deepEqual(operator, { + kind: 'legacy_posix_executable', + executablePath: join(deploymentRoot, 'operator'), + }); const action = args[0]; assert.ok(action === 'status' || action === 'retire'); if (action === 'status') { diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 773952b251..6a0c46df41 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { execFile as execFileCallback } from 'node:child_process'; +import { execFile as execFileCallback, spawn } from 'node:child_process'; import { mkdir, mkdtemp, @@ -39,6 +39,7 @@ import { decodeRuntimeHostSetupFrame, encodeRuntimeHostSetupFrame, resolveRuntimeHostManagedDeploymentConfigPath, + runtimeHostManagedOperatorCommand, RUNTIME_HOST_SETUP_FRAME_PREFIX, type RuntimeHostManagedDeploymentConfig, } from '@maka/runtime-host/operator'; @@ -125,7 +126,6 @@ test('on-demand setup installs one exact deployment without a service backend', version: '1.2.3', root: join(canonicalDataHome, 'Maka', 'runtime-host-services', serviceId), cliPath: '/verified/package/dist/cli.js', - operatorPath: '/opt/maka/operator', activate: async () => undefined, cleanup: async () => undefined, rollback: async () => undefined, @@ -199,10 +199,14 @@ test('on-demand setup installs one exact deployment without a service backend', .map(decodeRuntimeHostSetupFrame) .find((frame) => frame?.kind === 'complete'); assert.ok(complete?.kind === 'complete'); + assert.equal(complete.operator.kind, 'node'); + if (complete.operator.kind !== 'node') assert.fail('Setup returned a legacy operator'); + assert.equal(complete.operator.nodePath, process.execPath); const persisted = JSON.parse( await readFile(resolveRuntimeHostManagedDeploymentConfigPath(rootId), 'utf8'), ) as { deploymentRoot: string; + launch: { nodePath: string }; lifecycle: { mode: string }; listeners: { websocket: { port: number } }; reconciliation: { trigger: string }; @@ -211,12 +215,19 @@ test('on-demand setup installs one exact deployment without a service backend', persisted.deploymentRoot, join(canonicalDataHome, 'Maka', 'runtime-host-services', rootId), ); + assert.equal(complete.operator.modulePath, join(persisted.deploymentRoot, 'operator.mjs')); assert.equal(projectedOperatorDeploymentRoot, persisted.deploymentRoot); assert.equal(persisted.lifecycle.mode, 'on_demand'); assert.equal(persisted.listeners.websocket.port, 0); assert.equal(persisted.reconciliation.trigger, 'activation'); const retryOutputs: string[] = []; + persisted.launch.nodePath = + process.platform === 'win32' ? 'C:\\Program Files\\nodejs\\node.exe' : '/opt/maka/node'; + await writeFile( + resolveRuntimeHostManagedDeploymentConfigPath(rootId), + `${JSON.stringify(persisted)}\n`, + ); projectedOperatorDeploymentRoot = '/stale/operator/projection'; assert.equal( await runRuntimeHostSetupCli(options, { @@ -233,6 +244,12 @@ test('on-demand setup installs one exact deployment without a service backend', retryComplete?.kind === 'complete' ? retryComplete.deploymentId : undefined, complete.deploymentId, ); + assert.equal( + retryComplete?.kind === 'complete' && retryComplete.operator.kind === 'node' + ? retryComplete.operator.nodePath + : undefined, + persisted.launch.nodePath, + ); assert.deepEqual( JSON.parse(await readFile(resolveRuntimeHostManagedDeploymentConfigPath(rootId), 'utf8')), persisted, @@ -380,7 +397,12 @@ test('fresh supervised setup discovers its provider before constructing a legacy version: '1.2.3', root: join(base, 'deployment'), cliPath: '/verified/package/dist/cli.js', - operatorPath: '/opt/maka/operator', + operator: { + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath: '/opt/maka/operator.mjs', + }, activate: async () => undefined, cleanup: async () => undefined, rollback: async () => undefined, @@ -438,6 +460,30 @@ test('managed setup frames reject malformed machine output', () => { ), undefined, ); + assert.equal( + decodeRuntimeHostSetupFrame( + `${RUNTIME_HOST_SETUP_FRAME_PREFIX}${Buffer.from( + JSON.stringify({ + schemaVersion: 1, + sequence: 1, + kind: 'complete', + version: '0.2.0', + serviceId: 'a'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', + operator: { + kind: 'legacy_posix_executable', + executablePath: '/opt/maka/operator', + }, + rootPath: '/workspaces/default', + rootId: 'a'.repeat(64), + endpoint: 'ws://127.0.0.1:4321/runtime-host', + credentialId: 'credential', + credential: 'secret', + }), + ).toString('base64url')}\n`, + ), + undefined, + ); }); test('persisted OpenRC providers resolve without reselecting the platform default', () => { @@ -715,7 +761,7 @@ test('registry package identity avoids local content and recovers an interrupted } await convergeRuntimeHostManagedOperator(undefined, currentConfig); assert.equal( - (await readFile(join(currentDeploymentRoot, 'operator'), 'utf8')).includes( + (await readFile(join(currentDeploymentRoot, 'operator.mjs'), 'utf8')).includes( join(currentDeploymentRoot, 'versions', basename(registryRoot), 'dist', 'cli.js'), ), true, @@ -775,7 +821,12 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' lifecycle: { mode: 'on_demand', availability: 'activation' }, reconciliation: { trigger: 'manual' }, }; + const legacyOperatorPath = join(deployment.root, 'operator'); + await writeFile(legacyOperatorPath, '#!/bin/sh\nexit 99\n'); + await deployment.activate(); + assert.match(await readFile(legacyOperatorPath, 'utf8'), /operator\.mjs/u); await convergeRuntimeHostManagedOperator(undefined, config); + const operator = runtimeHostManagedOperatorCommand(config, 'posix'); const authorityRoot = join(base, 'authority'); await mkdir(authorityRoot); const authority = { authorityRoot, durabilityBoundary: authorityRoot }; @@ -802,7 +853,7 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' deployment.cliPath, `require('node:fs').writeFileSync(process.env.MAKA_TEST_OUTPUT, JSON.stringify(process.argv.slice(2)));\n`, ); - await execFile(deployment.operatorPath, ['status'], { + await execFile(legacyOperatorPath, ['status'], { env: { ...process.env, XDG_CONFIG_HOME: join(base, 'different-config'), @@ -822,8 +873,8 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' ]); await execFile( - deployment.operatorPath, - ['access', 'list', '--root', '/runtime-root', '--framed'], + operator.nodePath, + [operator.modulePath, 'access', 'list', '--root', '/runtime-root', '--framed'], { env: { ...process.env, MAKA_TEST_OUTPUT: invocationPath }, }, @@ -837,9 +888,11 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' '--framed', ]); - await execFile(deployment.operatorPath, ['activate', '--framed', '--root-id', 'a'.repeat(64)], { - env: { ...process.env, MAKA_TEST_OUTPUT: invocationPath }, - }); + await execFile( + operator.nodePath, + [operator.modulePath, 'activate', '--framed', '--root-id', 'a'.repeat(64)], + { env: { ...process.env, MAKA_TEST_OUTPUT: invocationPath } }, + ); assert.deepEqual(JSON.parse(await readFile(invocationPath, 'utf8')), [ 'runtime-host', 'activate', @@ -848,9 +901,11 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' 'a'.repeat(64), ]); - await execFile(deployment.operatorPath, ['connect', '--framed', '--root-id', 'a'.repeat(64)], { - env: { ...process.env, MAKA_TEST_OUTPUT: invocationPath }, - }); + await execFile( + operator.nodePath, + [operator.modulePath, 'connect', '--framed', '--root-id', 'a'.repeat(64)], + { env: { ...process.env, MAKA_TEST_OUTPUT: invocationPath } }, + ); assert.deepEqual(JSON.parse(await readFile(invocationPath, 'utf8')), [ 'runtime-host', 'connect', @@ -860,8 +915,9 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' ]); await execFile( - deployment.operatorPath, + operator.nodePath, [ + operator.modulePath, '__cleanup-managed-deployment', '--expected-service-id', serviceId, @@ -891,6 +947,16 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' '--operator-deployment-id', '00000000-0000-4000-8000-000000000001', ]); + + await writeFile(deployment.cliPath, "process.kill(process.pid, 'SIGTERM');\n"); + const signalExit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + const child = spawn(operator.nodePath, [operator.modulePath, 'status'], { stdio: 'ignore' }); + child.once('error', reject); + child.once('exit', (code, signal) => resolve({ code, signal })); + }, + ); + assert.deepEqual(signalExit, { code: null, signal: 'SIGTERM' }); }); async function createReleasePackage(base: string, version: string): Promise { diff --git a/packages/cli/src/__tests__/runtime-host-tui-context.test.ts b/packages/cli/src/__tests__/runtime-host-tui-context.test.ts index 689b98979d..947d1b75a4 100644 --- a/packages/cli/src/__tests__/runtime-host-tui-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-tui-context.test.ts @@ -35,7 +35,12 @@ const ENVIRONMENT_PROFILE: RuntimeHostProfile = { kind: 'environment', provider: { kind: 'wsl', distribution: 'Ubuntu' }, rootId: 'b'.repeat(64), - operatorPath: '/opt/maka/operator', + operator: { + kind: 'node', + platform: 'posix', + nodePath: '/usr/bin/node', + modulePath: '/opt/maka/operator.mjs', + }, }; const HOST_WORKSPACE_PROFILES = [REMOTE_PROFILE, ENVIRONMENT_PROFILE] as const; diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 7c2fc2d315..4348e8eb6d 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -782,7 +782,7 @@ export async function runMakaCli( id: command.id, name: command.name, distribution: command.distribution, - operatorPath: command.operatorPath, + operator: command.operator, expectedRootId: command.expectedRootId, }, {}, diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index 0fae0b15e8..0915703d62 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -22,9 +22,12 @@ import { isProductReleaseVersion, isSha512PackageIntegrity, } from '@maka/runtime-host/operator/update-package-evidence'; -import type { RuntimeHostManagedUpdatePolicy } from '@maka/runtime-host/operator'; import { + createRuntimeHostLegacyPosixOperatorCommand, + decodeRuntimeHostPosixOperatorCommand, decodeRuntimeHostWebRtcStunPolicy, + type RuntimeHostManagedUpdatePolicy, + type RuntimeHostPosixOperatorCommand, type RuntimeHostWebRtcStunPolicy, } from '@maka/runtime-host/operator'; import { @@ -364,7 +367,7 @@ export type RuntimeHostCliCommand = id: string; name: string; distribution: string; - operatorPath: string; + operator: RuntimeHostPosixOperatorCommand; expectedRootId: string; } | { kind: 'runtime-host-profile-remove'; id: string } @@ -1708,6 +1711,7 @@ function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { let sshWebSocketPath = '/runtime-host'; let sshWebSocketPathConfigured = false; let wslDistribution: string | undefined; + let operator: RuntimeHostPosixOperatorCommand | undefined; let operatorPath: string | undefined; let expectedRootId: string | undefined; let credentialEnv: string | undefined; @@ -1723,6 +1727,7 @@ function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { argument !== '--ssh-remote-port' && argument !== '--ssh-websocket-path' && argument !== '--wsl-distribution' && + argument !== '--operator-command' && argument !== '--operator-path' && argument !== '--expected-root' && argument !== '--credential-env' && @@ -1748,6 +1753,13 @@ function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { sshWebSocketPathConfigured = true; } if (argument === '--wsl-distribution') wslDistribution = parsed; + if (argument === '--operator-command') { + try { + operator = decodeRuntimeHostPosixOperatorCommand(JSON.parse(parsed)); + } catch { + return error('--operator-command must be a valid Runtime Host operator command JSON value'); + } + } if (argument === '--operator-path') operatorPath = parsed; if (argument === '--expected-root') expectedRootId = parsed; if (argument === '--credential-env') credentialEnv = parsed; @@ -1755,6 +1767,16 @@ function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { } if (!id) return error('--id is required'); if (!name) return error('--name is required'); + if (operator && operatorPath) { + return error('--operator-command and --operator-path cannot be combined'); + } + if (operatorPath) { + try { + operator = createRuntimeHostLegacyPosixOperatorCommand(operatorPath); + } catch { + return error('--operator-path must be an absolute POSIX path'); + } + } if ( (tlsUrl ? 1 : 0) + (plaintextUrl ? 1 : 0) + @@ -1766,12 +1788,11 @@ function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { 'exactly one of --tls-url, --plaintext-url, --ssh-destination, or --wsl-distribution is required', ); } - if (wslDistribution && !operatorPath) { - return error('--wsl-distribution requires --operator-path'); - } - if (!wslDistribution && operatorPath) { - return error('--operator-path requires --wsl-distribution'); + if (wslDistribution && !operator) { + return error('--wsl-distribution requires --operator-command or --operator-path'); } + if (!wslDistribution && operatorPath) return error('--operator-path requires --wsl-distribution'); + if (!wslDistribution && operator) return error('--operator-command requires --wsl-distribution'); if (wslDistribution && credentialEnv) { return error('WSL environment profiles do not accept --credential-env'); } @@ -1806,7 +1827,7 @@ function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { id, name, distribution: wslDistribution, - operatorPath: operatorPath!, + operator: operator!, expectedRootId, }; } diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index 60e24492a6..cd30881c89 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -18,7 +18,6 @@ */ import { randomUUID } from 'node:crypto'; -import { join } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { resolveExistingStorageRoot, @@ -41,6 +40,7 @@ import { readRuntimeHostManagedDeploymentAuthorityRecord, resolveRuntimeHostManagedDeploymentAuthority, resolveRuntimeHostNpmDeploymentLayout, + runtimeHostManagedOperatorModulePath, rollbackRuntimeHostManagedDeploymentTransition, RuntimeHostManagedDeploymentError, type RuntimeHostManagedDeploymentAuthorityOptions, @@ -859,7 +859,15 @@ export function runtimeHostReconciliationTriggerDefinition( ): RuntimeHostProviderDefinition { const canonical = decodeRuntimeHostManagedDeploymentConfig(config); return { - command: [join(canonical.deploymentRoot, 'operator'), 'reconcile-update', '--framed'], + command: [ + canonical.launch.nodePath, + runtimeHostManagedOperatorModulePath( + canonical.deploymentRoot, + process.platform === 'win32' ? 'win32' : 'posix', + ), + 'reconcile-update', + '--framed', + ], }; } diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index 16bfb9977c..46fbf42571 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; import { constants } from 'node:fs'; import { access, lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -36,6 +37,7 @@ import { resolveRuntimeHostManagedDeploymentAuthorityRoot, resolveRuntimeHostManagedDeploymentAuthority, resolveRuntimeHostNpmDeploymentLayout, + runtimeHostManagedOperatorModulePath, type RuntimeHostManagedDeploymentAuthorityOptions, type RuntimeHostManagedDeploymentConfig, } from '@maka/runtime-host/operator'; @@ -46,7 +48,6 @@ export interface RuntimeHostManagedPackageDeployment { readonly version: string; readonly root: string; readonly cliPath: string; - readonly operatorPath: string; /** Legacy service replacement only; canonical deployments project the operator transactionally. */ activate(): Promise; cleanup(): Promise; @@ -429,11 +430,16 @@ function resolveRuntimeHostManagedDataHome( ): string { const env = options.env ?? process.env; const homeDir = options.homeDir ?? homedir(); - return (options.platform ?? process.platform) === 'darwin' + const platform = options.platform ?? process.platform; + return platform === 'darwin' ? join(homeDir, 'Library', 'Application Support') - : env.XDG_DATA_HOME && isAbsolute(env.XDG_DATA_HOME) - ? env.XDG_DATA_HOME - : join(homeDir, '.local', 'share'); + : platform === 'win32' + ? env.LOCALAPPDATA && isAbsolute(env.LOCALAPPDATA) + ? env.LOCALAPPDATA + : join(homeDir, 'AppData', 'Local') + : env.XDG_DATA_HOME && isAbsolute(env.XDG_DATA_HOME) + ? env.XDG_DATA_HOME + : join(homeDir, '.local', 'share'); } export function resolveRuntimeHostManagedControlRoot(serviceId: string): string { @@ -588,8 +594,37 @@ export async function removeRuntimeHostManagedDeployment( // recognized as already complete and reclaimed by the next deployment. await syncDirectory(parent); } - await rm(retiredRoot, { recursive: true, force: true }); - await syncDirectory(parent); + try { + await rm(retiredRoot, { recursive: true, force: true }); + await syncDirectory(parent); + } catch (error) { + if (process.platform !== 'win32') throw error; + scheduleWindowsDeploymentCleanup(retiredRoot); + } +} + +function scheduleWindowsDeploymentCleanup(path: string): void { + // Windows keeps loaded native addons locked until this operator exits. The + // deployment was already atomically renamed out of service, so a detached + // Node process can finish physical reclamation without owning lifecycle state. + const script = `const { rm } = require('node:fs/promises'); +const path = process.argv[1]; +const parent = Number(process.argv[2]); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +(async () => { + for (let attempt = 0; attempt < 3000; attempt += 1) { + try { process.kill(parent, 0); } catch { break; } + await wait(100); + } + await rm(path, { recursive: true, force: true, maxRetries: 100, retryDelay: 100 }); +})().catch(() => { process.exitCode = 1; });`; + const cleanup = spawn(process.execPath, ['-e', script, path, String(process.pid)], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + cleanup.on('error', () => undefined); + cleanup.unref(); } function managedDeployment( @@ -597,20 +632,24 @@ function managedDeployment( clientDataRoot: string, managedRootId: string, ): RuntimeHostManagedPackageDeployment { - const operatorPath = join(staged.root, 'operator'); + const modulePath = runtimeHostManagedOperatorModulePath( + staged.root, + process.platform === 'win32' ? 'win32' : 'posix', + ); return { version: staged.version, root: staged.root, cliPath: staged.cliPath, - operatorPath, - activate: () => - writeOperatorLauncher( - operatorPath, + activate: async () => { + await writeOperatorLauncher( + modulePath, process.execPath, staged.cliPath, clientDataRoot, managedRootId, - ), + ); + await forwardLegacyOperatorIfPresent(staged.root, process.execPath, modulePath); + }, cleanup: staged.cleanup, rollback: staged.rollback, }; @@ -624,7 +663,6 @@ async function writeOperatorLauncher( managedRootId: string, deploymentId?: string, ): Promise { - const temporaryPath = `${path}.${randomUUID()}.tmp`; const contents = operatorLauncherContents( nodePath, cliPath, @@ -632,6 +670,11 @@ async function writeOperatorLauncher( managedRootId, deploymentId, ); + await writeStableOperator(path, contents); +} + +async function writeStableOperator(path: string, contents: string): Promise { + const temporaryPath = `${path}.${randomUUID()}.tmp`; try { const file = await open(temporaryPath, 'wx', 0o700); try { @@ -641,12 +684,7 @@ async function writeOperatorLauncher( await file.close(); } await rename(temporaryPath, path); - const parent = await open(dirname(path), 'r'); - try { - await parent.sync(); - } finally { - await parent.close(); - } + await syncDirectory(dirname(path)); } finally { await rm(temporaryPath, { force: true }); } @@ -659,31 +697,46 @@ function operatorLauncherContents( managedRootId: string, deploymentId?: string, ): string { - return [ - '#!/bin/sh', - 'if [ "$#" -ge 1 ] && [ "$1" = "__cleanup-managed-deployment" ]; then', - ' shift', - ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service cleanup-deployment "$@" --client-data-root ${quotePosix(clientDataRoot)} --managed-root-id ${quotePosix(managedRootId)}${deploymentId ? ` --operator-deployment-id ${quotePosix(deploymentId)}` : ''}`, - 'fi', - 'if [ "$#" -ge 1 ] && [ "$1" = "access" ]; then', - ' shift', - ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host access "$@"`, - 'fi', - 'if [ "$#" -ge 1 ] && [ "$1" = "activate" ]; then', - ' shift', - ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host activate "$@"`, - 'fi', - 'if [ "$#" -ge 1 ] && [ "$1" = "connect" ]; then', - ' shift', - ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host connect "$@"`, - 'fi', - 'if [ "$#" -ge 1 ] && [ "$1" = "serve" ]; then', - ' shift', - ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host serve "$@"`, - 'fi', - `exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service "$@" --client-data-root ${quotePosix(clientDataRoot)} --managed-root-id ${quotePosix(managedRootId)}${deploymentId ? ` --operator-deployment-id ${quotePosix(deploymentId)}` : ''}`, - '', - ].join('\n'); + const fixedServiceArguments = [ + '--client-data-root', + clientDataRoot, + '--managed-root-id', + managedRootId, + ...(deploymentId ? ['--operator-deployment-id', deploymentId] : []), + ]; + return `import { spawn } from 'node:child_process'; + +const [action, ...args] = process.argv.slice(2); +const direct = new Set(['access', 'activate', 'connect', 'serve']); +const cliArgs = action === '__cleanup-managed-deployment' + ? ['runtime-host', 'service', 'cleanup-deployment', ...args, ...${JSON.stringify(fixedServiceArguments)}] + : direct.has(action) + ? ['runtime-host', action, ...args] + : ['runtime-host', 'service', ...(action === undefined ? [] : [action]), ...args, ...${JSON.stringify(fixedServiceArguments)}]; +const child = spawn(${JSON.stringify(nodePath)}, [${JSON.stringify(cliPath)}, ...cliArgs], { + stdio: 'inherit', + windowsHide: true, +}); +const signalForwarders = new Map(); +for (const signal of ['SIGINT', 'SIGTERM']) { + const forward = () => child.kill(signal); + signalForwarders.set(signal, forward); + process.on(signal, forward); +} +const stopForwardingSignals = () => { + for (const [signal, forward] of signalForwarders) process.off(signal, forward); +}; +child.once('error', (error) => { + stopForwardingSignals(); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); +child.once('exit', (code, signal) => { + stopForwardingSignals(); + if (signal) process.kill(process.pid, signal); + else process.exitCode = code ?? 1; +}); +`; } export async function convergeRuntimeHostManagedOperator( @@ -695,7 +748,10 @@ export async function convergeRuntimeHostManagedOperator( // The stable operator is the bounded cleanup and recovery route after authority // is removed. Package cleanup removes it with the deployment root. if (!desired) return; - const operatorPath = join(deployment.deploymentRoot, 'operator'); + const operatorPath = runtimeHostManagedOperatorModulePath( + deployment.deploymentRoot, + process.platform === 'win32' ? 'win32' : 'posix', + ); const layout = resolveRuntimeHostNpmDeploymentLayout( desired.deploymentRoot, desired.launch.package.integrity, @@ -708,6 +764,34 @@ export async function convergeRuntimeHostManagedOperator( desired.root.id, desired.deploymentId, ); + await forwardLegacyOperatorIfPresent( + deployment.deploymentRoot, + desired.launch.nodePath, + operatorPath, + ); +} + +function legacyOperatorLauncherContents(nodePath: string, modulePath: string): string { + return `#!/bin/sh\nexec ${quotePosix(nodePath)} ${quotePosix(modulePath)} "$@"\n`; +} + +async function forwardLegacyOperatorIfPresent( + deploymentRoot: string, + nodePath: string, + modulePath: string, +): Promise { + if (process.platform === 'win32') return; + const path = join(deploymentRoot, 'operator'); + const exists = await access(path, constants.F_OK).then( + () => true, + (error: unknown) => { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + }, + ); + if (exists) { + await writeStableOperator(path, legacyOperatorLauncherContents(nodePath, modulePath)); + } } export async function restoreRuntimeHostLegacyManagedOperator(input: { @@ -718,7 +802,7 @@ export async function restoreRuntimeHostLegacyManagedOperator(input: { readonly serviceId: string; }): Promise { await writeOperatorLauncher( - join(input.deploymentRoot, 'operator'), + runtimeHostManagedOperatorModulePath(input.deploymentRoot, 'posix'), input.nodePath, input.cliPath, input.clientDataRoot, @@ -740,28 +824,58 @@ export async function verifyRuntimeHostManagedOperator( config.root.id, config.deploymentId, ); + const operatorPath = runtimeHostManagedOperatorModulePath( + config.deploymentRoot, + process.platform === 'win32' ? 'win32' : 'posix', + ); const observed = await readStableBoundedFile({ - path: join(config.deploymentRoot, 'operator'), + path: operatorPath, maxBytes: Buffer.byteLength(expected), invalidFile: () => new Error('The managed Runtime Host operator is not a stable regular file'), }).then((contents) => new TextDecoder('utf-8', { fatal: true }).decode(contents)); if (observed !== expected) throw new Error('The managed Runtime Host operator does not match its deployment'); - await access(join(config.deploymentRoot, 'operator'), constants.X_OK).catch((error: unknown) => { - throw new Error('The managed Runtime Host operator is not executable', { + await access(operatorPath, constants.R_OK).catch((error: unknown) => { + throw new Error('The managed Runtime Host operator is not readable', { cause: error, }); }); -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; + const legacyOperatorPath = join(config.deploymentRoot, 'operator'); + const legacyExpected = legacyOperatorLauncherContents(config.launch.nodePath, operatorPath); + const legacyExists = await access(legacyOperatorPath, constants.F_OK).then( + () => true, + (error: unknown) => { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + }, + ); + const legacyObserved = legacyExists + ? await readStableBoundedFile({ + path: legacyOperatorPath, + maxBytes: Buffer.byteLength(legacyExpected), + invalidFile: () => new Error('The legacy managed Runtime Host operator is invalid'), + }).then((contents) => new TextDecoder('utf-8', { fatal: true }).decode(contents)) + : null; + if (legacyObserved !== null && legacyObserved !== legacyExpected) { + throw new Error('The legacy managed Runtime Host operator does not match its deployment'); + } + if (legacyObserved !== null) { + await access(legacyOperatorPath, constants.X_OK).catch((error: unknown) => { + throw new Error('The legacy managed Runtime Host operator is not executable', { + cause: error, + }); + }); + } } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error && error.code === code; } diff --git a/packages/cli/src/runtime-host-profile-command.ts b/packages/cli/src/runtime-host-profile-command.ts index 30339bf2f1..cdeace6907 100644 --- a/packages/cli/src/runtime-host-profile-command.ts +++ b/packages/cli/src/runtime-host-profile-command.ts @@ -23,6 +23,7 @@ import { type RuntimeHostRemoteTransport, type RuntimeHostProfileCatalog, } from '@maka/runtime-host/client'; +import type { RuntimeHostPosixOperatorCommand } from '@maka/runtime-host/operator'; import { resolveMakaClientDataRoot } from '@maka/storage/workspace-root'; const DEFAULT_CREDENTIAL_ENV = 'MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'; @@ -42,7 +43,7 @@ export type RuntimeHostProfileCommand = readonly id: string; readonly name: string; readonly distribution: string; - readonly operatorPath: string; + readonly operator: RuntimeHostPosixOperatorCommand; readonly expectedRootId: string; } | { readonly kind: 'remove'; readonly id: string }; @@ -84,7 +85,7 @@ export async function runRuntimeHostProfileCommand( name: command.name, kind: 'environment', provider: { kind: 'wsl', distribution: command.distribution }, - operatorPath: command.operatorPath, + operator: command.operator, rootId: command.expectedRootId, } : { diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index e2a11e0176..a4739616f4 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -34,9 +34,11 @@ import { encodeRuntimeHostSetupFrame, isSha512PackageIntegrity, resolveRuntimeHostManagedDeployment, + runtimeHostManagedOperatorCommand, RUNTIME_HOST_SETUP_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SETUP_ERROR_MESSAGE_MAX_BYTES, type RuntimeHostManagedDeploymentConfig, + type RuntimeHostNodeOperatorCommand, type RuntimeHostSetupFrame, type RuntimeHostSetupPhase, type RuntimeHostSupervisorProvider, @@ -349,7 +351,7 @@ async function runRuntimeHostSupervisedSetupLocked( ): Promise<{ readonly serviceId: string; readonly deploymentId: string; - readonly operatorPath: string; + readonly operator: RuntimeHostNodeOperatorCommand; readonly rootPath: string; readonly endpoint: string; readonly directPeer?: { @@ -541,7 +543,10 @@ async function runRuntimeHostSupervisedSetupLocked( return { serviceId: capability.rootId, deploymentId: desired.deploymentId, - operatorPath: deployment.operatorPath, + operator: runtimeHostManagedOperatorCommand( + desired, + process.platform === 'win32' ? 'win32' : 'posix', + ), rootPath: capability.canonicalPath, endpoint: websocketUrl(websocket), ...(directPeer @@ -808,7 +813,6 @@ async function runRuntimeHostOnDemandSetupLocked( const reuseCurrent = current?.lifecycle.mode === 'on_demand' && sameDesiredManagedDeployment(current, draft); const config = reuseCurrent ? current : draft; - let operatorPath: string | undefined; let activation: Awaited> | undefined; const lifecycleDeps: RuntimeHostLifecycleTransactionDeps = { convergeOperator: (currentConfig, desiredConfig) => @@ -819,7 +823,7 @@ async function runRuntimeHostOnDemandSetupLocked( ? legacyMigrationDeps(legacyToMigrate, legacyBackend, legacyServiceId, options.clientDataRoot) : {}), }; - await resolvedPackage.use(async (packageRoot) => { + const deployedConfig = await resolvedPackage.use(async (packageRoot) => { let committed = false; const created = !current; let deployment: Awaited> | undefined; @@ -849,7 +853,6 @@ async function runRuntimeHostOnDemandSetupLocked( const desiredConfig: RuntimeHostManagedDeploymentConfig = current ? config : { ...config, deploymentRoot: deployment.root }; - operatorPath = deployment.operatorPath; emit({ kind: 'progress', phase: 'installing_service' }); if (legacyToMigrate && legacyBackend) { await legacyBackend.verifyDeployment(legacyToMigrate, { @@ -893,6 +896,7 @@ async function runRuntimeHostOnDemandSetupLocked( await deps.prunePackages( (await resolveRuntimeHostManagedDeployment(capability.rootId)).config, ); + return desiredConfig; } catch (error) { if (!committed && canDiscardRuntimeHostLifecycleDesiredArtifacts(error)) { if (packageChanged && deployment) await deployment.rollback().catch(() => undefined); @@ -905,9 +909,6 @@ async function runRuntimeHostOnDemandSetupLocked( throw error; } }); - if (!operatorPath) - throw new RuntimeHostSetupError('deployment_failed', 'Setup did not install an operator'); - activation = await deps.activateManaged({ rootId: capability.rootId }); if (legacyConfig) { await removeRuntimeHostServiceFile( @@ -925,8 +926,11 @@ async function runRuntimeHostOnDemandSetupLocked( options, { serviceId, - deploymentId: config.deploymentId, - operatorPath, + deploymentId: deployedConfig.deploymentId, + operator: runtimeHostManagedOperatorCommand( + deployedConfig, + process.platform === 'win32' ? 'win32' : 'posix', + ), rootPath: capability.canonicalPath, endpoint: websocketUrl({ host: activation.endpoint.host, @@ -1079,7 +1083,7 @@ async function pairAndVerifyRuntimeHostSetup( target: { readonly serviceId: string; readonly deploymentId: string; - readonly operatorPath: string; + readonly operator: RuntimeHostNodeOperatorCommand; readonly rootPath: string; readonly endpoint: string; readonly directPeer?: { @@ -1145,7 +1149,7 @@ async function pairAndVerifyRuntimeHostSetup( version: options.version, serviceId: target.serviceId, deploymentId: target.deploymentId, - operatorPath: target.operatorPath, + operator: target.operator, rootPath: target.rootPath, rootId: paired.rootId, endpoint: target.endpoint, diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 9914640edd..3f815971f7 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -22,6 +22,8 @@ import { dirname, join, resolve } from 'node:path'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { activateRuntimeHostManagedDeployment } from '@maka/runtime-host/client'; import { + createRuntimeHostLegacyPosixOperatorCommand, + runtimeHostManagedOperatorCommand, decodeRuntimeHostServiceManagementFrame, encodeRuntimeHostServiceManagementFrame, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, @@ -29,7 +31,9 @@ import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, + runtimeHostOperatorInvocation, type RuntimeHostOperatorCapability, + type RuntimeHostOperatorCommand, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, RuntimeHostManagedDeploymentError as RuntimeHostDeploymentAuthorityError, @@ -130,7 +134,7 @@ interface RuntimeHostUpdateCliDeps { readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend; readonly verifyReady: typeof verifyRuntimeHostManagedServiceReady; readonly runOperator: ( - operatorPath: string, + operator: RuntimeHostOperatorCommand, args: readonly string[], invocation?: RuntimeHostOperatorInvocation, ) => Promise; @@ -349,14 +353,16 @@ export async function runManagedRuntimeHostUpdateCli( } } - const currentOperatorPath = join(serviceConfig.managedDeploymentRoot, 'operator'); + const currentOperator = createRuntimeHostLegacyPosixOperatorCommand( + join(serviceConfig.managedDeploymentRoot, 'operator'), + ); let currentOperatorUsesProcessLifetimeLock = false; let currentOperatorUnavailable = false; if (status.service.active) { try { currentOperatorUsesProcessLifetimeLock = operatorUsesProcessLifetimeLock( await deps.runOperator( - currentOperatorPath, + currentOperator, ['status', '--framed', ...expectedTargetArgs(options.expectedTarget)], { capabilityRequest: RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, @@ -409,9 +415,9 @@ export async function runManagedRuntimeHostUpdateCli( emit(progress('retiring', currentVersion, options.version)); const runCurrentOperator = (args: readonly string[]) => currentOperatorUsesProcessLifetimeLock - ? deps.runOperator(currentOperatorPath, args) + ? deps.runOperator(currentOperator, args) : deps.withLegacyOperatorLeases(options.clientDataRoot, (inheritedFds) => - deps.runOperator(currentOperatorPath, args, { + deps.runOperator(currentOperator, args, { inheritedFds, }), ); @@ -1090,13 +1096,14 @@ function operatorCapabilities(): { } async function runManagedRuntimeHostOperator( - operatorPath: string, + operator: RuntimeHostOperatorCommand, args: readonly string[], invocation: RuntimeHostOperatorInvocation = {}, ): Promise { return new Promise((resolve, reject) => { const inheritedFds = invocation.inheritedFds ?? []; - const child = spawn(operatorPath, [...args], { + const command = runtimeHostOperatorInvocation(operator, args); + const child = spawn(command.executable, [...command.args], { // A detached legacy operator keeps the inherited advisory leases alive if // this updater is interrupted, so an exact retry never steals active work. detached: process.platform !== 'win32', diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index cd36ca4212..45cc7b654a 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -33,6 +33,7 @@ import { createFileRuntimeHostProfileCatalog, createRuntimeHostCapabilityProviderCredentialStore, createRuntimeHostProfileCredentialStore, + decodeEnvironmentRuntimeHostProfile, decodeRemoteRuntimeHostProfile, decodeRuntimeHostProfileDocument, RUNTIME_HOST_PLAINTEXT_ACKNOWLEDGEMENT, @@ -54,6 +55,12 @@ import { RuntimeHostPeerError } from '../transport/peer-native.js'; const ROOT_A = 'a'.repeat(64); const ROOT_B = 'b'.repeat(64); +const OPERATOR = { + kind: 'node', + platform: 'posix', + nodePath: '/usr/bin/node', + modulePath: '/opt/maka/operator.mjs', +} as const; const temporaryDirectories: string[] = []; afterEach(async () => { @@ -64,14 +71,14 @@ describe('Runtime Host profiles', () => { test('persists WSL environments without projecting a remote credential', async () => { const path = await profilePath(); const catalog = createFileRuntimeHostProfileCatalog(path, memoryCredentials()); - assert.deepEqual(await catalog.read(), { schemaVersion: 4, profiles: [] }); + assert.deepEqual(await catalog.read(), { schemaVersion: 5, profiles: [] }); await catalog.create({ id: 'ubuntu', name: 'Ubuntu', kind: 'environment', provider: { kind: 'wsl', distribution: 'Ubuntu-24.04' }, rootId: ROOT_A, - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }); assert.deepEqual(await catalog.resolve('ubuntu'), { profile: { @@ -80,11 +87,70 @@ describe('Runtime Host profiles', () => { kind: 'environment', provider: { kind: 'wsl', distribution: 'Ubuntu-24.04' }, rootId: ROOT_A, - operatorPath: '/home/operator/.local/share/maka/operator', + operator: OPERATOR, }, }); assert.doesNotMatch(await readFile(path, 'utf8'), /credential/u); await assert.rejects(() => catalog.remove('local'), /cannot be removed/); + assert.throws( + () => + decodeEnvironmentRuntimeHostProfile({ + id: 'windows-command', + name: 'Invalid WSL', + kind: 'environment', + provider: { kind: 'wsl', distribution: 'Ubuntu-24.04' }, + rootId: ROOT_A, + operator: { + kind: 'node', + platform: 'win32', + nodePath: 'C:\\Program Files\\nodejs\\node.exe', + modulePath: 'C:\\Maka\\operator.mjs', + }, + }), + /must target POSIX/u, + ); + }); + + test('migrates a released WSL operator path without losing its environment', async () => { + const path = await profilePath(); + await writeFile( + path, + `${JSON.stringify({ + schemaVersion: 2, + profiles: [ + { + id: 'ubuntu', + name: 'Ubuntu', + kind: 'environment', + provider: { kind: 'wsl', distribution: 'Ubuntu-24.04' }, + rootId: ROOT_A, + operatorPath: '/home/operator/.local/share/maka/operator', + }, + ], + })}\n`, + ); + + const catalog = createFileRuntimeHostProfileCatalog(path, memoryCredentials()); + const migrated = await catalog.resolve('ubuntu'); + assert.deepEqual(migrated, { + profile: { + id: 'ubuntu', + name: 'Ubuntu', + kind: 'environment', + provider: { kind: 'wsl', distribution: 'Ubuntu-24.04' }, + rootId: ROOT_A, + operator: { + kind: 'legacy_posix_executable', + executablePath: '/home/operator/.local/share/maka/operator', + }, + }, + }); + assert.match(await readFile(path, 'utf8'), /operatorPath/u); + if (migrated.profile.kind !== 'environment') assert.fail('WSL profile was not migrated'); + await catalog.save(migrated.profile); + const stored = await readFile(path, 'utf8'); + assert.match(stored, /"schemaVersion": 5/u); + assert.doesNotMatch(stored, /operatorPath/u); }); test('normalizes, serializes, updates, and removes remote profiles', async () => { @@ -124,7 +190,7 @@ describe('Runtime Host profiles', () => { ); assert.deepEqual(await catalog.read(), { - schemaVersion: 4, + schemaVersion: 5, profiles: [ { id: 'office', @@ -147,7 +213,7 @@ describe('Runtime Host profiles', () => { if (process.platform !== 'win32') assert.equal((await stat(path)).mode & 0o777, 0o600); assert.deepEqual(await catalog.remove('office'), { - schemaVersion: 4, + schemaVersion: 5, profiles: [ { id: 'backup', @@ -200,7 +266,7 @@ describe('Runtime Host profiles', () => { const catalog = createFileRuntimeHostProfileCatalog(path, memoryCredentials()); const document = await catalog.read(); - assert.equal(document.schemaVersion, 4); + assert.equal(document.schemaVersion, 5); assert.equal( (JSON.parse(await readFile(path, 'utf8')) as { schemaVersion: number }).schemaVersion, 1, @@ -220,7 +286,7 @@ describe('Runtime Host profiles', () => { transport: { kind: 'ssh', destination: 'operator@example.com', - activation: { kind: 'ssh_operator', operatorPath: '/opt/maka/bin/operator' }, + activation: { kind: 'ssh_operator', operator: OPERATOR }, }, rootId: ROOT_B, }, @@ -228,7 +294,7 @@ describe('Runtime Host profiles', () => { ); assert.equal( (JSON.parse(await readFile(path, 'utf8')) as { schemaVersion: number }).schemaVersion, - 2, + 5, ); }); @@ -281,7 +347,7 @@ describe('Runtime Host profiles', () => { assert.deepEqual(await desktop.removeIfCurrent(created), { removed: false, document: { - schemaVersion: 4, + schemaVersion: 5, profiles: [ { ...profile, @@ -295,7 +361,7 @@ describe('Runtime Host profiles', () => { assert.equal(rotated.credential, 'rotated-token'); assert.equal(rotated.profileIncarnationId, created.profileIncarnationId); assert.equal((await desktop.removeIfCurrent(rotated)).removed, true); - assert.deepEqual(await desktop.read(), { schemaVersion: 4, profiles: [] }); + assert.deepEqual(await desktop.read(), { schemaVersion: 5, profiles: [] }); }); test('conditionally updates one Host connection and credential', async () => { @@ -657,7 +723,7 @@ describe('Runtime Host profiles', () => { assert.equal(sameRemoteRuntimeHostProfileTarget(original, moved), true); assert.equal(sameRemoteRuntimeHostProfileTarget(original, replacement), false); assert.deepEqual( - decodeRuntimeHostProfileDocument({ schemaVersion: 4, profiles: [moved] }).profiles[0], + decodeRuntimeHostProfileDocument({ schemaVersion: 5, profiles: [moved] }).profiles[0], moved, ); }); @@ -858,7 +924,7 @@ describe('Runtime Host profiles', () => { transport: { kind: 'ssh', destination: 'operator@example.com', - activation: { kind: 'ssh_operator', operatorPath: '/opt/maka/operator' }, + activation: { kind: 'ssh_operator', operator: OPERATOR }, }, rootId: ROOT_A, }, @@ -869,7 +935,7 @@ describe('Runtime Host profiles', () => { { activateSshOperator: async (input) => { events.push('activate'); - assert.equal(input.operatorPath, '/opt/maka/operator'); + assert.deepEqual(input.operator, OPERATOR); assert.equal(input.rootId, ROOT_A); assert.equal(input.interaction, 'terminal'); return { diff --git a/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts b/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts index a5550260eb..927423add1 100644 --- a/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts +++ b/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts @@ -18,10 +18,16 @@ */ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { PassThrough } from 'node:stream'; +import { promisify } from 'node:util'; import test from 'node:test'; import { activateRuntimeHostSshOperator, + runtimeHostSshOperatorRemoteCommand, type RuntimeHostSshOperatorProcess, type RuntimeHostSshOperatorProcessFactory, } from '../client/ssh-operator-activation.js'; @@ -32,6 +38,7 @@ import { import { RUNTIME_HOST_PROTOCOL_VERSION } from '../protocol/index.js'; const ROOT_ID = 'a'.repeat(64); +const execFileAsync = promisify(execFile); const RESULT = { schemaVersion: 1, kind: 'result', @@ -44,13 +51,20 @@ const RESULT = { endpoint: { host: '127.0.0.1', port: 45_678, websocketPath: '/runtime-host' }, } as const; +const operator = (modulePath: string) => ({ + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath, +}); + test('SSH activation accepts a strict final frame that drains after process exit', async () => { let invocation: Parameters[0] | undefined; const result = await activateRuntimeHostSshOperator( { destination: 'operator@example.com', sshPort: 2222, - operatorPath: "/opt/maka/operator's bin", + operator: operator("/opt/maka/operator's bin"), rootId: ROOT_ID, interaction: 'batch', }, @@ -83,16 +97,38 @@ test('SSH activation accepts a strict final frame that drains after process exit '-p', '2222', 'operator@example.com', - `'${"/opt/maka/operator's bin".replaceAll("'", `'"'"'`)}' activate --framed --root-id ${ROOT_ID}`, + `exec '/usr/bin/node' '${"/opt/maka/operator's bin".replaceAll("'", `'"'"'`)}' 'activate' '--framed' '--root-id' '${ROOT_ID}'`, ]); }); +test('POSIX operator commands apply environment before exec', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-ssh-operator-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const modulePath = join(directory, 'operator.mjs'); + await writeFile(modulePath, `process.stdout.write(process.env.MAKA_TEST_VALUE ?? 'missing');\n`); + const command = runtimeHostSshOperatorRemoteCommand( + { ...operator(modulePath), nodePath: process.execPath }, + [], + { MAKA_TEST_VALUE: "value with ' quotes" }, + ); + + const { stdout } = await execFileAsync('/bin/sh', ['-c', command]); + assert.equal(stdout, "value with ' quotes"); + assert.equal( + runtimeHostSshOperatorRemoteCommand( + { kind: 'legacy_posix_executable', executablePath: '/opt/maka/operator' }, + ['activate'], + ), + "exec '/opt/maka/operator' 'activate'", + ); +}); + test('SSH activation rejects multiple framed results', async () => { await assert.rejects( activateRuntimeHostSshOperator( { destination: 'operator@example.com', - operatorPath: '/opt/maka/operator', + operator: operator('/opt/maka/operator.mjs'), rootId: ROOT_ID, interaction: 'batch', }, @@ -117,7 +153,7 @@ test('SSH activation kills and rejects oversized operator output', async () => { activateRuntimeHostSshOperator( { destination: 'operator@example.com', - operatorPath: '/opt/maka/operator', + operator: operator('/opt/maka/operator.mjs'), rootId: ROOT_ID, interaction: 'batch', }, diff --git a/packages/runtime-host/src/__tests__/wsl-environment.test.ts b/packages/runtime-host/src/__tests__/wsl-environment.test.ts index 722be8d464..d024e3cbc5 100644 --- a/packages/runtime-host/src/__tests__/wsl-environment.test.ts +++ b/packages/runtime-host/src/__tests__/wsl-environment.test.ts @@ -22,6 +22,13 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import test from 'node:test'; import { connectRuntimeHostWslEnvironment } from '../client/wsl-environment.js'; +const operator = (modulePath: string) => ({ + kind: 'node' as const, + platform: 'posix' as const, + nodePath: '/usr/bin/node', + modulePath, +}); + test('passes WSL target values as literal argv to the absolute operator', async () => { const sentinel = new Error('stop after argv capture'); let invocation: { readonly executable: string; readonly args: readonly string[] } | undefined; @@ -29,7 +36,7 @@ test('passes WSL target values as literal argv to the absolute operator', async connectRuntimeHostWslEnvironment( { distribution: 'Ubuntu work; echo unsafe', - operatorPath: "/opt/Maka operator's/bin/maka-operator", + operator: operator("/opt/Maka operator's/bin/maka-operator.mjs"), rootId: 'a'.repeat(64), clientInstanceId: 'desktop-test', }, @@ -49,7 +56,8 @@ test('passes WSL target values as literal argv to the absolute operator', async '--distribution', 'Ubuntu work; echo unsafe', '--exec', - "/opt/Maka operator's/bin/maka-operator", + '/usr/bin/node', + "/opt/Maka operator's/bin/maka-operator.mjs", 'connect', '--framed', '--root-id', @@ -66,7 +74,7 @@ test('owns WSL bridge cancellation without emitting a child-stream error', async const connection = connectRuntimeHostWslEnvironment( { distribution: 'Ubuntu', - operatorPath: '/opt/maka/operator', + operator: operator('/opt/maka/operator.mjs'), rootId: 'a'.repeat(64), clientInstanceId: 'desktop-test', signal: abort.signal, @@ -94,7 +102,7 @@ test('contains oversized WSL bridge diagnostics inside the connection failure', connectRuntimeHostWslEnvironment( { distribution: 'Ubuntu', - operatorPath: '/opt/maka/operator', + operator: operator('/opt/maka/operator.mjs'), rootId: 'a'.repeat(64), clientInstanceId: 'desktop-test', handshakeTimeoutMs: 10_000, diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index 632cfdc428..209f94ce6e 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -20,7 +20,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { watch } from 'node:fs'; import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; -import { dirname, join, posix } from 'node:path'; +import { dirname, join } from 'node:path'; import { createFileCredentialStore, type CredentialStore } from '@maka/storage/credential-store'; import { withFileUpdateLock } from '@maka/storage/file-update-lock'; import { @@ -31,6 +31,13 @@ import { requireClientInstanceId, requireHostRootId, } from '../protocol/index.js'; +import { + createRuntimeHostLegacyPosixOperatorCommand, + decodeRuntimeHostOperatorCommand, + decodeRuntimeHostPosixOperatorCommand, + type RuntimeHostOperatorCommand, + type RuntimeHostPosixOperatorCommand, +} from '../operator/operator-command.js'; import type { RuntimeHostProfileOfKind } from '../profile-kind.js'; import { connectRemoteRuntimeHost, @@ -64,11 +71,10 @@ import { waitForRuntimeHostReady } from './wait-for-ready.js'; import { connectRuntimeHostWslEnvironment, normalizeRuntimeHostWslDistribution, - normalizeRuntimeHostWslOperatorPath, type RuntimeHostWslProcessFactory, } from './wsl-environment.js'; -const PROFILE_SCHEMA_VERSION = 4; +const PROFILE_SCHEMA_VERSION = 5; const CLIENT_PROFILE_DOCUMENT_NAME = 'runtime-host-profiles.json'; const PROFILE_DOCUMENT_MAX_BYTES = 64 * 1024; const PROFILE_COUNT_MAX = 32; @@ -101,7 +107,7 @@ export interface EnvironmentRuntimeHostProfile extends RuntimeHostProfileOfKind< readonly distribution: string; }; readonly rootId: string; - readonly operatorPath: string; + readonly operator: RuntimeHostPosixOperatorCommand; } export interface RemoteRuntimeHostProfile extends RuntimeHostProfileOfKind<'remote'> { @@ -143,7 +149,7 @@ export type RuntimeHostRemoteTransport = readonly sshPort?: number; readonly activation: { readonly kind: 'ssh_operator'; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; }; readonly remotePort?: never; readonly websocketPath?: never; @@ -191,6 +197,18 @@ export function sameResolvedRuntimeHostProfileTarget( ); } +export function sameEnvironmentRuntimeHostDeployment( + left: EnvironmentRuntimeHostProfile, + right: EnvironmentRuntimeHostProfile, +): boolean { + const leftProfile = decodeEnvironmentRuntimeHostProfile(left); + const rightProfile = decodeEnvironmentRuntimeHostProfile(right); + return ( + leftProfile.provider.distribution === rightProfile.provider.distribution && + leftProfile.rootId === rightProfile.rootId + ); +} + export function sameRemoteRuntimeHostProfileTarget( left: RemoteRuntimeHostProfile, right: RemoteRuntimeHostProfile, @@ -408,7 +426,7 @@ export async function connectRuntimeHostProfile( return (overrides.connectWsl ?? connectRuntimeHostWslEnvironment)( { distribution: input.profile.provider.distribution, - operatorPath: input.profile.operatorPath, + operator: input.profile.operator, rootId: input.profile.rootId, clientInstanceId: input.clientInstanceId, ...(input.signal === undefined ? {} : { signal: input.signal }), @@ -490,7 +508,7 @@ export async function connectRemoteRuntimeHostProfile( ? await (overrides.activateSshOperator ?? activateRuntimeHostSshOperator)({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: transport.activation.operatorPath, + operator: transport.activation.operator, rootId: input.profile.rootId, interaction: input.sshInteraction ?? 'batch', ...(input.signal === undefined ? {} : { signal: input.signal }), @@ -793,6 +811,7 @@ export function decodeRuntimeHostProfileDocument(value: unknown): RuntimeHostPro record.schemaVersion !== 1 && record.schemaVersion !== 2 && record.schemaVersion !== 3 && + record.schemaVersion !== 4 && record.schemaVersion !== PROFILE_SCHEMA_VERSION ) { throw new Error('Runtime Host profile document has an unsupported schema'); @@ -800,7 +819,13 @@ export function decodeRuntimeHostProfileDocument(value: unknown): RuntimeHostPro if (!Array.isArray(record.profiles) || record.profiles.length > PROFILE_COUNT_MAX) { throw new Error('Runtime Host profile document has an invalid profile list'); } - const profiles = record.profiles.map(decodePersistedRuntimeHostProfile); + const profiles = record.profiles.map((profile) => + decodePersistedRuntimeHostProfile( + (record.schemaVersion as number) < PROFILE_SCHEMA_VERSION + ? migrateRuntimeHostProfileOperatorCommand(profile) + : profile, + ), + ); if ( record.schemaVersion === 1 && profiles.some( @@ -836,6 +861,52 @@ export function decodeRuntimeHostProfileDocument(value: unknown): RuntimeHostPro }); } +export function migrateRuntimeHostProfileOperatorCommand(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const profile = value as Record; + if ( + profile.kind === 'environment' && + typeof profile.operatorPath === 'string' && + !Object.hasOwn(profile, 'operator') + ) { + const { operatorPath, ...rest } = profile; + return { + ...rest, + operator: createRuntimeHostLegacyPosixOperatorCommand(operatorPath), + }; + } + if (profile.kind !== 'remote' || !profile.transport || typeof profile.transport !== 'object') { + return value; + } + const transport = profile.transport as Record; + if ( + transport.kind !== 'ssh' || + !transport.activation || + typeof transport.activation !== 'object' + ) { + return value; + } + const activation = transport.activation as Record; + if ( + activation.kind !== 'ssh_operator' || + typeof activation.operatorPath !== 'string' || + Object.hasOwn(activation, 'operator') + ) { + return value; + } + const { operatorPath, ...activationRest } = activation; + return { + ...profile, + transport: { + ...transport, + activation: { + ...activationRest, + operator: createRuntimeHostLegacyPosixOperatorCommand(operatorPath), + }, + }, + }; +} + class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { #operation = Promise.resolve(); @@ -926,8 +997,10 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { throw new Error('A new Runtime Host profile must use a new profile id'); } const targetChanged = previousProfile - ? profileTargetBinding(previousProfile) !== profileTargetBinding(profile) || - runtimeHostProfileAccess(previousProfile) !== runtimeHostProfileAccess(profile) + ? previousProfile.kind === 'environment' && profile.kind === 'environment' + ? !sameEnvironmentRuntimeHostDeployment(previousProfile, profile) + : profileTargetBinding(previousProfile) !== profileTargetBinding(profile) || + runtimeHostProfileAccess(previousProfile) !== runtimeHostProfileAccess(profile) : false; if (targetChanged) { throw new Error('A Runtime Host profile target cannot be changed; create a new profile id'); @@ -1213,7 +1286,7 @@ export function decodeEnvironmentRuntimeHostProfile(value: unknown): Environment 'kind', 'provider', 'rootId', - 'operatorPath', + 'operator', ]); if (record.kind !== 'environment') { throw new Error('Runtime Host environment profile kind must be environment'); @@ -1234,9 +1307,7 @@ export function decodeEnvironmentRuntimeHostProfile(value: unknown): Environment ), }), rootId: requireHostRootId(record.rootId), - operatorPath: normalizeRuntimeHostWslOperatorPath( - requireString(record.operatorPath, 'WSL operator path'), - ), + operator: decodeRuntimeHostPosixOperatorCommand(record.operator), }); } @@ -1317,17 +1388,17 @@ export function decodeRuntimeHostRemoteTransport(value: unknown): RuntimeHostRem if (activated) { const activation = requireExactRecord(record.activation, 'Runtime Host SSH activation', [ 'kind', - 'operatorPath', + 'operator', ]); if (activation.kind !== 'ssh_operator') { throw new Error('Runtime Host SSH activation kind is invalid'); } - const operatorPath = requireOperatorPath(activation.operatorPath); + const operator = decodeRuntimeHostOperatorCommand(activation.operator); return Object.freeze({ kind: 'ssh', destination, ...(sshPort === undefined ? {} : { sshPort }), - activation: Object.freeze({ kind: 'ssh_operator', operatorPath }), + activation: Object.freeze({ kind: 'ssh_operator', operator }), }); } const remotePort = requirePort(record.remotePort, 'Runtime Host SSH remote port'); @@ -1494,7 +1565,7 @@ function profileTargetBinding(profile: PersistedRuntimeHostProfile): string { 'environment', normalized.provider.kind, normalized.provider.distribution, - normalized.operatorPath, + operatorTargetBinding(normalized.operator), normalized.rootId, ].join('\0'); } @@ -1518,13 +1589,19 @@ function transportCredentialBinding(transport: RuntimeHostRemoteTransport): stri return `${transport.url}\0${transport.acknowledgement}`; case 'ssh': return transport.activation - ? `${transport.destination}\0${transport.sshPort ?? ''}\0activate\0${transport.activation.operatorPath}` + ? `${transport.destination}\0${transport.sshPort ?? ''}\0activate\0${operatorTargetBinding(transport.activation.operator)}` : `${transport.destination}\0${transport.sshPort ?? ''}\0${transport.remotePort}\0${transport.websocketPath}`; case 'libp2p-direct': return transport.reachability.lease.peerId; } } +function operatorTargetBinding(operator: RuntimeHostOperatorCommand): string { + return operator.kind === 'legacy_posix_executable' + ? operator.executablePath + : JSON.stringify(operator); +} + function requireBoundedToken(value: unknown, label: string, maxBytes: number): string { const token = requireString(value, label); if ( @@ -1619,18 +1696,6 @@ function requireWebSocketPath(value: unknown): string { return path; } -function requireOperatorPath(value: unknown): string { - const path = requireString(value, 'Runtime Host SSH operator path'); - if ( - !posix.isAbsolute(path) || - Buffer.byteLength(path, 'utf8') > 4_096 || - /[\u0000-\u001f\u007f]/u.test(path) - ) { - throw new Error('Runtime Host SSH operator path must be an absolute POSIX path'); - } - return posix.normalize(path); -} - function requireRecord(value: unknown, label: string): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new Error(`${label} must be an object`); @@ -1667,20 +1732,18 @@ async function writeProfileDocument( document: RuntimeHostProfileDocument, ): Promise { const schemaVersion = document.profiles.some( - (profile) => profile.kind === 'remote' && profile.transport.kind === 'libp2p-direct', + (profile) => + profile.kind === 'environment' || + (profile.kind === 'remote' && + (profile.transport.kind === 'libp2p-direct' || + (profile.transport.kind === 'ssh' && profile.transport.activation !== undefined))), ) ? PROFILE_SCHEMA_VERSION : document.profiles.some( (profile) => profile.kind === 'remote' && profile.access === 'session_guest', ) ? 3 - : document.profiles.some( - (profile) => - profile.kind === 'environment' || - (profile.transport.kind === 'ssh' && profile.transport.activation !== undefined), - ) - ? 2 - : 1; + : 1; const encoded = `${JSON.stringify({ ...document, schemaVersion }, null, 2)}\n`; if (Buffer.byteLength(encoded, 'utf8') > PROFILE_DOCUMENT_MAX_BYTES) { throw new Error('Runtime Host profile document exceeds its size limit'); diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index bb5a98bfb9..13bac9ed7c 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -53,10 +53,12 @@ export { createRuntimeHostCapabilityProviderCredentialStore, createRuntimeHostProfileCredentialStore, connectRuntimeHostProfile, + sameEnvironmentRuntimeHostDeployment, connectRemoteRuntimeHostProfile, decodeEnvironmentRuntimeHostProfile, decodePersistedRuntimeHostProfile, decodeRemoteRuntimeHostProfile, + migrateRuntimeHostProfileOperatorCommand, remoteRuntimeHostUnavailableError, runtimeHostProfileAccess, runtimeHostProfileTargetFingerprint, @@ -87,6 +89,7 @@ export { export { RuntimeHostSshOperatorActivationError, activateRuntimeHostSshOperator, + runtimeHostSshOperatorRemoteCommand, type RuntimeHostSshOperatorActivationInput, } from './ssh-operator-activation.js'; export { @@ -122,7 +125,6 @@ export { connectRuntimeHostWslEnvironment, listRuntimeHostWslDistributions, normalizeRuntimeHostWslDistribution, - normalizeRuntimeHostWslOperatorPath, resolveSystemRuntimeHostWslExecutable, type RuntimeHostWslEnvironmentInput, type RuntimeHostWslProcessFactory, diff --git a/packages/runtime-host/src/client/ssh-operator-activation.ts b/packages/runtime-host/src/client/ssh-operator-activation.ts index 7ea035db5c..3f49f217cf 100644 --- a/packages/runtime-host/src/client/ssh-operator-activation.ts +++ b/packages/runtime-host/src/client/ssh-operator-activation.ts @@ -18,13 +18,18 @@ */ import { spawn } from 'node:child_process'; -import { posix } from 'node:path'; +import { posix, win32 } from 'node:path'; import { finished } from 'node:stream/promises'; import { RUNTIME_HOST_ACTIVATION_FRAME_MAX_BYTES, decodeRuntimeHostActivationFrame, type RuntimeHostActivationResult, } from '../operator/index.js'; +import { + decodeRuntimeHostOperatorCommand, + runtimeHostOperatorInvocation, + type RuntimeHostOperatorCommand, +} from '../operator/operator-command.js'; import { requireHostRootId } from '../protocol/index.js'; import { normalizeRuntimeHostSshDestination, @@ -36,7 +41,7 @@ const DEFAULT_TIMEOUT_MS = 120_000; export interface RuntimeHostSshOperatorActivationInput { readonly destination: string; readonly sshPort?: number; - readonly operatorPath: string; + readonly operator: RuntimeHostOperatorCommand; readonly rootId: string; readonly interaction: RuntimeHostSshInteraction; readonly signal?: AbortSignal; @@ -77,13 +82,18 @@ export async function activateRuntimeHostSshOperator( } const destination = normalizeRuntimeHostSshDestination(input.destination); const sshPort = input.sshPort === undefined ? undefined : requirePort(input.sshPort); - const operatorPath = requireOperatorPath(input.operatorPath); + const operator = decodeRuntimeHostOperatorCommand(input.operator); const rootId = requireHostRootId(input.rootId); const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) { throw new RangeError('Runtime Host SSH activation timeout must be between 1 and 120000 ms'); } - const remoteCommand = `${quotePosix(operatorPath)} activate --framed --root-id ${rootId}`; + const remoteCommand = runtimeHostSshOperatorRemoteCommand(operator, [ + 'activate', + '--framed', + '--root-id', + rootId, + ]); const args = [ '-T', '-o', @@ -110,6 +120,52 @@ export async function activateRuntimeHostSshOperator( return waitForActivation(child, input, timeoutMs); } +export function runtimeHostSshOperatorRemoteCommand( + operator: RuntimeHostOperatorCommand, + args: readonly string[], + environment: Readonly> = {}, + options: { readonly missingOperatorIsSuccess?: boolean } = {}, +): string { + const invocation = runtimeHostOperatorInvocation(operator, args); + const entries = Object.entries(environment); + for (const [name] of entries) { + if (!/^[A-Z][A-Z0-9_]*$/u.test(name)) { + throw new Error('Runtime Host operator environment variable name is invalid'); + } + } + if (operator.kind === 'legacy_posix_executable' || operator.platform === 'posix') { + const command = [invocation.executable, ...invocation.args].map(quotePosix).join(' '); + const variables = entries.map(([name, value]) => `${name}=${quotePosix(value)}`).join(' '); + const execute = `${variables ? `${variables} ` : ''}exec ${command}`; + if (!options.missingOperatorIsSuccess) return execute; + const operatorPath = operator.kind === 'node' ? operator.modulePath : operator.executablePath; + const artifact = quotePosix(operatorPath); + const deploymentRoot = quotePosix(posix.dirname(operatorPath)); + return `if [ ! -e ${artifact} ]; then [ ! -e ${deploymentRoot} ] && exit 0; exit 1; fi; ${execute}`; + } + if (operator.kind !== 'node') throw new Error('Windows Runtime Host operator must use Node'); + const payload = Buffer.from( + JSON.stringify({ + ...invocation, + environment, + modulePath: operator.modulePath, + deploymentRoot: win32.dirname(operator.modulePath), + missingOperatorIsSuccess: options.missingOperatorIsSuccess === true, + }), + 'utf8', + ).toString('base64'); + const script = [ + `$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}'))|ConvertFrom-Json`, + `if($p.missingOperatorIsSuccess -and -not (Test-Path -LiteralPath $p.modulePath -PathType Leaf)){if(-not (Test-Path -LiteralPath $p.deploymentRoot)){exit 0}else{exit 1}}`, + `foreach($e in $p.environment.psobject.Properties){[Environment]::SetEnvironmentVariable($e.Name,[string]$e.Value,'Process')}`, + `$code=1`, + `try{& ([string]$p.executable) @($p.args|ForEach-Object {[string]$_});$code=if($null -eq $LASTEXITCODE){1}else{$LASTEXITCODE}}catch{$code=1}`, + `exit $code`, + ].join(';'); + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + return `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; +} + async function waitForActivation( child: RuntimeHostSshOperatorProcess, input: RuntimeHostSshOperatorActivationInput, @@ -203,17 +259,6 @@ function spawnSshOperatorProcess(input: { }; } -function requireOperatorPath(value: string): string { - if ( - !posix.isAbsolute(value) || - Buffer.byteLength(value, 'utf8') > 4_096 || - /[\u0000-\u001f\u007f]/u.test(value) - ) { - throw new Error('Runtime Host SSH operator path must be an absolute POSIX path'); - } - return posix.normalize(value); -} - function requirePort(value: number): number { if (!Number.isInteger(value) || value < 1 || value > 65_535) { throw new RangeError('Runtime Host SSH port must be between 1 and 65535'); diff --git a/packages/runtime-host/src/client/wsl-control.ts b/packages/runtime-host/src/client/wsl-control.ts index 4c4f074fc4..17acc6d1f8 100644 --- a/packages/runtime-host/src/client/wsl-control.ts +++ b/packages/runtime-host/src/client/wsl-control.ts @@ -90,17 +90,6 @@ export function normalizeRuntimeHostWslDistribution(value: string): string { return distribution; } -export function normalizeRuntimeHostWslOperatorPath(value: string): string { - if ( - !value.startsWith('/') || - Buffer.byteLength(value, 'utf8') > 4_096 || - /[\u0000-\u001f\u007f]/u.test(value) - ) { - throw new Error('WSL operator path must be an absolute Linux path'); - } - return value; -} - export function spawnRuntimeHostWslProcess(executable: string, args: readonly string[]) { return spawn(executable, args, { shell: false, diff --git a/packages/runtime-host/src/client/wsl-environment.ts b/packages/runtime-host/src/client/wsl-environment.ts index 56b9a01f03..390a0bace4 100644 --- a/packages/runtime-host/src/client/wsl-environment.ts +++ b/packages/runtime-host/src/client/wsl-environment.ts @@ -18,6 +18,11 @@ */ import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { + decodeRuntimeHostPosixOperatorCommand, + runtimeHostOperatorInvocation, + type RuntimeHostPosixOperatorCommand, +} from '../operator/operator-command.js'; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_PROTOCOL_VERSION, @@ -40,7 +45,6 @@ import { formatRuntimeHostWslStderr, listRuntimeHostWslDistributions, normalizeRuntimeHostWslDistribution, - normalizeRuntimeHostWslOperatorPath, resolveSystemRuntimeHostWslExecutable, RUNTIME_HOST_WSL_STDERR_MAX_BYTES, spawnRuntimeHostWslProcess, @@ -56,14 +60,13 @@ const DEFAULT_RUNTIME_HOST_WSL_READY_TIMEOUT_MS = 45_000; export { listRuntimeHostWslDistributions, normalizeRuntimeHostWslDistribution, - normalizeRuntimeHostWslOperatorPath, resolveSystemRuntimeHostWslExecutable, type RuntimeHostWslProcessFactory, } from './wsl-control.js'; export interface RuntimeHostWslEnvironmentInput { readonly distribution: string; - readonly operatorPath: string; + readonly operator: RuntimeHostPosixOperatorCommand; readonly rootId: string; readonly clientInstanceId: string; readonly signal?: AbortSignal; @@ -81,20 +84,23 @@ export async function connectRuntimeHostWslEnvironment( ): Promise { input.signal?.throwIfAborted(); const distribution = normalizeRuntimeHostWslDistribution(input.distribution); - const operatorPath = normalizeRuntimeHostWslOperatorPath(input.operatorPath); + const operator = decodeRuntimeHostPosixOperatorCommand(input.operator); const rootId = requireHostRootId(input.rootId); const processFactory = overrides.processFactory ?? spawnRuntimeHostWslProcess; - const child = processFactory(overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable(), [ - '--distribution', - distribution, - '--exec', - operatorPath, + const invocation = runtimeHostOperatorInvocation(operator, [ 'connect', '--framed', '--root-id', rootId, '--repair-root-after-remount', ]); + const child = processFactory(overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable(), [ + '--distribution', + distribution, + '--exec', + invocation.executable, + ...invocation.args, + ]); const resource = new WslProcessByteStream(child); const transport = new FramedByteStreamTransport(resource); const abort = () => transport.abort(abortReason(input.signal)); diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 61c7cd2d24..56d7be6ebc 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -94,6 +94,20 @@ export { type RuntimeHostSetupFrame, type RuntimeHostSetupPhase, } from './setup-frame.js'; +export { + createRuntimeHostOperatorCommand, + createRuntimeHostLegacyPosixOperatorCommand, + decodeRuntimeHostOperatorCommand, + decodeRuntimeHostPosixOperatorCommand, + runtimeHostManagedOperatorCommand, + runtimeHostManagedOperatorModulePath, + runtimeHostOperatorInvocation, + type RuntimeHostOperatorCommand, + type RuntimeHostOperatorPlatform, + type RuntimeHostPosixOperatorCommand, + type RuntimeHostLegacyPosixOperatorCommand, + type RuntimeHostNodeOperatorCommand, +} from './operator-command.js'; export { RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, compareProductReleaseVersions, diff --git a/packages/runtime-host/src/operator/operator-command.ts b/packages/runtime-host/src/operator/operator-command.ts new file mode 100644 index 0000000000..37581840e3 --- /dev/null +++ b/packages/runtime-host/src/operator/operator-command.ts @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { posix, win32 } from 'node:path'; + +const PATH_MAX_BYTES = 4 * 1024; + +export type RuntimeHostOperatorPlatform = 'posix' | 'win32'; + +/** A stable operator entrypoint with no target-shell dependency. */ +export interface RuntimeHostNodeOperatorCommand< + Platform extends RuntimeHostOperatorPlatform = RuntimeHostOperatorPlatform, +> { + readonly kind: 'node'; + readonly platform: Platform; + readonly nodePath: string; + readonly modulePath: string; +} + +/** Compatibility route for managed deployments created before the Node operator shipped. */ +export interface RuntimeHostLegacyPosixOperatorCommand { + readonly kind: 'legacy_posix_executable'; + readonly executablePath: string; +} + +export type RuntimeHostOperatorCommand = + | RuntimeHostNodeOperatorCommand + | RuntimeHostLegacyPosixOperatorCommand; + +export type RuntimeHostPosixOperatorCommand = + | RuntimeHostNodeOperatorCommand<'posix'> + | RuntimeHostLegacyPosixOperatorCommand; + +export function createRuntimeHostOperatorCommand< + Platform extends RuntimeHostOperatorPlatform, +>(input: { + readonly platform: Platform; + readonly nodePath: string; + readonly modulePath: string; +}): RuntimeHostNodeOperatorCommand { + return Object.freeze({ + kind: 'node', + platform: input.platform, + nodePath: requireAbsolutePath(input.nodePath, input.platform, 'operator Node path'), + modulePath: requireAbsolutePath(input.modulePath, input.platform, 'operator module path'), + }); +} + +export function createRuntimeHostLegacyPosixOperatorCommand( + executablePath: string, +): RuntimeHostLegacyPosixOperatorCommand { + return Object.freeze({ + kind: 'legacy_posix_executable', + executablePath: requireAbsolutePath(executablePath, 'posix', 'legacy operator executable'), + }); +} + +export function runtimeHostManagedOperatorCommand( + deployment: { + readonly deploymentRoot: string; + readonly launch: { readonly nodePath: string }; + }, + platform: Platform, +): RuntimeHostNodeOperatorCommand { + return createRuntimeHostOperatorCommand({ + platform, + nodePath: deployment.launch.nodePath, + modulePath: runtimeHostManagedOperatorModulePath(deployment.deploymentRoot, platform), + }); +} + +export function runtimeHostManagedOperatorModulePath( + deploymentRoot: string, + platform: RuntimeHostOperatorPlatform, +): string { + const paths = platform === 'win32' ? win32 : posix; + return paths.join(deploymentRoot, 'operator.mjs'); +} + +export function decodeRuntimeHostOperatorCommand(value: unknown): RuntimeHostOperatorCommand { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Runtime Host operator command is invalid'); + } + const record = value as Record; + if (record.kind === 'legacy_posix_executable') { + const keys = Object.keys(record).sort(); + const expected = ['executablePath', 'kind']; + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { + throw new Error('Runtime Host operator command has unexpected fields'); + } + return createRuntimeHostLegacyPosixOperatorCommand( + requireString(record.executablePath, 'Legacy Runtime Host operator executable'), + ); + } + const keys = Object.keys(record).sort(); + const expected = ['kind', 'modulePath', 'nodePath', 'platform']; + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { + throw new Error('Runtime Host operator command has unexpected fields'); + } + if (record.kind !== 'node') throw new Error('Runtime Host operator command kind is invalid'); + if (record.platform !== 'posix' && record.platform !== 'win32') { + throw new Error('Runtime Host operator platform is invalid'); + } + return createRuntimeHostOperatorCommand({ + platform: record.platform, + nodePath: requireString(record.nodePath, 'Runtime Host operator Node path'), + modulePath: requireString(record.modulePath, 'Runtime Host operator module path'), + }); +} + +export function decodeRuntimeHostPosixOperatorCommand( + value: unknown, +): RuntimeHostPosixOperatorCommand { + const command = decodeRuntimeHostOperatorCommand(value); + if (command.kind === 'legacy_posix_executable') return command; + if (command.platform !== 'posix') { + throw new Error('Runtime Host operator must target POSIX'); + } + return createRuntimeHostOperatorCommand({ + platform: 'posix', + nodePath: command.nodePath, + modulePath: command.modulePath, + }); +} + +export function runtimeHostOperatorInvocation( + command: RuntimeHostOperatorCommand, + args: readonly string[], +): { readonly executable: string; readonly args: readonly string[] } { + const normalized = decodeRuntimeHostOperatorCommand(command); + if (normalized.kind === 'legacy_posix_executable') { + return { executable: normalized.executablePath, args }; + } + return { + executable: normalized.nodePath, + args: [normalized.modulePath, ...args], + }; +} + +function requireAbsolutePath( + value: string, + platform: RuntimeHostOperatorPlatform, + label: string, +): string { + if ( + Buffer.byteLength(value, 'utf8') > PATH_MAX_BYTES || + /[\u0000-\u001f\u007f]/u.test(value) || + !(platform === 'win32' ? win32.isAbsolute(value) : posix.isAbsolute(value)) + ) { + throw new Error(`Runtime Host ${label} must be an absolute ${platform} path`); + } + return value; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} is invalid`); + return value; +} diff --git a/packages/runtime-host/src/operator/setup-frame.ts b/packages/runtime-host/src/operator/setup-frame.ts index aa74d34aed..e14f92b49e 100644 --- a/packages/runtime-host/src/operator/setup-frame.ts +++ b/packages/runtime-host/src/operator/setup-frame.ts @@ -19,6 +19,7 @@ import { z } from 'zod'; import { isCanonicalRuntimeHostWebSocketPath } from '../protocol/index.js'; +import { decodeRuntimeHostOperatorCommand } from './operator-command.js'; export const RUNTIME_HOST_SETUP_FRAME_PREFIX = 'MAKA_RUNTIME_HOST_SETUP_V1 '; const SETUP_FRAME_MAX_BYTES = 32 * 1024; @@ -61,9 +62,21 @@ const SETUP_FRAME_SCHEMA = z.discriminatedUnion('kind', [ version: boundedString(128), serviceId: z.string().regex(/^[a-f0-9]{64}$/u), deploymentId: z.string().uuid(), - operatorPath: boundedString(4 * 1024).refine( - (value) => value.startsWith('/') && !/[\u0000-\u001f\u007f]/u.test(value), - ), + operator: z.unknown().transform((value, context) => { + try { + const command = decodeRuntimeHostOperatorCommand(value); + if (command.kind !== 'node') { + throw new Error('Runtime Host setup operator must be a Node command'); + } + return command; + } catch (error) { + context.addIssue({ + code: 'custom', + message: error instanceof Error ? error.message : 'Runtime Host operator is invalid', + }); + return z.NEVER; + } + }), rootPath: boundedString(4 * 1024).refine((value) => !/[\u0000-\u001f\u007f]/u.test(value)), rootId: z.string().regex(/^[a-f0-9]{64}$/u), endpoint: boundedString(SETUP_FIELD_MAX_BYTES).refine(