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..3efc24053c 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 }); 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..62e80fe1ed --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -0,0 +1,49 @@ +/* + * 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'], + }), + { + 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', + '--enable-direct-peer', + '--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..28d064513c --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -0,0 +1,113 @@ +/* + * 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 } 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 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'); + 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 }) { + assert.equal(retired, true); + assert.equal(input.rootPath, rootPath); + return { + serviceId: 'b'.repeat(64), + 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, + 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')) + .rootPath, + rootPath, + ); +}); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 0c05fa693a..3bcfad4830 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -170,6 +170,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"; @@ -386,6 +388,16 @@ const runtimeHostSetupPackage = createRuntimeHostSetupPackageResolver({ appPath: app.getAppPath(), environment: process.env, }); +const localRuntimeHostOperator = createDesktopRuntimeHostLocalOperator(); +const localRuntimeHostRemoteAccess = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain, + clientDataRoot: userDataDir, + rootPath: startupLocalStorageRoot.canonicalPath, + directPeerAvailable: runtimeHostDirectPeerAvailable, + manager: () => runtimeHostManager, + resolveSetupPackage: runtimeHostSetupPackage.resolve, + operator: localRuntimeHostOperator, +}); const native = assembleDesktopNativeCapabilities({ isComputerUseRealModelE2e, locale: desktopLocale, @@ -1602,6 +1614,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..4b29c443de 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; @@ -517,6 +518,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 +860,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..04aca79160 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -0,0 +1,471 @@ +/* + * 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, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { redactSecrets } from '@maka/core/redaction'; +import { + DEFAULT_PROCESS_TERMINATION_GRACE_MS, + terminateChildProcessTree, +} from '@maka/runtime/process-tree-terminator'; +import { + decodeRuntimeHostPeerManagementFrame, + decodeRuntimeHostServiceManagementFrame, + decodeRuntimeHostSetupFrame, + RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_SETUP_FRAME_PREFIX, + 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 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[]; +}): 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', + '--enable-direct-peer', + ...(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 signal?: AbortSignal; + }): Promise; + runService(input: { + readonly operatorPath: string; + readonly action: 'status' | 'retire' | 'uninstall'; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; + }): Promise; + close(): Promise; +} { + const active = new Set(); + let closed = false; + 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 { + return await runSetupProcess({ + command, + cwd: workingDirectory, + environment: input.environment ?? process.env, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: setup.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'] + : []), + ...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: command.signal, + active, + }); + }, + 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'] : []), + ...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: command.signal, + active, + }); + }, + async close() { + if (closed) return; + closed = true; + await Promise.allSettled([...active].map((child) => stopProcess(child, terminate))); + }, + }; +} + +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 { + input.signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const child = input.spawnProcess(input.command.executable, [...input.command.args], { + detached: process.platform !== 'win32', + env: input.environment, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + input.active.add(child); + let result: Frame | undefined; + let failure: 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) => { + if (result) failure = new Error(`${input.label} returned multiple results`); + else result = frame; + }, + onError: (error) => { + failure = error; + }, + }); + const cleanup = () => { + clearTimeout(timeout); + input.signal?.removeEventListener('abort', onAbort); + input.active.delete(child); + }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(result!); + }; + const stop = (error: Error) => { + void stopProcess(child, input.terminate).then( + () => finish(error), + (stopError) => finish(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) => finish(error)); + child.once('close', (code, signal) => { + filter.finish(); + if (failure) return finish(failure); + if (result) return finish(); + const status = code === null ? signal ?? 'an unknown status' : `code ${code}`; + const detail = redactSecrets(stderr.trim()).slice(-2_000); + finish( + new Error( + detail + ? `${input.label} exited with ${status}: ${detail}` + : `${input.label} exited with ${status}`, + ), + ); + }); + if (input.signal?.aborted) onAbort(); + }); +} + +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 { + return new Promise((resolve, reject) => { + const child = input.spawnProcess(input.command.executable, [...input.command.args], { + cwd: input.cwd, + detached: process.platform !== 'win32', + env: input.environment, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + input.active.add(child); + let complete: RuntimeHostSetupCompleteFrame | undefined; + let failure: Error | undefined; + let stderr = ''; + let settled = false; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: RUNTIME_HOST_SETUP_FRAME_PREFIX, + pendingMaxBytes: SETUP_FRAME_PENDING_MAX, + 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; + }, + onError: (error) => { + failure = error; + }, + }); + const cleanup = () => { + clearTimeout(timeout); + input.signal?.removeEventListener('abort', onAbort); + input.active.delete(child); + }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(complete!); + }; + const stop = (error: Error) => { + void stopProcess(child, input.terminate).then(() => finish(error), finish); + }; + const onAbort = () => stop(abortError(input.signal)); + const timeout = setTimeout( + () => stop(new Error('Local Maka setup 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) => finish(error)); + child.once('close', (code, signal) => { + filter.finish(); + if (failure) return finish(failure); + if (complete && code === 0) return finish(); + const status = code === null ? signal ?? 'an unknown status' : `code ${code}`; + const detail = redactSecrets(stderr.trim()).slice(-2_000); + finish( + new Error( + complete + ? `Local Maka setup exited with ${status}` + : detail + ? `Local Maka setup exited with ${status}: ${detail}` + : `Local Maka setup ended without a completion 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'); +} 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..cc5036c4b5 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -0,0 +1,477 @@ +/* + * 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 { 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 RECEIPT_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; + +interface LocalServiceReceipt extends DesktopRuntimeHostLocalServiceTarget { + readonly schemaVersion: 1; + readonly operatorPath: string; +} + +interface LocalPeerDescriptor { + readonly peerId: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; +} + +type DesktopRuntimeHostLocalOperator = ReturnType< + typeof createDesktopRuntimeHostLocalOperator +>; + +export function createDesktopLocalRuntimeHostRemoteAccess(input: { + readonly ipcMain: Pick; + readonly clientDataRoot: string; + readonly rootPath: string; + readonly directPeerAvailable: boolean; + readonly manager: () => RuntimeHostDesktopManager | undefined; + readonly resolveSetupPackage: ( + signal?: AbortSignal, + ) => DesktopRuntimeHostSetupPackage | Promise; + readonly operator: DesktopRuntimeHostLocalOperator; +}): { close(): Promise } { + const receiptPath = join(input.clientDataRoot, RECEIPT_FILE); + 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 receipt = await readReceipt(receiptPath, input.rootPath); + if (!receipt) return { state: 'off' }; + const peer = await readPeer(input.operator, receipt); + return peer ? onSnapshot(peer) : { state: 'off', managedService: 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); + const existing = await readReceipt(receiptPath, input.rootPath); + if (existing) { + const currentPeer = await readPeer(input.operator, existing); + if ( + currentPeer && + sameStrings(currentPeer.coordinationRelays, request.coordinationRelays) + ) { + return enabledResult( + currentPeer, + await issueConnectionCode( + input.rootPath, + existing.rootId, + currentPeer, + localClient(input.manager), + ), + ); + } + const manager = requireManager(input.manager); + const previousHostEpoch = manager.current('local')?.candidate?.client.hostEpoch; + const response = await manager.runManagedLocalHostChange(() => + input.operator.runPeer({ + operatorPath: existing.operatorPath, + action: 'enable', + target: existing, + coordinationRelays: request.coordinationRelays, + }), + ); + if (response.kind === 'error') { + if (response.error.code === 'active_tasks') return { kind: 'active_tasks' }; + throw new Error(response.error.message); + } + const peer = requireEnabledPeer(response.status); + await manager.waitUntilReady('local', previousHostEpoch); + return enabledResult( + peer, + await issueConnectionCode(input.rootPath, existing.rootId, peer, localClient(input.manager)), + ); + } + + const setupPackage = await input.resolveSetupPackage(); + const manager = requireManager(input.manager); + const retirement = await manager.retireOwnedLocalHost( + request.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (retirement.kind === 'active_tasks') return { kind: 'active_tasks' }; + if (retirement.kind === 'not_owned') { + throw new Error('The Local Runtime Host is already managed outside this Desktop'); + } + try { + const complete = await input.operator.runSetup( + { + setupPackage, + clientDataRoot: input.clientDataRoot, + rootPath: input.rootPath, + principalId: `desktop-owner:${randomUUID()}`, + coordinationRelays: request.coordinationRelays, + }, + () => undefined, + ); + if (complete.rootPath !== input.rootPath || !complete.directPeer) { + throw new Error('Local Runtime Host setup returned an unrelated service'); + } + const peer = requireEnabledPeer({ state: 'enabled', ...complete.directPeer }); + const receipt = requireReceipt({ + schemaVersion: 1, + serviceId: complete.serviceId, + operatorPath: complete.operatorPath, + rootPath: complete.rootPath, + rootId: complete.rootId, + }, input.rootPath); + try { + await writeReceipt(receiptPath, receipt); + } catch (error) { + await input.operator.runService({ + operatorPath: receipt.operatorPath, + action: 'uninstall', + target: receipt, + }).catch((rollbackError) => { + throw new AggregateError([error, rollbackError], 'Local Runtime Host setup rollback failed'); + }); + throw error; + } + return enabledResult( + peer, + encodeRuntimeHostOwnerConnectionCode({ + name: hostName(), + rootId: receipt.rootId, + transport: { kind: 'libp2p-direct', ...peer }, + credential: complete.credential, + }), + ); + } finally { + retirement.resume(); + } + }); + + const createConnectionCode = (): Promise => + serialize(async () => { + const receipt = await requireStoredReceipt(receiptPath, input.rootPath); + const peer = await readPeer(input.operator, receipt); + if (!peer) throw new Error('Remote access is not enabled on this computer'); + return issueConnectionCode(input.rootPath, receipt.rootId, peer, localClient(input.manager)); + }); + + const disable = (): Promise => + serialize(async () => { + const receipt = await requireStoredReceipt(receiptPath, input.rootPath); + const response = await requireManager(input.manager).runManagedLocalHostChange(() => + input.operator.runPeer({ + operatorPath: receipt.operatorPath, + action: 'disable', + target: receipt, + }), + ); + if (response.kind === 'error') throw new Error(response.error.message); + return { state: 'off', managedService: true }; + }); + + 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 receipt = await requireStoredReceipt(receiptPath, input.rootPath); + return requireManager(input.manager).runManagedLocalHostChange(async () => { + const retired = await input.operator.runService({ + operatorPath: receipt.operatorPath, + action: 'retire', + target: receipt, + allowInterruptActiveTasks, + }); + if (retired.kind === 'error') throw new Error(retired.error.message); + if (retired.action !== 'retire') { + throw new Error('Local Runtime Host returned an unrelated service result'); + } + if (retired.retirement.kind === 'active_tasks') return { kind: 'active_tasks' }; + const removed = await input.operator.runService({ + operatorPath: receipt.operatorPath, + action: 'uninstall', + target: receipt, + }); + if (removed.kind === 'error') throw new Error(removed.error.message); + if (removed.action !== 'uninstall' || removed.service.state !== 'not_installed') { + throw new Error('Local Runtime Host service was not cleanly uninstalled'); + } + await rm(receiptPath, { force: true }); + return { kind: 'uninstalled' }; + }); + }); + + 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: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], disable); + input.ipcMain.handle(channels[4], (_event, value: unknown) => uninstall(value)); + + return { + async close() { + for (const channel of channels) input.ipcMain.removeHandler(channel); + 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: LocalServiceReceipt, +): 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'); + } + return { + peerId: value.peerId, + routeHints: requireAddresses(value.routeHints), + coordinationRelays: requireAddresses(value.coordinationRelays), + }; +} + +function onSnapshot(peer: LocalPeerDescriptor): Extract< + DesktopLocalRuntimeHostRemoteAccessSnapshot, + { state: 'on' } +> { + return { state: 'on', ...peer }; +} + +function enabledResult( + peer: LocalPeerDescriptor, + connectionCode: string, +): Extract { + return { kind: 'enabled', connectionCode, snapshot: onSnapshot(peer) }; +} + +async function issueConnectionCode( + rootPath: string, + rootId: string, + peer: LocalPeerDescriptor, + client: DesktopRuntimeHostClient, +): Promise { + const prepared = await client.request('access.credential.prepare', { + principalKind: 'remote_owner', + principalId: `desktop-owner:${randomUUID()}`, + operationGrants: REMOTE_OWNER_OPERATION_GRANTS, + canPublishClientCapabilities: true, + canUseHostPaths: false, + }); + const credential = await consumeAccessCredentialDelivery( + rootPath, + prepared.deliveryId, + prepared.credentialId, + ); + return encodeRuntimeHostOwnerConnectionCode({ + name: hostName(), + rootId, + transport: { kind: 'libp2p-direct', ...peer }, + credential, + }); +} + +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'; +} + +async function requireStoredReceipt(path: string, rootPath: string): Promise { + const receipt = await readReceipt(path, rootPath); + if (!receipt) throw new Error('Remote access has not been set up on this computer'); + return receipt; +} + +async function readReceipt(path: string, rootPath: string): Promise { + try { + return requireReceipt(JSON.parse(await readFile(path, 'utf8')) as unknown, rootPath); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } +} + +function requireReceipt(value: unknown, rootPath: string): LocalServiceReceipt { + 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, + }; +} + +async function writeReceipt(path: string, receipt: LocalServiceReceipt): 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(receipt, null, 2)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporaryPath, path); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +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..5d2d8b8a5e 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -23,6 +23,7 @@ import { dirname, join } from "node:path"; import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, + decodeRuntimeHostOwnerConnectionCode, LOCAL_RUNTIME_HOST_PROFILE, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, RuntimeHostOperationError, @@ -93,6 +94,7 @@ export interface DesktopRuntimeHostProfileService { readonly managedService?: DesktopRuntimeHostManagedService; }, ): Promise<{ readonly profileId: string }>; + importConnectionCode(code: string): Promise<{ readonly profileId: string }>; resolveManagedService( profileId: string, ): Promise; @@ -652,6 +654,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,48 +728,18 @@ 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), - }); - 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; - } + addAndEnableVerified, + importConnectionCode(code) { + const decoded = decodeRuntimeHostOwnerConnectionCode(code); + return addAndEnableVerified({ + profile: { + id: `remote-${randomUUID()}`, + name: decoded.name, + kind: 'remote', + rootId: decoded.rootId, + transport: decoded.transport, + }, + credential: decoded.credential, }); }, rotateManagedCredential(expected, credential) { @@ -1200,6 +1222,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,12 +1232,13 @@ 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); }; 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 c8bf2aa372..0b977f837b 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -381,6 +381,25 @@ export interface DesktopRuntimeHostProfileChangedEvent { readonly removed?: boolean; } +export type DesktopLocalRuntimeHostRemoteAccessSnapshot = + | { readonly state: 'unsupported'; readonly message: string } + | { readonly state: 'off'; readonly managedService?: true } + | { + readonly state: 'on'; + readonly peerId: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; + } + | { readonly state: 'unavailable'; readonly message: string }; + +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 +631,7 @@ export interface MakaBridge { addAndEnable( input: DesktopRuntimeHostProfileAddInput, ): Promise; + importConnectionCode(code: string): Promise<{ readonly profileId: string }>; remove(profileId: string): Promise; setEnabled(profileId: string, enabled: boolean): Promise; setDefault(profileId: string): Promise; @@ -621,6 +641,19 @@ export interface MakaBridge { ): () => void; }; + localRuntimeHostRemoteAccess: { + getSnapshot(): Promise; + enable(input: { + readonly allowInterruptActiveTasks: boolean; + readonly coordinationRelays: readonly string[]; + }): Promise; + createConnectionCode(): 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 22a583b09c..1e7937fef9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1176,6 +1176,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); }, @@ -1199,6 +1202,28 @@ 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', + ); + }, + 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..0756abac8a 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -28,7 +28,33 @@ 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; + uninstallLocalService: string; + uninstallLocalServiceConfirm: string; + uninstallLocalServiceDescription: string; + uninstallLocalServiceDone: string; + createConnectionCode: string; + connectionCodeTitle: string; + connectionCodeDescription: string; + importConnectionCodeTitle: string; + importConnectionCodeDescription: string; + connectionCode: string; + copyConnectionCode: string; + connectionCodeCopied: string; + connectWithCode: string; + remoteAccessActiveTasks: string; + remoteAccessActiveTasksDescription: string; + uninstallActiveTasksDescription: string; + interruptAndEnable: string; + interruptAndUninstall: string; + remoteAccessFailed: string; setupTitle: string; setupDescription: string; setupName: string; @@ -251,7 +277,33 @@ 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: '关闭', + uninstallLocalService: '移除后台服务', + uninstallLocalServiceConfirm: '移除 Runtime Host 后台服务?', + uninstallLocalServiceDescription: '数据会保留,Local Host 将恢复为仅在 Maka Desktop 运行时启动。', + uninstallLocalServiceDone: '后台服务已移除', + createConnectionCode: '新建连接码', + connectionCodeTitle: '连接这台电脑', + connectionCodeDescription: '在另一台 Maka Desktop 中粘贴此一次性连接码', + importConnectionCodeTitle: '使用连接码', + importConnectionCodeDescription: '粘贴另一台 Maka Desktop 生成的连接码', + connectionCode: '连接码', + copyConnectionCode: '复制连接码', + connectionCodeCopied: '连接码已复制', + connectWithCode: '连接', + remoteAccessActiveTasks: '这台电脑仍有正在运行的任务', + remoteAccessActiveTasksDescription: '开启远程访问需要把 Local Host 交给系统服务。是否中断当前任务并继续?', + uninstallActiveTasksDescription: '移除后台服务会停止当前任务。是否中断这些任务并继续?', + interruptAndEnable: '中断任务并开启', + interruptAndUninstall: '中断任务并移除', + remoteAccessFailed: '远程访问操作失败', setupTitle: '添加远程电脑', setupDescription: '通过 SSH 安装并连接 Runtime Host', setupName: '显示名称(可选)', @@ -495,7 +547,33 @@ 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', + uninstallLocalService: 'Remove background service', + uninstallLocalServiceConfirm: 'Remove the Runtime Host background service?', + uninstallLocalServiceDescription: 'Data is 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: 'Paste this one-time connection code into another Maka Desktop', + importConnectionCodeTitle: 'Use a connection code', + importConnectionCodeDescription: 'Paste a connection code created by another Maka Desktop', + connectionCode: 'Connection code', + copyConnectionCode: 'Copy connection code', + connectionCodeCopied: 'Connection code copied', + 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..df0dfeca7e 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -77,6 +77,7 @@ export function ProjectsSettingsPage(props: { ): Promise; onRetryRuntimeHost(): Promise; onRemoteHostAdded(profileId: string): void; + onLocalConnectionCode(connectionCode: string): void; }) { const host = useOptionalRuntimeHostSettingsTarget(); const locale = useUiLocale(); @@ -203,7 +204,10 @@ export function ProjectsSettingsPage(props: { if (!host) { return ( - + {props.runtimeHostStatus !== 'loading' ? ( - + {props.runtimeHostStatus === 'error' ? ( void; + readonly onImported?: (profileId: string) => void; +}) { + const locale = useUiLocale(); + const copy = getSettingsProjectsCopy(locale).runtimeHost; + const toast = useToast(); + const [draft, setDraft] = useState(''); + const [working, setWorking] = useState(false); + + useEffect(() => { + if (!props.isOpen) setDraft(''); + }, [props.isOpen]); + + 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 { + setWorking(true); + try { + const result = await window.maka.runtimeHostProfiles.importConnectionCode(draft.trim()); + 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={( + + +