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 c05f8b6052..6f3c4c9f46 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -23,7 +23,7 @@ import type { DesktopRuntimeHostProfileAddInput } from '../../preload/bridge-con import type { DesktopRuntimeHostManagedService } from '../runtime-host-managed-services.js'; import { createDesktopRuntimeHostOnboarding } from '../runtime-host-onboarding.js'; -test('persists a verified SSH profile without projecting its credential', async () => { +test('persists a verified on-demand SSH profile without endpoint or credential projection', async () => { let setupInput: unknown; let saved: | (DesktopRuntimeHostProfileAddInput & { @@ -62,19 +62,18 @@ test('persists a verified SSH profile without projecting its credential', async assert.deepEqual(saved?.profile.transport, { kind: 'ssh', destination: 'operator@example.com', - remotePort: 7443, - websocketPath: '/runtime-host', - }); - assert.deepEqual(saved?.managedService, { - id: 'b'.repeat(64), - rootPath: '/home/operator/.config/Maka/workspaces/default', - operatorPath: '/home/operator/.local/share/maka/operator', + activation: { + kind: 'ssh_operator', + operatorPath: '/home/operator/.local/share/maka/operator', + }, }); + assert.equal(saved?.managedService, undefined); assert.equal(saved?.credential, 'secret-access-token'); assert.deepEqual( (setupInput as { projectDirectoryRoots?: unknown }).projectDirectoryRoots, [{ label: 'Work', path: '/srv/work' }], ); + assert.equal((setupInput as { lifecycle?: unknown }).lifecycle, 'on_demand'); assert.doesNotMatch(JSON.stringify(harness.events), /secret-access-token/u); await harness.onboarding.close(); assert.equal(harness.handlers.size, 0); 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 ea66dedbe9..93e52e761b 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 @@ -23,10 +23,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import type { IPty } from 'node-pty'; +import { type RuntimeHostSshProcessFactory } from '@maka/runtime-host/client'; import { - type RuntimeHostSshProcessFactory, -} from '@maka/runtime-host/client'; -import { + encodeRuntimeHostActivationFrame, encodeRuntimeHostAccessManagementFrame, encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, @@ -689,6 +688,39 @@ test('does not launch a management process after the terminal owner closes', asy assert.equal(launches.length, 0); }); +test('runs interactive operator activation as one strict framed SSH command', async () => { + const harness = createHarness('pending'); + const rootId = 'a'.repeat(64); + const activation = harness.terminal.activateSshOperator({ + destination: 'operator@example.com', + operatorPath: '/home/operator/.local/share/maka/operator', + rootId, + interaction: 'terminal', + }); + await waitFor(() => harness.pty.hasDataListener()); + harness.pty.emitData( + encodeRuntimeHostActivationFrame({ + schemaVersion: 1, + kind: 'result', + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + rootId, + hostEpoch: 'host-epoch', + pid: 1234, + protocolVersion: 1, + endpoint: { host: '127.0.0.1', port: 43_210, websocketPath: '/runtime-host' }, + }), + ); + harness.pty.exit(0); + + assert.equal((await activation).pid, 1234); + const remoteCommand = harness.launchArgs[0]?.at(-1) ?? ''; + assert.match(remoteCommand, /'activate' '--framed' '--root-id'/u); + assert.match(remoteCommand, new RegExp(rootId, 'u')); + assert.doesNotMatch(remoteCommand, /credential|token/u); + await harness.terminal.close(); +}); + test('uploads a development release archive before running the same remote setup', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'maka-runtime-host-development-package-')); t.after(() => rm(directory, { recursive: true, force: true })); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 2132d23afb..e3aed9b74f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -842,6 +842,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( console.error("[runtime-host] projection refresh failed:", error), registerClientIpc: registerHostClientIpc, openSshTunnel: runtimeHostSshTerminal.openSshTunnel, + activateSshOperator: runtimeHostSshTerminal.activateSshOperator, }, { upgradePrompts: createRuntimeHostUpgradePrompts( diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 08f5b5f493..4b0b78e23a 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -25,6 +25,7 @@ import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { SessionChangedEvent, SessionChangedReason } from '@maka/core/session'; import type { BotRegistry } from '@maka/runtime/bots'; import { + type RuntimeHostSshOperatorActivationInput, connectOrSpawnRuntimeHost, connectRemoteRuntimeHostProfile, type RuntimeHostSshInteraction, @@ -38,6 +39,7 @@ import { type RemoteRuntimeHostProfile, type CandidateExitDetails, } from "@maka/runtime-host/client"; +import type { RuntimeHostActivationResult } from "@maka/runtime-host/operator"; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_PROTOCOL_VERSION, @@ -125,6 +127,9 @@ export interface DesktopRuntimeHostCandidateDeps { readonly openSshTunnel?: ( input: RuntimeHostSshTunnelInput, ) => Promise; + readonly activateSshOperator?: ( + input: RuntimeHostSshOperatorActivationInput, + ) => Promise; readonly createSessionCopyCleanup: (input: { removeSession: (sessionId: string) => Promise; resumeSessionCopy: (input: { @@ -389,6 +394,9 @@ async function startRemoteDesktopRuntimeHostCandidate( }, { ...(input.openSshTunnel ? { openSshTunnel: input.openSshTunnel } : {}), + ...(input.activateSshOperator + ? { activateSshOperator: input.activateSshOperator } + : {}), }); try { return { diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index 01b458b19c..6a97f2684a 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -106,6 +106,7 @@ export function createDesktopRuntimeHostOnboarding(input: { ): Promise => { try { const setupPackage = await input.resolveSetupPackage(signal); + const lifecycle = setupPackage.kind === 'npm' ? 'on_demand' : 'supervised'; signal.throwIfAborted(); publish({ kind: 'running', phase: 'connecting_ssh' }); let commitStarted = false; @@ -123,6 +124,7 @@ export function createDesktopRuntimeHostOnboarding(input: { destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), setupPackage, + lifecycle, principalId: `desktop:${input.clientInstanceId}`, ...(request.projectDirectoryRoots ? { projectDirectoryRoots: request.projectDirectoryRoots } @@ -153,16 +155,29 @@ export function createDesktopRuntimeHostOnboarding(input: { kind: 'ssh', destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), - remotePort: endpoint.port, - websocketPath: endpoint.websocketPath, + ...(lifecycle === 'on_demand' + ? { + activation: { + kind: 'ssh_operator' as const, + operatorPath: complete.operatorPath, + }, + } + : { + remotePort: endpoint.port, + websocketPath: endpoint.websocketPath, + }), }, }, credential: complete.credential, - managedService: { - id: complete.serviceId, - rootPath: complete.rootPath, - operatorPath: complete.operatorPath, - }, + ...(lifecycle === 'supervised' + ? { + managedService: { + id: complete.serviceId, + rootPath: complete.rootPath, + operatorPath: complete.operatorPath, + }, + } + : {}), }); return publish({ kind: 'complete', diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 8baa1c9aef..d0a7e6ab46 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -26,18 +26,23 @@ import type { IPty } from 'node-pty'; import { spawn as spawnPty } from 'node-pty'; import { terminateProcessTree } from '@maka/runtime/process-tree-terminator'; import { + activateRuntimeHostSshOperator, normalizeRuntimeHostSshDestination, openRuntimeHostSshTunnel, + type RuntimeHostSshOperatorActivationInput, type RuntimeHostSshProcess, type RuntimeHostSshProcessFactory, type RuntimeHostSshTunnel, type RuntimeHostSshTunnelInput, } from '@maka/runtime-host/client'; import { + decodeRuntimeHostActivationFrame, decodeRuntimeHostAccessManagementFrame, decodeRuntimeHostPeerManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, + RUNTIME_HOST_ACTIVATION_FRAME_MAX_BYTES, + RUNTIME_HOST_ACTIVATION_FRAME_PREFIX, RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, @@ -47,6 +52,7 @@ import { RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, type RuntimeHostAccessManagementFrame, + type RuntimeHostActivationResult, type RuntimeHostManagedUpdatePolicy, type RuntimeHostPeerManagementAction, type RuntimeHostPeerManagementFrame, @@ -94,6 +100,7 @@ export interface DesktopRuntimeHostSshSetupInput { readonly sshPort?: number; readonly setupPackage: DesktopRuntimeHostSetupPackage; readonly principalId: string; + readonly lifecycle?: 'supervised' | 'on_demand'; readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; readonly signal?: AbortSignal; } @@ -217,11 +224,15 @@ export function createDesktopRuntimeHostSshTerminal(input: { readonly send: (channel: string, event: DesktopRuntimeHostSshTerminalEvent) => void; readonly spawnPty?: typeof spawnPty; readonly openSshTunnel?: typeof openRuntimeHostSshTunnel; + readonly activateSshOperator?: typeof activateRuntimeHostSshOperator; readonly revealDelayMs?: number; readonly managementTimeoutMs?: number; readonly processStopGraceMs?: number; readonly terminateProcessTree?: typeof terminateProcessTree; }): { + activateSshOperator( + input: RuntimeHostSshOperatorActivationInput, + ): Promise; openSshTunnel(input: RuntimeHostSshTunnelInput): Promise; runSetup( input: DesktopRuntimeHostSshSetupInput, @@ -557,6 +568,27 @@ export function createDesktopRuntimeHostSshTerminal(input: { }; return { + activateSshOperator: async (activationInput) => { + if (activationInput.interaction !== 'terminal') { + return (input.activateSshOperator ?? activateRuntimeHostSshOperator)(activationInput); + } + const frame = await runFramedManagement({ + ...activationInput, + remoteCommand: runtimeHostActivationRemoteCommand(activationInput), + prefix: RUNTIME_HOST_ACTIVATION_FRAME_PREFIX, + pendingMaxBytes: RUNTIME_HOST_ACTIVATION_FRAME_MAX_BYTES, + decode: decodeRuntimeHostActivationFrame, + action: 'activate', + frameAction: () => 'activate', + label: 'Remote Runtime Host activation', + timeoutMs: activationInput.timeoutMs, + }); + if (frame.kind === 'error') throw new Error(frame.error.message); + if (frame.rootId !== activationInput.rootId) { + throw new Error('Remote Runtime Host activation returned an inconsistent root'); + } + return frame; + }, openSshTunnel: async (tunnelInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); const openSshTunnel = input.openSshTunnel ?? openRuntimeHostSshTunnel; @@ -997,7 +1029,10 @@ async function settlesWithin(promise: Promise, timeoutMs: number): Prom function runtimeHostSetupRemoteCommand( setupPackage: PreparedSetupPackage, - input: Pick, + input: Pick< + DesktopRuntimeHostSshSetupInput, + 'principalId' | 'projectDirectoryRoots' | 'lifecycle' + >, ): string { if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) { throw new Error('Runtime Host setup principal is invalid'); @@ -1009,6 +1044,8 @@ function runtimeHostSetupRemoteCommand( input.principalId, '--preset', 'desktop-client', + '--lifecycle', + input.lifecycle === 'on_demand' ? 'on-demand' : 'supervised', '--defer-pairing-commit', ...(input.projectDirectoryRoots === undefined ? [] @@ -1022,6 +1059,21 @@ function runtimeHostSetupRemoteCommand( ]); } +function runtimeHostActivationRemoteCommand( + input: RuntimeHostSshOperatorActivationInput, +): string { + if (!pathPosix.isAbsolute(input.operatorPath)) { + throw new Error('Runtime Host operator path must be absolute'); + } + return [ + input.operatorPath, + 'activate', + '--framed', + '--root-id', + input.rootId, + ].map(quotePosix).join(' '); +} + function runtimeHostServiceManagementRemoteCommand( input: DesktopRuntimeHostSshManagementInput, ): string { diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index f95c7f0eab..2afa0fd825 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| -| windows-backend-gap | 23 | -| portable-candidate | 8 | +| windows-backend-gap | 24 | +| portable-candidate | 10 | | platform-contract | 35 | -Total Windows-excluded declarations: **66** +Total Windows-excluded declarations: **69** ## Inventory @@ -45,6 +45,7 @@ Total Windows-excluded declarations: **66** | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` a non-reading Client overload is isolated to its connection | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` reports one shutdown failure through close and closed while releasing ownership | `process.platform === 'win32'` | | platform-contract | `packages/runtime-host/src/__tests__/host-kernel.test.ts` publishes private POSIX endpoint and registration permissions | `process.platform === 'win32'` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/managed-activation.test.ts` two real managed activations converge on one Host and exit at true idle | `process.platform === 'win32' ? 'requires a POSIX package-entrypoint symlink' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/memory-two-client-uds.test.ts` two UDS clients share one recoverable Memory authority across Host death | `process.platform === 'win32' ? 'POSIX process death gate' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts` two UDS clients converge on one Host-owned Project Catalog | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts` invalidates when a real published mutation loses its commit reply | `process.platform === 'win32'` | @@ -81,6 +82,7 @@ Total Windows-excluded declarations: **66** | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` preserves unexpected marker I/O failures at the public authority boundary | `process.platform === 'win32' ? 'POSIX permissions are required to make the marker unreadable' : typeof process.getuid === 'function' && process.getuid() === 0` | | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` rejects FIFO marker paths without blocking root resolution | `process.platform === 'win32'` | | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` rejects a lock path that aliases another filesystem object | `process.platform === 'win32' ? 'Windows file-symlink permissions are not guaranteed in CI' : false` | +| portable-candidate | `packages/storage/src/__tests__/root-authority.test.ts` cache deletion cannot create a second State Root owner | `process.platform === 'win32' ? 'Windows does not unlink an open native lock file' : false` | | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` validates an existing control directory without repairing its permissions | `process.platform === 'win32'` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` reports unknown outcome when credential persistence fails after clearing verified state | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` validates proxy policy mutations before clearing and reports failed follow-up commits as unknown | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | @@ -88,6 +90,7 @@ Total Windows-excluded declarations: **66** | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` preserves unknown commit semantics and consumes the completion ticket | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` successor recovery removes credentials orphaned by an interrupted connection removal | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` fails closed on final symlinks, FIFOs, and oversized documents without changing bytes | `process.platform === 'win32'` | +| portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` rejects a symlink instead of following it | `process.platform === 'win32' ? 'POSIX no-follow semantics are required' : false` | | platform-contract | `packages/storage/src/__tests__/usage-stores.test.ts` classifies a renamed or replaced live root as a draining persistence failure | `process.platform === 'win32' ? 'Windows does not permit renaming a directory with an open SQLite database' : false` | | platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unmarked read-only workspace fails without leaving marker state | `process.platform === 'win32' ? 'POSIX permissions are required to create a read-only workspace fixture' : false` | | portable-candidate | `scripts/release-cli-eval-support.test.mjs` preserves the primary process failure when diagnostics cannot be read | `process.platform === 'win32'` | 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 734041d325..5154073561 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -263,7 +263,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p }, profileCatalog: { read: async () => ({ - schemaVersion: 1, + schemaVersion: 2, profiles: [ { id: 'office', @@ -545,7 +545,7 @@ function incompatibleRemoteHandshake(overrides: Partial = {}): function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog { return { - read: async () => ({ schemaVersion: 1, profiles: [profile] }), + read: async () => ({ schemaVersion: 2, profiles: [profile] }), resolve: async (profileId) => { assert.equal(profileId, profile.id); return { profile, credential: 'opaque-token' }; diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index e5acb1b58d..b11796ec66 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -21,12 +21,14 @@ import assert from 'node:assert/strict'; import { resolve } from 'node:path'; import { describe, test } from 'node:test'; import type { RuntimeHostConnection } from '@maka/runtime-host/client'; +import { decodeRuntimeHostActivationFrame } from '@maka/runtime-host/operator'; import { HOST_OPERATION_SPECS, REMOTE_OWNER_OPERATION_GRANTS, RUNTIME_HOST_COMPATIBILITY_EPOCH, RUNTIME_HOST_PROTOCOL_VERSION, } from '@maka/runtime-host/protocol'; +import { runRuntimeHostManagedActivationCli } from '../runtime-host-activation-command.js'; import { resolveRuntimeHostAccessIssue, type RuntimeHostAccessIssueOptions, @@ -39,6 +41,44 @@ const projectRootA = process.platform === 'win32' ? 'C:\\srv\\projects' : '/srv/ const projectRootB = process.platform === 'win32' ? 'D:\\data' : '/mnt/data'; describe('Runtime Host operator commands', () => { + test('parses and emits the stable framed managed activation contract', async () => { + const rootId = 'a'.repeat(64); + assert.deepEqual(parseRuntimeHostCommand(['activate', '--framed', '--root-id', rootId]), { + kind: 'runtime-host-managed-activate', + rootId, + framed: true, + }); + assert.equal(parseRuntimeHostCommand(['activate', '--root-id', rootId]).kind, 'error'); + + let output = ''; + assert.equal( + await runRuntimeHostManagedActivationCli( + { rootId }, + { + activate: async () => ({ + schemaVersion: 1, + kind: 'result', + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + rootId, + hostEpoch: 'host-epoch', + pid: 1234, + protocolVersion: RUNTIME_HOST_PROTOCOL_VERSION, + endpoint: { + host: '127.0.0.1', + port: 43_210, + websocketPath: '/runtime-host', + }, + }), + writeOutput: (value) => { + output += value; + }, + }, + ), + 0, + ); + assert.equal(decodeRuntimeHostActivationFrame(output)?.kind, 'result'); + }); test('parses project management and machine-readable service readiness', () => { assert.deepEqual(parseRuntimeHostCommand(['project', 'list', '--root', '/srv/maka']), { kind: 'runtime-host-project-list', 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 ec7d89ca04..dcb92bf9ab 100644 --- a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts @@ -214,8 +214,8 @@ function createProfileCatalogCapture(): { catalog: RuntimeHostProfileCatalog; saved: Array<{ profile: RemoteRuntimeHostProfile; credential?: string }>; } { - const state = { - document: { schemaVersion: 1, profiles: [] } as RuntimeHostProfileDocument, + const state: { document: RuntimeHostProfileDocument } = { + document: { schemaVersion: 2, profiles: [] }, }; const saved: Array<{ profile: RemoteRuntimeHostProfile; credential?: string }> = []; const catalog: RuntimeHostProfileCatalog = { @@ -225,7 +225,7 @@ function createProfileCatalogCapture(): { save: async (profile: RemoteRuntimeHostProfile, credential?: string) => { saved.push({ profile, credential }); state.document = { - schemaVersion: 1, + schemaVersion: 2, 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 81225ffb21..477792969e 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -468,6 +468,7 @@ describe('managed Runtime Host service', () => { principalId: 'desktop.client-1', preset: 'desktop-client', clientDataRoot: '/var/lib/maka-client', + lifecycle: 'supervised', deferPairingCommit: true, directPeer: { coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'], diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 2c467a394c..70357a7921 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -37,8 +37,13 @@ import { promisify } from 'node:util'; import { decodeRuntimeHostSetupFrame, encodeRuntimeHostSetupFrame, + resolveRuntimeHostManagedDeploymentConfigPath, RUNTIME_HOST_SETUP_FRAME_PREFIX, } from '@maka/runtime-host/operator'; +import { + resolveRootControlNamespace, + resolveRootOwnershipNamespace, +} from '@maka/storage/root-authority'; import { prepareRuntimeHostManagedPackageDeployment, resolveRuntimeHostManagedDeploymentRoot, @@ -55,6 +60,122 @@ import { const execFile = promisify(execFileCallback); const PACKAGE_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString('base64')}`; +test('on-demand setup installs one exact deployment without a service backend', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-on-demand-setup-')); + const stateRoot = join(base, 'state'); + const clientDataRoot = join(base, 'client'); + const outputs: string[] = []; + let rootId = ''; + let prepareCount = 0; + t.after(async () => { + await Promise.all([ + rm(base, { recursive: true, force: true }), + rootId + ? rm(dirname(resolveRuntimeHostManagedDeploymentConfigPath(rootId)), { + recursive: true, + force: true, + }) + : Promise.resolve(), + rootId + ? rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }) + : Promise.resolve(), + rootId + ? rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { force: true }) + : Promise.resolve(), + ]); + }); + + const options = { + json: true, + lifecycle: 'on_demand', + clientDataRoot, + defaultRootPath: stateRoot, + sourcePackageRoot: base, + version: '1.2.3', + principalId: 'desktop:client-1', + preset: 'desktop-client', + } as const; + const deployment = (serviceId: string) => ({ + version: '1.2.3', + root: resolveRuntimeHostManagedDeploymentRoot(serviceId), + cliPath: '/verified/package/dist/cli.js', + operatorPath: '/opt/maka/operator', + activate: async () => undefined, + cleanup: async () => undefined, + rollback: async () => undefined, + }); + const overrides = { + createBackend: () => assert.fail('on-demand setup must not create a service backend'), + manageService: async () => assert.fail('on-demand setup must not manage a service'), + resolveRegistryCandidate: async () => ({ + kind: 'npm_registry', + version: '1.2.3', + integrity: PACKAGE_INTEGRITY, + }), + withRegistryPackage: async (_candidate, use) => use('/verified/package'), + prepareDeployment: async (input) => { + prepareCount += 1; + return deployment(input.serviceId); + }, + activateManaged: async (input) => { + rootId = input.rootId; + return { + schemaVersion: 1, + kind: 'result', + deploymentId: `${rootId.slice(0, 8)}-${rootId.slice(8, 12)}-4${rootId.slice(13, 16)}-8${rootId.slice(17, 20)}-${rootId.slice(20, 32)}`, + configRevision: 1, + rootId, + hostEpoch: 'host-epoch', + pid: 1234, + protocolVersion: 1, + endpoint: { host: '127.0.0.1', port: 43_210, websocketPath: '/runtime-host' }, + }; + }, + replaceCredential: async () => ({ + rootId, + credential: 'secret-token', + credentialId: 'credential-1', + principalKind: 'remote_owner' as const, + principalId: 'desktop:client-1', + operationGrants: [] as const, + canPublishClientCapabilities: false, + canUseHostPaths: false, + }), + verifyCredential: async ({ endpoint, rootId: expectedRootId }) => { + assert.equal(endpoint, 'ws://127.0.0.1:43210/runtime-host'); + assert.equal(expectedRootId, rootId); + }, + writeOutput: (value) => outputs.push(value), + } satisfies NonNullable[1]>; + assert.equal(await runRuntimeHostSetupCli(options, overrides), 0); + assert.equal(prepareCount, 1); + const complete = outputs + .map(decodeRuntimeHostSetupFrame) + .find((frame) => frame?.kind === 'complete'); + assert.equal(complete?.kind, 'complete'); + const persisted = JSON.parse( + await readFile(resolveRuntimeHostManagedDeploymentConfigPath(rootId), 'utf8'), + ) as { lifecycle: { mode: string }; listeners: { websocket: { port: number } } }; + assert.equal(persisted.lifecycle.mode, 'on_demand'); + assert.equal(persisted.listeners.websocket.port, 0); + + const rejected: string[] = []; + assert.equal( + await runRuntimeHostSetupCli( + { ...options, directPeer: { coordinationRelays: [] } }, + { ...overrides, writeOutput: (value) => rejected.push(value) }, + ), + 1, + ); + const failure = rejected + .map(decodeRuntimeHostSetupFrame) + .find((frame) => frame?.kind === 'error'); + assert.equal( + failure?.kind === 'error' ? failure.error.code : undefined, + 'unsupported_lifecycle_configuration', + ); +}); + test('managed setup converges on one exact package and verified Client pairing', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-')); t.after(() => rm(base, { recursive: true, force: true })); @@ -541,6 +662,17 @@ 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 }, + }); + assert.deepEqual(JSON.parse(await readFile(invocationPath, 'utf8')), [ + 'runtime-host', + 'activate', + '--framed', + '--root-id', + 'a'.repeat(64), + ]); + await execFile( deployment.operatorPath, [ diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 548edbcc76..61ab52fcb6 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -135,6 +135,7 @@ function helpText(cliCommand: string): string { ` ${cliCommand} -p ... Alias for ${cliCommand} run`, ` ${cliCommand} eval ... Run one declarative multi-arm experiment`, ` ${cliCommand} runtime-host serve [options] Run a Runtime Host service`, + ` ${cliCommand} runtime-host activate --framed --root-id `, ` ${cliCommand} runtime-host setup --principal --preset [options]`, ` ${cliCommand} runtime-host service install [options]`, ` ${cliCommand} runtime-host service configure (--project-root