diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 07b0dfc95d..2131d67ffa 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -141,6 +141,41 @@ test('quiesces reconnect and waits for the Host process before update install', await owner.close(); }); +test('quiesces Local reconnect while a managed service changes', async () => { + const current = candidateHarness({ lifecycleMode: 'service' }); + const replacement = candidateHarness({ + lifecycleMode: 'service', + hostEpoch: 'service-after', + }); + let starts = 0; + let finishChange!: () => void; + const change = new Promise((resolve) => { + finishChange = resolve; + }); + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async () => { + starts += 1; + return ready(starts === 1 ? current.candidate : replacement.candidate); + }, + }, + ); + + const changing = owner.runManagedLocalHostChange(async () => { + current.disconnect(); + await change; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(starts, 1); + + finishChange(); + await changing; + await owner.waitUntilReady('local', 'test-host-epoch'); + assert.equal(starts, 2); + await owner.close(); +}); + test('waits through a reconnect gap before quiescing Host retirement', async () => { const first = candidateHarness(); const replacement = candidateHarness({ disconnectOnPrepare: true }); @@ -531,6 +566,32 @@ test('replays pairing finalization after an unknown commit and reconnect', async await manager.close(); }); +test('reconnects after a pairing candidate becomes bound to this Client', async () => { + const local = candidateHarness({ hostId: 'host-a' }); + const remoteHostId = 'a'.repeat(64); + const candidate = candidateHarness({ + hostId: remoteHostId, + finalizeReconnectRequired: true, + }); + const claimed = candidateHarness({ hostId: remoteHostId }); + const queue = [local.candidate, candidate.candidate, claimed.candidate]; + const manager = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async () => ready(queue.shift()!), + reconnectBackoff: { minMs: 0, maxMs: 0 }, + }, + ); + await manager.enable(remoteTarget('office')); + + await manager.finalizePairing('office'); + + assert.equal(candidate.finalizeCalls, 1); + assert.equal(candidate.closeCalls, 1); + assert.equal(manager.current('office')?.candidate, claimed.candidate); + await manager.close(); +}); + for (const dispatch of ['not_dispatched', 'dispatched'] as const) { test(`replays ${dispatch} pairing finalization after connection loss`, async () => { const local = candidateHarness({ hostId: 'host-a' }); @@ -1102,6 +1163,7 @@ function candidateHarness( hostId?: string; hostEpoch?: string; finalizeFailures?: Error[]; + finalizeReconnectRequired?: boolean; disconnectOnFinalizeFailure?: boolean; onPrepare?: (mode: string) => unknown | Promise; } = {}, @@ -1158,7 +1220,7 @@ function candidateHarness( } throw failure; } - return {}; + return { reconnectRequired: options.finalizeReconnectRequired ?? false }; }, }, botIncoming: { 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 new file mode 100644 index 0000000000..6e848e782b --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -0,0 +1,58 @@ +/* + * 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 assert from 'node:assert/strict'; +import test from 'node:test'; +import { runtimeHostLocalSetupCommand } from '../runtime-host-local-operator.js'; + +test('local setup installs one managed service for the Desktop root with Direct peer enabled', () => { + assert.deepEqual( + runtimeHostLocalSetupCommand({ + packageSpecifier: 'maka-agent@0.2.0', + clientDataRoot: '/Users/ada/Library/Application Support/Maka', + rootPath: '/Users/ada/Library/Application Support/Maka/workspaces/default', + principalId: 'desktop-owner:pairing', + coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'], + expectedTarget: { + serviceId: 'b'.repeat(64), + rootPath: '/Users/ada/Library/Application Support/Maka/workspaces/default', + rootId: 'a'.repeat(64), + }, + }), + { + executable: 'npm', + args: [ + 'exec', '--yes', '--package', 'maka-agent@0.2.0', '--', + 'maka', 'runtime-host', 'setup', + '--client-data-root', '/Users/ada/Library/Application Support/Maka', + '--root', '/Users/ada/Library/Application Support/Maka/workspaces/default', + '--principal', 'desktop-owner:pairing', + '--preset', 'desktop-client', + '--defer-pairing-commit', + '--bind-pairing-to-client', + '--enable-direct-peer', + '--expected-service-id', 'b'.repeat(64), + '--expected-root-path', '/Users/ada/Library/Application Support/Maka/workspaces/default', + '--expected-root-id', 'a'.repeat(64), + '--coordination-relay', '/dns4/discovery.example/udp/443/quic-v1', + '--json', + ], + }, + ); +}); 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 new file mode 100644 index 0000000000..a21c272c8c --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -0,0 +1,528 @@ +/* + * 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 assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client'; +import { resolveRuntimeHostManagedServiceId } from '@maka/runtime-host/operator'; +import type { RuntimeHostDesktopManager } from '../runtime-host-desktop-manager.js'; +import { createDesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js'; +import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js'; + +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 })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); + await mkdir(rootPath, { recursive: true }); + const handlers = new Map[1]>(); + let retired = false; + let resumed = false; + const manager = { + async retireOwnedLocalHost() { + retired = true; + return { kind: 'retired' as const, resume: () => { resumed = true; } }; + }, + } as unknown as RuntimeHostDesktopManager; + const peer = { + peerId: '12D3KooWpeer', + routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'], + coordinationRelays: [], + }; + const operator = { + async runSetup(input: { readonly rootPath: string; readonly principalId: string }) { + assert.equal(retired, true); + assert.equal(input.rootPath, rootPath); + assert.equal(input.principalId, 'desktop-owner:local-runtime-host-sharing'); + return { + serviceId, + operatorPath: join(base, 'operator'), + rootPath, + rootId: 'a'.repeat(64), + credential: 'pending-credential', + directPeer: peer, + }; + }, + async runPeer() { + return { + kind: 'result' as const, + action: 'status' as const, + status: { + state: 'enabled' as const, + serviceState: 'running', + rootId: 'a'.repeat(64), + ...peer, + }, + }; + }, + async runService() { + throw new Error('rollback is not expected'); + }, + async close() {}, + } as unknown as ReturnType; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { + handle: (channel, handler) => { handlers.set(channel, handler); }, + removeHandler: (channel) => { handlers.delete(channel); }, + }, + clientDataRoot, + rootPath, + rootId: 'a'.repeat(64), + directPeerAvailable: true, + manager: () => manager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator, + }); + t.after(() => service.close()); + + const enable = handlers.get('local-runtime-host-remote-access:enable'); + assert.ok(enable); + const result = await enable({} as Electron.IpcMainInvokeEvent, { + allowInterruptActiveTasks: false, + coordinationRelays: [], + }) as { readonly kind: string; readonly connectionCode: string }; + assert.equal(result.kind, 'enabled'); + assert.equal(resumed, true); + assert.deepEqual(decodeRuntimeHostOwnerConnectionCode(result.connectionCode), { + name: decodeRuntimeHostOwnerConnectionCode(result.connectionCode).name, + rootId: 'a'.repeat(64), + transport: { kind: 'libp2p-direct', ...peer }, + credential: 'pending-credential', + }); + assert.equal( + JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) + .state, + 'managed', + ); +}); + +test('revokes the one Local sharing authority without changing peer connectivity', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-shared-access-revoke-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + await mkdir(rootPath, { recursive: true }); + await writeManagedLifecycle(clientDataRoot, rootPath, rootId); + const handlers = new Map[1]>(); + const revoked: unknown[] = []; + const peer = { + peerId: '12D3KooWpeer', + routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'], + coordinationRelays: [], + }; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { + handle: (channel, handler) => { handlers.set(channel, handler); }, + removeHandler: (channel) => { handlers.delete(channel); }, + }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + current() { + return { + candidate: { + client: { + async request(operation: string, input: unknown) { + assert.equal(operation, 'access.principal.revoke'); + revoked.push(input); + return { revoked: true }; + }, + }, + }, + }; + }, + }) as unknown as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runPeer() { + return { + kind: 'result' as const, + action: 'status' as const, + status: { + state: 'enabled' as const, + serviceState: 'running', + rootId, + ...peer, + }, + }; + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + const revoke = handlers.get('local-runtime-host-remote-access:revoke-shared-access'); + assert.ok(revoke); + assert.deepEqual(await revoke({} as Electron.IpcMainInvokeEvent), { state: 'on' }); + assert.deepEqual(revoked, [ + { + principalKind: 'remote_owner', + principalId: 'desktop-owner:local-runtime-host-sharing', + }, + ]); +}); + +test('an interrupted Local Host handoff converges to its exact managed service', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-recovery-')); + 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 serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); + await mkdir(rootPath, { recursive: true }); + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state: 'handoff', + serviceId, + rootPath, + rootId, + coordinationRelays: [], + allowInterruptActiveTasks: true, + })}\n`, + ); + let setupCalls = 0; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + async retireOwnedLocalHost(mode: string) { + assert.equal(mode, 'interrupt_active_work'); + return { kind: 'not_owned' as const }; + }, + }) as unknown as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runSetup() { + setupCalls += 1; + return { + serviceId, + operatorPath: join(base, 'operator'), + rootPath, + rootId, + credential: 'unused-pending-credential', + directPeer: { + peerId: '12D3KooWpeer', + routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'], + coordinationRelays: [], + }, + }; + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + await service.recover(); + + assert.equal(setupCalls, 1); + assert.equal( + JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) + .state, + 'managed', + ); +}); + +test('startup replays the persisted peer intent instead of gating recovery on status', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-peer-recovery-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + await mkdir(rootPath, { recursive: true }); + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state: 'peerChanging', + serviceId: 'b'.repeat(64), + operatorPath: join(clientDataRoot, 'operator'), + rootPath, + rootId, + peerEnabled: true, + coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'], + allowInterruptActiveTasks: false, + })}\n`, + ); + let resumed = false; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + async retireOwnedLocalHost() { + return { kind: 'retired' as const, resume: () => { resumed = true; } }; + }, + }) as unknown as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runPeer(input: { + readonly action: string; + readonly coordinationRelays?: readonly string[]; + }) { + assert.equal(input.action, 'enable'); + assert.deepEqual(input.coordinationRelays, [ + '/dns4/discovery.example/udp/443/quic-v1', + ]); + return { + kind: 'result' as const, + action: 'enable' as const, + restarted: true, + status: { + state: 'enabled' as const, + serviceState: 'running', + rootId, + peerId: '12D3KooWpeer', + routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'], + coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'], + }, + }; + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + await service.recover(); + assert.equal(resumed, true); + assert.equal( + JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) + .state, + 'managed', + ); +}); + +test('re-enabling a managed peer forwards explicit interruption authority', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-peer-interrupt-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + await mkdir(rootPath, { recursive: true }); + await writeManagedLifecycle(clientDataRoot, rootPath, rootId); + const handlers = new Map[1]>(); + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { + handle: (channel, handler) => { handlers.set(channel, handler); }, + removeHandler: (channel) => { handlers.delete(channel); }, + }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + current() { + return undefined; + }, + async retireOwnedLocalHost() { + return { kind: 'not_owned' as const }; + }, + async runManagedLocalHostChange(change: () => Promise) { + return change(); + }, + }) as unknown as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runPeer(input: { readonly allowInterruptActiveTasks?: boolean }) { + assert.equal(input.allowInterruptActiveTasks, true); + return { + kind: 'error' as const, + action: 'enable' as const, + error: { code: 'active_tasks', message: 'active' }, + }; + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + const enable = handlers.get('local-runtime-host-remote-access:enable'); + assert.ok(enable); + assert.deepEqual( + await enable({} as Electron.IpcMainInvokeEvent, { + allowInterruptActiveTasks: true, + coordinationRelays: [], + }), + { kind: 'active_tasks' }, + ); + assert.equal( + JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) + .state, + 'managed', + ); +}); + +test('startup completes an exact persisted uninstall intent after Desktop interruption', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-uninstall-recovery-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + await mkdir(rootPath, { recursive: true }); + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state: 'uninstalling', + serviceId: 'b'.repeat(64), + operatorPath: join(base, 'operator'), + rootPath, + rootId, + allowInterruptActiveTasks: false, + })}\n`, + ); + const actions: string[] = []; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + async retireOwnedLocalHost() { + return { kind: 'retired' as const, resume() {} }; + }, + }) as unknown as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runService(input: { + readonly action: 'retire' | 'uninstall'; + readonly retainManagedDeployment?: boolean; + }) { + actions.push(input.action); + assert.equal(input.action, 'uninstall'); + assert.equal(input.retainManagedDeployment, true); + return { + kind: 'result' as const, + action: 'uninstall' as const, + retirement: { kind: 'stopped' as const }, + service: { state: 'not_installed' }, + }; + }, + async cleanupManagedDeployment() { + actions.push('cleanup'); + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + await service.recover(); + assert.deepEqual(actions, ['uninstall', 'cleanup']); + await assert.rejects(readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'), { + code: 'ENOENT', + }); +}); + +test('startup resumes deployment cleanup without repeating a completed uninstall', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-cleanup-recovery-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + await mkdir(rootPath, { recursive: true }); + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state: 'cleanupPending', + serviceId: 'b'.repeat(64), + operatorPath: join(base, 'operator'), + rootPath, + rootId: 'a'.repeat(64), + allowInterruptActiveTasks: false, + })}\n`, + ); + let cleaned = false; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId: 'a'.repeat(64), + directPeerAvailable: true, + manager: () => ({}) as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runService() { + assert.fail('completed uninstall must not be repeated'); + }, + async cleanupManagedDeployment() { + cleaned = true; + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + await service.recover(); + assert.equal(cleaned, true); + await assert.rejects(readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'), { + code: 'ENOENT', + }); +}); + +async function writeManagedLifecycle( + clientDataRoot: string, + rootPath: string, + rootId: string, +): Promise { + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state: 'managed', + serviceId: 'b'.repeat(64), + operatorPath: join(clientDataRoot, 'operator'), + rootPath, + rootId, + })}\n`, + ); +} + +function sharedCredential(credentialId: string, status: 'active' | 'pending') { + return { + credentialId, + credentialFingerprint: 'f'.repeat(32), + principalKind: 'remote_owner' as const, + principalId: 'desktop-owner:local-runtime-host-sharing', + status, + operationGrants: [], + canPublishClientCapabilities: true, + canUseHostPaths: false, + createdAt: new Date(0).toISOString(), + ...(status === 'pending' ? { expiresAt: new Date(Date.now() + 60_000).toISOString() } : {}), + }; +} 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 1554bd0f78..ce7731ea0a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -907,25 +907,24 @@ test('keeps the SSH profile while adding and removing its managed Direct peer', runAccessManagement: async () => assert.fail('access management is not expected'), runPeerManagement: async (input) => { actions.push(input.action); - return { - kind: 'result', - action: input.action, - status: input.action === 'disable' + const status = input.action === 'disable' ? { - state: 'not_configured', + state: 'not_configured' as const, serviceState: 'running', routeHints: [], coordinationRelays: [], } : { - state: 'enabled', + state: 'enabled' as const, serviceState: 'running', peerId: '12D3KooWpeer', rootId: profile.rootId, routeHints: ['/ip4/192.0.2.8/udp/44001/quic-v1'], coordinationRelays: [], - }, - }; + }; + return input.action === 'status' + ? { kind: 'result', action: input.action, status } + : { kind: 'result', action: input.action, status, restarted: true }; }, cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), }); @@ -971,12 +970,9 @@ test('disables a newly enabled listener when its Desktop profile cannot be commi runAccessManagement: async () => assert.fail('access management is not expected'), runPeerManagement: async (input) => { actions.push(input.action); - return { - kind: 'result', - action: input.action, - status: input.action === 'disable' + const status = input.action === 'disable' ? { - state: 'disabled', + state: 'disabled' as const, serviceState: 'running', peerId: '12D3KooWpeer', rootId: 'a'.repeat(64), @@ -984,14 +980,16 @@ test('disables a newly enabled listener when its Desktop profile cannot be commi coordinationRelays: [], } : { - state: 'enabled', + state: 'enabled' as const, serviceState: 'running', peerId: '12D3KooWpeer', rootId: 'a'.repeat(64), routeHints: failure === 'descriptor' ? [] : ['/ip4/192.0.2.8/udp/44001/quic-v1'], coordinationRelays: [], - }, - }; + }; + return input.action === 'status' + ? { kind: 'result', action: input.action, status } + : { kind: 'result', action: input.action, status, restarted: true }; }, cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), }); @@ -1098,6 +1096,7 @@ function serviceResult( }, }; if (action === 'retire') return { ...result, action, retirement: { kind: 'stopped' } }; + if (action === 'uninstall') return { ...result, action, retirement: { kind: 'stopped' } }; if (action === 'configure') { return { ...result, action, configuration: { kind: 'unchanged' } }; } 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 5fe96453bd..219cbded36 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 @@ -25,6 +25,7 @@ import { afterEach, test } from "node:test"; import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, + encodeRuntimeHostOwnerConnectionCode, LOCAL_RUNTIME_HOST_PROFILE, RuntimeHostPermanentReconnectError, RuntimeHostRemoteCompatibilityError, @@ -494,6 +495,45 @@ test("keeps a separate profile when the same Host is paired through another conn assert.equal((await catalog.resolve("replacement")).credential, "new-token"); }); +test('classifies connection-code failures without exposing transport errors to the renderer', async () => { + const root = await clientRoot(); + const startup = await resolveDesktopRuntimeHostStartup(root); + const service = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup, + states: () => [connectingLocal()], + enable: async () => { + throw new RuntimeHostPermanentReconnectError( + 'Runtime Host profile candidate rejected its access credential', + ); + }, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + + assert.deepEqual(await service.importConnectionCode('not-a-code'), { + kind: 'error', + reason: 'invalid_code', + }); + assert.deepEqual( + await service.importConnectionCode( + encodeRuntimeHostOwnerConnectionCode({ + name: 'Other computer', + rootId: ROOT_ID, + transport: { + kind: 'libp2p-direct', + peerId: '12D3KooWpeer', + routeHints: ['/ip4/192.0.2.8/udp/44001/quic-v1'], + coordinationRelays: [], + }, + credential: 'pending-credential', + }), + ), + { kind: 'error', reason: 'code_unavailable' }, + ); +}); + test("finishes a persisted pairing after Desktop restarts before finalization", async () => { const root = await clientRoot(); const catalog = await stageInterruptedPairing(root); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index bd0c692f66..9440a70e3f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -169,6 +169,8 @@ import { } from "./runtime-host-ssh-terminal.js"; import { createRuntimeHostSetupPackageResolver } from "./runtime-host-setup-package.js"; import { configureDesktopRuntimeHostPeerClient } from './runtime-host-peer-client.js'; +import { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js'; +import { createDesktopLocalRuntimeHostRemoteAccess } from './runtime-host-local-remote-access.js'; import { createDesktopRuntimeHostOnboarding } from "./runtime-host-onboarding.js"; import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js"; import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js"; @@ -381,6 +383,17 @@ const runtimeHostSetupPackage = createRuntimeHostSetupPackageResolver({ appPath: app.getAppPath(), environment: process.env, }); +const localRuntimeHostOperator = createDesktopRuntimeHostLocalOperator(); +const localRuntimeHostRemoteAccess = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain, + clientDataRoot: userDataDir, + rootPath: startupLocalStorageRoot.canonicalPath, + rootId: startupLocalStorageRoot.rootId, + directPeerAvailable: runtimeHostDirectPeerAvailable, + manager: () => runtimeHostManager, + resolveSetupPackage: runtimeHostSetupPackage.resolve, + operator: localRuntimeHostOperator, +}); const native = assembleDesktopNativeCapabilities({ isComputerUseRealModelE2e, locale: desktopLocale, @@ -916,6 +929,9 @@ runtimeHostManager = await startRuntimeHostDesktopManager( }); wireLifecycle(); runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfileId); +await localRuntimeHostRemoteAccess.recover().catch((error: unknown) => { + console.error('[runtime-host] interrupted Local Host setup could not be recovered:', error); +}); void runtimeHostProfileService.startEnabledProfiles(); const unavailableDefault = runtimeHostStartup.unavailable.get( runtimeHostStartup.preferences.defaultProfileId, @@ -1591,6 +1607,7 @@ async function closeRuntimeHostDesktop(): Promise { Promise.resolve().then(() => runtimeHostManagement.close()), runtimeHostManager?.close(), runtimeHostOnboarding.close(), + localRuntimeHostRemoteAccess.close(), runtimeHostSetupPackage.close(), Promise.resolve().then(() => workBoardIpc.close()), runtimeHostSshTerminal.close(), diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index cc347473c6..e5baea49cb 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -71,6 +71,7 @@ export interface RuntimeHostDesktopManager { previousHostEpoch?: string, signal?: AbortSignal, ): Promise; + runManagedLocalHostChange(change: () => Promise): Promise; setDefaultProfile(profileId: string): void; retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise; close(): Promise; @@ -326,7 +327,11 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { try { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) throw new RuntimeHostPairingFinalizationInterruptedError(); - await candidate.client.finalizeAccessCredential(remainingMs); + const finalized = await candidate.client.finalizeAccessCredential(remainingMs); + if (finalized.reconnectRequired) { + await candidate.close(); + await this.#waitForReadyCandidate(lifecycle, candidate, signal); + } return; } catch (error) { if (pairingFinalizeTimedOut(error)) { @@ -517,6 +522,23 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { await this.#removeTarget(target); } + runManagedLocalHostChange(change: () => Promise): Promise { + return this.#mutateTarget(LOCAL_RUNTIME_HOST_PROFILE.id, async () => { + const lifecycle = this.#requireLifecycle( + this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), + ); + const quiescence = await lifecycle.quiesce(); + try { + if (quiescence.current.hostLifecycleMode !== 'service') { + throw new Error('The Local Runtime Host is not managed by a background service'); + } + return await change(); + } finally { + quiescence.resume(); + } + }); + } + setDefaultProfile(profileId: string): void { this.#defaultProfileId = profileId; this.onDefaultProfileChanged?.(profileId); @@ -842,19 +864,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } } - #mutateTarget(profileId: string, operation: () => Promise): Promise { + #mutateTarget(profileId: string, operation: () => Promise): Promise { if (this.#closed) { return Promise.reject(new Error('Desktop Runtime Host manager is closed')); } const previous = this.#targetMutations.get(profileId) ?? Promise.resolve(); const pending = previous.catch(() => undefined).then(operation); - const settled = pending.finally(() => { + const settled = pending.then( + () => undefined, + () => undefined, + ).finally(() => { if (this.#targetMutations.get(profileId) === settled) { this.#targetMutations.delete(profileId); } }); this.#targetMutations.set(profileId, settled); - return settled; + return pending; } #activate(target: DesktopRuntimeHostTargetGeneration): void { diff --git a/apps/desktop/src/main/runtime-host-framed-output.ts b/apps/desktop/src/main/runtime-host-framed-output.ts new file mode 100644 index 0000000000..5a24062815 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-framed-output.ts @@ -0,0 +1,95 @@ +/* + * 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. + */ + +export function createRuntimeHostFramedOutputFilter(input: { + readonly prefix: string; + readonly pendingMaxBytes: number; + readonly decode: (line: string) => Frame | undefined; + readonly label: string; + readonly onFrame: (frame: Frame) => void; + readonly onError: (error: Error) => void; +}): { push(data: string): string; finish(): string } { + let pending = ''; + let discardReservedLine = false; + const drain = (finished: boolean): string => { + let visible = ''; + while (pending) { + if (discardReservedLine) { + const newline = pending.indexOf('\n'); + if (newline < 0) { + pending = ''; + break; + } + pending = pending.slice(newline + 1); + discardReservedLine = false; + continue; + } + const marker = pending.indexOf(input.prefix); + if (marker >= 0) { + visible += pending.slice(0, marker); + pending = pending.slice(marker); + const newline = pending.indexOf('\n'); + if (newline < 0) { + if (finished) { + input.onError(new Error(`${input.label} returned an incomplete result`)); + pending = ''; + } else if (pending.length > input.pendingMaxBytes) { + input.onError(new Error(`${input.label} returned an oversized result`)); + pending = ''; + discardReservedLine = true; + } + break; + } + const line = pending.slice(0, newline + 1); + pending = pending.slice(newline + 1); + const frame = input.decode(line); + if (frame) input.onFrame(frame); + else input.onError(new Error(`${input.label} returned an invalid result`)); + continue; + } + if (finished) { + visible += pending; + pending = ''; + break; + } + const retained = markerSuffixLength(pending, input.prefix); + visible += pending.slice(0, pending.length - retained); + pending = pending.slice(pending.length - retained); + break; + } + return visible; + }; + return { + push(data) { + pending += data; + return drain(false); + }, + finish() { + return drain(true); + }, + }; +} + +function markerSuffixLength(value: string, prefix: string): number { + const limit = Math.min(value.length, prefix.length - 1); + for (let length = limit; length > 0; length -= 1) { + if (prefix.startsWith(value.slice(-length))) return length; + } + return 0; +} diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts new file mode 100644 index 0000000000..a6dd2c6d25 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -0,0 +1,605 @@ +/* + * 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 { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, realpath, rm, rmdir, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { redactSecrets } from '@maka/core/redaction'; +import { + DEFAULT_PROCESS_TERMINATION_GRACE_MS, + terminateChildProcessTree, +} from '@maka/runtime/process-tree-terminator'; +import { + decodeRuntimeHostAccessManagementFrame, + decodeRuntimeHostPeerManagementFrame, + decodeRuntimeHostServiceManagementFrame, + decodeRuntimeHostSetupFrame, + RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_SETUP_FRAME_PREFIX, + type RuntimeHostAccessManagementFrame, + type RuntimeHostPeerManagementFrame, + type RuntimeHostServiceManagementFrame, + type RuntimeHostSetupFrame, +} from '@maka/runtime-host/operator'; +import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; +import { + isExactRuntimeHostSetupPackageSpecifier, + type DesktopRuntimeHostSetupPackage, +} from './runtime-host-ssh-terminal.js'; + +const SETUP_TIMEOUT_MS = 10 * 60_000; +const SETUP_FRAME_PENDING_MAX = 20 * 1024; +const STDERR_MAX_BYTES = 64 * 1024; + +type RuntimeHostSetupCompleteFrame = Extract; + +export interface DesktopRuntimeHostLocalServiceTarget { + readonly serviceId: string; + readonly rootPath: string; + readonly rootId: string; +} + +export interface DesktopRuntimeHostLocalSetupInput { + readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly clientDataRoot: string; + readonly rootPath: string; + readonly principalId: string; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly coordinationRelays?: readonly string[]; + readonly expectedTarget: DesktopRuntimeHostLocalServiceTarget; + readonly signal?: AbortSignal; +} + +export interface DesktopRuntimeHostLocalSetupCommand { + readonly executable: string; + readonly args: readonly string[]; +} + +export function runtimeHostLocalSetupCommand(input: { + readonly packageSpecifier: string; + readonly clientDataRoot: string; + readonly rootPath: string; + readonly principalId: string; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly coordinationRelays?: readonly string[]; + readonly expectedTarget: DesktopRuntimeHostLocalServiceTarget; +}): DesktopRuntimeHostLocalSetupCommand { + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) { + throw new Error('Runtime Host setup principal is invalid'); + } + return { + executable: 'npm', + args: [ + 'exec', + '--yes', + '--package', + input.packageSpecifier, + '--', + 'maka', + 'runtime-host', + 'setup', + '--client-data-root', + input.clientDataRoot, + '--root', + input.rootPath, + '--principal', + input.principalId, + '--preset', + 'desktop-client', + '--defer-pairing-commit', + '--bind-pairing-to-client', + '--enable-direct-peer', + ...managedTargetArgs(input.expectedTarget), + ...(input.coordinationRelays ?? []).flatMap((relay) => [ + '--coordination-relay', + relay, + ]), + ...(input.projectDirectoryRoots === undefined + ? [] + : input.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : input.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + '--json', + ], + }; +} + +export function createDesktopRuntimeHostLocalOperator(input: { + readonly environment?: NodeJS.ProcessEnv; + readonly spawnProcess?: typeof spawn; + readonly setupTimeoutMs?: number; + readonly terminateProcess?: typeof terminateChildProcessTree; +} = {}): { + runSetup( + setup: DesktopRuntimeHostLocalSetupInput, + onProgress: (frame: Extract) => void, + ): Promise; + runPeer(input: { + readonly operatorPath: string; + readonly action: 'enable' | 'disable' | 'status'; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly coordinationRelays?: readonly string[]; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; + }): Promise; + runAccess(input: { + readonly operatorPath: string; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly signal?: AbortSignal; + }): Promise; + runService(input: { + readonly operatorPath: string; + readonly action: 'status' | 'retire' | 'uninstall'; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly allowInterruptActiveTasks?: boolean; + readonly retainManagedDeployment?: boolean; + readonly signal?: AbortSignal; + }): Promise; + cleanupManagedDeployment(input: { + readonly operatorPath: string; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly signal?: AbortSignal; + }): Promise; + close(): Promise; +} { + const active = new Set(); + let closed = false; + const closing = new AbortController(); + const terminate = input.terminateProcess ?? terminateChildProcessTree; + + return { + async runSetup(setup, onProgress) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + setup.signal?.throwIfAborted(); + const packageSpecifier = await resolveLocalSetupPackage(setup.setupPackage); + const command = runtimeHostLocalSetupCommand({ ...setup, packageSpecifier }); + const workingDirectory = await mkdtemp(join(tmpdir(), 'maka-runtime-host-local-setup-')); + try { + if (closed) throw new Error('Local Runtime Host operator is closed'); + const signal = combinedSignal(setup.signal, closing.signal); + signal.throwIfAborted(); + return await runSetupProcess({ + command, + cwd: workingDirectory, + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal, + onProgress, + active, + }); + } finally { + await rm(workingDirectory, { recursive: true, force: true }); + } + }, + runPeer(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runSingleFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + 'peer', + command.action, + '--framed', + ...(command.action === 'enable' + ? command.coordinationRelays?.length + ? command.coordinationRelays.flatMap((relay) => [ + '--coordination-relay', + relay, + ]) + : ['--clear-coordination-relays'] + : []), + ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + ...managedTargetArgs(command.target), + ], + }, + prefix: RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostPeerManagementFrame, + label: 'Local Runtime Host peer management', + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + }).then((frame) => requirePeerFrame(frame, command.action, command.target)); + }, + runAccess(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runSingleFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + 'access', + 'list', + '--framed', + '--root', + command.target.rootPath, + '--expected-root', + command.target.rootId, + ], + }, + prefix: RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostAccessManagementFrame, + label: 'Local Runtime Host access management', + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + }).then(requireAccessListFrame); + }, + runService(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runSingleFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + command.action, + '--framed', + ...(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', + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + }).then((frame) => requireServiceFrame(frame, command.action)); + }, + async cleanupManagedDeployment(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + try { + await stat(command.operatorPath); + } catch (error) { + if (!isNodeError(error, 'ENOENT')) throw error; + try { + await rmdir(dirname(command.operatorPath)); + } catch (directoryError) { + if (!isNodeError(directoryError, 'ENOENT')) throw directoryError; + } + return; + } + await runExitProcess({ + command: { + executable: command.operatorPath, + args: ['__cleanup-managed-deployment', ...managedTargetArgs(command.target)], + }, + label: 'Local Runtime Host deployment cleanup', + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + }); + }, + async close() { + if (closed) return; + closed = true; + closing.abort(new Error('Local Runtime Host operator is closed')); + await Promise.allSettled([...active].map((child) => stopProcess(child, terminate))); + }, + }; +} + +function requirePeerFrame( + frame: RuntimeHostPeerManagementFrame, + action: 'enable' | 'disable' | 'status', + target: DesktopRuntimeHostLocalServiceTarget, +): RuntimeHostPeerManagementFrame { + if (frame.action !== action) { + throw new Error('Local Runtime Host peer management returned an unrelated result'); + } + if ( + frame.kind === 'result' && + frame.status.state === 'enabled' && + frame.status.rootId !== target.rootId + ) { + throw new Error('Local Runtime Host peer management returned an unrelated root'); + } + return frame; +} + +function requireAccessListFrame( + frame: RuntimeHostAccessManagementFrame, +): RuntimeHostAccessManagementFrame { + if (frame.action !== 'list') { + throw new Error('Local Runtime Host access management returned an unrelated result'); + } + return frame; +} + +function requireServiceFrame( + frame: RuntimeHostServiceManagementFrame, + action: 'status' | 'retire' | 'uninstall', +): RuntimeHostServiceManagementFrame { + if (frame.action !== action) { + throw new Error('Local Runtime Host service management returned an unrelated result'); + } + return frame; +} + +function combinedSignal( + operation: AbortSignal | undefined, + closing: AbortSignal, +): AbortSignal { + return operation ? AbortSignal.any([operation, closing]) : closing; +} + +async function resolveLocalSetupPackage( + setupPackage: DesktopRuntimeHostSetupPackage, +): Promise { + if (setupPackage.kind === 'npm') { + if (!isExactRuntimeHostSetupPackageSpecifier(setupPackage.specifier)) { + throw new Error('Runtime Host setup package is invalid'); + } + return setupPackage.specifier; + } + const archive = await realpath(setupPackage.path); + if (!(await stat(archive)).isFile() || !archive.endsWith('.tgz')) { + throw new Error('Runtime Host development package must be a .tgz file'); + } + return archive; +} + +function managedTargetArgs(target: DesktopRuntimeHostLocalServiceTarget): string[] { + return [ + '--expected-service-id', + target.serviceId, + '--expected-root-path', + target.rootPath, + '--expected-root-id', + target.rootId, + ]; +} + +function runSingleFrameProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly prefix: string; + readonly decode: (line: string) => Frame | undefined; + readonly label: string; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; +}): Promise { + let result: Frame | undefined; + let failure: Error | undefined; + return runFramedProcess({ + ...input, + onFrame(frame) { + if (result) failure = new Error(`${input.label} returned multiple results`); + else result = frame; + }, + result: () => result, + failure: () => failure, + acceptNonzeroResult: true, + }); +} + +function runExitProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly label: string; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; +}): Promise { + return runFramedProcess({ + ...input, + prefix: 'MAKA_UNUSED_FRAME ', + decode: () => undefined, + onFrame: () => undefined, + result: () => true, + failure: () => undefined, + }).then(() => undefined); +} + +function runSetupProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly onProgress: (frame: Extract) => void; + readonly active: Set; +}): Promise { + let complete: RuntimeHostSetupCompleteFrame | undefined; + let failure: Error | undefined; + return runFramedProcess({ + ...input, + prefix: RUNTIME_HOST_SETUP_FRAME_PREFIX, + decode: decodeRuntimeHostSetupFrame, + label: 'Local Maka setup', + onFrame(frame) { + if (frame.kind === 'progress') input.onProgress(frame); + else if (frame.kind === 'error') failure = new Error(frame.error.message); + else if (complete) failure = new Error('Local Maka setup returned multiple results'); + else complete = frame; + }, + result: () => complete, + failure: () => failure, + }); +} + +function runFramedProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly cwd?: string; + readonly prefix: string; + readonly decode: (line: string) => Frame | undefined; + readonly label: string; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; + readonly onFrame: (frame: Frame) => void; + readonly result: () => Result | undefined; + readonly failure: () => Error | undefined; + readonly acceptNonzeroResult?: boolean; +}): Promise { + input.signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const child = input.spawnProcess(input.command.executable, [...input.command.args], { + ...(input.cwd ? { cwd: input.cwd } : {}), + detached: process.platform !== 'win32', + env: input.environment, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + input.active.add(child); + let filterFailure: Error | undefined; + let stopFailure: Error | undefined; + let stderr = ''; + let settled = false; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: input.prefix, + pendingMaxBytes: SETUP_FRAME_PENDING_MAX, + decode: input.decode, + label: input.label, + onFrame: (frame) => { + try { + input.onFrame(frame); + } catch (error) { + filterFailure = error instanceof Error ? error : new Error(String(error)); + } + }, + onError: (error) => { + filterFailure = error; + }, + }); + const cleanup = () => { + clearTimeout(timeout); + input.signal?.removeEventListener('abort', onAbort); + input.active.delete(child); + }; + const finish = (result: Result | undefined, error?: Error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(result!); + }; + const stop = (error: Error) => { + if (stopFailure) return; + stopFailure = error; + void stopProcess(child, input.terminate).then( + () => finish(undefined, error), + (stopError) => finish(undefined, new AggregateError([error, stopError])), + ); + }; + const onAbort = () => stop(abortError(input.signal)); + const timeout = setTimeout(() => stop(new Error(`${input.label} timed out`)), input.timeoutMs); + input.signal?.addEventListener('abort', onAbort, { once: true }); + child.stdout?.on('data', (chunk: Buffer) => filter.push(chunk.toString('utf8'))); + child.stderr?.on('data', (chunk: Buffer) => { + stderr = appendBounded(stderr, chunk.toString('utf8'), STDERR_MAX_BYTES); + }); + child.once('error', (error) => { + if (!stopFailure) finish(undefined, error); + }); + child.once('close', (code, signal) => { + if (stopFailure) return; + filter.finish(); + const failure = filterFailure ?? input.failure(); + if (failure) return finish(undefined, failure); + const result = input.result(); + if (result && (code === 0 || input.acceptNonzeroResult)) return finish(result); + const status = code === null ? signal ?? 'an unknown status' : `code ${code}`; + const detail = redactSecrets(stderr.trim()).slice(-2_000); + finish( + undefined, + new Error( + detail + ? `${input.label} exited with ${status}: ${detail}` + : result + ? `${input.label} exited with ${status}` + : `${input.label} ended without a result (${status})`, + ), + ); + }); + if (input.signal?.aborted) onAbort(); + }); +} + +async function stopProcess( + child: ChildProcess, + terminate: typeof terminateChildProcessTree, +): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await terminate(child, 'SIGTERM'); + if (await exitsWithin(child, DEFAULT_PROCESS_TERMINATION_GRACE_MS)) return; + await terminate(child, 'SIGKILL'); + if (!(await exitsWithin(child, DEFAULT_PROCESS_TERMINATION_GRACE_MS))) { + throw new Error('Local Runtime Host operator did not exit after forced termination'); + } +} + +function exitsWithin(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); + return new Promise((resolve) => { + const timeout = setTimeout(() => { + child.removeListener('close', onClose); + resolve(false); + }, timeoutMs); + const onClose = () => { + clearTimeout(timeout); + resolve(true); + }; + child.once('close', onClose); + }); +} + +function appendBounded(current: string, chunk: string, maxBytes: number): string { + const next = current + chunk; + const encoded = Buffer.from(next); + return encoded.byteLength <= maxBytes + ? next + : encoded.subarray(encoded.byteLength - maxBytes).toString('utf8'); +} + +function abortError(signal: AbortSignal | undefined): Error { + return signal?.reason instanceof Error ? signal.reason : new Error('Local Maka setup was cancelled'); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts new file mode 100644 index 0000000000..5a1117a980 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -0,0 +1,899 @@ +/* + * 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 { 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 type { IpcMain } from 'electron'; +import { + consumeAccessCredentialDelivery, + encodeRuntimeHostOwnerConnectionCode, +} from '@maka/runtime-host/client'; +import { resolveRuntimeHostManagedServiceId } from '@maka/runtime-host/operator'; +import { REMOTE_OWNER_OPERATION_GRANTS } from '@maka/runtime-host/protocol'; +import type { + DesktopLocalRuntimeHostRemoteAccessEnableResult, + DesktopLocalRuntimeHostRemoteAccessSnapshot, +} from '../preload/bridge-contract.js'; +import type { RuntimeHostDesktopManager } from './runtime-host-desktop-manager.js'; +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import type { + createDesktopRuntimeHostLocalOperator, + DesktopRuntimeHostLocalServiceTarget, +} from './runtime-host-local-operator.js'; +import type { DesktopRuntimeHostSetupPackage } from './runtime-host-ssh-terminal.js'; + +const LIFECYCLE_FILE = 'runtime-host-local-service.json'; +const SERVICE_ID_PATTERN = /^[a-f0-9]{64}$/u; +const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; +const ADDRESS_MAX_BYTES = 2 * 1024; +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; +} + +interface LocalServiceHandoff { + readonly schemaVersion: 1; + readonly state: 'handoff'; + readonly serviceId: string; + readonly rootPath: string; + readonly rootId: string; + readonly coordinationRelays: readonly string[]; + readonly allowInterruptActiveTasks: boolean; +} + +interface LocalServiceManaged extends LocalServiceTarget { + readonly state: 'managed'; +} + +interface LocalServicePeerChanging extends LocalServiceTarget { + readonly state: 'peerChanging'; + readonly peerEnabled: boolean; + readonly coordinationRelays: readonly string[]; + readonly allowInterruptActiveTasks: boolean; +} + +interface LocalServiceUninstalling extends LocalServiceTarget { + readonly state: 'uninstalling' | 'cleanupPending'; + readonly allowInterruptActiveTasks: boolean; +} + +type LocalServiceLifecycle = + | LocalServiceHandoff + | LocalServiceManaged + | LocalServicePeerChanging + | LocalServiceUninstalling; + +interface LocalPeerDescriptor { + readonly peerId: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; +} + +type DesktopRuntimeHostLocalOperator = ReturnType< + typeof createDesktopRuntimeHostLocalOperator +>; +type LocalPeerResultFrame = Extract< + Awaited>, + { kind: 'result'; action: 'enable' | 'disable' } +>; + +export function createDesktopLocalRuntimeHostRemoteAccess(input: { + readonly ipcMain: Pick; + readonly clientDataRoot: string; + readonly rootPath: string; + readonly rootId: string; + readonly directPeerAvailable: boolean; + readonly manager: () => RuntimeHostDesktopManager | undefined; + readonly resolveSetupPackage: ( + signal?: AbortSignal, + ) => DesktopRuntimeHostSetupPackage | Promise; + readonly operator: DesktopRuntimeHostLocalOperator; +}): { recover(): Promise; close(): Promise } { + const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE); + const closing = new AbortController(); + let mutation = Promise.resolve(); + const serialize = (operation: () => Promise): Promise => { + const result = mutation.then(operation); + mutation = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + + const getSnapshot = (): Promise => + serialize(async () => { + if (!supported(input.directPeerAvailable)) return unsupportedSnapshot(); + try { + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (!lifecycle) return { state: 'off' }; + if (lifecycle.state !== 'managed') { + return { + state: 'unavailable', + message: + lifecycle.state === 'handoff' + ? 'Local Runtime Host setup is being recovered' + : lifecycle.state === 'peerChanging' + ? 'Local Runtime Host remote access is being recovered' + : 'Local Runtime Host uninstall is being recovered', + }; + } + const sharedAccess = await hasSharedAccess(input.operator, lifecycle); + const peer = await readPeer(input.operator, lifecycle); + return peer + ? onSnapshot(sharedAccess) + : { state: 'off', managedService: true, ...(sharedAccess ? { sharedAccess: true } : {}) }; + } catch (error) { + return { state: 'unavailable', message: errorMessage(error) }; + } + }); + + const enable = (value: unknown): Promise => + serialize(async () => { + const request = requireEnableInput(value); + if (!supported(input.directPeerAvailable)) throw new Error(unsupportedSnapshot().message); + let lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state === 'uninstalling') { + const recovered = await finishUninstall(lifecycle); + if (recovered.kind === 'active_tasks') return recovered; + lifecycle = undefined; + } + if (lifecycle?.state === 'cleanupPending') { + await finishUninstall(lifecycle); + lifecycle = undefined; + } + if (lifecycle?.state === 'peerChanging') { + const recovered = await finishPeerChange(lifecycle); + if (recovered.kind === 'active_tasks') return recovered; + lifecycle = managedLifecycle(lifecycle); + } + if (lifecycle?.state === 'handoff') { + const recovered = await finishHandoff(lifecycle, true); + if (recovered.kind === 'active_tasks') return recovered; + lifecycle = recovered.managed; + } + if (lifecycle?.state === 'managed') { + const manager = requireManager(input.manager); + const previousHostEpoch = manager.current('local')?.candidate?.client.hostEpoch; + const desired: LocalServicePeerChanging = { + ...lifecycle, + state: 'peerChanging', + peerEnabled: true, + coordinationRelays: request.coordinationRelays, + allowInterruptActiveTasks: request.allowInterruptActiveTasks, + }; + await writeDocument(lifecyclePath, desired); + const changed = await finishPeerChange(desired); + if (changed.kind === 'active_tasks') { + return changed; + } + const peer = requireEnabledPeer(changed.response.status); + await manager.waitUntilReady( + 'local', + changed.response.restarted ? previousHostEpoch : undefined, + ); + return enabledResult( + await issueConnectionCode(input.rootPath, desired.rootId, peer, localClient(input.manager)), + ); + } + + const handoff: LocalServiceHandoff = { + schemaVersion: 1, + state: 'handoff', + serviceId: resolveRuntimeHostManagedServiceId(input.clientDataRoot), + rootPath: input.rootPath, + rootId: input.rootId, + coordinationRelays: request.coordinationRelays, + allowInterruptActiveTasks: request.allowInterruptActiveTasks, + }; + await writeDocument(lifecyclePath, handoff); + const completed = await finishHandoff(handoff, false); + if (completed.kind === 'active_tasks') return completed; + return enabledResult( + encodeRuntimeHostOwnerConnectionCode({ + name: hostName(), + rootId: completed.managed.rootId, + transport: { kind: 'libp2p-direct', ...completed.peer }, + credential: completed.credential, + }), + ); + }); + + const finishHandoff = async ( + handoff: LocalServiceHandoff, + allowAlreadyManaged: boolean, + ): Promise< + | { readonly kind: 'active_tasks' } + | { + readonly kind: 'complete'; + readonly managed: LocalServiceManaged; + readonly peer: LocalPeerDescriptor; + readonly credential: string; + } + > => { + const setupPackage = await input.resolveSetupPackage(closing.signal); + const manager = requireManager(input.manager); + const retirement = await manager.retireOwnedLocalHost( + handoff.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (retirement.kind === 'active_tasks') { + if (!allowAlreadyManaged) await removeDocument(lifecyclePath); + return { kind: 'active_tasks' }; + } + if (retirement.kind === 'not_owned' && !allowAlreadyManaged) { + await removeDocument(lifecyclePath); + throw new Error('The Local Runtime Host is already managed outside this Desktop'); + } + let target: LocalServiceTarget | undefined; + try { + const complete = await input.operator.runSetup( + { + setupPackage, + clientDataRoot: input.clientDataRoot, + rootPath: handoff.rootPath, + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + coordinationRelays: handoff.coordinationRelays, + expectedTarget: handoff, + signal: closing.signal, + }, + () => undefined, + ); + if ( + complete.serviceId !== handoff.serviceId || + complete.rootPath !== handoff.rootPath || + complete.rootId !== handoff.rootId || + !complete.directPeer + ) { + throw new Error('Local Runtime Host setup returned an unrelated service'); + } + target = requireServiceTarget( + { + schemaVersion: 1, + serviceId: complete.serviceId, + operatorPath: complete.operatorPath, + rootPath: complete.rootPath, + rootId: complete.rootId, + }, + handoff.rootPath, + ); + const peer = requireEnabledPeer({ state: 'enabled', ...complete.directPeer }); + const managed: LocalServiceManaged = { + ...target, + state: 'managed', + }; + await writeDocument(lifecyclePath, managed); + return { kind: 'complete', managed, peer, credential: complete.credential }; + } catch (error) { + if (!target) throw error; + try { + await uninstallExactService(input.operator, target); + await removeDocument(lifecyclePath); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Local Runtime Host setup rollback failed', + ); + } + throw error; + } finally { + if (retirement.kind === 'retired') retirement.resume(); + } + }; + + const createConnectionCode = (): Promise => + serialize(async () => { + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + const peer = await readPeer(input.operator, managed); + if (!peer) throw new Error('Remote access is not enabled on this computer'); + return issueConnectionCode(input.rootPath, managed.rootId, peer, localClient(input.manager)); + }); + + const revokeSharedAccess = (): Promise => + serialize(async () => { + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + await localClient(input.manager).request('access.principal.revoke', { + principalKind: 'remote_owner', + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + }); + const peer = await readPeer(input.operator, managed); + return peer ? onSnapshot(false) : { state: 'off', managedService: true }; + }); + + const disable = (): Promise => + serialize(async () => { + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + const desired: LocalServicePeerChanging = { + ...managed, + state: 'peerChanging', + peerEnabled: false, + coordinationRelays: [], + allowInterruptActiveTasks: false, + }; + await writeDocument(lifecyclePath, desired); + const changed = await finishPeerChange(desired); + if (changed.kind === 'active_tasks') { + throw new Error('Runtime Host still owns active work; remote access was not disabled'); + } + if (changed.response.status.state === 'enabled') { + throw new Error('Local Runtime Host Direct peer did not disable'); + } + return { state: 'off', managedService: true, ...(await sharedAccessFlag(input.operator, managed)) }; + }); + + const uninstall = ( + value: unknown, + ): Promise<{ readonly kind: 'active_tasks' | 'uninstalled' }> => + serialize(async () => { + if (!isRecord(value) || typeof value.allowInterruptActiveTasks !== 'boolean') { + throw new Error('Local Runtime Host uninstall request is invalid'); + } + const allowInterruptActiveTasks = value.allowInterruptActiveTasks; + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + const intent: LocalServiceUninstalling = { + ...managed, + state: 'uninstalling', + allowInterruptActiveTasks, + }; + await writeDocument(lifecyclePath, intent); + return finishUninstall(intent); + }); + + const finishPeerChange = async ( + intent: LocalServicePeerChanging, + ): Promise< + | { readonly kind: 'active_tasks' } + | { readonly kind: 'complete'; readonly response: LocalPeerResultFrame } + > => { + const changed = await runManagedServiceChange(intent.allowInterruptActiveTasks, () => + input.operator.runPeer({ + operatorPath: intent.operatorPath, + action: intent.peerEnabled ? 'enable' : 'disable', + target: intent, + coordinationRelays: intent.coordinationRelays, + allowInterruptActiveTasks: intent.allowInterruptActiveTasks, + }), + ); + if (changed.kind === 'active_tasks') { + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return changed; + } + const response = changed.value; + if (response.kind === 'error') { + if (response.error.code === 'active_tasks') { + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return { kind: 'active_tasks' }; + } + throw new Error(response.error.message); + } + if (response.action === 'status') { + throw new Error('Local Runtime Host returned an unrelated peer result'); + } + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return { kind: 'complete', response }; + }; + + const finishUninstall = async ( + intent: LocalServiceUninstalling, + ): Promise<{ readonly kind: 'active_tasks' } | { readonly kind: 'uninstalled' }> => { + if (intent.state === 'uninstalling') { + const changed = await runManagedServiceChange(intent.allowInterruptActiveTasks, () => + input.operator.runService({ + operatorPath: intent.operatorPath, + action: 'uninstall', + target: intent, + allowInterruptActiveTasks: intent.allowInterruptActiveTasks, + retainManagedDeployment: true, + }), + ); + if (changed.kind === 'active_tasks') { + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return changed; + } + const response = changed.value; + if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== 'uninstall') { + throw new Error('Local Runtime Host returned an unrelated service result'); + } + if (response.retirement.kind === 'active_tasks') { + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return { kind: 'active_tasks' }; + } + intent = { ...intent, state: 'cleanupPending' }; + await writeDocument(lifecyclePath, intent); + } + await input.operator.cleanupManagedDeployment({ + operatorPath: intent.operatorPath, + target: intent, + signal: closing.signal, + }); + await removeDocument(lifecyclePath); + return { kind: 'uninstalled' }; + }; + + const runManagedServiceChange = async ( + allowInterruptActiveTasks: boolean, + change: () => Promise, + ): Promise<{ readonly kind: 'active_tasks' } | { readonly kind: 'complete'; readonly value: T }> => { + const manager = requireManager(input.manager); + const retirement = await manager.retireOwnedLocalHost( + allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (retirement.kind === 'active_tasks') return { kind: 'active_tasks' }; + if (retirement.kind === 'not_owned') { + return { kind: 'complete', value: await manager.runManagedLocalHostChange(change) }; + } + try { + return { kind: 'complete', value: await change() }; + } finally { + retirement.resume(); + } + }; + + const channels = [ + 'local-runtime-host-remote-access:get-snapshot', + 'local-runtime-host-remote-access:enable', + 'local-runtime-host-remote-access:create-connection-code', + 'local-runtime-host-remote-access:revoke-shared-access', + 'local-runtime-host-remote-access:disable', + 'local-runtime-host-remote-access:uninstall', + ] as const; + input.ipcMain.handle(channels[0], getSnapshot); + input.ipcMain.handle(channels[1], (_event, value: unknown) => enable(value)); + input.ipcMain.handle(channels[2], createConnectionCode); + input.ipcMain.handle(channels[3], revokeSharedAccess); + input.ipcMain.handle(channels[4], disable); + input.ipcMain.handle(channels[5], (_event, value: unknown) => uninstall(value)); + + return { + recover: () => + serialize(async () => { + if (!supported(input.directPeerAvailable)) return; + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (!lifecycle) return; + if (lifecycle.state === 'handoff') { + await finishHandoff(lifecycle, true); + return; + } + if (lifecycle.state === 'uninstalling') { + await finishUninstall(lifecycle); + return; + } + if (lifecycle.state === 'cleanupPending') { + await finishUninstall(lifecycle); + return; + } + if (lifecycle.state === 'peerChanging') { + const recovered = await finishPeerChange(lifecycle); + if (recovered.kind === 'active_tasks') { + throw new Error('Local Runtime Host peer recovery was blocked by active work'); + } + } + }), + async close() { + for (const channel of channels) input.ipcMain.removeHandler(channel); + closing.abort(new Error('Maka is shutting down')); + await input.operator.close(); + await mutation; + }, + }; +} + +function supported(directPeerAvailable: boolean): boolean { + return directPeerAvailable && (process.platform === 'darwin' || process.platform === 'linux'); +} + +function unsupportedSnapshot(): Extract< + DesktopLocalRuntimeHostRemoteAccessSnapshot, + { state: 'unsupported' } +> { + return { + state: 'unsupported', + message: + process.platform === 'darwin' || process.platform === 'linux' + ? 'This Desktop build does not include Direct peer support' + : 'Remote access to this computer currently requires macOS or Linux', + }; +} + +function requireEnableInput(value: unknown): { + readonly allowInterruptActiveTasks: boolean; + readonly coordinationRelays: readonly string[]; +} { + if (!isRecord(value) || typeof value.allowInterruptActiveTasks !== 'boolean') { + throw new Error('Local Runtime Host remote-access request is invalid'); + } + return { + allowInterruptActiveTasks: value.allowInterruptActiveTasks, + coordinationRelays: requireAddresses(value.coordinationRelays), + }; +} + +async function readPeer( + operator: DesktopRuntimeHostLocalOperator, + receipt: LocalServiceTarget, +): Promise { + const response = await operator.runPeer({ + operatorPath: receipt.operatorPath, + action: 'status', + target: receipt, + }); + if (response.kind === 'error') throw new Error(response.error.message); + return response.status.state === 'enabled' ? requireEnabledPeer(response.status) : undefined; +} + +function requireEnabledPeer(value: unknown): LocalPeerDescriptor { + if (!isRecord(value) || value.state !== 'enabled') { + throw new Error('Runtime Host Direct peer is not enabled'); + } + if (typeof value.peerId !== 'string' || value.peerId.length === 0 || value.peerId.length > 160) { + throw new Error('Runtime Host returned an invalid peer identity'); + } + const peer = { + peerId: value.peerId, + routeHints: requireAddresses(value.routeHints), + coordinationRelays: requireAddresses(value.coordinationRelays), + }; + if (peer.routeHints.length === 0 && peer.coordinationRelays.length === 0) { + throw new Error('Runtime Host Direct peer has no reachable route'); + } + return peer; +} + +function onSnapshot(sharedAccess: boolean): Extract< + DesktopLocalRuntimeHostRemoteAccessSnapshot, + { state: 'on' } +> { + return { state: 'on', ...(sharedAccess ? { sharedAccess: true } : {}) }; +} + +function enabledResult( + connectionCode: string, +): Extract { + return { kind: 'enabled', connectionCode, snapshot: onSnapshot(true) }; +} + +async function issueConnectionCode( + rootPath: string, + rootId: string, + peer: LocalPeerDescriptor, + client: DesktopRuntimeHostClient, +): Promise { + const prepared = await client.request('access.credential.prepare', { + principalKind: 'remote_owner', + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + operationGrants: REMOTE_OWNER_OPERATION_GRANTS, + canPublishClientCapabilities: true, + canUseHostPaths: false, + bindClientInstance: true, + }); + const credential = await consumeAccessCredentialDelivery( + rootPath, + prepared.deliveryId, + prepared.credentialId, + ); + return encodeRuntimeHostOwnerConnectionCode({ + name: hostName(), + rootId, + transport: { kind: 'libp2p-direct', ...peer }, + credential, + }); +} + +async function hasSharedAccess( + operator: DesktopRuntimeHostLocalOperator, + target: LocalServiceTarget, +): Promise { + const response = await operator.runAccess({ + operatorPath: target.operatorPath, + target, + }); + if (response.kind === 'error') throw new Error(response.error.message); + return response.credentials.some( + (credential) => + credential.principalKind === 'remote_owner' && + credential.principalId === LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + ); +} + +async function sharedAccessFlag( + operator: DesktopRuntimeHostLocalOperator, + target: LocalServiceTarget, +): Promise<{ readonly sharedAccess: true } | Record> { + return (await hasSharedAccess(operator, target)) ? { sharedAccess: true } : {}; +} + +function localClient(manager: () => RuntimeHostDesktopManager | undefined): DesktopRuntimeHostClient { + const snapshot = requireManager(manager).current('local'); + if (!snapshot?.candidate) throw new Error('The Local Runtime Host is reconnecting'); + return snapshot.candidate.client; +} + +function requireManager( + manager: () => RuntimeHostDesktopManager | undefined, +): RuntimeHostDesktopManager { + const current = manager(); + if (!current) throw new Error('Runtime Host manager is unavailable'); + return current; +} + +function hostName(): string { + return hostname().trim().slice(0, 128) || 'Remote computer'; +} + +function requireServiceTarget(value: unknown, rootPath: string): LocalServiceTarget { + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + typeof value.serviceId !== 'string' || + !SERVICE_ID_PATTERN.test(value.serviceId) || + typeof value.rootId !== 'string' || + !ROOT_ID_PATTERN.test(value.rootId) || + value.rootPath !== rootPath || + typeof value.operatorPath !== 'string' || + !isAbsolute(value.operatorPath) + ) { + throw new Error('Local Runtime Host service receipt is invalid'); + } + return { + schemaVersion: 1, + serviceId: value.serviceId, + rootPath, + rootId: value.rootId, + operatorPath: value.operatorPath, + }; +} + +function requireManaged(lifecycle: LocalServiceLifecycle | undefined): LocalServiceManaged { + if (lifecycle?.state !== 'managed') { + throw new Error('Remote access has not been set up on this computer'); + } + return lifecycle; +} + +function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged { + return { + schemaVersion: 1, + state: 'managed', + serviceId: intent.serviceId, + operatorPath: intent.operatorPath, + rootPath: intent.rootPath, + rootId: intent.rootId, + }; +} + +async function readLifecycle( + path: string, + rootPath: string, + rootId: string, +): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(path, 'utf8')) as unknown; + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + value.rootPath !== rootPath || + value.rootId !== rootId + ) { + throw new Error('Local Runtime Host service lifecycle is invalid'); + } + if (value.state === 'handoff') { + assertExactKeys(value, [ + 'schemaVersion', + 'state', + 'serviceId', + 'rootPath', + 'rootId', + 'coordinationRelays', + 'allowInterruptActiveTasks', + ]); + if ( + typeof value.serviceId !== 'string' || + !SERVICE_ID_PATTERN.test(value.serviceId) || + typeof value.allowInterruptActiveTasks !== 'boolean' + ) { + throw new Error('Local Runtime Host handoff intent is invalid'); + } + return { + schemaVersion: 1, + state: 'handoff', + serviceId: value.serviceId, + rootPath, + rootId, + coordinationRelays: requireAddresses(value.coordinationRelays), + allowInterruptActiveTasks: value.allowInterruptActiveTasks, + }; + } + const target = requireServiceTarget(value, rootPath); + assertExactKeys( + value, + value.state === 'managed' + ? [ + 'schemaVersion', + 'state', + 'serviceId', + 'operatorPath', + 'rootPath', + 'rootId', + ] + : value.state === 'peerChanging' + ? [ + 'schemaVersion', + 'state', + 'serviceId', + 'operatorPath', + 'rootPath', + 'rootId', + 'peerEnabled', + 'coordinationRelays', + 'allowInterruptActiveTasks', + ] + : [ + 'schemaVersion', + 'state', + 'serviceId', + 'operatorPath', + 'rootPath', + 'rootId', + 'allowInterruptActiveTasks', + ], + ); + if ( + value.state !== 'managed' && + value.state !== 'peerChanging' && + value.state !== 'uninstalling' && + value.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') { + throw new Error('Local Runtime Host service intent is invalid'); + } + if (value.state === 'peerChanging') { + if (typeof value.peerEnabled !== 'boolean') { + throw new Error('Local Runtime Host peer intent is invalid'); + } + return { + ...target, + state: 'peerChanging', + peerEnabled: value.peerEnabled, + coordinationRelays: requireAddresses(value.coordinationRelays), + allowInterruptActiveTasks: value.allowInterruptActiveTasks, + }; + } + return { + ...target, + state: value.state, + allowInterruptActiveTasks: value.allowInterruptActiveTasks, + }; +} + +function assertExactKeys(value: Record, keys: readonly string[]): void { + if ( + Object.keys(value).some((key) => !keys.includes(key)) || + Object.keys(value).length !== keys.length + ) { + throw new Error('Local Runtime Host service lifecycle is invalid'); + } +} + +async function writeDocument(path: string, value: object): Promise { + const temporaryPath = join(dirname(path), `.runtime-host-local-service-${randomUUID()}.tmp`); + const handle = await open(temporaryPath, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporaryPath, path); + await syncDirectory(dirname(path)); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return; + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function removeDocument(path: string): Promise { + await rm(path, { force: true }); + await syncDirectory(dirname(path)); +} + +async function uninstallExactService( + operator: DesktopRuntimeHostLocalOperator, + receipt: LocalServiceTarget, +): Promise { + const response = await operator.runService({ + operatorPath: receipt.operatorPath, + action: 'uninstall', + target: receipt, + }); + if ( + response.kind === 'error' || + response.action !== 'uninstall' || + response.service.state !== 'not_installed' + ) { + throw new Error( + response.kind === 'error' + ? response.error.message + : 'Local Runtime Host service was not cleanly uninstalled', + ); + } +} + +function requireAddresses(value: unknown): readonly string[] { + if (!Array.isArray(value) || value.length > ADDRESS_MAX_COUNT) { + throw new Error('Runtime Host peer routes are invalid'); + } + return value.map((entry) => { + if ( + typeof entry !== 'string' || + !entry.startsWith('/') || + Buffer.byteLength(entry, 'utf8') > ADDRESS_MAX_BYTES || + /[\s\u0000-\u001f\u007f]/u.test(entry) + ) { + throw new Error('Runtime Host peer route is invalid'); + } + return entry; + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 5688823e0c..0d6fe86db7 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -23,10 +23,12 @@ import { dirname, join } from "node:path"; import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, + decodeRuntimeHostOwnerConnectionCode, LOCAL_RUNTIME_HOST_PROFILE, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, RuntimeHostOperationError, RuntimeHostPermanentReconnectError, + RuntimeHostRemoteCompatibilityError, sameRemoteRuntimeHostProfileTarget, sameResolvedRuntimeHostProfileTarget, type RemoteRuntimeHostProfile, @@ -41,6 +43,7 @@ import type { DesktopRuntimeHostProfileAddResult, DesktopRuntimeHostProfileEntry, DesktopRuntimeHostProfileSnapshot, + DesktopRuntimeHostConnectionCodeImportResult, } from "../preload/bridge-contract.js"; import { RuntimeHostPairingFinalizationInterruptedError, @@ -93,6 +96,7 @@ export interface DesktopRuntimeHostProfileService { readonly managedService?: DesktopRuntimeHostManagedService; }, ): Promise<{ readonly profileId: string }>; + importConnectionCode(code: string): Promise; resolveManagedService( profileId: string, ): Promise; @@ -652,6 +656,56 @@ export function createDesktopRuntimeHostProfileService(input: { } }); + const addAndEnableVerified = ( + value: DesktopRuntimeHostProfileAddInput & { + readonly credential: string; + readonly managedService?: DesktopRuntimeHostManagedService; + }, + ): Promise<{ readonly profileId: string }> => { + requireSaveInput(value); + return mutateProfiles(async () => { + const currentDocument = await catalog.read(); + const existing = currentDocument.profiles.find((profile) => + profile.rootId === value.profile.rootId && + sameRemoteRuntimeHostProfileTarget(profile, value.profile), + ); + const previousTarget = existing ? await catalog.resolve(existing.id) : undefined; + const profile = existing ? { ...value.profile, id: existing.id } : value.profile; + const target = { profile, credential: value.credential } as const; + const intent = createDesktopRuntimeHostPairingIntent({ + target, + ...(previousTarget ? { previous: previousTarget } : {}), + wasEnabled: + previousTarget !== undefined && + preferences.enabledRemoteProfileIds.includes(previousTarget.profile.id), + }); + await beginPairingIntent(intent); + try { + if (value.managedService) { + await managedServices.save(profile, value.managedService); + } + if (previousTarget) { + const rebound = await catalog.rebindIfCurrent( + previousTarget, + profile, + value.credential, + ); + if (!rebound.rebound) { + throw new Error("Runtime Host profile changed before it could be updated"); + } + } else { + await catalog.create(profile, value.credential); + } + await finishPairingIntent(intent); + return { profileId: profile.id }; + } catch (failure) { + if (failure instanceof RuntimeHostPairingFinalizationInterruptedError) throw failure; + await rollbackPairingIntent(intent, failure); + throw failure; + } + }); + }; + return { getSnapshot: () => mutate(snapshot), addAndEnable(value) { @@ -676,49 +730,29 @@ export function createDesktopRuntimeHostProfileService(input: { : { kind: "connected", snapshot: await snapshot() }; }); }, - addAndEnableVerified(value) { - requireSaveInput(value); - return mutateProfiles(async () => { - const currentDocument = await catalog.read(); - const existing = currentDocument.profiles.find((profile) => - profile.rootId === value.profile.rootId && - sameRemoteRuntimeHostProfileTarget(profile, value.profile), - ); - const previousTarget = existing ? await catalog.resolve(existing.id) : undefined; - const profile = existing ? { ...value.profile, id: existing.id } : value.profile; - const target = { profile, credential: value.credential } as const; - const intent = createDesktopRuntimeHostPairingIntent({ - target, - ...(previousTarget ? { previous: previousTarget } : {}), - wasEnabled: - previousTarget !== undefined && - preferences.enabledRemoteProfileIds.includes(previousTarget.profile.id), + addAndEnableVerified, + async importConnectionCode(code) { + let decoded; + try { + decoded = decodeRuntimeHostOwnerConnectionCode(code); + } catch { + return { kind: 'error', reason: 'invalid_code' }; + } + try { + const result = await addAndEnableVerified({ + profile: { + id: `remote-${randomUUID()}`, + name: decoded.name, + kind: 'remote', + rootId: decoded.rootId, + transport: decoded.transport, + }, + credential: decoded.credential, }); - await beginPairingIntent(intent); - try { - if (value.managedService) { - await managedServices.save(profile, value.managedService); - } - if (previousTarget) { - const rebound = await catalog.rebindIfCurrent( - previousTarget, - profile, - value.credential, - ); - if (!rebound.rebound) { - throw new Error("Runtime Host profile changed before it could be updated"); - } - } else { - await catalog.create(profile, value.credential); - } - await finishPairingIntent(intent); - return { profileId: profile.id }; - } catch (failure) { - if (failure instanceof RuntimeHostPairingFinalizationInterruptedError) throw failure; - await rollbackPairingIntent(intent, failure); - throw failure; - } - }); + return { kind: 'connected', profileId: result.profileId }; + } catch (error) { + return { kind: 'error', reason: connectionCodeImportFailure(error) }; + } }, rotateManagedCredential(expected, credential) { return mutateProfiles(async () => { @@ -1200,6 +1234,7 @@ export function registerDesktopRuntimeHostProfileIpc( const channels = [ "runtime-host-profiles:getSnapshot", "runtime-host-profiles:add-and-enable", + "runtime-host-profiles:import-connection-code", "runtime-host-profiles:set-enabled", "runtime-host-profiles:set-default", "runtime-host-profiles:remove", @@ -1209,17 +1244,48 @@ export function registerDesktopRuntimeHostProfileIpc( ipcMain.handle(channels[1], (_event, value: DesktopRuntimeHostProfileAddInput) => service.addAndEnable(value), ); - ipcMain.handle(channels[2], (_event, profileId: string, enabled: boolean) => + ipcMain.handle(channels[2], (_event, code: string) => service.importConnectionCode(code)); + ipcMain.handle(channels[3], (_event, profileId: string, enabled: boolean) => service.setEnabled(profileId, enabled), ); - ipcMain.handle(channels[3], (_event, profileId: string) => service.setDefault(profileId)); - ipcMain.handle(channels[4], (_event, profileId: string) => service.remove(profileId)); - ipcMain.handle(channels[5], () => service.resolvePairingRecovery()); + ipcMain.handle(channels[4], (_event, profileId: string) => service.setDefault(profileId)); + ipcMain.handle(channels[5], (_event, profileId: string) => service.remove(profileId)); + ipcMain.handle(channels[6], () => service.resolvePairingRecovery()); return () => { for (const channel of channels) ipcMain.removeHandler(channel); }; } +function connectionCodeImportFailure( + error: unknown, +): Extract['reason'] { + if (error instanceof RuntimeHostRemoteCompatibilityError) return 'host_mismatch'; + if ( + error instanceof RuntimeHostOperationError && + error.operation === 'access.credential.finalize' && + error.code === 'invalid_request' + ) { + return 'code_unavailable'; + } + if (error instanceof RuntimeHostPermanentReconnectError) { + if (/rejected its access credential/u.test(error.message)) return 'code_unavailable'; + if (/unexpected State Root|incompatible Host composition/u.test(error.message)) { + return 'host_mismatch'; + } + } + if (error !== null && typeof error === 'object' && 'code' in error && typeof error.code === 'string') { + if (error.code === 'peer_identity_mismatch') return 'host_mismatch'; + if ( + error.code === 'direct_path_unavailable' || + error.code === 'coordination_unavailable' || + error.code === 'peer_connect_in_progress' + ) { + return 'host_unreachable'; + } + } + return 'unknown'; +} + function requireSaveInput(value: unknown): asserts value is { readonly profile: RemoteRuntimeHostProfile; readonly credential?: string; diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 6e7f05db87..8baa1c9aef 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -60,6 +60,7 @@ import type { DesktopRuntimeHostSshTerminalEvent, DesktopRuntimeHostSshTerminalSnapshot, } from '../preload/bridge-contract.js'; +import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; interface ActiveTerminal { readonly sessionId: string; @@ -497,7 +498,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { let failure: Error | undefined; let activeTerminal: ActiveTerminal | undefined; let receivedProgress = false; - const filter = createFramedOutputFilter({ + const filter = createRuntimeHostFramedOutputFilter({ prefix: options.prefix, pendingMaxBytes: options.pendingMaxBytes, decode: options.decode, @@ -592,7 +593,7 @@ export function createDesktopRuntimeHostSshTerminal(input: { let complete: RuntimeHostSetupCompleteFrame | undefined; let setupFailure: Error | undefined; let setupTerminal: ActiveTerminal | undefined; - const filter = createFramedOutputFilter({ + const filter = createRuntimeHostFramedOutputFilter({ prefix: RUNTIME_HOST_SETUP_FRAME_PREFIX, pendingMaxBytes: SETUP_FRAME_PENDING_MAX, decode: decodeRuntimeHostSetupFrame, @@ -832,83 +833,6 @@ function cancellableUntilComplete(signal: AbortSignal | undefined): { }; } -function createFramedOutputFilter(input: { - readonly prefix: string; - readonly pendingMaxBytes: number; - readonly decode: (line: string) => Frame | undefined; - readonly label: string; - readonly onFrame: (frame: Frame) => void; - readonly onError: (error: Error) => void; -}): { push(data: string): string; finish(): string } { - let pending = ''; - let discardReservedLine = false; - const drain = (finished: boolean): string => { - let visible = ''; - while (pending) { - if (discardReservedLine) { - const newline = pending.indexOf('\n'); - if (newline < 0) { - pending = ''; - break; - } - pending = pending.slice(newline + 1); - discardReservedLine = false; - continue; - } - const marker = pending.indexOf(input.prefix); - if (marker >= 0) { - visible += pending.slice(0, marker); - pending = pending.slice(marker); - const newline = pending.indexOf('\n'); - if (newline < 0) { - if (finished) { - input.onError(new Error(`${input.label} returned an incomplete result`)); - pending = ''; - } else if (pending.length > input.pendingMaxBytes) { - input.onError(new Error(`${input.label} returned an oversized result`)); - pending = ''; - discardReservedLine = true; - } - break; - } - const line = pending.slice(0, newline + 1); - pending = pending.slice(newline + 1); - const frame = input.decode(line); - if (frame) input.onFrame(frame); - else input.onError(new Error(`${input.label} returned an invalid result`)); - continue; - } - if (finished) { - visible += pending; - pending = ''; - break; - } - const retained = markerSuffixLength(pending, input.prefix); - visible += pending.slice(0, pending.length - retained); - pending = pending.slice(pending.length - retained); - break; - } - return visible; - }; - return { - push(data) { - pending += data; - return drain(false); - }, - finish() { - return drain(true); - }, - }; -} - -function markerSuffixLength(value: string, prefix: string): number { - const limit = Math.min(value.length, prefix.length - 1); - for (let length = limit; length > 0; length -= 1) { - if (prefix.startsWith(value.slice(-length))) return length; - } - return 0; -} - interface PreparedSetupPackage { readonly specifier: string; readonly removeAfterSetup?: string; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 94129cd1f7..c28197801a 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -381,6 +381,32 @@ export interface DesktopRuntimeHostProfileChangedEvent { readonly removed?: boolean; } +export type DesktopLocalRuntimeHostRemoteAccessSnapshot = + | { readonly state: 'unsupported'; readonly message: string } + | { readonly state: 'off'; readonly managedService?: true; readonly sharedAccess?: true } + | { readonly state: 'on'; readonly sharedAccess?: true } + | { readonly state: 'unavailable'; readonly message: string; readonly sharedAccess?: true }; + +export type DesktopRuntimeHostConnectionCodeImportResult = + | { readonly kind: 'connected'; readonly profileId: string } + | { + readonly kind: 'error'; + readonly reason: + | 'invalid_code' + | 'code_unavailable' + | 'host_unreachable' + | 'host_mismatch' + | 'unknown'; + }; + +export type DesktopLocalRuntimeHostRemoteAccessEnableResult = + | { readonly kind: 'active_tasks' } + | { + readonly kind: 'enabled'; + readonly connectionCode: string; + readonly snapshot: Extract; + }; + export type DesktopRuntimeHostSshTerminalEvent = | { readonly kind: 'opened'; readonly revision: number; readonly sessionId: string } | { readonly kind: 'data'; readonly revision: number; readonly sessionId: string; readonly data: string } @@ -612,6 +638,7 @@ export interface MakaBridge { addAndEnable( input: DesktopRuntimeHostProfileAddInput, ): Promise; + importConnectionCode(code: string): Promise; remove(profileId: string): Promise; setEnabled(profileId: string, enabled: boolean): Promise; setDefault(profileId: string): Promise; @@ -621,6 +648,20 @@ export interface MakaBridge { ): () => void; }; + localRuntimeHostRemoteAccess: { + getSnapshot(): Promise; + enable(input: { + readonly allowInterruptActiveTasks: boolean; + readonly coordinationRelays: readonly string[]; + }): Promise; + createConnectionCode(): Promise; + revokeSharedAccess(): Promise; + disable(): Promise; + uninstall(input: { + readonly allowInterruptActiveTasks: boolean; + }): Promise<{ readonly kind: 'active_tasks' | 'uninstalled' }>; + }; + runtimeHostSshTerminal: { getSnapshot(): Promise; write(sessionId: string, data: string): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 217bcd3281..18ad2260e0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1177,6 +1177,9 @@ const makaBridge = { addAndEnable(input: DesktopRuntimeHostProfileAddInput) { return ipcRenderer.invoke('runtime-host-profiles:add-and-enable', input); }, + importConnectionCode(code: string) { + return ipcRenderer.invoke('runtime-host-profiles:import-connection-code', code); + }, remove(profileId: string) { return ipcRenderer.invoke('runtime-host-profiles:remove', profileId); }, @@ -1200,6 +1203,31 @@ const makaBridge = { return () => ipcRenderer.off('runtime-host-profiles:changed', listener); }, }, + localRuntimeHostRemoteAccess: { + getSnapshot() { + return ipcRenderer.invoke('local-runtime-host-remote-access:get-snapshot'); + }, + enable(input: { + readonly allowInterruptActiveTasks: boolean; + readonly coordinationRelays: readonly string[]; + }) { + return ipcRenderer.invoke('local-runtime-host-remote-access:enable', input); + }, + createConnectionCode() { + return ipcRenderer.invoke( + 'local-runtime-host-remote-access:create-connection-code', + ); + }, + revokeSharedAccess() { + return ipcRenderer.invoke('local-runtime-host-remote-access:revoke-shared-access'); + }, + disable() { + return ipcRenderer.invoke('local-runtime-host-remote-access:disable'); + }, + uninstall(input: { readonly allowInterruptActiveTasks: boolean }) { + return ipcRenderer.invoke('local-runtime-host-remote-access:uninstall', input); + }, + }, runtimeHostSshTerminal: { getSnapshot(): Promise { return ipcRenderer.invoke('runtime-host-ssh-terminal:getSnapshot'); diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 64b22fc9c5..ee4e6de8d7 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -28,7 +28,44 @@ export type SettingsProjectsCopy = { remoteTitle: string; remoteDescription: string; addComputer: string; + useConnectionCode: string; configureManually: string; + thisComputerRemoteAccess: string; + thisComputerRemoteAccessHelp: string; + remoteAccessOn: string; + remoteAccessOff: string; + enableRemoteAccess: string; + disableRemoteAccess: string; + disableRemoteAccessConfirm: string; + disableRemoteAccessDescription: string; + revokeSharedAccess: string; + revokeSharedAccessConfirm: string; + revokeSharedAccessDescription: string; + revokeSharedAccessDone: string; + uninstallLocalService: string; + uninstallLocalServiceConfirm: string; + uninstallLocalServiceDescription: string; + uninstallLocalServiceDone: string; + createConnectionCode: string; + connectionCodeTitle: string; + connectionCodeDescription: string; + importConnectionCodeTitle: string; + importConnectionCodeDescription: string; + connectionCode: string; + copyConnectionCode: string; + connectionCodeCopied: string; + connectionCodeInvalid: string; + connectionCodeUnavailable: string; + connectionCodeHostUnreachable: string; + connectionCodeHostMismatch: string; + connectionCodeUnknownError: string; + connectWithCode: string; + remoteAccessActiveTasks: string; + remoteAccessActiveTasksDescription: string; + uninstallActiveTasksDescription: string; + interruptAndEnable: string; + interruptAndUninstall: string; + remoteAccessFailed: string; setupTitle: string; setupDescription: string; setupName: string; @@ -251,7 +288,44 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { remoteTitle: '远程 Host', remoteDescription: '通过 SSH 自动设置一台电脑,或手动连接已有 Runtime Host。', addComputer: '添加电脑', + useConnectionCode: '使用连接码', configureManually: '手动配置', + thisComputerRemoteAccess: '远程访问', + thisComputerRemoteAccessHelp: '让其他 Maka Desktop 通过实验性 Direct peer 连接此 Host', + remoteAccessOn: '已开启', + remoteAccessOff: '未开启', + enableRemoteAccess: '开启', + disableRemoteAccess: '关闭连接', + disableRemoteAccessConfirm: '关闭远程连接?', + disableRemoteAccessDescription: '这只会停止 Direct peer 连接;已授予的共享访问仍会保留。', + revokeSharedAccess: '撤销共享访问', + revokeSharedAccessConfirm: '撤销共享访问?', + revokeSharedAccessDescription: '已连接的 Desktop 将断开,尚未使用的连接码也会失效。', + revokeSharedAccessDone: '共享访问已撤销', + uninstallLocalService: '移除后台服务', + uninstallLocalServiceConfirm: '移除 Runtime Host 后台服务?', + uninstallLocalServiceDescription: '数据和已授予的共享访问会保留;Local Host 将恢复为仅在 Maka Desktop 运行时启动。', + uninstallLocalServiceDone: '后台服务已移除', + createConnectionCode: '新建连接码', + connectionCodeTitle: '连接这台电脑', + connectionCodeDescription: '连接码将在 15 分钟后过期且只能使用一次。对方将获得 Owner 权限;Direct peer 无后备连接。', + importConnectionCodeTitle: '使用连接码', + importConnectionCodeDescription: '连接后将获得对方 Host 的 Owner 权限。Direct peer 无后备连接。', + connectionCode: '连接码', + copyConnectionCode: '复制连接码', + connectionCodeCopied: '连接码已复制', + connectionCodeInvalid: '连接码格式无效。', + connectionCodeUnavailable: '连接码已过期或已被使用。请在另一台电脑上新建连接码。', + connectionCodeHostUnreachable: '无法建立 Direct peer 连接。请确认两台电脑在线且网络允许 UDP。', + connectionCodeHostMismatch: '连接码指向的 Host 与实际连接的 Host 不匹配或版本不兼容。', + connectionCodeUnknownError: '连接结果未知。请先检查远程 Host 列表,再决定是否重试。', + connectWithCode: '连接', + remoteAccessActiveTasks: '这台电脑仍有正在运行的任务', + remoteAccessActiveTasksDescription: '开启远程访问需要把 Local Host 交给系统服务。是否中断当前任务并继续?', + uninstallActiveTasksDescription: '移除后台服务会停止当前任务。是否中断这些任务并继续?', + interruptAndEnable: '中断任务并开启', + interruptAndUninstall: '中断任务并移除', + remoteAccessFailed: '远程访问操作失败', setupTitle: '添加远程电脑', setupDescription: '通过 SSH 安装并连接 Runtime Host', setupName: '显示名称(可选)', @@ -495,7 +569,44 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { remoteDescription: 'Set up a computer over SSH, or connect an existing Runtime Host manually.', addComputer: 'Add computer', + useConnectionCode: 'Use connection code', configureManually: 'Configure manually', + thisComputerRemoteAccess: 'Remote access', + thisComputerRemoteAccessHelp: 'Let another Maka Desktop reach this Host through experimental Direct peer', + remoteAccessOn: 'On', + remoteAccessOff: 'Off', + enableRemoteAccess: 'Enable', + disableRemoteAccess: 'Turn off connectivity', + disableRemoteAccessConfirm: 'Turn off remote connectivity?', + disableRemoteAccessDescription: 'This only stops Direct peer connectivity. Granted shared access is retained.', + revokeSharedAccess: 'Revoke shared access', + revokeSharedAccessConfirm: 'Revoke shared access?', + revokeSharedAccessDescription: 'The connected Desktop will be disconnected, and unused connection codes will stop working.', + revokeSharedAccessDone: 'Shared access revoked', + uninstallLocalService: 'Remove background service', + uninstallLocalServiceConfirm: 'Remove the Runtime Host background service?', + uninstallLocalServiceDescription: 'Data and granted shared access are retained. The Local Host will return to running only while Maka Desktop is open.', + uninstallLocalServiceDone: 'Background service removed', + createConnectionCode: 'New connection code', + connectionCodeTitle: 'Connect to this computer', + connectionCodeDescription: 'Expires in 15 minutes and can be used once. The other Desktop receives Owner access. Direct peer has no fallback.', + importConnectionCodeTitle: 'Use a connection code', + importConnectionCodeDescription: 'Connecting grants this Desktop Owner access to the other Host. Direct peer has no fallback.', + connectionCode: 'Connection code', + copyConnectionCode: 'Copy connection code', + connectionCodeCopied: 'Connection code copied', + connectionCodeInvalid: 'The connection code is invalid.', + connectionCodeUnavailable: 'The connection code expired or was already used. Create a new code on the other computer.', + connectionCodeHostUnreachable: 'A Direct peer connection could not be established. Check that both computers are online and UDP is allowed.', + connectionCodeHostMismatch: 'The code does not match the connected Host, or the Host version is incompatible.', + connectionCodeUnknownError: 'The connection outcome is unknown. Check the remote Host list before retrying.', + connectWithCode: 'Connect', + remoteAccessActiveTasks: 'This computer still has running tasks', + remoteAccessActiveTasksDescription: 'Enabling remote access hands the Local Host to a system service. Interrupt the current tasks and continue?', + uninstallActiveTasksDescription: 'Removing the background service stops the current tasks. Interrupt them and continue?', + interruptAndEnable: 'Interrupt and enable', + interruptAndUninstall: 'Interrupt and remove', + remoteAccessFailed: 'Remote access failed', setupTitle: 'Add remote computer', setupDescription: 'Install and connect Runtime Host over SSH', setupName: 'Display name (optional)', diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx index 2afcaa2c89..4b94af4c79 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -203,7 +203,9 @@ export function ProjectsSettingsPage(props: { if (!host) { return ( - + {props.runtimeHostStatus !== 'loading' ? ( - + {props.runtimeHostStatus === 'error' ? ( void; +} & ( + | { readonly mode: 'share'; readonly connectionCode: string } + | { readonly mode: 'import'; readonly onImported: (profileId: string) => void } +); + +export function RuntimeHostConnectionCodeDialog(props: RuntimeHostConnectionCodeDialogProps) { + const locale = useUiLocale(); + const copy = getSettingsProjectsCopy(locale).runtimeHost; + const toast = useToast(); + const [draft, setDraft] = useState(''); + const [working, setWorking] = useState(false); + + const value = props.mode === 'share' ? props.connectionCode : draft; + + async function copyCode(): Promise { + try { + await navigator.clipboard.writeText(value); + toast.success(copy.connectionCodeCopied); + } catch (error) { + toast.error(copy.remoteAccessFailed, settingsActionErrorMessage(error, locale)); + } + } + + async function connect(): Promise { + if (props.mode !== 'import') return; + setWorking(true); + try { + const result = await window.maka.runtimeHostProfiles.importConnectionCode(draft.trim()); + if (result.kind === 'error') { + toast.error(copy.remoteAccessFailed, connectionCodeError(copy, result.reason)); + return; + } + props.onImported(result.profileId); + props.onClose(); + } catch (error) { + toast.error(copy.remoteAccessFailed, settingsActionErrorMessage(error, locale)); + } finally { + setWorking(false); + } + } + + return ( + { + if (!open && !working) props.onClose(); + }} + purpose="form" + width={520} + > + { + if (!open && !working) props.onClose(); + }} + /> + )} + content={( + + +