diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index cc0ab39bc1d..b98a86dec22 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -41,6 +41,7 @@ export const CLI_MAIN_ENTRY_NAMES = [ const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', + 'session-scanner-service-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', 'orcad/orcad-local-serve-selection-entry', diff --git a/config/patches/ws@8.22.0.patch b/config/patches/ws@8.22.0.patch new file mode 100644 index 00000000000..2f211591048 --- /dev/null +++ b/config/patches/ws@8.22.0.patch @@ -0,0 +1,19 @@ +diff --git a/lib/sender.js b/lib/sender.js +index 2c767e2c62a7fb50a987454f0c148ec5b4a0a523..48549a5c0fde4347a3fde83a32e56c2bbc81d0ce 100644 +--- a/lib/sender.js ++++ b/lib/sender.js +@@ -561,7 +561,13 @@ class Sender { + * @private + */ + sendFrame(list, cb) { +- if (list.length === 2) { ++ // ++ // Never write an empty payload on its own: `socket.write()` of a ++ // zero-length buffer fails with EFAULT under Electron on arm64 kernels with ++ // 39-bit virtual addresses, which kills the connection (stablyai/orca#23398). ++ // The header already encodes the zero length, so the wire bytes are unchanged. ++ // ++ if (list.length === 2 && list[1].length !== 0) { + this._socket.cork(); + this._socket.write(list[0]); + this._socket.write(list[1], cb); diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 96a46677626..5d6d556bb5e 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -7,7 +7,8 @@ import { buildOrcadLauncher, externalNativeAddons, ORCAD_EXTERNAL_MODULES, - ORCAD_CHILD_ENTRY_POINTS + ORCAD_CHILD_ENTRY_POINTS, + orcadChildOutputFilename } from './orcad-entry-build.mjs' import { createRequire } from 'node:module' import { @@ -25,12 +26,11 @@ import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' +import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, - ORCAD_FOREIGN_SQLITE_READER_ENTRY, - ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY, ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME, ORCAD_NODE_PTY_DIR, @@ -52,24 +52,13 @@ const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = process.env.ORCAD_OUT_DIR ? resolve(process.env.ORCAD_OUT_DIR) : join(ROOT, 'out', 'orcad') -// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher -// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath` -// looks for it in the app root. A deployment has no desktop out/main to fall back to. -const WATCHER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.watcher) -const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') -// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process, -// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every -// orcad restart would SIGKILL every running terminal. -const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon) -const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') -// Why beside orcad.js: the hook server's OpenCode binder and the OpenCode history scanner -// start this worker from the module dir, since orcad has no Electron resources tree. -const FOREIGN_SQLITE_READER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader) -const FOREIGN_SQLITE_READER_OUT_FILE = join(OUT_DIR, ORCAD_FOREIGN_SQLITE_READER_ENTRY) -// Why beside orcad.js: workspace port detection runs its probe commands on this worker thread, -// and `resolveWorkerEntryPath` looks for it next to the running bundle. -const PORT_SCAN_WORKER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.portScanCommandWorker) -const PORT_SCAN_WORKER_OUT_FILE = join(OUT_DIR, ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY) +// Why beside orcad.js: every forked child and worker resolves its entry in the app root, and a +// deployment has no desktop out/main to fall back to. A missing one only shows on a host as a +// feature that never starts (the daemon's absence makes every restart kill every terminal). +const childOutFile = (role) => + join(OUT_DIR, orcadChildOutputFilename(ORCAD_CHILD_ENTRY_POINTS[role])) +const WATCHER_OUT_FILE = childOutFile('watcher') +const DAEMON_OUT_FILE = childOutFile('daemon') const OUT_FILE = join(OUT_DIR, ORCAD_LAUNCHER_FILENAME) const SERVER_OUT_FILE = join(OUT_DIR, ORCAD_SERVER_ENTRY_FILENAME) const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET @@ -220,18 +209,11 @@ function buildForkedChild(entryPoint, outfile) { }) } -const childResults = await Promise.all([ - buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), - buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE), - buildForkedChild(FOREIGN_SQLITE_READER_ENTRY, FOREIGN_SQLITE_READER_OUT_FILE), - buildForkedChild(PORT_SCAN_WORKER_ENTRY, PORT_SCAN_WORKER_OUT_FILE), - ...['writer', 'backup'].map((role) => - buildForkedChild( - join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]), - join(OUT_DIR, `profile-state-${role}-worker-entry.js`) - ) +const childResults = await Promise.all( + Object.values(ORCAD_CHILD_ENTRY_POINTS).map((entryPoint) => + buildForkedChild(join(ROOT, entryPoint), join(OUT_DIR, orcadChildOutputFilename(entryPoint))) ) -]) +) const result = await buildOrcadEntry(SERVER_OUT_FILE) const launcherResult = await buildOrcadLauncher(OUT_FILE) @@ -355,24 +337,23 @@ if (graphErrors.length > 0) { } } -try { - await smokeProfileStateWorkers(OUT_DIR) - if (nodeRuntimePath) { - await smokeProfileStateWorkers(OUT_DIR, { runtimePath: nodeRuntimePath }) +/** @type {{ label: string, smoke: (outDir: string, options?: { runtimePath?: string }) => unknown }[]} */ +const workerSmokes = [ + { label: 'profile state worker', smoke: smokeProfileStateWorkers }, + { label: 'foreign SQLite reader worker', smoke: smokeForeignSqliteReaderWorker }, + { label: 'session scanner service', smoke: smokeSessionScannerService } +] +// Each shipped child is checked under the build's Node and, when set, the pinned runtime. +for (const { label, smoke } of workerSmokes) { + try { + await smoke(OUT_DIR) + if (nodeRuntimePath) { + await smoke(OUT_DIR, { runtimePath: nodeRuntimePath }) + } + } catch (error) { + console.error(`[build-orcad] ${label} check failed:`, error) + process.exitCode = 1 } -} catch (error) { - console.error('[build-orcad] profile state worker check failed:', error) - process.exitCode = 1 -} - -try { - smokeForeignSqliteReaderWorker(OUT_DIR) - if (nodeRuntimePath) { - smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath }) - } -} catch (error) { - console.error('[build-orcad] foreign SQLite reader worker check failed:', error) - process.exitCode = 1 } // Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs index 36121ad88d4..43f42aca655 100644 --- a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs @@ -57,9 +57,9 @@ describe('foreign SQLite reader build smoke', () => { it('runs in the orcad build under both runtimes', () => { const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') - expect(source).toContain('smokeForeignSqliteReaderWorker(OUT_DIR)') expect(source).toContain( - 'smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath })' + "{ label: 'foreign SQLite reader worker', smoke: smokeForeignSqliteReaderWorker }" ) + expect(source).toContain('await smoke(OUT_DIR, { runtimePath: nodeRuntimePath })') }) }) diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index a746ac4bb5b..1267ef9664a 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -1,7 +1,7 @@ import { build } from 'esbuild' import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts' const root = join(import.meta.dirname, '..', '..') @@ -14,7 +14,13 @@ export const ORCAD_CHILD_ENTRY_POINTS = { writer: 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts', backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts', foreignSqliteReader: 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts', - portScanCommandWorker: 'src/main/ports/port-scan-command-worker-entry.ts' + portScanCommandWorker: 'src/main/ports/port-scan-command-worker-entry.ts', + sessionScanner: 'src/main/ai-vault/session-scanner-service-entry.ts' +} + +/** Each child ships flat beside orcad.js under its source basename; the runtime resolvers look there. */ +export function orcadChildOutputFilename(entryPoint) { + return basename(entryPoint).replace(/\.ts$/, '.js') } export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] diff --git a/config/scripts/session-scanner-service-smoke.mjs b/config/scripts/session-scanner-service-smoke.mjs new file mode 100644 index 00000000000..d226e2f09b7 --- /dev/null +++ b/config/scripts/session-scanner-service-smoke.mjs @@ -0,0 +1,87 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { ORCAD_SESSION_SCANNER_SERVICE_ENTRY } from '../../src/shared/orcad-artifacts.ts' +import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from '../../src/main/ai-vault/session-scanner-service-protocol.ts' + +// Why fork from a probe: the child must run under the runtime orcad ships, and it forks with +// that runtime's execPath. The verdict is the exit code, never matched output. +const PROBE = ` +const { fork } = require('node:child_process') +const [entry, home, protocol] = process.argv.slice(2) +const fail = (code, message) => { + console.error(message) + process.exit(code) +} +const child = fork(entry, [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home }, + windowsHide: true +}) +let stderr = '' +child.stderr.on('data', (chunk) => { stderr += String(chunk) }) +setTimeout(() => { child.kill('SIGKILL'); fail(3, 'session scanner did not answer\\n' + stderr) }, 30000).unref() +child.on('error', (error) => fail(4, String(error && error.stack || error))) +child.on('exit', (code, signal) => fail(5, 'session scanner exited ' + code + ' ' + signal + '\\n' + stderr)) +child.on('message', (message) => { + if (message && message.type === 'ready') { + child.send({ type: 'request', id: 1, operation: 'scan', options: { limit: 5 } }) + return + } + if (!message || message.id !== 1) { + return + } + const sessions = message.type === 'result' ? message.value.result.sessions : [] + if (!sessions.some((session) => session.agent === 'claude' && session.sessionId === 'smoke-session')) { + fail(6, 'scan answered ' + JSON.stringify(message).slice(0, 2000)) + } + child.removeAllListeners('exit') + child.kill() + process.exit(0) +}) +child.send({ type: 'init', protocol: Number(protocol) }) +` + +/** + * Fork the built session scanner child and list a seeded Claude session through it. + * @param outDir - orcad output directory holding the entry. + * @param options.runtimePath - Node to run under; the build's own Node when omitted. + */ +export function smokeSessionScannerService(outDir, { runtimePath, timeoutMs = 60_000 } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-session-scanner-smoke-')) + try { + const home = join(directory, 'home') + const project = join(home, '.claude', 'projects', 'smoke-project') + mkdirSync(project, { recursive: true }) + writeFileSync( + join(project, 'smoke-session.jsonl'), + `${JSON.stringify({ + type: 'user', + sessionId: 'smoke-session', + timestamp: '2026-01-01T00:00:00.000Z', + cwd: '/smoke', + message: { role: 'user', content: 'smoke' } + })}\n` + ) + const probe = join(directory, 'probe.cjs') + writeFileSync(probe, PROBE) + const result = runProcessSync({ + program: runtimePath ?? process.execPath, + args: [ + probe, + resolve(outDir, ORCAD_SESSION_SCANNER_SERVICE_ENTRY), + home, + String(AI_VAULT_SERVICE_PROTOCOL_VERSION) + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs, + maxOutputBytes: 64 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + throw new Error(`Session scanner service smoke failed: ${describeProcessFailure(result)}`) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/session-scanner-service-smoke.test.mjs b/config/scripts/session-scanner-service-smoke.test.mjs new file mode 100644 index 00000000000..b2e05158247 --- /dev/null +++ b/config/scripts/session-scanner-service-smoke.test.mjs @@ -0,0 +1,74 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs' +import { + externalNativeAddons, + ORCAD_CHILD_ENTRY_POINTS, + ORCAD_EXTERNAL_MODULES, + orcadChildOutputFilename +} from './orcad-entry-build.mjs' + +const ENTRY = orcadChildOutputFilename(ORCAD_CHILD_ENTRY_POINTS.sessionScanner) +const directories = [] +let builtDirectory + +function fixtureDirectory() { + const directory = mkdtempSync(join(tmpdir(), 'orca-session-scanner-smoke-test-')) + directories.push(directory) + return directory +} + +beforeAll(async () => { + builtDirectory = fixtureDirectory() + await build({ + entryPoints: [resolve(ORCAD_CHILD_ENTRY_POINTS.sessionScanner)], + outfile: join(builtDirectory, ENTRY), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + external: ORCAD_EXTERNAL_MODULES, + plugins: [externalNativeAddons], + logLevel: 'silent' + }) +}, 60_000) + +afterAll(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('session scanner service build smoke', () => { + it('lists a seeded session through the built entry', () => { + expect(() => smokeSessionScannerService(builtDirectory)).not.toThrow() + }, 60_000) + + it('fails when the entry is missing', () => { + expect(() => smokeSessionScannerService(fixtureDirectory())).toThrow('smoke failed') + }) + + it('fails when the child answers without scanning', () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, ENTRY), + `process.on('message', (m) => { + if (m.type === 'init') process.send({ type: 'ready', protocol: m.protocol, pid: process.pid }) + if (m.type === 'request') process.send({ type: 'result', id: m.id, operation: 'scan', + value: { result: { sessions: [], issues: [] }, durationMs: 0 } }) + })` + ) + expect(() => smokeSessionScannerService(directory)).toThrow('smoke failed') + }) + + it('runs in the orcad build under both runtimes', () => { + const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') + expect(source).toContain( + "{ label: 'session scanner service', smoke: smokeSessionScannerService }" + ) + expect(source).toContain('await smoke(OUT_DIR, { runtimePath: nodeRuntimePath })') + }) +}) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 09029f3c6d2..1233564b961 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -35,6 +35,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/**/*expiry*.test.ts', 'src/renderer/src/lib/flatten-retained-slice.test.ts', 'src/shared/own-retained-string.test.ts', + // Times V8 string flattening, which is what Electron main runs. + 'src/main/runtime/terminal-tail-redraw-wall-time.test.ts', 'src/main/agent-hooks/server-transport-interference.test.ts', 'src/main/plugins/plugin-worker-supervision.integration.test.ts', 'src/main/usage/usage-scan-worker-event-loop.test.ts', @@ -71,6 +73,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/structured-session-mail-redrive-wiring.test.ts', 'src/main/runtime/rpc/ws-transport.test.ts', + // Bun resolves 'ws' to its built-in client, which bypasses the patched package. + 'src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts', 'src/shared/remote-runtime-client.test.ts', 'src/shared/remote-runtime-connect-bound.test.ts', 'src/shared/remote-runtime-subscription-connect-bound.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 1655071b95a..64fd1a0181a 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -153,6 +153,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts', 'src/main/opencode/opencode-acp-stored-messages.test.ts', 'src/main/orcad/orcad-automations.test.ts', + 'src/main/orcad/orcad-local-serve-selection.test.ts', + 'src/main/orcad/serve-profile-ssh-targets.test.ts', 'src/main/pi/rpc-launch-resolution.test.ts', 'src/main/runtime/agent-session-conversation-clear-commit.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8501286145..67c57bfc30b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,7 @@ patchedDependencies: i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 node-pty@1.1.0: 92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192 + ws@8.22.0: 9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14 importers: @@ -255,7 +256,7 @@ importers: version: 1.0.3 ws: specifier: ^8.22.0 - version: 8.22.0 + version: 8.22.0(patch_hash=9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14) yaml: specifier: ^2.9.1 version: 2.9.1 @@ -14757,7 +14758,7 @@ snapshots: dependencies: inspect-webkit: 0.0.5(typescript@7.0.2) sonner: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - ws: 8.22.0 + ws: 8.22.0(patch_hash=9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14) transitivePeerDependencies: - '@types/react' - bufferutil @@ -15406,7 +15407,7 @@ snapshots: ws@8.21.3: {} - ws@8.22.0: {} + ws@8.22.0(patch_hash=9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14): {} wsl-utils@0.3.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e3c133b29ee..2d673455655 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,3 +63,4 @@ patchedDependencies: lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch '@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch i18next-cli@1.74.2: config/patches/i18next-cli@1.74.2.patch + ws@8.22.0: config/patches/ws@8.22.0.patch diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 49450d7c19b..ac9097b2d13 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -102,10 +102,12 @@ export const CORE_HANDLERS: Record = { const projectRoot = typeof projectRootValue === 'string' ? projectRootValue : null const noPairing = flags.get('no-pairing') === true const mobilePairing = flags.get('mobile-pairing') === true + const grantDesktopControl = flags.get('grant-desktop-control') === true const recipeJson = flags.get('recipe-json') === true const validationError = getServeOptionValidationError({ noPairing, mobilePairing, + grantDesktopControl, recipeJson, projectRoot }) @@ -120,6 +122,7 @@ export const CORE_HANDLERS: Record = { pairingAddress: typeof pairingAddressValue === 'string' ? pairingAddressValue : null, noPairing, mobilePairing, + grantDesktopControl, recipeJson, projectRoot }) diff --git a/src/cli/index-serve-command.test.ts b/src/cli/index-serve-command.test.ts index 5b204a4220d..3db82cc2684 100644 --- a/src/cli/index-serve-command.test.ts +++ b/src/cli/index-serve-command.test.ts @@ -68,11 +68,22 @@ describe('orca cli worktree awareness', () => { pairingAddress: '100.64.1.20', noPairing: true, mobilePairing: false, + grantDesktopControl: false, recipeJson: false, projectRoot: null }) }) + it('forwards the pairing-time desktop-control grant', async () => { + serveOrcaAppMock.mockResolvedValue(0) + + await main(['serve', '--grant-desktop-control'], '/tmp/repo') + + expect(serveOrcaAppMock).toHaveBeenCalledWith( + expect.objectContaining({ grantDesktopControl: true, mobilePairing: false }) + ) + }) + it('starts a foreground headless server with mobile pairing enabled', async () => { serveOrcaAppMock.mockResolvedValue(0) @@ -87,6 +98,7 @@ describe('orca cli worktree awareness', () => { pairingAddress: '100.64.1.20', noPairing: false, mobilePairing: true, + grantDesktopControl: false, recipeJson: false, projectRoot: null }) @@ -113,6 +125,7 @@ describe('orca cli worktree awareness', () => { pairingAddress: 'wss://sandbox.example.com', noPairing: false, mobilePairing: false, + grantDesktopControl: false, recipeJson: true, projectRoot: '/workspace/repo' }) diff --git a/src/cli/index.ts b/src/cli/index.ts index be527727876..b51041edec3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,10 @@ import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' import { refuseConflictingSessionCallerFlags } from './session-caller-flags' +import { + refuseOwnerOnlySshBridgeCommand, + refuseSshBridgeEnvironmentRouting +} from './ssh-bridge-command-policy' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -112,10 +116,10 @@ export async function main( // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) - refuseConflictingSessionCallerFlags( - findCommandSpec(COMMAND_SPECS, parsed.commandPath), - parsed.flags - ) + const commandSpec = findCommandSpec(COMMAND_SPECS, parsed.commandPath) + // Why: the canonical path, so an alias cannot slip an owner-only command past the bridge. + refuseOwnerOnlySshBridgeCommand(commandSpec?.path ?? parsed.commandPath, parsed.flags) + refuseConflictingSessionCallerFlags(commandSpec, parsed.flags) const RuntimeClientClass = await loadRuntimeClientClass() const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') @@ -152,6 +156,7 @@ export async function main( // Why: --host runtime: is canonicalized to the environment's id so downstream host-id // comparisons against stored rows still match; rewrite the flag once, here, rather than // resolving the name again at every consumer. + refuseSshBridgeEnvironmentRouting(hostEnvironmentId) if (hostEnvironmentId !== null) { parsed.flags.set('host', `runtime:${hostEnvironmentId}`) } @@ -196,6 +201,7 @@ export async function main( async function runClaudeTeams(argv: string[], cwd: string): Promise { try { + refuseOwnerOnlySshBridgeCommand(['claude-teams'], new Map()) // Why: everything after `orca claude-teams` belongs to Claude Code, not // Orca's own flag parser, so new Claude flags work without Orca changes. const client = new (await loadRuntimeClientClass())(undefined, undefined, null, null) diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 68cb770ec2d..4b61499ffc1 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -36,7 +36,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [ '', 'Common Commands:', ' orca open [--json]', - ' orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json]', + ' orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--grant-desktop-control] [--project-root ] [--recipe-json] [--json]', ' orca status [--json]', ' orca diagnostics memory [--json]', ' orca agent-context [--json]', diff --git a/src/cli/runtime/launch-serve-runtime.test.ts b/src/cli/runtime/launch-serve-runtime.test.ts index 9d1fc96802a..b1d34435bdb 100644 --- a/src/cli/runtime/launch-serve-runtime.test.ts +++ b/src/cli/runtime/launch-serve-runtime.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events' +import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { spawnMock, resolveLocalServeRuntimeMock, serveWithOrcadMock } = vi.hoisted(() => ({ @@ -50,6 +51,9 @@ describe('orca serve host selection', () => { vi.restoreAllMocks() delete process.env.ORCA_APP_EXECUTABLE delete process.env.ORCA_SERVE_RUNTIME + delete process.env.ORCA_USER_DATA_PATH + delete process.env.ORCA_DEV_USER_DATA_PATH + delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT }) it('serves on orcad by default', async () => { @@ -90,4 +94,72 @@ describe('orca serve host selection', () => { expect(spawnMock).toHaveBeenCalledOnce() expect(stderr.join('')).not.toContain('[serve]') }) + + describe('serves the profile it selected on', () => { + function electronSpawn(): { args: string[]; env: NodeJS.ProcessEnv } { + const [, args, options] = spawnMock.mock.calls[0] + return { args, env: options.env } + } + + it('starts the Electron fallback on ORCA_USER_DATA_PATH, not the default profile', async () => { + const profile = resolve('/isolated/profile-r') + process.env.ORCA_USER_DATA_PATH = profile + resolveLocalServeRuntimeMock.mockResolvedValue({ kind: 'electron', reason: 'ssh targets' }) + electronChild() + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(resolveLocalServeRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ userDataPath: profile }) + ) + const child = electronSpawn() + expect(child.args).toContain(`--user-data-dir=${profile}`) + expect(child.env).toMatchObject({ + ORCA_USER_DATA_PATH: profile, + ORCA_SERVE_USER_DATA_PATH: profile + }) + }) + + it('pins the opt-out Electron serve to the same profile', async () => { + const profile = resolve('/isolated/profile-e') + process.env.ORCA_USER_DATA_PATH = profile + process.env.ORCA_SERVE_RUNTIME = 'electron' + electronChild() + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(electronSpawn().args).toContain(`--user-data-dir=${profile}`) + }) + + it('hands orcad the absolute profile the selector read', async () => { + process.env.ORCA_USER_DATA_PATH = join('relative', 'profile') + const selection = { kind: 'orcad', runtime: '/node', entry: '/slot/orcad.js', version: '1' } + resolveLocalServeRuntimeMock.mockResolvedValue(selection) + serveWithOrcadMock.mockResolvedValue(0) + + await expect(serveOrcaApp({})).resolves.toBe(0) + const profile = resolve('relative', 'profile') + expect(resolveLocalServeRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ userDataPath: profile }) + ) + expect(serveWithOrcadMock).toHaveBeenCalledWith(selection, {}, profile, expect.any(Object)) + }) + + it('uses ORCA_DEV_USER_DATA_PATH for both the selector and a dev Electron child', async () => { + const profile = resolve('/isolated/dev-profile') + delete process.env.ORCA_USER_DATA_PATH + process.env.ORCA_DEV_USER_DATA_PATH = profile + process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1' + resolveLocalServeRuntimeMock.mockResolvedValue({ kind: 'electron', reason: 'dev' }) + electronChild() + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(resolveLocalServeRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ userDataPath: profile }) + ) + // A dev build ignores --user-data-dir and reads this instead. + expect(electronSpawn().env).toMatchObject({ + ORCA_DEV_USER_DATA_PATH: profile, + ORCA_SERVE_USER_DATA_PATH: profile + }) + }) + }) }) diff --git a/src/cli/runtime/launch-user-data-path.test.ts b/src/cli/runtime/launch-user-data-path.test.ts new file mode 100644 index 00000000000..eb301ddacd3 --- /dev/null +++ b/src/cli/runtime/launch-user-data-path.test.ts @@ -0,0 +1,58 @@ +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { getPlatformUserDataPath } from './metadata' +import { getMacOpenArgs } from './launch' +import { pinLaunchUserDataPath, resolveLaunchUserDataPath } from './launch-user-data-path' + +describe('resolveLaunchUserDataPath', () => { + it('prefers ORCA_USER_DATA_PATH, made absolute', () => { + expect( + resolveLaunchUserDataPath({ + ORCA_USER_DATA_PATH: 'rel/profile', + ORCA_DEV_USER_DATA_PATH: '/dev', + ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' + }) + ).toBe(resolve('rel/profile')) + }) + + it("falls back to the dev app's own profile for a dev executable", () => { + expect(resolveLaunchUserDataPath({ ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' })).toBe( + resolve(join(getPlatformUserDataPath(), '..', 'orca-dev')) + ) + }) + + it('ignores ORCA_DEV_USER_DATA_PATH for a packaged executable', () => { + expect(resolveLaunchUserDataPath({ ORCA_DEV_USER_DATA_PATH: '/dev' })).toBe( + getPlatformUserDataPath() + ) + }) +}) + +describe('pinLaunchUserDataPath', () => { + it('sets the dev override only for a dev executable', () => { + expect(pinLaunchUserDataPath(['--no-sandbox'], {}, '/p')).toEqual({ + args: ['--no-sandbox', '--user-data-dir=/p'], + env: { ORCA_USER_DATA_PATH: '/p' } + }) + expect( + pinLaunchUserDataPath([], { ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' }, '/p').env + ).toMatchObject({ ORCA_DEV_USER_DATA_PATH: '/p' }) + }) +}) + +describe('getMacOpenArgs', () => { + it('reopens the bundle as before on the default profile', () => { + expect(getMacOpenArgs('/Applications/Orca.app', getPlatformUserDataPath())).toEqual([ + '/Applications/Orca.app' + ]) + }) + + it('starts a new instance on another profile instead of activating the running app', () => { + expect(getMacOpenArgs('/Applications/Orca.app', '/isolated/p')).toEqual([ + '-n', + '/Applications/Orca.app', + '--args', + '--user-data-dir=/isolated/p' + ]) + }) +}) diff --git a/src/cli/runtime/launch-user-data-path.ts b/src/cli/runtime/launch-user-data-path.ts new file mode 100644 index 00000000000..658108a0860 --- /dev/null +++ b/src/cli/runtime/launch-user-data-path.ts @@ -0,0 +1,48 @@ +import { dirname, join, resolve } from 'node:path' +import { SERVE_USER_DATA_PATH_ENV } from '../../shared/serve-user-data-path' +import { getPlatformUserDataPath } from './metadata' + +/** An unpackaged checkout build, which takes its profile from ORCA_DEV_USER_DATA_PATH, not --user-data-dir. */ +function launchesDevApp(env: NodeJS.ProcessEnv): boolean { + return env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' +} + +/** + * The one profile a CLI launch both reads and hands to the app it starts. + * Absolute, because the child runs from the app root, not the caller's cwd. + */ +export function resolveLaunchUserDataPath(env: NodeJS.ProcessEnv = process.env): string { + if (env.ORCA_USER_DATA_PATH) { + return resolve(env.ORCA_USER_DATA_PATH) + } + if (launchesDevApp(env)) { + // Mirrors the dev app's own default (`/orca-dev`). + return resolve( + env.ORCA_DEV_USER_DATA_PATH || join(dirname(getPlatformUserDataPath()), 'orca-dev') + ) + } + return getPlatformUserDataPath() +} + +/** Pins a launched app to `userDataPath` in every form a packaged or dev build reads. */ +export function pinLaunchUserDataPath( + args: string[], + env: NodeJS.ProcessEnv, + userDataPath: string +): { args: string[]; env: NodeJS.ProcessEnv } { + const pinnedEnv: NodeJS.ProcessEnv = { ...env, ORCA_USER_DATA_PATH: userDataPath } + if (launchesDevApp(env)) { + pinnedEnv.ORCA_DEV_USER_DATA_PATH = userDataPath + } + return { args: [...args, `--user-data-dir=${userDataPath}`], env: pinnedEnv } +} + +/** Serve additionally refuses to start, before the instance lock, if it lands anywhere else. */ +export function pinServeUserDataPath( + args: string[], + env: NodeJS.ProcessEnv, + userDataPath: string +): { args: string[]; env: NodeJS.ProcessEnv } { + const pinned = pinLaunchUserDataPath(args, env, userDataPath) + return { args: pinned.args, env: { ...pinned.env, [SERVE_USER_DATA_PATH_ENV]: userDataPath } } +} diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 584ab18e503..0f74cea652e 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -70,6 +70,8 @@ const SSH_RECIPE_JSON = JSON.stringify({ }) const INVALID_SSH_RECIPE_JSON = SSH_RECIPE_JSON.replace('/workspace/repo', 'relative/repo') const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' +const PROFILE = resolve('/profiles/serve') +const PIN_PROFILE = `--user-data-dir=${PROFILE}` function startRecipeJsonServer() { const child = new FakeChildProcess() @@ -92,6 +94,7 @@ describe('serveOrcaApp', () => { process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca' // These cover Electron serve itself; the orcad default is in launch-serve-runtime.test.ts. process.env.ORCA_SERVE_RUNTIME = 'electron' + process.env.ORCA_USER_DATA_PATH = PROFILE }) afterEach(() => { @@ -349,7 +352,7 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/Applications/Orca.app/Contents/MacOS/Orca', - ['--serve', '--serve-json'], + [PIN_PROFILE, '--serve', '--serve-json'], expect.objectContaining({ cwd: resolve(__dirname, '../../..') }) @@ -382,6 +385,7 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/Applications/Orca.app/Contents/MacOS/Orca', [ + PIN_PROFILE, '--serve', '--serve-json', '--serve-port', @@ -416,7 +420,14 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/repo/node_modules/.bin/electron', - [resolve(__dirname, '../../..'), '--serve', '--serve-json', '--serve-port', '6768'], + [ + resolve(__dirname, '../../..'), + PIN_PROFILE, + '--serve', + '--serve-json', + '--serve-port', + '6768' + ], expect.objectContaining({ cwd: resolve(__dirname, '../../..') }) @@ -466,7 +477,7 @@ describe('serveOrcaApp', () => { ) expect(spawnMock).toHaveBeenCalledWith( executable, - [...expectedPrefix, '--serve', '--serve-json'], + [...expectedPrefix, PIN_PROFILE, '--serve', '--serve-json'], // Foreground serve must share POSIX job-control signals with its CLI supervisor. expect.objectContaining({ detached: false }) ) @@ -502,6 +513,7 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/Applications/Orca.app/Contents/MacOS/Orca', [ + PIN_PROFILE, '--serve', '--serve-pairing-address', 'wss://sandbox.example.com', @@ -622,7 +634,7 @@ describe('serveOrcaApp', () => { await expect(serveOrcaApp({ json: true })).resolves.toBe(0) expect(spawnMock).toHaveBeenCalledWith( 'C:\\repo\\node_modules\\.bin\\electron.cmd', - ['--serve', '--serve-json'], + [PIN_PROFILE, '--serve', '--serve-json'], expect.objectContaining({ shell: true }) @@ -639,9 +651,11 @@ describe('launchOrcaApp', () => { beforeEach(() => { spawnMock.mockReset() spawnSyncMock.mockReset() + process.env.ORCA_USER_DATA_PATH = PROFILE }) afterEach(() => { + delete process.env.ORCA_USER_DATA_PATH delete process.env.ORCA_OPEN_COMMAND delete process.env.ORCA_APP_EXECUTABLE delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT @@ -684,7 +698,7 @@ describe('launchOrcaApp', () => { ) expect(spawnMock).toHaveBeenCalledWith( executable, - ['--no-sandbox'], + ['--no-sandbox', PIN_PROFILE], expect.objectContaining({ detached: true, stdio: 'ignore', diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index afce6264154..37e53053ae5 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -6,8 +6,14 @@ import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath } from '../../shared/serve-update-handoff' -import { getDefaultUserDataPath } from './metadata' +import { + pinLaunchUserDataPath, + pinServeUserDataPath, + resolveLaunchUserDataPath +} from './launch-user-data-path' import { getMacAppBundlePath } from './mac-app-update-bundle' +import { getPlatformUserDataPath } from './metadata' +import { isSameUserDataPath } from '../../shared/serve-user-data-path' import { readServeUpdateHandoffSync, resumeInterruptedServeUpdate, @@ -31,11 +37,18 @@ export function launchOrcaApp(): void { return } + // Why: `openOrca` waits on this profile's metadata, so the app must start on it too. + const userDataPath = resolveLaunchUserDataPath() const overrideExecutable = process.env.ORCA_APP_EXECUTABLE if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { - spawnDetached(overrideExecutable, getExecutableAppArgs(overrideExecutable), { + const pinned = pinLaunchUserDataPath( + getExecutableAppArgs(overrideExecutable), + stripElectronRunAsNode(process.env), + userDataPath + ) + spawnDetached(overrideExecutable, pinned.args, { ...getExecutableSpawnOptions(overrideExecutable), - env: stripElectronRunAsNode(process.env) + env: pinned.env }) return } @@ -47,16 +60,19 @@ export function launchOrcaApp(): void { // Why: launching the inner MacOS binary directly can trigger macOS app // launch failures and bypass normal bundle lifecycle. The public // packaged CLI should re-open the .app the same way Finder does. - spawnDetached('open', [appBundlePath], { + spawnDetached('open', getMacOpenArgs(appBundlePath, userDataPath), { env: stripElectronRunAsNode(process.env) }) return } } - spawnDetached(process.execPath, getExecutableAppArgs(process.execPath), { - env: stripElectronRunAsNode(process.env) - }) + const pinned = pinLaunchUserDataPath( + getExecutableAppArgs(process.execPath), + stripElectronRunAsNode(process.env), + userDataPath + ) + spawnDetached(process.execPath, pinned.args, { env: pinned.env }) return } @@ -66,6 +82,17 @@ export function launchOrcaApp(): void { ) } +/** + * Plain `open` activates whichever instance already runs, which is the default profile's. + * Why `-n` only off the default: `open` drops our env, and `--args` reach only a new instance. + */ +export function getMacOpenArgs(appBundlePath: string, userDataPath: string): string[] { + if (isSameUserDataPath(userDataPath, getPlatformUserDataPath())) { + return [appBundlePath] + } + return ['-n', appBundlePath, '--args', `--user-data-dir=${userDataPath}`] +} + function spawnDetached(command: string, args: string[], options: SpawnOptions): void { const child = spawnProcess(command, args, { detached: true, @@ -83,40 +110,47 @@ export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise { if (args.recipeJson && !args.projectRoot) { throw new RuntimeClientError('invalid_argument', 'Recipe JSON output requires --project-root.') } + // Why one value: the selector, orcad and Electron must all serve the profile the caller chose. + const userDataPath = resolveLaunchUserDataPath() // Why synchronous on the opt-out: it must spawn Electron exactly as before, without asking. if (process.env[SERVE_RUNTIME_ENV] === SERVE_RUNTIME_ELECTRON) { - return serveWithElectron(executable, args) + return serveWithElectron(executable, args, userDataPath) } - return serveWithSelectedRuntime(executable, args) + return serveWithSelectedRuntime(executable, args, userDataPath) } async function serveWithSelectedRuntime( executable: string, - args: ServeOrcaAppArgs + args: ServeOrcaAppArgs, + userDataPath: string ): Promise { const selection = await resolveLocalServeRuntime({ executable, appRoot: resolveAppRoot(), - userDataPath: getDefaultUserDataPath(), + userDataPath, usesMacUpdateHandoff: args.recipeJson !== true && getMacAppBundlePath(executable) !== null }) if (selection.kind === 'orcad') { process.stderr.write(`[serve] running on orcad ${selection.version}\n`) - return serveWithOrcad( - selection, - args, - getDefaultUserDataPath(), - stripElectronRunAsNode(process.env) - ) + return serveWithOrcad(selection, args, userDataPath, stripElectronRunAsNode(process.env)) } if (selection.reason) { process.stderr.write(`[serve] using Electron serve: ${selection.reason}\n`) } - return serveWithElectron(executable, args) + return serveWithElectron(executable, args, userDataPath) } -function serveWithElectron(executable: string, args: ServeOrcaAppArgs): Promise { - const childArgs = [...getExecutableAppArgs(executable)] +function serveWithElectron( + executable: string, + args: ServeOrcaAppArgs, + userDataPath: string +): Promise { + const pinned = pinServeUserDataPath( + getExecutableAppArgs(executable), + stripElectronRunAsNode(process.env), + userDataPath + ) + const childArgs = pinned.args childArgs.push('--serve') if (args.json) { childArgs.push('--serve-json') @@ -133,15 +167,18 @@ function serveWithElectron(executable: string, args: ServeOrcaAppArgs): Promise< if (args.mobilePairing) { childArgs.push('--serve-mobile-pairing') } + if (args.grantDesktopControl) { + childArgs.push('--serve-grant-desktop-control') + } if (args.recipeJson && args.projectRoot) { childArgs.push('--serve-recipe-json', '--serve-project-root', args.projectRoot) } const handoffPath = args.recipeJson !== true && getMacAppBundlePath(executable) - ? getServeUpdateHandoffPath(getDefaultUserDataPath()) + ? getServeUpdateHandoffPath(userDataPath) : null - const childEnv = stripElectronRunAsNode(process.env) + const childEnv = pinned.env if (handoffPath) { childEnv[SERVE_UPDATE_HANDOFF_PATH_ENV] = handoffPath } diff --git a/src/cli/runtime/metadata.ts b/src/cli/runtime/metadata.ts index de1a8122fc2..7a0d4f04ca9 100644 --- a/src/cli/runtime/metadata.ts +++ b/src/cli/runtime/metadata.ts @@ -50,6 +50,14 @@ export function getDefaultUserDataPath( if (process.env.ORCA_USER_DATA_PATH) { return process.env.ORCA_USER_DATA_PATH } + return getPlatformUserDataPath(platform, homeDir) +} + +/** The packaged app's own default profile, ignoring any CLI override. */ +export function getPlatformUserDataPath( + platform: NodeJS.Platform = process.platform, + homeDir = homedir() +): string { if (platform === 'darwin') { return join(homeDir, 'Library', 'Application Support', 'orca') } diff --git a/src/cli/runtime/serve-orcad-launch.ts b/src/cli/runtime/serve-orcad-launch.ts index 3379518d829..7716de68242 100644 --- a/src/cli/runtime/serve-orcad-launch.ts +++ b/src/cli/runtime/serve-orcad-launch.ts @@ -21,6 +21,7 @@ export type ServeOrcaAppArgs = { pairingAddress?: string | null noPairing?: boolean mobilePairing?: boolean + grantDesktopControl?: boolean recipeJson?: boolean projectRoot?: string | null } @@ -128,6 +129,7 @@ export function orcadServeArgs(args: ServeOrcaAppArgs): string[] { ...(args.pairingAddress ? ['--pairing-address', args.pairingAddress] : []), ...(args.noPairing ? ['--no-pairing'] : []), ...(args.mobilePairing ? ['--mobile-pairing'] : []), + ...(args.grantDesktopControl ? ['--grant-desktop-control'] : []), ...(args.recipeJson && args.projectRoot ? ['--recipe-json', '--project-root', args.projectRoot] : []) diff --git a/src/cli/runtime/transport-ssh-bridge-credential.test.ts b/src/cli/runtime/transport-ssh-bridge-credential.test.ts new file mode 100644 index 00000000000..bcfcdbde90f --- /dev/null +++ b/src/cli/runtime/transport-ssh-bridge-credential.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createServer } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' +import { sendRequest } from './transport' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +async function captureAuthToken(): Promise { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-bridge-transport-')) + const endpoint = join(userDataPath, 'runtime.sock') + let authToken = '' + const server = createServer((socket) => { + socket.once('data', (data) => { + const request: { id: string; authToken: string } = JSON.parse(String(data).trim()) + authToken = request.authToken + socket.end( + `${JSON.stringify({ id: request.id, ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })}\n` + ) + }) + }) + await new Promise((resolve) => server.listen(endpoint, resolve)) + const metadata: RuntimeMetadata = { + runtimeId: 'runtime-1', + pid: 123, + transports: [{ kind: 'unix', endpoint }], + authToken: 'owner-token', + startedAt: 1 + } + try { + await sendRequest(metadata, 'status.get', undefined, 5_000) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + return authToken +} + +describe.skipIf(process.platform === 'win32')('runtime transport credentials', () => { + it('presents the SSH bridge credential instead of the owner token when bridged', async () => { + vi.stubEnv(ORCA_SSH_BRIDGE_CREDENTIAL_ENV, 'sshb_scoped') + expect(await captureAuthToken()).toBe('sshb_scoped') + }) + + it('presents the owner token outside the bridge', async () => { + vi.stubEnv(ORCA_SSH_BRIDGE_CREDENTIAL_ENV, '') + expect(await captureAuthToken()).toBe('owner-token') + }) +}) diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 2e5a047d037..f17eb7db92e 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,3 +1,4 @@ +import { readSshBridgeCredential } from '../../shared/ssh-bridge-credential-env' import { createConnection } from 'node:net' import { randomUUID } from 'node:crypto' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' @@ -196,7 +197,8 @@ export async function sendRequest( socket.write( `${JSON.stringify({ id: requestId, - authToken: metadata.authToken, + // Why: a bridged SSH invocation must present its scoped credential, never the owner token. + authToken: readSshBridgeCredential() ?? metadata.authToken, method, params, orchestrationCapability: envelope?.orchestrationCapability, diff --git a/src/cli/specs/serve.ts b/src/cli/specs/serve.ts index eefbb34a1cb..cc7369ec0e8 100644 --- a/src/cli/specs/serve.ts +++ b/src/cli/specs/serve.ts @@ -6,13 +6,14 @@ export const SERVE_COMMAND_SPECS: CommandSpec[] = [ path: ['serve'], summary: 'Start an Orca runtime server without opening a desktop window', usage: - 'orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json]', + 'orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--grant-desktop-control] [--project-root ] [--recipe-json] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'port', 'pairing-address', 'mobile-pairing', 'no-pairing', + 'grant-desktop-control', 'project-root', 'recipe-json' ], @@ -21,6 +22,7 @@ export const SERVE_COMMAND_SPECS: CommandSpec[] = [ '--pairing-address changes only the client-advertised address; use a reachable LAN, Tailscale, SSH-forward, or reverse-proxy endpoint.', 'Use --recipe-json with --project-root from VM recipes to print the recipe result JSON and leave the server running.', 'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.', + "A paired client cannot drive this machine's desktop (orca computer) unless the offer was created with --grant-desktop-control.", 'When the web client bundle is available, the server also prints a browser URL with the pairing data embedded.' ], examples: [ diff --git a/src/cli/ssh-bridge-command-policy.test.ts b/src/cli/ssh-bridge-command-policy.test.ts new file mode 100644 index 00000000000..2b54cb72d68 --- /dev/null +++ b/src/cli/ssh-bridge-command-policy.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../shared/ssh-bridge-credential-env' +import { + refuseOwnerOnlySshBridgeCommand, + refuseSshBridgeEnvironmentRouting +} from './ssh-bridge-command-policy' + +const BRIDGED = { [ORCA_SSH_BRIDGE_CREDENTIAL_ENV]: 'sshb_test' } + +describe('owner-only commands over the SSH bridge', () => { + it.each([ + [['account', 'add']], + [['environment', 'add']], + [['skills', 'install']], + [['agent', 'hooks', 'on']], + [['profile', 'state', 'rollback']], + [['serve']], + [['claude-teams']] + ])('refuses %j, which acts on the Orca host directly', (commandPath) => { + expect(() => refuseOwnerOnlySshBridgeCommand(commandPath, new Map(), BRIDGED)).toThrow( + /cannot run from an SSH host/ + ) + }) + + it.each([['environment'], ['pairing-code']])( + 'refuses --%s, which routes through the host’s paired servers', + (flag) => { + expect(() => + refuseOwnerOnlySshBridgeCommand(['terminal', 'list'], new Map([[flag, 'prod']]), BRIDGED) + ).toThrow(/paired servers/) + } + ) + + it('refuses --host runtime: routing', () => { + expect(() => refuseSshBridgeEnvironmentRouting('env-1', BRIDGED)).toThrow(/paired servers/) + }) + + it('leaves runtime-routed commands to the scoped runtime socket', () => { + expect(() => + refuseOwnerOnlySshBridgeCommand(['terminal', 'list'], new Map(), BRIDGED) + ).not.toThrow() + expect(() => + refuseOwnerOnlySshBridgeCommand(['skills', 'list'], new Map(), BRIDGED) + ).not.toThrow() + }) + + it('does nothing outside the bridge', () => { + expect(() => refuseOwnerOnlySshBridgeCommand(['account', 'add'], new Map(), {})).not.toThrow() + expect(() => refuseSshBridgeEnvironmentRouting('env-1', {})).not.toThrow() + }) +}) diff --git a/src/cli/ssh-bridge-command-policy.ts b/src/cli/ssh-bridge-command-policy.ts new file mode 100644 index 00000000000..8ac11e614f7 --- /dev/null +++ b/src/cli/ssh-bridge-command-policy.ts @@ -0,0 +1,60 @@ +import { readSshBridgeCredential } from '../shared/ssh-bridge-credential-env' +import { RuntimeClientError } from './runtime/types' + +// Why: these act on this machine directly (its files, accounts, processes) rather than through the +// scoped runtime socket, so the runtime's per-caller permissions can never see them. +const OWNER_MACHINE_COMMANDS: readonly (readonly string[])[] = [ + ['account'], + ['agent', 'hooks'], + ['claude-teams'], + ['environment'], + ['host', 'list'], + ['profile'], + ['serve'], + ['skills', 'install'], + ['skills', 'update'], + ['skills', 'share'], + ['vm'] +] + +function isSshBridgeInvocation(env: NodeJS.ProcessEnv = process.env): boolean { + return readSshBridgeCredential(env) !== null +} + +/** Throws when an SSH-bridged invocation asks for something only this machine's owner may do. */ +export function refuseOwnerOnlySshBridgeCommand( + commandPath: readonly string[], + flags: ReadonlyMap, + env: NodeJS.ProcessEnv = process.env +): void { + if (!isSshBridgeInvocation(env)) { + return + } + const command = commandPath.join(' ') + if (OWNER_MACHINE_COMMANDS.some((prefix) => prefix.every((part, i) => commandPath[i] === part))) { + throw new RuntimeClientError( + 'forbidden', + `orca ${command} acts on the Orca host machine itself and cannot run from an SSH host. Run it on the Orca host.` + ) + } + for (const flag of ['environment', 'pairing-code']) { + if (flags.has(flag)) { + throw new RuntimeClientError( + 'forbidden', + `--${flag} would route through the Orca host's own paired servers and cannot be used from an SSH host.` + ) + } + } +} + +export function refuseSshBridgeEnvironmentRouting( + hostEnvironmentId: string | null, + env: NodeJS.ProcessEnv = process.env +): void { + if (hostEnvironmentId !== null && isSshBridgeInvocation(env)) { + throw new RuntimeClientError( + 'forbidden', + "--host runtime: would route through the Orca host's own paired servers and cannot be used from an SSH host." + ) + } +} diff --git a/src/main/ai-vault-search/session-search-in-process-service.ts b/src/main/ai-vault-search/session-search-in-process-service.ts index 8cb0adbe3e4..bcda7949cce 100644 --- a/src/main/ai-vault-search/session-search-in-process-service.ts +++ b/src/main/ai-vault-search/session-search-in-process-service.ts @@ -11,8 +11,8 @@ import { sessionSearchSqliteAvailable } from './session-search-sqlite-support' * * The desktop puts the index in that child because the child is where the * transcript reader runs, so one read serves both the session list and the index. - * Neither of these hosts has that child: orcad ships only the watcher and daemon - * entries beside `orcad.js`, and the relay's AI Vault sidecar runs the remote + * Neither host gives that child the index: orcad's scanner child gets no search + * data root and only lists sessions, and the relay's AI Vault sidecar runs the remote * scanner, which reads through a filesystem provider and publishes nothing to the * transcript channel. On both, the process that would drive the index's reads is * this one, and it is the only writer, so the two-process rebuild race the diff --git a/src/main/ai-vault/session-scanner-service-entry-path.test.ts b/src/main/ai-vault/session-scanner-service-entry-path.test.ts index 7723a4f8fe7..4a1cfbbd7b6 100644 --- a/src/main/ai-vault/session-scanner-service-entry-path.test.ts +++ b/src/main/ai-vault/session-scanner-service-entry-path.test.ts @@ -37,6 +37,31 @@ describe('resolveAiVaultServiceEntryPath', () => { ) }) + it('uses the entry beside the bundle on a packaged host without an asar', () => { + const orcadRoot = join('/opt', 'orcad', '1.0.0') + const adjacent = join(orcadRoot, 'session-scanner-service-entry.js') + + expect(resolveAiVaultServiceEntryPath(orcadRoot, true, (path) => path === adjacent)).toBe( + adjacent + ) + }) + + it('never forks an adjacent decoy from inside app.asar', () => { + const appPath = join('/opt', 'Orca', 'resources', 'app.asar') + + expect(resolveAiVaultServiceEntryPath(appPath, true, () => true)).toBe( + join( + '/opt', + 'Orca', + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'session-scanner-service-entry.js' + ) + ) + }) + it('uses resourcesPath from packaged Electron-as-Node runtimes', () => { const resourcesPath = join('Applications', 'Orca.app', 'Contents', 'Resources') const entry = join( diff --git a/src/main/ai-vault/session-scanner-service-entry-path.ts b/src/main/ai-vault/session-scanner-service-entry-path.ts index a64676d66b3..ca9d0098212 100644 --- a/src/main/ai-vault/session-scanner-service-entry-path.ts +++ b/src/main/ai-vault/session-scanner-service-entry-path.ts @@ -1,27 +1,24 @@ import { existsSync } from 'node:fs' -import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { join } from 'node:path' +import { + getForkedChildEntryPath, + resolveForkedChildEntryPath, + resolveForkedChildEntryPathWithoutApp +} from '../forked-child-entry-path' -type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } - -// Why the port and not require('electron'): this module is reachable from plain-Node -// fork entries, where the literal text require("electron") fails the build guard even -// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. -function loadElectronApp(): ElectronAppPath | null { - return hasAppEnvironment() ? getAppEnvironment() : null -} +// orcad resolves this literal beside orcad.js; build-orcad.mjs emits it from ORCAD_CHILD_ENTRY_POINTS. +export const AI_VAULT_SERVICE_ENTRY_FILENAME = 'session-scanner-service-entry.js' export function resolveAiVaultServiceEntryPath( appPath: string, isPackaged: boolean, pathExists: (candidate: string) => boolean = existsSync ): string { - const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath - const adjacentEntry = join(basePath, 'session-scanner-service-entry.js') - if (!isPackaged && pathExists(adjacentEntry)) { - return adjacentEntry - } - return join(basePath, 'out', 'main', 'session-scanner-service-entry.js') + return resolveForkedChildEntryPath( + AI_VAULT_SERVICE_ENTRY_FILENAME, + appPath, + isPackaged, + pathExists + ) } export function resolveAiVaultServiceEntryPathWithoutApp( @@ -29,24 +26,14 @@ export function resolveAiVaultServiceEntryPathWithoutApp( resourcesPath: string | undefined, pathExists: (candidate: string) => boolean = existsSync ): string { - if (resourcesPath) { - const packagedEntry = join( - resourcesPath, - 'app.asar.unpacked', - 'out', - 'main', - 'session-scanner-service-entry.js' - ) - if (pathExists(packagedEntry)) { - return packagedEntry - } - } - return resolveAiVaultServiceEntryPath(cwd, false, pathExists) + return resolveForkedChildEntryPathWithoutApp( + AI_VAULT_SERVICE_ENTRY_FILENAME, + cwd, + resourcesPath, + pathExists + ) } export function getAiVaultServiceEntryPath(): string { - const app = loadElectronApp() - return app - ? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged()) - : resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath) + return getForkedChildEntryPath(AI_VAULT_SERVICE_ENTRY_FILENAME) } diff --git a/src/main/codex/codex-daemon-socket-path-guard.ts b/src/main/codex/codex-daemon-socket-path-guard.ts index 98b7b87e0cf..10c74fee0a8 100644 --- a/src/main/codex/codex-daemon-socket-path-guard.ts +++ b/src/main/codex/codex-daemon-socket-path-guard.ts @@ -1,6 +1,6 @@ import { realpathSync } from 'node:fs' import { parseWslUncPath } from '../../shared/wsl-paths' -import { unixSocketPathByteLimit } from '../../shared/unix-socket-path-limit' +import { unixSocketPathFits } from '../../shared/unix-socket-path-limit' import { upsertTableSettingsInContent } from './codex-config-settings-upsert' import { createTomlLineScanState, @@ -46,8 +46,7 @@ export function codexDaemonSocketPathExceedsLimit( ): boolean { // Why: WSL homes run Linux Codex; Windows Codex's uds_windows also uses a 108-byte sun_path. const os = platform === 'darwin' && !parseWslUncPath(homePath) ? 'darwin' : 'linux' - const socketPath = codexDaemonSocketPath(homePath, platform) - return Buffer.byteLength(socketPath, 'utf8') > unixSocketPathByteLimit(os) + return !unixSocketPathFits(codexDaemonSocketPath(homePath, platform), os) } const unguardableHomesWarned = new Set() diff --git a/src/main/daemon/daemon-cgroup-scope.test.ts b/src/main/daemon/daemon-cgroup-scope.test.ts index 0261f85bb9b..bd75bb0b775 100644 --- a/src/main/daemon/daemon-cgroup-scope.test.ts +++ b/src/main/daemon/daemon-cgroup-scope.test.ts @@ -71,33 +71,44 @@ afterEach(() => { describe('isDurableDaemonScopeSupported', () => { it('is false on non-Linux platforms regardless of environment', () => { - expect(isDurableDaemonScopeSupported({ XDG_RUNTIME_DIR: '/run/user/1000' }, 'darwin')).toBe( - false - ) - expect(isDurableDaemonScopeSupported({ XDG_RUNTIME_DIR: '/run/user/1000' }, 'win32')).toBe( - false - ) + expect( + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: '/run/user/1000' }, + platform: 'darwin' + }) + ).toBe(false) + expect( + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: '/run/user/1000' }, + platform: 'win32' + }) + ).toBe(false) }) it('is false when not booted under systemd, even with a reachable bus and a working binary', () => { const perUidDir = fakeRuntimeDirWithBus() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: perUidDir }, - 'linux', - perUidDir, - '/definitely/not/systemd-boot', - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: '/definitely/not/systemd-boot', + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) }) it('is false when there is no runtime dir to resolve at all', () => { expect( - isDurableDaemonScopeSupported({}, 'linux', null, fakeSystemdBootPath(), () => ({ - code: 0, - timedOut: false - })) + isDurableDaemonScopeSupported({ + env: {}, + platform: 'linux', + canonicalRuntimeDir: null, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) }) @@ -107,13 +118,14 @@ describe('isDurableDaemonScopeSupported', () => { // Neither fixture has a `bus` socket written — the probe must fail closed regardless of // which path it looks at first. expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: envDir }, - 'linux', - canonical, - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: envDir }, + platform: 'linux', + canonicalRuntimeDir: canonical, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) }) @@ -121,22 +133,24 @@ describe('isDurableDaemonScopeSupported', () => { const perUidDir = fakeRuntimeDirWithBus() const bootPath = fakeSystemdBootPath() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: perUidDir }, - 'linux', - perUidDir, - bootPath, - () => ({ code: 1, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: bootPath, + runVersionProbe: () => ({ code: 1, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: perUidDir }, - 'linux', - perUidDir, - bootPath, - () => ({ code: null, timedOut: true }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: bootPath, + runVersionProbe: () => ({ code: null, timedOut: true }), + outlivesCaller: () => true + }) ).toBe(false) }) @@ -148,26 +162,28 @@ describe('isDurableDaemonScopeSupported', () => { const hardenedOverrideDir = fakeRuntimeDirWithoutBus() const realPerUidDir = fakeRuntimeDirWithBus() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: hardenedOverrideDir }, - 'linux', - realPerUidDir, - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: hardenedOverrideDir }, + platform: 'linux', + canonicalRuntimeDir: realPerUidDir, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(true) }) it('is true when the caller env XDG_RUNTIME_DIR already points at the correct, reachable per-UID bus', () => { const perUidDir = fakeRuntimeDirWithBus() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: perUidDir }, - 'linux', - perUidDir, - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(true) }) @@ -177,13 +193,14 @@ describe('isDurableDaemonScopeSupported', () => { const canonicalWithoutBus = fakeRuntimeDirWithoutBus() const envDirWithBus = fakeRuntimeDirWithBus() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: envDirWithBus }, - 'linux', - canonicalWithoutBus, - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: envDirWithBus }, + platform: 'linux', + canonicalRuntimeDir: canonicalWithoutBus, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(true) }) }) @@ -373,31 +390,89 @@ describe('legacy daemon scope migration', () => { it('migrates only a proven legacy scope and fails closed when systemd rejects it', () => { const runtimeDir = fakeRuntimeDirWithBus() const runMigration = vi.fn(() => ({ code: 0, timedOut: false })) - const migrated = migrateLegacyDaemonScope( - 321, - 'new-nonce', - { XDG_RUNTIME_DIR: runtimeDir }, - 'linux', - runtimeDir, - () => ({ unit: 'app-orca-1420296.scope', pids: [321, 400] }), - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }), - runMigration - ) + const migrated = migrateLegacyDaemonScope(321, 'new-nonce', { + env: { XDG_RUNTIME_DIR: runtimeDir }, + platform: 'linux', + canonicalRuntimeDir: runtimeDir, + readProcesses: () => ({ unit: 'app-orca-1420296.scope', pids: [321, 400] }), + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + runMigration: runMigration, + outlivesCaller: () => true + }) expect(migrated).toBe(true) expect(runMigration).toHaveBeenCalledOnce() expect( - migrateLegacyDaemonScope( - 321, - 'new-nonce', - { XDG_RUNTIME_DIR: runtimeDir }, - 'linux', - runtimeDir, - () => ({ unit: 'app-orca-1420296.scope', pids: [321, 400] }), - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }), - () => ({ code: 1, timedOut: false }) - ) + migrateLegacyDaemonScope(321, 'new-nonce', { + env: { XDG_RUNTIME_DIR: runtimeDir }, + platform: 'linux', + canonicalRuntimeDir: runtimeDir, + readProcesses: () => ({ unit: 'app-orca-1420296.scope', pids: [321, 400] }), + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + runMigration: () => ({ code: 1, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) }) }) + +describe('durable scope launch environment and lifetime (#25580, #24201)', () => { + it("drops Chromium's disabled: bus marker so systemd-run reaches the user manager", () => { + const perUidDir = fakeRuntimeDirWithBus() + const result = buildDurableDaemonScopeCommand( + '/usr/bin/node', + [], + 'n', + { PATH: '/bin', DBUS_SESSION_BUS_ADDRESS: 'disabled:' }, + perUidDir + ) + expect(result.env).not.toHaveProperty('DBUS_SESSION_BUS_ADDRESS') + expect(result.env.XDG_RUNTIME_DIR).toBe(perUidDir) + }) + + it('keeps a real session bus address', () => { + const result = buildDurableDaemonScopeCommand( + '/usr/bin/node', + [], + 'n', + { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' }, + null + ) + expect(result.env.DBUS_SESSION_BUS_ADDRESS).toBe('unix:path=/run/user/1000/bus') + }) + + it('refuses the scope when the user manager would not outlive the caller', () => { + const perUidDir = fakeRuntimeDirWithBus() + const versionProbe = vi.fn(() => ({ code: 0, timedOut: false })) + expect( + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: versionProbe, + outlivesCaller: () => false + }) + ).toBe(false) + expect(versionProbe).not.toHaveBeenCalled() + }) + + it('never migrates an adopted daemon into a manager that dies at logout', () => { + const runtimeDir = fakeRuntimeDirWithBus() + const runMigration = vi.fn(() => ({ code: 0, timedOut: false })) + expect( + migrateLegacyDaemonScope(321, 'new-nonce', { + env: { XDG_RUNTIME_DIR: runtimeDir }, + platform: 'linux', + canonicalRuntimeDir: runtimeDir, + readProcesses: () => ({ unit: 'app-orca-1.scope', pids: [321] }), + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + runMigration: runMigration, + outlivesCaller: () => false + }) + ).toBe(false) + expect(runMigration).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/daemon-cgroup-scope.ts b/src/main/daemon/daemon-cgroup-scope.ts index 2f93a711eb8..1ea52e2a677 100644 --- a/src/main/daemon/daemon-cgroup-scope.ts +++ b/src/main/daemon/daemon-cgroup-scope.ts @@ -12,13 +12,16 @@ * manager and execs the command into it, so the daemon's cgroup becomes a *sibling* of the * unit's that no unit-scoped kill reaches; `--collect` drops the unit once it exits. * - * That needs systemd as PID 1 and a reachable `--user` manager (a login session, or - * `loginctl enable-linger ` for a service account). Everywhere else keeps the direct-fork + * That needs systemd as PID 1, a reachable `--user` manager, and one that outlives the caller: + * `loginctl enable-linger `, unless the caller already runs inside that manager (see + * daemon-user-manager-lifetime.ts). Everywhere else keeps the direct-fork * launch, so this module fails closed to "not supported" rather than guessing. */ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' +import { cgroupPathFromProcLine, userManagerOutlivesCaller } from './daemon-user-manager-lifetime' const SYSTEMD_RUN_BINARY = 'systemd-run' const UNIT_NAME_PREFIX = 'orca-daemon-' @@ -105,22 +108,35 @@ function runSystemdRunVersionProbe( binary: string, timeoutMs: number ): Pick { - const { code, timedOut } = runProcessSync({ - program: binary, - args: ['--version'], - stdio: 'ignore', - timeoutMs - }) - return { code, timedOut } + return runProcessSync({ program: binary, args: ['--version'], stdio: 'ignore', timeoutMs }) } -export function isDurableDaemonScopeSupported( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, - canonicalRuntimeDir: string | null = CANONICAL_USER_RUNTIME_DIR, - systemdBootPath: string = SYSTEMD_BOOT_PATH, - runVersionProbe: SystemdRunVersionProbe = runSystemdRunVersionProbe -): boolean { +/** Test seams shared by the scope probe and the legacy migration; each defaults to the real host. */ +type ScopeProbeDeps = { + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + canonicalRuntimeDir?: string | null + systemdBootPath?: string + runVersionProbe?: SystemdRunVersionProbe + outlivesCaller?: () => boolean +} + +// Why not `??`: an explicit null means "no canonical dir", not "use the real one". +function resolveCanonicalRuntimeDir(deps: ScopeProbeDeps): string | null { + return deps.canonicalRuntimeDir !== undefined + ? deps.canonicalRuntimeDir + : CANONICAL_USER_RUNTIME_DIR +} + +export function isDurableDaemonScopeSupported(deps: ScopeProbeDeps = {}): boolean { + const { + env = process.env, + platform = process.platform, + systemdBootPath = SYSTEMD_BOOT_PATH, + runVersionProbe = runSystemdRunVersionProbe, + outlivesCaller = userManagerOutlivesCaller + } = deps + const canonicalRuntimeDir = resolveCanonicalRuntimeDir(deps) if (platform !== 'linux') { return false } @@ -134,6 +150,10 @@ export function isDurableDaemonScopeSupported( // systemd-run --user would just fail to connect. return false } + if (!outlivesCaller()) { + // A bus only proves a login session is open now; without linger the scope dies at logout. + return false + } try { const probe = runVersionProbe(SYSTEMD_RUN_BINARY, SYSTEMD_RUN_PROBE_TIMEOUT_MS) // A non-zero exit is data here rather than a throw, and a timeout kill leaves an exit behind @@ -159,11 +179,8 @@ function cgroupPathFromProc(contents: string): string | null { const paths: { path: string; priority: number }[] = [] for (const line of contents.split('\n')) { const fields = line.split(':') - if (fields.length < 3) { - continue - } - const path = fields.slice(2).join(':').trim() - if (path) { + const path = cgroupPathFromProcLine(line) + if (fields.length >= 3 && path) { const controllers = fields[1]?.split(',') ?? [] const priority = fields[0] === '0' ? 0 : controllers.includes('name=systemd') ? 1 : 2 paths.push({ path, priority }) @@ -247,31 +264,25 @@ export function buildLegacyScopeMigrationCommand( export function migrateLegacyDaemonScope( pid: number, launchNonce: string, - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, - canonicalRuntimeDir: string | null = CANONICAL_USER_RUNTIME_DIR, - readProcesses: typeof readLegacyDaemonScopeProcesses = readLegacyDaemonScopeProcesses, - systemdBootPath: string = SYSTEMD_BOOT_PATH, - runVersionProbe: SystemdRunVersionProbe = runSystemdRunVersionProbe, - runMigration: SystemdScopeMigrationRunner = (command, timeoutMs) => - runProcessSync({ - program: command.command, - args: command.args, - env: command.env, - timeoutMs, - stdio: 'ignore' - }) + deps: ScopeProbeDeps & { + readProcesses?: typeof readLegacyDaemonScopeProcesses + runMigration?: SystemdScopeMigrationRunner + } = {} ): boolean { - if ( - platform !== 'linux' || - !isDurableDaemonScopeSupported( - env, - platform, - canonicalRuntimeDir, - systemdBootPath, - runVersionProbe - ) - ) { + const { + env = process.env, + platform = process.platform, + readProcesses = readLegacyDaemonScopeProcesses, + runMigration = (command, timeoutMs) => + runProcessSync({ + program: command.command, + args: command.args, + env: command.env, + timeoutMs, + stdio: 'ignore' + }) + } = deps + if (platform !== 'linux' || !isDurableDaemonScopeSupported(deps)) { return false } const legacy = readProcesses(pid) @@ -280,7 +291,12 @@ export function migrateLegacyDaemonScope( } try { const result = runMigration( - buildLegacyScopeMigrationCommand(launchNonce, legacy.pids, env, canonicalRuntimeDir), + buildLegacyScopeMigrationCommand( + launchNonce, + legacy.pids, + env, + resolveCanonicalRuntimeDir(deps) + ), SYSTEMD_SCOPE_MIGRATION_TIMEOUT_MS ) return result.code === 0 && !result.timedOut @@ -302,6 +318,11 @@ export function buildDurableDaemonScopeCommand( canonicalRuntimeDir: string | null = CANONICAL_USER_RUNTIME_DIR ): DurableDaemonScopeCommand { const runtimeDir = resolveUserRuntimeDir(env, canonicalRuntimeDir) + const scopeEnv: NodeJS.ProcessEnv = runtimeDir + ? { ...env, XDG_RUNTIME_DIR: runtimeDir } + : { ...env } + // sd-bus prefers this over XDG_RUNTIME_DIR and refuses Chromium's marker (#25580). + removeChromiumDisabledSessionBus(scopeEnv) return { command: SYSTEMD_RUN_BINARY, args: [ @@ -318,7 +339,7 @@ export function buildDurableDaemonScopeCommand( // Explicit, not inherited: the daemon must land in the same user manager the resolution // above just confirmed is reachable, regardless of what this spread `env`'s own // `XDG_RUNTIME_DIR` says (see `resolveUserRuntimeDir` for why that value can be wrong). - env: runtimeDir ? { ...env, XDG_RUNTIME_DIR: runtimeDir } : { ...env } + env: scopeEnv } } diff --git a/src/main/daemon/daemon-client-connections.ts b/src/main/daemon/daemon-client-connections.ts index 7eedef1285b..ce16616dc04 100644 --- a/src/main/daemon/daemon-client-connections.ts +++ b/src/main/daemon/daemon-client-connections.ts @@ -1,9 +1,10 @@ import type { Socket } from 'node:net' import { StringDecoder } from 'node:string_decoder' +import { isDaemonRequestFrame, isHelloFrame } from './daemon-client-frame-guards' import type { DaemonFileLog } from './daemon-file-log' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { createNdjsonParser, encodeNdjson } from './ndjson' -import type { DaemonRequest, HelloMessage } from './types' +import type { DaemonRequest } from './types' // Idle time before the first probe. How long the close then takes is the OS's probe schedule, not // ours, which is why the producer-stall watchdog never waits on it. @@ -108,13 +109,13 @@ export class DaemonClientConnections { } private handleFirstMessage(socket: Socket, message: unknown): void { - const hello = message as HelloMessage - if (hello.type !== 'hello') { + if (!isHelloFrame(message)) { this.options.log.log('client-hello-rejected', { reason: 'expected-hello' }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) socket.destroy() return } + const hello = message if (hello.version !== this.options.protocolVersion) { this.options.log.log('client-hello-rejected', { reason: 'protocol-mismatch', @@ -175,7 +176,7 @@ export class DaemonClientConnections { this.options.onAuthenticatedPair() return } - // Parsed wire data is not made safe by the HelloMessage assertion above. + // The guard checks only that role is a string; anything else was answered above. socket.destroy() } @@ -201,7 +202,15 @@ export class DaemonClientConnections { private setupControlParser(socket: Socket, clientId: string): void { const decoder = new StringDecoder('utf8') const parser = createNdjsonParser( - (message) => this.options.onControlRequest(socket, clientId, message as DaemonRequest), + (message) => { + if (isDaemonRequestFrame(message)) { + this.options.onControlRequest(socket, clientId, message) + return + } + // Unroutable without an id, so no reply is possible; drop only this connection. + this.options.log.log('client-frame-rejected', { clientId }) + socket.destroy() + }, () => {} ) socket.removeAllListeners('data') diff --git a/src/main/daemon/daemon-client-frame-guards.ts b/src/main/daemon/daemon-client-frame-guards.ts new file mode 100644 index 00000000000..6e07b79b2c4 --- /dev/null +++ b/src/main/daemon/daemon-client-frame-guards.ts @@ -0,0 +1,30 @@ +/** + * Shape checks for the frames a daemon client sends. JSON.parse yields anything — `null`, a + * number, an object missing `id` — and one unchecked property read on such a frame throws out of + * the socket handler and takes the daemon, and every PTY in it, down (#17841). + */ +import type { DaemonRequest, HelloMessage } from './types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Field types only; the caller still checks version, token and role values for its replies. */ +export function isHelloFrame(value: unknown): value is HelloMessage { + return ( + isRecord(value) && + value.type === 'hello' && + typeof value.version === 'number' && + typeof value.token === 'string' && + typeof value.clientId === 'string' && + typeof value.role === 'string' + ) +} + +/** + * The routing envelope only: a request needs a string `id` to be answered and a string `type` to + * be routed. Payload faults throw inside the router's try and come back as error replies. + */ +export function isDaemonRequestFrame(value: unknown): value is DaemonRequest { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} diff --git a/src/main/daemon/daemon-client-frame-validation.test.ts b/src/main/daemon/daemon-client-frame-validation.test.ts new file mode 100644 index 00000000000..2c6b025567e --- /dev/null +++ b/src/main/daemon/daemon-client-frame-validation.test.ts @@ -0,0 +1,144 @@ +import './mock-descendant-sweep' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { connect, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DaemonClient } from './client' +import { isDaemonRequestFrame, isHelloFrame } from './daemon-client-frame-guards' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import type { SubprocessHandle } from './session-subprocess-handle' +import { PROTOCOL_VERSION } from './types' + +function createMockSubprocess(): SubprocessHandle { + let notifyExit: ((code: number) => void) | null = null + const exit = (): void => notifyExit?.(0) + return { + pid: 44445, + getForegroundProcess: () => null, + write() {}, + resize() {}, + kill: exit, + terminateOwnedTree: () => 'unavailable' as const, + forceKill: exit, + signal() {}, + onData() {}, + onExit(callback) { + notifyExit = callback + }, + dispose() {} + } +} + +/** Writes raw lines and resolves with whatever came back once the daemon closes the socket. */ +function sendRawFrames(socketPath: string, lines: string[]): Promise { + return new Promise((resolve, reject) => { + const socket: Socket = connect({ path: socketPath }) + let received = '' + const timer = setTimeout(() => { + socket.destroy() + reject(new Error(`daemon kept the connection open; got: ${received}`)) + }, 5_000) + socket.on('data', (chunk) => { + received += chunk.toString('utf8') + }) + socket.on('error', () => {}) + socket.on('close', () => { + clearTimeout(timer) + resolve(received) + }) + socket.on('connect', () => socket.write(lines.map((line) => `${line}\n`).join(''))) + }) +} + +describe('frame guards', () => { + it.each([null, 7, 'hello', [], {}, { type: 'hello' }, { type: 'hello', version: '1' }])( + 'rejects %j as a hello', + (frame) => expect(isHelloFrame(frame)).toBe(false) + ) + + it('accepts a well-formed hello', () => { + expect( + isHelloFrame({ type: 'hello', version: 1, token: 't', clientId: 'c', role: 'control' }) + ).toBe(true) + }) + + it.each([null, {}, { id: 123, type: 'ping' }, { id: 'a' }, { type: 'ping' }])( + 'rejects %j as a request', + (frame) => expect(isDaemonRequestFrame(frame)).toBe(false) + ) +}) + +describe.skipIf(process.platform === 'win32')('malformed client frames (#17841)', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let client: DaemonClient | null = null + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'daemon-frames-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'test.token') + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + await client.request('createOrAttach', { sessionId: 'survivor', cols: 80, rows: 24 }) + }) + + afterEach(async () => { + client?.disconnect() + client = null + await server.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + function validHello(): string { + return JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: 'raw-client', + role: 'control' + }) + } + + async function expectDaemonStillServing(): Promise { + const listed = await client!.request<{ sessions: { sessionId: string; isAlive: boolean }[] }>( + 'listSessions', + undefined + ) + expect(listed.sessions).toContainEqual( + expect.objectContaining({ sessionId: 'survivor', isAlive: true }) + ) + } + + it.each(['null', '42', '"hello"', '[]', '{}', '{"type":"hello","version":"x"}'])( + 'refuses the pre-auth frame %s on that connection only', + async (frame) => { + const reply = await sendRawFrames(socketPath, [frame]) + expect(reply).toContain('Expected hello') + await expectDaemonStillServing() + } + ) + + it.each(['null', '{}', '{"type":"ping"}', '{"id":123,"type":"ping"}'])( + 'drops the authenticated connection that sends %s', + async (frame) => { + const reply = await sendRawFrames(socketPath, [validHello(), frame]) + expect(reply).toContain('"ok":true') + await expectDaemonStillServing() + } + ) + + it('still answers a well-formed request after a bad one on another connection', async () => { + await sendRawFrames(socketPath, [validHello(), '{"method":"x"}']) + await expect(client!.request('ping', undefined)).resolves.toBeDefined() + }) +}) diff --git a/src/main/daemon/daemon-client-socket-connect.ts b/src/main/daemon/daemon-client-socket-connect.ts index e6a2f615e17..1f05d57f8e5 100644 --- a/src/main/daemon/daemon-client-socket-connect.ts +++ b/src/main/daemon/daemon-client-socket-connect.ts @@ -1,8 +1,11 @@ import { connect, type Socket } from 'node:net' import { DaemonProtocolError } from './types' +import { ensureDaemonSocketDir } from './daemon-socket-endpoint-path' export function connectDaemonSocket(socketPath: string, timeoutMs: number): Promise { return new Promise((resolve, reject) => { + // Throws inside the executor, so an untrusted relocated dir rejects before the hello token is sent. + ensureDaemonSocketDir(socketPath) const socket = connect(socketPath) const cleanup = (): void => { clearTimeout(timer) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 04cf322b893..7778a5c95fa 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs' import { connect, type Socket } from 'node:net' import { encodeNdjson } from './ndjson' import { ptySpawnHealthPlatformCoverage } from './daemon-health-identity' +import { daemonSocketDirIsTrusted } from './daemon-socket-endpoint-path' import { PROTOCOL_VERSION, type HelloMessage, @@ -144,6 +145,10 @@ export function checkDaemonHealthWithCoverage( HEALTH_CHECK_TIMEOUT_MS ) + if (!daemonSocketDirIsTrusted(socketPath)) { + settle({ verdict: 'unreachable', coverage: fallbackCoverage }) + return + } sock = connect({ path: socketPath }) sock.on('error', onError) sock.on('connect', onConnect) @@ -272,6 +277,10 @@ export function getMacDaemonSystemResolverHealth( } const timer = setTimeout(() => settle('unknown'), RESOLVER_HEALTH_CHECK_TIMEOUT_MS) + if (!daemonSocketDirIsTrusted(socketPath)) { + settle('unknown') + return + } sock = connect({ path: socketPath }) sock.on('error', onError) sock.on('connect', onConnect) diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index a3b5d0d4252..a28aa222237 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -49,3 +49,33 @@ describe('daemon launch scope ownership', () => { ) }) }) + +describe('daemon launch environment', () => { + const originalBus = process.env.DBUS_SESSION_BUS_ADDRESS + afterEach(() => { + if (originalBus === undefined) { + delete process.env.DBUS_SESSION_BUS_ADDRESS + } else { + process.env.DBUS_SESSION_BUS_ADDRESS = originalBus + } + }) + + it.each([false, true])( + "never hands Chromium's disabled: bus marker to the daemon (scoped=%s)", + (scoped) => { + process.env.DBUS_SESSION_BUS_ADDRESS = 'disabled:' + spawnDaemonChildProcess(options, scoped) + const spec = (scoped ? spawn : fork).mock.calls[0]?.[0] + expect(spec.env).not.toHaveProperty('DBUS_SESSION_BUS_ADDRESS') + expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1') + } + ) + + it('keeps a real session bus address', () => { + process.env.DBUS_SESSION_BUS_ADDRESS = 'unix:path=/run/user/1000/bus' + spawnDaemonChildProcess(options, false) + expect(fork.mock.calls[0]?.[0].env.DBUS_SESSION_BUS_ADDRESS).toBe( + 'unix:path=/run/user/1000/bus' + ) + }) +}) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 1d069b70aa5..bc39d6b3ee6 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -1,6 +1,7 @@ import { forkProcess, type ForkSpec } from '../../shared/child-process/fork-process' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import { getAppEnvironment } from '../../shared/app-environment' +import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' import { buildDurableDaemonScopeCommand } from './daemon-cgroup-scope' import { daemonLogArgs } from './daemon-launch-paths' @@ -60,12 +61,14 @@ export function spawnDaemonChildProcess( const { forkEntryPath, relocatedExecPath, userDataPath, launchNonce } = options const scriptArgs = buildDaemonScriptArgs(options) // Why: run as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper. - const daemonEnv = { + const daemonEnv: NodeJS.ProcessEnv = { ...process.env, ELECTRON_RUN_AS_NODE: '1', // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath } + // Why: this env becomes the daemon's, and so every PTY's (#21119). + removeChromiumDisabledSessionBus(daemonEnv) // Why cwd: detached daemons outlive dev worktrees; userData keeps process.cwd() valid after a repo/worktree is deleted. // Why detached/stdio: detached+unref outlives Electron; stdout 'ignore' (else blocks exit), stderr 'pipe' captures startup crashes lost in v1.4.129-rc.1. const childOptions: Pick = { diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 1aa811c1d5d..87a9289a72a 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -24,6 +24,7 @@ import { } from './daemon-spawner' import { PROTOCOL_VERSION } from './types' import { prepareDaemonReplacement } from './daemon-replacement-preflight' +import { ensureDaemonSocketDir } from './daemon-socket-endpoint-path' // Why: the adapter decides a runtime resolver replacement, but the launcher completes it — and by // then the daemon has usually self-retired (dropping its last authenticated client is enough), so @@ -70,6 +71,9 @@ export function createOutOfProcessLauncher( // what makes a bare module-scoped slot safe — keep it that way or a concurrent launch can steal it. const attributedReason = attributedReplaceReason attributedReplaceReason = null + // Why first: adoption, the preflight probes and health checks all talk to this endpoint, and a + // relocated one is only safe to talk to once its directories are proven private and ours. + ensureDaemonSocketDir(socketPath) let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index a917891f109..02be19b439e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -131,8 +131,14 @@ export class DaemonServer { }, onLastAuthenticatedClientDisconnected: () => this.lifecycle.onLastAuthenticatedClientDisconnected(), - onControlRequest: (socket, clientId, request) => - void this.handleRequest(socket, clientId, request), + onControlRequest: (socket, clientId, request) => { + // Why: a rejection here would be unhandled, and the daemon treats that as fatal. + this.handleRequest(socket, clientId, request).catch((error: unknown) => { + this.log.log('client-request-failed', { + error: error instanceof Error ? error.message : String(error) + }) + }) + }, onControlReplaced: (clientId) => { this.preparations.cancelForClient(clientId) this.historySeedTransfers.clearOwner(clientId) diff --git a/src/main/daemon/daemon-socket-endpoint-path.test.ts b/src/main/daemon/daemon-socket-endpoint-path.test.ts new file mode 100644 index 00000000000..77e7aa19be9 --- /dev/null +++ b/src/main/daemon/daemon-socket-endpoint-path.test.ts @@ -0,0 +1,160 @@ +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { createServer, type Server, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { DaemonClient } from './client' +import { checkDaemonHealthWithCoverage } from './daemon-health' +import { createOutOfProcessLauncher } from './daemon-out-of-process-launcher' +import { unixSocketPathFits } from '../../shared/unix-socket-path-limit' +import { getDaemonSocketBindPath } from './daemon-endpoint-ownership' +import { getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' +import { + ensureDaemonSocketDir, + resolveDaemonUnixSocketPath, + shortDaemonSocketDir +} from './daemon-socket-endpoint-path' + +const SOCKET_NAME = 'daemon-v99.sock' +const longRuntimeDir = (bytes: number): string => `/home/${'u'.repeat(bytes - 20)}/orca/daemon` + +describe('resolveDaemonUnixSocketPath (#17840)', () => { + it.each(['linux', 'darwin'] as const)( + 'keeps the userData endpoint for a short root on %s', + (os) => { + expect( + resolveDaemonUnixSocketPath('/home/me/.config/orca/daemon', SOCKET_NAME, os, 1000) + ).toBe(`/home/me/.config/orca/daemon/${SOCKET_NAME}`) + } + ) + + it.each(['linux', 'darwin'] as const)( + 'moves a 120-byte root to the per-uid short dir on %s, and the bind name fits too', + (os) => { + const runtimeDir = longRuntimeDir(120) + const resolved = resolveDaemonUnixSocketPath(runtimeDir, SOCKET_NAME, os, 1000) + expect(resolved).toBe(`${shortDaemonSocketDir(runtimeDir, 1000)}/${SOCKET_NAME}`) + expect(unixSocketPathFits(resolved, os)).toBe(true) + expect(unixSocketPathFits(getDaemonSocketBindPath(resolved), os)).toBe(true) + } + ) + + it('relocates on darwin at a length linux still accepts', () => { + // 104-byte darwin sun_path vs 108 on linux. + const runtimeDir = `/h/${'x'.repeat(105 - 3 - 1 - SOCKET_NAME.length)}` + expect(resolveDaemonUnixSocketPath(runtimeDir, SOCKET_NAME, 'linux', 1)).toBe( + `${runtimeDir}/${SOCKET_NAME}` + ) + expect(resolveDaemonUnixSocketPath(runtimeDir, SOCKET_NAME, 'darwin', 1)).not.toContain( + runtimeDir + ) + }) + + it('gives distinct data roots distinct short dirs', () => { + expect(shortDaemonSocketDir(longRuntimeDir(120), 1)).not.toBe( + shortDaemonSocketDir(`${longRuntimeDir(120)}x`, 1) + ) + }) +}) + +describe.skipIf(process.platform === 'win32')('relocated endpoint binds (#17840)', () => { + const uid = process.getuid!() + let base = '' + let runtimeDir = '' + + afterEach(() => { + rmSync(shortDaemonSocketDir(runtimeDir, uid), { recursive: true, force: true }) + rmSync(base, { recursive: true, force: true }) + try { + rmdirSync(dirname(shortDaemonSocketDir(runtimeDir, uid))) + } catch { + // Not empty: another Orca on this machine uses it. + } + }) + + it('creates a private dir the over-long data root can bind its socket in', async () => { + base = mkdtempSync(join(tmpdir(), 'orca-long-')) + runtimeDir = join(base, 'x'.repeat(130)) + mkdirSync(runtimeDir, { recursive: true }) + const socketPath = getDaemonSocketPath(runtimeDir, 99) + expect(socketPath.startsWith(runtimeDir)).toBe(false) + + ensureDaemonSocketDir(socketPath) + expect(lstatSync(shortDaemonSocketDir(runtimeDir, uid)).mode & 0o777).toBe(0o700) + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(getDaemonSocketBindPath(socketPath), resolve) + }) + await new Promise((resolve) => server.close(() => resolve())) + }) + + it('refuses a shared short dir rather than repairing it', () => { + base = mkdtempSync(join(tmpdir(), 'orca-long-')) + runtimeDir = join(base, 'x'.repeat(130)) + const socketPath = getDaemonSocketPath(runtimeDir, 99) + ensureDaemonSocketDir(socketPath) + chmodSync(shortDaemonSocketDir(runtimeDir, uid), 0o777) + expect(() => ensureDaemonSocketDir(socketPath)).toThrow(/not a private directory/) + }) + it('never connects to a listener planted in a shared relocated dir', async () => { + base = mkdtempSync(join(tmpdir(), 'orca-long-')) + runtimeDir = join(base, 'x'.repeat(130)) + mkdirSync(runtimeDir, { recursive: true }) + const socketPath = getDaemonSocketPath(runtimeDir, 99) + const tokenPath = getDaemonTokenPath(runtimeDir, 99) + writeFileSync(tokenPath, 'secret-token') + // Stands in for another user's pre-created, traversable dir; mode is what the guard sees. + mkdirSync(shortDaemonSocketDir(runtimeDir, uid), { recursive: true, mode: 0o700 }) + chmodSync(shortDaemonSocketDir(runtimeDir, uid), 0o777) + const received: string[] = [] + const accepted: Socket[] = [] + const fake: Server = createServer((socket) => { + accepted.push(socket) + socket.on('data', (chunk) => { + received.push(chunk.toString()) + socket.write(`${JSON.stringify({ type: 'hello', ok: true })}\n`) + }) + }) + await new Promise((resolve, reject) => { + fake.once('error', reject) + fake.listen(socketPath, resolve) + }) + setAppEnvironment({ + getPath: () => base, + getAppPath: () => base, + getVersion: () => '0.0.0', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + try { + await expect(createOutOfProcessLauncher(runtimeDir)(socketPath, tokenPath)).rejects.toThrow( + /not a private directory/ + ) + await expect(new DaemonClient({ socketPath, tokenPath }).ensureConnected()).rejects.toThrow( + /not a private directory/ + ) + await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({ + verdict: 'unreachable' + }) + expect(accepted).toHaveLength(0) + expect(received).toEqual([]) + } finally { + for (const socket of accepted) { + socket.destroy() + } + await new Promise((resolve) => fake.close(() => resolve())) + } + }) +}) diff --git a/src/main/daemon/daemon-socket-endpoint-path.ts b/src/main/daemon/daemon-socket-endpoint-path.ts new file mode 100644 index 00000000000..8af2bad57f0 --- /dev/null +++ b/src/main/daemon/daemon-socket-endpoint-path.ts @@ -0,0 +1,81 @@ +/** + * Keeps the daemon's Unix socket inside `sockaddr_un.sun_path`. + * + * The endpoint normally lives at `/daemon/daemon-v.sock`. A long data root pushes + * that past the kernel cap, and the bind then fails or "succeeds" with no directory entry, so the + * daemon never comes up and terminals stop surviving restarts (#17840). Only then does the socket + * move to a fixed-length per-uid base; the token, pid record and history stay under userData. + * Windows binds named pipes, which have no such limit. + */ +import { createHash } from 'node:crypto' +import { lstatSync, mkdirSync } from 'node:fs' +import { posix } from 'node:path' +import { unixSocketPathFits } from '../../shared/unix-socket-path-limit' +import { hasErrorCode } from './daemon-process-inspection' +import { PRIVATE_DIR_MODE } from './daemon-private-file-modes' + +/** `/tmp` is the only POSIX directory whose length does not depend on the user. */ +const SHORT_DAEMON_SOCKET_DIR_PREFIX = '/tmp/.orca-daemon-' + +export function shortDaemonSocketDir(runtimeDir: string, uid: number): string { + const runtimeHash = createHash('sha256').update(runtimeDir).digest('hex').slice(0, 12) + return `${SHORT_DAEMON_SOCKET_DIR_PREFIX}${uid}/${runtimeHash}` +} + +export function resolveDaemonUnixSocketPath( + runtimeDir: string, + socketName: string, + platform: NodeJS.Platform = process.platform, + uid: number | undefined = process.getuid?.() +): string { + const preferred = posix.join(runtimeDir, socketName) + if ( + uid === undefined || + unixSocketPathFits(preferred, platform === 'darwin' ? 'darwin' : 'linux') + ) { + return preferred + } + return posix.join(shortDaemonSocketDir(runtimeDir, uid), socketName) +} + +/** Validate, never repair: a dir planted by another user or a symlink is refused, not chmod'ed. */ +function ensureOwnedPrivateDir(dir: string, uid: number): void { + try { + mkdirSync(dir, { mode: PRIVATE_DIR_MODE }) + } catch (error) { + if (!hasErrorCode(error, 'EEXIST')) { + throw error + } + } + const entry = lstatSync(dir) + if (!entry.isDirectory() || entry.uid !== uid || (entry.mode & 0o077) !== 0) { + throw new Error(`Daemon socket directory ${dir} is not a private directory owned by this user`) + } +} + +/** + * Creates and validates the relocated socket's directories; a no-op for the default endpoint. + * Why before every connect, not just the bind: the `/tmp` path is predictable, so a listener another + * user planted there would otherwise receive the hello token before any bind-time check ran. + */ +export function ensureDaemonSocketDir( + socketPath: string, + uid: number | undefined = process.getuid?.() +): void { + if (uid === undefined || !socketPath.startsWith(`${SHORT_DAEMON_SOCKET_DIR_PREFIX}${uid}/`)) { + return + } + const runtimeDir = posix.dirname(socketPath) + ensureOwnedPrivateDir(posix.dirname(runtimeDir), uid) + ensureOwnedPrivateDir(runtimeDir, uid) +} + +/** Non-throwing form for probes that must fail closed instead of connecting. */ +export function daemonSocketDirIsTrusted(socketPath: string): boolean { + try { + ensureDaemonSocketDir(socketPath) + return true + } catch { + return false + } +} diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 717bef90d7a..960ef7996e0 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -10,6 +10,7 @@ import { } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' +import { resolveDaemonUnixSocketPath } from './daemon-socket-endpoint-path' import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' export type DaemonConnectionInfo = { @@ -136,7 +137,7 @@ export function getDaemonSocketPath( const suffix = createHash('sha256').update(runtimeDir).digest('hex').slice(0, 12) return `\\\\?\\pipe\\orca-terminal-host-v${protocolVersion}-${suffix}` } - return join(runtimeDir, `daemon-v${protocolVersion}.sock`) + return resolveDaemonUnixSocketPath(runtimeDir, `daemon-v${protocolVersion}.sock`) } export function getDaemonTokenPath(runtimeDir: string, protocolVersion = PROTOCOL_VERSION): string { diff --git a/src/main/daemon/daemon-user-manager-lifetime.test.ts b/src/main/daemon/daemon-user-manager-lifetime.test.ts new file mode 100644 index 00000000000..d8667ea9009 --- /dev/null +++ b/src/main/daemon/daemon-user-manager-lifetime.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + cgroupIsInsideUserManager, + userManagerOutlivesCaller +} from './daemon-user-manager-lifetime' + +const dirs: string[] = [] +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function cgroupFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-cgroup-')) + dirs.push(dir) + const path = join(dir, 'cgroup') + writeFileSync(path, contents) + return path +} + +const SYSTEM_SERVICE = '0::/system.slice/orca-serve.service\n' +const SSH_SESSION = '0::/user.slice/user-1000.slice/session-4.scope\n' +const DESKTOP_APP = '0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-orca-12.scope\n' + +describe('cgroupIsInsideUserManager', () => { + it.each([ + [DESKTOP_APP, true], + ['1:name=systemd:/user.slice/user-1000.slice/user@1000.service/app.slice/x.scope\n', true], + [SYSTEM_SERVICE, false], + [SSH_SESSION, false], + ['0::/user.slice/user-1000.slice/user@10000.service/app.slice/x.scope\n', false] + ])('%s -> %s', (contents, expected) => { + expect(cgroupIsInsideUserManager(contents, 1000)).toBe(expected) + }) +}) + +describe('userManagerOutlivesCaller', () => { + it('trusts a caller already inside the user manager without asking logind', () => { + const runLingerProbe = vi.fn() + expect( + userManagerOutlivesCaller({ uid: 1000, cgroupPath: cgroupFile(DESKTOP_APP), runLingerProbe }) + ).toBe(true) + expect(runLingerProbe).not.toHaveBeenCalled() + }) + + it.each([SYSTEM_SERVICE, SSH_SESSION])('requires linger for a caller in %s', (contents) => { + const log = vi.fn() + const runLingerProbe = vi.fn(() => ({ code: 0, timedOut: false, stdout: 'yes\n' })) + expect( + userManagerOutlivesCaller({ + uid: 1000, + cgroupPath: cgroupFile(contents), + runLingerProbe, + log + }) + ).toBe(true) + expect(runLingerProbe).toHaveBeenCalledWith(1000, 2_000) + expect(log).not.toHaveBeenCalled() + }) + + it.each([ + ['linger off', () => ({ code: 0, timedOut: false, stdout: 'no\n' })], + ['unknown user', () => ({ code: 1, timedOut: false, stdout: '' })], + ['timed out', () => ({ code: null, timedOut: true, stdout: 'yes\n' })], + [ + 'loginctl missing', + () => { + throw new Error('spawn loginctl ENOENT') + } + ] + ])('fails closed and says why when %s', (_label, runLingerProbe) => { + const log = vi.fn() + expect( + userManagerOutlivesCaller({ + uid: 1000, + cgroupPath: cgroupFile(SYSTEM_SERVICE), + runLingerProbe, + log + }) + ).toBe(false) + expect(log).toHaveBeenCalledWith( + expect.stringContaining('daemon-scope-unavailable: linger off') + ) + }) + + it('falls back to linger when cgroup membership is unreadable', () => { + expect( + userManagerOutlivesCaller({ + uid: 1000, + cgroupPath: '/definitely/missing/cgroup', + runLingerProbe: () => ({ code: 0, timedOut: false, stdout: 'yes' }) + }) + ).toBe(true) + }) + + it('is false without a uid', () => { + expect(userManagerOutlivesCaller({ uid: null, log: () => {} })).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-user-manager-lifetime.ts b/src/main/daemon/daemon-user-manager-lifetime.ts new file mode 100644 index 00000000000..a2942cdf0e7 --- /dev/null +++ b/src/main/daemon/daemon-user-manager-lifetime.ts @@ -0,0 +1,85 @@ +/** + * Whether a scope under the systemd `--user` manager lives at least as long as the caller. + * + * Without lingering, systemd stops `user@.service` — and every `orca-daemon-*.scope` in it — + * shortly after the user's last login session closes. A caller in a system unit or an SSH + * session's `session-N.scope` outlives that, so moving the daemon into the user manager would + * shorten its life (#24201). A caller already inside the user manager (a desktop app scope) + * dies with it anyway, so the scope costs it nothing. + */ +import { readFileSync } from 'node:fs' +import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' + +const LINGER_PROBE_TIMEOUT_MS = 2_000 + +export type LingerProbe = ( + uid: number, + timeoutMs: number +) => Pick + +function runLoginctlLingerProbe( + uid: number, + timeoutMs: number +): Pick { + return runProcessSync({ + program: 'loginctl', + args: ['show-user', String(uid), '-p', 'Linger', '--value'], + stdio: ['ignore', 'pipe', 'ignore'], + timeoutMs + }) +} + +/** The path field of one `/proc//cgroup` line (`id:controllers:path`); '' when absent. */ +export function cgroupPathFromProcLine(line: string): string { + return line.split(':').slice(2).join(':').trim() +} + +/** True when any of this process's cgroup paths sits inside the uid's user manager. */ +export function cgroupIsInsideUserManager(contents: string, uid: number): boolean { + const unit = `/user@${uid}.service` + return contents.split('\n').some((line) => { + const path = cgroupPathFromProcLine(line) + return path.endsWith(unit) || path.includes(`${unit}/`) + }) +} + +export type UserManagerLifetimeProbeOptions = { + uid?: number | null + cgroupPath?: string + runLingerProbe?: LingerProbe + log?: (message: string) => void +} + +/** Fails closed: an unreadable cgroup plus an unknown linger state means "not durable". */ +export function userManagerOutlivesCaller(options: UserManagerLifetimeProbeOptions = {}): boolean { + const uid = options.uid !== undefined ? options.uid : (process.getuid?.() ?? null) + if (uid === null) { + return false + } + try { + if ( + cgroupIsInsideUserManager( + readFileSync(options.cgroupPath ?? '/proc/self/cgroup', 'utf8'), + uid + ) + ) { + return true + } + } catch { + // Unreadable membership proves nothing; linger alone decides. + } + let lingering = false + try { + const probe = (options.runLingerProbe ?? runLoginctlLingerProbe)(uid, LINGER_PROBE_TIMEOUT_MS) + lingering = probe.code === 0 && !probe.timedOut && probe.stdout.trim() === 'yes' + } catch { + // loginctl missing or unstartable. + } + if (!lingering) { + ;(options.log ?? console.warn)( + '[daemon] daemon-scope-unavailable: linger off; the terminal daemon stays in this ' + + "process's cgroup. Run `loginctl enable-linger` so terminals survive a service restart." + ) + } + return lingering +} diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index b818c2efdaf..428178cf43a 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -4,6 +4,7 @@ import { restoreOrStripOverlayEnv } from '../../../shared/agent-overlay-env' import { delimiter } from 'node:path' import { dropInheritedOrcaFishHistory } from '../../fish-history-session' import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env' +import { removeChromiumDisabledSessionBus } from '../../pty/chromium-session-bus-env' import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env' import { stripPiProcessOwnerEnv } from '../../pty/pi-process-owner-env' import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env' @@ -199,6 +200,7 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record boolean = existsSync +): string { + // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the + // packaged entry must be forked from app.asar.unpacked. + const usesAsarArchive = isPackaged && appPath.includes('app.asar') + const basePath = usesAsarArchive ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const adjacentBuildEntry = join(basePath, entryFileName) + // Why asar and not isPackaged: orcad is a packaged non-Electron host whose app root holds + // orcad.js and its children side by side; only the asar layout nests them under out/main. + // electron-vite's unpackaged appPath is already out/main, so dev also lands here. + if (!usesAsarArchive && pathExists(adjacentBuildEntry)) { + return adjacentBuildEntry + } + return join(basePath, 'out', 'main', entryFileName) +} + +/** For ELECTRON_RUN_AS_NODE, which exposes resourcesPath but no app root. */ +export function resolveForkedChildEntryPathWithoutApp( + entryFileName: string, + cwd: string, + resourcesPath: string | undefined, + pathExists: (candidate: string) => boolean = existsSync +): string { + if (resourcesPath) { + const packagedEntry = join(resourcesPath, 'app.asar.unpacked', 'out', 'main', entryFileName) + if (pathExists(packagedEntry)) { + return packagedEntry + } + } + return resolveForkedChildEntryPath(entryFileName, cwd, false, pathExists) +} + +/** The current process's path for a forked child's built entry. */ +export function getForkedChildEntryPath(entryFileName: string): string { + // Why the port: hasAppEnvironment() gives the same "no app root here" answer as electron.app. + if (hasAppEnvironment()) { + const app = getAppEnvironment() + return resolveForkedChildEntryPath(entryFileName, app.getAppPath(), app.isPackaged()) + } + return resolveForkedChildEntryPathWithoutApp(entryFileName, process.cwd(), process.resourcesPath) +} diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index d056727fdb7..63299bd99d6 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -8,6 +8,7 @@ import type { AppIdentity } from '../../shared/app-identity' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import { relaunchApp } from '../app-relaunch' +import { quitProcess } from '../startup/process-quit-request' import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailableAsync } from '../pwsh' @@ -312,7 +313,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp await runBeforeRelaunchCleanup(options.onBeforeRelaunch) setTimeout(() => { relaunchApp('admin-restart') - app.quit() + quitProcess() }, 150) }) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 694841e544e..1bb41ce63fa 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -1,6 +1,7 @@ -import { app, ipcMain, type WebContents } from 'electron' +import { ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' import { relaunchApp, type AppRelaunchReason } from '../app-relaunch' +import { quitProcess } from '../startup/process-quit-request' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -149,7 +150,7 @@ function scheduleProfileRelaunch(reason: ProfileRelaunchReason, sender: WebConte // Why: app.quit() (not app.exit) so before-quit/will-quit still run — // renderer scrollback capture, PTY kill, stats flush, and daemon final // checkpoints must not be skipped on a profile switch. - app.quit() + quitProcess() }, 150) } diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index 4633739fe15..79fb16e166b 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -1,8 +1,11 @@ import { existsSync } from 'node:fs' -import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { join } from 'node:path' +import { + getForkedChildEntryPath, + resolveForkedChildEntryPath, + resolveForkedChildEntryPathWithoutApp +} from '../forked-child-entry-path' -type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } +const WATCHER_ENTRY_FILENAME = 'parcel-watcher-process-entry.js' export function watcherProcessEntryExists(entryPath: string): boolean { if (existsSync(entryPath)) { @@ -12,32 +15,12 @@ export function watcherProcessEntryExists(entryPath: string): boolean { return false } -// Why the port and not require('electron'): this module is reachable from plain-Node -// fork entries, where the literal text require("electron") fails the build guard even -// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. -function loadElectronApp(): ElectronAppPath | null { - return hasAppEnvironment() ? getAppEnvironment() : null -} - export function resolveWatcherProcessEntryPath( appPath: string, isPackaged: boolean, pathExists: (candidate: string) => boolean = existsSync ): string { - // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the - // packaged entry must be forked from app.asar.unpacked. - const usesAsarArchive = isPackaged && appPath.includes('app.asar') - const basePath = usesAsarArchive ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath - const adjacentBuildEntry = join(basePath, 'parcel-watcher-process-entry.js') - // Why: electron-vite's unpackaged appPath is already out/main. Appending - // out/main again silently disables crash isolation in dev and E2E builds. - // Why asar and not isPackaged: orcad is a packaged non-Electron host whose app root - // holds orcad.js and this child side by side. Only the asar layout nests it under - // out/main, and only there is the adjacent probe guaranteed to miss. - if (!usesAsarArchive && pathExists(adjacentBuildEntry)) { - return adjacentBuildEntry - } - return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') + return resolveForkedChildEntryPath(WATCHER_ENTRY_FILENAME, appPath, isPackaged, pathExists) } export function resolveWatcherProcessEntryPathWithoutApp( @@ -45,27 +28,14 @@ export function resolveWatcherProcessEntryPathWithoutApp( resourcesPath: string | undefined, pathExists: (candidate: string) => boolean = existsSync ): string { - if (resourcesPath) { - const packagedEntry = join( - resourcesPath, - 'app.asar.unpacked', - 'out', - 'main', - 'parcel-watcher-process-entry.js' - ) - // Why: ELECTRON_RUN_AS_NODE exposes resourcesPath but not electron.app. - // Prefer the unpacked packaged entry without breaking dev Node fallbacks. - if (pathExists(packagedEntry)) { - return packagedEntry - } - } - return resolveWatcherProcessEntryPath(cwd, false, pathExists) + return resolveForkedChildEntryPathWithoutApp( + WATCHER_ENTRY_FILENAME, + cwd, + resourcesPath, + pathExists + ) } export function getWatcherProcessEntryPath(): string { - const app = loadElectronApp() - if (app) { - return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged()) - } - return resolveWatcherProcessEntryPathWithoutApp(process.cwd(), process.resourcesPath) + return getForkedChildEntryPath(WATCHER_ENTRY_FILENAME) } diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index cf61b71c9e5..ee3e77fd505 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { LocalPtyProvider } from '../providers/local-pty-provider' +import { SshPlainShellPtyProvider } from '../providers/ssh-plain-shell-pty-provider' import { registerPtyHandlers, registerSshPtyProvider, @@ -207,6 +208,28 @@ describe('registerPtyHandlers', () => { } }) + it('answers unknown for an earlier relay PTY once the target is in plain SSH mode', async () => { + // Why: rung D never saw that PTY; it may still run on the earlier relay. + const connectionId = 'ssh-plain-1' + const ptyId = `ssh:${connectionId}@@prior-relay-pty` + setLocalPtyProvider(new LocalPtyProvider()) + const openShell = vi.fn(async () => { + throw new Error('no shell in this test') + }) + const mode = { reason: 'no_runtime' as const, message: 'plain' } + registerSshPtyProvider( + connectionId, + new SshPlainShellPtyProvider(connectionId, openShell, mode, true, 1) + ) + const controller = registerAgentClaimController() + try { + await expect(controller.probePtyLiveness(ptyId)).resolves.toBeNull() + await expect(controller.attach(ptyId)).resolves.toBe(false) + } finally { + unregisterSshPtyProvider(connectionId) + } + }) + it('answers unknown for remote-scoped ids without consulting local providers', async () => { // Why: a locally routed provider would answer confidently — and wrongly — // for a PTY that lives on a remote Orca host. diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index 6feb56dba56..6b191c5c0a8 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -26,7 +26,6 @@ const { mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn() }, diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index cc9081ce2a2..74797fa3d6a 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -31,7 +31,6 @@ const { mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn(), getSettings: vi.fn() diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index 0ff8b48c773..834c9e97e92 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -343,7 +343,7 @@ describe('repos:add + repos:clone', () => { }) ).rejects.toThrow('Imported folder does not match the selected project identity.') - expect(mockStore.removeProject).toHaveBeenCalledWith(added[0]?.id) + expect(mockStore.removeProjectForHost).toHaveBeenCalledWith(added[0]?.id, 'local') expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() }) diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index 7f7c3a3976d..ce658981574 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -51,7 +51,6 @@ describe('repos folder pickers', () => { const mockStore = { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn() } diff --git a/src/main/ipc/repos-remote-client-events.test.ts b/src/main/ipc/repos-remote-client-events.test.ts index adf2cc2f17b..fbd3197a805 100644 --- a/src/main/ipc/repos-remote-client-events.test.ts +++ b/src/main/ipc/repos-remote-client-events.test.ts @@ -15,7 +15,6 @@ const { handleMock, mockStore } = vi.hoisted(() => ({ getRepo: vi.fn(), addRepo: vi.fn(), updateRepo: vi.fn(), - removeProject: vi.fn(), removeProjectForHost: vi.fn(), reorderRepos: vi.fn().mockReturnValue(true) } @@ -69,23 +68,12 @@ beforeEach(() => { handlers.set(channel, handler) }) mainWindow.webContents.send.mockReset() - mockStore.removeProject.mockReset() mockStore.removeProjectForHost.mockReset() mockStore.reorderRepos.mockReset().mockReturnValue(true) }) describe('repo IPC mutations notify paired clients', () => { - it('broadcasts once for repos:remove and still notifies the local renderer', async () => { - const notify = vi.fn() - await registerHandlersWithNotifier(notify) - - await handlers.get('repos:remove')!(null, { repoId: 'repo-1' }) - - expect(notify).toHaveBeenCalledTimes(1) - expect(mainWindow.webContents.send).toHaveBeenCalledWith('repos:changed') - }) - - it('broadcasts once for repos:removeForHost', async () => { + it('broadcasts once for repos:removeForHost and still notifies the local renderer', async () => { const notify = vi.fn() await registerHandlersWithNotifier(notify) @@ -93,6 +81,7 @@ describe('repo IPC mutations notify paired clients', () => { expect(mockStore.removeProjectForHost).toHaveBeenCalledWith('repo-1', 'ssh:host-1') expect(notify).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('repos:changed') }) it('broadcasts for non-removal mutations too, via the shared helper', async () => { @@ -107,7 +96,9 @@ describe('repo IPC mutations notify paired clients', () => { it('does not throw when no notifier has been set', async () => { await registerHandlersWithoutNotifier() - await expect(handlers.get('repos:remove')!(null, { repoId: 'repo-1' })).resolves.toBeUndefined() + await expect( + handlers.get('repos:removeForHost')!(null, { repoId: 'repo-1', hostId: 'local' }) + ).resolves.toBeUndefined() expect(mainWindow.webContents.send).toHaveBeenCalledWith('repos:changed') }) @@ -116,6 +107,8 @@ describe('repo IPC mutations notify paired clients', () => { throw new Error('client event stream exploded') }) - await expect(handlers.get('repos:remove')!(null, { repoId: 'repo-1' })).resolves.toBeUndefined() + await expect( + handlers.get('repos:removeForHost')!(null, { repoId: 'repo-1', hostId: 'local' }) + ).resolves.toBeUndefined() }) }) diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 3e7cfe783c2..183e3a4ab7b 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -15,7 +15,7 @@ export type ReposIpcMocks = { mockStore: Record< | 'getRepos' | 'addRepo' - | 'removeProject' + | 'removeProjectForHost' | 'getRepo' | 'getProjects' | 'getProjectHostSetups' @@ -52,7 +52,7 @@ export function createReposIpcMocks(): ReposIpcMocks { mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), + removeProjectForHost: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn(), getProjects: vi.fn().mockReturnValue([]), diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 95388193985..dfe9dcf46f5 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -59,7 +59,6 @@ describe('repos:addRemote', () => { captureHandlers(handleMock) mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.addRepo.mockReset() - mockStore.removeProject.mockReset() mockStore.getSshTarget.mockReset() mockStore.updateRepo.mockReset() mockGitProvider.isGitRepoAsync.mockReset() diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index 31843858c05..df660555c0d 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -8,7 +8,6 @@ const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({ mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn(), getSparsePresets: vi.fn(), diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 3c2754507b7..9e7a4175c22 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -24,7 +24,6 @@ export function registerRepoHandlers( ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:listForExecutionHost') ipcMain.removeHandler('repos:add') - ipcMain.removeHandler('repos:remove') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:reorderForHost') diff --git a/src/main/ipc/repos/project-host-setup-handlers.ts b/src/main/ipc/repos/project-host-setup-handlers.ts index 3feb101324c..8898d5ec07a 100644 --- a/src/main/ipc/repos/project-host-setup-handlers.ts +++ b/src/main/ipc/repos/project-host-setup-handlers.ts @@ -14,7 +14,7 @@ import type { } from '../../../shared/project-types' import { getProjectIdForProviderIdentity } from '../../../shared/project-host-setup-projection' import { getProjectHostSetupForRepo } from '../../../shared/project-host-setup-lookup' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' import { emitRepoAdded } from './repo-added-telemetry' @@ -177,7 +177,7 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor } catch (err) { // Why: an import that cannot be linked must not leave a new repo registration or authorization root behind. if (!result.alreadyExisted) { - store.removeProject(result.repo.id) + store.removeProjectForHost(result.repo.id, getRepoExecutionHostId(result.repo)) invalidateAuthorizedRootsCache() } throw err diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index 825daef9850..608d9105f19 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -86,12 +86,6 @@ export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: St } ) - ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { - store.removeProject(args.repoId) - invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) - }) - // Why: forget a project on one execution host without disturbing the same repo id on other hosts (SSH-workspace forget flow). ipcMain.handle( 'repos:removeForHost', diff --git a/src/main/ipc/ssh-host-node-runtime-connect.test.ts b/src/main/ipc/ssh-host-node-runtime-connect.test.ts new file mode 100644 index 00000000000..b2c7941eb66 --- /dev/null +++ b/src/main/ipc/ssh-host-node-runtime-connect.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = await vi.hoisted(async () => { + const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') + return createSshIpcMocks() +}) + +vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) +vi.mock('electron', () => mocks.electron) +vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) +vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) +vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) +vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) +vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) +vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) +vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) +vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) +vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) +vi.mock('./pty', () => mocks.pty) +vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) +vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) +vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) +vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) +vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) + +import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' +import { recordSshRelayRuntimeStep } from '../ssh/ssh-host-node-runtime-mode' +import { setSshHostServerStatus, clearSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { getPublicSshState } from './ssh-renderer-broadcast' +import { createSshIpcHarness } from './ssh-ipc-test-harness' + +const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks + +const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy', + remoteRuntime: 'legacy' +} + +function lastConnectedState(send: ReturnType): SshConnectionState | undefined { + return ( + send.mock.calls + .filter(([channel]) => channel === 'ssh:state-changed') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh:state-changed always carries { targetId, state }. + .map(([, payload]) => (payload as { state: SshConnectionState }).state) + .findLast((state) => state.status === 'connected') + ) +} + +describe('ssh:connect on the opt-in Host Node runtime', () => { + const harness = createSshIpcHarness(mocks) + const { handlers, mockWindow } = harness + + beforeEach(async () => { + await harness.reset() + recordSshRelayRuntimeStep('ssh-1', false) + clearSshHostServerStatus('ssh-1') + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + }) + + it('publishes the connection as running the unsupported Host Node runtime', async () => { + mockDeployAndLaunchRelay.mockImplementationOnce(async () => { + recordSshRelayRuntimeStep('ssh-1', true) + return harness.createRelayLaunchResult() + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + expect(lastConnectedState(mockWindow.webContents.send)?.hostNodeRuntime).toBe(true) + }) + + it('stays quiet on a ladder connect and once the host moved to its managed server', async () => { + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + expect(lastConnectedState(mockWindow.webContents.send)).not.toHaveProperty('hostNodeRuntime') + + recordSshRelayRuntimeStep('ssh-1', true) + setSshHostServerStatus('ssh-1', { + kind: 'managed', + environmentId: 'env-1' + }) + expect(getPublicSshState('ssh-1')).not.toHaveProperty('hostNodeRuntime') + }) +}) diff --git a/src/main/ipc/ssh-plain-ssh-connect.test.ts b/src/main/ipc/ssh-plain-ssh-connect.test.ts index 974eb2eab18..65583ca681c 100644 --- a/src/main/ipc/ssh-plain-ssh-connect.test.ts +++ b/src/main/ipc/ssh-plain-ssh-connect.test.ts @@ -33,9 +33,9 @@ import { createSshIpcHarness } from './ssh-ipc-test-harness' const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks function noexecHome(): RemoteRuntimeUnavailableError { - const run = new RelayRuntimeLadderRun('ssh-1', null) + const run = new RelayRuntimeLadderRun('ssh-1', null, true) run.refused('A', 'noexec') - return new RemoteRuntimeUnavailableError('home_noexec', run) + return new RemoteRuntimeUnavailableError(run) } function connectedStates(send: ReturnType): SshConnectionState[] { diff --git a/src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts b/src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts new file mode 100644 index 00000000000..4a25988bd43 --- /dev/null +++ b/src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createSshPtyOutputIntakeHarness as createHarness, + sshPtyOutputEvent as event +} from './ssh-pty-output-intake-test-harness' +import { SSH_PTY_ACK_FLUSH_MS } from './ssh-pty-source-ack-coalescer' + +const EXIT_EVENT = { id: 'pty-1', code: -1, providerGeneration: 1, ptyIncarnation: 'incarnation-1' } + +// #24367: a canceled recovery delivery left a published projection whose span the ledger had +// already released, so every later renderer ACK threw and the next delivery was never credited. +describe('SshPtyOutputIntake recovery cancellation', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps crediting the next delivery after a recovery cancellation reclaims a published span', async () => { + vi.useFakeTimers() + const { harness, credited } = await cancelPublishedRecoverySpan() + const next = harness.intake.acceptData( + event({ + data: 'bbbb', + source: { + spanId: 'next-span', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'next-token', + sourceStartSu: 0, + sourceEndSu: 4 + } + }) + ) + harness.completions[1]!.resolve() + const nextReceipt = await next + harness.intake.publishProjectionPrefix( + [nextReceipt.projection.identity.projectionSemanticsId], + 4, + 4 + ) + + // The renderer parses the canceled span first; that ACK is owed to nobody. + expect(() => harness.intake.settleProjectionPrefix('pty-1', 4)).not.toThrow() + await vi.advanceTimersByTimeAsync(SSH_PTY_ACK_FLUSH_MS) + expect(credited).toEqual([]) + + expect(() => harness.intake.settleProjectionPrefix('pty-1', 4)).not.toThrow() + await vi.advanceTimersByTimeAsync(SSH_PTY_ACK_FLUSH_MS) + expect(credited).toEqual(['next-token@4']) + expect(harness.intake.getDebugSnapshot().projection.records).toBe(0) + }) + + it('transfers a canceled span without throwing when the renderer drops it', async () => { + vi.useFakeTimers() + const { harness } = await cancelPublishedRecoverySpan() + + expect(() => harness.intake.transferPtyProjections('pty-1', 'renderer-destroyed')).not.toThrow() + expect(harness.intake.getDebugSnapshot().projection.records).toBe(0) + }) + + it('closes the generation while a canceled span is still unACKed', async () => { + vi.useFakeTimers() + const { harness, credited } = await cancelPublishedRecoverySpan() + + expect(() => harness.intake.closeGeneration(1, 'provider-closed')).not.toThrow() + await vi.advanceTimersByTimeAsync(SSH_PTY_ACK_FLUSH_MS) + + expect(harness.intake.getDebugSnapshot()).toMatchObject({ + projection: { records: 0 }, + source: { openedTokens: 0, ptyIdentities: 0 } + }) + expect(credited).toEqual([]) + }) + + it('accepts a recovery span whose model admission finishes after the cancellation', async () => { + const harness = createHarness() + const recovered = harness.intake.acceptData(event({ source: recoverySource() })) + await vi.waitFor(() => expect(harness.completions).toHaveLength(1)) + harness.intake.applySourceRecoveryCancellationProof(EXIT_EVENT, { + sentEndSu: 8, + creditedEndSu: 4 + }) + harness.completions[0]!.resolve() + + await expect(recovered).resolves.toMatchObject({ sequence: 4 }) + }) +}) + +async function cancelPublishedRecoverySpan() { + const credited: string[] = [] + const harness = createHarness({ + publishSourceAck: (_providerGeneration, batch, onSettled) => { + for (const ack of batch.acknowledgements) { + credited.push(`${ack.deliveryToken}@${ack.creditedEndSu}`) + } + onSettled({ ok: true }) + } + }) + const recovered = harness.intake.acceptData(event({ source: recoverySource() })) + harness.completions[0]!.resolve() + const receipt = await recovered + // Sent to the renderer, not yet parsed. + harness.intake.publishProjectionPrefix([receipt.projection.identity.projectionSemanticsId], 4, 4) + harness.intake.applySourceRecoveryCancellationProof(EXIT_EVENT, { + sentEndSu: 8, + creditedEndSu: 4 + }) + return { harness, credited } +} + +function recoverySource() { + return { + spanId: 'recovery-span', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'recovery-token', + sourceStartSu: 4, + sourceEndSu: 8 + } +} diff --git a/src/main/ipc/ssh-pty-output-source-obligations.ts b/src/main/ipc/ssh-pty-output-source-obligations.ts index 410b8c05644..79864e79cb5 100644 --- a/src/main/ipc/ssh-pty-output-source-obligations.ts +++ b/src/main/ipc/ssh-pty-output-source-obligations.ts @@ -14,8 +14,14 @@ import type { SshPtySourceCancellationRequest } from './ssh-pty-output-intake-contract' import { SshPtyRemoteSourceRangeConsumers } from './ssh-pty-remote-source-range-consumers' -import type { SshPtySourceAdmissionReservation } from './ssh-pty-source-obligation-contract' -import { SshPtySourceObligationCoordinator } from './ssh-pty-source-obligation-coordinator' +import type { + SshPtySourceAdmissionReservation, + SshPtySourceConsumerId +} from './ssh-pty-source-obligation-contract' +import { + SshPtySourceObligationCoordinator, + type SshPtySourceObligationTransition +} from './ssh-pty-source-obligation-coordinator' export type SshPtyOutputSourceReservation = Readonly<{ admission: SshPtySourceAdmissionReservation @@ -107,31 +113,22 @@ export class SshPtyOutputSourceObligations { } settleModel(span: PtySourceSpan): void { - this.coordinator.settle({ - identity: span, - spanId: span.spanId, - consumer: 'model', - reason: 'model-accepted' - }) + const transition = this.openTransition(span, 'model', 'model-accepted') + if (transition) { + this.coordinator.settle(transition) + } } settleDesktop(span: DesktopProjectionSpan, reason: string): void { - this.coordinator.settle({ - identity: span, - spanId: span.spanId, - consumer: 'desktop', - reason - }) + const transition = this.openTransition(span, 'desktop', reason) + if (transition) { + this.coordinator.settle(transition) + } } transferDesktop(span: DesktopProjectionSpan, reason: string): void { - const transition = { - identity: span, - spanId: span.spanId, - consumer: 'desktop' as const, - reason - } - if (this.coordinator.beginTransfer(transition, 'model')) { + const transition = this.openTransition(span, 'desktop', reason) + if (transition && this.coordinator.beginTransfer(transition, 'model')) { this.coordinator.commitTransfer(transition) } } @@ -288,6 +285,24 @@ export class SshPtyOutputSourceObligations { return `${event.providerGeneration}\0${event.id}\0${event.ptyIncarnation}` } + // Why: a recovery cancellation closes a delivery whose spans the model or renderer may still + // settle. Nothing is owed to a closed delivery, and a throw would fail every later ACK. + private openTransition( + span: PtySourceDeliveryIdentity & Readonly<{ spanId: string }>, + consumer: SshPtySourceConsumerId, + reason: string + ): SshPtySourceObligationTransition | null { + return this.isDeliveryOpen(span) + ? { identity: span, spanId: span.spanId, consumer, reason } + : null + } + + private isDeliveryOpen( + span: Pick + ): boolean { + return this.openedTokens.has(ptySourceDeliveryKey(span)) + } + private removeIdentity(identity: PtySourceDeliveryIdentity): void { this.openedTokens.delete(ptySourceDeliveryKey(identity)) for (const [key, record] of this.identityByPty) { diff --git a/src/main/ipc/ssh-pty-source-ack-session-contract.test.ts b/src/main/ipc/ssh-pty-source-ack-session-contract.test.ts index 9ee6f90c5a3..aa968e78aa3 100644 --- a/src/main/ipc/ssh-pty-source-ack-session-contract.test.ts +++ b/src/main/ipc/ssh-pty-source-ack-session-contract.test.ts @@ -83,4 +83,79 @@ describe('SSH PTY intake to relay ACK contract', () => { cleanup() } }) + + it('withholds source credit until the main terminal model finishes the chunk', async () => { + vi.useFakeTimers() + const batches: PtySourceCreditAckBatch[] = [] + const cleanup = installSshPtySourceAckPublisher(8, (batch, onSettled) => { + batches.push(batch) + onSettled({ ok: true }) + }) + let finishModel: () => void = () => {} + const projectionIds: string[] = [] + let sequence = 0 + const intake = new SshPtyOutputIntake({ + getModelSequence: () => sequence, + acceptModel: (event) => { + sequence += event.rawLength + return { + sequence, + completion: new Promise((resolve) => { + finishModel = resolve + }) + } + }, + project: (_event, projection) => { + projectionIds.push(projection.identity.projectionSemanticsId) + }, + prepareExit: () => {}, + finalizeExit: () => {}, + publishSourceAck: publishSshPtySourceAck + }) + + try { + const receipt = intake.acceptData({ + id: 'ssh:target@@relay-pty-2', + data: 'frame', + providerGeneration: 8, + ptyIncarnation: 'incarnation-2', + rawLength: 5, + transformed: false, + source: { + relayPtyId: 'relay-pty-2', + spanId: 'token-2:0:5', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-2', + sourceStartSu: 0, + sourceEndSu: 5 + } + }) + // The renderer drains its copy first; a slow main model must still hold the remote credit. + intake.publishProjectionPrefix(projectionIds, 5, 5) + intake.settleProjectionPrefix('ssh:target@@relay-pty-2', 5) + await vi.advanceTimersByTimeAsync(8) + expect(batches).toHaveLength(0) + + finishModel() + await receipt + await vi.advanceTimersByTimeAsync(8) + expect(batches).toEqual([ + { + acknowledgements: [ + { + id: 'relay-pty-2', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-2', + creditedEndSu: 5 + } + ] + } + ]) + } finally { + intake.dispose() + cleanup() + } + }) }) diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 112ad70d6ff..81646454320 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -15,6 +15,7 @@ import { import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' +import { isSshRelayOnHostNodeRuntime } from '../ssh/ssh-host-node-runtime-mode' import { clearSshHostServerStatus, getSshHostServerStatus } from '../ssh/ssh-host-server-status' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { activeSessions } from './ssh-active-relay-sessions' @@ -53,6 +54,12 @@ function withSshRemotePlatform(targetId: string, state: SshConnectionState): Ssh const authority = getSshProviderAuthority(targetId) const plainSsh = state.status === 'connected' ? getSshPlainSshMode(targetId) : undefined const managedServer = state.managedServer ?? getSshHostServerStatus(targetId) + // Why the managed check: a host that moved to its managed server no longer runs that relay. + const hostNodeRuntime = + state.status === 'connected' && + !plainSsh && + managedServer?.kind !== 'managed' && + isSshRelayOnHostNodeRuntime(targetId) return { ...state, targetId, @@ -60,6 +67,7 @@ function withSshRemotePlatform(targetId: string, state: SshConnectionState): Ssh connectionGeneration: authority.connectionGeneration, ...(remotePlatform ? { remotePlatform } : {}), ...(plainSsh ? { plainSsh } : {}), + ...(hostNodeRuntime ? { hostNodeRuntime } : {}), ...(managedServer ? { managedServer } : {}) } } diff --git a/src/main/ipc/ssh-session-teardown.ts b/src/main/ipc/ssh-session-teardown.ts index e8e46f32cb6..e76e19fc2fd 100644 --- a/src/main/ipc/ssh-session-teardown.ts +++ b/src/main/ipc/ssh-session-teardown.ts @@ -3,6 +3,7 @@ import type { SshConnection } from '../ssh/ssh-connection' import type { SshRelaySession } from '../ssh/ssh-relay-session' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { clearSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { recordSshRelayRuntimeStep } from '../ssh/ssh-host-node-runtime-mode' import { isSshConnectionSolelyOwnedBy } from '../ssh/ssh-connection-attribution' import { activeSessions } from './ssh-active-relay-sessions' import { @@ -30,6 +31,7 @@ export async function removeRegisteredSshTarget(targetId: string): Promise } invalidateConnectAttempt(targetId) clearSshHostServerStatus(targetId) + recordSshRelayRuntimeStep(targetId, false) await runTargetLifecycle(targetId, async () => { try { // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. diff --git a/src/main/menu/app-menu-quit-item.ts b/src/main/menu/app-menu-quit-item.ts new file mode 100644 index 00000000000..c31974e6144 --- /dev/null +++ b/src/main/menu/app-menu-quit-item.ts @@ -0,0 +1,23 @@ +import { translateMain } from '../i18n/main-i18n' + +/** + * The app menu's Quit entry: "Quit " in the macOS app menu, else File > Exit. + * Why a handler instead of role 'quit': a host that must not exit on a user Quit + * (`orca serve`, #15537) supplies `onQuit`; the native-quit guard only covers macOS. + */ +export function createAppMenuQuitItem( + onQuit: (() => void) | undefined, + macAppName?: string +): Electron.MenuItemConstructorOptions { + const exitLabel = macAppName === undefined ? translateMain('menu.exit', 'Exit') : undefined + if (!onQuit) { + return { role: 'quit', ...(exitLabel ? { label: exitLabel } : {}) } + } + return { + label: + exitLabel ?? translateMain('menu.quitApp', `Quit ${macAppName}`, { appName: macAppName }), + // Same accelerator Electron gives role 'quit' (none on Windows). + ...(process.platform === 'win32' ? {} : { accelerator: 'CmdOrCtrl+Q' }), + click: () => onQuit() + } +} diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index ee8bf6ccc08..5e10b24d10d 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -583,4 +583,34 @@ describe('registerAppMenu', () => { expect(appearanceSubmenu.find((item) => item.label === leftLabel)?.accelerator).toBeUndefined() expect(appearanceSubmenu.find((item) => item.label === rightLabel)?.accelerator).toBeUndefined() }) + + it.each(['darwin', 'linux', 'win32'] as const)( + 'keeps the native quit role unless the host supplies a quit handler (%s, #15537)', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const quitMenu = platform === 'darwin' ? 'Orca' : 'File' + const findQuit = (): Electron.MenuItemConstructorOptions | undefined => + getSubmenu(getTemplate(), quitMenu).find( + (item) => + item.role === 'quit' || item.accelerator === 'CmdOrCtrl+Q' || item.label === 'Exit' + ) + + registerAppMenu(buildMenuOptions()) + expect(findQuit()?.role).toBe('quit') + + buildFromTemplateMock.mockClear() + const onQuit = vi.fn() + registerAppMenu({ ...buildMenuOptions(), onQuit }) + const quit = findQuit() + expect(quit?.role).toBeUndefined() + expect(quit?.label).toBe(platform === 'darwin' ? 'Quit Orca' : 'Exit') + expect(quit?.accelerator).toBe(platform === 'win32' ? undefined : 'CmdOrCtrl+Q') + // Why untyped: the handler ignores its Electron arguments. + const click: unknown = quit?.click + if (typeof click === 'function') { + click() + } + expect(onQuit).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 3c8bb434c29..77c42b1c385 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -8,6 +8,7 @@ import { import type { UpdateCheckOptions } from '../../shared/update-status-types' import { translateMain } from '../i18n/main-i18n' import { createAppMenuSelectionItem } from './app-menu-selection-item' +import { createAppMenuQuitItem } from './app-menu-quit-item' import { createAppWindowMenu } from './app-menu-window' export type AppearanceMenuState = { @@ -42,6 +43,7 @@ type RegisterAppMenuOptions = { // Why: the macOS app-menu title. Passed the per-branch dev label since // app.name is now pinned to a stable value for Keychain-key stability. appMenuLabel?: string + onQuit?: () => void } function buildAndApplyMenu(options: RegisterAppMenuOptions): void { @@ -160,7 +162,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, - { role: 'quit' } + createAppMenuQuitItem(options.onQuit, options.appMenuLabel ?? app.name) ] } @@ -169,11 +171,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { // Why: on Windows/Linux there is no app-named menu, so Settings and // Quit live under File — matching the common platform convention and // keeping all user-facing actions reachable from the in-window menu bar. - submenu: [ - settingsItem, - { type: 'separator' }, - { role: 'quit', label: translateMain('menu.exit', 'Exit') } - ] + submenu: [settingsItem, { type: 'separator' }, createAppMenuQuitItem(options.onQuit)] } // Why: keep native menu hints while letting non-macOS Ctrl+Z/Ctrl+Y reach the focused terminal or DOM control. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 3ba5bbb612e..e20bd60b9eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -115,7 +115,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId, + carriedFrom: ctx.sessionId }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/orcad/orcad-command-arguments.ts b/src/main/orcad/orcad-command-arguments.ts index 8077b2cb898..346379f72b7 100644 --- a/src/main/orcad/orcad-command-arguments.ts +++ b/src/main/orcad/orcad-command-arguments.ts @@ -23,6 +23,8 @@ export function parseArgs(argv: string[]): OrcadOptions { options.noPairing = true } else if (arg === '--mobile-pairing') { options.mobilePairing = true + } else if (arg === '--grant-desktop-control') { + options.grantDesktopControl = true } else if (arg === '--recipe-json') { options.recipeJson = true } else if (arg === '--project-root') { @@ -50,6 +52,9 @@ export function parseArgs(argv: string[]): OrcadOptions { throw new Error(`Unknown argument: ${arg}`) } } + if (options.grantDesktopControl && (options.noPairing || options.mobilePairing)) { + throw new Error('--grant-desktop-control applies only to the default runtime pairing offer') + } if (options.recipeJson && !options.projectRoot) { throw new Error('--recipe-json requires --project-root') } diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index b89c813774f..55f6ace305f 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -104,6 +104,8 @@ export type OrcadOptions = { pairingAddress?: string /** Desktop `orca serve` parity: a mobile-scoped offer with a terminal QR. */ mobilePairing?: boolean + /** Lets the paired runtime client drive this machine's desktop (computer.*). */ + grantDesktopControl?: boolean /** Desktop `orca serve` parity: print only the ephemeral-VM recipe line. */ recipeJson?: boolean projectRoot?: string diff --git a/src/main/orcad/orcad-forked-child-paths.test.ts b/src/main/orcad/orcad-forked-child-paths.test.ts index 9f6f63fdc82..70fe1009744 100644 --- a/src/main/orcad/orcad-forked-child-paths.test.ts +++ b/src/main/orcad/orcad-forked-child-paths.test.ts @@ -4,6 +4,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { getAppEnvironment } from '../../shared/app-environment' import { getWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path' +import { + AI_VAULT_SERVICE_ENTRY_FILENAME, + getAiVaultServiceEntryPath +} from '../ai-vault/session-scanner-service-entry-path' import { FOREIGN_SQLITE_READER_ENTRY_FILENAME, resolveForeignSqliteReaderEntryPath @@ -26,6 +30,7 @@ describe('orcad forked-child paths', () => { writeFileSync(join(deployRoot, 'orcad.js'), '') writeFileSync(join(deployRoot, 'parcel-watcher-process-entry.js'), '') writeFileSync(join(deployRoot, FOREIGN_SQLITE_READER_ENTRY_FILENAME), '') + writeFileSync(join(deployRoot, AI_VAULT_SERVICE_ENTRY_FILENAME), '') originalArgv = process.argv process.argv = [process.execPath, join(deployRoot, 'orcad.js')] installOrcadHostAdapters() @@ -40,6 +45,11 @@ describe('orcad forked-child paths', () => { expect(getWatcherProcessEntryPath()).toBe(join(deployRoot, 'parcel-watcher-process-entry.js')) }) + it('forks the session scanner child shipped beside orcad.js', () => { + // orcad reports isPackaged with no asar, which used to send this lookup to out/main. + expect(getAiVaultServiceEntryPath()).toBe(join(deployRoot, AI_VAULT_SERVICE_ENTRY_FILENAME)) + }) + it('starts the foreign SQLite reader worker shipped beside orcad.js', () => { // orcad.js is one bundle, so its __dirname is the deploy root. expect(resolveForeignSqliteReaderEntryPath(currentWorkerEntryLayout(deployRoot))).toBe( diff --git a/src/main/orcad/orcad-local-serve-selection.test.ts b/src/main/orcad/orcad-local-serve-selection.test.ts index 9da11cfccf4..f6df27980c4 100644 --- a/src/main/orcad/orcad-local-serve-selection.test.ts +++ b/src/main/orcad/orcad-local-serve-selection.test.ts @@ -11,9 +11,19 @@ import { } from '../../shared/orcad-artifacts' import { formatOrcadNativePreflightReport } from '../../shared/orcad-native-preflight-report' import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../shared/orca-profiles' +import { PROFILE_STATE_DATABASE_FILE_NAME } from '../../shared/profile-state-storage-paths' +import Database from '../sqlite/sync-database' +import { + openProfileStateDatabase, + profileStatePragmaNumber +} from '../persistence/profile-state/profile-state-database' +import { hashProfileStatePayload } from '../persistence/profile-state/profile-state-document-validation' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' import { selectServeRuntime, type ServeRuntimeSelectionInput } from './orcad-local-serve-selection' const TARGET = 'linux-x64-glibc' +const SSH_TARGET = { id: 'ssh-1', label: 'box', host: 'box.example', port: 22, username: 'me' } const SHA = NODE_RUNTIME_ASSETS[TARGET].executableSha256 let root = '' @@ -37,6 +47,47 @@ function slotFixture(): string { return slot } +/** A valid schema-1 profile, as an Orca from before the normalized run tables left it. */ +function seedSchemaV1Profile(dbPath: string, state: Record): void { + const db = new Database(dbPath) + db.exec(` + PRAGMA user_version = 1; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + `) + db.prepare('INSERT INTO profile_state_meta (key, value) VALUES (?, ?)').run( + 'profile_id', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + db.prepare('INSERT INTO profile_state_meta (key, value) VALUES (?, ?)').run('revision', '1') + const insert = db.prepare( + `INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, 1, 1, 100, ?)` + ) + for (const [domain, value] of Object.entries(state)) { + const payload = JSON.stringify(value) + insert.run(domain, payload, hashProfileStatePayload(payload)) + } + db.close() +} + +function schemaVersion(dbPath: string): number { + const db = new Database(dbPath, { readonly: true }) + try { + return profileStatePragmaNumber(db, 'user_version') + } finally { + db.close() + } +} + function input(overrides: Partial = {}): ServeRuntimeSelectionInput { const template = join(root, 'orcad-template') mkdirSync(template, { recursive: true }) @@ -70,6 +121,68 @@ describe('orca serve runtime selection', () => { } }) + it('stays on Electron by default when the profile has SSH targets orcad cannot serve (#25886)', async () => { + const options = input({ profileHasSshTargets: () => true }) + expect(await selectServeRuntime(options)).toEqual({ + kind: 'electron', + reason: 'this profile has SSH targets, which orcad cannot serve yet' + }) + expect(options.materializeSlot).not.toHaveBeenCalled() + }) + + it('keeps orcad for SSH profiles when orcad is asked for by name', async () => { + const named = input({ env: { [SERVE_RUNTIME_ENV]: 'orcad' }, profileHasSshTargets: () => true }) + expect(await selectServeRuntime(named)).toMatchObject({ kind: 'orcad' }) + }) + + it('stays on Electron when it cannot tell whether the profile has SSH targets', async () => { + const unreadable = input({ + profileHasSshTargets: () => { + throw new Error('locked') + } + }) + expect(await selectServeRuntime(unreadable)).toEqual({ + kind: 'electron', + reason: + 'could not tell whether this profile has SSH targets, which orcad cannot serve yet (locked)' + }) + expect(unreadable.materializeSlot).not.toHaveBeenCalled() + }) + + it.each([ + ['with', [SSH_TARGET], 'electron'], + ['without', [], 'orcad'] + ] as const)( + 'stays on Electron, without migrating, for a schema-1 profile %s SSH targets', + async (_label, targets, afterMigration) => { + const dbPath = join(root, PROFILE_STATE_DATABASE_FILE_NAME) + seedSchemaV1Profile(dbPath, { sshTargets: targets }) + expect(await selectServeRuntime(input())).toMatchObject({ + kind: 'electron', + reason: expect.stringContaining('could not tell whether this profile has SSH targets') + }) + expect(schemaVersion(dbPath)).toBe(1) + // The serve host's own open migrates it; selection then reads the same targets. + openProfileStateDatabase(dbPath, DEFAULT_LOCAL_ORCA_PROFILE_ID).db.close() + expect(await selectServeRuntime(input())).toMatchObject({ kind: afterMigration }) + } + ) + + it('serves on orcad for a current-schema profile with no SSH targets', async () => { + const opened = openProfileStateDatabase( + join(root, PROFILE_STATE_DATABASE_FILE_NAME), + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + importProfileStateJson(opened.db, JSON.stringify({ sshTargets: [], settings: {} })) + opened.db.close() + expect(await selectServeRuntime(input())).toMatchObject({ kind: 'orcad' }) + }) + + it('reads SSH targets from the profile on disk', async () => { + writeFileSync(join(root, 'orca-data.json'), JSON.stringify({ sshTargets: [{ id: 'ssh-1' }] })) + expect(await selectServeRuntime(input())).toMatchObject({ kind: 'electron' }) + }) + it('runs the local slot on its pinned Node, linked into userData beside it', async () => { const options = input() const selection = await selectServeRuntime(options) diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts index 2d8f51769fe..49e5f2d2bc8 100644 --- a/src/main/orcad/orcad-local-serve-selection.ts +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -31,6 +31,7 @@ import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime' import { detectNativeHostAbi, nativeSlotName } from './native-host-abi' import { materializeOrcadArtifact } from '../ssh/orcad-artifact-materializer' import { materializeCachedNodeRuntime } from '../ssh/pinned-runtime-materializer' +import { serveProfileHasSshTargets } from './serve-profile-ssh-targets' const NATIVE_PREFLIGHT_TIMEOUT_MS = 30_000 @@ -43,6 +44,7 @@ export type ServeRuntimeSelectionInput = { materializeSlot?: typeof materializeOrcadArtifact materializeRuntime?: typeof materializeCachedNodeRuntime nativePreflight?: (runtime: string, entry: string) => Promise + profileHasSshTargets?: (userDataPath: string) => boolean } function isServerTarget(value: string): value is ServerTarget { @@ -63,6 +65,12 @@ export async function selectServeRuntime( if (requested && requested !== 'orcad') { return electron(`${SERVE_RUNTIME_ENV}=${requested} is neither orcad nor electron`) } + // Why: orcad has no SSH connection stack yet, so a default serve on it would list no targets + // and fail every connect (#25886, #8489). An explicit ORCA_SERVE_RUNTIME=orcad keeps orcad. + const sshReason = requested ? null : profileSshReason(input) + if (sshReason) { + return electron(sshReason) + } const target = (input.hostTarget ?? (() => nativeSlotName(detectNativeHostAbi())))() if (!isServerTarget(target)) { return electron(`no orcad build exists for this host (${target})`) @@ -111,6 +119,19 @@ export async function selectServeRuntime( } } +/** Why this profile must serve on Electron for SSH, or null when it provably has no targets. */ +function profileSshReason(input: ServeRuntimeSelectionInput): string | null { + try { + return (input.profileHasSshTargets ?? serveProfileHasSshTargets)(input.userDataPath) + ? 'this profile has SSH targets, which orcad cannot serve yet' + : null + } catch (error) { + // Why: fail closed. The read-only probe rejects profiles the serve host would still migrate + // (an older schema), and guessing "no SSH" there leaves saved targets unreachable. + return `could not tell whether this profile has SSH targets, which orcad cannot serve yet (${errorText(error)})` + } +} + /** The slot references its runtime beside it; the cached pinned Node is linked into place. */ async function placeSlotRuntime( slotDir: string, diff --git a/src/main/orcad/orcad-runtime-entry-artifacts.test.ts b/src/main/orcad/orcad-runtime-entry-artifacts.test.ts index 17c85a8e9ec..b9421483294 100644 --- a/src/main/orcad/orcad-runtime-entry-artifacts.test.ts +++ b/src/main/orcad/orcad-runtime-entry-artifacts.test.ts @@ -16,10 +16,7 @@ const RUNTIME_ENTRY_FILENAME = /[A-Za-z0-9_.-]+-(?:entry|worker)\.c?js/g * Named in orcad's bundle but not shipped yet. Each is a known gap, not an exemption: shipping * one removes it here, and this list may only shrink. */ -const KNOWN_UNSHIPPED_ENTRIES = new Set([ - 'session-scanner-service-entry.js', - 'wsl-transcript-fs-process-entry.js' -]) +const KNOWN_UNSHIPPED_ENTRIES = new Set(['wsl-transcript-fs-process-entry.js']) const directory = mkdtempSync(join(tmpdir(), 'orcad-runtime-entries-')) afterAll(() => { @@ -31,13 +28,13 @@ function buildOrcadBundles(): string[] { const builder = pathToFileURL(join(REPO_ROOT, 'config/scripts/orcad-entry-build.mjs')).href const script = ` import { build } from 'esbuild' - import { basename, join } from 'node:path' + import { join } from 'node:path' import * as entries from ${JSON.stringify(builder)} const out = ${JSON.stringify(directory)} await entries.buildOrcadEntry(join(out, 'orcad.js')) await Promise.all(Object.values(entries.ORCAD_CHILD_ENTRY_POINTS).map((entry) => build({ entryPoints: [entry], bundle: true, platform: 'node', target: 'node18', format: 'cjs', - outfile: join(out, basename(entry).replace(/\\.ts$/, '.js')), + outfile: join(out, entries.orcadChildOutputFilename(entry)), external: entries.ORCAD_EXTERNAL_MODULES, plugins: [entries.externalNativeAddons], logLevel: 'error' })))` @@ -66,3 +63,14 @@ it('ships every worker and child entry orcad loads at runtime', () => { expect(named.has(filename) && !shipped.has(filename), filename).toBe(true) } }) + +it('ships every child entry build-orcad.mjs builds', async () => { + const builder = pathToFileURL(join(REPO_ROOT, 'config/scripts/orcad-entry-build.mjs')).href + const entries = await import(builder) + const shipped = new Set(orcadArtifactFilenames('linux-x64-glibc')) + const built = Object.values(entries.ORCAD_CHILD_ENTRY_POINTS).map((entry) => + entries.orcadChildOutputFilename(entry) + ) + expect(built).toContain('session-scanner-service-entry.js') + expect(built.filter((filename) => !shipped.has(filename))).toEqual([]) +}) diff --git a/src/main/orcad/orcad-serve-readiness.ts b/src/main/orcad/orcad-serve-readiness.ts index 2148f02291a..92b36fa0191 100644 --- a/src/main/orcad/orcad-serve-readiness.ts +++ b/src/main/orcad/orcad-serve-readiness.ts @@ -26,7 +26,8 @@ export async function buildOrcadServeReadiness(input: { : rpc.createPairingOffer({ address: options.pairingAddress, name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, - scope: options.mobilePairing ? 'mobile' : 'runtime' + scope: options.mobilePairing ? 'mobile' : 'runtime', + grants: options.grantDesktopControl ? ['desktop-control'] : [] }) return { diff --git a/src/main/orcad/orcad-session-search.ts b/src/main/orcad/orcad-session-search.ts index 32d32272066..66468f38df0 100644 --- a/src/main/orcad/orcad-session-search.ts +++ b/src/main/orcad/orcad-session-search.ts @@ -8,9 +8,8 @@ import { installInProcessSessionSearchService } from '../ai-vault-search/session /** * orcad's session search registration. * - * In this process and not a scanner child: orcad ships only the watcher and the - * daemon entries beside `orcad.js`, so there is no scanner-service child here to - * own the index — and this process is the sole writer, so nothing can race it. + * In this process and not the scanner child: orcad never installs the search data + * root, so its scanner child gets no index and this process is the sole writer. * Null on a host whose Node has no `node:sqlite`, which is orcad's stated floor. */ export async function installOrcadSessionSearchService(args: { diff --git a/src/main/orcad/serve-profile-ssh-targets.test.ts b/src/main/orcad/serve-profile-ssh-targets.test.ts new file mode 100644 index 00000000000..fb018ac9ac1 --- /dev/null +++ b/src/main/orcad/serve-profile-ssh-targets.test.ts @@ -0,0 +1,72 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile +} from '../../shared/profile-state-storage-paths' +import { serveProfileHasSshTargets } from './serve-profile-ssh-targets' + +const TARGET = { id: 'ssh-1', label: 'box', host: 'box.example', port: 22, username: 'me' } +let userData = '' + +beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-serve-ssh-targets-')) +}) + +afterEach(() => { + rmSync(userData, { recursive: true, force: true }) +}) + +function writeIndex(profileId: string): void { + writeFileSync( + join(userData, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) +} + +function writeSqliteProfile(profileId: string, state: Record): void { + mkdirSync(join(userData, 'profiles', profileId), { recursive: true }) + const opened = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile(profileId, userData), + profileId + ) + importProfileStateJson(opened.db, JSON.stringify(state)) + opened.db.close() +} + +describe('serveProfileHasSshTargets', () => { + it('is false for a fresh install', () => { + expect(serveProfileHasSshTargets(userData)).toBe(false) + }) + + it('reads the active SQLite profile', () => { + writeIndex('work') + writeSqliteProfile('work', { sshTargets: [TARGET] }) + expect(serveProfileHasSshTargets(userData)).toBe(true) + }) + + it('is false for an active SQLite profile with no targets', () => { + writeIndex('work') + writeSqliteProfile('work', { sshTargets: [], settings: {} }) + expect(serveProfileHasSshTargets(userData)).toBe(false) + }) + + it('reads an active JSON-only profile', () => { + writeIndex('work') + mkdirSync(join(userData, 'profiles', 'work'), { recursive: true }) + writeFileSync( + getOrcaProfileDataFile('work', userData), + JSON.stringify({ sshTargets: [TARGET] }) + ) + expect(serveProfileHasSshTargets(userData)).toBe(true) + }) + + it('reads the legacy root profile when there is no profile index', () => { + writeFileSync(join(userData, 'orca-data.json'), JSON.stringify({ sshTargets: [TARGET] })) + expect(serveProfileHasSshTargets(userData)).toBe(true) + }) +}) diff --git a/src/main/orcad/serve-profile-ssh-targets.ts b/src/main/orcad/serve-profile-ssh-targets.ts new file mode 100644 index 00000000000..0f75c3a2d2a --- /dev/null +++ b/src/main/orcad/serve-profile-ssh-targets.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../shared/orca-profiles' +import { PROFILE_STATE_DATABASE_FILE_NAME } from '../../shared/profile-state-storage-paths' +import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location' +import { readProfileStateDomain } from '../persistence/profile-state/profile-state-domain-reader' +import { classifyProfileStateStorage } from '../persistence/profile-state/profile-state-storage-classification' + +/** + * Whether the profile `orca serve` would load has saved SSH targets. Read-only: never creates, + * migrates or locks the profile, because the serve host that starts next owns it. Throws when it + * cannot tell, including for an older schema the serve host would still migrate. + */ +export function serveProfileHasSshTargets(userDataPath: string): boolean { + // A profile index predates any profile-state import, so no index means the legacy root files. + const location = getActiveProfileStateLocation(userDataPath) ?? { + dataFile: join(userDataPath, 'orca-data.json'), + databaseFile: join(userDataPath, PROFILE_STATE_DATABASE_FILE_NAME), + profileId: DEFAULT_LOCAL_ORCA_PROFILE_ID + } + const storage = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (storage === 'neither') { + return false + } + if (storage === 'json-only') { + const parsed: unknown = JSON.parse(readFileSync(location.dataFile, 'utf8')) + return hasEntries(isRecord(parsed) ? parsed.sshTargets : undefined) + } + const result = readProfileStateDomain(location.databaseFile, location.profileId, 'sshTargets') + if (result.kind === 'unreadable') { + throw result.error + } + return result.kind === 'value' && hasEntries(result.value) +} + +function hasEntries(value: unknown): boolean { + return Array.isArray(value) && value.length > 0 +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index efc5abc1476..69a00dc96fa 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -6,9 +6,9 @@ import { } from './persistence-test-harness' /** * The same repo id may be registered on two execution hosts (see `removeProjectForHost`). - * Every deletion that resolves a *row* must therefore delete only that row: `removeProject` - * is id-only and would take the sibling host's registration with it. Since #11994 those - * deletions fan out to every paired device, so a cross-host over-delete is no longer local. + * Every deletion that resolves a *row* must therefore delete only that row, never the sibling + * host's registration. Since #11994 those deletions fan out to every paired device, so a + * cross-host over-delete is no longer local. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -123,6 +123,23 @@ describe('deleting one host copy of a repo id shared by two hosts', () => { expect(store.getRepos().map((repo) => repo.path)).toEqual(['/laptop/dup', '/remote/dup']) }) + it.each(['git', 'folder'] as const)( + 'removing the local %s row keeps the other host row and its metadata', + async (kind) => { + // A stale renderer catalog once sent id-only removal when it could not see the SSH twin (#13071). + const store = await createStoreFromState({ + repos: duplicateIdRepos().map((repo) => ({ ...repo, kind })) + }) + store.setWorktreeMetaForHost('dup::/remote/dup', 'ssh:ssh-1', { displayName: 'SSH wt' }) + + store.removeProjectForHost('dup', 'local') + expect(store.getRepos().map((repo) => repo.path)).toEqual(['/remote/dup']) + expect(Object.values(store.getAllWorktreeMeta()).map((meta) => meta.displayName)).toContain( + 'SSH wt' + ) + } + ) + it('removeProjectForHost drops only the addressed host row', async () => { const store = await createStoreWithDuplicateRepoId() diff --git a/src/main/persistence-orcad-migration-catalog.test.ts b/src/main/persistence-orcad-migration-catalog.test.ts index 30f089d6b74..cf8596ec69a 100644 --- a/src/main/persistence-orcad-migration-catalog.test.ts +++ b/src/main/persistence-orcad-migration-catalog.test.ts @@ -487,7 +487,7 @@ describe('orcad migration catalog persistence', () => { const store = createStore() const input = manifest() commitCatalog(store, input) - store.removeProject(REPOSITORY.id) + store.removeProjectForHost(REPOSITORY.id, 'local') expect(store.getOrcadMigrationCatalogState(input).state).toBe('committed') expect(store.getRepos()).toEqual([]) diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index a84e449b242..b76c827c198 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -107,7 +107,7 @@ describe('Store', () => { expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeAdd)).toBe(false) const beforeRemove = getLocalWorktreeScanGeneration(repoId) - store.removeProject(repoId) + store.removeProjectForHost(repoId, 'local') expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeRemove)).toBe(false) const beforeReAdd = getLocalWorktreeScanGeneration(repoId) @@ -121,7 +121,7 @@ describe('Store', () => { for (let index = 0; index < 200; index += 1) { const repoId = `scan-churn-${index}` store.addRepo(makeRepo({ id: repoId })) - store.removeProject(repoId) + store.removeProjectForHost(repoId, 'local') } expect(_getLocalWorktreeScanGenerationCacheSize()).toBe(initialCacheSize) @@ -278,9 +278,9 @@ describe('Store', () => { expect(store.getRepo('nonexistent')).toBeUndefined() }) - // ── 6. removeProject cleans up worktree meta ────────────────────────── + // ── 6. removeProjectForHost cleans up worktree meta ────────────────────────── - it('removeProject deletes the repo and its worktree meta', async () => { + it('removeProjectForHost deletes the repo and its worktree meta', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) @@ -289,7 +289,7 @@ describe('Store', () => { store.setWorktreeMeta('r1::/path/wt2', { displayName: 'wt2' }) store.setWorktreeMeta('r2::/other', { displayName: 'other' }) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') expect(store.getRepo('r1')).toBeUndefined() expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined() @@ -308,13 +308,13 @@ describe('Store', () => { ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [repoId]: 1 } }) - store.removeProject(repoId) + store.removeProjectForHost(repoId, 'local') } expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId).toEqual({}) }) - it('removeProject prunes the repo worktrees from workspace session state', async () => { + it('removeProjectForHost prunes the repo worktrees from workspace session state', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) @@ -327,14 +327,14 @@ describe('Store', () => { lastVisitedAtByWorktreeId: { 'r1::/path/wt1': 111, 'r2::/other': 222 } }) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') const session = store.getWorkspaceSession() expect(session.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined() expect(session.lastVisitedAtByWorktreeId?.['r2::/other']).toBe(222) }) - it('removeProject prunes the repo worktrees from per-host workspace session partitions', async () => { + it('removeProjectForHost prunes the repo worktrees from per-host workspace session partitions', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) @@ -349,7 +349,7 @@ describe('Store', () => { hostId ) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') const hostSession = store.getWorkspaceSession(hostId) expect(hostSession.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined() @@ -370,18 +370,18 @@ describe('Store', () => { ]) }) - it('removeProject removes the derived project host setup compatibility record', async () => { + it('removeProjectForHost removes the derived project host setup compatibility record', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r2']) expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual(['r2']) }) - it('removeProject deletes child and parent lineage for the repo', async () => { + it('removeProjectForHost deletes child and parent lineage for the repo', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) @@ -408,7 +408,7 @@ describe('Store', () => { }) ) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') expect(store.getWorktreeLineage('r1::/path/child')).toBeUndefined() expect(store.getWorktreeLineage('r2::/other-child')).toBeUndefined() diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index ef5ba9c2f89..a9890f66164 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -213,7 +213,7 @@ describe('Store', () => { } }) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') store.flush() expect(store.getMobileClientTabSelections()['device-a']).toEqual({ diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index 3bb572e9a34..6e976d1e5b5 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getDefaultPersistedState } from '../shared/constants' +import { getRepoExecutionHostId } from '../shared/execution-host' import { MARINE_CREATURES } from '../shared/marine-creatures' import { createRetiredNameLookup } from '../shared/worktree/retired-name-registry' import type { SshTarget } from '../shared/ssh-types' @@ -176,7 +177,7 @@ describe('worktree name retirement registry', () => { store.addRepo({ id: REPO, path: '/repos/a', displayName: 'a', badgeColor: '', addedAt: 0 }) store.addRetiredWorktreeName(REPO, 'nautilus') - store.removeProject(REPO) + store.removeProjectForHost(REPO, 'local') expect(store.getRetiredWorktreeNameRegistry(REPO).names).toEqual([]) }) @@ -195,7 +196,7 @@ describe('worktree name retirement registry', () => { const { getRetiredNameRegistryForRepo, retireGeneratedWorktreeName } = await import('./worktree-name-retirement') await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(oldRepo)) store.flush() store = await reloadStore() @@ -224,7 +225,7 @@ describe('worktree name retirement registry', () => { await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') // The deleted workspace has no Claude bucket; Codex rollout files are not backfilled. - store.removeProject(REPO) + store.removeProjectForHost(REPO, 'local') const newRepo = { ...oldRepo, id: OTHER_REPO } store.addRepo(newRepo) @@ -243,7 +244,7 @@ describe('worktree name retirement registry', () => { await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') // Removing and re-adding the host mints a fresh row id for the same machine and account. - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(oldRepo)) store.removeSshTarget('ssh-old') store.addSshTarget(sshTarget('ssh-new')) const newRepo = { ...oldRepo, id: OTHER_REPO, connectionId: 'ssh-new' } @@ -264,7 +265,7 @@ describe('worktree name retirement registry', () => { await import('./worktree-name-retirement') await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(oldRepo)) const otherRepo = { ...oldRepo, id: OTHER_REPO, connectionId: 'ssh-other' } store.addRepo(otherRepo) @@ -307,7 +308,7 @@ describe('worktree name retirement registry', () => { await retireGeneratedWorktreeName(store, repo, store.getSettings(), 'nautilus') // Drop the repo row, so the namespace copy is the only thing left holding the tombstone. - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(repo)) // The ssh-config alias is unchanged, so re-adoption matches even though the host moved. store.removeSshTarget('ssh-old') store.addRemovedSshTargetTombstone({ @@ -344,7 +345,7 @@ describe('worktree name retirement registry', () => { store.updateSshTarget('ssh-1', { host: 'new.example.com' }) // Drop the repo row the way a project remove/re-add does, leaving the mirror as the only source. - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(repo)) const readded = { ...REMOTE_REPO, id: OTHER_REPO, connectionId: 'ssh-1' } store.addRepo(readded) await expect( @@ -392,7 +393,7 @@ describe('worktree name retirement registry', () => { store.updateSshTarget(runtimeId, { host: 'vm-new.example.com' }) - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(repo)) const readded = { ...REMOTE_REPO, id: OTHER_REPO, connectionId: runtimeId } store.addRepo(readded) await expect( @@ -429,7 +430,7 @@ describe('worktree name retirement registry', () => { // `ssh-y` never moved. Drop its repo row the way a project remove/re-add does, so the namespace // copy is the only thing left holding the tombstone. - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(sibling)) const readded = { ...REMOTE_REPO, id: OTHER_REPO, connectionId: 'ssh-y' } store.addRepo(readded) await expect( @@ -447,7 +448,7 @@ describe('worktree name retirement registry', () => { await import('./worktree-name-retirement') await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(oldRepo)) const newRepo = { ...oldRepo, id: OTHER_REPO, connectionId: 'ssh-new' } store.addRepo(newRepo) diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts index d81fa4bd5cf..5531cb96a4e 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -150,6 +150,7 @@ const SshRemoteRuntimeResolutionSchema = z .object({ rung: z.enum(SSH_REMOTE_RUNTIME_RUNGS), pinnedRefusal: z.string().max(64).optional(), + refusedAt: z.number().int().nonnegative().optional(), glibc: z .string() .regex(/^\d+\.\d+$/) diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 1dde3076116..7aa43607282 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -62,33 +62,6 @@ export class RepoLifecycleOperations { return getRepoOrderOperations(this).reorderReposForHost(orderedIds, hostId) } - removeProject(id: string): void { - const repoRemoved = this[repoLifecycleOperationsContext].runtime.state.repos.some( - (repo) => repo.id === id - ) - this[repoLifecycleOperationsContext].runtime.state.repos = this[ - repoLifecycleOperationsContext - ].runtime.state.repos.filter((r) => r.id !== id) - if (repoRemoved) { - retireLocalWorktreeScanGeneration(id) - } - syncProjectHostSetupCompatibilityState(this) - delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] - delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] - pruneWorktreeStateForRepo(this, id, null) - this[repoLifecycleOperationsContext].runtime.state.workspaceSession = - removeRepoFromWorkspaceSession( - this[repoLifecycleOperationsContext].runtime.state.workspaceSession, - id - ) - this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = - removeRepoFromHostWorkspaceSessions( - this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, - id - ) - scheduleSave(this[repoLifecycleOperationsContext].scheduling) - } - removeProjectForHost(id: string, hostId: ExecutionHostId): void { const repoRemoved = this[repoLifecycleOperationsContext].runtime.state.repos.some( (repo) => repo.id === id && getRepoExecutionHostId(repo) === hostId diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 2d4694ba5ea..462c7c781a9 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -5,6 +5,7 @@ import { ORCA_IMAGE_PROTOCOL_VALUE } from '../../shared/terminal-image-protocol' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' +import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripPiProcessOwnerEnv } from '../pty/pi-process-owner-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' @@ -39,6 +40,7 @@ export function buildLocalPtySpawnEnvironment(args: { removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env) stripPiProcessOwnerEnv(spawnEnv) removeAppImageRuntimeEnv(spawnEnv) + removeChromiumDisabledSessionBus(spawnEnv) removeInheritedNoColor(spawnEnv) for (const key of spawn.envToDelete ?? []) { delete spawnEnv[key] diff --git a/src/main/providers/ssh-plain-shell-pty-provider.test.ts b/src/main/providers/ssh-plain-shell-pty-provider.test.ts index 064f37a5cef..4b223eb5578 100644 --- a/src/main/providers/ssh-plain-shell-pty-provider.test.ts +++ b/src/main/providers/ssh-plain-shell-pty-provider.test.ts @@ -133,6 +133,11 @@ describe('SshPlainShellPtyProvider', () => { await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('not active') }) + it('leaves a PTY it never minted unverifiable, since an earlier relay may still run it', async () => { + const { provider } = createProvider() + await expect(provider.probePtyLiveness('ssh:target-1@@prior-relay-pty')).resolves.toBeNull() + }) + it('starts a fresh shell for a reattach request and says the session expired', async () => { const { provider } = createProvider() const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:target-1@@pty-3' }) diff --git a/src/main/providers/ssh-plain-shell-pty-provider.ts b/src/main/providers/ssh-plain-shell-pty-provider.ts index c0fbe34372a..2272bfda36b 100644 --- a/src/main/providers/ssh-plain-shell-pty-provider.ts +++ b/src/main/providers/ssh-plain-shell-pty-provider.ts @@ -55,7 +55,8 @@ const LOST_NOTICE = export class SshPlainShellPtyProvider implements IPtyProvider { private readonly shells = new Map() - private readonly lost = new Set() + // Why only confirmed exits answer absent: an id this provider never minted may still run on an earlier relay. + private readonly exited = new Set() private readonly dataListeners = new Set() private readonly exitListeners = new Set() private disposed = false @@ -137,10 +138,10 @@ export class SshPlainShellPtyProvider implements IPtyProvider { this.shells.delete(id) if (shell.exitCode === null && !shell.closeRequested) { // Why: no exit-status means nothing proved the shell ended; report it lost, never exited. - this.lost.add(id) this.emitData(id, shell, LOST_NOTICE) return } + this.exited.add(id) const payload = { id, code: shell.exitCode ?? 0, @@ -180,7 +181,7 @@ export class SshPlainShellPtyProvider implements IPtyProvider { if (this.shells.has(id)) { return true } - return this.lost.has(id) ? null : false + return this.exited.has(id) ? false : null } write(id: string, data: string): boolean { @@ -304,10 +305,9 @@ export class SshPlainShellPtyProvider implements IPtyProvider { return } this.disposed = true - const open = [...this.shells] + const open = [...this.shells.values()] this.shells.clear() - for (const [id, shell] of open) { - this.lost.add(id) + for (const shell of open) { try { shell.channel.close() } catch { diff --git a/src/main/pty/chromium-session-bus-env.test.ts b/src/main/pty/chromium-session-bus-env.test.ts new file mode 100644 index 00000000000..ac2d54ad988 --- /dev/null +++ b/src/main/pty/chromium-session-bus-env.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' +import { removeChromiumDisabledSessionBus } from './chromium-session-bus-env' + +describe('removeChromiumDisabledSessionBus', () => { + it("drops Chromium's no-bus marker", () => { + const env: Record = { DBUS_SESSION_BUS_ADDRESS: 'disabled:', PATH: '/bin' } + removeChromiumDisabledSessionBus(env) + expect(env).toEqual({ PATH: '/bin' }) + }) + + it('keeps a real address', () => { + const env = { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' } + removeChromiumDisabledSessionBus(env) + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe('unix:path=/run/user/1000/bus') + }) +}) + +describe('daemon PTY environment', () => { + afterEach(() => vi.unstubAllEnvs()) + + // #21119: the daemon's own env used to hand the marker to every shell and agent. + it.each([ + ['inherited from the daemon', {}, true], + ['sent by the client', { DBUS_SESSION_BUS_ADDRESS: 'disabled:' }, false] + ])('strips the marker %s', (_label, explicitEnv, inherited) => { + if (inherited) { + vi.stubEnv('DBUS_SESSION_BUS_ADDRESS', 'disabled:') + } + const env = createDaemonPtyEnvironment({ sessionId: 's', cols: 80, rows: 24, env: explicitEnv }) + expect(env).not.toHaveProperty('DBUS_SESSION_BUS_ADDRESS') + }) + + it('keeps a real inherited address', () => { + vi.stubEnv('DBUS_SESSION_BUS_ADDRESS', 'unix:path=/run/user/1000/bus') + const env = createDaemonPtyEnvironment({ sessionId: 's', cols: 80, rows: 24 }) + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe('unix:path=/run/user/1000/bus') + }) +}) diff --git a/src/main/pty/chromium-session-bus-env.ts b/src/main/pty/chromium-session-bus-env.ts new file mode 100644 index 00000000000..d1057362a4c --- /dev/null +++ b/src/main/pty/chromium-session-bus-env.ts @@ -0,0 +1,9 @@ +/** What Chromium writes into its own env when it starts without a session bus. */ +const CHROMIUM_DISABLED_SESSION_BUS = 'disabled:' + +/** Drops Chromium's no-bus marker so children fall back to `$XDG_RUNTIME_DIR/bus`; a real address is kept. */ +export function removeChromiumDisabledSessionBus(env: Record): void { + if (env.DBUS_SESSION_BUS_ADDRESS === CHROMIUM_DISABLED_SESSION_BUS) { + delete env.DBUS_SESSION_BUS_ADDRESS + } +} diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 612b5fcada1..5e310ff19ce 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -16,6 +16,7 @@ import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import { RUNTIME_DEVICE_GRANTS, type RuntimeDeviceGrant } from './rpc/rpc-method-permission' import { parseMobilePushRegistration, type MobilePushRegistration @@ -38,6 +39,27 @@ export type DeviceEntry = { // Why: survives a desktop restart so the host can keep pushing without the phone // re-registering. Absent on every registry written before background push existed. pushRegistration?: MobilePushRegistration + // Why: administrative permissions are granted only when pairing; absent on older rows means none. + grants?: RuntimeDeviceGrant[] +} + +const GRANTABLE: ReadonlySet = new Set(RUNTIME_DEVICE_GRANTS) + +function isRuntimeDeviceGrant(value: unknown): value is RuntimeDeviceGrant { + return typeof value === 'string' && GRANTABLE.has(value) +} + +function validGrants(value: unknown, scope: DeviceScope): RuntimeDeviceGrant[] | undefined { + if (scope !== 'runtime' || !Array.isArray(value)) { + return undefined + } + const grants = [...new Set(value.filter(isRuntimeDeviceGrant))].sort() + return grants.length > 0 ? grants : undefined +} + +function sameGrants(entry: DeviceEntry, grants: readonly RuntimeDeviceGrant[]): boolean { + const current = entry.grants ?? [] + return current.length === grants.length && current.every((grant) => grants.includes(grant)) } function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { @@ -83,17 +105,20 @@ export class DeviceRegistry { addDevice( name: string, scope: DeviceScope = 'mobile', - pairingReach: RuntimePairingReach = 'network' + pairingReach: RuntimePairingReach = 'network', + grants: readonly RuntimeDeviceGrant[] = [] ): DeviceEntry { - return this.createAndPersistDevice(this.devices, name, scope, pairingReach) + return this.createAndPersistDevice(this.devices, name, scope, pairingReach, grants) } private createAndPersistDevice( existingDevices: DeviceEntry[], name: string, scope: DeviceScope, - pairingReach: RuntimePairingReach + pairingReach: RuntimePairingReach, + grants: readonly RuntimeDeviceGrant[] ): DeviceEntry { + const validatedGrants = validGrants(grants, scope) const entry: DeviceEntry = { deviceId: randomUUID(), name, @@ -101,7 +126,8 @@ export class DeviceRegistry { scope, pairedAt: Date.now(), lastSeenAt: 0, - pairingReach + pairingReach, + ...(validatedGrants ? { grants: validatedGrants } : {}) } const nextDevices = [...existingDevices, entry] // Why: a credential is not valid until its durable registry write succeeds. @@ -119,9 +145,14 @@ export class DeviceRegistry { getOrCreatePendingDevice( name: string, scope: DeviceScope = 'mobile', - pairingReach: RuntimePairingReach = 'network' + pairingReach: RuntimePairingReach = 'network', + grants: readonly RuntimeDeviceGrant[] = [] ): DeviceEntry { - const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope) + // Why: a pending token is reused only for the same grants, so re-advertising never widens one. + const existing = this.devices.find( + (d) => + d.lastSeenAt === 0 && d.scope === scope && sameGrants(d, validGrants(grants, scope) ?? []) + ) if (existing) { // Why: the same pending token can be re-advertised at a broader reach; widen it but never narrow it, // or a link already handed out for off-host use would stop being served after the next launch. @@ -129,7 +160,7 @@ export class DeviceRegistry { ? this.setPairingReach(existing, 'network') : existing } - return this.addDevice(name, scope, pairingReach) + return this.addDevice(name, scope, pairingReach, grants) } private setPairingReach(existing: DeviceEntry, pairingReach: RuntimePairingReach): DeviceEntry { @@ -153,10 +184,11 @@ export class DeviceRegistry { rotatePendingDevice( name: string, scope: DeviceScope = 'mobile', - pairingReach: RuntimePairingReach = 'network' + pairingReach: RuntimePairingReach = 'network', + grants: readonly RuntimeDeviceGrant[] = [] ): DeviceEntry { const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) - return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach) + return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach, grants) } removeDevice(deviceId: string): boolean { @@ -325,6 +357,7 @@ export class DeviceRegistry { // Why: older registries only existed for phone pairing. Treat missing // scope as mobile so legacy device tokens do not gain new CLI powers. scope: device.scope === 'runtime' ? 'runtime' : 'mobile', + grants: validGrants(device.grants, device.scope === 'runtime' ? 'runtime' : 'mobile'), relayBinding: validRelayBinding(device.relayBinding, device.deviceId), mobilePairingConnectionMode: device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic', diff --git a/src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts b/src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts new file mode 100644 index 00000000000..37078ee435b --- /dev/null +++ b/src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts @@ -0,0 +1,57 @@ +// #11315: SSH TUI repaints reach Electron main through onPtyData; tail work must stay linear in bytes. +import { describe, expect, it, vi } from 'vitest' +import * as ansiNormalization from './terminal-ansi-normalization' +import { readPtyTail, runtimeWithLeaf } from './orca-runtime-pty-leaf.test-fixture' + +const ESC = '\x1b' +// An ask_user-style panel: climb to the top, then clear and repaint each full-width row. +function panelFrames(width: number, frames: number): string { + const frame = `${ESC}[4A${['title', 'option a', 'option b', 'hint'] + .map((label) => `\r${ESC}[2K${label} ${'─'.repeat(width - label.length - 1)}\n`) + .join('')}` + return frame.repeat(frames) +} + +describe('onPtyData redraw cost', () => { + it('replays a full-history TUI redraw without splicing the tail once per line', async () => { + const ptyId = 'pty-replay' + const { runtime } = runtimeWithLeaf(ptyId) + try { + runtime.onPtyData(ptyId, 'history\n'.repeat(2_000), 0) + // The panel leaves the cursor parked inside it, so later chunks take the redraw model. + runtime.onPtyData(ptyId, `${panelFrames(120, 1)}${ESC}[2A`, 1) + const replay = Array.from({ length: 20_000 }, (_, index) => `line ${index}\r\n`).join('') + const splice = vi.spyOn(Array.prototype, 'splice') + let spliceCalls: number + try { + runtime.onPtyData(ptyId, `${ESC}[2J${ESC}[H${ESC}[3J${replay}`, 2) + spliceCalls = splice.mock.calls.length + } finally { + splice.mockRestore() + } + // One splice per capped row made a replay cost O(lines x tail cap) on Electron main. + expect(spliceCalls).toBeLessThan(100) + expect(readPtyTail(runtime, ptyId).at(-1)).toBe('line 19999') + } finally { + await runtime.onPtyExit(ptyId, 0) + } + }) + + it('normalizes a chunk once when a diverged leaf carries the same escape prefix', async () => { + const ptyId = 'pty-diverged' + const { runtime, leaf } = runtimeWithLeaf(ptyId) + // Different retained history sends the leaf down its own tail update. + leaf.tailBuffer = ['leaf-only history'] + leaf.tailLinesTotal = 1 + const normalize = vi.spyOn(ansiNormalization, 'normalizeTerminalChunk') + try { + const chunk = panelFrames(80, 3) + runtime.onPtyData(ptyId, chunk, 1_000) + expect(normalize.mock.calls.filter(([data]) => data === chunk)).toHaveLength(1) + expect(leaf.tailBuffer).toContain(`hint ${'─'.repeat(75)}`) + } finally { + normalize.mockRestore() + await runtime.onPtyExit(ptyId, 0) + } + }) +}) diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index c3dfd7b2cac..e665d55ce10 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -86,11 +86,13 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution } : null let ptyTailAfter: ReturnType | null = null + let ptyNormalized: ReturnType | null = null if (pty) { pty.connected = true pty.disconnectedAt = null pty.lastOutputAt = at const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) + ptyNormalized = normalized pty.tailPendingAnsi = normalized.pendingAnsi observeTerminalCommandPaint(pty, data, normalized.text) const nextTail = appendNormalizedToTailBuffer( @@ -162,7 +164,11 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution // mismatch branch below recomputes an exact state on its next chunk. leaf.tailWaitState = pty.tailWaitState } else { - const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) + // Why: the same carried escape prefix normalizes the same chunk identically. + const normalized = + ptyNormalized && ptyTailBefore?.pendingAnsi === leaf.tailPendingAnsi + ? ptyNormalized + : normalizeTerminalChunk(data, leaf.tailPendingAnsi) leaf.tailPendingAnsi = normalized.pendingAnsi const previousWaitState = leaf.tailWaitState?.fromTail === true diff --git a/src/main/runtime/orca-runtime-pty-leaf.test-fixture.ts b/src/main/runtime/orca-runtime-pty-leaf.test-fixture.ts new file mode 100644 index 00000000000..09181e450a6 --- /dev/null +++ b/src/main/runtime/orca-runtime-pty-leaf.test-fixture.ts @@ -0,0 +1,40 @@ +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimeLeafRecord } from './runtime-terminal-state-records' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +/** A runtime whose single tab leaf is bound to `ptyId`, so onPtyData updates PTY and leaf tails. */ +export function runtimeWithLeaf(ptyId: string): { + runtime: OrcaRuntimeService + leaf: RuntimeLeafRecord +} { + const runtime = new OrcaRuntimeService() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [{ tabId: 'tab-1', worktreeId: 'wt-1', activeLeafId: LEAF_ID, layout: null, title: '' }], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId, + paneTitle: null + } + ] + }) + const leaves = runtime['leaves'] + const leaf = leaves.values().next().value + if (leaves.size !== 1 || !leaf) { + throw new Error('Expected exactly one runtime leaf') + } + return { runtime, leaf } +} + +export function readPtyTail(runtime: OrcaRuntimeService, ptyId: string): string[] { + const lines = runtime['ptysById'].get(ptyId)?.tailBuffer + if (!lines) { + throw new Error('PTY record has no tail') + } + return lines.map(String) +} diff --git a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts index c9879bf3d7c..31294118b60 100644 --- a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts +++ b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts @@ -267,7 +267,7 @@ describe('OrcaRuntimeService', () => { Object.assign(repo, updates) return { ...repo } as never }, - removeProject: (id: string) => { + removeProjectForHost: (id: string) => { const index = repos.findIndex((repo) => repo.id === id) if (index !== -1) { repos.splice(index, 1) @@ -309,7 +309,7 @@ describe('OrcaRuntimeService', () => { const runtimeStore = { ...store, getRepos: () => [...repos] as never, - removeProject: (id: string) => { + removeProjectForHost: (id: string) => { const index = repos.findIndex((repo) => repo.id === id) if (index !== -1) { repos.splice(index, 1) diff --git a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts index b11ce57dcfb..649d7de4116 100644 --- a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts +++ b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' +import { mulberry32 } from '../../shared/agent-tui-ansi-fuzz-stream' import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' -import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer' +import { MAX_TAIL_PARTIAL_CHARS } from './terminal-tail-limits' +import { + appendNormalizedToMultilineTailBufferUnwindowed, + type RetainedTailRedrawCursor +} from './terminal-tail-redraw-buffer' // Differential guard for the windowed redraw tail path: the public // appendNormalizedToTailBuffer routes vertical-control chunks through a @@ -9,16 +14,6 @@ import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail // This fuzz asserts the windowed result is byte-identical to the reference // implementation across randomized tails and redraw chunks. -function mulberry32(seed: number): () => number { - let a = seed >>> 0 - return () => { - a = (a + 0x6d2b79f5) | 0 - let t = Math.imul(a ^ (a >>> 15), 1 | a) - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } -} - function randomTail(rng: () => number, maxLines: number): string[] { const count = Math.floor(rng() * maxLines) return Array.from({ length: count }, (_, i) => { @@ -55,6 +50,25 @@ function randomRedrawChunk(rng: () => number): string { return parts.join('') } +function expectMatchesUnwindowed( + tail: string[], + partial: string, + chunk: string, + redrawCursor: RetainedTailRedrawCursor | null, + label: string +): void { + const actual = appendNormalizedToTailBuffer(tail, partial, chunk, redrawCursor) + // Reference path over the full tail. + const expected = appendNormalizedToMultilineTailBufferUnwindowed( + tail, + partial.slice(-MAX_TAIL_PARTIAL_CHARS), + chunk, + partial.length > MAX_TAIL_PARTIAL_CHARS, + redrawCursor + ) + expect(actual, label).toEqual(expected) +} + describe('windowed redraw tail equivalence', () => { it('matches the unwindowed reference across 500 randomized cases', () => { const rng = mulberry32(42) @@ -68,24 +82,28 @@ describe('windowed redraw tail equivalence', () => { // without one take the single-line fast path, which is out of scope. const chunk = `\x1b[${1 + Math.floor(rng() * 4)}A${randomRedrawChunk(rng)}` - const actual = appendNormalizedToTailBuffer(tail, partial, chunk, redrawCursor) - // Reference path over the full tail. - const expected = appendNormalizedToMultilineTailBufferUnwindowed( - tail, - partial.slice(-4000), - chunk, - partial.length > 4000, - redrawCursor - ) + expectMatchesUnwindowed(tail, partial, chunk, redrawCursor, `round ${round}`) + } + }) - expect(actual.lines, `round ${round} lines`).toEqual(expected.lines) - expect(actual.partialLine, `round ${round} partial`).toBe(expected.partialLine) - expect(actual.redrawCursor, `round ${round} cursor`).toEqual(expected.redrawCursor) - expect(actual.truncated, `round ${round} truncated`).toBe(expected.truncated) - expect(actual.newCompleteLines, `round ${round} newLines`).toBe(expected.newCompleteLines) - expect(actual.newlyCompletedLines, `round ${round} completedLines`).toEqual( - expected.newlyCompletedLines - ) + it('matches the reference when a chunk repeats multi-row repaint frames', () => { + const rng = mulberry32(7) + for (let round = 0; round < 80; round++) { + // Why below the cap: both paths trim a full tail differently mid-chunk, which is out of scope. + const tail = randomTail(rng, 1900) + const partial = rng() < 0.5 ? `partial ${'z'.repeat(Math.floor(rng() * 30))}` : '' + const redrawCursor = + rng() < 0.3 ? { rowFromEnd: Math.floor(rng() * 20), column: Math.floor(rng() * 40) } : null + // Why repeated frames: their summed cursor-ups exceed the tail while the net reach does not, + // so only the net-reach window keeps these chunks windowed. + const panelRows = 1 + Math.floor(rng() * 30) + const frame = `\x1b[${panelRows}A${Array.from( + { length: panelRows + Math.floor(rng() * 3) - 1 }, + () => `\r${randomRedrawChunk(rng).replace(/\n/g, '')}\n` + ).join('')}` + const chunk = frame.repeat(1 + Math.floor(rng() * 40)) + + expectMatchesUnwindowed(tail, partial, chunk, redrawCursor, `round ${round}`) } }) }) diff --git a/src/main/runtime/rpc/core-typed-method-contract.test.ts b/src/main/runtime/rpc/core-typed-method-contract.test.ts index dc2b72bea57..b93c5efe2d6 100644 --- a/src/main/runtime/rpc/core-typed-method-contract.test.ts +++ b/src/main/runtime/rpc/core-typed-method-contract.test.ts @@ -20,18 +20,21 @@ const ProbeParams = z.object({ id: z.string(), count: z.number().optional() }) const probe = defineMethod({ name: 'test.typedProbe', + permission: 'workspace', params: ProbeParams, handler: (params) => ({ id: params.id, count: params.count ?? 0 }) }) const schemalessProbe = defineMethod({ name: 'test.schemalessProbe', + permission: 'workspace', params: null, handler: () => ['a', 'b'] }) const streamingProbe = defineStreamingMethod({ name: 'test.streamingProbe', + permission: 'workspace', params: ProbeParams, handler: async (params, _ctx, emit) => { emit(params.id) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 006bb7a982d..82df98666bc 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -12,6 +12,7 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import type { RpcCallerIdentity } from './rpc-caller-identity' +import type { RpcMethodPermission } from './rpc-method-permission' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -134,6 +135,8 @@ type RpcParsedParams = TSchema extends ZodType // Why: the authored shape — literal name, params schema, and producer result all survive for compile-time contracts. export type RpcTypedMethod = { readonly name: TName + // Why: required so a new method cannot ship without deciding which callers may reach it. + readonly permission: RpcMethodPermission readonly params: TSchema readonly handler: RpcHandler, TResult> } @@ -143,6 +146,7 @@ export function defineMethod { return { name: spec.name, + permission: spec.permission, params: spec.params, handler: spec.handler } @@ -157,6 +161,7 @@ export type RpcStreamingHandler = ( // Why: emitted values stay `unknown` — the emit callback is an input, so there is no return position to infer them from. export type RpcTypedStreamingMethod = { readonly name: TName + readonly permission: RpcMethodPermission readonly params: TSchema readonly stream: true readonly handler: RpcStreamingHandler> @@ -167,6 +172,7 @@ export function defineStreamingMethod { return { name: spec.name, + permission: spec.permission, params: spec.params, stream: true, handler: spec.handler @@ -177,12 +183,14 @@ export function defineStreamingMethod unknown } export type RpcStreamingMethodDeclaration = { readonly name: string + readonly permission: RpcMethodPermission readonly params: ZodType | null readonly stream: true readonly handler: ( @@ -197,6 +205,7 @@ export type RpcAnyMethodDeclaration = RpcMethodDeclaration | RpcStreamingMethodD // Why: RpcMethod is the registry's erased view; the dispatcher parses params itself and hands handlers `unknown`. export type RpcMethod = { readonly name: string + readonly permission: RpcMethodPermission readonly params: ZodType | null readonly handler: (params: unknown, ctx: RpcContext) => unknown } @@ -204,6 +213,7 @@ export type RpcMethod = { // Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path. export type RpcStreamingMethod = { readonly name: string + readonly permission: RpcMethodPermission readonly params: ZodType | null readonly stream: true readonly handler: ( diff --git a/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts b/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts index 35d8f5bb097..f9b48c49108 100644 --- a/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts +++ b/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts @@ -9,6 +9,7 @@ const handler = vi.fn(() => ({ source: 'server' })) const methods = [ defineMethod({ name: 'browser.click', + permission: 'workspace', params: z.object({ page: z.string(), x: z.number() }), handler }) diff --git a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts index 9640352faf7..247f24cb980 100644 --- a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts +++ b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts @@ -18,22 +18,26 @@ function makeRuntime(): OrcaRuntimeService { const METHODS = [ defineMethod({ name: 'computer.click', + permission: 'desktop-control', params: z.object({ app: z.string().min(1, 'Missing app') }), handler: () => ({ ok: true }) }), defineMethod({ name: 'browser.click', + permission: 'workspace', params: z.object({ page: z.string().min(1, 'Missing page') }), handler: () => ({ ok: true }) }), defineMethod({ name: 'orchestration.throwZod', + permission: 'workspace', params: z.object({}), handler: () => z.object({ title: z.string().min(1, 'Handler title missing') }).parse({ title: '' }) }), defineMethod({ name: 'orchestration.invalidArgument', + permission: 'workspace', params: z.object({}), handler: () => { throw new InvalidArgumentError('Async validation rejected payload') @@ -41,6 +45,7 @@ const METHODS = [ }), defineMethod({ name: 'orchestration.inspectCaller', + permission: 'workspace', params: z.object({}), handler: (_params, { authenticatedCallerFingerprint }) => ({ authenticatedCallerFingerprint @@ -48,6 +53,7 @@ const METHODS = [ }), defineMethod({ name: 'orchestration.federationInspectCaller', + permission: 'workspace', params: z.object({}), handler: (_params, { authenticatedCallerFingerprint }) => ({ authenticatedCallerFingerprint diff --git a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts index c4c9433b939..1f2cc987da8 100644 --- a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -58,31 +58,37 @@ function makeRuntime(ui: PersistedUIState = getDefaultUIState()): OrcaRuntimeSer const METHODS = [ defineMethod({ name: 'browser.click', + permission: 'workspace', params: z.object({}), handler: () => ({ clicked: true }) }), defineMethod({ name: 'browser.tabCreate', + permission: 'workspace', params: z.object({}), handler: () => ({ browserPageId: 'page-1' }) }), defineMethod({ name: 'browser.tabShow', + permission: 'workspace', params: z.object({}), handler: () => ({ tab: { id: 'page-1' } }) }), defineMethod({ name: 'browser.viewport', + permission: 'workspace', params: z.object({}), handler: () => ({ ok: true }) }), defineMethod({ name: 'browser.eval', + permission: 'workspace', params: z.object({}), handler: () => ({ value: 'ok' }) }), defineStreamingMethod({ name: 'browser.screencast', + permission: 'workspace', params: z.object({}), handler: async (_params, _options, emit) => { emit({ type: 'frame' }) @@ -91,51 +97,61 @@ const METHODS = [ }), defineStreamingMethod({ name: 'browser.screencast.binaryOnly', + permission: 'workspace', params: z.object({}), handler: async () => {} }), defineMethod({ name: 'browser.screencast.unsubscribe', + permission: 'workspace', params: z.object({}), handler: () => ({ ok: true }) }), defineMethod({ name: 'browser.profileImportFromBrowser', + permission: 'workspace', params: z.object({}), handler: () => ({ ok: true }) }), defineMethod({ name: 'browser.profileList', + permission: 'workspace', params: z.object({}), handler: () => ({ profiles: [] }) }), defineMethod({ name: 'browser.profileClearDefaultCookies', + permission: 'workspace', params: z.object({}), handler: () => ({ cleared: false }) }), defineMethod({ name: 'computer.permissions', + permission: 'desktop-control', params: z.object({}), handler: () => ({ opened: true }) }), defineMethod({ name: 'computer.permissionsStatus', + permission: 'workspace', params: z.object({}), handler: () => ({ permissions: [] }) }), defineMethod({ name: 'computer.click', + permission: 'desktop-control', params: z.object({}), handler: () => ({ clicked: true }) }), defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: z.object({}), handler: () => ({ id: 'msg-1' }) }), defineMethod({ name: 'browser.fail', + permission: 'workspace', params: z.object({}), handler: () => { throw new Error('nope') diff --git a/src/main/runtime/rpc/dispatcher-request-parsing.test.ts b/src/main/runtime/rpc/dispatcher-request-parsing.test.ts index e3df920a39a..0185420099b 100644 --- a/src/main/runtime/rpc/dispatcher-request-parsing.test.ts +++ b/src/main/runtime/rpc/dispatcher-request-parsing.test.ts @@ -13,6 +13,7 @@ describe('parseRpcRequestParams', () => { it('parses repeated valid requests through the compiled schema', () => { const method = defineMethod({ name: 'test.parse', + permission: 'workspace', params: z.object({ requestId: z.string().min(1), count: z.number().int().nonnegative() }), handler: () => null }) @@ -28,6 +29,7 @@ describe('parseRpcRequestParams', () => { it('preserves validation errors from the runtime fallback', () => { const method = defineMethod({ name: 'test.parse', + permission: 'workspace', params: z.object({ count: z.number().int('Count must be an integer') }), handler: () => null }) @@ -43,6 +45,7 @@ describe('parseRpcRequestParams', () => { it('keeps transform output when compilation falls back', () => { const method = defineMethod({ name: 'test.parse', + permission: 'workspace', params: z.object({ value: z.string().transform((value) => value.toUpperCase()) }), handler: () => null }) diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index cca01d83588..af8a87a8edb 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -2,6 +2,7 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { PairingRpcContext } from './core' import type { RpcCallerIdentity } from './rpc-caller-identity' +import type { RpcCallerScope } from './rpc-caller-scope' export type RpcDispatchStreamingOptions = { authenticatedCallerFingerprint?: string @@ -11,6 +12,8 @@ export type RpcDispatchStreamingOptions = { pairedDeviceId?: string /** Set by a transport that knows its caller but carries no paired device (the desktop's IPC). */ caller?: RpcCallerIdentity + /** What the transport proved the caller may do; absent means this host's owner. */ + callerScope?: RpcCallerScope clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 4c87ceaaf50..7920166d119 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -25,6 +25,12 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' import { resolveRpcCallerIdentity } from './rpc-caller-identity' +import { + bindRpcCallToCallerScope, + denyRpcMethodForCaller, + OWNER_RPC_CALLER_SCOPE, + type RpcCallerScope +} from './rpc-caller-scope' import { needsOrchestrationCallerResolution, resolveOrchestrationSessionCaller, @@ -34,6 +40,8 @@ import { export type DispatcherOptions = { runtime: OrcaRuntimeService methods?: readonly RpcAnyMethodDeclaration[] + /** Pins every call to this scope, for in-process bridges that relay a non-owner caller. */ + callerScope?: RpcCallerScope } type DispatchCallOptions = RpcDispatchStreamingOptions @@ -44,9 +52,11 @@ export class RpcDispatcher { private readonly orchestrationMutations: OrchestrationMutationExecutor private readonly legacyOrchestration: OrchestrationLegacyCompatibility private readonly streamingDispatcher: RpcStreamingDispatcher + private readonly pinnedCallerScope: RpcCallerScope | undefined - constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) { + constructor({ runtime, methods = ALL_RPC_METHODS, callerScope }: DispatcherOptions) { this.runtime = runtime + this.pinnedCallerScope = callerScope this.registry = buildRegistry(methods) this.orchestrationMutations = getOrchestrationMutationExecutor(runtime) this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime) @@ -55,13 +65,19 @@ export class RpcDispatcher { registry: this.registry, orchestrationMutations: this.orchestrationMutations, legacyOrchestration: this.legacyOrchestration, - meta: () => this.meta() + meta: () => this.meta(), + pinnedCallerScope: callerScope }) } async dispatch(request: RpcRequest, options?: DispatchCallOptions): Promise { const meta = this.meta() const method = this.registry.get(request.method) + const callerScope = this.pinnedCallerScope ?? options?.callerScope ?? OWNER_RPC_CALLER_SCOPE + const denial = denyRpcMethodForCaller(callerScope, request.method, method?.permission) + if (denial) { + return errorResponse(request.id, meta, 'forbidden', denial) + } if (!method) { return errorResponse( request.id, @@ -98,6 +114,17 @@ export class RpcDispatcher { ) } + const pendingBinding = bindRpcCallToCallerScope( + callerScope, + this.runtime, + request.method, + parsedParams.value + ) + const binding = pendingBinding ? await pendingBinding : null + if (binding?.kind === 'denied') { + return errorResponse(request.id, meta, 'forbidden', binding.message) + } + if (request.method.startsWith('emulator.')) { emulatorProbe(`rpc ${request.method}`, request.params) } @@ -127,7 +154,11 @@ export class RpcDispatcher { orchestrationMutations: this.orchestrationMutations, legacyOrchestration: this.legacyOrchestration }) - return successResponse(request.id, meta, result) + const filtered = binding?.filterResult?.(result) ?? { kind: 'allowed', result } + if (filtered.kind === 'denied') { + return errorResponse(request.id, meta, 'forbidden', filtered.message) + } + return successResponse(request.id, meta, filtered.result) } catch (error) { if (request.method.startsWith('emulator.')) { emulatorProbeError(`rpc ${request.method}`, error, { params: request.params }) diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 786bc73985d..7c97c36ac40 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -25,6 +25,7 @@ export const ACCOUNT_METHODS = [ // Why local only: sign-in runs `claude auth login` in a terminal on the execution host. defineMethod({ name: 'accounts.beginClaudeSignIn', + permission: 'accounts-admin', params: BeginClaudeSignInParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -35,6 +36,7 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.finishClaudeSignIn', + permission: 'accounts-admin', params: FinishClaudeSignInParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -45,6 +47,7 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.cancelClaudeSignIn', + permission: 'accounts-admin', params: FinishClaudeSignInParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -56,11 +59,13 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.listData', + permission: 'workspace', params: null, handler: async (_, { runtime }) => runtime.getDataAccountsSnapshot() }), defineMethod({ name: 'accounts.addDataFromHome', + permission: 'accounts-admin', params: AddDataAccountParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -71,18 +76,21 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.selectData', + permission: 'accounts-admin', params: SelectDataAccountParams, handler: async (params, { runtime }) => runtime.selectDataAccount(params.provider, params.accountId) }), defineMethod({ name: 'accounts.removeData', + permission: 'accounts-admin', params: RemoveDataAccountParams, handler: async (params, { runtime }) => runtime.removeDataAccount(params.provider, params.accountId) }), defineMethod({ name: 'accounts.list', + permission: 'workspace', params: ListAccountsParams, handler: async (params, { runtime }) => { // Why: ensure the snapshot reflects the latest provider state before @@ -97,11 +105,13 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.selectClaude', + permission: 'accounts-admin', params: SelectAccountParams, handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId) }), defineMethod({ name: 'accounts.selectCodex', + permission: 'accounts-admin', params: SelectAccountParams, handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId) }), @@ -109,28 +119,33 @@ export const ACCOUNT_METHODS = [ // Why: old hosts silently strip unknown target fields from selectCodex. // A distinct RPC makes version skew fail before it can clear the host slot. name: 'accounts.selectCodexForTarget', + permission: 'accounts-admin', params: SelectCodexAccountForTargetParams, handler: async (params, { runtime }) => runtime.selectCodexAccountForTarget(params.accountId, params.target) }), defineMethod({ name: 'accounts.consumeCodexResetCredit', + permission: 'accounts-admin', params: ConsumeCodexResetCreditParams, handler: async (params, { runtime }) => runtime.consumeCodexRateLimitResetCredit(params.idempotencyKey, params.expectedScope) }), defineMethod({ name: 'accounts.removeClaude', + permission: 'accounts-admin', params: RemoveAccountParams, handler: async (params, { runtime }) => runtime.removeClaudeAccount(params.accountId) }), defineMethod({ name: 'accounts.removeCodex', + permission: 'accounts-admin', params: RemoveAccountParams, handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId) }), defineMethod({ name: 'accounts.addClaudeFromConfigDir', + permission: 'accounts-admin', params: AddClaudeFromConfigDirParams, handler: async () => { throw new Error( @@ -140,6 +155,7 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.addCodexFromHome', + permission: 'accounts-admin', params: AddCodexFromHomeParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -156,6 +172,7 @@ export const ACCOUNT_METHODS = [ // accounts on either side. Mirrors the notifications.subscribe pattern. defineStreamingMethod({ name: 'accounts.subscribe', + permission: 'workspace', params: null, handler: async (_params, { runtime, connectionId }, emit) => { await new Promise((resolve) => { @@ -191,6 +208,7 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.unsubscribe', + permission: 'workspace', params: AccountsUnsubscribeParams, handler: async (params, { runtime }) => { runtime.cleanupSubscription(params.subscriptionId) diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts index 59273cf8715..388ba3736b7 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -6,6 +6,7 @@ import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/ag export const AGENT_HOOK_METHODS = [ defineMethod({ name: 'agentHooks.prepareCodexForWslPane', + permission: 'workspace', params: PrepareCodexForWslPaneParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 15aeb28bd12..0de59791663 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -305,6 +305,7 @@ function runReplaySafeAgentLaunch( export const AGENT_LAUNCH_METHODS = [ defineMethod({ name: 'agent.launchReplay', + permission: 'workspace', params: AgentLaunchReplay, handler: async (params, context): Promise => { if (!supportsAgentLaunch(context)) { @@ -334,6 +335,7 @@ export const AGENT_LAUNCH_METHODS = [ }), defineMethod({ name: 'agent.launch', + permission: 'workspace', params: AgentLaunch, handler: async (params, context): Promise => { if (!supportsAgentLaunch(context)) { diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts index 84e008a7f55..3c9854afb6c 100644 --- a/src/main/runtime/rpc/methods/agent-session.ts +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -61,6 +61,7 @@ function assertOperationTimestampWithinFutureSkew(clientOperationId: string): vo export const AGENT_SESSION_METHODS = [ defineMethod({ name: 'terminal.ensureAgentSession', + permission: 'workspace', params: EnsureAgentSessionParams, handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => (runtime as AgentSessionRuntime).ensureAgentSession( @@ -70,6 +71,7 @@ export const AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'terminal.createAgentSession', + permission: 'workspace', params: CreateAgentSessionParams, handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => { assertOperationTimestampWithinFutureSkew(params.clientOperationId) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index af6ff78b23a..01e0cbe0e92 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -29,6 +29,7 @@ export { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, AiVaultSe export const AI_VAULT_METHODS = [ defineMethod({ name: 'aiVault.searchSessions', + permission: 'workspace', params: AiVaultSearchRequestSchema, handler: (params, { runtime, clientKind, clientCapabilities }) => { const response = searchSessionService(params, clientKind ? 'relay' : 'runtime') @@ -41,12 +42,14 @@ export const AI_VAULT_METHODS = [ }), defineMethod({ name: 'aiVault.searchStatus', + permission: 'workspace', params: AiVaultSearchStatusRequestSchema, handler: (params, { clientKind }) => sessionSearchServiceStatus(params, clientKind ? 'relay' : 'runtime') }), defineMethod({ name: 'aiVault.setSearchEnabled', + permission: 'settings-write', params: AiVaultSetSearchEnabledParamsSchema, handler: async (params, { runtime, clientKind, pairedDeviceId }) => { // Paired clients only: an in-process caller writes this host's own settings directly, @@ -66,12 +69,14 @@ export const AI_VAULT_METHODS = [ }), defineMethod({ name: 'aiVault.resolveSessionTitles', + permission: 'workspace', params: AiVaultSessionTitlesParams, handler: (params, { runtime, signal }) => runtime.resolveAiVaultSessionTitles(params.requests, signal) }), defineMethod({ name: 'aiVault.listSessions', + permission: 'workspace', params: AiVaultListSessionsParams, handler: async (params, { runtime, clientKind, clientCapabilities }) => { await ensureStructuredAgentSessionHostUnlessRefused(() => @@ -107,6 +112,7 @@ export const AI_VAULT_METHODS = [ }), defineMethod({ name: 'aiVault.prepareSessionResume', + permission: 'workspace', params: AiVaultPrepareSessionResumeParams, handler: async (params, { runtime }) => { const args: AiVaultPrepareSessionResumeArgs = { diff --git a/src/main/runtime/rpc/methods/antigravity-accounts.ts b/src/main/runtime/rpc/methods/antigravity-accounts.ts index 18d6205c652..fc0e8c66adb 100644 --- a/src/main/runtime/rpc/methods/antigravity-accounts.ts +++ b/src/main/runtime/rpc/methods/antigravity-accounts.ts @@ -8,22 +8,26 @@ import { getAntigravityAccountService } from '../../../antigravity/native-accoun export const ANTIGRAVITY_ACCOUNT_METHODS = [ defineMethod({ name: 'accounts.antigravityList', + permission: 'workspace', params: AntigravityAccountTargetParams, handler: async (target) => getAntigravityAccountService(target).listAccounts() }), defineMethod({ name: 'accounts.antigravityAddCurrent', + permission: 'accounts-admin', params: AntigravityAccountTargetParams, handler: async (target) => getAntigravityAccountService(target).addCurrentAccount() }), defineMethod({ name: 'accounts.antigravitySelect', + permission: 'accounts-admin', params: AntigravityAccountMutationParams, handler: async ({ target, accountId }) => getAntigravityAccountService(target).selectAccount(accountId) }), defineMethod({ name: 'accounts.antigravityRemove', + permission: 'accounts-admin', params: AntigravityAccountMutationParams, handler: async ({ target, accountId }) => getAntigravityAccountService(target).removeAccount(accountId) diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts index b6b91af8b0b..7eda8a6ecd4 100644 --- a/src/main/runtime/rpc/methods/artifacts.ts +++ b/src/main/runtime/rpc/methods/artifacts.ts @@ -9,36 +9,43 @@ import { export const ARTIFACT_METHODS = [ defineMethod({ name: 'artifacts.list', + permission: 'workspace', params: ListOptions, handler: (params, { runtime }) => runtime.listArtifacts(params) }), defineMethod({ name: 'artifacts.getPublishedLink', + permission: 'workspace', params: SourceRequest, handler: (params, { runtime }) => runtime.getPublishedArtifactLink(params) }), defineMethod({ name: 'artifacts.share', + permission: 'workspace', params: WriteRequest, handler: (params, { runtime }) => runtime.shareArtifact(params) }), defineMethod({ name: 'artifacts.publish', + permission: 'workspace', params: WriteRequest, handler: (params, { runtime }) => runtime.publishArtifact(params) }), defineMethod({ name: 'artifacts.update', + permission: 'workspace', params: WriteRequest, handler: (params, { runtime }) => runtime.updateArtifact(params) }), defineMethod({ name: 'artifacts.unshare', + permission: 'workspace', params: SourceRequest, handler: (params, { runtime }) => runtime.unshareArtifact(params) }), defineMethod({ name: 'artifacts.delete', + permission: 'workspace', params: ArtifactsDeleteParams, handler: (params, { runtime }) => runtime.deleteArtifact(params.id, params) }) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index b1e3eebe6eb..861eb6e6462 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -28,12 +28,14 @@ function mutationOwner( export const AUTOMATION_METHODS = [ defineMethod({ name: 'automation.list', + permission: 'workspace', params: AutomationList, // The projection retains `automations`, so old clients ignore the added owner metadata. handler: (params, { runtime }) => runtime.listAutomationsForScope(params) }), defineMethod({ name: 'automation.show', + permission: 'workspace', params: AutomationId, // Why: the owner rides along so a client that cannot project one itself — the // CLI — can echo it back on the mutation that follows. Optional: an older @@ -46,6 +48,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.create', + permission: 'workspace', params: AutomationCreate, handler: async (params, { runtime }) => ({ automation: await runtime.createAutomation(params) @@ -53,6 +56,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.update', + permission: 'workspace', params: AutomationUpdate, handler: async (params, context) => ({ automation: await context.runtime.updateAutomation(params.id, params.updates, { @@ -63,6 +67,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.delete', + permission: 'workspace', params: AutomationId, handler: (params, context) => context.runtime.deleteAutomation( @@ -72,6 +77,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.runNow', + permission: 'workspace', params: AutomationId, handler: async (params, context) => ({ run: await context.runtime.runAutomationNow( @@ -82,6 +88,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.runs', + permission: 'workspace', params: AutomationRuns, handler: (params, { runtime }) => { if (params.limit !== undefined || params.cursor !== undefined) { diff --git a/src/main/runtime/rpc/methods/browser-client-file-channel.ts b/src/main/runtime/rpc/methods/browser-client-file-channel.ts index 362a1990531..6ee9761da24 100644 --- a/src/main/runtime/rpc/methods/browser-client-file-channel.ts +++ b/src/main/runtime/rpc/methods/browser-client-file-channel.ts @@ -67,6 +67,7 @@ function requireFileChannelPage( export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [ defineMethod({ name: 'browser.clientHost.fileChannel.read', + permission: 'workspace', params: BrowserClientFileChannelReadParams, handler: async (params, context) => { const page = requireFileChannelPage(params, context) @@ -87,6 +88,7 @@ export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [ }), defineMethod({ name: 'browser.clientHost.fileChannel.write', + permission: 'workspace', params: BrowserClientFileChannelWriteParams, handler: async (params, context) => { const page = requireFileChannelPage(params, context) @@ -108,6 +110,7 @@ export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [ }), defineMethod({ name: 'browser.clientHost.fileChannel.abort', + permission: 'workspace', params: BrowserClientFileChannelAbortParams, handler: async (params, context) => { requireFileChannelPage(params, context) diff --git a/src/main/runtime/rpc/methods/browser-client-host.ts b/src/main/runtime/rpc/methods/browser-client-host.ts index e06632f7959..03dc0446c6f 100644 --- a/src/main/runtime/rpc/methods/browser-client-host.ts +++ b/src/main/runtime/rpc/methods/browser-client-host.ts @@ -19,6 +19,7 @@ import { defineMethod, defineStreamingMethod } from '../core' export const BROWSER_CLIENT_HOST_METHODS = [ defineStreamingMethod({ name: 'browser.clientHost.attach', + permission: 'workspace', params: BrowserClientHostAttachParams, handler: async ( params, @@ -164,6 +165,7 @@ export const BROWSER_CLIENT_HOST_METHODS = [ }), defineMethod({ name: 'browser.clientHost.commandResult', + permission: 'workspace', params: BrowserClientHostCommandResultParams, handler: ( params, @@ -196,6 +198,7 @@ export const BROWSER_CLIENT_HOST_METHODS = [ }), defineMethod({ name: 'browser.clientHost.pageMetadata', + permission: 'workspace', params: BrowserClientPageMetadataParams, handler: ( params, diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 780f40fb373..dfe1dbf8fd5 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -14,18 +14,13 @@ import { Is, Keypress, LimitParam, - ProfileCreate, - ProfileDelete, - ProfileImportFromBrowser, Screenshot, Scroll, Select, SelectorPath, TabCurrent, - TabSetProfile, TabClose, TabList, - TabProfileClone, TabShow, TabSwitch, Upload, @@ -33,82 +28,98 @@ import { } from './browser-schemas' import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-schema' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' +import { BROWSER_PROFILE_METHODS } from './browser-profile-rpc-methods' import { CertificateProceed } from '../../../../shared/rpc-contract/browser-core-params' export const BROWSER_CORE_METHODS = [ defineMethod({ name: 'browser.snapshot', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserSnapshot(params) }), defineMethod({ name: 'browser.click', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserClick(params) }), defineMethod({ name: 'browser.goto', + permission: 'workspace', params: Goto, handler: async (params, { runtime }) => runtime.browserGoto(params) }), defineMethod({ name: 'browser.certificate.proceed', + permission: 'workspace', params: CertificateProceed, handler: async (params, { runtime }) => runtime.browserProceedCertificate(params) }), ...BROWSER_TEXT_METHODS, defineMethod({ name: 'browser.select', + permission: 'workspace', params: Select, handler: async (params, { runtime }) => runtime.browserSelect(params) }), defineMethod({ name: 'browser.scroll', + permission: 'workspace', params: Scroll, handler: async (params, { runtime }) => runtime.browserScroll(params) }), defineMethod({ name: 'browser.back', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserBack(params) }), defineMethod({ name: 'browser.reload', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserReload(params) }), defineMethod({ name: 'browser.screenshot', + permission: 'workspace', params: Screenshot, handler: async (params, { runtime }) => runtime.browserScreenshot(params) }), defineMethod({ name: 'browser.eval', + permission: 'workspace', params: Eval, handler: async (params, { runtime }) => runtime.browserEval(params) }), defineMethod({ name: 'browser.tabList', + permission: 'workspace', params: TabList, handler: async (params, { runtime }) => runtime.browserTabList(params) }), defineMethod({ name: 'browser.tabShow', + permission: 'workspace', params: TabShow, handler: async (params, { runtime }) => runtime.browserTabShow(params) }), defineMethod({ name: 'browser.tabCurrent', + permission: 'workspace', params: TabCurrent, handler: async (params, { runtime }) => runtime.browserTabCurrent(params) }), defineMethod({ name: 'browser.tabSwitch', + permission: 'workspace', params: TabSwitch, handler: async (params, { runtime }) => runtime.browserTabSwitch(params) }), defineMethod({ name: 'browser.tabCreate', + permission: 'workspace', params: BrowserTabCreateParams, handler: async (params, { runtime, pairedDeviceId, clientKind }) => pairedDeviceId @@ -117,176 +128,158 @@ export const BROWSER_CORE_METHODS = [ }), defineMethod({ name: 'browser.openUrl', + permission: 'workspace', params: BrowserOpenUrlParams, handler: async (params, { runtime }) => runtime.browserOpenUrlOnClient(params) }), - defineMethod({ - name: 'browser.tabSetProfile', - params: TabSetProfile, - handler: async (params, { runtime }) => runtime.browserTabSetProfile(params) - }), - defineMethod({ - name: 'browser.tabProfileShow', - params: TabShow, - handler: async (params, { runtime }) => runtime.browserTabProfileShow(params) - }), - defineMethod({ - name: 'browser.tabProfileClone', - params: TabProfileClone, - handler: async (params, { runtime }) => runtime.browserTabProfileClone(params) - }), defineMethod({ name: 'browser.tabClose', + permission: 'workspace', params: TabClose, handler: async (params, { runtime }) => runtime.browserTabClose(params) }), - defineMethod({ - name: 'browser.profileList', - params: null, - handler: async (_params, { runtime }) => runtime.browserProfileList() - }), - defineMethod({ - name: 'browser.profileCreate', - params: ProfileCreate, - handler: async (params, { runtime }) => runtime.browserProfileCreate(params) - }), - defineMethod({ - name: 'browser.profileDelete', - params: ProfileDelete, - handler: async (params, { runtime }) => runtime.browserProfileDelete(params) - }), - defineMethod({ - name: 'browser.profileDetectBrowsers', - params: null, - handler: async (_params, { runtime }) => runtime.browserProfileDetectBrowsers() - }), - defineMethod({ - name: 'browser.profileImportFromBrowser', - params: ProfileImportFromBrowser, - handler: async (params, { runtime }) => runtime.browserProfileImportFromBrowser(params) - }), - defineMethod({ - name: 'browser.profileClearDefaultCookies', - params: null, - handler: async (_params, { runtime }) => runtime.browserProfileClearDefaultCookies() - }), + ...BROWSER_PROFILE_METHODS, defineMethod({ name: 'browser.hover', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserHover(params) }), defineMethod({ name: 'browser.drag', + permission: 'workspace', params: Drag, handler: async (params, { runtime }) => runtime.browserDrag(params) }), defineMethod({ name: 'browser.upload', + permission: 'workspace', params: Upload, handler: async (params, { runtime }) => runtime.browserUpload(params) }), defineMethod({ name: 'browser.wait', + permission: 'workspace', params: Wait, handler: async (params, { runtime }) => runtime.browserWait(params) }), defineMethod({ name: 'browser.check', + permission: 'workspace', params: Check, handler: async (params, { runtime }) => runtime.browserCheck(params) }), defineMethod({ name: 'browser.focus', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserFocus(params) }), defineMethod({ name: 'browser.clear', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserClear(params) }), defineMethod({ name: 'browser.selectAll', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserSelectAll(params) }), defineMethod({ name: 'browser.keypress', + permission: 'workspace', params: Keypress, handler: async (params, { runtime }) => runtime.browserKeypress(params) }), defineMethod({ name: 'browser.pdf', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserPdf(params) }), defineMethod({ name: 'browser.fullScreenshot', + permission: 'workspace', params: FullScreenshot, handler: async (params, { runtime }) => runtime.browserFullScreenshot(params) }), defineMethod({ name: 'browser.dblclick', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserDblclick(params) }), defineMethod({ name: 'browser.forward', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserForward(params) }), defineMethod({ name: 'browser.scrollIntoView', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserScrollIntoView(params) }), defineMethod({ name: 'browser.get', + permission: 'workspace', params: Get, handler: async (params, { runtime }) => runtime.browserGet(params) }), defineMethod({ name: 'browser.is', + permission: 'workspace', params: Is, handler: async (params, { runtime }) => runtime.browserIs(params) }), defineMethod({ name: 'browser.find', + permission: 'workspace', params: Find, handler: async (params, { runtime }) => runtime.browserFind(params) }), defineMethod({ name: 'browser.console', + permission: 'workspace', params: LimitParam, handler: async (params, { runtime }) => runtime.browserConsoleLog(params) }), defineMethod({ name: 'browser.network', + permission: 'workspace', params: LimitParam, handler: async (params, { runtime }) => runtime.browserNetworkLog(params) }), defineMethod({ name: 'browser.exec', + permission: 'workspace', params: Exec, handler: async (params, { runtime }) => runtime.browserExec(params) }), defineMethod({ name: 'browser.capture.start', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserCaptureStart(params) }), defineMethod({ name: 'browser.capture.stop', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserCaptureStop(params) }), defineMethod({ name: 'browser.download', + permission: 'workspace', params: SelectorPath, handler: async (params, { runtime }) => runtime.browserDownload(params) }), defineMethod({ name: 'browser.highlight', + permission: 'workspace', params: Highlight, handler: async (params, { runtime }) => runtime.browserHighlight(params) }) diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts index 000838c4938..41954070929 100644 --- a/src/main/runtime/rpc/methods/browser-extras.ts +++ b/src/main/runtime/rpc/methods/browser-extras.ts @@ -26,101 +26,121 @@ import { MouseClick } from '../../../../shared/rpc-contract/browser-extras-param export const BROWSER_EXTRA_METHODS = [ defineMethod({ name: 'browser.cookie.get', + permission: 'workspace', params: CookieGet, handler: async (params, { runtime }) => runtime.browserCookieGet(params) }), defineMethod({ name: 'browser.cookie.set', + permission: 'workspace', params: CookieSet, handler: async (params, { runtime }) => runtime.browserCookieSet(params) }), defineMethod({ name: 'browser.cookie.delete', + permission: 'workspace', params: CookieDelete, handler: async (params, { runtime }) => runtime.browserCookieDelete(params) }), defineMethod({ name: 'browser.viewport', + permission: 'workspace', params: Viewport, handler: async (params, { runtime }) => runtime.browserSetViewport(params) }), defineMethod({ name: 'browser.geolocation', + permission: 'workspace', params: Geolocation, handler: async (params, { runtime }) => runtime.browserSetGeolocation(params) }), defineMethod({ name: 'browser.intercept.enable', + permission: 'workspace', params: InterceptEnable, handler: async (params, { runtime }) => runtime.browserInterceptEnable(params) }), defineMethod({ name: 'browser.intercept.disable', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserInterceptDisable(params) }), defineMethod({ name: 'browser.intercept.list', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserInterceptList(params) }), defineMethod({ name: 'browser.mouseMove', + permission: 'workspace', params: MouseXY, handler: async (params, { runtime }) => runtime.browserMouseMove(params) }), defineMethod({ name: 'browser.mouseDown', + permission: 'workspace', params: MouseButton, handler: async (params, { runtime }) => runtime.browserMouseDown(params) }), defineMethod({ name: 'browser.mouseClick', + permission: 'workspace', params: MouseClick, handler: async (params, { runtime }) => runtime.browserMouseClick(params) }), defineMethod({ name: 'browser.mouseUp', + permission: 'workspace', params: MouseButton, handler: async (params, { runtime }) => runtime.browserMouseUp(params) }), defineMethod({ name: 'browser.mouseWheel', + permission: 'workspace', params: MouseWheel, handler: async (params, { runtime }) => runtime.browserMouseWheel(params) }), defineMethod({ name: 'browser.setDevice', + permission: 'workspace', params: SetDevice, handler: async (params, { runtime }) => runtime.browserSetDevice(params) }), defineMethod({ name: 'browser.setOffline', + permission: 'workspace', params: SetOffline, handler: async (params, { runtime }) => runtime.browserSetOffline(params) }), defineMethod({ name: 'browser.setHeaders', + permission: 'workspace', params: SetHeaders, handler: async (params, { runtime }) => runtime.browserSetHeaders(params) }), defineMethod({ name: 'browser.setCredentials', + permission: 'workspace', params: SetCredentials, handler: async (params, { runtime }) => runtime.browserSetCredentials(params) }), defineMethod({ name: 'browser.setMedia', + permission: 'workspace', params: SetMedia, handler: async (params, { runtime }) => runtime.browserSetMedia(params) }), defineMethod({ name: 'browser.clipboardRead', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserClipboardRead(params) }), defineMethod({ name: 'browser.clipboardWrite', + permission: 'workspace', params: ClipboardWrite, handler: async (params, { runtime }) => { await assertRpcClipboardTextWriteWithinLimit(params.text) @@ -129,41 +149,49 @@ export const BROWSER_EXTRA_METHODS = [ }), defineMethod({ name: 'browser.dialogAccept', + permission: 'workspace', params: DialogAccept, handler: async (params, { runtime }) => runtime.browserDialogAccept(params) }), defineMethod({ name: 'browser.dialogDismiss', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserDialogDismiss(params) }), defineMethod({ name: 'browser.storage.local.get', + permission: 'workspace', params: StorageKey, handler: async (params, { runtime }) => runtime.browserStorageLocalGet(params) }), defineMethod({ name: 'browser.storage.local.set', + permission: 'workspace', params: StorageKeyValue, handler: async (params, { runtime }) => runtime.browserStorageLocalSet(params) }), defineMethod({ name: 'browser.storage.local.clear', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserStorageLocalClear(params) }), defineMethod({ name: 'browser.storage.session.get', + permission: 'workspace', params: StorageKey, handler: async (params, { runtime }) => runtime.browserStorageSessionGet(params) }), defineMethod({ name: 'browser.storage.session.set', + permission: 'workspace', params: StorageKeyValue, handler: async (params, { runtime }) => runtime.browserStorageSessionSet(params) }), defineMethod({ name: 'browser.storage.session.clear', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserStorageSessionClear(params) }) diff --git a/src/main/runtime/rpc/methods/browser-identity-rpc.ts b/src/main/runtime/rpc/methods/browser-identity-rpc.ts index 55fbd8d1e6b..60d80d9fc02 100644 --- a/src/main/runtime/rpc/methods/browser-identity-rpc.ts +++ b/src/main/runtime/rpc/methods/browser-identity-rpc.ts @@ -10,11 +10,13 @@ import { export const BROWSER_IDENTITY_METHODS = [ defineMethod({ name: 'browser.identity.get', + permission: 'workspace', params: null, handler: () => getBrowserIdentityModeStatus() }), defineMethod({ name: 'browser.identity.set', + permission: 'workspace', params: BrowserIdentitySet, handler: async ({ mode, reset }) => setBrowserIdentityMode(mode, { reset }) }) diff --git a/src/main/runtime/rpc/methods/browser-network-tunnel.ts b/src/main/runtime/rpc/methods/browser-network-tunnel.ts index d610824b1f2..3e0c312be46 100644 --- a/src/main/runtime/rpc/methods/browser-network-tunnel.ts +++ b/src/main/runtime/rpc/methods/browser-network-tunnel.ts @@ -23,6 +23,7 @@ export function createBrowserNetworkTunnelMethods( return [ defineStreamingMethod({ name: 'network.browserTunnel', + permission: 'host-admin', params: BrowserNetworkTunnelAttachParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/browser-profile-rpc-methods.ts b/src/main/runtime/rpc/methods/browser-profile-rpc-methods.ts new file mode 100644 index 00000000000..685857dc94f --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-profile-rpc-methods.ts @@ -0,0 +1,66 @@ +import { defineMethod } from '../core' +import { + ProfileCreate, + ProfileDelete, + ProfileImportFromBrowser, + TabProfileClone, + TabSetProfile, + TabShow +} from './browser-schemas' + +export const BROWSER_PROFILE_METHODS = [ + defineMethod({ + name: 'browser.tabSetProfile', + permission: 'workspace', + params: TabSetProfile, + handler: async (params, { runtime }) => runtime.browserTabSetProfile(params) + }), + defineMethod({ + name: 'browser.tabProfileShow', + permission: 'workspace', + params: TabShow, + handler: async (params, { runtime }) => runtime.browserTabProfileShow(params) + }), + defineMethod({ + name: 'browser.tabProfileClone', + permission: 'workspace', + params: TabProfileClone, + handler: async (params, { runtime }) => runtime.browserTabProfileClone(params) + }), + defineMethod({ + name: 'browser.profileList', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileList() + }), + defineMethod({ + name: 'browser.profileCreate', + permission: 'workspace', + params: ProfileCreate, + handler: async (params, { runtime }) => runtime.browserProfileCreate(params) + }), + defineMethod({ + name: 'browser.profileDelete', + permission: 'workspace', + params: ProfileDelete, + handler: async (params, { runtime }) => runtime.browserProfileDelete(params) + }), + defineMethod({ + name: 'browser.profileDetectBrowsers', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileDetectBrowsers() + }), + defineMethod({ + name: 'browser.profileImportFromBrowser', + permission: 'workspace', + params: ProfileImportFromBrowser, + handler: async (params, { runtime }) => runtime.browserProfileImportFromBrowser(params) + }), + defineMethod({ + name: 'browser.profileClearDefaultCookies', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileClearDefaultCookies() + }) +] diff --git a/src/main/runtime/rpc/methods/browser-screencast.ts b/src/main/runtime/rpc/methods/browser-screencast.ts index 798e1ed84d2..5b59ebd629e 100644 --- a/src/main/runtime/rpc/methods/browser-screencast.ts +++ b/src/main/runtime/rpc/methods/browser-screencast.ts @@ -8,6 +8,7 @@ import { ScreencastUnsubscribe } from '../../../../shared/rpc-contract/browser-s export const BROWSER_SCREENCAST_METHODS = [ defineStreamingMethod({ name: 'browser.screencast', + permission: 'workspace', params: Screencast, handler: async ( params, @@ -26,6 +27,7 @@ export const BROWSER_SCREENCAST_METHODS = [ }), defineMethod({ name: 'browser.screencast.unsubscribe', + permission: 'workspace', params: ScreencastUnsubscribe, handler: async (params, { runtime }) => { if (!runtimeBrowserCommandsFactoryIsAvailable()) { diff --git a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts index 18fd19a0e6a..91f16ec1869 100644 --- a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts @@ -5,6 +5,7 @@ import { Fill, KeyboardInsert, Type } from './browser-schemas' export const BROWSER_TEXT_METHODS = [ defineMethod({ name: 'browser.fill', + permission: 'workspace', params: Fill, handler: async (params, { runtime }) => { await assertRpcClipboardTextWriteWithinLimit(params.value) @@ -13,6 +14,7 @@ export const BROWSER_TEXT_METHODS = [ }), defineMethod({ name: 'browser.type', + permission: 'workspace', params: Type, handler: async (params, { runtime }) => { await assertRpcClipboardTextWriteWithinLimit(params.input) @@ -21,6 +23,7 @@ export const BROWSER_TEXT_METHODS = [ }), defineMethod({ name: 'browser.keyboardInsertText', + permission: 'workspace', params: KeyboardInsert, handler: async (params, { runtime }) => { await assertRpcClipboardTextWriteWithinLimit(params.text) diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index e7506ad2f59..68c80f20ee0 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -8,6 +8,7 @@ let clientEventSubscriptionSeq = 0 export const CLIENT_EVENT_METHODS = [ defineStreamingMethod({ name: 'runtime.clientEvents.subscribe', + permission: 'workspace', params: null, handler: async (_params, { runtime, connectionId, clientKind }, emit) => { await new Promise((resolve) => { @@ -51,6 +52,7 @@ export const CLIENT_EVENT_METHODS = [ }), defineMethod({ name: 'runtime.clientEvents.unsubscribe', + permission: 'workspace', params: ClientEventsUnsubscribeParams, handler: async (params, { runtime, connectionId }) => { const expectedPrefix = `runtime-client-events-${connectionId ?? 'inproc'}-` diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 6ed36a6fe83..a2c1ad20d95 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -15,11 +15,13 @@ import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema export const CLIENT_UI_METHODS = [ defineMethod({ name: 'settings.get', + permission: 'workspace', params: null, handler: (_params, { runtime }) => ({ settings: runtime.getClientSettings() }) }), defineMethod({ name: 'settings.update', + permission: 'settings-write', params: SettingsUpdate, handler: async (params, { runtime }) => ({ settings: await runtime.updateClientSettings(params) @@ -27,6 +29,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'settings.getTerminalQuickCommands', + permission: 'workspace', params: null, // Why: command bodies can total ~240 KB, so keep unrelated settings reads // from carrying them over every paired/relay connection. @@ -36,6 +39,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'settings.updateTerminalQuickCommands', + permission: 'settings-write', params: TerminalQuickCommandsUpdate, handler: (params, { runtime }) => ({ terminalQuickCommands: runtime.updateClientTerminalQuickCommands(params.mutation) @@ -43,6 +47,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'settings.updatePRBotAuthorOverride', + permission: 'settings-write', params: PRBotAuthorOverrideUpdate, handler: (params, { runtime }) => ({ settings: runtime.updateClientPRBotAuthorOverride(params) @@ -50,6 +55,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'settings.mutateNativeChatSessionOptions', + permission: 'settings-write', params: NativeChatSessionOptionsMutation, handler: (params, { runtime }) => { runtime.updateClientNativeChatSessionOptions(params) @@ -58,11 +64,13 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'ui.get', + permission: 'workspace', params: null, handler: (_params, { runtime }) => ({ ui: omitPairingLocalUiFields(runtime.getUIState()) }) }), defineMethod({ name: 'ui.set', + permission: 'workspace', params: UiUpdate, // Why the fields are dropped here rather than removed from the schema: UiUpdate is strict, so // an unlisted key would make the dispatcher reject an old client's ENTIRE payload. @@ -74,6 +82,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'ui.recordFeatureInteraction', + permission: 'workspace', params: FeatureInteractionIdParam, handler: (params, { runtime }) => ({ ui: omitPairingLocalUiFields(runtime.recordFeatureInteraction(params)) diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index 60ed142455f..55ba5761c67 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -92,6 +92,7 @@ function assertMobileUploadOwner( export const CLIPBOARD_METHODS = [ defineMethod({ name: 'clipboard.saveImageAsTempFile', + permission: 'workspace', params: SaveImageAsTempFile, handler: async (params, ctx) => { const clientId = mobileClientId(ctx) @@ -109,6 +110,7 @@ export const CLIPBOARD_METHODS = [ }), defineMethod({ name: 'clipboard.startImageUpload', + permission: 'workspace', params: StartImageUpload, handler: (params, ctx) => { pruneExpiredUploads() @@ -130,6 +132,7 @@ export const CLIPBOARD_METHODS = [ }), defineMethod({ name: 'clipboard.appendImageUploadChunk', + permission: 'workspace', params: AppendImageUploadChunk, handler: (params, ctx) => { const upload = getUpload(params.uploadId) @@ -149,6 +152,7 @@ export const CLIPBOARD_METHODS = [ }), defineMethod({ name: 'clipboard.commitImageUpload', + permission: 'workspace', params: CommitImageUpload, handler: async (params, ctx) => { const upload = getUpload(params.uploadId) @@ -176,6 +180,7 @@ export const CLIPBOARD_METHODS = [ }), defineMethod({ name: 'clipboard.abortImageUpload', + permission: 'workspace', params: AbortImageUpload, handler: (params, ctx) => { pruneExpiredUploads() diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts index e2708667633..c580a0d9af8 100644 --- a/src/main/runtime/rpc/methods/computer.ts +++ b/src/main/runtime/rpc/methods/computer.ts @@ -34,6 +34,7 @@ export function resetComputerSessionsForTest(): void { export const COMPUTER_METHODS = [ defineMethod({ name: 'computer.capabilities', + permission: 'workspace', params: ComputerCapabilitiesParams, handler: async () => { return await callComputerSidecarCapabilities() @@ -41,6 +42,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.listApps', + permission: 'desktop-control', params: ListApps, handler: async () => { return await callComputerSidecarListApps() @@ -48,6 +50,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.permissions', + permission: 'desktop-control', params: ComputerPermissions, handler: async (params) => { const { openComputerUsePermissions } = @@ -57,6 +60,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.permissionsStatus', + permission: 'workspace', params: ComputerPermissionsStatusParams, handler: async () => { const { getComputerUsePermissionStatus } = @@ -66,6 +70,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.listWindows', + permission: 'desktop-control', params: ListWindows, handler: async (params) => { return await callComputerSidecarListWindows(params) @@ -73,6 +78,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.getAppState', + permission: 'desktop-control', params: ComputerObserveTarget, handler: async (params) => { return await callComputerSidecarSnapshot(params) @@ -80,6 +86,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.click', + permission: 'desktop-control', params: Click, handler: async (params) => { return await callComputerSidecarAction('click', params) @@ -87,6 +94,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.performSecondaryAction', + permission: 'desktop-control', params: PerformSecondaryAction, handler: async (params) => { return await callComputerSidecarAction('performSecondaryAction', params) @@ -94,6 +102,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.scroll', + permission: 'desktop-control', params: Scroll, handler: async (params) => { return await callComputerSidecarAction('scroll', params) @@ -101,6 +110,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.drag', + permission: 'desktop-control', params: Drag, handler: async (params) => { return await callComputerSidecarAction('drag', params) @@ -108,6 +118,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.typeText', + permission: 'desktop-control', params: TypeText, handler: async (params) => { return await callComputerSidecarAction('typeText', params) @@ -115,6 +126,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.pressKey', + permission: 'desktop-control', params: PressKey, handler: async (params) => { return await callComputerSidecarAction('pressKey', params) @@ -122,6 +134,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.hotkey', + permission: 'desktop-control', params: Hotkey, handler: async (params) => { return await callComputerSidecarAction('hotkey', params) @@ -129,6 +142,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.pasteText', + permission: 'desktop-control', params: PasteText, handler: async (params) => { return await callComputerSidecarAction('pasteText', params) @@ -136,6 +150,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.setValue', + permission: 'desktop-control', params: SetValue, handler: async (params) => { return await callComputerSidecarAction('setValue', params) diff --git a/src/main/runtime/rpc/methods/diagnostics.ts b/src/main/runtime/rpc/methods/diagnostics.ts index 953eccd5d51..789d33a6f41 100644 --- a/src/main/runtime/rpc/methods/diagnostics.ts +++ b/src/main/runtime/rpc/methods/diagnostics.ts @@ -3,6 +3,7 @@ import { defineMethod } from '../core' export const DIAGNOSTICS_METHODS = [ defineMethod({ name: 'diagnostics.memory', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => { return await runtime.getMemorySnapshot() diff --git a/src/main/runtime/rpc/methods/emulator.ts b/src/main/runtime/rpc/methods/emulator.ts index b472e539603..6b1b12f07c3 100644 --- a/src/main/runtime/rpc/methods/emulator.ts +++ b/src/main/runtime/rpc/methods/emulator.ts @@ -35,96 +35,115 @@ const InstallParams = z.object({ export const EMULATOR_METHODS = [ defineMethod({ name: 'emulator.list', + permission: 'workspace', params: ListParams, handler: async (params, { runtime }) => runtime.emulatorList(params) }), defineMethod({ name: 'emulator.attach', + permission: 'workspace', params: AttachParams, handler: async (params, { runtime }) => runtime.emulatorAttach(params) }), defineMethod({ name: 'emulator.tap', + permission: 'workspace', params: TapParams, handler: async (params, { runtime }) => runtime.emulatorTap(params) }), defineMethod({ name: 'emulator.gesture', + permission: 'workspace', params: GestureParams, handler: async (params, { runtime }) => runtime.emulatorGesture(params) }), defineMethod({ name: 'emulator.type', + permission: 'workspace', params: TypeParams, handler: async (params, { runtime }) => runtime.emulatorType(params) }), defineMethod({ name: 'emulator.button', + permission: 'workspace', params: ButtonParams, handler: async (params, { runtime }) => runtime.emulatorButton(params) }), defineMethod({ name: 'emulator.rotate', + permission: 'workspace', params: RotateParams, handler: async (params, { runtime }) => runtime.emulatorRotate(params) }), defineMethod({ name: 'emulator.exec', + permission: 'workspace', params: ExecParams, handler: async (params, { runtime }) => runtime.emulatorExec(params) }), defineMethod({ name: 'emulator.kill', + permission: 'workspace', params: KillParams, handler: async (params, { runtime }) => runtime.emulatorKill(params) }), defineMethod({ name: 'emulator.shutdown', + permission: 'workspace', params: ShutdownParams, handler: async (params, { runtime }) => runtime.emulatorShutdown(params) }), defineMethod({ name: 'emulator.listSimulators', + permission: 'workspace', params: EmulatorListSimulatorsParams, handler: async (params, { runtime }) => runtime.emulatorListSimulators(params) }), defineMethod({ name: 'emulator.availability', + permission: 'workspace', params: EmulatorAvailabilityParams, handler: async (params, { runtime }) => runtime.emulatorAvailability(params) }), defineMethod({ name: 'emulator.listDevices', + permission: 'workspace', params: EmulatorListDevicesParams, handler: async (params, { runtime }) => runtime.emulatorListDevices(params) }), defineMethod({ name: 'emulator.install', + permission: 'workspace', params: InstallParams, handler: async (params, { runtime }) => runtime.emulatorInstall(params) }), defineMethod({ name: 'emulator.launch', + permission: 'workspace', params: LaunchParams, handler: async (params, { runtime }) => runtime.emulatorLaunch(params) }), defineMethod({ name: 'emulator.permissions', + permission: 'workspace', params: PermissionsParams, handler: async (params, { runtime }) => runtime.emulatorPermissions(params) }), defineMethod({ name: 'emulator.ax', + permission: 'workspace', params: AxParams, handler: async (params, { runtime }) => runtime.emulatorAx(params) }), defineMethod({ name: 'emulator.logcat', + permission: 'workspace', params: LogcatParams, handler: async (params, { runtime }) => runtime.emulatorLogcat(params) }), defineMethod({ name: 'emulator.unregisterActive', + permission: 'workspace', params: EmulatorUnregisterActiveParams, handler: async (params, { runtime }) => runtime.emulatorUnregisterActive(params) }) diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts index eb734d5c8d6..89cb2eca069 100644 --- a/src/main/runtime/rpc/methods/files-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts @@ -36,6 +36,7 @@ function sshMutationArguments( export const FILE_MUTATION_METHODS = [ defineMethod({ name: 'files.write', + permission: 'workspace', params: FileWrite, handler: async (params, { runtime }) => runtime.writeFileExplorerFile( @@ -47,6 +48,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.writeBase64', + permission: 'workspace', params: FileWriteBase64, handler: async (params, { runtime }) => runtime.writeFileExplorerFileBase64( @@ -58,6 +60,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.writeBase64Chunk', + permission: 'workspace', params: FileWriteBase64Chunk, handler: async (params, { runtime }) => runtime.writeFileExplorerFileBase64Chunk( @@ -70,6 +73,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.createFile', + permission: 'workspace', params: FileMutationOpen, handler: async (params, { runtime }) => runtime.createFileExplorerFile( @@ -80,6 +84,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.createDir', + permission: 'workspace', params: FileMutationOpen, handler: async (params, { runtime }) => runtime.createFileExplorerDir( @@ -90,6 +95,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.createDirNoClobber', + permission: 'workspace', params: FileMutationOpen, handler: async (params, { runtime }) => runtime.createFileExplorerDirNoClobber( @@ -100,6 +106,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.commitUpload', + permission: 'workspace', params: FileCommitUpload, handler: async (params, { runtime }) => runtime.commitFileExplorerUpload( @@ -111,6 +118,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.rename', + permission: 'workspace', params: FileRename, handler: async (params, { runtime }) => runtime.renameFileExplorerPath( @@ -122,6 +130,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.copy', + permission: 'workspace', params: FileCopy, handler: async (params, { runtime }) => runtime.copyFileExplorerPath( @@ -133,6 +142,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.delete', + permission: 'workspace', params: FileDelete, handler: async (params, { runtime }) => runtime.deleteFileExplorerPath( diff --git a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts index 20d52eb424d..6b619a9b13f 100644 --- a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts +++ b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts @@ -8,6 +8,7 @@ import { export const FILE_TERMINAL_ARTIFACT_METHODS = [ defineMethod({ name: 'files.readTerminalArtifact', + permission: 'workspace', params: TerminalArtifactFile, handler: async (params, { runtime, clientId }) => runtime.readTerminalArtifactFile( @@ -19,6 +20,7 @@ export const FILE_TERMINAL_ARTIFACT_METHODS = [ }), defineMethod({ name: 'files.readTerminalArtifactPreview', + permission: 'workspace', params: TerminalArtifactFile, handler: async (params, { runtime, clientId, clientKind, requestId }) => { const budget = remoteFileContentBudget(clientKind, requestId) @@ -40,6 +42,7 @@ export const FILE_TERMINAL_ARTIFACT_METHODS = [ }), defineMethod({ name: 'files.writeTerminalArtifact', + permission: 'workspace', params: TerminalArtifactFileWrite, handler: async (params, { runtime, clientId }) => runtime.writeTerminalArtifactFile( diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 99eaa3f2c4d..2a64b9ceb49 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -25,6 +25,7 @@ let filesWatchSubscriptionSeq = 0 export const FILE_METHODS = [ defineMethod({ name: 'files.list', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime, signal }) => signal === undefined @@ -33,6 +34,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.searchPaths', + permission: 'workspace', params: FilePathSearch, handler: async (params, { runtime, signal, clientKind, requestId }) => { if (params.mode !== 'quick-open') { @@ -58,12 +60,14 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.open', + permission: 'workspace', params: FileOpenTab, handler: async (params, { runtime }) => runtime.openMobileFile(params.worktree, params.relativePath, params.navigation) }), defineMethod({ name: 'files.openDiff', + permission: 'workspace', params: FileOpenDiff, handler: async (params, { runtime }) => runtime.openMobileDiff( @@ -75,12 +79,14 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.read', + permission: 'workspace', params: FileOpen, handler: async (params, { runtime }) => runtime.readMobileFile(params.worktree, params.relativePath) }), defineMethod({ name: 'files.readDocPreview', + permission: 'workspace', params: DocPreviewFileRead, handler: async (params, { runtime, clientKind, requestId }) => runtime.readDocPreviewFile( @@ -94,6 +100,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.resolveTerminalPath', + permission: 'workspace', params: ResolveTerminalPath, handler: async (params, { runtime, clientId }) => runtime.resolveTerminalPath( @@ -109,6 +116,7 @@ export const FILE_METHODS = [ ...FILE_TERMINAL_ARTIFACT_METHODS, defineMethod({ name: 'files.readPreview', + permission: 'workspace', params: FileOpen, handler: async (params, { runtime, clientKind, requestId }) => { const budget = remoteFileContentBudget(clientKind, requestId) @@ -119,6 +127,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.readChunk', + permission: 'workspace', params: FileReadChunk, handler: async (params, { runtime }) => runtime.readFileExplorerChunk( @@ -130,6 +139,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.readDir', + permission: 'workspace', params: FileTreePath, handler: async (params, { runtime }) => params.followSymlinks === undefined @@ -140,12 +150,14 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.browseServerDir', + permission: 'workspace', params: ServerDirectoryBrowse, handler: async (params, { runtime }) => runtime.browseServerDir(params.path) }), ...FILE_MUTATION_METHODS, defineMethod({ name: 'files.search', + permission: 'workspace', params: FileSearch, handler: async (params, { runtime, signal }) => runtime.searchRuntimeFiles( @@ -164,6 +176,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.listAll', + permission: 'workspace', params: FileListAll, handler: async (params, { runtime, clientKind, requestId, signal }) => { const maxContentBytes = remoteFileContentBudget(clientKind, requestId) @@ -180,23 +193,27 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.listMarkdownDocuments', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listRuntimeMarkdownDocuments(params.worktree) }), defineMethod({ name: 'files.pathsExist', + permission: 'workspace', params: FilePathsExist, handler: async (params, { runtime }) => runtime.pathsExistRuntimeFiles(params.worktree, params.relativePaths) }), defineMethod({ name: 'files.stat', + permission: 'workspace', params: FileTreePath, handler: async (params, { runtime }) => runtime.statRuntimeFile(params.worktree, params.relativePath) }), defineStreamingMethod({ name: 'files.watch', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime, connectionId, signal }, emit) => { const seq = ++filesWatchSubscriptionSeq @@ -213,6 +230,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.unwatch', + permission: 'workspace', params: FileUnwatch, handler: async (params, { runtime }) => { await runtime.cleanupSubscriptionAndWait(params.subscriptionId) diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts index a2e178b8ed9..9163b1d3533 100644 --- a/src/main/runtime/rpc/methods/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -10,6 +10,7 @@ import { export const FOLDER_WORKSPACE_METHODS = [ defineMethod({ name: 'folderWorkspace.list', + permission: 'workspace', params: null, handler: (_params, { runtime }) => ({ folderWorkspaces: runtime.listFolderWorkspaces() @@ -17,6 +18,7 @@ export const FOLDER_WORKSPACE_METHODS = [ }), defineMethod({ name: 'folderWorkspace.create', + permission: 'workspace', params: FolderWorkspaceCreate, handler: async (params, context) => ({ folderWorkspace: await context.runtime.createFolderWorkspace({ @@ -27,6 +29,7 @@ export const FOLDER_WORKSPACE_METHODS = [ }), defineMethod({ name: 'folderWorkspace.update', + permission: 'workspace', params: FolderWorkspaceUpdate, handler: async (params, { runtime }) => ({ folderWorkspace: await runtime.updateFolderWorkspace(params.folderWorkspaceId, params.updates) @@ -34,11 +37,13 @@ export const FOLDER_WORKSPACE_METHODS = [ }), defineMethod({ name: 'folderWorkspace.delete', + permission: 'workspace', params: FolderWorkspaceSelector, handler: async (params, { runtime }) => runtime.deleteFolderWorkspace(params.folderWorkspaceId) }), defineMethod({ name: 'folderWorkspace.getPathStatus', + permission: 'workspace', params: FolderWorkspacePathStatus, handler: async (params, { runtime }) => ({ status: await runtime.getFolderWorkspacePathStatus(params) diff --git a/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts b/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts index dcc376decf4..24c28f47f70 100644 --- a/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts +++ b/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts @@ -65,6 +65,7 @@ function buildCommitMessageGenerationOverride(params: { export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ defineMethod({ name: 'git.generateCommitMessage', + permission: 'workspace', params: GitGenerateCommitMessage, handler: async (params, { runtime }) => { const override = buildCommitMessageGenerationOverride(params) @@ -76,6 +77,7 @@ export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ }), defineMethod({ name: 'git.discoverCommitMessageModels', + permission: 'workspace', params: GitDiscoverCommitMessageModels, handler: async (params, { runtime }) => runtime.discoverRuntimeCommitMessageModels( @@ -90,12 +92,14 @@ export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ }), defineMethod({ name: 'git.cancelGenerateCommitMessage', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.cancelRuntimeGenerateCommitMessage(params.worktree) }), defineMethod({ name: 'git.generatePullRequestFields', + permission: 'workspace', params: GitGeneratePullRequestFields, handler: async (params, { runtime }) => { const input = { @@ -115,6 +119,7 @@ export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ }), defineMethod({ name: 'git.cancelGeneratePullRequestFields', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.cancelRuntimeGeneratePullRequestFields(params.worktree) diff --git a/src/main/runtime/rpc/methods/git-diff-methods.ts b/src/main/runtime/rpc/methods/git-diff-methods.ts index edcaebbdf42..5f83c239772 100644 --- a/src/main/runtime/rpc/methods/git-diff-methods.ts +++ b/src/main/runtime/rpc/methods/git-diff-methods.ts @@ -14,6 +14,7 @@ function remoteDiffContentBudget( export const GIT_DIFF_METHODS = [ defineMethod({ name: 'git.diff', + permission: 'workspace', params: GitDiff, handler: async (params, { runtime, clientKind, requestId }) => runtime.getRuntimeGitDiff( @@ -26,6 +27,7 @@ export const GIT_DIFF_METHODS = [ }), defineMethod({ name: 'git.branchDiff', + permission: 'workspace', params: GitBranchDiff, handler: async (params, { runtime, clientKind, requestId }) => runtime.getRuntimeGitBranchDiff( @@ -38,6 +40,7 @@ export const GIT_DIFF_METHODS = [ }), defineMethod({ name: 'git.commitDiff', + permission: 'workspace', params: GitCommitDiff, handler: async (params, { runtime, clientKind, requestId }) => runtime.getRuntimeGitCommitDiff( diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 20102d71e28..7af9ecf0eee 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -24,6 +24,7 @@ import { export const GIT_METHODS = [ defineMethod({ name: 'git.status', + permission: 'workspace', params: GitStatusParams, handler: async (params, { runtime, signal }) => { const options = @@ -59,18 +60,21 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.checkIgnored', + permission: 'workspace', params: GitCheckIgnored, handler: async (params, { runtime }) => runtime.checkRuntimeGitIgnoredPaths(params.worktree, params.paths) }), defineMethod({ name: 'git.submoduleStatus', + permission: 'workspace', params: GitSubmoduleStatus, handler: async (params, { runtime }) => runtime.getRuntimeGitSubmoduleStatus(params.worktree, params.submodulePath, params.area) }), defineMethod({ name: 'git.history', + permission: 'workspace', params: GitHistory, handler: async (params, { runtime }) => runtime.getRuntimeGitHistory(params.worktree, { @@ -80,45 +84,53 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.conflictOperation', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree) }), defineMethod({ name: 'git.abortMerge', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.abortRuntimeGitMerge(params.worktree) }), defineMethod({ name: 'git.abortRebase', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.abortRuntimeGitRebase(params.worktree) }), defineMethod({ name: 'git.checkout', + permission: 'workspace', params: GitCheckout, handler: async (params, { runtime }) => runtime.checkoutRuntimeGitBranch(params.worktree, params.branch) }), defineMethod({ name: 'git.localBranches', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listRuntimeGitLocalBranches(params.worktree) }), ...GIT_DIFF_METHODS, defineMethod({ name: 'git.branchCompare', + permission: 'workspace', params: GitBranchCompare, handler: async (params, { runtime }) => runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef, params.admissionTier) }), defineMethod({ name: 'git.commitCompare', + permission: 'workspace', params: GitCommitCompare, handler: async (params, { runtime }) => runtime.getRuntimeGitCommitCompare(params.worktree, params.commitId) }), defineMethod({ name: 'git.upstreamStatus', + permission: 'workspace', params: GitTargetedRemote, handler: async (params, { runtime }) => params.pushTarget === undefined @@ -127,6 +139,7 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.fetch', + permission: 'workspace', params: GitTargetedRemote, handler: async (params, { runtime }) => params.pushTarget === undefined @@ -135,12 +148,14 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.forkSync', + permission: 'workspace', params: GitForkSync, handler: async (params, { runtime }) => runtime.syncRuntimeGitForkDefaultBranch(params.worktree, params.expectedUpstream) }), defineMethod({ name: 'git.pull', + permission: 'workspace', params: GitTargetedRemote, handler: async (params, { runtime }) => params.pushTarget === undefined @@ -149,6 +164,7 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.fastForward', + permission: 'workspace', params: GitTargetedRemote, handler: async (params, { runtime }) => params.pushTarget === undefined @@ -157,12 +173,14 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.rebaseFromBase', + permission: 'workspace', params: GitRebaseFromBase, handler: async (params, { runtime }) => runtime.rebaseRuntimeGitFromBase(params.worktree, params.baseRef) }), defineMethod({ name: 'git.push', + permission: 'workspace', params: GitPush, handler: async (params, { runtime }) => runtime.pushRuntimeGit( @@ -174,6 +192,7 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.commit', + permission: 'workspace', params: GitCommit, handler: async (params, { runtime }) => runtime.commitRuntimeGit(params.worktree, params.message) @@ -181,48 +200,56 @@ export const GIT_METHODS = [ ...GIT_COMMIT_MESSAGE_GENERATION_METHODS, defineMethod({ name: 'git.stage', + permission: 'workspace', params: GitFilePath, handler: async (params, { runtime }) => runtime.stageRuntimeGitPath(params.worktree, params.filePath) }), defineMethod({ name: 'git.bulkStage', + permission: 'workspace', params: GitBulkPaths, handler: async (params, { runtime }) => runtime.bulkStageRuntimeGitPaths(params.worktree, params.filePaths) }), defineMethod({ name: 'git.unstage', + permission: 'workspace', params: GitFilePath, handler: async (params, { runtime }) => runtime.unstageRuntimeGitPath(params.worktree, params.filePath) }), defineMethod({ name: 'git.bulkUnstage', + permission: 'workspace', params: GitBulkPaths, handler: async (params, { runtime }) => runtime.bulkUnstageRuntimeGitPaths(params.worktree, params.filePaths) }), defineMethod({ name: 'git.discard', + permission: 'workspace', params: GitFilePath, handler: async (params, { runtime }) => runtime.discardRuntimeGitPath(params.worktree, params.filePath) }), defineMethod({ name: 'git.bulkDiscard', + permission: 'workspace', params: GitBulkPaths, handler: async (params, { runtime }) => runtime.bulkDiscardRuntimeGitPaths(params.worktree, params.filePaths) }), defineMethod({ name: 'git.remoteFileUrl', + permission: 'workspace', params: GitRemoteFileUrl, handler: async (params, { runtime }) => runtime.getRuntimeGitRemoteFileUrl(params.worktree, params.relativePath, params.line) }), defineMethod({ name: 'git.remoteCommitUrl', + permission: 'workspace', params: GitRemoteCommitUrl, handler: async (params, { runtime }) => runtime.getRuntimeGitRemoteCommitUrl(params.worktree, params.sha) diff --git a/src/main/runtime/rpc/methods/github-account-binding-methods.ts b/src/main/runtime/rpc/methods/github-account-binding-methods.ts index 30f893141c1..26fdab4d6e6 100644 --- a/src/main/runtime/rpc/methods/github-account-binding-methods.ts +++ b/src/main/runtime/rpc/methods/github-account-binding-methods.ts @@ -7,6 +7,7 @@ import { export const GITHUB_ACCOUNT_BINDING_METHODS = [ defineMethod({ name: 'github.listBindableAccounts', + permission: 'workspace', params: BindableAccounts, handler: async (params, { runtime }) => runtime.listGitHubBindableAccounts(params.repo, { @@ -15,6 +16,7 @@ export const GITHUB_ACCOUNT_BINDING_METHODS = [ }), defineMethod({ name: 'github.validateAccountBinding', + permission: 'workspace', params: ValidateAccountBinding, handler: async (params, { runtime }) => runtime.validateGitHubAccountBinding(params.repo, { diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts index 825fe573d2f..42714c18f39 100644 --- a/src/main/runtime/rpc/methods/github-issue-methods.ts +++ b/src/main/runtime/rpc/methods/github-issue-methods.ts @@ -9,11 +9,13 @@ import { export const GITHUB_ISSUE_METHODS = [ defineMethod({ name: 'github.issue', + permission: 'workspace', params: Issue, handler: async (params, { runtime }) => runtime.getRepoIssue(params.repo, params.number) }), defineMethod({ name: 'github.createIssue', + permission: 'workspace', params: CreateIssue, handler: async (params, { runtime }) => { const fields = @@ -27,12 +29,14 @@ export const GITHUB_ISSUE_METHODS = [ }), defineMethod({ name: 'github.updateIssue', + permission: 'workspace', params: UpdateIssue, handler: async (params, { runtime }) => runtime.updateRepoIssue(params.repo, params.number, params.updates) }), defineMethod({ name: 'github.addIssueComment', + permission: 'workspace', params: IssueComment, handler: async (params, { runtime }) => params.type diff --git a/src/main/runtime/rpc/methods/github-project-methods.ts b/src/main/runtime/rpc/methods/github-project-methods.ts index c05086decbf..226bcba015d 100644 --- a/src/main/runtime/rpc/methods/github-project-methods.ts +++ b/src/main/runtime/rpc/methods/github-project-methods.ts @@ -20,81 +20,97 @@ import { export const GITHUB_PROJECT_METHODS = [ defineMethod({ name: 'github.project.listAccessible', + permission: 'workspace', params: GithubProjectListAccessibleParams, handler: async (params, { runtime }) => runtime.listGitHubProjects(params) }), defineMethod({ name: 'github.project.listLabelsBySlug', + permission: 'workspace', params: SlugRepo, handler: async (params, { runtime }) => runtime.listGitHubLabelsBySlug(params) }), defineMethod({ name: 'github.project.listAssignableUsersBySlug', + permission: 'workspace', params: SlugAssignableUsers, handler: async (params, { runtime }) => runtime.listGitHubAssignableUsersBySlug(params) }), defineMethod({ name: 'github.project.listIssueTypesBySlug', + permission: 'workspace', params: SlugRepo, handler: async (params, { runtime }) => runtime.listGitHubIssueTypesBySlug(params) }), defineMethod({ name: 'github.project.resolveRef', + permission: 'workspace', params: ProjectRef, handler: async (params, { runtime }) => runtime.resolveGitHubProjectRef(params) }), defineMethod({ name: 'github.project.listViews', + permission: 'workspace', params: ProjectViews, handler: async (params, { runtime }) => runtime.listGitHubProjectViews(params) }), defineMethod({ name: 'github.project.viewTable', + permission: 'workspace', params: ProjectViewTable, handler: async (params, { runtime }) => runtime.getGitHubProjectViewTable(params) }), defineMethod({ name: 'github.project.workItemDetailsBySlug', + permission: 'workspace', params: ProjectWorkItemDetailsBySlug, handler: async (params, { runtime }) => runtime.getGitHubProjectWorkItemDetailsBySlug(params) }), defineMethod({ name: 'github.project.updateItemField', + permission: 'workspace', params: ProjectItemField, handler: async (params, { runtime }) => runtime.updateGitHubProjectItemField(params) }), defineMethod({ name: 'github.project.clearItemField', + permission: 'workspace', params: ClearProjectItemField, handler: async (params, { runtime }) => runtime.clearGitHubProjectItemField(params) }), defineMethod({ name: 'github.project.updateIssueBySlug', + permission: 'workspace', params: SlugIssueUpdate, handler: async (params, { runtime }) => runtime.updateGitHubIssueBySlug(params) }), defineMethod({ name: 'github.project.updatePullRequestBySlug', + permission: 'workspace', params: SlugPullRequestUpdate, handler: async (params, { runtime }) => runtime.updateGitHubPullRequestBySlug(params) }), defineMethod({ name: 'github.project.updateIssueTypeBySlug', + permission: 'workspace', params: SlugIssueTypeUpdate, handler: async (params, { runtime }) => runtime.updateGitHubIssueTypeBySlug(params) }), defineMethod({ name: 'github.project.addIssueCommentBySlug', + permission: 'workspace', params: SlugIssueComment, handler: async (params, { runtime }) => runtime.addGitHubIssueCommentBySlug(params) }), defineMethod({ name: 'github.project.updateIssueCommentBySlug', + permission: 'workspace', params: SlugIssueCommentEdit, handler: async (params, { runtime }) => runtime.updateGitHubIssueCommentBySlug(params) }), defineMethod({ name: 'github.project.deleteIssueCommentBySlug', + permission: 'workspace', params: SlugIssueCommentDelete, handler: async (params, { runtime }) => runtime.deleteGitHubIssueCommentBySlug(params) }) diff --git a/src/main/runtime/rpc/methods/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-methods.ts index 0a7f2efd522..2b2f66098f6 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-methods.ts @@ -14,6 +14,7 @@ import { export const GITHUB_PULL_REQUEST_METHODS = [ defineMethod({ name: 'github.prForBranch', + permission: 'workspace', params: PrForBranch, handler: async (params, { runtime }) => runtime.getRepoPRForBranch( @@ -28,6 +29,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.prChecks', + permission: 'workspace', params: PullRequestChecks, handler: async (params, { runtime }) => runtime.getRepoPRChecks(params.repo, params.prNumber, params.headSha, params.prRepo ?? null, { @@ -36,6 +38,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.prCheckDetails', + permission: 'workspace', params: PullRequestCheckDetails, handler: async (params, { runtime, signal }) => runtime.getRepoPRCheckDetails( @@ -52,6 +55,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.rerunPRChecks', + permission: 'workspace', params: RerunPullRequestChecks, handler: async (params, { runtime }) => runtime.rerunRepoPRChecks(params.repo, params.prNumber, { @@ -62,6 +66,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.prComments', + permission: 'workspace', params: PullRequest, handler: async (params, { runtime }) => runtime.getRepoPRComments(params.repo, params.prNumber, params.prRepo ?? null, { @@ -70,6 +75,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.setPRCommentReaction', + permission: 'workspace', params: PRCommentReaction, handler: async (params, { runtime }) => runtime.setRepoPRCommentReaction( @@ -82,6 +88,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.prFileContents', + permission: 'workspace', params: PullRequestFileContents, handler: async (params, { runtime }) => runtime.getRepoPRFileContents(params.repo, { @@ -96,6 +103,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.resolveReviewThread', + permission: 'workspace', params: ReviewThread, handler: async (params, { runtime }) => runtime.resolveRepoReviewThread( @@ -107,6 +115,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.setPRFileViewed', + permission: 'workspace', params: PullRequestFileViewed, handler: async (params, { runtime }) => runtime.setRepoPRFileViewed(params.repo, { diff --git a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts index 59089b82015..e2fbfa5937e 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts @@ -15,12 +15,14 @@ import { export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ defineMethod({ name: 'github.updatePRTitle', + permission: 'workspace', params: UpdatePrTitle, handler: async (params, { runtime }) => runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title, params.prRepo ?? null) }), defineMethod({ name: 'github.updatePR', + permission: 'workspace', params: UpdatePr, handler: async (params, { runtime }) => runtime.updateRepoPRDetails( @@ -32,12 +34,14 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.mergePR', + permission: 'workspace', params: MergePr, handler: async (params, { runtime }) => runtime.mergeRepoPR(params.repo, params.prNumber, params.method, params.prRepo ?? null) }), defineMethod({ name: 'github.setPRAutoMerge', + permission: 'workspace', params: SetPrAutoMerge, handler: async (params, { runtime }) => runtime.setRepoPRAutoMerge( @@ -50,18 +54,21 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.updatePRState', + permission: 'workspace', params: UpdatePrState, handler: async (params, { runtime }) => runtime.updateRepoPRState(params.repo, params.prNumber, params.updates, params.prRepo ?? null) }), defineMethod({ name: 'github.markPRReadyForReview', + permission: 'workspace', params: MarkPrReadyForReview, handler: async (params, { runtime }) => runtime.markRepoPRReadyForReview(params.repo, params.prNumber, params.prRepo ?? null) }), defineMethod({ name: 'github.requestPRReviewers', + permission: 'workspace', params: RequestPrReviewers, handler: async (params, { runtime }) => runtime.requestRepoPRReviewers( @@ -73,6 +80,7 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.removePRReviewers', + permission: 'workspace', params: RemovePrReviewers, handler: async (params, { runtime }) => runtime.removeRepoPRReviewers( @@ -84,6 +92,7 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.addPRReviewComment', + permission: 'workspace', params: PRReviewComment, handler: async (params, { runtime }) => runtime.addRepoPRReviewComment(params.repo, { @@ -98,6 +107,7 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.addPRReviewCommentReply', + permission: 'workspace', params: PRReviewCommentReply, handler: async (params, { runtime }) => runtime.addRepoPRReviewCommentReply(params.repo, { diff --git a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts index 6b2d83988d7..fabce2b8a0b 100644 --- a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts +++ b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts @@ -13,21 +13,25 @@ import { export const GITHUB_REPO_WORK_ITEM_METHODS = [ defineMethod({ name: 'github.repoSlug', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoSlug(params.repo) }), defineMethod({ name: 'github.repoUpstream', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoUpstream(params.repo) }), defineMethod({ name: 'github.rateLimit', + permission: 'workspace', params: RateLimit, handler: async (params, { runtime }) => runtime.getGitHubRateLimit(params) }), defineMethod({ name: 'github.listWorkItems', + permission: 'workspace', params: WorkItemsList, handler: async (params, { runtime }) => runtime.listRepoWorkItems( @@ -40,32 +44,38 @@ export const GITHUB_REPO_WORK_ITEM_METHODS = [ }), defineMethod({ name: 'github.listIssues', + permission: 'workspace', params: IssuesList, handler: async (params, { runtime }) => runtime.listRepoIssues(params.repo, params.limit) }), defineMethod({ name: 'github.countWorkItems', + permission: 'workspace', params: WorkItemsCount, handler: async (params, { runtime }) => runtime.countRepoWorkItems(params.repo, params.query) }), defineMethod({ name: 'github.listLabels', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.listRepoLabels(params.repo) }), defineMethod({ name: 'github.listAssignableUsers', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.listRepoAssignableUsers(params.repo) }), defineMethod({ name: 'github.workItem', + permission: 'workspace', params: WorkItem, handler: async (params, { runtime }) => runtime.getRepoWorkItem(params.repo, params.number, params.type) }), defineMethod({ name: 'github.workItemByOwnerRepo', + permission: 'workspace', params: WorkItemByOwnerRepo, handler: async (params, { runtime }) => runtime.getRepoWorkItemByOwnerRepo( @@ -81,6 +91,7 @@ export const GITHUB_REPO_WORK_ITEM_METHODS = [ }), defineMethod({ name: 'github.workItemDetails', + permission: 'workspace', params: WorkItemDetails, handler: async (params, { runtime }) => runtime.getRepoWorkItemDetails(params.repo, params.number, params.type) diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index 93f73d5f05c..fdc5cd75397 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -26,6 +26,7 @@ import { export const GITLAB_METHODS = [ defineMethod({ name: 'gitlab.listMRs', + permission: 'workspace', params: WorkItemsList, handler: async (params, { runtime }) => runtime.listGitLabRepoMRs( @@ -38,6 +39,7 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.listWorkItems', + permission: 'workspace', params: WorkItemsList, handler: async (params, { runtime }) => runtime.listGitLabRepoWorkItems( @@ -50,6 +52,7 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.listIssues', + permission: 'workspace', params: IssuesList, handler: async (params, { runtime }) => { const normalized = normalizeGitLabIssueListArgs(params) @@ -64,56 +67,66 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.todos', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.listGitLabRepoTodos(params.repo) }), defineMethod({ name: 'gitlab.diagnoseAuth', + permission: 'workspace', params: EmptyParams, handler: async (_params, { runtime }) => runtime.diagnoseGitLabAuth() }), defineMethod({ name: 'gitlab.rateLimit', + permission: 'workspace', params: GitLabRateLimit, handler: async (params, { runtime }) => runtime.getGitLabRateLimit(params) }), defineMethod({ name: 'gitlab.listLabels', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.listGitLabRepoLabels(params.repo) }), defineMethod({ name: 'gitlab.createIssue', + permission: 'workspace', params: CreateIssue, handler: async (params, { runtime }) => runtime.createGitLabRepoIssue(params.repo, params.title, params.body) }), defineMethod({ name: 'gitlab.updateIssue', + permission: 'workspace', params: UpdateIssue, handler: async (params, { runtime }) => runtime.updateGitLabRepoIssue(params.repo, params.number, params.updates, params.projectRef) }), defineMethod({ name: 'gitlab.addIssueComment', + permission: 'workspace', params: AddIssueComment, handler: async (params, { runtime }) => runtime.addGitLabRepoIssueComment(params.repo, params.number, params.body, params.projectRef) }), defineMethod({ name: 'gitlab.addMRComment', + permission: 'workspace', params: AddMRComment, handler: async (params, { runtime }) => runtime.addGitLabRepoMRComment(params.repo, params.iid, params.body, params.projectRef) }), defineMethod({ name: 'gitlab.addMRInlineComment', + permission: 'workspace', params: AddMRInlineComment, handler: async (params, { runtime }) => runtime.addGitLabRepoMRInlineComment(params.repo, params.iid, params.input, params.projectRef) }), defineMethod({ name: 'gitlab.resolveMRDiscussion', + permission: 'workspace', params: ResolveMRDiscussion, handler: async (params, { runtime }) => runtime.resolveGitLabRepoMRDiscussion( @@ -126,6 +139,7 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.jobTrace', + permission: 'workspace', params: JobTrace, handler: async (params, { runtime }) => { const result = await runtime.getGitLabRepoJobTrace( @@ -138,30 +152,35 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.retryJob', + permission: 'workspace', params: RetryJob, handler: async (params, { runtime }) => runtime.retryGitLabRepoJob(params.repo, params.jobId, params.projectRef) }), defineMethod({ name: 'gitlab.mergeMR', + permission: 'workspace', params: MergeMr, handler: async (params, { runtime }) => runtime.mergeGitLabRepoMR(params.repo, params.iid, params.method, params.projectRef) }), defineMethod({ name: 'gitlab.updateMRState', + permission: 'workspace', params: UpdateMrState, handler: async (params, { runtime }) => runtime.updateGitLabRepoMRState(params.repo, params.iid, params.state, params.projectRef) }), defineMethod({ name: 'gitlab.updateMR', + permission: 'workspace', params: UpdateMr, handler: async (params, { runtime }) => runtime.updateGitLabRepoMR(params.repo, params.iid, params.updates, params.projectRef) }), defineMethod({ name: 'gitlab.updateMRReviewers', + permission: 'workspace', params: UpdateMrReviewers, handler: async (params, { runtime }) => runtime.updateGitLabRepoMRReviewers( @@ -173,12 +192,14 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.workItemDetails', + permission: 'workspace', params: WorkItemDetails, handler: async (params, { runtime }) => runtime.getGitLabRepoWorkItemDetails(params.repo, params.iid, params.type, params.projectRef) }), defineMethod({ name: 'gitlab.workItemByPath', + permission: 'workspace', params: WorkItemByPath, handler: async (params, { runtime }) => runtime.getGitLabRepoWorkItemByPath( diff --git a/src/main/runtime/rpc/methods/host-capabilities.ts b/src/main/runtime/rpc/methods/host-capabilities.ts index 85a32fd1e5c..e1fa849a662 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.ts @@ -6,6 +6,7 @@ import { isGitBashAvailable } from '../../../git-bash' export const HOST_CAPABILITY_METHODS = [ defineMethod({ name: 'host.platform', + permission: 'workspace', params: null, handler: async () => ({ platform: process.platform }) }), @@ -13,21 +14,25 @@ export const HOST_CAPABILITY_METHODS = [ // execFileSync wsl.exe/pwsh.exe on this host's main event loop for up to 5s per call. defineMethod({ name: 'host.wsl.isAvailable', + permission: 'workspace', params: null, handler: async () => isWslAvailableAsync() }), defineMethod({ name: 'host.wsl.listDistros', + permission: 'workspace', params: null, handler: async () => listWslDistrosAsync() }), defineMethod({ name: 'host.pwsh.isAvailable', + permission: 'workspace', params: null, handler: async () => isPwshAvailableAsync() }), defineMethod({ name: 'host.gitBash.isAvailable', + permission: 'workspace', params: null, handler: async () => isGitBashAvailable() }) diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index c2f071d2cbb..ed547c5912a 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -10,6 +10,7 @@ import { export const HOSTED_REVIEW_METHODS = [ defineMethod({ name: 'hostedReview.forBranch', + permission: 'workspace', params: HostedReviewForBranch, handler: async (params, { runtime }) => { const fallbackGitHubPR = @@ -32,6 +33,7 @@ export const HOSTED_REVIEW_METHODS = [ }), defineMethod({ name: 'hostedReview.getCreationEligibility', + permission: 'workspace', params: HostedReviewCreationEligibility, handler: async (params, { runtime }) => { const fallbackGitHubPR = @@ -56,6 +58,7 @@ export const HOSTED_REVIEW_METHODS = [ }), defineMethod({ name: 'hostedReview.create', + permission: 'workspace', params: HostedReviewCreate, handler: async (params, { runtime }) => { // The wire carries the host's own provider token, so this is where an arm this build does @@ -78,6 +81,7 @@ export const HOSTED_REVIEW_METHODS = [ }), defineMethod({ name: 'hostedReview.createStacked', + permission: 'workspace', params: HostedReviewCreate, handler: async (params, { runtime }) => { if (!supportsHostedReviewCreation(params.provider)) { diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 0e959cf0f5e..dc13c8ead0b 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -37,6 +37,7 @@ function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void export const JIRA_METHODS = [ defineMethod({ name: 'jira.connect', + permission: 'accounts-admin', params: Connect, handler: async (params, { runtime }) => runtime.jiraConnect({ @@ -48,48 +49,57 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.disconnect', + permission: 'accounts-admin', params: SiteSelection, handler: async (params, { runtime }) => runtime.jiraDisconnect(params?.siteId) }), defineMethod({ name: 'jira.selectSite', + permission: 'accounts-admin', params: SelectSite, handler: async (params, { runtime }) => runtime.jiraSelectSite(params.siteId.trim()) }), defineMethod({ name: 'jira.status', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.jiraStatus() }), defineMethod({ name: 'jira.readStatus', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.jiraReadStatus() }), defineMethod({ name: 'jira.testConnection', + permission: 'workspace', params: SiteSelection, handler: async (params, { runtime }) => runtime.jiraTestConnection(params?.siteId) }), defineMethod({ name: 'jira.searchIssues', + permission: 'workspace', params: SearchIssues, handler: async (params, { runtime, signal }) => runtime.jiraSearchIssues(params.jql, params.limit, params.siteId, signal) }), defineMethod({ name: 'jira.listIssues', + permission: 'workspace', params: ListIssues, handler: async (params, { runtime }) => runtime.jiraListIssues(params?.filter, params?.limit, params?.siteId) }), defineMethod({ name: 'jira.getIssue', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }) => runtime.jiraGetIssue(params.key.trim(), params.siteId) }), defineMethod({ name: 'jira.lookupIssueSummary', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime, signal }) => { if (!params.siteId) { @@ -100,6 +110,7 @@ export const JIRA_METHODS = [ }), defineStreamingMethod({ name: 'jira.getIssueStream', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }, emit) => { emitJiraPayload(await runtime.jiraGetIssue(params.key.trim(), params.siteId), emit) @@ -107,6 +118,7 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.createIssue', + permission: 'workspace', params: CreateIssue, handler: async (params, { runtime }) => runtime.jiraCreateIssue({ @@ -121,24 +133,28 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.updateIssue', + permission: 'workspace', params: IssueUpdate, handler: async (params, { runtime }) => runtime.jiraUpdateIssue(params.key.trim(), params.updates, params.siteId) }), defineMethod({ name: 'jira.addIssueComment', + permission: 'workspace', params: IssueComment, handler: async (params, { runtime }) => runtime.jiraAddIssueComment(params.key.trim(), params.body.trim(), params.siteId) }), defineMethod({ name: 'jira.issueComments', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }) => runtime.jiraIssueComments(params.key.trim(), params.siteId) }), defineStreamingMethod({ name: 'jira.issueCommentsStream', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }, emit) => { emitJiraPayload(await runtime.jiraIssueComments(params.key.trim(), params.siteId), emit) @@ -146,17 +162,20 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.listProjects', + permission: 'workspace', params: SiteSelection, handler: async (params, { runtime }) => runtime.jiraListProjects(params?.siteId) }), defineMethod({ name: 'jira.listIssueTypes', + permission: 'workspace', params: ProjectIssueTypes, handler: async (params, { runtime }) => runtime.jiraListIssueTypes(params.projectIdOrKey.trim(), params.siteId) }), defineMethod({ name: 'jira.listCreateFields', + permission: 'workspace', params: ProjectIssueTypeFields, handler: async (params, { runtime }) => runtime.jiraListCreateFields( @@ -167,28 +186,33 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.listPriorities', + permission: 'workspace', params: SiteSelection, handler: async (params, { runtime }) => runtime.jiraListPriorities(params?.siteId) }), defineMethod({ name: 'jira.listAssignableUsers', + permission: 'workspace', params: AssignableUsers, handler: async (params, { runtime }) => runtime.jiraListAssignableUsers(params.key.trim(), params.query, params.siteId) }), defineMethod({ name: 'jira.searchUsers', + permission: 'workspace', params: UserSearch, handler: async (params, { runtime }) => runtime.jiraSearchUsers(params.query, params.siteId) }), defineMethod({ name: 'jira.listTransitions', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }) => runtime.jiraListTransitions(params.key.trim(), params.siteId) }), defineMethod({ name: 'jira.getProjectStatusOrder', + permission: 'workspace', params: ProjectStatusOrder, handler: async (params, { runtime }) => runtime.jiraGetProjectStatusOrder(params.projectKey.trim(), params.siteId) diff --git a/src/main/runtime/rpc/methods/linear-agent-access.ts b/src/main/runtime/rpc/methods/linear-agent-access.ts index 2c46face39d..1c8d6c177fe 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.ts @@ -31,12 +31,14 @@ function parseLinearWriteId(writeId: string | undefined): string | undefined { export const LINEAR_AGENT_ACCESS_METHODS = [ defineMethod({ name: 'linear.saveIssue', + permission: 'workspace', params: LinearSaveIssue, handler: async (params, { runtime }) => runtime.linearSaveIssue({ ...params, writeId: parseLinearWriteId(params.writeId) }) }), defineMethod({ name: 'linear.agentSearchIssues', + permission: 'workspace', params: AgentSearchIssues, handler: async (params, { runtime }) => runtime.linearSearchForAgents({ @@ -47,73 +49,87 @@ export const LINEAR_AGENT_ACCESS_METHODS = [ }), defineMethod({ name: 'linear.issueContext', + permission: 'workspace', params: AgentIssueContext, handler: async (params, { runtime }) => runtime.linearIssueContext(params) }), defineMethod({ name: 'linear.agentTeamList', + permission: 'workspace', params: LinearWorkspaceRead, handler: async (params, { runtime }) => runtime.linearTeamListForAgents(params) }), defineMethod({ name: 'linear.agentTeamMembers', + permission: 'workspace', params: LinearTeamLookup, handler: async (params, { runtime }) => runtime.linearTeamMembersForAgents(params) }), defineMethod({ name: 'linear.agentTeamStates', + permission: 'workspace', params: LinearTeamLookup, handler: async (params, { runtime }) => runtime.linearTeamStatesForAgents(params) }), defineMethod({ name: 'linear.agentTeamLabels', + permission: 'workspace', params: LinearTeamLookup, handler: async (params, { runtime }) => runtime.linearTeamLabelsForAgents(params) }), defineMethod({ name: 'linear.agentIssueList', + permission: 'workspace', params: LinearIssueList, handler: async (params, { runtime }) => runtime.linearIssueListForAgents(params) }), defineMethod({ name: 'linear.agentProjectList', + permission: 'workspace', params: LinearProjectList, handler: async (params, { runtime }) => runtime.linearProjectListForAgents(params) }), defineMethod({ name: 'linear.resolveCurrentIssue', + permission: 'workspace', params: LinearCurrentContext, handler: async (params, { runtime }) => runtime.linearResolveCurrentIssue(params) }), defineMethod({ name: 'linear.issueSetState', + permission: 'workspace', params: LinearIssueSetState, handler: async (params, { runtime }) => runtime.linearIssueSetState(params) }), defineMethod({ name: 'linear.issueUpdateTask', + permission: 'workspace', params: LinearIssueUpdateTask, handler: async (params, { runtime }) => runtime.linearIssueUpdateTask(params) }), defineMethod({ name: 'linear.issueRelationWrite', + permission: 'workspace', params: LinearIssueRelationWrite, handler: async (params, { runtime }) => runtime.linearIssueRelationWrite(params) }), defineMethod({ name: 'linear.issueAddComment', + permission: 'workspace', params: LinearIssueAddComment, handler: async (params, { runtime }) => runtime.linearIssueAddComment({ ...params, writeId: parseLinearWriteId(params.writeId) }) }), defineMethod({ name: 'linear.issueAttachLink', + permission: 'workspace', params: LinearIssueAttachLink, handler: async (params, { runtime }) => runtime.linearIssueAttachLink({ ...params, writeId: parseLinearWriteId(params.writeId) }) }), defineMethod({ name: 'linear.issueCreate', + permission: 'workspace', params: LinearIssueCreate, handler: async (params, { runtime }) => runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) }) diff --git a/src/main/runtime/rpc/methods/linear-issue-list-method.ts b/src/main/runtime/rpc/methods/linear-issue-list-method.ts index 9dd838c837a..e69719d2c6e 100644 --- a/src/main/runtime/rpc/methods/linear-issue-list-method.ts +++ b/src/main/runtime/rpc/methods/linear-issue-list-method.ts @@ -4,6 +4,7 @@ import { ListIssues, McpListIssues } from '../../../../shared/rpc-contract/linea export const LINEAR_ISSUE_LIST_METHOD = defineMethod({ name: 'linear.listIssues', + permission: 'workspace', params: ListIssues, handler: async (params, { runtime }) => { if (isMcpIssueListRequest(params)) { @@ -17,6 +18,7 @@ export const LINEAR_ISSUE_LIST_METHOD = defineMethod({ export const LINEAR_MCP_ISSUE_LIST_METHOD = defineMethod({ name: 'linear.mcpListIssues', + permission: 'workspace', params: McpListIssues, handler: async (params, { runtime }) => runtime.linearMcpIssueList(params) }) diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts index 8377b69acdf..481ed9fd0cc 100644 --- a/src/main/runtime/rpc/methods/linear-project-create.ts +++ b/src/main/runtime/rpc/methods/linear-project-create.ts @@ -3,6 +3,7 @@ import { CreateProject } from '../../../../shared/rpc-contract/linear-project-cr export const LINEAR_PROJECT_CREATE_METHOD = defineMethod({ name: 'linear.createProject', + permission: 'workspace', params: CreateProject, handler: async (params, { runtime }) => runtime.linearCreateProject( diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index 456b4c5b4e9..179d7610c98 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -23,31 +23,37 @@ import { export const LINEAR_METHODS = [ defineMethod({ name: 'linear.connect', + permission: 'accounts-admin', params: Connect, handler: async (params, { runtime }) => runtime.linearConnect(params.apiKey.trim()) }), defineMethod({ name: 'linear.disconnect', + permission: 'accounts-admin', params: WorkspaceSelection, handler: async (params, { runtime }) => runtime.linearDisconnect(params?.workspaceId) }), defineMethod({ name: 'linear.selectWorkspace', + permission: 'accounts-admin', params: SelectWorkspace, handler: async (params, { runtime }) => runtime.linearSelectWorkspace(params.workspaceId.trim()) }), defineMethod({ name: 'linear.status', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.linearStatus() }), defineMethod({ name: 'linear.testConnection', + permission: 'workspace', params: WorkspaceSelection, handler: async (params, { runtime }) => runtime.linearTestConnection(params?.workspaceId) }), defineMethod({ name: 'linear.searchIssues', + permission: 'workspace', params: SearchIssues, handler: async (params, { runtime }) => runtime.linearSearchIssues(params.query, params.limit, params.workspaceId) @@ -56,6 +62,7 @@ export const LINEAR_METHODS = [ LINEAR_MCP_ISSUE_LIST_METHOD, defineMethod({ name: 'linear.createIssue', + permission: 'workspace', params: CreateIssue, handler: async (params, { runtime }) => runtime.linearCreateIssue( @@ -75,35 +82,41 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.getIssue', + permission: 'workspace', params: IssueId, handler: async (params, { runtime }) => runtime.linearGetIssue(params.id.trim(), params.workspaceId) }), defineMethod({ name: 'linear.updateIssue', + permission: 'workspace', params: IssueUpdate, handler: async (params, { runtime }) => runtime.linearUpdateIssue(params.id.trim(), params.updates, params.workspaceId) }), defineMethod({ name: 'linear.addIssueComment', + permission: 'workspace', params: IssueComment, handler: async (params, { runtime }) => runtime.linearAddIssueComment(params.issueId.trim(), params.body.trim(), params.workspaceId) }), defineMethod({ name: 'linear.issueComments', + permission: 'workspace', params: LinearIssueCommentsParams, handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.listTeams', + permission: 'workspace', params: WorkspaceSelection, handler: async (params, { runtime }) => runtime.linearListTeams(params?.workspaceId) }), defineMethod({ name: 'linear.listProjects', + permission: 'workspace', params: ListProjects, handler: async (params, { runtime }) => runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId, params?.force) @@ -111,12 +124,14 @@ export const LINEAR_METHODS = [ LINEAR_PROJECT_CREATE_METHOD, defineMethod({ name: 'linear.getProject', + permission: 'workspace', params: ProjectId, handler: async (params, { runtime }) => runtime.linearGetProject(params.id.trim(), params.workspaceId.trim(), params.force) }), defineMethod({ name: 'linear.listProjectIssues', + permission: 'workspace', params: ProjectIssues, handler: async (params, { runtime }) => runtime.linearListProjectIssues( @@ -128,12 +143,14 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.listCustomViews', + permission: 'workspace', params: ListCustomViews, handler: async (params, { runtime }) => runtime.linearListCustomViews(params.model, params.limit, params.workspaceId, params.force) }), defineMethod({ name: 'linear.getCustomView', + permission: 'workspace', params: CustomViewId, handler: async (params, { runtime }) => runtime.linearGetCustomView( @@ -145,6 +162,7 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.listCustomViewIssues', + permission: 'workspace', params: CustomViewContents, handler: async (params, { runtime }) => runtime.linearListCustomViewIssues( @@ -156,6 +174,7 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.listCustomViewProjects', + permission: 'workspace', params: CustomViewContents, handler: async (params, { runtime }) => runtime.linearListCustomViewProjects( @@ -167,18 +186,21 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.teamStates', + permission: 'workspace', params: TeamId, handler: async (params, { runtime }) => runtime.linearTeamStates(params.teamId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.teamLabels', + permission: 'workspace', params: TeamId, handler: async (params, { runtime }) => runtime.linearTeamLabels(params.teamId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.teamMembers', + permission: 'workspace', params: TeamId, handler: async (params, { runtime }) => runtime.linearTeamMembers(params.teamId.trim(), params.workspaceId) diff --git a/src/main/runtime/rpc/methods/managed-server.ts b/src/main/runtime/rpc/methods/managed-server.ts index 95902891bae..aace76a8551 100644 --- a/src/main/runtime/rpc/methods/managed-server.ts +++ b/src/main/runtime/rpc/methods/managed-server.ts @@ -22,32 +22,38 @@ function actions(): ManagedServerActions { export const MANAGED_SERVER_METHODS = [ defineMethod({ name: 'managedServer.status', + permission: 'workspace', params: ManagedServerSelector, handler: ({ selector }) => actions().status(selector) }), defineMethod({ name: 'managedServer.update', + permission: 'host-admin', params: ManagedServerUpdate, handler: ({ selector, force }) => actions().update(selector, force === true) }), defineMethod({ name: 'managedServer.rollback', + permission: 'host-admin', params: ManagedServerSelector, handler: ({ selector }) => actions().rollback(selector) }), defineMethod({ name: 'managedServer.recover', + permission: 'host-admin', params: ManagedServerRecover, handler: ({ selector, acceptChangedState }) => actions().recover(selector, acceptChangedState === true) }), defineMethod({ name: 'managedServer.stop', + permission: 'host-admin', params: ManagedServerSelector, handler: ({ selector }) => actions().stop(selector) }), defineMethod({ name: 'managedServer.cancelStop', + permission: 'host-admin', params: ManagedServerSelector, handler: ({ selector }) => actions().cancelStop(selector) }) diff --git a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts index 756d4719f22..c9eb997f6cd 100644 --- a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts +++ b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts @@ -4,12 +4,14 @@ import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' export const MOBILE_MARKDOWN_TAB_METHODS = [ defineMethod({ name: 'markdown.readTab', + permission: 'workspace', params: ActivateTab, handler: async (params, { runtime }) => runtime.readMobileMarkdownTab(params.worktree, params.tabId) }), defineMethod({ name: 'markdown.saveTab', + permission: 'workspace', params: SaveMarkdownTab, handler: async (params, { runtime }) => runtime.saveMobileMarkdownTab( diff --git a/src/main/runtime/rpc/methods/mobile-web-bundle.ts b/src/main/runtime/rpc/methods/mobile-web-bundle.ts index f88938373b3..5f879bf20ff 100644 --- a/src/main/runtime/rpc/methods/mobile-web-bundle.ts +++ b/src/main/runtime/rpc/methods/mobile-web-bundle.ts @@ -161,6 +161,7 @@ async function readVerifiedWindow( export const MOBILE_WEB_BUNDLE_METHODS = [ defineMethod({ name: MOBILE_WEB_BUNDLE_MANIFEST_METHOD, + permission: 'workspace', params: null, handler: async (): Promise => ({ manifest: requireBundle().manifest, @@ -170,6 +171,7 @@ export const MOBILE_WEB_BUNDLE_METHODS = [ }), defineMethod({ name: MOBILE_WEB_BUNDLE_CHUNK_METHOD, + permission: 'workspace', params: MobileWebBundleChunkParamsSchema, handler: (params, ctx): Promise => readVerifiedWindow(ctx, params, MOBILE_WEB_BUNDLE_CHUNK_BYTES, async (window) => ({ @@ -179,6 +181,7 @@ export const MOBILE_WEB_BUNDLE_METHODS = [ }), defineMethod({ name: MOBILE_WEB_BUNDLE_RANGE_METHOD, + permission: 'workspace', params: MobileWebBundleRangeParamsSchema, handler: (params, ctx): Promise => readVerifiedWindow(ctx, params, MOBILE_WEB_BUNDLE_RANGE_BYTES, async (window) => { diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 296d55e3b6e..1cc734b0197 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -77,6 +77,7 @@ function pageForClient( export const NATIVE_CHAT_METHODS = [ defineMethod({ name: 'nativeChat.readSession', + permission: 'workspace', params: NativeChatSession, handler: async (params, { runtime, clientKind, signal }) => { const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW @@ -111,6 +112,7 @@ export const NATIVE_CHAT_METHODS = [ }), defineStreamingMethod({ name: 'nativeChat.subscribe', + permission: 'workspace', params: NativeChatSession, handler: async (params, { runtime, connectionId, clientKind, signal }, emit) => { if (signal?.aborted) { @@ -237,6 +239,7 @@ export const NATIVE_CHAT_METHODS = [ }), defineMethod({ name: 'nativeChat.unsubscribe', + permission: 'workspace', params: NativeChatUnsubscribe, handler: async (params, { runtime, connectionId }) => { const connection = connectionId ?? 'local' diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 7df66b4acf4..cca473aa975 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -16,6 +16,7 @@ let notificationsSubscriptionSeq = 0 export const NOTIFICATION_METHODS = [ defineStreamingMethod({ name: 'notifications.subscribe', + permission: 'workspace', params: NotificationsSubscribeParams, handler: async (params, { runtime, connectionId }, emit) => { const shouldEmit = createNotificationStreamFilter(params?.includeDesktopSuppressed) @@ -48,6 +49,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.unsubscribe', + permission: 'workspace', params: NotificationUnsubscribeParams, handler: async (params, { runtime }) => { runtime.cleanupSubscription(params.subscriptionId) @@ -56,6 +58,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.getMissedSince', + permission: 'workspace', params: NotificationGetMissedSinceParams, // Why: returns only notifications with seq > lastSeenSeq. The runtime owns // the monotonic seq, so this is the single source of truth for what the @@ -75,6 +78,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.registerPush', + permission: 'pairing-admin', params: NotificationRegisterPushParams, // Why: the registration is keyed by the revocable paired device identity, never // by anything the caller can assert, so an in-process or CLI caller has no device @@ -89,6 +93,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.testPush', + permission: 'pairing-admin', params: null, handler: async (_params, { runtime, clientKind, pairedDeviceId }) => { if (clientKind !== 'mobile' || !pairedDeviceId) { @@ -99,6 +104,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.unregisterPush', + permission: 'pairing-admin', params: null, // Deleting the gateway token is durable (outbox), so an offline gateway still // reports success to the phone that asked to stop being pushed to. diff --git a/src/main/runtime/rpc/methods/orcad-migration.ts b/src/main/runtime/rpc/methods/orcad-migration.ts index 69c24d294e8..be09836eb4e 100644 --- a/src/main/runtime/rpc/methods/orcad-migration.ts +++ b/src/main/runtime/rpc/methods/orcad-migration.ts @@ -8,6 +8,7 @@ import { defineMethod, type RpcContext } from '../core' export const ORCAD_MIGRATION_METHODS = [ defineMethod({ name: 'orcad.migration.stageCatalog', + permission: 'host-admin', params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) @@ -18,6 +19,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.commitCatalog', + permission: 'host-admin', params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) @@ -28,6 +30,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.stageSnapshotChunk', + permission: 'host-admin', params: OrcadMigrationSnapshotChunkRequestSchema, handler: async (params, context) => { requireMigrationRuntimeClient(context) @@ -36,6 +39,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.abortCatalog', + permission: 'host-admin', params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) @@ -46,6 +50,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.catalogState', + permission: 'host-admin', params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.ts index f48c553156d..f32c443577b 100644 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.ts +++ b/src/main/runtime/rpc/methods/orcad-terminal-census.ts @@ -8,6 +8,7 @@ import { export const ORCAD_TERMINAL_CENSUS_METHODS = [ defineMethod({ name: ORCAD_TERMINAL_CENSUS_METHOD, + permission: 'host-admin', params: OrcadTerminalCensusParamsSchema, handler: async (params, { runtime }) => { // Why lazy: the census reaches the daemon modules, whose xterm polyfill defines a global diff --git a/src/main/runtime/rpc/methods/orchestration/caller-show.ts b/src/main/runtime/rpc/methods/orchestration/caller-show.ts index e7d801f50be..0d2240770cb 100644 --- a/src/main/runtime/rpc/methods/orchestration/caller-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/caller-show.ts @@ -21,6 +21,7 @@ import { defineMethod } from '../../core' export const ORCHESTRATION_CALLER_METHODS = [ defineMethod({ name: 'orchestration.callerShow', + permission: 'workspace', params: null, // Why no params: the dispatch entry already resolved the session the caller's environment names, // and a session it cannot admit never reaches here: its refusal is the answer. @@ -32,6 +33,7 @@ export const ORCHESTRATION_CALLER_METHODS = [ }), defineMethod({ name: 'orchestration.sessionAddress', + permission: 'workspace', params: SessionAddressParams, // Why host-side: only the host's session records know a chat's `/clear` root. handler: (params, { runtime }): OrchestrationSessionAddressResult => { @@ -48,6 +50,7 @@ export const ORCHESTRATION_CALLER_METHODS = [ }), defineMethod({ name: 'orchestration.partyLocation', + permission: 'workspace', params: PartyLocationParams, // Why host-side: a chat sender's live session follows its `/clear` lineage on this host. handler: (params, { runtime }): OrchestrationPartyLocationResult => diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts index caec51d462e..2b9996b7f76 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts @@ -19,6 +19,7 @@ import { export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.federationFleetSnapshot', + permission: 'workspace', params: FederationFleetSnapshotParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { const items = await mapWithConcurrency(params.dispatchIds, 16, async (dispatchId) => { @@ -41,6 +42,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationRelease', + permission: 'workspace', params: FederationDispatchParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { const attachment = requireHomeAttachment( @@ -54,6 +56,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationShow', + permission: 'workspace', params: FederationDispatchParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { const attachment = requireHomeAttachment( @@ -78,6 +81,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationRead', + permission: 'workspace', params: FederationReadParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) @@ -103,6 +107,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationReadOutput', + permission: 'workspace', params: FederationOutputReadParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { const attachment = requireHomeAttachment( @@ -186,6 +191,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationStop', + permission: 'workspace', params: FederationDispatchParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts index 721232f194a..16b4c11f5d6 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts @@ -16,6 +16,7 @@ import { export const ORCHESTRATION_FEDERATION_RELAY_METHODS = [ defineMethod({ name: 'orchestration.federationPull', + permission: 'workspace', params: FederationPullParams, handler: (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) @@ -37,6 +38,7 @@ export const ORCHESTRATION_FEDERATION_RELAY_METHODS = [ }), defineMethod({ name: 'orchestration.federationAck', + permission: 'workspace', params: FederationAckParams, handler: (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) @@ -101,6 +103,7 @@ export const ORCHESTRATION_FEDERATION_RELAY_METHODS = [ }), defineMethod({ name: 'orchestration.federationImport', + permission: 'workspace', params: FederationImportParams, handler: (params, { runtime, authenticatedCallerFingerprint }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 0e5f3fedf11..2fb84779c6e 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -32,6 +32,7 @@ import { prepareFederatedAttachmentAuthority } from './federation-attachment-aut export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ defineMethod({ name: 'orchestration.federationAttachStart', + permission: 'workspace', params: FederationAttachStartParams, handler: async (params, { runtime, orchestrationMutation }) => { if (!orchestrationMutation) { diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts index f0736db916c..8efdf234f3e 100644 --- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts +++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts @@ -23,6 +23,7 @@ export const ORCHESTRATION_GATE_METHODS = [ // CLI (or any caller) for the entire duration of the pipeline. defineMethod({ name: 'orchestration.run', + permission: 'workspace', params: RunParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -64,6 +65,7 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.runStop', + permission: 'workspace', params: RunStopParams, handler: (_params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -83,6 +85,7 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateCreate', + permission: 'workspace', params: GateCreateParams, handler: ( params, @@ -130,6 +133,7 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateResolve', + permission: 'workspace', params: GateResolveParams, handler: ( params, @@ -162,6 +166,7 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateList', + permission: 'workspace', params: GateListParams, handler: ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts index 25b8058e5df..96919bae3c1 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts @@ -16,6 +16,7 @@ import { assertWorkerCanReport } from '../../../../orchestration/worker-report-a export const ORCHESTRATION_ASK_METHODS = [ defineMethod({ name: 'orchestration.ask', + permission: 'workspace', params: AskParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index b9aa437e179..5693320cbcc 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -17,6 +17,7 @@ import { export const ORCHESTRATION_CHECK_METHODS = [ defineMethod({ name: 'orchestration.check', + permission: 'workspace', params: CheckParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts index e079596f80d..507fef56b2f 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts @@ -24,6 +24,7 @@ import { export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.reply', + permission: 'workspace', params: ReplyParams, handler: async ( params, @@ -139,6 +140,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.inbox', + permission: 'workspace', params: InboxParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -155,6 +157,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskCreate', + permission: 'workspace', params: TaskCreateParams, handler: ( params, @@ -203,6 +206,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskList', + permission: 'workspace', params: TaskListParams, handler: ( params, @@ -245,6 +249,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskUpdate', + permission: 'workspace', params: TaskUpdateParams, handler: ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index a98e016da75..35ca1237227 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -32,6 +32,7 @@ import { assertLifecycleCallerIsNotAnotherParty } from './lifecycle-caller-fence export const ORCHESTRATION_SEND_METHODS = [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: SendParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/terminal-owned-mailboxes.ts b/src/main/runtime/rpc/methods/orchestration/messaging/terminal-owned-mailboxes.ts new file mode 100644 index 00000000000..27b5aa95282 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/terminal-owned-mailboxes.ts @@ -0,0 +1,45 @@ +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { DispatchContextRow } from '../../../../orchestration/types' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { hasRunBindingKey } from '../../../../orchestration/orchestration-caller-identity' +import { orchestrationCallerIdentity } from '../runs/run-scope' +import { callerHoldsDispatchPane } from './dispatch-mailbox-fence' + +export type TerminalOwnedMailboxes = { + /** The canonical addresses this terminal's live pane reads: itself, its bound Run, its Dispatch. */ + addresses: ReadonlySet + /** The Dispatch the live pane and process currently hold. */ + dispatch: DispatchContextRow | undefined + /** The Run the live pane currently coordinates. */ + runId: string | undefined +} + +/** + * The mailboxes `check` reads for this terminal, validated against its live pane and process. + * Why: routing rewrites a handle to `run:`/`dispatch:`, so owning mail means owning those exact rows. + */ +export function resolveTerminalOwnedMailboxes( + runtime: OrcaRuntimeService, + db: OrchestrationDb, + handle: string +): TerminalOwnedMailboxes { + const addresses = new Set([handle]) + const paneKey = runtime.getLiveTerminalPaneKey(handle) ?? undefined + const caller = orchestrationCallerIdentity(runtime, { handle, paneKey, session: undefined }) + const run = hasRunBindingKey(caller) ? db.getCurrentRunForCoordinator(caller) : undefined + if (run) { + addresses.add(`run:${run.id}`) + } + const candidate = paneKey ? db.getActiveDispatchForIdentity(handle, paneKey) : undefined + const dispatch = + candidate && + callerHoldsDispatchPane(candidate, paneKey) && + (candidate.process_incarnation === null || + runtime.getTerminalProcessIncarnation(handle) === candidate.process_incarnation) + ? candidate + : undefined + if (dispatch) { + addresses.add(`dispatch:${dispatch.id}`) + } + return { addresses, dispatch, runId: run?.id } +} diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index 6834aeb22b9..041ae7fff38 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -25,6 +25,7 @@ import { dispatchTaskSource } from '../../../../orchestration/dispatch-task-sour export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ name: 'orchestration.dispatch', + permission: 'workspace', params: DispatchParams, handler: async ( params, @@ -223,6 +224,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ name: 'orchestration.dispatchShow', + permission: 'workspace', params: DispatchShowParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts index a1cf43ab424..689b03498e3 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts @@ -8,6 +8,7 @@ import { RequestShowParams } from '../../../../../../shared/rpc-contract/orchest export const ORCHESTRATION_MUTATION_REQUEST_METHODS = [ defineMethod({ name: 'orchestration.requestShow', + permission: 'workspace', params: RequestShowParams, // Why: recovery needs a way to ask whether a mutation landed without mutating // again; this reads the durable receipt and never writes one, so it must stay diff --git a/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts index 6946606651f..36141403707 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts @@ -4,6 +4,7 @@ import { ResetParams } from '../schemas' export const ORCHESTRATION_RESET_METHODS = [ defineMethod({ name: 'orchestration.reset', + permission: 'workspace', params: ResetParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index 88653af8ecd..6af5ac6a2ed 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -28,6 +28,7 @@ function cancelBoundDispatchWaiters( export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCreate', + permission: 'workspace', params: RunCreateParams, handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { const caller = resolveOrchestrationCaller(runtime, { @@ -54,6 +55,7 @@ export const ORCHESTRATION_RUN_METHODS = [ }), defineMethod({ name: 'orchestration.runUse', + permission: 'workspace', params: RunUseParams, handler: ( params, @@ -112,6 +114,7 @@ export const ORCHESTRATION_RUN_METHODS = [ }), defineMethod({ name: 'orchestration.runCurrent', + permission: 'workspace', params: RunCurrentParams, handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { const caller = resolveOrchestrationCaller(runtime, { @@ -126,6 +129,7 @@ export const ORCHESTRATION_RUN_METHODS = [ }), defineMethod({ name: 'orchestration.runList', + permission: 'workspace', params: RunListParams, handler: (params, { runtime }) => { const listed = runtime.getOrchestrationDb().listRuns(params) @@ -134,6 +138,7 @@ export const ORCHESTRATION_RUN_METHODS = [ }), defineMethod({ name: 'orchestration.runShow', + permission: 'workspace', params: RunShowParams, handler: (params, { runtime }) => { const run = runtime.getOrchestrationDb().getRun(params.id) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index 1dff6d9a6b6..7482f573ccc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -26,6 +26,7 @@ import { export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.workerShow', + permission: 'workspace', params: WorkerDispatchParams, handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -79,6 +80,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.workerRead', + permission: 'workspace', params: WorkerReadParams, handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -199,6 +201,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.workerAbandon', + permission: 'workspace', params: WorkerDispatchParams, handler: (params, { runtime, orchestrationCaller }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts index 9c77fd8895f..315a8cffdf4 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts @@ -27,6 +27,7 @@ import { WORKER_TERMINAL_LIST_STATES, WorkerListParams } from './worker-release- export const ORCHESTRATION_WORKER_LIST_METHOD = defineMethod({ name: 'orchestration.workerList', + permission: 'workspace', params: WorkerListParams, handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts index f641485e757..976df7ba83d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts @@ -14,6 +14,7 @@ import { OrchestrationWorkerTerminalUserInputParams } from '../../../../../../sh export const ORCHESTRATION_WORKER_RELEASE_METHODS = [ defineMethod({ name: 'orchestration.workerRelease', + permission: 'workspace', params: WorkerDispatchParams, handler: async (params, { runtime, orchestrationMutation }): Promise => { const db = runtime.getOrchestrationDb() @@ -85,6 +86,7 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS = [ }), defineMethod({ name: 'orchestration.workerRetain', + permission: 'workspace', params: WorkerRetainParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -132,6 +134,7 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS = [ ORCHESTRATION_WORKER_LIST_METHOD, defineMethod({ name: 'orchestration.workerTerminalUserInput', + permission: 'workspace', // `sessionId` addresses a worker that IS a structured agent session. Its pane key is a random // identity credential that never leaves main, so the caller names the session and the owning // runtime resolves it — a renderer echoing the pane key back would make it learnable. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts index 57b12b6da6b..dbb94c2726f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts @@ -15,6 +15,7 @@ import { WorkerDispatchParams } from '../../../../../../shared/rpc-contract/orch export const ORCHESTRATION_WORKER_STOP_METHODS = [ defineMethod({ name: 'orchestration.workerStop', + permission: 'workspace', params: WorkerDispatchParams, handler: (params, { runtime, orchestrationMutation }) => dedupeWorkerStop(runtime, params.dispatch, async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index 5dd3f0c598b..3d6a2b24a5a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -19,6 +19,7 @@ import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as SESSION_CODES } from '../.. export const ORCHESTRATION_WORKER_START_METHODS = [ defineMethod({ name: 'orchestration.workerStart', + permission: 'workspace', params: WorkerStartParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index 5a32ddab62f..775bcea7580 100644 --- a/src/main/runtime/rpc/methods/pairing.ts +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -7,6 +7,7 @@ import { export const PAIRING_METHODS = [ defineMethod({ name: 'pairing.getEndpoints', + permission: 'pairing-admin', params: PairingGetEndpointsParamsSchema, handler: async (params, ctx) => { if (!ctx.pairing) { @@ -17,6 +18,7 @@ export const PAIRING_METHODS = [ }), defineMethod({ name: 'pairing.provisionRelay', + permission: 'pairing-admin', params: PairingProvisionRelayParamsSchema, handler: async (params, ctx) => { if (!ctx.pairing) { diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts index 667aff9179d..869783f5a39 100644 --- a/src/main/runtime/rpc/methods/plugins.ts +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -68,6 +68,7 @@ function bindRpcPanelOwner(service: PluginService, context: RpcContext): string export const PLUGIN_METHODS = [ defineMethod({ name: 'plugins.list', + permission: 'workspace', params: null, handler: async () => listForRpc() }), @@ -75,6 +76,7 @@ export const PLUGIN_METHODS = [ // Why: headless serve has no consent dialog — an explicit consent call is // the only way a pending plugin becomes active on a server. name: 'plugins.consent', + permission: 'host-admin', params: pluginConsentRequestSchema, handler: async (params) => { const service = requirePluginService() @@ -88,6 +90,7 @@ export const PLUGIN_METHODS = [ }), defineMethod({ name: 'plugins.setEnabled', + permission: 'host-admin', params: PluginSetEnabledParams, handler: async (params) => { const service = requirePluginService() @@ -104,6 +107,7 @@ export const PLUGIN_METHODS = [ // capability enforcement must live behind this method too, not only in // the desktop IPC handler. name: 'plugins.panelAction', + permission: 'host-admin', // Why: raw admission must run before strict schema parsing so malformed // and oversized traffic cannot bypass the panel budget. params: PluginsPanelActionParams, @@ -117,6 +121,7 @@ export const PLUGIN_METHODS = [ }), defineMethod({ name: 'plugins.readPanelEntry', + permission: 'workspace', params: PluginReadPanelEntryParams, handler: async (params, context): Promise => { const service = requirePluginService() @@ -132,6 +137,7 @@ export const PLUGIN_METHODS = [ }), defineMethod({ name: 'plugins.invokeCommand', + permission: 'host-admin', params: PluginInvokeCommandParams, handler: async (params) => { const service = requirePluginService() diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index cc1dd5705c3..c4ef0d6f10f 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -15,26 +15,31 @@ import { export const PREFLIGHT_METHODS = [ defineMethod({ name: 'preflight.check', + permission: 'workspace', params: PreflightCheck, handler: async (params) => runPreflightCheck(params.force) }), defineMethod({ name: 'preflight.detectAgents', + permission: 'workspace', params: null, handler: async () => detectInstalledAgentsWithShellPathHydration() }), defineMethod({ name: 'preflight.detectRemoteAgents', + permission: 'workspace', params: PreflightDetectRemoteAgents, handler: async (params) => detectRemoteAgents(params) }), defineMethod({ name: 'preflight.detectRemoteWindowsTerminalCapabilities', + permission: 'workspace', params: PreflightDetectRemoteWindowsTerminalCapabilities, handler: async (params) => detectRemoteWindowsTerminalCapabilities(params) }), defineMethod({ name: 'preflight.refreshAgents', + permission: 'workspace', params: null, handler: async () => refreshShellPathAndDetectAgents() }) diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index f67705f6cdd..b90dd229205 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -12,6 +12,7 @@ import { export const PROJECT_RUNTIME_METHODS = [ defineMethod({ name: 'project.list', + permission: 'workspace', params: null, handler: (_params, { runtime }) => { runtime.enrichMissingRepoGitRemoteIdentities?.() @@ -20,6 +21,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'project.update', + permission: 'workspace', params: ProjectUpdate, handler: (params, { runtime }) => ({ project: runtime.updateProject(params.projectId, params.updates) @@ -27,6 +29,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.list', + permission: 'workspace', params: null, handler: (_params, { runtime }) => { runtime.enrichMissingRepoGitRemoteIdentities?.() @@ -35,6 +38,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.create', + permission: 'workspace', params: ProjectHostSetupCreate, handler: (params, { runtime }) => ({ result: runtime.createProjectHostSetup(params) @@ -42,6 +46,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.setupExistingFolder', + permission: 'workspace', params: ProjectHostSetupExistingFolder, handler: async (params, context) => ({ result: projectRepoResultVisibilityForClient( @@ -52,6 +57,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.clone', + permission: 'workspace', params: ProjectHostSetupClone, handler: async (params, context) => ({ result: projectRepoResultVisibilityForClient( @@ -62,6 +68,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.update', + permission: 'workspace', params: ProjectHostSetupUpdate, handler: (params, context) => ({ result: projectRepoResultVisibilityForClient( @@ -72,6 +79,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.delete', + permission: 'workspace', params: ProjectHostSetupDelete, handler: (params, context) => ({ result: projectRepoResultVisibilityForClient( diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 51e362c1692..b3725e17e0e 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -31,6 +31,7 @@ import { export const REPO_METHODS = [ defineMethod({ name: 'repo.list', + permission: 'workspace', params: null, handler: (_params, context) => { context.runtime.enrichMissingRepoGitRemoteIdentities?.() @@ -44,11 +45,13 @@ export const REPO_METHODS = [ ...PROJECT_RUNTIME_METHODS, defineMethod({ name: 'projectGroup.list', + permission: 'workspace', params: null, handler: (_params, { runtime }) => ({ groups: runtime.listProjectGroups() }) }), defineMethod({ name: 'projectGroup.create', + permission: 'workspace', params: ProjectGroupCreate, handler: async (params, { runtime }) => ({ group: await runtime.createProjectGroup(params) @@ -56,6 +59,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'projectGroup.update', + permission: 'workspace', params: ProjectGroupUpdate, handler: async (params, { runtime }) => ({ group: await runtime.updateProjectGroup(params.groupId, params.updates) @@ -63,11 +67,13 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'projectGroup.delete', + permission: 'workspace', params: ProjectGroupSelector, handler: async (params, { runtime }) => runtime.deleteProjectGroup(params.groupId) }), defineMethod({ name: 'projectGroup.moveProject', + permission: 'workspace', params: ProjectGroupMoveProject, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -79,16 +85,19 @@ export const REPO_METHODS = [ ...FOLDER_WORKSPACE_METHODS, defineMethod({ name: 'projectGroup.scanNested', + permission: 'workspace', params: ProjectGroupScanNested, handler: async (params, { runtime }) => runtime.scanNestedRepos(params.path) }), defineMethod({ name: 'projectGroup.importNested', + permission: 'workspace', params: ProjectGroupImportNested, handler: async (params, { runtime }) => runtime.importNestedRepos(params) }), defineMethod({ name: 'repo.sparsePresets', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => ({ presets: await runtime.listSparsePresets(params.repo) @@ -96,6 +105,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.saveSparsePreset', + permission: 'workspace', params: RepoSparsePresetSave, handler: async (params, { runtime }) => ({ preset: await runtime.saveSparsePreset(params.repo, { @@ -107,6 +117,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.add', + permission: 'workspace', params: RepoPath, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -117,6 +128,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.create', + permission: 'workspace', params: RepoCreate, handler: async (params, context) => projectRepoResultVisibilityForClient( @@ -126,11 +138,13 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.gitAvailable', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => ({ available: await runtime.isGitAvailable() }) }), defineMethod({ name: 'repo.clone', + permission: 'workspace', params: RepoClone, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -141,6 +155,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.show', + permission: 'workspace', params: RepoSelector, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient(await context.runtime.showRepo(params.repo), context) @@ -148,6 +163,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.update', + permission: 'workspace', params: RepoUpdate, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -161,16 +177,19 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.rm', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.removeProject(params.repo) }), defineMethod({ name: 'repo.reorder', + permission: 'workspace', params: RepoReorder, handler: async (params, { runtime }) => runtime.reorderRepos(params.orderedIds) }), defineMethod({ name: 'repo.setBaseRef', + permission: 'workspace', params: RepoSetBaseRef, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -181,11 +200,13 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.baseRefDefault', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoBaseRefDefault(params.repo) }), defineMethod({ name: 'repo.searchRefs', + permission: 'workspace', params: RepoSearchRefs, handler: async (params, { runtime, clientCapabilities }) => projectRepoSearchRefsForClient( @@ -200,26 +221,31 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.hooks', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo) }), defineMethod({ name: 'repo.hooksCheck', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.checkRepoHooks(params.repo) }), defineMethod({ name: 'repo.setupScriptImports', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.inspectRepoSetupScriptImports(params.repo) }), defineMethod({ name: 'repo.issueCommandRead', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.readRepoIssueCommand(params.repo) }), defineMethod({ name: 'repo.issueCommandWrite', + permission: 'workspace', params: RepoIssueCommandWrite, handler: async (params, { runtime }) => runtime.writeRepoIssueCommand(params.repo, params.content) diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts index 2fa62b53934..66aac3a889f 100644 --- a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts @@ -5,6 +5,7 @@ import { ClientCapabilitiesUpdate } from '../../../../shared/rpc-contract/runtim export const RUNTIME_CLIENT_CAPABILITY_METHODS = [ defineMethod({ name: 'runtime.clientCapabilities.update', + permission: 'workspace', params: ClientCapabilitiesUpdate, handler: (params, { updateClientCapabilities }) => { if (!updateClientCapabilities) { diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 762dc7af5bf..ef2176c0860 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -9,6 +9,7 @@ import { projectSessionTabsForClient } from './session-tabs-inventory' export const SESSION_TAB_CLOSE_METHODS = [ defineMethod({ name: 'session.tabs.close', + permission: 'workspace', params: CloseTab, handler: async (params, context) => { if (context.clientKind) { @@ -85,6 +86,7 @@ export const SESSION_TAB_CLOSE_METHODS = [ }), defineMethod({ name: 'session.tabs.closeLifecycle', + permission: 'workspace', params: CloseLifecycleTab, handler: async (params, context) => { if (context.clientKind) { diff --git a/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts b/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts index f2be1d4a61d..58cbaf909f9 100644 --- a/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts @@ -4,12 +4,14 @@ import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' export const SESSION_TAB_MARKDOWN_METHODS = [ defineMethod({ name: 'markdown.readTab', + permission: 'workspace', params: ActivateTab, handler: async (params, { runtime }) => runtime.readMobileMarkdownTab(params.worktree, params.tabId) }), defineMethod({ name: 'markdown.saveTab', + permission: 'workspace', params: SaveMarkdownTab, handler: async (params, { runtime }) => runtime.saveMobileMarkdownTab( diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index 38b824a9fee..76b72fb992f 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -11,6 +11,7 @@ import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-t export const SESSION_TAB_MUTATION_METHODS = [ defineMethod({ name: 'session.tabs.activate', + permission: 'workspace', params: ActivateTab, handler: async (params, { runtime, clientKind, pairedDeviceId, clientCapabilities }) => { if (clientKind) { @@ -41,6 +42,7 @@ export const SESSION_TAB_MUTATION_METHODS = [ }), defineMethod({ name: 'session.tabs.move', + permission: 'workspace', params: MoveTab, handler: async (params, { runtime, pairedDeviceId, clientCapabilities, clientKind }) => { let translated: Parameters[2] = params @@ -73,6 +75,7 @@ export const SESSION_TAB_MUTATION_METHODS = [ }), defineMethod({ name: 'session.tabs.updatePaneLayout', + permission: 'workspace', params: UpdatePaneLayout, handler: async (params, { runtime, pairedDeviceId, clientCapabilities, clientKind }) => { await assertVisibleMutationTab( @@ -94,6 +97,7 @@ export const SESSION_TAB_MUTATION_METHODS = [ }), defineMethod({ name: 'session.tabs.setTabProps', + permission: 'workspace', params: SetTabProps, handler: async (params, { runtime, pairedDeviceId, clientCapabilities, clientKind }) => { await assertVisibleMutationTab( diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index d549daffbaf..e53349c3e5b 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -23,6 +23,7 @@ import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../ export const SESSION_TAB_METHODS = [ defineMethod({ name: 'session.tabs.list', + permission: 'workspace', params: WorktreeTabSelector, handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) @@ -35,6 +36,7 @@ export const SESSION_TAB_METHODS = [ }), defineMethod({ name: 'session.tabs.listAll', + permission: 'workspace', params: null, handler: async (_params, context) => { await restoreStructuredTabsIfSupported(context) @@ -45,6 +47,7 @@ export const SESSION_TAB_METHODS = [ ...SESSION_TAB_CLOSE_METHODS, defineMethod({ name: 'session.tabs.createTerminal', + permission: 'workspace', params: CreateTerminalTab, handler: async ( params, @@ -93,6 +96,7 @@ export const SESSION_TAB_METHODS = [ }), defineStreamingMethod({ name: 'session.tabs.subscribe', + permission: 'workspace', params: WorktreeTabSelector, handler: async ( params, @@ -188,6 +192,7 @@ export const SESSION_TAB_METHODS = [ }), defineMethod({ name: 'session.tabs.unsubscribe', + permission: 'workspace', params: SessionTabsUnsubscribe, handler: async ( params, @@ -213,11 +218,13 @@ export const SESSION_TAB_METHODS = [ }), defineStreamingMethod({ name: 'session.tabs.subscribeAll', + permission: 'workspace', params: null, handler: (_params, context, emit) => subscribeSessionTabsInventory(context, emit) }), defineMethod({ name: 'session.tabs.unsubscribeAll', + permission: 'workspace', params: SessionTabsUnsubscribeAllParams, handler: async (params, { runtime, connectionId }) => { const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 39a3a73eb7c..7866de18eb8 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -67,6 +67,7 @@ function skillDeleteDependencies( export const SKILL_METHODS = [ defineMethod({ name: 'skills.discover', + permission: 'workspace', params: SkillsDiscoverParams, handler: async (params, { runtime }) => { // Why: the executing runtime owns WSL project preferences. Remote callers @@ -81,6 +82,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.previewDelete', + permission: 'workspace', params: SkillDeleteRequestSchema, handler: async (params, { runtime }) => previewSkillDeleteRequest( @@ -91,6 +93,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.delete', + permission: 'skills-admin', params: SkillDeleteRequestSchema, handler: async (params, { runtime }) => runSkillDeleteRequest( @@ -101,6 +104,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.share', + permission: 'skills-admin', params: AgentSkillShareRequestSchema, handler: async (params, { runtime, signal, clientKind }) => { runtime.assertAgentSkillSharingAllowed() @@ -126,6 +130,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.install', + permission: 'skills-admin', params: SkillInstallRequestSchema, handler: async (params, { runtime, signal, clientCapabilities }) => { const result = await runtime.installSharedSkillRequest(params, signal) @@ -145,12 +150,14 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.installBundle', + permission: 'skills-admin', params: SkillBundleInstallRequestSchema, handler: (params, { runtime, signal }) => runtime.installSharedSkillBundleRequest(params, signal) }), defineMethod({ name: 'skills.cancelInstall', + permission: 'skills-admin', params: SkillsCancelInstallParams, handler: (params, { runtime }) => ({ cancelled: runtime.cancelSharedSkillInstall(params.operationId) @@ -158,6 +165,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.getInstallProgress', + permission: 'workspace', params: SkillsGetInstallProgressParams, handler: (params, { runtime }) => { const progress = runtime.getSharedSkillInstallProgress(params.operationId) @@ -166,36 +174,43 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.previewInstall', + permission: 'workspace', params: SkillInstallPreviewRequestSchema, handler: (params, { runtime }) => runtime.previewSharedSkillInstallRequest(params) }), defineMethod({ name: 'skills.removeInstall', + permission: 'skills-admin', params: SkillRemoveRequestSchema, handler: (params, { runtime }) => runtime.removeSharedSkillInstallRequest(params) }), defineMethod({ name: 'skills.listManagedInstalls', + permission: 'workspace', params: null, handler: (_params, { runtime }) => runtime.listManagedSkillInstalls() }), defineMethod({ name: 'skills.beginUpload', + permission: 'skills-admin', params: SkillUploadBeginRequestSchema, handler: (params, { runtime }) => runtime.beginSkillUpload(params) }), defineMethod({ name: 'skills.uploadChunk', + permission: 'skills-admin', params: SkillUploadChunkRequestSchema, handler: (params, { runtime }) => runtime.appendSkillUploadChunk(params) }), defineMethod({ name: 'skills.commitUpload', + permission: 'skills-admin', params: SkillUploadCommitRequestSchema, handler: (params, { runtime }) => runtime.commitSkillUpload(params.uploadId) }), defineMethod({ name: 'skills.cancelUpload', + permission: 'skills-admin', params: SkillUploadCommitRequestSchema, handler: (params, { runtime }) => runtime.cancelSkillUpload(params.uploadId) }) diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index 086a528ab0e..ff1b6914c40 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -10,21 +10,25 @@ import { export const SPEECH_METHODS = [ defineMethod({ name: 'speech.models.list', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.listMobileSpeechModels() }), defineMethod({ name: 'speech.models.download', + permission: 'workspace', params: SpeechModelAction, handler: async (params, { runtime }) => runtime.downloadMobileSpeechModel(params.modelId) }), defineMethod({ name: 'speech.models.delete', + permission: 'workspace', params: SpeechModelAction, handler: async (params, { runtime }) => runtime.deleteMobileSpeechModel(params.modelId) }), defineMethod({ name: 'speech.dictation.setup', + permission: 'workspace', params: DictationSetup, handler: async (params, { runtime }) => runtime.configureMobileDictation({ @@ -35,24 +39,28 @@ export const SPEECH_METHODS = [ }), defineMethod({ name: 'speech.dictation.start', + permission: 'workspace', params: DictationStart, handler: async (params, { runtime, clientId, connectionId }) => runtime.startMobileDictation({ ...params, clientId, connectionId }) }), defineMethod({ name: 'speech.dictation.chunk', + permission: 'workspace', params: DictationChunk, handler: (params, { runtime, clientId, connectionId }) => runtime.feedMobileDictation({ ...params, clientId, connectionId }) }), defineMethod({ name: 'speech.dictation.finish', + permission: 'workspace', params: DictationHandle, handler: async (params, { runtime, clientId, connectionId }) => runtime.finishMobileDictation({ ...params, clientId, connectionId }) }), defineMethod({ name: 'speech.dictation.cancel', + permission: 'workspace', params: DictationHandle, handler: async (params, { runtime, clientId, connectionId }) => runtime.cancelMobileDictation({ ...params, clientId, connectionId }) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 2c8e2520f9b..0f2e0b606c9 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -28,6 +28,7 @@ function listRegisteredSshTargetSummaries(): SshTargetSummary[] { export const SSH_METHODS = [ defineMethod({ name: 'ssh.getState', + permission: 'workspace', params: SshTarget, handler: (params) => ({ state: getPublicSshState(getRegisteredSshState(params.targetId) ?? null) @@ -35,6 +36,7 @@ export const SSH_METHODS = [ }), defineMethod({ name: 'ssh.connect', + permission: 'host-admin', params: SshTarget, handler: async (params) => { try { @@ -47,18 +49,21 @@ export const SSH_METHODS = [ }), defineMethod({ name: 'ssh.listTargets', + permission: 'workspace', params: null, // Why: legacy clients can call this method directly, so it must preserve the same HUB-private secret boundary. handler: () => ({ targets: listRegisteredSshTargetSummaries() }) }), defineMethod({ name: 'ssh.listTargetSummaries', + permission: 'workspace', params: null, // Why: paired clients need display identity only; SSH addresses, jump chains, and credentials remain HUB-private. handler: () => ({ targets: listRegisteredSshTargetSummaries() }) }), defineMethod({ name: 'ssh.listRemovedTargetLabels', + permission: 'workspace', params: null, handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() }) }) diff --git a/src/main/runtime/rpc/methods/stats.ts b/src/main/runtime/rpc/methods/stats.ts index 9cdfe5269d3..05558a933c5 100644 --- a/src/main/runtime/rpc/methods/stats.ts +++ b/src/main/runtime/rpc/methods/stats.ts @@ -3,6 +3,7 @@ import { defineMethod } from '../core' export const STATS_METHODS = [ defineMethod({ name: 'stats.summary', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => { return runtime.getStatsSummary() ?? {} diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index c8041bbabb4..cad7cded626 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -4,6 +4,7 @@ import { getRemoteServerUpdaterSnapshot } from '../../remote-server-updater' export const STATUS_METHODS = [ defineMethod({ name: 'status.get', + permission: 'workspace', params: null, handler: async (_params, { runtime, pairedDeviceId }) => { // Why: a status answered while the friendly-name lookup is still in flight publishes the bare diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts index 8faf017f493..4f8a071446b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -15,6 +15,7 @@ import { clientReadsStructuredSessionAgent } from './structured-agent-session-po export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ defineMethod({ name: AGENT_SESSION_AGENTS_METHOD, + permission: 'workspace', params: AgentsParams, // Read from the registrations the host is built from, as createSupport is: the answer is // fixed for this build, so it never waits on, or fails with, installing the host. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts index 2c45d75c662..eb25df049c4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -34,6 +34,7 @@ async function requireStore( export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ defineMethod({ name: 'agentSessionAttachment.uploadStart', + permission: 'workspace', params: AttachmentUploadStartParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) @@ -42,6 +43,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ }), defineMethod({ name: 'agentSessionAttachment.uploadAppend', + permission: 'workspace', params: AttachmentUploadAppendParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) @@ -50,6 +52,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ }), defineMethod({ name: 'agentSessionAttachment.uploadCommit', + permission: 'workspace', params: AttachmentUploadIdParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) @@ -58,6 +61,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ }), defineMethod({ name: 'agentSessionAttachment.uploadAbort', + permission: 'workspace', params: AttachmentUploadIdParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) @@ -66,6 +70,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ }), defineMethod({ name: 'agentSessionAttachment.read', + permission: 'workspace', params: AttachmentReadParams, handler: async (params, ctx) => { const { store } = await requireStore(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts index a68a41ec1db..11f63c2c95b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts @@ -13,6 +13,7 @@ import { OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ defineMethod({ name: 'agentSession.conversationOutline', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => readAgentSessionConversationOutline( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index ef6e89f8ee8..3398445c4b5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -15,6 +15,7 @@ import { HoldParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', + permission: 'workspace', params: HoldParams, handler: async (params, ctx) => { await requireInstalledStructuredHost(ctx, params.sessionId) @@ -23,6 +24,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ }), defineMethod({ name: 'agentSession.release', + permission: 'workspace', params: HoldParams, handler: async (_params, ctx) => { requireStructuredCleanupHost(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 97833114c2e..6259125a151 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -24,24 +24,28 @@ import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-la export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.handoffStatus', + permission: 'workspace', params: HandoffStatusParams, handler: async (params, ctx) => (await requireInstalledStructuredHost(ctx, params.sessionId)).handoffStatus(params.sessionId) }), defineMethod({ name: 'agentSession.commands', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => (await requireInstalledStructuredHost(ctx, params.sessionId)).readCommands(params.sessionId) }), defineMethod({ name: 'agentSession.options', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => (await requireInstalledStructuredHost(ctx, params.sessionId)).readOptions(params.sessionId) }), defineMethod({ name: 'agentSession.modelCatalog', + permission: 'workspace', params: ModelCatalogParams, // A structured chat's read names its session and builds the host, since it may come first; // terminal-backed chat's session-less read must not open the journal where none runs. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-prompt-response.ts b/src/main/runtime/rpc/methods/structured-agent-session-prompt-response.ts new file mode 100644 index 00000000000..557642c8232 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-prompt-response.ts @@ -0,0 +1,41 @@ +// Answers to an agent's approvals and questions, and its options. +import { defineMethod } from '../core' +import { + requireStructuredSessionHost as requireSessionHost, + structuredCallerFor as callerFor +} from './structured-agent-session-gate' +import { + RespondParams, + RespondToQuestionParams, + SetOptionParams +} from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS = [ + defineMethod({ + name: 'agentSession.respondToApproval', + permission: 'workspace', + params: RespondParams, + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { + ...params, + kind: 'approval' + }) + }), + defineMethod({ + name: 'agentSession.respondToQuestion', + permission: 'workspace', + params: RespondToQuestionParams, + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { + ...params, + kind: 'question' + }) + }), + defineMethod({ + name: 'agentSession.setOption', + permission: 'workspace', + params: SetOptionParams, + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).setOption(callerFor(ctx), params) + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts index c98dfa4c787..5689330e3c3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -15,18 +15,21 @@ import { export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [ defineMethod({ name: 'agentSession.queuedMessageSend', + permission: 'workspace', params: QueuedMessageActionParams, handler: async (params, ctx) => requireSessionHost(ctx, params.envelope.sessionId).queuedMessageSend(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.queuedMessageDelete', + permission: 'workspace', params: QueuedMessageActionParams, handler: async (params, ctx) => requireSessionHost(ctx, params.envelope.sessionId).queuedMessageDelete(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.queuedMessagesResume', + permission: 'workspace', params: QueuedMessagesResumeParams, handler: async (params, ctx) => requireSessionHost(ctx, params.envelope.sessionId).queuedMessagesResume( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index c28fe9856f6..235a2784b88 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -21,6 +21,7 @@ import { structuredAgentsReadBy } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ defineMethod({ name: 'agentSession.restartResumable', + permission: 'workspace', params: RestartResumableParams, handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) @@ -37,6 +38,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // Explicitly abandons the markers without resuming. Closing the dialog is a snooze and does // not call this method, so the status-bar entry can reopen the offer later. name: 'agentSession.restartResumableDismiss', + permission: 'workspace', params: RestartResumableParams, handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) @@ -61,6 +63,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // and what an opted-in launch runs without asking. Separate from `restartResume`, which sends // nothing, but reachable from a setting rather than only from a button. name: 'agentSession.restartContinue', + permission: 'workspace', params: RestartResumeParams, handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) @@ -77,6 +80,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // instead of an offer. Clients gate it on AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY. // It names one chat the client shows, as a send does, so no agent audience applies. name: 'agentSession.continueInterrupted', + permission: 'workspace', params: ContinueInterruptedParams, handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) @@ -89,6 +93,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // answers that nothing was resumed. No Orca surface calls it, but it is a PUBLISHED wire // method, so dropping it is a wire removal an older client would meet as an unknown method. name: 'agentSession.restartResume', + permission: 'workspace', params: RestartResumeParams, handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 118cfb418f5..b82f0e3c3a0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -28,6 +28,7 @@ import { OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ defineMethod({ name: 'agentSession.reveal', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index a4a2de57959..134c0bcf9c4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -45,6 +45,7 @@ export function bindStructuredAgentSessionStream( export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ defineStreamingMethod({ name: 'agentSession.subscribeStatus', + permission: 'workspace', params: null, handler: async (_params, ctx, emit) => { const host = requireHost(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts index 87ba26fe37b..872b791768d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts @@ -13,6 +13,7 @@ import { ThreadGoalParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS = [ defineMethod({ name: 'agentSession.threadGoal', + permission: 'workspace', params: ThreadGoalParams, handler: async (params, ctx) => requireSessionHost(ctx, params.envelope.sessionId).changeThreadGoal(callerFor(ctx), params) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts index a5442097f46..e8b2ee90d6f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts @@ -24,6 +24,7 @@ export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [ // its journal epoch already name them, so no host is installed to answer. True: the read applied. defineMethod({ name: 'agentSession.acknowledgeAttention', + permission: 'workspace', params: AcknowledgeAttentionParams, handler: (params, ctx) => { requireStructuredCapability(ctx) @@ -36,6 +37,7 @@ export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [ }), defineStreamingMethod({ name: 'agentSession.subscribeTurnCompletions', + permission: 'workspace', params: SubscribeTurnCompletionsParams, handler: async (params, ctx, emit) => { const host = requireHost(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts index ed486013a65..58f42d8e961 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts @@ -19,6 +19,7 @@ import { requireInstalledStructuredHost } from './structured-agent-session-gate' export const STRUCTURED_AGENT_SESSION_VISUAL_METHODS = [ defineMethod({ name: 'agentSession.readVisual', + permission: 'workspace', params: ReadVisualParams, handler: async (params, ctx): Promise => { const host = await requireInstalledStructuredHost(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 8536cf27ec0..7324ae7ffd2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -35,6 +35,7 @@ import { structuredAgentSessionCreateIntentFingerprint } from './structured-agent-session-create' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' +import { STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS } from './structured-agent-session-prompt-response' import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods' import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume' @@ -59,11 +60,8 @@ import { CreateSupportParams, HistoryParams, OptionsParams, - RespondParams, - RespondToQuestionParams, RewindParams, SendParams, - SetOptionParams, SubscribeParams, UnsubscribeParams } from './structured-agent-session-schemas' @@ -103,6 +101,7 @@ async function attachClientSuppliedLocation( export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.rewind', + permission: 'workspace', params: RewindParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) @@ -112,6 +111,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.conversationCommand', + permission: 'workspace', params: ConversationCommandParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) @@ -133,6 +133,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.createSupport', + permission: 'workspace', params: CreateSupportParams, handler: async (params, ctx) => { requireStructuredCreateSupportAdmission(ctx, params.agent) @@ -149,6 +150,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.create', + permission: 'workspace', params: CreateParams, handler: async (params, ctx) => { requireStructuredAgentAudience(ctx, params.agent) @@ -198,16 +200,19 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.ensure', + permission: 'workspace', params: AttachParams, handler: async (params, ctx) => attachClientSuppliedLocation(params, ctx) }), defineMethod({ name: 'agentSession.send', + permission: 'workspace', params: SendParams, handler: sendStructuredAgentSessionForClient }), defineMethod({ name: 'agentSession.cancel', + permission: 'workspace', params: CancelParams, handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params) }), @@ -216,6 +221,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ // Releasing a chat view, not ending a conversation: the record and journal stay on disk so the // same session can be attached again. Only the provider child and the in-memory entry go. name: 'agentSession.close', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => { const host = requireStructuredCleanupHost(ctx) @@ -228,32 +234,10 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ return { ok: true as const } } }), - defineMethod({ - name: 'agentSession.respondToApproval', - params: RespondParams, - handler: async (params, ctx) => - requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { - ...params, - kind: 'approval' - }) - }), - defineMethod({ - name: 'agentSession.respondToQuestion', - params: RespondToQuestionParams, - handler: async (params, ctx) => - requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { - ...params, - kind: 'question' - }) - }), - defineMethod({ - name: 'agentSession.setOption', - params: SetOptionParams, - handler: async (params, ctx) => - requireSessionHost(ctx, params.envelope.sessionId).setOption(callerFor(ctx), params) - }), + ...STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS, defineMethod({ name: 'agentSession.history', + permission: 'workspace', params: HistoryParams, handler: async (params, ctx) => { const host = await requireInstalledHost(ctx, params.sessionId) @@ -266,6 +250,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineStreamingMethod({ name: 'agentSession.subscribe', + permission: 'workspace', params: SubscribeParams, handler: async (params, ctx, emit) => { const host = await requireInstalledHost(ctx, params.sessionId) @@ -298,6 +283,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.unsubscribe', + permission: 'workspace', params: UnsubscribeParams, handler: async (params, ctx) => { requireStructuredCleanupHost(ctx) diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts index 0b728629dcb..74700378d45 100644 --- a/src/main/runtime/rpc/methods/terminal-orphan.ts +++ b/src/main/runtime/rpc/methods/terminal-orphan.ts @@ -4,6 +4,7 @@ import { TerminalAdoptOrphans } from '../../../../shared/rpc-contract/terminal-o export const TERMINAL_ORPHAN_METHODS = [ defineMethod({ name: 'terminal.adoptOrphans', + permission: 'workspace', params: TerminalAdoptOrphans, handler: async (params, { runtime }) => runtime.adoptTerminalOrphans(params) }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts index a9bcf1af695..9a52d8f585c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts @@ -66,7 +66,7 @@ export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs return } const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId) if (registration.released) { return } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index 818fdff043e..f192b40b0d8 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -62,7 +62,6 @@ export async function publishLegacyBinaryInitialSnapshot( let serialized = await serializeBudgetedMobileSnapshot( runtime, ptyId, - isMobile, mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) ) if (state.closed) { @@ -167,7 +166,6 @@ export async function publishLegacyBinaryInitialSnapshot( serialized = await serializeBudgetedMobileSnapshot( runtime, ptyId, - isMobile, mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, { kind: 'scrollback', displayMode: state.displayMode @@ -237,7 +235,6 @@ export async function publishLegacyBinaryInitialSnapshot( const recovery = await serializeBudgetedMobileSnapshot( runtime, ptyId, - isMobile, mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, recoveryFrame) ) if (state.closed) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 0fc55560ca5..e0775c87f7c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -22,6 +22,7 @@ import { TerminalResizeForClient } from './stream-schemas' export const TERMINAL_LIFECYCLE_METHODS = [ defineMethod({ name: 'terminal.wait', + permission: 'workspace', params: TerminalWait, handler: async (params, { runtime, signal }) => ({ wait: await runtime.waitForTerminal(params.terminal, { @@ -33,6 +34,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.create', + permission: 'workspace', params: TerminalCreateParams, handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => { // A focused terminal create predates paired-client navigation. Keep the @@ -84,6 +86,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.split', + permission: 'workspace', params: TerminalSplit, handler: async (params, { runtime }) => ({ split: await runtime.splitTerminal(params.terminal, { @@ -96,21 +99,25 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.stop', + permission: 'workspace', params: TerminalStop, handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) }), defineMethod({ name: 'terminal.closeAll', + permission: 'workspace', params: TerminalCloseAll, handler: async (params, { runtime }) => runtime.closeTerminalsForWorktree(params.worktree) }), defineMethod({ name: 'terminal.sleep', + permission: 'workspace', params: TerminalSleep, handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) }), defineMethod({ name: 'terminal.stopExact', + permission: 'workspace', params: TerminalStopExact, handler: async (params, { runtime }) => runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { @@ -120,6 +127,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.resizeForClient', + permission: 'workspace', params: TerminalResizeForClient, handler: async (params, { runtime }) => { // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). @@ -144,6 +152,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.focus', + permission: 'workspace', params: TerminalFocus, handler: async (params, { runtime, clientKind }) => ({ focus: await runtime.focusTerminal(params.terminal, { @@ -155,6 +164,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.close', + permission: 'workspace', params: TerminalHandle, handler: async (params, context) => ({ close: await withTerminalCloseAttribution( @@ -168,6 +178,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.closeTab', + permission: 'workspace', params: TerminalHandle, handler: async (params, context) => ({ close: await withTerminalCloseAttribution( @@ -181,6 +192,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'agentTeams.tmuxCompat', + permission: 'workspace', params: AgentTeamsTmuxCompat, handler: async (params, { runtime }) => ({ tmux: await runtime.handleAgentTeamsTmuxCompat(params) @@ -188,6 +200,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'agentTeams.prepareLaunch', + permission: 'workspace', params: AgentTeamsPrepareLaunch, handler: async (params, { runtime }) => ({ launch: await runtime.prepareClaudeAgentTeamsLeader({ diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts index 735c9d21cfb..21b4f3f44b5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -32,6 +32,7 @@ export function installMultiplexFlowControl( stream.ackRecoverySnapshotInFlight = true let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null try { + // Why screen-only: this client is already behind, and a 0-row image tells it to keep its own history. const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { return @@ -79,6 +80,7 @@ export function installMultiplexFlowControl( alternateScreen: serialized.alternateScreen, terminalOwner: serialized.terminalOwner, truncatedByByteBudget: serialized.truncatedByByteBudget, + scrollbackRows: serialized.scrollbackRows, data: serialized.data } ) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index a2c9fadfb8d..e0644abd1a5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -26,7 +26,7 @@ export async function publishMultiplexInitialSnapshot( const forcedInitialSnapshotTruncated = process.env.ORCA_E2E_FORCE_REMOTE_TERMINAL_INITIAL_SNAPSHOT_TRUNCATED === '1' let read = await runtime.readTerminal(request.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId) if (state.closed || streams.get(request.streamId) !== stream) { return null } @@ -36,7 +36,7 @@ export async function publishMultiplexInitialSnapshot( stream.pendingOutputBytes = 0 stream.pendingOutputOverflowed = false read = await runtime.readTerminal(request.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId) if (state.closed || streams.get(request.streamId) !== stream) { return null } @@ -95,6 +95,7 @@ export async function publishMultiplexInitialSnapshot( cwd: serialized?.cwd, truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, + scrollbackRows: serialized?.scrollbackRows, source: serialized?.source, kittyKeyboardFlags: serialized?.kittyKeyboardFlags, alternateScreen: serialized?.alternateScreen, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts index e811614e400..bfd14a48c09 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -14,6 +14,7 @@ import { installMultiplexSubscribeFrame } from './terminal-multiplex-subscribe-f export const TERMINAL_MULTIPLEX_METHODS = [ defineStreamingMethod({ name: 'terminal.multiplex', + permission: 'workspace', params: TerminalMultiplex, handler: async ( _params, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts index f75afb29fa5..bad459b44cd 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -241,6 +241,7 @@ export function installMultiplexSlotFrames( pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, truncatedByByteBudget: serialized?.truncatedByByteBudget, + scrollbackRows: serialized?.scrollbackRows, // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. unavailable: serialized ? undefined : 'no-serializable-buffer', data: serialized?.data ?? '' diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index 68afc1e8d19..20fe9633b1b 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -13,6 +13,7 @@ import { export const TERMINAL_QUERY_METHODS = [ defineMethod({ name: 'terminal.list', + permission: 'workspace', params: TerminalListParams, handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit, { @@ -23,6 +24,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.resolveActive', + permission: 'workspace', params: TerminalResolveActive, handler: async (params, { runtime }) => ({ handle: await runtime.resolveActiveTerminal( @@ -33,6 +35,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.resolvePane', + permission: 'workspace', params: TerminalResolvePane, handler: async (params, { runtime }) => ({ terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) @@ -40,6 +43,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.recoverPane', + permission: 'workspace', params: TerminalRecoverPane, handler: async (params, { runtime }) => ({ terminal: await runtime.recoverTerminalPane( @@ -51,6 +55,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.show', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ terminal: await runtime.showTerminal(params.terminal) @@ -60,6 +65,7 @@ export const TERMINAL_QUERY_METHODS = [ // Read-only identity probe. Deliberately NOT `terminal.show`: this one resolves a structured // worker too, and must therefore never hand back anything that looks writable. name: 'terminal.resolveIdentity', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ identity: runtime.resolveTerminalIdentity(params.terminal) @@ -67,6 +73,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.read', + permission: 'workspace', params: TerminalRead, handler: async (params, { runtime }) => ({ terminal: await runtime.readTerminal(params.terminal, { @@ -78,6 +85,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.inspectProcess', + permission: 'workspace', params: TerminalInspectProcess, handler: async (params, { runtime }) => { const options = { @@ -96,6 +104,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.isRunningAgent', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) @@ -103,6 +112,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.agentStatus', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ agentStatus: await runtime.getTerminalAgentStatus(params.terminal) @@ -110,6 +120,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.rename', + permission: 'workspace', params: TerminalRename, handler: async (params, { runtime }) => ({ rename: await runtime.renameTerminal(params.terminal, params.title || null) @@ -117,6 +128,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.clearBuffer', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ clear: await runtime.clearTerminalBuffer(params.terminal) @@ -124,6 +136,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.resetInputModes', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ reset: await runtime.resetTerminalInputModes(params.terminal) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 8309d8c15c1..7880713b131 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -22,6 +22,7 @@ import { export const TERMINAL_SEND_METHODS = [ defineMethod({ name: 'terminal.send', + permission: 'workspace', params: TerminalSend, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts index f54498777d8..dab3d898297 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts @@ -171,7 +171,7 @@ function alwaysOversizeRuntime(): Pick { it('reproduces the defect: the raw budget lets a screen past the frame cap', async () => { - const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1', true) + const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1') expect(serialized).not.toBeNull() const data = serialized?.data ?? '' // Under the budget the desktop applies, which is measured on the text. @@ -192,7 +192,6 @@ describe('the mobile snapshot the page receives', () => { const serialized = await serializeBudgetedMobileSnapshot( denseRuntime(), 'pty-1', - true, budget(PAGE_BUDGET) ) expect(serialized).not.toBeNull() @@ -209,8 +208,8 @@ describe('the mobile snapshot the page receives', () => { it('trims further when the publication carries more metadata', async () => { const runtime = denseRuntime() const [plain, withRequestId] = await Promise.all([ - serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, budget(PAGE_BUDGET)), - serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, { + serializeBudgetedMobileSnapshot(runtime, 'pty-1', budget(PAGE_BUDGET)), + serializeBudgetedMobileSnapshot(runtime, 'pty-1', { bytes: PAGE_BUDGET, streamId: STREAM_ID, frame: { ...PUBLICATION, reason: 'a-reason-of-some-length', requestId: 999_999_999 } @@ -224,7 +223,6 @@ describe('the mobile snapshot the page receives', () => { const serialized = await serializeBudgetedMobileSnapshot( denseRuntime(), 'pty-1', - true, budget(PAGE_BUDGET) ) expect(serialized?.truncatedByByteBudget).toBe(true) @@ -235,8 +233,8 @@ describe('the mobile snapshot the page receives', () => { // exactly what it was served before: the payload size is not its transport's problem. const runtime = denseRuntime() const [withoutBudget, withBudget] = await Promise.all([ - serializeBudgetedMobileSnapshot(runtime, 'pty-1', true), - serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, budget(PAGE_BUDGET)) + serializeBudgetedMobileSnapshot(runtime, 'pty-1'), + serializeBudgetedMobileSnapshot(runtime, 'pty-1', budget(PAGE_BUDGET)) ]) expect(withoutBudget?.scrollbackRows).toBeGreaterThan(withBudget?.scrollbackRows ?? 0) }) @@ -254,12 +252,7 @@ describe('the mobile snapshot the page receives', () => { * knows the screen it holds is not the screen the host had, and the next byte of output fixes it. */ it('empties the text of a zero-row candidate it cannot fit, rather than posting it over', async () => { - const serialized = await serializeBudgetedMobileSnapshot( - denseRuntime(), - 'pty-1', - true, - budget(4096) - ) + const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1', budget(4096)) expect(serialized?.scrollbackRows).toBe(0) expect(serialized?.data).toBe('') expect(serialized?.truncatedByByteBudget).toBe(true) @@ -275,12 +268,7 @@ describe('the mobile snapshot the page receives', () => { * has rather than as a promise the host cannot keep. */ it('cannot go below the metadata the frame carries, and keeps the text empty there', async () => { - const serialized = await serializeBudgetedMobileSnapshot( - denseRuntime(), - 'pty-1', - true, - budget(16) - ) + const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1', budget(16)) expect(serialized?.scrollbackRows).toBe(0) expect(serialized?.data).toBe('') expect(serialized?.truncatedByByteBudget).toBe(true) @@ -290,7 +278,7 @@ describe('the mobile snapshot the page receives', () => { it('still sends an unbudgeted subscriber the screen it has always been sent', async () => { // The other half of ruling 15, and the compatibility one: the raw rule keeps its fallback, so // an older page and every socket client get the oversize screen rather than an empty frame. - const serialized = await serializeBudgetedMobileSnapshot(alwaysOversizeRuntime(), 'pty-1', true) + const serialized = await serializeBudgetedMobileSnapshot(alwaysOversizeRuntime(), 'pty-1') expect(serialized?.data.length).toBeGreaterThan(MOBILE_SNAPSHOT_BYTE_BUDGET) expect(serialized?.truncatedByByteBudget).toBe(true) }) @@ -330,7 +318,6 @@ describe('a snapshot that round one accepted at exactly the budget', () => { const serialized = await serializeBudgetedMobileSnapshot( exactlyAtRoundOnesBudgetRuntime(), 'pty-1', - true, budget(PAGE_BUDGET) ) expect(serialized).not.toBeNull() @@ -435,16 +422,11 @@ describe('the publication fields the budget has to assume', () => { // longer than the number that approved it, and no trimming is left to absorb them: this is the // margin, not a fixture with room in it. const data = 'x'.repeat(4096) - const serialized = await serializeBudgetedMobileSnapshot( - fixedScreenRuntime(data), - 'pty-1', - true, - { - bytes: measured({ kind: 'scrollback', displayMode: 'auto' }, data), - streamId: STREAM_ID, - frame: { kind: 'scrollback', displayMode: 'auto' } - } - ) + const serialized = await serializeBudgetedMobileSnapshot(fixedScreenRuntime(data), 'pty-1', { + bytes: measured({ kind: 'scrollback', displayMode: 'auto' }, data), + streamId: STREAM_ID, + frame: { kind: 'scrollback', displayMode: 'auto' } + }) expect(required(serialized).data).toBe('') }) }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts index 8f361bf95f2..5d7ba74ba23 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts @@ -53,7 +53,11 @@ export function buildSnapshotFrameMeta(options: SnapshotFrameOptions): Record { if (isTerminalSnapshotForcedUnavailable()) { return null } - if (!isMobile) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized - ? { - ...serialized, - data: (serialized.scrollbackAnsi ?? '') + serialized.data, - scrollbackRows: 0, - truncatedByByteBudget: false - } - : null - } + // Why desktop shares the ladder: a screen-only image left a remounted or reconnected pane with no history (#20158). const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] for (const rows of candidates) { const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) @@ -328,7 +318,7 @@ export async function sendMobileResizeRestream( if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { return false } - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true, snapshotByteBudget) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, snapshotByteBudget) if (!serialized) { return false } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts new file mode 100644 index 00000000000..7b3e74a249f --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { MOBILE_SNAPSHOT_BYTE_BUDGET } from '../../../scrollback-limits' +import { buildSnapshotFrameMeta } from './terminal-snapshot-payload' +import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' + +function historyRuntime(bytesPerRow: number): Pick { + return { + serializeTerminalBuffer: vi.fn( + async (_ptyId: string, options?: { scrollbackRows?: number }) => ({ + scrollbackAnsi: 'h'.repeat((options?.scrollbackRows ?? 0) * bytesPerRow), + data: 'SCREEN', + cols: 80, + rows: 24 + }) + ) + } +} + +describe('subscribe snapshot scrollback (#20158)', () => { + it('serializes history for every subscriber, not a screen-only image', async () => { + const runtime = historyRuntime(10) + const serialized = await serializeBudgetedMobileSnapshot(runtime, 'pty-1') + expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(1) + expect(runtime.serializeTerminalBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 1000 }) + expect(serialized?.scrollbackRows).toBe(1000) + expect(serialized?.data).toBe(`${'h'.repeat(10_000)}SCREEN`) + }) + + it('steps down the row ladder when history is over the byte budget', async () => { + // 1000 rows is over 512 KiB, 500 rows is not. + const bytesPerRow = Math.floor(MOBILE_SNAPSHOT_BYTE_BUDGET / 600) + const serialized = await serializeBudgetedMobileSnapshot(historyRuntime(bytesPerRow), 'pty-1') + expect(serialized?.scrollbackRows).toBe(500) + expect(serialized?.data.endsWith('SCREEN')).toBe(true) + }) +}) + +describe('SnapshotStart scrollbackRows', () => { + const base = { kind: 'scrollback' as const, cols: 80, rows: 24, data: '' } + + it('publishes how many history rows the image carries, including zero', () => { + expect(buildSnapshotFrameMeta({ ...base, scrollbackRows: 1000 }).scrollbackRows).toBe(1000) + expect(buildSnapshotFrameMeta({ ...base, scrollbackRows: 0 }).scrollbackRows).toBe(0) + }) + + it('omits the field when the publisher does not know, so the client reads it as unknown', () => { + expect('scrollbackRows' in buildSnapshotFrameMeta(base)).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts index 121cf9a7adb..379a2589795 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -20,6 +20,8 @@ export type SnapshotFrameOptions = { cwd?: string | null truncated?: boolean truncatedByByteBudget?: boolean + /** History rows this image carries above its screen; 0 tells the client to keep its own. */ + scrollbackRows?: number // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. unavailable?: TerminalSnapshotUnavailableReason source?: 'headless' | 'renderer' diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index 6fa2c936c0c..1bd7cd268c7 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -13,6 +13,7 @@ export const TERMINAL_SUBSCRIBE_METHODS = [ // Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. defineStreamingMethod({ name: 'terminal.subscribe', + permission: 'workspace', params: TerminalSubscribe, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts index c2c94efee6d..095bc694bfa 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts @@ -6,6 +6,7 @@ import { TerminalSetViewerColors } from './unary-schemas' export const TERMINAL_VIEWER_COLORS_METHODS = [ defineMethod({ name: 'terminal.setViewerColors', + permission: 'workspace', params: TerminalSetViewerColors, // Why: a headless host has no theme of its own to answer OSC 10/11 with, so it answers with // the colours of the paired client that last pushed; a host with its own window keeps its own. diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts index b977c113668..f24dea4602a 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -12,6 +12,7 @@ import { TerminalGetAutoRestoreFitParams } from '../../../../../shared/rpc-contr export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ defineMethod({ name: 'terminal.setDisplayMode', + permission: 'workspace', params: TerminalSetDisplayMode, handler: async (params, { runtime }) => { // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). @@ -33,6 +34,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ }), defineMethod({ name: 'terminal.restoreFit', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => { // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). @@ -45,6 +47,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ }), defineMethod({ name: 'terminal.getDisplayMode', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => { const leaf = runtime.resolveLeafForHandle(params.terminal) @@ -55,6 +58,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ }), defineMethod({ name: 'terminal.updateViewport', + permission: 'workspace', params: TerminalUpdateViewport, handler: async (params, { runtime }) => { // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). @@ -81,6 +85,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ defineMethod({ name: 'terminal.unsubscribe', + permission: 'workspace', params: TerminalUnsubscribe, handler: async (params, { runtime, connectionId, subscriptionRegistrationVersion }) => { if (params.requestId !== undefined) { @@ -112,6 +117,7 @@ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ }), defineMethod({ name: 'terminal.getAutoRestoreFit', + permission: 'workspace', params: TerminalGetAutoRestoreFitParams, handler: async (_params, { runtime }) => ({ ms: runtime.getMobileAutoRestoreFitMs() @@ -119,6 +125,7 @@ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ }), defineMethod({ name: 'terminal.setAutoRestoreFit', + permission: 'workspace', params: TerminalSetAutoRestoreFit, handler: async (params, { runtime }) => ({ ms: runtime.setMobileAutoRestoreFitMs(params.ms) diff --git a/src/main/runtime/rpc/methods/updater.ts b/src/main/runtime/rpc/methods/updater.ts index a14fb5ab6a4..98ccee8c768 100644 --- a/src/main/runtime/rpc/methods/updater.ts +++ b/src/main/runtime/rpc/methods/updater.ts @@ -10,21 +10,25 @@ import { UpdaterCheckParams } from '../../../../shared/rpc-contract/updater-para export const UPDATER_METHODS = [ defineMethod({ name: 'updater.getStatus', + permission: 'workspace', params: null, handler: (_params, { runtime }) => getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) }), defineMethod({ name: 'updater.check', + permission: 'host-admin', params: UpdaterCheckParams, handler: (params, { runtime }) => checkRemoteServerUpdater(runtime.getRuntimeId(), params) }), defineMethod({ name: 'updater.download', + permission: 'host-admin', params: null, handler: (_params, { runtime }) => downloadRemoteServerUpdater(runtime.getRuntimeId()) }), defineMethod({ name: 'updater.install', + permission: 'host-admin', params: null, handler: (_params, { runtime }) => installRemoteServerUpdater(runtime.getRuntimeId()) }) diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts index c96c91b436d..dbc0b4ef9f1 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.ts @@ -7,11 +7,13 @@ import { export const WORKSPACE_PORT_METHODS = [ defineMethod({ name: 'workspacePorts.scan', + permission: 'workspace', params: WorkspacePortScanParams, handler: async (params, { runtime }) => runtime.scanWorkspacePorts(params.repoId) }), defineMethod({ name: 'workspacePorts.kill', + permission: 'workspace', params: WorkspacePortKillParams, handler: async (params, { runtime }) => runtime.killWorkspacePort({ diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index f4f3ba99cae..dc0a01cfe60 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -15,6 +15,7 @@ import { export const WORKTREE_CATALOG_METHODS = [ defineMethod({ name: 'worktree.ps', + permission: 'workspace', params: WorktreePsParams, handler: async (params, context) => { const pendingAtScan = snapshotPendingWorktreeRemovals() @@ -37,6 +38,7 @@ export const WORKTREE_CATALOG_METHODS = [ }), defineMethod({ name: 'worktree.list', + permission: 'workspace', params: WorktreeListParams, handler: async (params, context) => { const pendingAtScan = snapshotPendingWorktreeRemovals() @@ -53,11 +55,13 @@ export const WORKTREE_CATALOG_METHODS = [ }), defineMethod({ name: 'worktree.listRetiredNames', + permission: 'workspace', params: WorktreeDetectedListParams, handler: async (params, { runtime }) => runtime.listRetiredWorktreeNames(params.repo) }), defineMethod({ name: 'worktree.detectedList', + permission: 'workspace', params: WorktreeDetectedListParams, handler: async (params, context) => { const pendingAtScan = snapshotPendingWorktreeRemovals() diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 39bc3d0618d..9f59a872e25 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -32,6 +32,7 @@ export const WORKTREE_METHODS = [ ...WORKTREE_CATALOG_METHODS, defineMethod({ name: 'worktree.teardownMissingTerminals', + permission: 'workspace', params: WorktreeTeardownMissingTerminalsParams, handler: async (params, { runtime }) => runtime.teardownMissingManagedWorktreeTerminals( @@ -42,6 +43,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.lineageList', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => ({ lineage: await runtime.listWorktreeLineage(), @@ -50,6 +52,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.show', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => ({ worktree: await runtime.showManagedWorktree(params.worktree) @@ -57,11 +60,13 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.sleep', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree) }), defineMethod({ name: 'worktree.activate', + permission: 'workspace', params: WorktreeActivate, handler: async (params, { runtime, clientKind }) => // Why: clientKind ('mobile'|'runtime') scopes the host-renderer slept-agent @@ -79,6 +84,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.create', + permission: 'workspace', params: WorktreeCreate, handler: async (params, context) => // Why: a mobile create interrupted by a connection migration is retried with @@ -127,6 +133,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.prefetchCreateBase', + permission: 'workspace', params: WorktreePrefetchCreateBase, handler: async (params, { runtime }) => { await runtime.prefetchManagedWorktreeCreateBase({ @@ -138,6 +145,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.set', + permission: 'workspace', params: WorktreeSet, handler: async (params, { runtime }) => ({ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { @@ -193,12 +201,14 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.persistSortOrder', + permission: 'workspace', params: WorktreeSortOrder, handler: async (params, { runtime }) => runtime.persistManagedWorktreeSortOrder(params.orderedIds) }), defineMethod({ name: 'worktree.resolvePrBase', + permission: 'workspace', params: WorktreeResolvePrBase, handler: async (params, { runtime }) => runtime.resolveManagedPrBase({ @@ -211,6 +221,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.resolveMrBase', + permission: 'workspace', params: WorktreeResolveMrBase, handler: async (params, { runtime }) => runtime.resolveManagedMrBase({ @@ -223,6 +234,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.rm', + permission: 'workspace', params: WorktreeRemove, handler: async (params, context) => { const { runtime } = context @@ -274,6 +286,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.forceDeleteBranch', + permission: 'workspace', params: WorktreeForceDeleteBranch, handler: async (params, { runtime }) => { const hostId = resolvePairedCallerHostId( diff --git a/src/main/runtime/rpc/orchestration-contract-fence.test.ts b/src/main/runtime/rpc/orchestration-contract-fence.test.ts index 10d4d13b1fe..d3a4a50d0f4 100644 --- a/src/main/runtime/rpc/orchestration-contract-fence.test.ts +++ b/src/main/runtime/rpc/orchestration-contract-fence.test.ts @@ -27,6 +27,7 @@ describe('orchestration contract fence', () => { methods: [ defineMethod({ name: method, + permission: 'workspace', params: z.object({ subject: z.string() }), handler: effect }) diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index 3a6b734c259..c06e03bb41c 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -52,6 +52,7 @@ describe('durable orchestration mutation ledger', () => { methods: [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: Params, handler: ({ subject }) => ({ message: effect(subject) }) }) @@ -156,6 +157,7 @@ describe('durable orchestration mutation ledger', () => { methods: [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: Params, handler: effect }) @@ -269,6 +271,7 @@ describe('durable orchestration mutation ledger', () => { methods: [ defineMethod({ name: 'orchestration.workerRelease', + permission: 'workspace', params: z.object({ dispatch: z.string() }), handler: effect }) @@ -326,6 +329,7 @@ describe('durable orchestration mutation ledger', () => { methods: [ defineMethod({ name: 'orchestration.workerStart', + permission: 'workspace', params: z.object({ from: z.string(), task: z.string() }), handler: effect }) diff --git a/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts b/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts index 68310db7224..6db80a563ef 100644 --- a/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts @@ -30,6 +30,7 @@ function createHarness() { methods: [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: Params, handler: ({ subject }) => ({ message: effect(subject) }) }), @@ -118,6 +119,7 @@ describe('orchestration.requestShow', () => { methods: [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: Params, handler: async () => { reportStarted?.() diff --git a/src/main/runtime/rpc/rpc-caller-scope.test.ts b/src/main/runtime/rpc/rpc-caller-scope.test.ts new file mode 100644 index 00000000000..e570a5d2bb0 --- /dev/null +++ b/src/main/runtime/rpc/rpc-caller-scope.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeTerminalListResult } from '../../../shared/runtime-terminal-contracts' +import type { RpcRequest, RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { ALL_RPC_METHODS } from './methods' +import type { RpcCallerScope } from './rpc-caller-scope' +import { RPC_METHOD_PERMISSIONS } from './rpc-method-permission' +import { SSH_BRIDGE_HOST_BINDERS } from './ssh-bridge-host-binding' +import { + CONTROL_GRANTED_SSH_BRIDGE_SCOPE as CONTROL_GRANTED, + HOST_BOUND_SSH_BRIDGE_SCOPE as HOST_BOUND +} from '../../ssh/ssh-bridge-caller-scope.test-fixture' + +const PAIRED: RpcCallerScope = { kind: 'runtime-paired', grants: [] } + +function request(method: string, params?: unknown): RpcRequest { + return { id: `req-${method}`, authToken: 'unused', method, params } +} + +async function dispatchAs( + scope: RpcCallerScope | undefined, + method: string, + params?: unknown, + runtime = new OrcaRuntimeService() +): Promise { + return new RpcDispatcher({ runtime }).dispatch( + request(method, params), + scope ? { callerScope: scope } : undefined + ) +} + +async function dispatchStreamingAs( + scope: RpcCallerScope, + method: string, + params?: unknown +): Promise { + const replies: RpcResponse[] = [] + await new RpcDispatcher({ runtime: new OrcaRuntimeService() }).dispatchStreaming( + request(method, params), + (raw) => replies.push(JSON.parse(raw)), + { callerScope: scope } + ) + const [reply] = replies + if (!reply) { + throw new Error('no reply') + } + return reply +} + +function errorCode(response: RpcResponse): string | null { + return response.ok ? null : response.error.code +} + +function terminalSummary(handle: string, executionHostId: 'ssh:box-1' | 'ssh:box-2' | 'local') { + return { + handle, + ptyId: null, + worktreeId: `wt-${handle}`, + worktreePath: `/wt/${handle}`, + branch: 'main', + tabId: 'tab', + leafId: 'leaf', + title: null, + connected: true, + writable: true, + lastOutputAt: null, + preview: `secret output of ${handle}`, + executionHostId + } as const +} + +describe('every RPC method declares a known permission', () => { + it('rejects nothing outside the permission vocabulary', () => { + for (const method of ALL_RPC_METHODS) { + expect(RPC_METHOD_PERMISSIONS, method.name).toContain(method.permission) + } + }) + + it('classifies desktop control and owner administration as non-workspace', () => { + const permissionOf = (name: string) => ALL_RPC_METHODS.find((m) => m.name === name)?.permission + expect(permissionOf('computer.click')).toBe('desktop-control') + expect(permissionOf('computer.typeText')).toBe('desktop-control') + expect(permissionOf('computer.permissionsStatus')).toBe('workspace') + expect(permissionOf('accounts.selectClaude')).toBe('accounts-admin') + expect(permissionOf('settings.update')).toBe('settings-write') + expect(permissionOf('skills.install')).toBe('skills-admin') + expect(permissionOf('skills.share')).toBe('skills-admin') + expect(permissionOf('pairing.provisionRelay')).toBe('pairing-admin') + expect(permissionOf('ssh.connect')).toBe('host-admin') + expect(permissionOf('network.browserTunnel')).toBe('host-admin') + }) +}) + +describe('SSH bridge without the per-host opt-in', () => { + it('binds only registered workspace methods', () => { + const permissionOf = (name: string) => ALL_RPC_METHODS.find((m) => m.name === name)?.permission + for (const method of SSH_BRIDGE_HOST_BINDERS.keys()) { + expect(permissionOf(method), method).toBe('workspace') + } + }) + + it.each([ + ['computer.click', { app: 'Finder' }], + ['accounts.selectClaude', {}], + ['settings.update', {}], + ['skills.install', {}], + ['terminal.create', {}], + ['orchestration.dispatch', { task: 'task_1' }], + ['files.read', {}] + ])('refuses %s before any handler runs', async (method, params) => { + const response = await dispatchAs(HOST_BOUND, method, params) + expect(errorCode(response)).toBe('forbidden') + }) + + it('enforces the same refusal on the streaming transport', async () => { + const response = await dispatchStreamingAs(HOST_BOUND, 'computer.click', { app: 'Finder' }) + expect(errorCode(response)).toBe('forbidden') + }) + + it("refuses a terminal handle that resolves to another host's terminal", async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + ...terminalSummary('term_other', 'ssh:box-2'), + paneRuntimeId: 1, + rendererGraphEpoch: 0 + }) + const read = vi.spyOn(runtime, 'readTerminal') + const response = await dispatchAs( + HOST_BOUND, + 'terminal.read', + { terminal: 'term_other' }, + runtime + ) + expect(errorCode(response)).toBe('forbidden') + expect(read).not.toHaveBeenCalled() + }) + + it('refuses a terminal whose host cannot be named', async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'showTerminal').mockRejectedValue(new Error('terminal_not_found')) + const send = vi.spyOn(runtime, 'sendTerminal') + const response = await dispatchAs( + HOST_BOUND, + 'terminal.send', + { terminal: 'term_unknown', text: 'rm -rf ~' }, + runtime + ) + expect(errorCode(response)).toBe('forbidden') + expect(send).not.toHaveBeenCalled() + }) + + it("reads the bridged host's own terminal", async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + ...terminalSummary('term_own', 'ssh:box-1'), + paneRuntimeId: 1, + rendererGraphEpoch: 0 + }) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_own', + status: 'running', + tail: ['ok'], + truncated: false, + nextCursor: null + }) + const response = await dispatchAs( + HOST_BOUND, + 'terminal.read', + { terminal: 'term_own' }, + runtime + ) + expect(response.ok).toBe(true) + }) + + it("lists only the bridged host's terminals", async () => { + const runtime = new OrcaRuntimeService() + const listing: RuntimeTerminalListResult = { + terminals: [ + terminalSummary('term_own', 'ssh:box-1'), + terminalSummary('term_local', 'local'), + terminalSummary('term_other', 'ssh:box-2') + ], + totalCount: 3, + truncated: false, + topologyRevisions: { 'wt-term_own': 1, 'wt-term_local': 2 }, + hostScope: { hostIds: ['local', 'ssh:box-1', 'ssh:box-2'], omittedHostIds: [] } + } + vi.spyOn(runtime, 'listTerminals').mockResolvedValue(listing) + const response = await dispatchAs(HOST_BOUND, 'terminal.list', {}, runtime) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + terminals: [terminalSummary('term_own', 'ssh:box-1')], + totalCount: 1, + truncated: false, + topologyRevisions: { 'wt-term_own': 1 }, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: [] } + }) + }) + + it("refuses a terminal listing selected by another host's worktree", async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminalSummary('term_local', 'local')], + totalCount: 1, + truncated: false + }) + const response = await dispatchAs( + HOST_BOUND, + 'terminal.list', + { worktree: 'path:/Users/me/secret' }, + runtime + ) + expect(errorCode(response)).toBe('forbidden') + }) +}) + +describe('SSH bridge with the per-host opt-in', () => { + it('reaches workspace methods outside its own host', async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminalSummary('term_local', 'local')], + totalCount: 1, + truncated: false + }) + const response = await dispatchAs(CONTROL_GRANTED, 'terminal.list', {}, runtime) + expect(response.ok).toBe(true) + }) + + it.each([ + ['computer.click', { app: 'Finder' }], + ['accounts.selectClaude', {}], + ['settings.update', {}], + ['skills.install', {}], + ['ssh.connect', {}], + ['pairing.provisionRelay', {}] + ])('still refuses owner-only %s', async (method, params) => { + const response = await dispatchAs(CONTROL_GRANTED, method, params) + expect(errorCode(response)).toBe('forbidden') + }) +}) + +describe('paired runtime clients', () => { + it('cannot drive the desktop without a pairing-time grant', async () => { + const response = await dispatchAs(PAIRED, 'computer.click', { app: 'Finder' }) + expect(errorCode(response)).toBe('forbidden') + }) + + it('may drive the desktop when the pairing granted it', async () => { + const response = await dispatchAs( + { kind: 'runtime-paired', grants: ['desktop-control'] }, + 'computer.click', + {} + ) + expect(errorCode(response)).not.toBe('forbidden') + }) + + it.each(['settings.update', 'accounts.selectClaude', 'skills.install', 'updater.check'])( + 'keep the remote UI surface: %s', + async (method) => { + const response = await dispatchAs(PAIRED, method, {}) + expect(errorCode(response)).not.toBe('forbidden') + } + ) + + it.each(['pairing.provisionRelay', 'pairing.getEndpoints', 'notifications.registerPush'])( + 'are refused pairing and push administration: %s', + async (method) => { + const response = await dispatchAs(PAIRED, method, {}) + expect(errorCode(response)).toBe('forbidden') + } + ) + + it('learn an unregistered method is missing rather than forbidden', async () => { + const response = await dispatchAs(PAIRED, 'computer.methodFromTheFuture', {}) + expect(errorCode(response)).toBe('method_not_found') + }) +}) + +describe('the owner', () => { + it('reaches desktop control unscoped', async () => { + const response = await dispatchAs(undefined, 'computer.click', {}) + expect(errorCode(response)).not.toBe('forbidden') + }) +}) + +describe('a pinned in-process bridge dispatcher', () => { + it('ignores a wider per-call scope', async () => { + const dispatcher = new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + callerScope: HOST_BOUND + }) + const response = await dispatcher.dispatch(request('computer.click', { app: 'Finder' }), { + callerScope: { kind: 'owner' } + }) + expect(errorCode(response)).toBe('forbidden') + }) +}) diff --git a/src/main/runtime/rpc/rpc-caller-scope.ts b/src/main/runtime/rpc/rpc-caller-scope.ts new file mode 100644 index 00000000000..b44b8d8c2a2 --- /dev/null +++ b/src/main/runtime/rpc/rpc-caller-scope.ts @@ -0,0 +1,89 @@ +/** + * Which permissions each kind of caller holds, checked once by the dispatcher for every transport. + * + * A transport stamps the scope it proved (device token, SSH bridge credential); an unstamped call is + * this host's own desktop or owner-token CLI. Every non-owner row is deny-by-default: a method is + * reachable only when its declared permission is in the row. + */ +import { MOBILE_RPC_METHOD_ALLOWLIST } from '../runtime-rpc/runtime-rpc-mobile-method-allowlist' +import type { RpcMethodPermission, RuntimeDeviceGrant } from './rpc-method-permission' +import type { OrcaRuntimeService } from '../orca-runtime' +import { + bindSshBridgeCall, + SSH_BRIDGE_HOST_BINDERS, + SSH_BRIDGE_REMOTE_CONTROL_HINT, + type SshBridgeCallBinding +} from './ssh-bridge-host-binding' + +export type { RuntimeDeviceGrant } + +export type RpcCallerScope = + | { kind: 'owner' } + | { kind: 'mobile' } + | { kind: 'runtime-paired'; grants: readonly RuntimeDeviceGrant[] } + /** The `orca` CLI of a remote SSH host, relayed through this host's bridge. `remoteCliControl` + * is the per-target opt-in that lets that host's CLI act outside its own terminals. */ + | { kind: 'ssh-bridge'; targetId: string; remoteCliControl: boolean } + +export const OWNER_RPC_CALLER_SCOPE: RpcCallerScope = { kind: 'owner' } + +// Why: a paired desktop, web client or `--host runtime:` CLI is this runtime's full remote UI, so its +// standard set covers what that UI drives here (settings, accounts, skills, SSH and server updates). +// Everything else (desktop control, pairing and push admin) is refused unless granted at pairing. +const RUNTIME_PAIRED_STANDARD: ReadonlySet = new Set([ + 'workspace', + 'settings-write', + 'accounts-admin', + 'skills-admin', + 'host-admin' +]) + +/** + * Returns the refusal message, or null when the caller may invoke the method. `permission` is + * undefined for a method this build does not register; only the mobile row answers that by name, + * because phones read `forbidden` (not `method_not_found`) as "this desktop predates the method". + */ +export function denyRpcMethodForCaller( + scope: RpcCallerScope, + methodName: string, + permission: RpcMethodPermission | undefined +): string | null { + if (scope.kind === 'mobile') { + return MOBILE_RPC_METHOD_ALLOWLIST.has(methodName) + ? null + : `Method '${methodName}' is not available to mobile clients` + } + if (scope.kind === 'owner' || permission === undefined) { + return null + } + if (scope.kind === 'runtime-paired') { + return !RUNTIME_PAIRED_STANDARD.has(permission) && + !scope.grants.some((grant) => grant === permission) + ? `Method '${methodName}' needs the '${permission}' permission, which this paired client was not granted when it paired.` + : null + } + if (scope.remoteCliControl) { + return permission === 'workspace' + ? null + : `Method '${methodName}' is not available to an SSH host's orca CLI.` + } + return permission === 'workspace' && SSH_BRIDGE_HOST_BINDERS.has(methodName) + ? null + : `Method '${methodName}' is not available to the orca CLI on SSH host '${scope.targetId}'. ${SSH_BRIDGE_REMOTE_CONTROL_HINT}` +} + +/** + * Binds an allowed call's selectors to the caller's host; only the unopted SSH bridge is bound. + * Null (not a resolved promise) when unbound, so other callers' dispatch gains no extra tick. + */ +export function bindRpcCallToCallerScope( + scope: RpcCallerScope, + runtime: OrcaRuntimeService, + methodName: string, + params: unknown +): Promise | null { + if (scope.kind !== 'ssh-bridge' || scope.remoteCliControl) { + return null + } + return bindSshBridgeCall(runtime, scope.targetId, methodName, params) +} diff --git a/src/main/runtime/rpc/rpc-method-permission.ts b/src/main/runtime/rpc/rpc-method-permission.ts new file mode 100644 index 00000000000..43d06460f62 --- /dev/null +++ b/src/main/runtime/rpc/rpc-method-permission.ts @@ -0,0 +1,37 @@ +/** + * What a method lets its caller do to this Orca, declared on every method definition. + * + * The dispatcher grants each caller kind a set of these (rpc-caller-scope.ts), so a new method + * cannot ship without someone deciding which callers may reach it. + */ +export type RpcMethodPermission = + /** Projects, worktrees, terminals, files, git, browser, orchestration and integrations. */ + | 'workspace' + /** Drives this machine's own desktop: clicks, keys and app state outside Orca. */ + | 'desktop-control' + /** Adds, removes or switches agent and integration accounts and their credentials. */ + | 'accounts-admin' + /** Changes Orca settings. */ + | 'settings-write' + /** Installs, publishes or removes agent skills. */ + | 'skills-admin' + /** Pairing offers, relay credentials and push registration for paired devices. */ + | 'pairing-admin' + /** Updates, managed servers, SSH connections, plugins and network tunnels on this host. */ + | 'host-admin' + +/** Administrative permissions a paired runtime client holds only when granted at pairing time. */ +export const RUNTIME_DEVICE_GRANTS = [ + 'desktop-control' +] as const satisfies readonly RpcMethodPermission[] +export type RuntimeDeviceGrant = (typeof RUNTIME_DEVICE_GRANTS)[number] + +export const RPC_METHOD_PERMISSIONS: readonly RpcMethodPermission[] = [ + 'workspace', + 'desktop-control', + 'accounts-admin', + 'settings-write', + 'skills-admin', + 'pairing-admin', + 'host-admin' +] diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 7dc5dc0586a..14f960439e6 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -22,6 +22,12 @@ import { routeDispatcherClientHostedBrowserRpc } from './dispatcher-client-brows import { needsLocalCallerFingerprint } from './dispatcher-caller-fingerprint' import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming-feature-emitter' import { resolveRpcCallerIdentity } from './rpc-caller-identity' +import { + bindRpcCallToCallerScope, + denyRpcMethodForCaller, + OWNER_RPC_CALLER_SCOPE, + type RpcCallerScope +} from './rpc-caller-scope' import { needsOrchestrationCallerResolution, resolveOrchestrationSessionCaller, @@ -34,6 +40,7 @@ export type RpcStreamingDispatcherDependencies = { orchestrationMutations: OrchestrationMutationExecutor legacyOrchestration: OrchestrationLegacyCompatibility meta: () => RpcEnvelopeMeta + pinnedCallerScope?: RpcCallerScope } export class RpcStreamingDispatcher { @@ -50,6 +57,13 @@ export class RpcStreamingDispatcher { this.dependencies const envelopeMeta = meta() const method = registry.get(request.method) + const callerScope = + this.dependencies.pinnedCallerScope ?? options?.callerScope ?? OWNER_RPC_CALLER_SCOPE + const denial = denyRpcMethodForCaller(callerScope, request.method, method?.permission) + if (denial) { + reply(JSON.stringify(errorResponse(request.id, envelopeMeta, 'forbidden', denial))) + return + } if (!method) { reply( JSON.stringify( @@ -88,6 +102,12 @@ export class RpcStreamingDispatcher { return } const params = parsedParams.value + const pendingBinding = bindRpcCallToCallerScope(callerScope, runtime, request.method, params) + const binding = pendingBinding ? await pendingBinding : null + if (binding?.kind === 'denied') { + reply(JSON.stringify(errorResponse(request.id, envelopeMeta, 'forbidden', binding.message))) + return + } if (!isStreamingMethod(method)) { try { @@ -170,7 +190,14 @@ export class RpcStreamingDispatcher { orchestrationCaller?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) - reply(JSON.stringify(successResponse(request.id, envelopeMeta, result))) + const filtered = binding?.filterResult?.(result) ?? { kind: 'allowed', result } + reply( + JSON.stringify( + filtered.kind === 'denied' + ? errorResponse(request.id, envelopeMeta, 'forbidden', filtered.message) + : successResponse(request.id, envelopeMeta, filtered.result) + ) + ) } catch (error) { reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) } diff --git a/src/main/runtime/rpc/ssh-bridge-credentials.ts b/src/main/runtime/rpc/ssh-bridge-credentials.ts new file mode 100644 index 00000000000..4b549880669 --- /dev/null +++ b/src/main/runtime/rpc/ssh-bridge-credentials.ts @@ -0,0 +1,32 @@ +/** + * Short-lived credentials for the `orca` CLI an SSH host runs through this host's bridge. + * + * One credential per invocation, bound to the SSH target that relayed it, revoked when the + * invocation settles. The runtime socket maps it to that target's bridge scope, so the child CLI + * never holds the owner token. + */ +import { randomBytes } from 'node:crypto' +import type { RpcCallerScope } from './rpc-caller-scope' + +export type SshBridgeCallerScope = Extract + +export type SshBridgeCredential = { + token: string + revoke: () => void +} + +export class SshBridgeCredentialRegistry { + private readonly bindings = new Map() + + mint(scope: SshBridgeCallerScope): SshBridgeCredential { + const token = `sshb_${randomBytes(24).toString('hex')}` + this.bindings.set(token, { ...scope }) + return { token, revoke: () => this.bindings.delete(token) } + } + + resolve(token: string): SshBridgeCallerScope | null { + return this.bindings.get(token) ?? null + } +} + +export const sshBridgeCredentials = new SshBridgeCredentialRegistry() diff --git a/src/main/runtime/rpc/ssh-bridge-host-binding.ts b/src/main/runtime/rpc/ssh-bridge-host-binding.ts new file mode 100644 index 00000000000..a03a4c0e8c7 --- /dev/null +++ b/src/main/runtime/rpc/ssh-bridge-host-binding.ts @@ -0,0 +1,155 @@ +/** + * Keeps an SSH host's relayed `orca` CLI inside that host's own terminals. + * + * Without the per-target opt-in, the bridge reaches only these methods, and each selector must + * resolve to a terminal on the bridged host; orchestration callers are bound the same way. Terminals whose host cannot be named are refused: + * failing closed is the only safe answer for a credential that must not reach other hosts. + */ +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { RuntimeTerminalListResult } from '../../../shared/runtime-terminal-contracts' +import type { OrcaRuntimeService } from '../orca-runtime' +import { + findSshBridgeOrchestrationViolation, + isHostTerminal, + parseSshBridgeSelectors, + SSH_BRIDGE_ORCHESTRATION_METHODS, + type SshBridgeSelectors +} from './ssh-bridge-orchestration-binding' + +export const SSH_BRIDGE_REMOTE_CONTROL_HINT = + 'To let that host\'s CLI control this Orca, enable "Allow this host\'s orca CLI to control Orca" in Settings > SSH for that host.' + +export type SshBridgeResultFilter = ( + result: unknown +) => { kind: 'denied'; message: string } | { kind: 'allowed'; result: unknown } + +export type SshBridgeCallBinding = + | { kind: 'denied'; message: string } + | { kind: 'allowed'; filterResult?: SshBridgeResultFilter } + +type SshBridgeBinder = ( + runtime: OrcaRuntimeService, + targetId: string, + methodName: string, + selectors: SshBridgeSelectors +) => Promise + +const bindTerminalHandle: SshBridgeBinder = async (runtime, targetId, _methodName, selectors) => { + const handle = selectors.terminal + return handle && (await isHostTerminal(runtime, toSshExecutionHostId(targetId), handle)) + ? { kind: 'allowed' } + : { kind: 'denied', message: outsideHostMessage(targetId, `terminal '${handle ?? ''}'`) } +} + +const bindOrchestration: SshBridgeBinder = async (runtime, targetId, methodName, selectors) => { + const violation = await findSshBridgeOrchestrationViolation( + runtime, + toSshExecutionHostId(targetId), + methodName, + selectors + ) + return violation + ? { kind: 'denied', message: outsideHostMessage(targetId, violation) } + : { kind: 'allowed' } +} + +/** Every method an unopted bridge may reach, each with how its selectors are bound to the host. */ +export const SSH_BRIDGE_HOST_BINDERS: ReadonlyMap = new Map< + string, + SshBridgeBinder +>([ + ['status.get', async () => ({ kind: 'allowed' })], + [ + 'terminal.list', + async (_runtime, targetId, _methodName, { worktree }) => ({ + kind: 'allowed', + filterResult: (result) => filterTerminalListToHost(result, targetId, worktree) + }) + ], + ['terminal.show', bindTerminalHandle], + ['terminal.read', bindTerminalHandle], + ['terminal.send', bindTerminalHandle], + ['terminal.wait', bindTerminalHandle], + ...SSH_BRIDGE_ORCHESTRATION_METHODS.map((method): [string, SshBridgeBinder] => [ + method, + bindOrchestration + ]) +]) + +export function bindSshBridgeCall( + runtime: OrcaRuntimeService, + targetId: string, + methodName: string, + params: unknown +): Promise { + const bind = SSH_BRIDGE_HOST_BINDERS.get(methodName) + // Why: the dispatcher admits only table methods, so a miss is unreachable and must fail closed. + return bind + ? bind(runtime, targetId, methodName, parseSshBridgeSelectors(params)) + : Promise.resolve({ + kind: 'denied', + message: outsideHostMessage(targetId, `method '${methodName}'`) + }) +} + +function filterTerminalListToHost( + result: unknown, + targetId: string, + worktreeSelector: string | undefined +): ReturnType { + const hostId = toSshExecutionHostId(targetId) + if (!isTerminalListResult(result)) { + return { kind: 'denied', message: 'Terminal listing could not be scoped to the SSH host.' } + } + const terminals = result.terminals.filter((terminal) => terminal.executionHostId === hostId) + if (worktreeSelector && terminals.length !== result.terminals.length) { + return { + kind: 'denied', + message: outsideHostMessage(targetId, `worktree '${worktreeSelector}'`) + } + } + const worktreeIds = new Set(terminals.map((terminal) => terminal.worktreeId)) + const scoped: RuntimeTerminalListResult = { + terminals, + totalCount: terminals.length, + truncated: result.truncated && terminals.length === result.terminals.length, + ...(result.visualLayouts + ? { + visualLayouts: result.visualLayouts.filter((layout) => worktreeIds.has(layout.worktreeId)) + } + : {}), + ...(result.topologyRevisions + ? { + topologyRevisions: Object.fromEntries( + Object.entries(result.topologyRevisions).filter(([worktreeId]) => + worktreeIds.has(worktreeId) + ) + ) + } + : {}), + ...(result.hostScope + ? { + hostScope: { + hostIds: result.hostScope.hostIds.filter((id) => id === hostId), + omittedHostIds: result.hostScope.omittedHostIds.filter((id) => id === hostId) + } + } + : {}) + } + return { kind: 'allowed', result: scoped } +} + +function isTerminalListResult(value: unknown): value is RuntimeTerminalListResult { + return ( + typeof value === 'object' && + value !== null && + 'terminals' in value && + Array.isArray(value.terminals) && + 'truncated' in value && + typeof value.truncated === 'boolean' + ) +} + +function outsideHostMessage(targetId: string, subject: string): string { + return `The orca CLI on SSH host '${targetId}' can only reach that host's own terminals and the coordinator that dispatched them, and ${subject} is outside that. ${SSH_BRIDGE_REMOTE_CONTROL_HINT}` +} diff --git a/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts new file mode 100644 index 00000000000..eca0f9227a5 --- /dev/null +++ b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts @@ -0,0 +1,371 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { OrchestrationDb } from '../orchestration/db' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { createOrchestrationRpcHarness } from './methods/orchestration/rpc-test-harness' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import { HOST_BOUND_SSH_BRIDGE_SCOPE as HOST_BOUND } from '../../ssh/ssh-bridge-caller-scope.test-fixture' + +const SentMessageResult = z.object({ message: z.object({ id: z.string() }) }) +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const SIBLING_PANE = 'tab_sibling:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const LOCAL_PANE = 'tab_local:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +// A worker on the relay-route SSH host, a sibling on another host, and a local coordinator. +const HOSTS: Record = { + term_worker: 'ssh:box-1', + term_box_peer: 'ssh:box-1', + term_coord: 'local', + term_local: 'local', + term_sibling: 'ssh:box-2' +} + +describe('SSH bridge orchestration without the per-host opt-in', () => { + const harness = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + + afterEach(() => harness.cleanup()) + + function setup(): void { + ;({ db, runtime } = harness.setup()) + const panes: Record = { + term_coord: harness.coordinatorPaneKey, + term_worker: WORKER_PANE, + term_sibling: SIBLING_PANE, + term_local: LOCAL_PANE + } + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) + vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( + (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null + ) + vi.spyOn(runtime, 'showTerminal').mockImplementation(async (handle) => { + const host = HOSTS[handle] + if (!host) { + throw new Error('terminal_not_found') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding reads only executionHostId. + return { handle, executionHostId: host } as Awaited< + ReturnType + > + }) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') + } + + function dispatchFromCoordinator(name: string, paneKey: string) { + const task = db.createTask({ spec: `${name} work` }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'terminal', handle: 'term_coord', paneKey: harness.coordinatorPaneKey }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: `term_${name}`, + paneKey, + processIncarnation: `runtime_test:term_${name}:1`, + worktreeId: `repo::${name}`, + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.dispatch.id) + return { taskId: task.id, dispatchId: started.dispatch.id } + } + + function callAsBridge(method: string, params: Record): Promise { + return new RpcDispatcher({ runtime }).dispatch( + { + id: `req-${method}`, + authToken: 'unused', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + }, + { callerScope: HOST_BOUND } + ) + } + + function forbidden(response: RpcResponse): boolean { + return !response.ok && response.error.code === 'forbidden' + } + + it('lets a worker on the host report worker_done to the local coordinator that dispatched it', async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const response = await callAsBridge('orchestration.send', { + from: 'term_worker', + senderPaneKey: WORKER_PANE, + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ ...worker, outcome: 'succeeded' }) + }) + expect(response).toMatchObject({ ok: true, result: { lifecycle: { action: 'completed' } } }) + expect(db.getTask(worker.taskId)?.status).toBe('completed') + }) + + it('lets that worker check its own mailbox and message its coordinator by handle', async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + expect((await callAsBridge('orchestration.check', { terminal: 'term_worker' })).ok).toBe(true) + expect((await callAsBridge('orchestration.inbox', { terminal: 'term_worker' })).ok).toBe(true) + const status = await callAsBridge('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'progress', + type: 'status' + }) + expect(forbidden(status)).toBe(false) + }) + + it.each<[string, string, Record]>([ + ['a sender on another host', 'orchestration.send', { from: 'term_sibling', subject: 'x' }], + ['a sender with no handle', 'orchestration.send', { subject: 'x' }], + [ + "a sibling's pane key", + 'orchestration.send', + { from: 'term_worker', senderPaneKey: SIBLING_PANE, subject: 'x' } + ], + [ + 'a local terminal that is not its coordinator', + 'orchestration.send', + { + from: 'term_worker', + to: 'term_local', + subject: 'x' + } + ], + ['a group address', 'orchestration.send', { from: 'term_worker', to: '@all', subject: 'x' }], + ["another host's mailbox", 'orchestration.check', { terminal: 'term_sibling' }], + ['an unscoped inbox', 'orchestration.inbox', {}], + [ + "a local terminal's question", + 'orchestration.ask', + { + from: 'term_local', + question: 'Proceed?' + } + ] + ])('refuses %s', async (_case, method, params) => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + expect(forbidden(await callAsBridge(method, params))).toBe(true) + }) + + it("refuses a report that names another worker's Dispatch", async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) + const response = await callAsBridge('orchestration.send', { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ ...sibling, outcome: 'succeeded' }) + }) + expect(forbidden(response)).toBe(true) + expect(db.getTask(sibling.taskId)?.status).toBe('dispatched') + }) + + it('refuses a run the caller is no party to', async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + const other = db.createRun({ + objective: 'Other', + coordinatorHandle: 'term_local', + coordinatorPaneKey: LOCAL_PANE + }) + const response = await callAsBridge('orchestration.check', { + terminal: 'term_box_peer', + run: other.id + }) + expect(forbidden(response)).toBe(true) + }) + + function callAsOwner(method: string, params: Record): Promise { + return new RpcDispatcher({ runtime }).dispatch({ + id: `owner-${method}`, + authToken: 'unused', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + }) + } + + function placeCoordinatorOnHost(): void { + vi.mocked(runtime.showTerminal).mockImplementation(async (handle) => { + const host = handle === 'term_coord' ? 'ssh:box-1' : HOSTS[handle] + if (!host) { + throw new Error('terminal_not_found') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding reads only executionHostId. + return { handle, executionHostId: host } as Awaited< + ReturnType + > + }) + } + + it("lets a worker reply to coordinator mail routed to its own Dispatch's mailbox", async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const sent = await callAsOwner('orchestration.send', { + from: 'term_coord', + to: 'term_worker', + subject: 'Please confirm', + body: 'Continue?', + type: 'status' + }) + expect(sent).toMatchObject({ + ok: true, + result: { message: { to_handle: `dispatch:${worker.dispatchId}` } } + }) + const { message } = SentMessageResult.parse(sent.ok ? sent.result : null) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_worker', + id: message.id, + body: 'Confirmed' + }) + expect(reply.ok).toBe(true) + }) + + it('lets an SSH coordinator answer a worker question stored in its own Run mailbox', async () => { + setup() + placeCoordinatorOnHost() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' + const { message, question } = db.createQuestion({ + runId, + dispatchId: worker.dispatchId, + askerHandle: 'term_worker', + question: 'May I continue?', + options: [] + }) + expect(message.to_handle).toBe(`run:${runId}`) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_coord', + id: message.id, + body: 'Proceed' + }) + expect(reply.ok).toBe(true) + expect(db.getQuestion(question.message_id)?.status).toBe('answered') + }) + + it("lets a worker address its own Run's mailbox and its own Dispatch explicitly", async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' + for (const to of [`run:${runId}`, `dispatch:${worker.dispatchId}`]) { + const sent = await callAsBridge('orchestration.send', { + from: 'term_worker', + to, + subject: 'p' + }) + expect(forbidden(sent)).toBe(false) + } + const asked = await callAsBridge('orchestration.ask', { + from: 'term_worker', + to: `run:${runId}`, + question: 'Proceed?', + timeoutMs: 1 + }) + expect(forbidden(asked)).toBe(false) + }) + + it('refuses a reply to mail addressed to another terminal, Dispatch, or Run', async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) + const other = db.createRun({ + objective: 'Other', + coordinatorHandle: 'term_local', + coordinatorPaneKey: LOCAL_PANE + }) + for (const to of ['term_local', `dispatch:${sibling.dispatchId}`, `run:${other.id}`]) { + const original = db.insertMessage({ from: 'term_coord', to, subject: 's', body: 's' }) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_worker', + id: original.id, + body: 'forged' + }) + expect(forbidden(reply)).toBe(true) + expect(db.getMessageById(original.id)?.read).toBe(0) + } + }) + + it("refuses a worker reply to its Run's question, which only the coordinator owns", async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' + const { message } = db.createQuestion({ + runId, + dispatchId: worker.dispatchId, + askerHandle: 'term_worker', + question: 'May I continue?', + options: [] + }) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_worker', + id: message.id, + body: 'Proceed' + }) + expect(forbidden(reply)).toBe(true) + }) + + it("refuses explicit addresses for another worker's Dispatch or an unrelated Run", async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) + const other = db.createRun({ + objective: 'Other', + coordinatorHandle: 'term_local', + coordinatorPaneKey: LOCAL_PANE + }) + for (const to of [`dispatch:${sibling.dispatchId}`, `run:${other.id}`]) { + const sent = await callAsBridge('orchestration.send', { + from: 'term_worker', + to, + subject: 'p' + }) + expect(forbidden(sent)).toBe(true) + } + }) + + it('refuses a forged pane claiming the Dispatch mailbox', async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + // A process on the worker's handle that does not hold the Dispatch's pane owns no Dispatch mail. + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_worker' + ? SIBLING_PANE + : handle === 'term_coord' + ? harness.coordinatorPaneKey + : null + ) + const original = db.insertMessage({ + from: 'term_coord', + to: `dispatch:${worker.dispatchId}`, + subject: 's', + body: 's' + }) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_worker', + id: original.id, + body: 'forged' + }) + expect(forbidden(reply)).toBe(true) + }) + + it("lets an SSH coordinator address a same-host worker's Dispatch but not another host's", async () => { + setup() + placeCoordinatorOnHost() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) + const send = (to: string) => + callAsBridge('orchestration.send', { from: 'term_coord', to, subject: 'p' }) + expect(forbidden(await send(`dispatch:${worker.dispatchId}`))).toBe(false) + expect(forbidden(await send(`dispatch:${sibling.dispatchId}`))).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts new file mode 100644 index 00000000000..3293abf33f2 --- /dev/null +++ b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts @@ -0,0 +1,166 @@ +/** + * Lets an SSH host's relayed `orca` CLI take part in orchestration as that host's own terminals. + * + * Without the per-target opt-in, the caller (`from` / `terminal`) must be a terminal on the bridged + * host. A party outside the host is reachable only as the coordinator of a Dispatch the caller works + * on, so a worker reports back to whoever dispatched it but cannot message or read anyone else. + * Routing stores mail under `run:`/`dispatch:` addresses, so ownership is resolved to those exact + * mailboxes the caller's live pane reads, never to a Run it merely shares. + */ +import { z } from 'zod' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrchestrationDb } from '../orchestration/db' +import { + resolveTerminalOwnedMailboxes, + type TerminalOwnedMailboxes +} from './methods/orchestration/messaging/terminal-owned-mailboxes' + +const TERMINAL_CALLER_METHODS: ReadonlySet = new Set([ + 'orchestration.check', + 'orchestration.inbox' +]) + +export const SSH_BRIDGE_ORCHESTRATION_METHODS: readonly string[] = [ + ...TERMINAL_CALLER_METHODS, + 'orchestration.send', + 'orchestration.ask', + 'orchestration.reply' +] + +/** The refused subject, or null when the call stays inside the caller's own orchestration. */ +export async function findSshBridgeOrchestrationViolation( + runtime: OrcaRuntimeService, + hostId: ExecutionHostId, + methodName: string, + selectors: SshBridgeSelectors +): Promise { + const caller = selectors[TERMINAL_CALLER_METHODS.has(methodName) ? 'terminal' : 'from'] + if (!caller || !(await isHostTerminal(runtime, hostId, caller))) { + return `terminal '${caller ?? ''}'` + } + const paneKey = runtime.getTerminalPaneKey(caller) ?? undefined + const claimedPane = + selectors[methodName === 'orchestration.check' ? 'terminalPaneKey' : 'senderPaneKey'] + // Why: the pane key is the lifecycle identity, so it must be the caller's own, not a sibling's. + if (claimedPane && claimedPane !== paneKey) { + return `pane '${claimedPane}'` + } + const db = runtime.getOrchestrationDb() + const ownDispatch = db.getActiveDispatchForIdentity(caller, paneKey) + const run = selectors.run + if (run && ownDispatch?.run_id !== run && db.getRun(run)?.coordinator_handle !== caller) { + return `run '${run}'` + } + const owned = resolveTerminalOwnedMailboxes(runtime, db, caller) + if (methodName === 'orchestration.reply') { + const id = selectors.id ?? '' + const original = db.getMessageById(id) + return original && owned.addresses.has(original.to_handle) && (!run || run === original.run_id) + ? null + : `message '${id}'` + } + if (TERMINAL_CALLER_METHODS.has(methodName)) { + return null + } + const dispatchId = readPayloadDispatchId(selectors) + if (dispatchId) { + const named = db.getDispatchContextById(dispatchId) + const party = + named && + (named.assignee_handle === caller || + (paneKey !== undefined && named.assignee_pane_key === paneKey) || + named.creator_handle === caller) + if (!party) { + return `dispatch '${dispatchId}'` + } + } + const to = selectors.to + if ( + !to || + (ownDispatch?.creator_handle && to === ownDispatch.creator_handle) || + (await isOwnCanonicalRecipient(db, owned, runtime, hostId, to)) + ) { + return null + } + return (await isHostTerminal(runtime, hostId, to)) ? null : `recipient '${to}'` +} + +// A canonical address is in scope when it is the caller's own mailbox, the Run mailbox its held +// Dispatch reports to, or a same-host worker's Dispatch in the Run the caller coordinates. +async function isOwnCanonicalRecipient( + db: OrchestrationDb, + owned: TerminalOwnedMailboxes, + runtime: OrcaRuntimeService, + hostId: ExecutionHostId, + to: string +): Promise { + if (owned.addresses.has(to) || (owned.dispatch && to === `run:${owned.dispatch.run_id}`)) { + return true + } + if (!to.startsWith('dispatch:') || owned.runId === undefined) { + return false + } + const dispatch = db.getDispatchContextById(to.slice('dispatch:'.length)) + return ( + dispatch?.run_id === owned.runId && + dispatch.assignee_handle !== null && + (await isHostTerminal(runtime, hostId, dispatch.assignee_handle)) + ) +} + +export async function isHostTerminal( + runtime: OrcaRuntimeService, + hostId: ExecutionHostId, + handle: string +): Promise { + return (await resolveTerminalHost(runtime, handle)) === hostId +} + +async function resolveTerminalHost( + runtime: OrcaRuntimeService, + handle: string +): Promise { + try { + return (await runtime.showTerminal(handle)).executionHostId ?? null + } catch { + return null + } +} + +function readPayloadDispatchId(selectors: SshBridgeSelectors): string | null { + if (!selectors.payload) { + return null + } + try { + return parseSshBridgeSelectors(JSON.parse(selectors.payload)).dispatchId ?? null + } catch { + // Why: the handler owns malformed-payload errors; nothing here can name a Dispatch. + return null + } +} + +// Why: a missing, non-string or empty selector names nothing, and the handler owns rejecting it. +const BridgeSelector = z.string().min(1).optional().catch(undefined) + +/** The selector fields the bridge binds to the caller's host; anything else names nothing. */ +const SshBridgeCallSelectors = z + .object({ + terminal: BridgeSelector, + terminalPaneKey: BridgeSelector, + senderPaneKey: BridgeSelector, + from: BridgeSelector, + to: BridgeSelector, + run: BridgeSelector, + id: BridgeSelector, + payload: BridgeSelector, + dispatchId: BridgeSelector, + worktree: BridgeSelector + }) + .catch({}) + +export type SshBridgeSelectors = z.infer + +export function parseSshBridgeSelectors(params: unknown): SshBridgeSelectors { + return SshBridgeCallSelectors.parse(params) +} diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 48afad4f3ac..d24eaeb10de 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -33,6 +33,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.pairing-stream', + permission: 'workspace', params: null, handler: async (_params, ctx) => { receivedPairing = ctx.pairing @@ -56,6 +57,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'terminal.subscribe', + permission: 'workspace', params: z.object({ terminal: z.string() }), handler: async (params, { runtime }, emit) => { const read = await (runtime as OrcaRuntimeService).readTerminal(params.terminal) @@ -100,6 +102,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.stream', + permission: 'workspace', params: null, handler: async (_params, _ctx, emit) => { emitFn = emit @@ -156,6 +159,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.subscribe', + permission: 'workspace', params: null, handler: async (_params, { runtime }, emit) => { emit({ type: 'scrollback', lines: '' }) @@ -170,6 +174,7 @@ describe('RpcDispatcher streaming', () => { }), defineMethod({ name: 'test.unsubscribe', + permission: 'workspace', params: z.object({ subscriptionId: z.string() }), handler: async (params, { runtime }) => { ;(runtime as OrcaRuntimeService).cleanupSubscription(params.subscriptionId) @@ -218,6 +223,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineMethod({ name: 'status.get', + permission: 'workspace', params: null, handler: async () => ({ status: 'ok' }) }) @@ -256,6 +262,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.stream', + permission: 'workspace', params: null, handler: async () => {} }) @@ -277,6 +284,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.explode', + permission: 'workspace', params: null, handler: async () => { throw new Error('boom') diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 19e22540c1f..f07a595c50b 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -339,8 +339,9 @@ describe('terminal multiplex RPC', () => { ).toMatchObject({ requestId: 7 }) + // Desktop initial snapshots carry history, not just the screen (#20158). expect(runtime.serializeTerminalBuffer).toHaveBeenCalledWith('pty-1', { - scrollbackRows: 0 + scrollbackRows: 1000 }) expect(runtime.serializeAuthoritativeTerminalBuffer).toHaveBeenLastCalledWith('pty-1', { scrollbackRows: 5000 diff --git a/src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts b/src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts new file mode 100644 index 00000000000..b0ad5514ad7 --- /dev/null +++ b/src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts @@ -0,0 +1,143 @@ +import type { Socket } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocket, WebSocketServer } from 'ws' + +/** + * Guards config/patches/ws@8.22.0.patch. Under Electron on arm64 kernels with + * 39-bit virtual addresses, `socket.write()` of a zero-length buffer fails with + * EFAULT and destroys the socket, so every empty ping, auto-pong or empty frame + * closed the remote runtime connection with 1006. These sockets fail the same + * way, so the cases fail if the patch is dropped or a ws bump loses it. + */ + +type Peer = { ws: WebSocket; writeLengths: number[] } + +const cleanups: (() => void)[] = [] + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } +}) + +function failEmptyWritesLikeArm64Electron(socket: Socket): number[] { + const writeLengths: number[] = [] + const write = socket.write.bind(socket) + // ws calls write(chunk, cb); Node treats a function encoding as the callback. + vi.spyOn(socket, 'write').mockImplementation((chunk, encoding, cb) => { + writeLengths.push(chunk.length) + if (chunk.length === 0) { + socket.destroy(Object.assign(new Error('write EFAULT'), { code: 'EFAULT' })) + return false + } + return write(chunk, encoding, cb) + }) + return writeLengths +} + +async function connectPair(): Promise<{ server: Peer; client: Peer }> { + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + cleanups.push(() => wss.close()) + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() + if (typeof address !== 'object' || address === null) { + throw new Error('WebSocketServer has no TCP address') + } + const { port } = address + + const serverPeer = new Promise((resolve) => { + wss.once('connection', (ws, request) => { + resolve({ ws, writeLengths: failEmptyWritesLikeArm64Electron(request.socket) }) + }) + }) + const clientWs = new WebSocket(`ws://127.0.0.1:${port}`) + cleanups.push(() => clientWs.terminate()) + let clientWriteLengths: number[] = [] + clientWs.once('upgrade', (response) => { + clientWriteLengths = failEmptyWritesLikeArm64Electron(response.socket) + }) + await new Promise((resolve, reject) => { + clientWs.once('open', () => resolve()) + clientWs.once('error', reject) + }) + const server = await serverPeer + return { server, client: { ws: clientWs, writeLengths: clientWriteLengths } } +} + +function sendCallback(): { + done: Promise + cb: (err?: Error | null) => void +} { + let cb: (err?: Error | null) => void = () => {} + const done = new Promise((resolve) => { + cb = vi.fn((err?: Error | null) => resolve(err)) + }) + return { done, cb } +} + +function closedCode(ws: WebSocket): Promise { + return new Promise((resolve) => ws.once('close', (code) => resolve(code))) +} + +describe('ws empty-payload frame writes', () => { + it('server heartbeat ping with no payload reaches the client and keeps the socket open', async () => { + const { server, client } = await connectPair() + const received = new Promise((resolve) => client.ws.once('ping', resolve)) + const { done, cb } = sendCallback() + + server.ws.ping(undefined, undefined, cb) + + expect(await done).toBeFalsy() + expect((await received).length).toBe(0) + // ws auto-pongs the client's empty ping back on the server's socket. + await new Promise((resolve) => server.ws.once('pong', () => resolve())) + expect(server.writeLengths).not.toContain(0) + expect(client.writeLengths).not.toContain(0) + expect(server.ws.readyState).toBe(WebSocket.OPEN) + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('auto-pong to an empty client ping does not write an empty payload on the server', async () => { + const { server, client } = await connectPair() + const pong = new Promise((resolve) => client.ws.once('pong', resolve)) + + client.ws.ping() + + expect((await pong).length).toBe(0) + expect(server.writeLengths).not.toContain(0) + expect(client.writeLengths).not.toContain(0) + expect(server.ws.readyState).toBe(WebSocket.OPEN) + }) + + it('empty pong and empty text frames are delivered with exactly one callback each', async () => { + const { server, client } = await connectPair() + const pong = new Promise((resolve) => client.ws.once('pong', resolve)) + const message = new Promise((resolve) => + client.ws.once('message', (data: Buffer) => resolve(data.toString())) + ) + const pongCallback = sendCallback() + const sendCallbackState = sendCallback() + + server.ws.pong(undefined, undefined, pongCallback.cb) + server.ws.send('', sendCallbackState.cb) + + expect(await pongCallback.done).toBeFalsy() + expect(await sendCallbackState.done).toBeFalsy() + expect((await pong).length).toBe(0) + expect(await message).toBe('') + expect(pongCallback.cb).toHaveBeenCalledTimes(1) + expect(sendCallbackState.cb).toHaveBeenCalledTimes(1) + expect(server.writeLengths).not.toContain(0) + }) + + it('a close with no code completes the closing handshake instead of 1006', async () => { + const { server, client } = await connectPair() + const clientClosed = closedCode(client.ws) + + server.ws.close() + + expect(await clientClosed).toBe(1005) + expect(server.writeLengths).not.toContain(0) + expect(client.writeLengths).not.toContain(0) + }) +}) diff --git a/src/main/runtime/runtime-pairing-desktop-control-grant.test.ts b/src/main/runtime/runtime-pairing-desktop-control-grant.test.ts new file mode 100644 index 00000000000..3b047705da6 --- /dev/null +++ b/src/main/runtime/runtime-pairing-desktop-control-grant.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { DeviceRegistry } from './device-registry' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { getServeOptions } from '../startup/serve-options' +import { parseArgs as parseOrcadArgs } from '../orcad/orcad-command-arguments' + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), 'orca-desktop-control-grant-')) +} + +async function dispatchOverWebSocket( + server: OrcaRuntimeRpcServer, + deviceToken: string, + method: string, + params: unknown = { app: 'Finder' } +): Promise> { + const replies: Record[] = [] + await server['handleWebSocketMessage']( + JSON.stringify({ id: `req-${method}`, method, deviceToken, params }), + (response) => replies.push(JSON.parse(response)), + () => {} + ) + return replies[0] ?? {} +} + +function errorCode(reply: Record): unknown { + const error = reply.error + return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined +} + +describe('desktop control for paired runtime clients', () => { + it('is refused to a runtime pairing that did not grant it', async () => { + const userDataPath = tempDir() + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('laptop', 'runtime') + const reply = await dispatchOverWebSocket(server, device.token, 'computer.click') + expect(errorCode(reply)).toBe('forbidden') + }) + + it('passes the permission gate for a pairing created with the grant', async () => { + const userDataPath = tempDir() + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('laptop', 'runtime', 'network', [ + 'desktop-control' + ]) + const reply = await dispatchOverWebSocket(server, device.token, 'computer.click') + expect(errorCode(reply)).not.toBe('forbidden') + }) +}) + +describe('pairing and push administration for paired runtime clients', () => { + it.each<[string, unknown]>([ + ['pairing.provisionRelay', {}], + ['notifications.registerPush', { token: 't', platform: 'ios' }] + ])('refuses %s without reaching its service', async (method, params) => { + const userDataPath = tempDir() + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('laptop', 'runtime') + const registerPush = vi.spyOn(runtime, 'registerMobilePushDevice') + const reply = await dispatchOverWebSocket(server, device.token, method, params) + expect(errorCode(reply)).toBe('forbidden') + expect(registerPush).not.toHaveBeenCalled() + }) +}) + +describe('pairing-time grants on the device registry', () => { + it('persist across a reload and are dropped for mobile devices', () => { + const userDataPath = tempDir() + const registry = new DeviceRegistry(userDataPath) + const runtime = registry.addDevice('laptop', 'runtime', 'network', ['desktop-control']) + const phone = registry.addDevice('phone', 'mobile', 'network', ['desktop-control']) + + const reloaded = new DeviceRegistry(userDataPath) + expect(reloaded.getDevice(runtime.deviceId)?.grants).toEqual(['desktop-control']) + expect(reloaded.getDevice(phone.deviceId)?.grants).toBeUndefined() + }) + + it('never widen a pending offer that was advertised without them', () => { + const registry = new DeviceRegistry(tempDir()) + const plain = registry.getOrCreatePendingDevice('cli', 'runtime') + const granted = registry.getOrCreatePendingDevice('cli', 'runtime', 'network', [ + 'desktop-control' + ]) + expect(granted.token).not.toBe(plain.token) + expect(registry.getDevice(plain.deviceId)?.grants).toBeUndefined() + expect(registry.getOrCreatePendingDevice('cli', 'runtime').token).toBe(plain.token) + }) +}) + +describe('the --grant-desktop-control serve flag', () => { + it('reaches the desktop serve options in both argv shapes', () => { + expect(getServeOptions(['/AppRun', '--serve']).grantDesktopControl).toBe(false) + expect( + getServeOptions(['/AppRun', '--serve', '--serve-grant-desktop-control']).grantDesktopControl + ).toBe(true) + expect(() => + getServeOptions(['/AppRun', '--serve', '--grant-desktop-control', '--mobile-pairing']) + ).toThrow('--grant-desktop-control applies only to the default runtime pairing offer.') + }) + + it('reaches orcad', () => { + expect(parseOrcadArgs(['--grant-desktop-control'])).toEqual({ grantDesktopControl: true }) + expect(() => parseOrcadArgs(['--grant-desktop-control', '--no-pairing'])).toThrow( + '--grant-desktop-control applies only to the default runtime pairing offer' + ) + }) +}) diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index cdc3ab1c60f..bfcbb4e5fe1 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -14,6 +14,7 @@ import type { } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' import { + getRepoExecutionHostId, getSshTargetIdForExecutionHost, parseExecutionHostId, type ExecutionHostId @@ -162,7 +163,10 @@ export class RuntimeProjectHostSetupController { return this.linkRepo(args, initialRepo) } catch (error) { if (repoWasCreated) { - this.deps.getStore()?.removeProject?.(initialRepo.id) + // Why: host-scoped so a rollback never takes a same-id row on another host. + this.deps + .getStore() + ?.removeProjectForHost?.(initialRepo.id, getRepoExecutionHostId(initialRepo)) this.deps.invalidateResolvedWorktrees() this.deps.invalidateWorktreeScan(initialRepo.id) invalidateAuthorizedRootsCache() diff --git a/src/main/runtime/runtime-remove-project-host-scope.test.ts b/src/main/runtime/runtime-remove-project-host-scope.test.ts index ab9a781d0db..43b9d44ae78 100644 --- a/src/main/runtime/runtime-remove-project-host-scope.test.ts +++ b/src/main/runtime/runtime-remove-project-host-scope.test.ts @@ -1,7 +1,7 @@ /** * The same repo id is allowed on two execution hosts (persistence.ts `removeProjectForHost`). - * `repo.rm` resolves a *row*, so the deletion it performs must be scoped to that row's host: - * `Store.removeProject` is id-only and would take the sibling host's registration with it. + * `repo.rm` resolves a *row*, so the deletion it performs must be scoped to that row's host, + * never the sibling host's registration. * A `path:`/`name:` selector resolves unambiguously even when the id is duplicated, so this * is reachable — and since #11994 every paired device now learns about it immediately. */ @@ -32,13 +32,6 @@ function makeRepos(): Repo[] { function createRuntime() { const repos = makeRepos() - const removeProject = vi.fn((id: string) => { - for (let index = repos.length - 1; index >= 0; index -= 1) { - if (repos[index].id === id) { - repos.splice(index, 1) - } - } - }) const removeProjectForHost = vi.fn((id: string, hostId: string) => { for (let index = repos.length - 1; index >= 0; index -= 1) { const repo = repos[index] @@ -57,10 +50,9 @@ function createRuntime() { setWorktreeMeta: vi.fn(), removeWorktreeMeta: vi.fn(), getGitHubCache: () => null, - removeProject, removeProjectForHost } as never) - return { runtime, repos, removeProject, removeProjectForHost } + return { runtime, repos, removeProjectForHost } } describe('repo.rm with the same repo id on two execution hosts', () => { @@ -81,10 +73,9 @@ describe('repo.rm with the same repo id on two execution hosts', () => { }) it('refuses a bare duplicated id rather than guessing a host', async () => { - const { runtime, repos, removeProject, removeProjectForHost } = createRuntime() + const { runtime, repos, removeProjectForHost } = createRuntime() await expect(runtime.removeProject('dup')).rejects.toThrow('selector_ambiguous') - expect(removeProject).not.toHaveBeenCalled() expect(removeProjectForHost).not.toHaveBeenCalled() expect(repos).toHaveLength(2) }) diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index 27260d23744..c4a02d656f4 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -105,22 +105,11 @@ export class RuntimeRepositorySettingsController { async remove(repoSelector: string): Promise<{ removed: true }> { const store = this.deps.getStore() - if (!store?.removeProject) { + if (!store?.removeProjectForHost) { throw new Error('runtime_unavailable') } const repo = await this.deps.resolveRepo(repoSelector) - const hostId = getRepoExecutionHostId(repo) - const idExistsOnOtherHost = store - .getRepos() - .some((entry) => entry.id === repo.id && getRepoExecutionHostId(entry) !== hostId) - if (idExistsOnOtherHost) { - if (!store.removeProjectForHost) { - throw new Error('runtime_unavailable') - } - store.removeProjectForHost(repo.id, hostId) - } else { - store.removeProject(repo.id) - } + store.removeProjectForHost(repo.id, getRepoExecutionHostId(repo)) this.deps.forgetTerminalTopology(repo.id) this.deps.invalidateResolvedWorktrees() this.deps.invalidateWorktreeScan(repo.id) diff --git a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts index 8b7446e6f40..b9187315478 100644 --- a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts +++ b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts @@ -32,6 +32,7 @@ describe('runtime RPC browser-host admission', () => { const blockingMethod = (name: 'browser.clientHost.attach' | 'terminal.wait') => defineStreamingMethod({ name, + permission: 'workspace', params: null, handler: async (_params, { signal }) => { await new Promise((resolve) => { @@ -102,6 +103,7 @@ describe('runtime RPC browser-host admission', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-host-fairness-')) const blockingHost = defineStreamingMethod({ name: 'browser.clientHost.attach', + permission: 'workspace', params: null, handler: async (_params, { signal }) => await new Promise((resolve) => signal?.addEventListener('abort', () => resolve())) @@ -195,6 +197,7 @@ describe('runtime RPC browser-host admission', () => { ) => defineStreamingMethod({ name, + permission: 'workspace', params: null, handler: async (_params, { signal }) => await new Promise((resolve) => signal?.addEventListener('abort', () => resolve())) diff --git a/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts b/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts new file mode 100644 index 00000000000..79e67e708e0 --- /dev/null +++ b/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { sshBridgeCredentials } from './rpc/ssh-bridge-credentials' +import type { RpcResponse } from './rpc/core' + +const BRIDGE_SCOPE = { kind: 'ssh-bridge', targetId: 'box-1', remoteCliControl: false } as const + +function createServer(): OrcaRuntimeRpcServer { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-bridge-')) + return new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: false + }) +} + +async function send( + server: OrcaRuntimeRpcServer, + authToken: string, + method: string, + params: unknown = {} +): Promise { + return server['handleMessage'](JSON.stringify({ id: `req-${method}`, authToken, method, params })) +} + +function errorCode(response: RpcResponse): string | null { + return response.ok ? null : response.error.code +} + +describe('the runtime socket and SSH bridge credentials', () => { + it('scopes a bridge credential to its SSH target instead of owner authority', async () => { + const server = createServer() + const credential = sshBridgeCredentials.mint(BRIDGE_SCOPE) + try { + expect(errorCode(await send(server, credential.token, 'computer.click'))).toBe('forbidden') + expect(errorCode(await send(server, credential.token, 'accounts.selectClaude'))).toBe( + 'forbidden' + ) + expect(errorCode(await send(server, credential.token, 'skills.install'))).toBe('forbidden') + expect((await send(server, credential.token, 'status.get')).ok).toBe(true) + } finally { + credential.revoke() + } + }) + + it('rejects a credential once its invocation settled', async () => { + const server = createServer() + const credential = sshBridgeCredentials.mint(BRIDGE_SCOPE) + credential.revoke() + expect(errorCode(await send(server, credential.token, 'status.get'))).toBe('unauthorized') + }) + + it('keeps the owner token unscoped', async () => { + const server = createServer() + const response = await send(server, server['authToken'], 'computer.click') + expect(errorCode(response)).not.toBe('forbidden') + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 7d8bba9f958..987d2b66ada 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -1,3 +1,4 @@ +import type { RuntimeDeviceGrant } from '../rpc/rpc-caller-scope' import type { DeviceEntry, DeviceRegistry, DeviceScope } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' @@ -130,6 +131,8 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { // Why: STA-2370 — recorded on the grant so a "This computer only" client reconnecting cannot make the // next launch bind every interface. Defaults to network reach, which is what every other caller means. reach?: RuntimePairingReach + // Why: administrative permissions exist only when granted here, never added to a paired device later. + grants?: readonly RuntimeDeviceGrant[] }): | PairingOfferUnavailable | { @@ -167,9 +170,10 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { let device: DeviceEntry try { const reach = args.reach ?? 'network' + const grants = args.grants ?? [] device = args.rotate - ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach) - : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach) + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach, grants) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach, grants) } catch (error) { console.error('[runtime] Failed to persist pairing credential:', error) return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts index b79b87a5be5..ad1bd49cbad 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts @@ -5,6 +5,8 @@ import type { RpcRequest, RpcResponse } from '../rpc/core' import { errorResponse } from '../rpc/errors' import { RuntimeRpcBinaryRouting } from './runtime-rpc-binary-routing' import { classifyRuntimeLongPoll, type RuntimeLongPollClass } from './runtime-rpc-long-poll' +import { OWNER_RPC_CALLER_SCOPE, type RpcCallerScope } from '../rpc/rpc-caller-scope' +import { sshBridgeCredentials } from '../rpc/ssh-bridge-credentials' export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { // Why: Unix socket dispatch is one-shot and auths via the shared token from the 0o600 metadata file. See §3.1. @@ -21,7 +23,7 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { if ('error' in parsed) { return parsed.error } - const request = parsed.request + const { request, callerScope } = parsed // Why: long-poll admission fence; short RPCs bypass the counter. See §7 risk #2. const longPoll = classifyRuntimeLongPoll(request) @@ -36,7 +38,8 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { try { return await this.dispatcher.dispatch(request, { - signal: longPoll ? context?.signal : undefined + signal: longPoll ? context?.signal : undefined, + callerScope }) } finally { this.releaseLongPoll(longPoll) @@ -111,7 +114,9 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { } } - protected parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { + protected parseAndAuth( + rawMessage: string + ): { request: RpcRequest; callerScope: RpcCallerScope } | { error: RpcResponse } { let request: RpcRequest try { request = JSON.parse(rawMessage) as RpcRequest @@ -128,11 +133,15 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { if (typeof request.authToken !== 'string' || request.authToken.length === 0) { return { error: this.buildError(request.id, 'unauthorized', 'Missing auth token') } } - if (request.authToken !== this.authToken) { - return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } + if (request.authToken === this.authToken) { + return { request, callerScope: OWNER_RPC_CALLER_SCOPE } } - - return { request } + // Why: a bridged SSH CLI holds only its invocation's credential, scoped to that SSH target. + const bridgeScope = sshBridgeCredentials.resolve(request.authToken) + if (bridgeScope) { + return { request, callerScope: bridgeScope } + } + return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } } protected buildError(id: string, code: string, message: string): RpcResponse { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index dd714b77528..3c07d5ae265 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -10,7 +10,7 @@ import type { WebSocketTransport } from '../rpc/ws-transport' import type { DeviceScope } from '../device-registry' import { RuntimeRpcRequestAdmission } from './runtime-rpc-request-admission' import { classifyRuntimeLongPoll } from './runtime-rpc-long-poll' -import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc-mobile-method-allowlist' +import type { RpcCallerScope } from '../rpc/rpc-caller-scope' // Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary. function injectDeviceScope(response: string, scope: DeviceScope): string { @@ -73,18 +73,11 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) return } - if (device.scope === 'mobile' && !MOBILE_RPC_METHOD_ALLOWLIST.has(request.method)) { - reply( - JSON.stringify( - this.buildError( - request.id, - 'forbidden', - `Method '${request.method}' is not available to mobile clients` - ) - ) - ) - return - } + // Why: the dispatcher enforces this scope's permissions; it never trusts a request field. + const callerScope: RpcCallerScope = + device.scope === 'mobile' + ? { kind: 'mobile' } + : { kind: 'runtime-paired', grants: device.grants ?? [] } // Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected. if (wsTransport && ws) { @@ -140,6 +133,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { pairedDeviceId: device.deviceId, // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, + callerScope, clientCapabilities: authenticatedSocket?.clientCapabilities, updateClientCapabilities: authenticatedSocket && device.scope === 'mobile' diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 24940d7f2db..b25168334c6 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -26,7 +26,6 @@ export type RuntimeStore = { createFolderWorkspace?: Store['createFolderWorkspace'] updateFolderWorkspace?: Store['updateFolderWorkspace'] removeFolderWorkspace?: Store['removeFolderWorkspace'] - removeProject?: Store['removeProject'] removeProjectForHost?: Store['removeProjectForHost'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] diff --git a/src/main/runtime/terminal-tail-buffer.ts b/src/main/runtime/terminal-tail-buffer.ts index de9a36331fb..5cf9cb1809b 100644 --- a/src/main/runtime/terminal-tail-buffer.ts +++ b/src/main/runtime/terminal-tail-buffer.ts @@ -8,6 +8,7 @@ import { trimTerminalLineRight } from './terminal-tail-line-controls' import { MAX_TAIL_CHARS, MAX_TAIL_LINES, MAX_TAIL_PARTIAL_CHARS } from './terminal-tail-limits' +import { maxUpwardCursorReach } from './terminal-tail-redraw-reach' import { appendNormalizedToMultilineTailBufferUnwindowed, type RetainedTailRedrawCursor @@ -223,24 +224,6 @@ export function appendNormalizedToTailBuffer( // Why a window: the unwindowed impl below is O(tail) per chunk (~93% of the event loop under TUI flood, findings log 2026-07-03); a redraw only touches rows the cursor reaches, so window the suffix and share the prefix by reference. Equivalence fuzz-verified in retained-tail-redraw-window.equivalence.test.ts. const REDRAW_WINDOW_SAFETY_ROWS = 8 -// Why module-level: this ran `new RegExp` per redraw chunk — i.e. per TUI frame per PTY. -// Safe to share because `maxUpwardCursorReach` is synchronous and non-reentrant; it resets -// `lastIndex` before every scan. -const CURSOR_UP_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[(\\d*)(?:;[\\d;]*)?A`, 'g') - -function maxUpwardCursorReach( - normalizedChunk: string, - previousRedrawCursor: RetainedTailRedrawCursor | null -): number { - let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 - CURSOR_UP_PATTERN.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = CURSOR_UP_PATTERN.exec(normalizedChunk)) !== null) { - reach += match[1] ? Number.parseInt(match[1], 10) : 1 - } - return reach -} - function appendNormalizedToMultilineTailBuffer( previousLines: string[], boundedPreviousPartialLine: string, @@ -256,7 +239,11 @@ function appendNormalizedToMultilineTailBuffer( newlyCompletedLines: string[] } { const windowRows = - maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS + maxUpwardCursorReach( + normalizedChunk, + previousRedrawCursor, + previousLines.length - REDRAW_WINDOW_SAFETY_ROWS + ) + REDRAW_WINDOW_SAFETY_ROWS if (windowRows >= previousLines.length) { const unwindowed = appendNormalizedToMultilineTailBufferUnwindowed( previousLines, diff --git a/src/main/runtime/terminal-tail-per-character-redraw.test-fixture.ts b/src/main/runtime/terminal-tail-per-character-redraw.test-fixture.ts new file mode 100644 index 00000000000..1ab234cd347 --- /dev/null +++ b/src/main/runtime/terminal-tail-per-character-redraw.test-fixture.ts @@ -0,0 +1,253 @@ +// Test-only: the redraw row model as it was before #11315 (one string rebuild per character), +// vendored so wall-time and equivalence tests can measure the current model against it. +import { + hasCanonicalNumericCsiParams, + parseAnsiControlSequence +} from './terminal-ansi-normalization' +import { ownRetainedString } from '../../shared/own-retained-string' +import { clampTerminalPreviewCursor, trimTerminalLineRight } from './terminal-tail-line-controls' +import { MAX_TAIL_CHARS, MAX_TAIL_LINES, MAX_TAIL_PARTIAL_CHARS } from './terminal-tail-limits' +import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' + +export function appendPerCharacterRedrawReference( + previousLines: string[], + boundedPreviousPartialLine: string, + normalizedChunk: string, + previousPartialWasCapped: boolean, + previousRedrawCursor: RetainedTailRedrawCursor | null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number + newlyCompletedLines: string[] +} { + const rows: RetainedTerminalRow[] = [ + ...previousLines.map((line) => ({ text: line, completed: true })), + { text: boundedPreviousPartialLine, completed: false } + ] + let cursorRow = previousRedrawCursor + ? Math.max(0, rows.length - 1 - previousRedrawCursor.rowFromEnd) + : rows.length - 1 + let cursorColumn = previousRedrawCursor?.column ?? boundedPreviousPartialLine.length + let newCompleteLines = 0 + const newlyCompletedLines: string[] = [] + let newlyCompletedLineCharacters = 0 + let newlyCompletedLineStart = 0 + let truncated = previousPartialWasCapped + + const retainNewlyCompletedLine = (line: string): void => { + newlyCompletedLines.push(line) + newlyCompletedLineCharacters += line.length + while ( + newlyCompletedLines.length - newlyCompletedLineStart > MAX_TAIL_LINES || + newlyCompletedLineCharacters > MAX_TAIL_CHARS + ) { + newlyCompletedLineCharacters -= newlyCompletedLines[newlyCompletedLineStart]!.length + newlyCompletedLineStart += 1 + } + // Why: a single PTY chunk can carry unbounded newlines; compact in batches while retaining the suffix needed for stable pagination. + if (newlyCompletedLineStart >= MAX_TAIL_LINES) { + newlyCompletedLines.splice(0, newlyCompletedLineStart) + newlyCompletedLineStart = 0 + } + } + + const ensureCursorRow = (): void => { + while (cursorRow >= rows.length) { + rows.push({ text: '', completed: false }) + } + } + const trimRows = (): void => { + const maxRows = MAX_TAIL_LINES + 1 + if (rows.length <= maxRows) { + return + } + const removeCount = rows.length - maxRows + rows.splice(0, removeCount) + cursorRow = Math.max(0, cursorRow - removeCount) + truncated = true + } + const moveCursorToColumn = (nextColumn: number): void => { + cursorColumn = clampTerminalPreviewCursor(nextColumn) + } + const markCursorRowRewritten = (): void => { + ensureCursorRow() + rows[cursorRow]!.completed = false + } + const writeChar = (char: string): void => { + ensureCursorRow() + markCursorRowRewritten() + const row = rows[cursorRow]! + if (cursorColumn > row.text.length) { + row.text = `${row.text}${' '.repeat(cursorColumn - row.text.length)}` + } + row.text = + cursorColumn >= row.text.length + ? `${row.text}${char}` + : `${row.text.slice(0, cursorColumn)}${char}${row.text.slice(cursorColumn + 1)}` + cursorColumn += 1 + } + const eraseLine = (mode: number): void => { + ensureCursorRow() + markCursorRowRewritten() + const row = rows[cursorRow]! + if (mode === 0) { + row.text = row.text.slice(0, cursorColumn) + } else if (mode === 1) { + const deleteCount = Math.min(cursorColumn + 1, row.text.length) + row.text = `${' '.repeat(deleteCount)}${row.text.slice(deleteCount)}` + } else if (mode === 2) { + row.text = '' + } + } + + for (let index = 0; index < normalizedChunk.length; index += 1) { + const char = normalizedChunk[index] + if (char === '\n') { + ensureCursorRow() + rows[cursorRow]!.completed = true + newCompleteLines += 1 + retainNewlyCompletedLine(trimTerminalLineRight(rows[cursorRow]!.text)) + cursorRow += 1 + cursorColumn = 0 + ensureCursorRow() + trimRows() + continue + } + if (char === '\r') { + cursorColumn = 0 + continue + } + if (char === '\u0008') { + cursorColumn = Math.max(0, cursorColumn - 1) + continue + } + if (char === '\u001b') { + const parsed = parseAnsiControlSequence(normalizedChunk, index) + if (!parsed) { + continue + } + index = parsed.endIndex + if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { + continue + } + const firstParam = parsed.firstParam ?? 1 + if (parsed.final === 'A') { + cursorRow = Math.max(0, cursorRow - firstParam) + rows.splice(cursorRow + 1) + } else if (parsed.final === 'K') { + eraseLine(parsed.firstParam ?? 0) + } else if (parsed.final === 'G' || parsed.final === '`') { + moveCursorToColumn(firstParam - 1) + } else if (parsed.final === 'D') { + cursorColumn = Math.max(0, cursorColumn - firstParam) + } else if (parsed.final === 'C') { + moveCursorToColumn(cursorColumn + firstParam) + } + continue + } + writeChar(char) + } + + return finalizeRetainedTerminalRows( + rows, + cursorRow, + cursorColumn, + truncated, + newCompleteLines, + newlyCompletedLineStart > 0 + ? newlyCompletedLines.slice(newlyCompletedLineStart) + : newlyCompletedLines + ) +} + +type RetainedTerminalRow = { + text: string + completed: boolean +} + +function finalizeRetainedTerminalRows( + rows: RetainedTerminalRow[], + cursorRow: number, + cursorColumn: number, + initialTruncated: boolean, + newCompleteLines: number, + newlyCompletedLines: string[] +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number + newlyCompletedLines: string[] +} { + let truncated = initialTruncated + let retainedRows = rows.map((row) => ({ ...row, text: trimTerminalLineRight(row.text) })) + + if (retainedRows.length > MAX_TAIL_LINES + 1) { + const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) + retainedRows = retainedRows.slice(removeCount) + cursorRow = Math.max(0, cursorRow - removeCount) + truncated = true + } + + let totalChars = retainedRows.reduce((sum, row) => sum + row.text.length, 0) + let trimStartIndex = 0 + while (trimStartIndex < retainedRows.length - 1 && totalChars > MAX_TAIL_CHARS) { + totalChars -= retainedRows[trimStartIndex]!.text.length + trimStartIndex += 1 + } + if (trimStartIndex > 0) { + retainedRows = retainedRows.slice(trimStartIndex) + cursorRow = Math.max(0, cursorRow - trimStartIndex) + truncated = true + } + while ( + retainedRows.length > 1 && + cursorRow < retainedRows.length - 1 && + retainedRows.at(-1)?.completed === false && + retainedRows.at(-1)?.text.length === 0 + ) { + retainedRows.pop() + } + + const lastRow = retainedRows.at(-1) + let partialLine = lastRow && !lastRow.completed ? lastRow.text : '' + let lines = (lastRow && !lastRow.completed ? retainedRows.slice(0, -1) : retainedRows).map( + (row) => row.text + ) + + if (partialLine.length > MAX_TAIL_PARTIAL_CHARS) { + partialLine = partialLine.slice(-MAX_TAIL_PARTIAL_CHARS) + truncated = true + } + if (lines.length > MAX_TAIL_LINES) { + lines = lines.slice(lines.length - MAX_TAIL_LINES) + truncated = true + } + const outputRowCount = lines.length + 1 + const defaultCursorRow = outputRowCount - 1 + const defaultCursorColumn = partialLine.length + const redrawCursor = + cursorRow === defaultCursorRow && cursorColumn === defaultCursorColumn + ? null + : { + rowFromEnd: Math.max(0, outputRowCount - 1 - cursorRow), + column: clampTerminalPreviewCursor(cursorColumn) + } + + return { + lines, + // Why only the partial: redraw rows are built character by character and never sliced from + // the chunk, but the partial is re-sliced from its own row on every chunk, so it alone can + // accumulate a backing string across frames. Owning the rows too costs 20-36% on TUI floods + // for no measured retention. + partialLine: ownRetainedString(partialLine), + redrawCursor, + truncated, + newCompleteLines, + newlyCompletedLines + } +} diff --git a/src/main/runtime/terminal-tail-redraw-buffer.ts b/src/main/runtime/terminal-tail-redraw-buffer.ts index 2dd6d72badd..1b7e0968ed9 100644 --- a/src/main/runtime/terminal-tail-redraw-buffer.ts +++ b/src/main/runtime/terminal-tail-redraw-buffer.ts @@ -3,8 +3,15 @@ import { parseAnsiControlSequence } from './terminal-ansi-normalization' import { ownRetainedString } from '../../shared/own-retained-string' -import { clampTerminalPreviewCursor, trimTerminalLineRight } from './terminal-tail-line-controls' +import { clampTerminalPreviewCursor } from './terminal-tail-line-controls' import { MAX_TAIL_CHARS, MAX_TAIL_LINES, MAX_TAIL_PARTIAL_CHARS } from './terminal-tail-limits' +import { + eraseRetainedRow, + retainedRow, + retainedRowSnapshot, + writeRetainedRow, + type RetainedTerminalRow +} from './terminal-tail-redraw-row' export function appendNormalizedToMultilineTailBufferUnwindowed( previousLines: string[], @@ -21,8 +28,8 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( newlyCompletedLines: string[] } { const rows: RetainedTerminalRow[] = [ - ...previousLines.map((line) => ({ text: line, completed: true })), - { text: boundedPreviousPartialLine, completed: false } + ...previousLines.map((line) => retainedRow(line, true)), + retainedRow(boundedPreviousPartialLine, false) ] let cursorRow = previousRedrawCursor ? Math.max(0, rows.length - 1 - previousRedrawCursor.rowFromEnd) @@ -53,75 +60,54 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( const ensureCursorRow = (): void => { while (cursorRow >= rows.length) { - rows.push({ text: '', completed: false }) + rows.push(retainedRow('', false)) } } + // Rows before this index are already dropped by the line cap but not yet spliced out. + let trimmedRows = 0 const trimRows = (): void => { - const maxRows = MAX_TAIL_LINES + 1 - if (rows.length <= maxRows) { + const excess = rows.length - trimmedRows - (MAX_TAIL_LINES + 1) + if (excess <= 0) { return } - const removeCount = rows.length - maxRows - rows.splice(0, removeCount) - cursorRow = Math.max(0, cursorRow - removeCount) + trimmedRows += excess truncated = true + // Why batched: splicing one row off a full tail per newline made a long chunk, such as a TUI + // replaying its whole history, cost O(lines x MAX_TAIL_LINES). + if (trimmedRows >= MAX_TAIL_LINES) { + spliceTrimmedRows() + } + } + const spliceTrimmedRows = (): void => { + rows.splice(0, trimmedRows) + cursorRow -= trimmedRows + trimmedRows = 0 } const moveCursorToColumn = (nextColumn: number): void => { cursorColumn = clampTerminalPreviewCursor(nextColumn) } - const markCursorRowRewritten = (): void => { + const cursorRowForEdit = (): RetainedTerminalRow => { ensureCursorRow() - rows[cursorRow]!.completed = false - } - const writeText = (text: string): void => { - ensureCursorRow() - markCursorRowRewritten() const row = rows[cursorRow]! - if (cursorColumn > row.text.length) { - row.text = `${row.text}${' '.repeat(cursorColumn - row.text.length)}` - } - row.text = - cursorColumn >= row.text.length - ? `${row.text}${text}` - : `${row.text.slice(0, cursorColumn)}${text}${row.text.slice(cursorColumn + text.length)}` - cursorColumn += text.length + row.completed = false + return row + } + const writeText = (start: number, end: number): void => { + writeRetainedRow(cursorRowForEdit(), cursorColumn, normalizedChunk, start, end) + cursorColumn += end - start } const eraseLine = (mode: number): void => { - ensureCursorRow() - markCursorRowRewritten() - const row = rows[cursorRow]! - if (mode === 0) { - row.text = row.text.slice(0, cursorColumn) - } else if (mode === 1) { - const deleteCount = Math.min(cursorColumn + 1, row.text.length) - row.text = `${' '.repeat(deleteCount)}${row.text.slice(deleteCount)}` - } else if (mode === 2) { - row.text = '' - } + eraseRetainedRow(cursorRowForEdit(), mode, cursorColumn) } - let textStart = 0 - const writePendingText = (end: number): void => { - if (end > textStart) { - writeText( - end === textStart + 1 ? normalizedChunk[textStart]! : normalizedChunk.slice(textStart, end) - ) - } - textStart = end + 1 - } for (let index = 0; index < normalizedChunk.length; index += 1) { const char = normalizedChunk[index] if (char === '\n') { - writePendingText(index) ensureCursorRow() - rows[cursorRow]!.completed = true - newCompleteLines += 1 - // Own only retained text; a short erase-to-end result can otherwise pin a large span. const row = rows[cursorRow]! - const trimmed = trimTerminalLineRight(row.text) - const completedLine = ownRetainedString(trimmed) - row.text = trimmed === row.text ? completedLine : ownRetainedString(row.text) - retainNewlyCompletedLine(completedLine) + row.completed = true + newCompleteLines += 1 + retainNewlyCompletedLine(retainedRowSnapshot(row)) cursorRow += 1 cursorColumn = 0 ensureCursorRow() @@ -129,29 +115,25 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( continue } if (char === '\r') { - writePendingText(index) cursorColumn = 0 continue } if (char === '\u0008') { - writePendingText(index) cursorColumn = Math.max(0, cursorColumn - 1) continue } if (char === '\u001b') { - writePendingText(index) const parsed = parseAnsiControlSequence(normalizedChunk, index) if (!parsed) { continue } index = parsed.endIndex - textStart = index + 1 if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { continue } const firstParam = parsed.firstParam ?? 1 if (parsed.final === 'A') { - cursorRow = Math.max(0, cursorRow - firstParam) + cursorRow = Math.max(trimmedRows, cursorRow - firstParam) rows.splice(cursorRow + 1) } else if (parsed.final === 'K') { eraseLine(parsed.firstParam ?? 0) @@ -164,9 +146,18 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( } continue } + let textEnd = index + 1 + while ( + textEnd < normalizedChunk.length && + !isRedrawControlCode(normalizedChunk.charCodeAt(textEnd)) + ) { + textEnd += 1 + } + writeText(index, textEnd) + index = textEnd - 1 } - writePendingText(normalizedChunk.length) + spliceTrimmedRows() return finalizeRetainedTerminalRows( rows, cursorRow, @@ -179,16 +170,15 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( ) } +function isRedrawControlCode(code: number): boolean { + return code === 0x0a || code === 0x0d || code === 0x08 || code === 0x1b +} + export type RetainedTailRedrawCursor = { rowFromEnd: number column: number } -type RetainedTerminalRow = { - text: string - completed: boolean -} - function finalizeRetainedTerminalRows( rows: RetainedTerminalRow[], cursorRow: number, @@ -205,10 +195,10 @@ function finalizeRetainedTerminalRows( newlyCompletedLines: string[] } { let truncated = initialTruncated - let retainedRows = rows.map((row) => { - const trimmed = trimTerminalLineRight(row.text) - return { ...row, text: trimmed === row.text ? row.text : ownRetainedString(trimmed) } - }) + let retainedRows = rows.map((row) => ({ + text: retainedRowSnapshot(row), + completed: row.completed + })) if (retainedRows.length > MAX_TAIL_LINES + 1) { const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) @@ -264,7 +254,10 @@ function finalizeRetainedTerminalRows( return { lines, - // Completed rows own their storage at newline; the final partial may still reference a span. + // Why only the partial: redraw runs are owned as they are written, so rows never slice the + // chunk, but the partial is re-sliced from its own row on every chunk, so it alone can + // accumulate a backing string across frames. Owning the rows too costs 20-36% on TUI floods + // for no measured retention. partialLine: ownRetainedString(partialLine), redrawCursor, truncated, diff --git a/src/main/runtime/terminal-tail-redraw-reach.ts b/src/main/runtime/terminal-tail-redraw-reach.ts new file mode 100644 index 00000000000..bd5641d6367 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-reach.ts @@ -0,0 +1,72 @@ +import { + hasCanonicalNumericCsiParams, + parseAnsiControlSequence +} from './terminal-ansi-normalization' +import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' + +// Why module-level: this ran `new RegExp` per redraw chunk — i.e. per TUI frame per PTY. +// Safe to share because `summedUpwardCursorReach` is synchronous and non-reentrant; it resets +// `lastIndex` before every scan. +const CURSOR_UP_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[(\\d*)(?:;[\\d;]*)?A`, 'g') + +function summedUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null +): number { + let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 + CURSOR_UP_PATTERN.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = CURSOR_UP_PATTERN.exec(normalizedChunk)) !== null) { + reach += match[1] ? Number.parseInt(match[1], 10) : 1 + } + return reach +} + +// Why net: a TUI repaints each frame with a cursor-up to the same rows, so summing every CUU in +// a multi-frame chunk overstated the reach and forced the O(tail) unwindowed path. Mirrors the +// redraw model's own tokenizing, so only rows its cursor can actually visit are counted. +function netUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null +): number { + let rowFromEnd = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 + let reach = rowFromEnd + for (let index = 0; index < normalizedChunk.length; index += 1) { + const code = normalizedChunk.charCodeAt(index) + if (code === 0x0a) { + rowFromEnd -= 1 + continue + } + if (code !== 0x1b) { + continue + } + const parsed = parseAnsiControlSequence(normalizedChunk, index) + if (!parsed) { + continue + } + index = parsed.endIndex + if ( + parsed.kind === 'csi' && + parsed.final === 'A' && + hasCanonicalNumericCsiParams(parsed.params) + ) { + rowFromEnd += parsed.firstParam ?? 1 + if (rowFromEnd > reach) { + reach = rowFromEnd + } + } + } + return reach +} + +export function maxUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null, + windowLimit: number +): number { + const summed = summedUpwardCursorReach(normalizedChunk, previousRedrawCursor) + // The cheap sum is already an upper bound; only pay for the exact scan when it could still window. + return summed < windowLimit || windowLimit <= 0 + ? summed + : netUpwardCursorReach(normalizedChunk, previousRedrawCursor) +} diff --git a/src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts b/src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts new file mode 100644 index 00000000000..c4b84573dd9 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { mulberry32 } from '../../shared/agent-tui-ansi-fuzz-stream' +import { + eraseRetainedRow, + retainedRow, + retainedRowSnapshot, + writeRetainedRow +} from './terminal-tail-redraw-row' +import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer' +import { appendPerCharacterRedrawReference } from './terminal-tail-per-character-redraw.test-fixture' + +// Differential guard: the cached string/cell row must match the original one-character-at-a-time +// string row on every write, erase, and newline snapshot, including identical rewrites and +// trailing spaces/tabs. + +/** The pre-cache row semantics: per-character string writes. */ +class ReferenceRow { + constructor(public text: string) {} + + write(column: number, run: string): void { + for (const char of run.split('')) { + if (column > this.text.length) { + this.text += ' '.repeat(column - this.text.length) + } + this.text = + column >= this.text.length + ? `${this.text}${char}` + : `${this.text.slice(0, column)}${char}${this.text.slice(column + 1)}` + column += 1 + } + } + + erase(mode: number, column: number): void { + if (mode === 0) { + this.text = this.text.slice(0, column) + } else if (mode === 1) { + const count = Math.min(column + 1, this.text.length) + this.text = `${' '.repeat(count)}${this.text.slice(count)}` + } else if (mode === 2) { + this.text = '' + } + } + + snapshot(): string { + return this.text.replace(/[ \t]+$/, '') + } +} + +function randomRun(rng: () => number, reference: string, column: number): string { + const roll = rng() + const length = 1 + Math.floor(rng() * 12) + if (roll < 0.3 && column < reference.length) { + // Identical rewrite, sometimes running past the row end. + return reference.slice(column, column + length) || 'z' + } + const alphabet = roll < 0.6 ? ' \t' : 'ab \ty' + let run = '' + for (let index = 0; index < length; index += 1) { + run += alphabet[Math.floor(rng() * alphabet.length)] + } + return run +} + +function randomInitialRow(rng: () => number): string { + const kinds = ['', 'panel', 'x'.repeat(30), `panel${' '.repeat(40)}`, `a\t \t${'\t'.repeat(9)}`] + return kinds[Math.floor(rng() * kinds.length)]! +} + +describe('retained terminal row equivalence', () => { + it('matches per-character string rows across 3,000 randomized edit sequences', () => { + const rng = mulberry32(0x26316) + for (let sequence = 0; sequence < 3_000; sequence += 1) { + const initial = randomInitialRow(rng) + const row = retainedRow(initial, true) + const reference = new ReferenceRow(initial) + const ops: string[] = [] + for (let step = 0; step < 24; step += 1) { + const width = reference.text.length + const column = Math.floor(rng() * (width + 6)) + const roll = rng() + if (roll < 0.55) { + const run = randomRun(rng, reference.text, column) + ops.push(`w${column}:${JSON.stringify(run)}`) + reference.write(column, run) + writeRetainedRow(row, column, `<${run}>`, 1, run.length + 1) + } else if (roll < 0.75) { + const mode = Math.floor(rng() * 4) + ops.push(`e${mode}@${column}`) + reference.erase(mode, column) + eraseRetainedRow(row, mode, column) + } + // Newline snapshots interleave with edits, so cached snapshots must invalidate exactly. + if (rng() < 0.5) { + expect(retainedRowSnapshot(row), `${JSON.stringify(initial)} ${ops.join(' ')}`).toBe( + reference.snapshot() + ) + } + } + expect(retainedRowSnapshot(row), `${JSON.stringify(initial)} ${ops.join(' ')}`).toBe( + reference.snapshot() + ) + } + }) + + it('matches the per-character redraw model across 2,000 randomized chunk streams', () => { + const rng = mulberry32(0x263160) + const pick = (items: T[]): T => items[Math.floor(rng() * items.length)]! + const tokens: (() => string)[] = [ + () => 'abc y'.slice(0, 1 + Math.floor(rng() * 5)), + () => 'x'.repeat(Math.floor(rng() * 300)), + () => pick([' ', '\t']).repeat(Math.floor(rng() * 200)), + () => pick(['\n', '\r', '\b', '\r\n']), + () => `\x1b[${pick(['', '1', '2', '3'])}A`, + () => `\x1b[${pick(['', '0', '1', '2', '3'])}K`, + () => `\x1b[${Math.floor(rng() * 400)}${pick(['G', 'C', 'D'])}`, + () => `\x1b[1A\r${pick(['y', 'y'.repeat(33), 'panel'])}\n` + ] + type TailState = ReturnType + const advance = ( + model: typeof appendPerCharacterRedrawReference, + state: TailState, + chunk: string + ): TailState => model(state.lines, state.partialLine, chunk, false, state.redrawCursor) + for (let stream = 0; stream < 2_000; stream += 1) { + const empty: TailState = { + lines: [], + partialLine: '', + redrawCursor: null, + truncated: false, + newCompleteLines: 0, + newlyCompletedLines: [] + } + let current = empty + let reference = empty + for (let chunkIndex = 0; chunkIndex < 6; chunkIndex += 1) { + let chunk = '' + const count = Math.floor(rng() * 40) + for (let token = 0; token < count; token += 1) { + chunk += pick(tokens)() + } + current = advance(appendNormalizedToMultilineTailBufferUnwindowed, current, chunk) + reference = advance(appendPerCharacterRedrawReference, reference, chunk) + expect(current, `stream ${stream} chunk ${chunkIndex}`).toEqual(reference) + } + } + }) +}) diff --git a/src/main/runtime/terminal-tail-redraw-row.ts b/src/main/runtime/terminal-tail-redraw-row.ts new file mode 100644 index 00000000000..8bde12e3abd --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-row.ts @@ -0,0 +1,179 @@ +import { ownRetainedString } from '../../shared/own-retained-string' + +// Flat-string rows: one splice per written run, which V8 flattens with a memcpy. The caches let a +// row the cursor only revisits, or rewrites with identical text, skip its newline snapshot. +export type RetainedTerminalRow = { + text: string + /** Right-trimmed snapshot; null after a content change. */ + snapshot: string | null + /** Index after the last non-space/tab, or -1 when unknown. */ + contentEnd: number + /** Lower bound on the all-space prefix, so a repeated erase-to-start is a no-op. */ + blankPrefix: number + /** Upper bound on the backing string `text` may slice; 0 when `text` owns its storage. */ + backing: number + completed: boolean +} + +export function retainedRow(text: string, completed: boolean): RetainedTerminalRow { + // A carried row may be a snapshot that slices up to twice its length. + return { + text, + snapshot: null, + contentEnd: -1, + blankPrefix: 0, + backing: 2 * text.length, + completed + } +} + +function isTrimmedWhitespace(code: number): boolean { + return code === 0x20 || code === 0x09 +} + +/** Write source[start, end) at `column`; identical characters leave the row clean. */ +export function writeRetainedRow( + row: RetainedTerminalRow, + column: number, + source: string, + start: number, + end: number +): void { + const text = row.text + const runLength = end - start + const overlap = Math.max(0, Math.min(text.length - column, runLength)) + let offset = 0 + while ( + offset < overlap && + text.charCodeAt(column + offset) === source.charCodeAt(start + offset) + ) { + offset += 1 + } + if (offset === runLength) { + return + } + // Why own: the row can outlive this chunk as a tail line, and a long run is a slice of it. + if (column === 0 && runLength >= text.length) { + row.text = ownRetainedString(source.slice(start, end)) + row.backing = 0 + } else if (column >= text.length) { + const gap = column - text.length + const run = ownRetainedString(source.slice(start, end)) + row.text = gap > 0 ? `${text}${' '.repeat(gap)}${run}` : `${text}${run}` + } else { + // Skip the identical prefix so a write past the row end can append instead of splicing. + const at = column + offset + const run = ownRetainedString(source.slice(start + offset, end)) + if (offset === overlap) { + row.text = `${text}${run}` + } else { + row.text = `${text.slice(0, at)}${run}${text.slice(column + runLength)}` + row.backing = Math.max(row.backing, text.length) + } + } + noteWrite(row, column, column + runLength, writtenEnd(column, source, start, end)) +} + +function writtenEnd(column: number, source: string, start: number, end: number): number { + for (let index = end - 1; index >= start; index -= 1) { + if (!isTrimmedWhitespace(source.charCodeAt(index))) { + return column + index + 1 - start + } + } + return -1 +} + +/** Note a content-changing write of [column, runEnd) whose last non-whitespace ends at `written`. */ +function noteWrite( + row: RetainedTerminalRow, + column: number, + runEnd: number, + written: number +): void { + row.snapshot = null + row.blankPrefix = Math.min(row.blankPrefix, column) + const contentEnd = row.contentEnd + if (contentEnd > runEnd) { + return + } + if (contentEnd === -1) { + // Unknown content may survive past the run. + row.contentEnd = runEnd >= row.text.length ? written : -1 + } else if (written !== -1) { + row.contentEnd = written + } else if (contentEnd > column) { + row.contentEnd = -1 + } +} + +/** CSI K in modes 0/1/2 at `column`; other modes are no-ops. */ +export function eraseRetainedRow(row: RetainedTerminalRow, mode: number, column: number): void { + const text = row.text + if (mode === 2 || mode === 0) { + const keep = mode === 2 ? 0 : column + if (keep >= text.length) { + return + } + row.text = keep === 0 ? '' : text.slice(0, keep) + row.backing = keep === 0 ? 0 : Math.max(row.backing, text.length) + ownShrunkText(row) + row.snapshot = null + row.blankPrefix = Math.min(row.blankPrefix, keep) + if (row.contentEnd > keep) { + row.contentEnd = -1 + } + return + } + if (mode !== 1) { + return + } + const blankCount = Math.min(column + 1, text.length) + let firstNonBlank = row.blankPrefix + while (firstNonBlank < blankCount && text.charCodeAt(firstNonBlank) === 0x20) { + firstNonBlank += 1 + } + row.blankPrefix = Math.max(row.blankPrefix, blankCount) + if (firstNonBlank >= blankCount) { + return + } + row.text = `${' '.repeat(blankCount)}${text.slice(blankCount)}` + row.backing = Math.max(row.backing, text.length) + row.snapshot = null + if (row.contentEnd !== -1 && row.contentEnd <= blankCount) { + row.contentEnd = 0 + } +} + +/** The right-trimmed row text; rebuilt only after a content change. */ +export function retainedRowSnapshot(row: RetainedTerminalRow): string { + if (row.snapshot !== null) { + return row.snapshot + } + const text = row.text + let end = row.contentEnd + if (end === -1) { + end = text.length + while (end > 0 && isTrimmedWhitespace(text.charCodeAt(end - 1))) { + end -= 1 + } + row.contentEnd = end + } + if (end === text.length) { + ownShrunkText(row) + row.snapshot = row.text + } else { + // Why own a short trim: the slice would otherwise pin the whole padded row while retained. + const backing = Math.max(row.backing, text.length) + row.snapshot = end * 2 < backing ? ownRetainedString(text.slice(0, end)) : text.slice(0, end) + } + return row.snapshot +} + +// Why cumulative: a chain of erases that each keep half the row never crosses a per-erase +// threshold, yet nested slices still pin the original row. +function ownShrunkText(row: RetainedTerminalRow): void { + if (row.backing > 2 * row.text.length) { + row.text = ownRetainedString(row.text) + row.backing = 0 + } +} diff --git a/src/main/runtime/terminal-tail-redraw-scaling.test.ts b/src/main/runtime/terminal-tail-redraw-scaling.test.ts new file mode 100644 index 00000000000..bee99d65e49 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-scaling.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { MAX_TAIL_LINES } from './terminal-tail-limits' +import * as redrawBuffer from './terminal-tail-redraw-buffer' +import { buildRestoredTerminalTailSeed } from './terminal-tail-restore-seed' + +// #11315: an SSH TUI (Pi ask_user) repainting full-width rows pinned Electron main at 100% CPU +// because every character written behind the row end rebuilt the whole row string. + +const ESC = '\x1b' + +// One frame: climb to the top of a three-row panel and overwrite every row in place. +function overwriteFrame(width: number): string { + return [ + `${ESC}[3A\r${'a'.repeat(width)}\n`, + `\r${ESC}[5G${'b'.repeat(width)}\n`, + `\r${'c'.repeat(width)}\n` + ].join('') +} + +function redrawMillis(width: number): number { + const seed = `${'x'.repeat(width)}\n`.repeat(3) + let tail = appendNormalizedToTailBuffer([], '', seed, null) + const chunk = overwriteFrame(width).repeat(36) + let fastest = Number.POSITIVE_INFINITY + for (let round = 0; round < 8; round += 1) { + const started = performance.now() + tail = appendNormalizedToTailBuffer(tail.lines, tail.partialLine, chunk, tail.redrawCursor) + fastest = Math.min(fastest, performance.now() - started) + } + return fastest +} + +// Paint one wide row, then revisit it with cursor-up/newline; `edit` runs before each newline. +function wideRowRevisits(width: number, revisits: number, edit = ''): string { + return `${ESC}[1A\r${'x'.repeat(width)}\n${`${ESC}[1A${edit}\n`.repeat(revisits)}` +} + +// Each join of a row-sized cell array is an O(width) rebuild. +function countWideJoins(width: number, run: () => void): number { + const join = vi.spyOn(Array.prototype, 'join') + try { + run() + return join.mock.contexts.filter((cells) => Array.isArray(cells) && cells.length >= width) + .length + } finally { + join.mockRestore() + } +} + +describe('terminal tail redraw cost', () => { + it('keeps the overwritten rows exact', () => { + const width = 40 + const seed = appendNormalizedToTailBuffer([], '', `${'x'.repeat(width)}\n`.repeat(3), null) + const next = appendNormalizedToTailBuffer( + seed.lines, + seed.partialLine, + overwriteFrame(width).repeat(2), + seed.redrawCursor + ) + expect(next.lines.slice(-3)).toEqual([ + 'a'.repeat(width), + ` ${'b'.repeat(width)}`, + 'c'.repeat(width) + ]) + }) + + it('grows linearly with row width when a TUI overwrites full rows in place', () => { + // Warm both sizes so JIT tiering does not land on one side of the ratio. + redrawMillis(1000) + redrawMillis(8000) + const narrow = redrawMillis(1000) + const wide = redrawMillis(8000) + // 8x the width is ~8x the bytes; the per-character row rebuild made this ~64x. + expect(wide / narrow).toBeLessThan(24) + }) + + it('windows a multi-frame chunk by the net cursor reach, not the summed cursor-ups', () => { + const history = Array.from({ length: MAX_TAIL_LINES }, (_, index) => `history ${index}`) + const panelRows = 40 + const frame = `${ESC}[${panelRows}A${Array.from( + { length: panelRows }, + (_, row) => `\r${ESC}[2Kchoice ${row}\n` + ).join('')}` + const unwindowed = vi.spyOn(redrawBuffer, 'appendNormalizedToMultilineTailBufferUnwindowed') + try { + // 60 frames climb 2400 rows in total but never more than one panel above the end. + appendNormalizedToTailBuffer(history, '', frame.repeat(60), null) + expect(unwindowed).toHaveBeenCalledTimes(1) + expect(unwindowed.mock.calls[0]![0].length).toBeLessThan(panelRows * 2) + } finally { + unwindowed.mockRestore() + } + }) + + it('keeps only the newest MAX_TAIL_LINES rows when one chunk replays a long history', () => { + const history = Array.from({ length: MAX_TAIL_LINES }, (_, index) => `history ${index}`) + // A full-screen TUI redraw replays every line it has ever printed in one write. + const replay = Array.from({ length: 20_000 }, (_, index) => `replayed ${index}\n`).join('') + const next = appendNormalizedToTailBuffer(history, '', replay, { rowFromEnd: 3, column: 0 }) + expect(next.lines).toHaveLength(MAX_TAIL_LINES) + expect(next.lines.at(-1)).toBe('replayed 19999') + expect(next.truncated).toBe(true) + }) + + it('does not rebuild an unchanged wide row on every cursor-up/newline revisit', () => { + const width = 32_000 + let tail: ReturnType | undefined + const joins = countWideJoins(width, () => { + tail = appendNormalizedToTailBuffer([], '', wideRowRevisits(width, 4_000), null) + }) + expect(joins).toBe(0) + expect(tail?.lines).toEqual(['x'.repeat(width)]) + }) + + it('never joins a wide row when every revisit repeats the same one-cell edit', () => { + const width = 64_000 + let tail: ReturnType | undefined + const joins = countWideJoins(width, () => { + tail = appendNormalizedToTailBuffer([], '', wideRowRevisits(width, 2_000, '\ry'), null) + }) + expect(joins).toBe(0) + expect(tail?.lines).toEqual([`y${'x'.repeat(width - 1)}`]) + }) + + it('seeds a restored tail without rebuilding a revisited wide row', () => { + const width = 32_000 + let seedLines: string[] | undefined + const joins = countWideJoins(width, () => { + seedLines = buildRestoredTerminalTailSeed(wideRowRevisits(width, 4_000))?.lines + }) + expect(joins).toBe(0) + expect(seedLines).toEqual(['x'.repeat(width)]) + }) +}) diff --git a/src/main/runtime/terminal-tail-redraw-wall-time.test.ts b/src/main/runtime/terminal-tail-redraw-wall-time.test.ts new file mode 100644 index 00000000000..b570532be92 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-wall-time.test.ts @@ -0,0 +1,206 @@ +import { writeFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { readPtyTail, runtimeWithLeaf } from './orca-runtime-pty-leaf.test-fixture' +import { normalizeTerminalChunk } from './terminal-ansi-normalization' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { appendPerCharacterRedrawReference } from './terminal-tail-per-character-redraw.test-fixture' +import * as redrawBuffer from './terminal-tail-redraw-buffer' +import { buildRestoredTerminalTailSeed } from './terminal-tail-restore-seed' + +// #11315 review probes, timed against the vendored pre-#11315 row model on all three main-thread +// entry points. ORCA_TAIL_WALL_TIME_FULL=1 runs them at restore-cap scale; ORCA_TAIL_WALL_TIME_TABLE +// names a file that receives the measured table. + +const ESC = '\x1b' +const FULL = process.env.ORCA_TAIL_WALL_TIME_FULL === '1' +const WIDTH = FULL ? 128_000 : 8_000 +const BUDGET = FULL ? 256 * 1024 - 16 : 32 * 1024 +const FLOOD_CHUNKS = FULL ? 4 : 1 +// The review probes used 1,500-4,000 revisits; more only lengthens the old model's run. +const MAX_REVISITS = 4_000 +// Generous so a loaded CI host does not flake; the measured bar is 1.5x. +const MAX_RATIO = 3 +const SLACK_MS = 30 +const ROUNDS = Number(process.env.ORCA_TAIL_WALL_TIME_ROUNDS ?? 3) + +type Probe = { name: string; chunks: string[] } + +const letter = (index: number): string => String.fromCharCode(0x61 + (index % 26)) + +/** One wide row, then as many revisits as fit the byte budget. */ +function revisits(name: string, row: string, unit: (index: number) => string): Probe { + const count = Math.min( + MAX_REVISITS, + Math.max(1, Math.floor((BUDGET - row.length) / unit(0).length)) + ) + const parts = Array.from({ length: count }, (_, index) => unit(index)) + return { name, chunks: [`${ESC}[1A\r${row}\n${parts.join('')}`] } +} + +/** 64 KiB live chunks against a persistent tail holding one wide row. */ +function flood(name: string, width: number, unit: (index: number) => string): Probe { + const perChunk = Math.floor((64 * 1024) / unit(0).length) + const chunks = [`${'x'.repeat(width)}\n`] + for (let chunk = 0; chunk < FLOOD_CHUNKS; chunk += 1) { + chunks.push(Array.from({ length: perChunk }, (_, index) => unit(index + chunk)).join('')) + } + return { name, chunks } +} + +function probes(): Probe[] { + const x = 'x'.repeat(WIDTH) + const half = Math.floor(WIDTH / 2) + const rows = [`${'x'.repeat(half)}\n${'y'.repeat(half)}\n`] + const twoRowCount = Math.floor((BUDGET - WIDTH) / 10) + rows.push( + Array.from({ length: twoRowCount }, (_, i) => `${ESC}[2A${letter(i)}\n${letter(i)}\n`).join('') + ) + const panel = (label: string, width: number): string => + `\r${ESC}[2K${label} ${'─'.repeat(width - label.length - 1)}\n` + const frame = `${ESC}[4A${['title', 'option a', 'option b', 'hint'].map((l) => panel(l, 200)).join('')}` + return [ + revisits('cursor-only, trailing spaces', `panel${' '.repeat(WIDTH - 5)}`, () => `${ESC}[1A\n`), + revisits('cursor-only, trailing tabs', `panel${'\t'.repeat(WIDTH - 5)}`, () => `${ESC}[1A\n`), + revisits('33 identical edits per revisit', x, () => `${ESC}[1A${'\ry'.repeat(33)}\n`), + revisits('alternating one-character edit', x, (i) => `${ESC}[1A\r${i % 2 ? 'a' : 'b'}\n`), + revisits( + 'changing edit, trailing spaces', + `p${' '.repeat(WIDTH - 1)}`, + (i) => `${ESC}[A${letter(i)}\n` + ), + revisits( + 'changing edit, trailing tabs', + `p${'\t'.repeat(WIDTH - 1)}`, + (i) => `${ESC}[A${letter(i)}\n` + ), + revisits('ESC[4000G write', x, (i) => `${ESC}[A${ESC}[4000G${letter(i)}\n`), + revisits('ESC[1K erase', x, (i) => `${ESC}[A\r${letter(i)}${ESC}[4000G${ESC}[1K\n`), + revisits('ESC[K then write', x, (i) => `${ESC}[A${ESC}[2000G${ESC}[K${letter(i)}\n`), + { + name: 'ESC[1K erase, no newline', + chunks: [ + `${x}${Array.from({ length: Math.floor((BUDGET - WIDTH) / 12) }, (_, i) => `\r${letter(i)}${ESC}[4000G${ESC}[1K`).join('')}` + ] + }, + { + name: 'one row, no newline', + chunks: [ + `${x}${Array.from({ length: Math.floor((BUDGET - WIDTH) / 2) }, (_, i) => `\r${letter(i)}`).join('')}` + ] + }, + { name: 'two alternating rows', chunks: [rows.join('')] }, + { name: 'write/backspace runs', chunks: [`${'x'.repeat(4_000)}\r${'ab\b'.repeat(2_000)}`] }, + { + name: '#11315 panel repaint flood', + chunks: [`${'\n'.repeat(4)}${frame.repeat(Math.floor(BUDGET / frame.length))}`] + }, + ...[4_000, 16_000, 128_000].flatMap((width) => [ + flood(`live 64 KiB flood, ${width} cols`, width, (i) => `${ESC}[A${letter(i)}\n`), + flood( + `live 64 KiB ESC[1K flood, ${width} cols`, + width, + (i) => `${ESC}[A\r${letter(i)}${ESC}[4000G${ESC}[1K\n` + ) + ]) + ] +} + +type Entry = { name: string; run: (chunks: string[]) => { ms: number; result: unknown } } + +function timed(run: () => unknown): { ms: number; result: unknown } { + const started = performance.now() + const result = run() + return { ms: performance.now() - started, result } +} + +const entries: Entry[] = [ + { name: 'restore', run: (chunks) => timed(() => buildRestoredTerminalTailSeed(chunks.join(''))) }, + { + name: 'append', + run: (chunks) => + timed(() => { + let tail: Pick< + ReturnType, + 'lines' | 'partialLine' | 'redrawCursor' + > = { lines: [], partialLine: '', redrawCursor: null } + let pending = '' + for (const chunk of chunks) { + const normalized = normalizeTerminalChunk(chunk, pending) + pending = normalized.pendingAnsi + tail = appendNormalizedToTailBuffer( + tail.lines, + tail.partialLine, + normalized.text, + tail.redrawCursor + ) + } + return tail + }) + }, + { + name: 'onPtyData', + run: (chunks) => { + const ptyId = 'pty-wall-time' + const { runtime } = runtimeWithLeaf(ptyId) + const measured = timed(() => { + chunks.forEach((chunk, index) => runtime.onPtyData(ptyId, chunk, index + 1)) + return readPtyTail(runtime, ptyId) + }) + void runtime.onPtyExit(ptyId, 0) + return measured + } + } +] + +function withPerCharacterRows(run: () => T): T { + const reference = vi + .spyOn(redrawBuffer, 'appendNormalizedToMultilineTailBufferUnwindowed') + .mockImplementation(appendPerCharacterRedrawReference) + try { + return run() + } finally { + reference.mockRestore() + } +} + +describe('terminal tail redraw wall time against the per-character row model', () => { + it( + 'is no slower than the old row model on any review probe', + () => { + const table: string[] = [] + const tablePath = process.env.ORCA_TAIL_WALL_TIME_TABLE + const only = process.env.ORCA_TAIL_WALL_TIME_PROBE + for (const probe of probes().filter((p) => !only || p.name.includes(only))) { + for (const entry of entries) { + let current = Number.POSITIVE_INFINITY + let reference = Number.POSITIVE_INFINITY + // Alternate the order so JIT warm-up and GC debt do not favor one model. + for (let round = 0; round < ROUNDS; round += 1) { + const runReference = (): ReturnType => + withPerCharacterRows(() => entry.run(probe.chunks)) + const before = round % 2 === 1 ? runReference() : null + const now = entry.run(probe.chunks) + const after = before ?? runReference() + expect(now.result, `${probe.name} via ${entry.name}`).toEqual(after.result) + current = Math.min(current, now.ms) + reference = Math.min(reference, after.ms) + } + table.push( + `| ${probe.name} | ${entry.name} | ${reference.toFixed(1)} | ${current.toFixed(1)} |` + ) + if (tablePath) { + writeFileSync( + tablePath, + ['| Probe | Entry | Old ms | Now ms |', '|---|---|---:|---:|', ...table].join('\n') + ) + } + expect( + current, + `${probe.name} via ${entry.name}: ${current} vs ${reference} ms` + ).toBeLessThan(reference * MAX_RATIO + SLACK_MS) + } + } + }, + FULL ? 1_800_000 : 180_000 + ) +}) diff --git a/src/main/runtime/terminal-tail-row-retention.test.ts b/src/main/runtime/terminal-tail-row-retention.test.ts index e49fd7fdd0c..5ec5ede2c09 100644 --- a/src/main/runtime/terminal-tail-row-retention.test.ts +++ b/src/main/runtime/terminal-tail-row-retention.test.ts @@ -61,6 +61,45 @@ describe('retained terminal tail row storage', () => { expect(lines.at(-1)).toBe(`step ${chunkCount - 1} done`) }) + it('does not pin a padded redraw row behind its trimmed text', () => { + let tail = appendNormalizedToTailBuffer([], '', 'warm\n') + const before = collectHeap() + for (let index = 0; index < 250; index += 1) { + const text = `\x1b[0Arow-${String(index).padStart(5, '0')}-completed${' '.repeat(64_000)}\n` + tail = appendNormalizedToTailBuffer(tail.lines, tail.partialLine, text, tail.redrawCursor) + } + const retained = collectHeap() - before + + expect(tail.lines.at(-1)).toBe('row-00249-completed') + // Sliced from the padded row, 250 short lines pinned about 16 MB. + expect(retained).toBeLessThan(4 * 1024 * 1024) + }) + + it.each([ + ['half', 0.5], + ['quarter', 0.75] + ])('does not pin a 64 KiB row that chained erases shrink by a %s at a time', (_, ratio) => { + const shrinkChunk = (index: number): string => { + const char = String.fromCharCode(65 + (index % 26)) + let chunk = `\x1b[0A${char.repeat(65_536)}` + // Each \r + identical prefix + CSI K keeps at least half of the current row. + for (let keep = Math.floor(65_536 * ratio); keep >= 16; keep = Math.floor(keep * ratio)) { + chunk += `\r${char.repeat(keep)}\x1b[K` + } + return `${chunk}\n` + } + let lines = appendNormalizedToTailBuffer([], '', shrinkChunk(0)).lines + const before = collectHeap() + for (let index = 1; index <= 200; index += 1) { + lines = appendNormalizedToTailBuffer(lines, '', shrinkChunk(index)).lines + } + const retained = collectHeap() - before + + expect(lines.at(-1)).toMatch(/^[A-Z]{1,20}$/) + // Nested slices pinned each completed row's 64 KiB original: about 13 MB. + expect(retained).toBeLessThan(4 * 1024 * 1024) + }) + it('routes every retained row and partial line through ownRetainedString', () => { const own = vi.spyOn(ownership, 'ownRetainedString') try { @@ -108,9 +147,8 @@ describe('retained terminal tail row storage', () => { expect(redrawn.lines).toEqual(['row one']) expect(redrawn.partialLine).toBe('rewritten two') - // Only the final partial is retained; carried rows need no copies. - expect(own).toHaveBeenCalledTimes(1) - expect(own).toHaveBeenLastCalledWith('rewritten two') + // Only the written run and the partial are owned; carried rows are never re-copied. + expect(own.mock.calls).toEqual([['rewritten two'], ['rewritten two']]) } finally { own.mockRestore() } diff --git a/src/main/serve-update-handoff.ts b/src/main/serve-update-handoff.ts index ab831a90d49..221c7c027a6 100644 --- a/src/main/serve-update-handoff.ts +++ b/src/main/serve-update-handoff.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { app } from 'electron' +import { quitProcess } from './startup/process-quit-request' import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath, @@ -79,7 +80,7 @@ export function installServeSupervisorDisconnectQuit( if (!isServeMode || !hasServeUpdateSupervisor()) { return () => undefined } - const quit = (): void => app.quit() + const quit = (): void => quitProcess() parent.once('disconnect', quit) return () => parent.off('disconnect', quit) } diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index f84baa16a73..18c79e26ed7 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -523,7 +523,7 @@ describe('a retained source is never deleted', () => { [ 'a folder workspace on a folder-only host', () => { - store.removeProject('repo-1') + store.removeProjectForHost('repo-1', `ssh:${TARGET.id}`) const group = store.createProjectGroup({ name: 'folders', parentPath: '/srv/folders', @@ -640,7 +640,7 @@ describe('a delta move against a rollback of an update taken before it', () => { it('protects a folder-only delta the same way', async () => { await convertedThenChangedOnOlderBuild() - store.removeProject('repo-2') + store.removeProjectForHost('repo-2', `ssh:${TARGET.id}`) const group = store.createProjectGroup({ name: 'folders', parentPath: '/srv/folders', diff --git a/src/main/ssh/orcad-remote-node-runtime-report.ts b/src/main/ssh/orcad-remote-node-runtime-report.ts index 5fb05424ee7..6e4e75eb7be 100644 --- a/src/main/ssh/orcad-remote-node-runtime-report.ts +++ b/src/main/ssh/orcad-remote-node-runtime-report.ts @@ -1,4 +1,5 @@ /** What the host-side runtime-store scripts print, shared by the POSIX and Windows installers. */ +import { RelayHostAnsweredError } from './ssh-relay-host-answered-failure' export const REMOTE_NODE_RUNTIME_READY = 'ORCA_NODE_RUNTIME_READY' export const REMOTE_NODE_RUNTIME_MISSING = 'ORCA_NODE_RUNTIME_MISSING' @@ -9,7 +10,7 @@ export const REMOTE_NODE_RUNTIME_EXIT_PREFIX = 'ORCA_RUNTIME_EXIT=' export const REMOTE_NODE_RUNTIME_VERIFIED_MARKER = '.verified' /** The pinned runtime ran on the host and did not report its version: a host verdict, with evidence. */ -export class RemoteNodeRuntimeSelfTestError extends Error { +export class RemoteNodeRuntimeSelfTestError extends RelayHostAnsweredError { constructor( readonly exitStatus: number | null, readonly output: string @@ -22,7 +23,7 @@ export class RemoteNodeRuntimeSelfTestError extends Error { } /** The host answered, and what it answered is that something rewrote or removed our verified bytes. */ -export class RemoteNodeRuntimeSecurityModifiedError extends Error { +export class RemoteNodeRuntimeSecurityModifiedError extends RelayHostAnsweredError { constructor(readonly detail: string) { super(`Security software on the host removed or modified the pinned Node runtime: ${detail}`) this.name = 'RemoteNodeRuntimeSecurityModifiedError' @@ -65,6 +66,8 @@ export function assertRemoteNodeRuntimePromoted(promoted: string): void { ) } if (promoted.trim().split(/\r?\n/).at(-1)?.trim() !== REMOTE_NODE_RUNTIME_READY) { - throw new Error(`The host did not verify the pinned Node runtime: ${promoted.trim()}`) + throw new RelayHostAnsweredError( + `The host did not verify the pinned Node runtime: ${promoted.trim()}` + ) } } diff --git a/src/main/ssh/orcad-remote-node-runtime-windows.ts b/src/main/ssh/orcad-remote-node-runtime-windows.ts index e0b568d8509..9f386dd0493 100644 --- a/src/main/ssh/orcad-remote-node-runtime-windows.ts +++ b/src/main/ssh/orcad-remote-node-runtime-windows.ts @@ -80,11 +80,25 @@ export function windowsNodeRuntimeProbeCommand( } /** Cheap warm-path check: a published runtime has its marker and node.exe; no re-hash. */ -export function windowsNodeRuntimePresentCommand(runtimeDir: string): string { +/** `runs`: also run node.exe, reporting a refusal as promotion does (see the POSIX twin). */ +export function windowsNodeRuntimePresentCommand(runtimeDir: string, runs = false): string { + const ready = runs + ? [ + "$runOut = ''; $runStatus = $null", + 'try { $runOut = ((& $exe --version 2>&1) | ForEach-Object { "$_" }) -join "`n"; $runStatus = $LASTEXITCODE } catch { $runStatus = -1; $runOut = $_.Exception.Message }', + `if (($runStatus -eq 0) -and ($runOut.Trim() -eq ${powerShellLiteral(`v${NODE_RUNTIME_PIN.version}`)})) { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)} } else {`, + `Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_SELFTEST_FAILED)}`, + `Write-Output (${powerShellLiteral(REMOTE_NODE_RUNTIME_EXIT_PREFIX)} + $runStatus)`, + 'Write-Output ($runOut.Substring(0, [Math]::Min(4000, $runOut.Length)))', + '}' + ].join('\n') + : `Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)}` return powerShellCommand( [ ...runtimeVariables(runtimeDir), - `if ((Test-Path -LiteralPath $verified -PathType Leaf) -and (Test-Path -LiteralPath $exe -PathType Leaf)) { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)} } else { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_MISSING)} }` + `if ((Test-Path -LiteralPath $verified -PathType Leaf) -and (Test-Path -LiteralPath $exe -PathType Leaf)) {`, + ready, + `} else { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_MISSING)} }` ].join('\n') ) } diff --git a/src/main/ssh/orcad-remote-node-runtime.ts b/src/main/ssh/orcad-remote-node-runtime.ts index 42b0680628f..8587fe04545 100644 --- a/src/main/ssh/orcad-remote-node-runtime.ts +++ b/src/main/ssh/orcad-remote-node-runtime.ts @@ -133,20 +133,32 @@ export function probeRemoteNodeRuntimeCommand( } /** Cheap warm-path check: a published runtime has its marker and an executable; no re-hash. */ +/** + * READY when the verified runtime is in place. With `run`, the same command also runs it and + * reports a refusal the way promotion does, so a host whose exec policy or libraries changed since + * the install is caught before a launch, at no extra round trip. + */ export function remoteNodeRuntimePresentCommand( host: RemoteHostPlatform, - runtimeDir: string + runtimeDir: string, + run = false ): string { if (isWindowsRemoteHost(host)) { - return windowsNodeRuntimePresentCommand(runtimeDir) + return windowsNodeRuntimePresentCommand(runtimeDir, run) } const executable = shellEscape( joinRemotePath(host, runtimeDir, ...ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE.split('/')) ) const verified = shellEscape(joinRemotePath(host, runtimeDir, VERIFIED_MARKER)) + const ready = run + ? `{ orca_rt_out=$(${executable} --version 2>&1); orca_rt_status=$?; ` + + `if [ "$orca_rt_out" = ${shellEscape(`v${NODE_RUNTIME_PIN.version}`)} ]; then echo ${REMOTE_NODE_RUNTIME_READY}; ` + + `else echo ${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}; echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_rt_status"; ` + + `printf '%s\\n' "$orca_rt_out" | head -c 4000; fi; }` + : `echo ${REMOTE_NODE_RUNTIME_READY}` return ( `if [ -f ${verified} ] && [ -x ${executable} ]; ` + - `then echo ${REMOTE_NODE_RUNTIME_READY}; else echo ${REMOTE_NODE_RUNTIME_MISSING}; fi` + `then ${ready}; else echo ${REMOTE_NODE_RUNTIME_MISSING}; fi` ) } diff --git a/src/main/ssh/relay-socket-path-limit.test.ts b/src/main/ssh/relay-socket-path-limit.test.ts index 8d58fbb10e0..1208c470d5d 100644 --- a/src/main/ssh/relay-socket-path-limit.test.ts +++ b/src/main/ssh/relay-socket-path-limit.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-opencode-runtime', () => ({ ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') @@ -169,8 +169,11 @@ describe('remote unix socket path limit', () => { }) describe('relay launch with a long remote $HOME', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') }) it('keeps the launched socket path inside the remote sun_path limit', async () => { diff --git a/src/main/ssh/relay-socket-path-limit.ts b/src/main/ssh/relay-socket-path-limit.ts index 34ce21565b6..328f42e1f8f 100644 --- a/src/main/ssh/relay-socket-path-limit.ts +++ b/src/main/ssh/relay-socket-path-limit.ts @@ -11,7 +11,7 @@ */ import { createHash } from 'node:crypto' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { unixSocketPathByteLimit } from '../../shared/unix-socket-path-limit' +import { unixSocketPathByteLimit, unixSocketPathFits } from '../../shared/unix-socket-path-limit' export function remoteUnixSocketPathByteLimit(host: RemoteHostPlatform): number | null { if (isWindowsRemoteHost(host)) { @@ -21,8 +21,10 @@ export function remoteUnixSocketPathByteLimit(host: RemoteHostPlatform): number } export function remoteSocketPathFitsLimit(host: RemoteHostPlatform, sockPath: string): boolean { - const limit = remoteUnixSocketPathByteLimit(host) - return limit === null || Buffer.byteLength(sockPath, 'utf8') <= limit + return ( + isWindowsRemoteHost(host) || + unixSocketPathFits(sockPath, host.os === 'darwin' ? 'darwin' : 'linux') + ) } /** Fixed-length, per-uid base. `/tmp` is the only POSIX directory whose length is not user-dependent. */ diff --git a/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts new file mode 100644 index 00000000000..a0b540777e3 --- /dev/null +++ b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts @@ -0,0 +1,26 @@ +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' +import type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' + +/** A bridged SSH CLI whose host the user opted in to control Orca, i.e. the pre-scope behaviour. */ +export const CONTROL_GRANTED_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { + kind: 'ssh-bridge', + targetId: 'box-1', + remoteCliControl: true +} + +/** The default bridge: reaches only that host's own terminals. */ +export const HOST_BOUND_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { + kind: 'ssh-bridge', + targetId: 'box-1', + remoteCliControl: false +} + +/** Runs the bridge as a host the user opted in, for tests of the surface such a host reaches. */ +export function runControlGrantedRemoteOrcaCli( + runtime: OrcaRuntimeService, + request: Omit +): Promise { + return runRemoteOrcaCli(runtime, { ...request, callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE }) +} diff --git a/src/main/ssh/ssh-host-node-runtime-mode.ts b/src/main/ssh/ssh-host-node-runtime-mode.ts new file mode 100644 index 00000000000..bbe7c1e8757 --- /dev/null +++ b/src/main/ssh/ssh-host-node-runtime-mode.ts @@ -0,0 +1,17 @@ +/** + * Hosts whose relay last launched on the opt-in host-Node runtime (`legacy`: host Node plus an + * npm install on the host). That path is outside the supported ladder, so the card says so. + */ +const hostNodeTargets = new Set() + +export function recordSshRelayRuntimeStep(targetId: string, hostNode: boolean): void { + if (hostNode) { + hostNodeTargets.add(targetId) + } else { + hostNodeTargets.delete(targetId) + } +} + +export function isSshRelayOnHostNodeRuntime(targetId: string): boolean { + return hostNodeTargets.has(targetId) +} diff --git a/src/main/ssh/ssh-hostile-host-cells.test.ts b/src/main/ssh/ssh-hostile-host-cells.test.ts index 0b1ffb85d85..498d569bac3 100644 --- a/src/main/ssh/ssh-hostile-host-cells.test.ts +++ b/src/main/ssh/ssh-hostile-host-cells.test.ts @@ -184,10 +184,23 @@ describe('hostileHostCellViolations', () => { target: 'linux-x64-glibc', unavailableReason: 'home_noexec', deployError: 'home directory is mounted noexec', - refusals: [{ step: 'A', reason: 'noexec' }], + refusals: [ + { step: 'A', reason: 'noexec' }, + { step: 'legacy', reason: 'host_node_missing' } + ], forbiddenToolCalls: [] } expect(hostileHostCellViolations(noexec, observed)).toEqual([]) + // The fallback's read-only npm probe is allowed; an install is not. + expect( + hostileHostCellViolations(noexec, { ...observed, forbiddenToolCalls: ['npm --version'] }) + ).toEqual([]) + expect( + hostileHostCellViolations(noexec, { + ...observed, + forbiddenToolCalls: ['npm --version', 'npm ci --omit=dev'] + }) + ).toEqual(['toolchain invoked: npm ci --omit=dev']) expect( hostileHostCellViolations(noexec, { ...observed, @@ -198,7 +211,7 @@ describe('hostileHostCellViolations', () => { ] }) ).toEqual([ - 'refusals A:noexec > C:noexec, expected A:noexec', + 'refusals A:noexec > C:noexec, expected A:noexec > legacy:host_node_missing', 'rung D reason no_runtime, expected home_noexec' ]) }) @@ -221,11 +234,11 @@ describe('hostileHostCellViolations', () => { { step: 'B', reason: 'artifacts_unavailable' }, { step: 'C', reason: 'libc_floor' } ], - forbiddenToolCalls: ['npm --version'] + forbiddenToolCalls: ['npm --version', 'npm install', 'make BUILDTYPE=Release -C build'] }) ).toEqual([ 'refusals A:libc_floor > B:artifacts_unavailable > C:libc_floor, expected A:libc_floor', - 'toolchain invoked: npm --version', + 'toolchain invoked: npm install; make BUILDTYPE=Release -C build', 'deploy failed: Node.js was not found on the remote host', 'settled on nothing, expected B' ]) diff --git a/src/main/ssh/ssh-hostile-host-cells.ts b/src/main/ssh/ssh-hostile-host-cells.ts index f15d0daf218..ed07ae9f9fd 100644 --- a/src/main/ssh/ssh-hostile-host-cells.ts +++ b/src/main/ssh/ssh-hostile-host-cells.ts @@ -38,8 +38,6 @@ export type HostileHostExpectation = reason: RemoteRuntimeUnavailableReason refusals: readonly RungRefusal[] } - /** The ladder fell through to the host-npm path, which cannot run on this host either. */ - | { outcome: 'legacy_failed'; refusals: readonly RungRefusal[] } /** The host opted out: the ladder never runs and nothing enters the pinned runtime store. */ | { outcome: 'legacy_opt_out' } @@ -138,7 +136,7 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ }, { // The musl Node links libstdc++, so its self-test proves the missing library; B has no - // compat runtime yet and C finds no host Node. + // compat runtime yet, C finds no host Node, and the host-Node fallback proves it has none. id: 'alpine-musl-no-libstdcxx', dockerfile: [`FROM ${ALPINE_3_20}`, ALPINE_SSHD], expect: { @@ -147,12 +145,14 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ refusals: [ { step: 'A', reason: 'missing_lib' }, { step: 'B', reason: 'runtime_unavailable' }, - { step: 'C', reason: 'host_node_missing' } + { step: 'C', reason: 'host_node_missing' }, + { step: 'legacy', reason: 'host_node_missing' } ] } }, { - // A host Node 20 does not help: rung C would load its addons from the same noexec tree. + // A proved noexec skips B and C (same tree) for the host-Node fallback, which finds Node 20 + // without npm; that answered "no Node" is the only way to D. id: 'ubuntu2204-node20-noexec-home', dockerfile: [ `FROM ${NODE_20} AS host-node`, @@ -166,7 +166,10 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ expect: { outcome: 'unavailable', reason: 'home_noexec', - refusals: [{ step: 'A', reason: 'noexec' }] + refusals: [ + { step: 'A', reason: 'noexec' }, + { step: 'legacy', reason: 'host_node_missing' } + ] } }, { @@ -254,7 +257,7 @@ export function selectHostileHostCells( } export type HostileHostObservation = { - /** The rung the ladder settled on, or null when it never settled (legacy failure). */ + /** The rung the ladder settled on, or null when it never settled. */ settledRung: SshRemoteRuntimeRung | null /** The server target of a launched relay, when the ladder resolved one. */ target: ServerTarget | null @@ -282,10 +285,11 @@ export function hostileHostCellViolations( `refusals ${describeRefusals(observed.refusals)}, expected ${describeRefusals(expectedRefusals)}` ) } - // Why every ladder outcome: no rung reached before legacy may reach for npm or a compiler. - const legacy = expect.outcome === 'legacy_failed' || expect.outcome === 'legacy_opt_out' - if (!legacy && observed.forbiddenToolCalls.length > 0) { - violations.push(`toolchain invoked: ${observed.forbiddenToolCalls.join('; ')}`) + // Why every ladder outcome: nothing may install or compile. A version probe is read-only; the + // host-Node fallback asks `npm --version` before it settles D on a host with no usable npm. + const toolchainActions = observed.forbiddenToolCalls.filter((call) => !isReadOnlyToolProbe(call)) + if (expect.outcome !== 'legacy_opt_out' && toolchainActions.length > 0) { + violations.push(`toolchain invoked: ${toolchainActions.join('; ')}`) } switch (expect.outcome) { case 'launched': @@ -309,14 +313,6 @@ export function hostileHostCellViolations( ) } break - case 'legacy_failed': - if (observed.settledRung !== null) { - violations.push(`settled on ${observed.settledRung}, expected the host-npm path to fail`) - } - if (!observed.deployError) { - violations.push('deploy succeeded on a host with no runnable runtime') - } - break case 'legacy_opt_out': // The host-Node path's own verdict depends on the host's Node, so only the ladder is judged. if (observed.settledRung !== null) { @@ -327,6 +323,11 @@ export function hostileHostCellViolations( return violations } +/** A shim call that only asks a tool its version, as ` --version` or ` -v`. */ +export function isReadOnlyToolProbe(call: string): boolean { + return /^\S+ (?:--version|-v)$/.test(call.trim()) +} + export function parseForbiddenToolLog(contents: string): string[] { return contents .split('\n') diff --git a/src/main/ssh/ssh-hostile-host-test-fixture.ts b/src/main/ssh/ssh-hostile-host-test-fixture.ts index dedaea2d5e8..be52dae1395 100644 --- a/src/main/ssh/ssh-hostile-host-test-fixture.ts +++ b/src/main/ssh/ssh-hostile-host-test-fixture.ts @@ -300,7 +300,7 @@ export function hostileHostSshTarget(target: HostileHostTarget): SshTarget { port: target.port, username: target.username, identityFile: target.identityFile, - identitiesOnly: true, - remoteRuntime: 'pinned-node' + // No remoteRuntime: the matrix proves the ladder a host gets by default. + identitiesOnly: true } } diff --git a/src/main/ssh/ssh-relay-answered-failure-producers.test.ts b/src/main/ssh/ssh-relay-answered-failure-producers.test.ts new file mode 100644 index 00000000000..1daee192722 --- /dev/null +++ b/src/main/ssh/ssh-relay-answered-failure-producers.test.ts @@ -0,0 +1,208 @@ +/** + * The real producers behind the relay ladder's "did the host answer?" question: system-ssh + * directory upload, system sftp, and the warm runtime check. Each runs real processes where it can. + */ +import { randomBytes } from 'node:crypto' +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import type { SshTarget } from '../../shared/ssh-types' +import { runProcess, runProcessSync } from '../../shared/child-process/run-process' +import { + REMOTE_NODE_RUNTIME_READY, + REMOTE_NODE_RUNTIME_VERIFIED_MARKER, + RemoteNodeRuntimeSelfTestError, + assertRemoteNodeRuntimePromoted +} from './orcad-remote-node-runtime-report' +import { remoteNodeRuntimePresentCommand } from './orcad-remote-node-runtime' +import { isAnsweredHostFailure } from './ssh-relay-host-answered-failure' +import { classifyPinnedRuntimeFailure } from './ssh-relay-runtime-self-test' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { findSystemSsh } from './system-ssh-binary' +import { uploadDirectoryViaSystemSsh } from './system-ssh-file-transfer' +import { runSftpBatch } from './system-ssh-sftp-transfer' + +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal>()), + runProcess: vi.fn() +})) +vi.mock('./system-ssh-args', () => ({ buildSshArgs: () => ['--', 'host'] })) +vi.mock('./system-ssh-binary', () => ({ findSystemSsh: vi.fn(() => '/usr/bin/ssh') })) + +const posix = process.platform !== 'win32' +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh args are mocked, so the target is never read. +const target = {} as SshTarget +let scratch: string + +beforeEach(async () => { + scratch = await mkdtemp(join(tmpdir(), 'orca-answered-producers-')) +}) +afterEach(async () => { + vi.unstubAllEnvs() + await rm(scratch, { recursive: true, force: true }) +}) + +async function script(name: string, body: string): Promise { + const path = join(scratch, name) + await writeFile(path, `#!/bin/sh\n${body}\n`) + await chmod(path, 0o755) + return path +} + +async function payload(): Promise { + const dir = join(scratch, 'payload') + await mkdir(dir) + await writeFile(join(dir, 'runtime.tar.gz'), 'fixture') + return dir +} + +describe.runIf(posix)('system-ssh directory upload', () => { + it('reports a remote tar ENOSPC as the host answering', async () => { + vi.mocked(findSystemSsh).mockReturnValue( + await script( + 'ssh', + 'cat >/dev/null\necho "tar: Cannot write: No space left on device" >&2\nexit 2' + ) + ) + const error = await uploadDirectoryViaSystemSsh(target, await payload(), '/remote').catch( + (e: unknown) => e + ) + expect(String(error)).toContain('No space left on device') + expect(isAnsweredHostFailure(error)).toBe(true) + }) + + it('keeps the remote ENOSPC when the far end closes mid-stream and local tar then fails to write', async () => { + // Why 8 MiB of noise: tar is still writing when ssh exits, so it fails downstream (BSD tar: + // exit 1 "Write error", no signal) instead of finishing first. + const dir = join(scratch, 'large') + await mkdir(dir) + await writeFile(join(dir, 'runtime.bin'), randomBytes(8 * 1024 * 1024)) + vi.mocked(findSystemSsh).mockReturnValue( + await script( + 'ssh', + 'head -c 1024 >/dev/null\necho "tar: Cannot write: No space left on device" >&2\nexit 2' + ) + ) + for (let attempt = 0; attempt < 3; attempt++) { + const error = await uploadDirectoryViaSystemSsh(target, dir, '/remote').catch( + (e: unknown) => e + ) + expect(String(error)).toContain('No space left on device') + expect(isAnsweredHostFailure(error)).toBe(true) + } + }) + + it("keeps ssh's own exit 255 (contact lost) retryable", async () => { + vi.mocked(findSystemSsh).mockReturnValue( + await script('ssh', 'cat >/dev/null\necho "Connection reset by peer" >&2\nexit 255') + ) + const error = await uploadDirectoryViaSystemSsh(target, await payload(), '/remote').catch( + (e: unknown) => e + ) + expect(isAnsweredHostFailure(error)).toBe(false) + }) + + it('blames a local tar failure, not the remote tar that saw a truncated stream', async () => { + vi.mocked(findSystemSsh).mockReturnValue( + await script('ssh', 'cat >/dev/null\necho "tar: Unexpected EOF in archive" >&2\nexit 2') + ) + const error = await uploadDirectoryViaSystemSsh( + target, + join(scratch, 'missing'), + '/remote' + ).catch((e: unknown) => e) + expect(String(error)).toContain('local tar relay upload') + expect(isAnsweredHostFailure(error)).toBe(false) + }) +}) + +describe('system sftp batch', () => { + beforeEach(() => { + vi.stubEnv('ORCA_SYSTEM_SFTP_PATH', '/fixture/sftp') + }) + + it('reports a Windows file permission refusal as the host answering', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: 1, + signal: null, + stdout: '', + stderr: 'dest open "C:/Users/me/.orca-remote/node.zip": Permission denied', + timedOut: false + }) + const error = await runSftpBatch(target, ['put node.zip']).catch((e: unknown) => e) + expect(String(error)).toContain('sftp batch failed (exit 1)') + expect(isAnsweredHostFailure(error)).toBe(true) + }) + + it.each([ + [255, 'Connection closed'], + [1, 'Connection reset by peer'] + ])('keeps a lost connection (exit %i: %s) retryable', async (code, stderr) => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code, + signal: null, + stdout: '', + stderr, + timedOut: false + }) + const error = await runSftpBatch(target, ['put node.zip']).catch((e: unknown) => e) + expect(isAnsweredHostFailure(error)).toBe(false) + }) +}) + +describe.runIf(posix)('warm runtime check on a cached install', () => { + const host = getRemoteHostPlatform('linux-x64') + + async function cachedRuntime(nodeBody: string): Promise { + const runtimeDir = join(scratch, 'runtime') + await mkdir(join(runtimeDir, 'bin'), { recursive: true }) + await writeFile(join(runtimeDir, REMOTE_NODE_RUNTIME_VERIFIED_MARKER), '') + const node = join(runtimeDir, 'bin', 'node') + await writeFile(node, `#!/bin/sh\n${nodeBody}\n`) + await chmod(node, 0o755) + return runtimeDir + } + + function runCheck(runtimeDir: string, run: boolean): string { + return runProcessSync({ + program: '/bin/sh', + args: ['-c', remoteNodeRuntimePresentCommand(host, runtimeDir, run)] + }).stdout + } + + it('stays READY while the cached runtime still runs', async () => { + const runtimeDir = await cachedRuntime(`echo v${NODE_RUNTIME_PIN.version}`) + expect(runCheck(runtimeDir, true).trim()).toBe(REMOTE_NODE_RUNTIME_READY) + }) + + it.each([ + [ + 'a shared library removed since the install', + 'echo "node: error while loading shared libraries: libatomic.so.1: cannot open shared object file" >&2\nexit 127', + 'missing_lib' + ], + [ + 'an exec policy that now denies uploaded binaries', + 'echo "sh: node: Permission denied" >&2\nexit 126', + 'noexec' + ] + ])('reports %s as a classified refusal before launch', async (_label, body, refusal) => { + const runtimeDir = await cachedRuntime(body) + // Without running it, presence alone still says READY: the gap this check closes. + expect(runCheck(runtimeDir, false).trim()).toBe(REMOTE_NODE_RUNTIME_READY) + const output = runCheck(runtimeDir, true) + let thrown: unknown + try { + assertRemoteNodeRuntimePromoted(output) + } catch (error) { + thrown = error + } + if (!(thrown instanceof RemoteNodeRuntimeSelfTestError)) { + throw new Error(`expected a self-test refusal, got ${String(thrown)}`) + } + expect(classifyPinnedRuntimeFailure(thrown.exitStatus, thrown.output)).toBe(refusal) + expect(isAnsweredHostFailure(thrown)).toBe(true) + }) +}) diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index f519dbab4fb..18624eb170d 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -8,7 +8,7 @@ // the original "stale daemon serves new client" bug. import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ @@ -100,8 +100,11 @@ function makeMockConnection(): SshConnection { } describe('cross-version isolation', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') }) it('a v2 deploy never references the v1 install dir or v1 socket path', async () => { diff --git a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts index 4826f3c4dcc..7c5676f572d 100644 --- a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts +++ b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-opencode-runtime', () => ({ ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') @@ -123,8 +123,11 @@ function launchedDaemon(conn: SshConnection): boolean { * probe exists to prevent: the fresh daemon loses the bind by luck, not by design. */ describe('deployAndLaunchRelay honours the incumbent verdict', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(sweepSupersededRelayEndpoints).mockReset().mockResolvedValue([]) vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset() diff --git a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts index a8b0be31048..18f4bca2a35 100644 --- a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts +++ b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + isSshRelayOnHostNodeRuntime, + recordSshRelayRuntimeStep +} from './ssh-host-node-runtime-mode' import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' +import type * as OrcadRemoteNodeRuntimeModule from './orcad-remote-node-runtime' +import type * as PinnedNodeInstallModule from './ssh-relay-pinned-node-install' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } @@ -50,7 +56,8 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') })) -vi.mock('./ssh-remote-node-resolution', () => ({ +vi.mock('./ssh-remote-node-resolution', async (importOriginal) => ({ + ...(await importOriginal>()), resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) @@ -112,6 +119,14 @@ vi.mock('./ssh-relay-pinned-node-install', async (importOriginal) => ({ verifyPinnedRelayInstall: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./orcad-remote-node-runtime', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ensureRemoteOrcadNodeRuntime: vi.fn(actual.ensureRemoteOrcadNodeRuntime) + } +}) + vi.mock('./ssh-relay-host-node-addons', async (importOriginal) => ({ ...(await importOriginal>()), planHostNodeAddonRelay: vi.fn() @@ -127,7 +142,7 @@ vi.mock('./remote-node-runtime-store-gc', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' import { execCommand } from './ssh-relay-deploy-helpers' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { RemoteNodeNotFoundError, resolveRemoteNodePath } from './ssh-remote-node-resolution' import { finalizeInstall, gcOldRelayVersions, @@ -139,8 +154,16 @@ import { resolvePinnedRelayTargetFacts, type PinnedRelayPlan } from './ssh-relay-pinned-node' +import { + MUTABLE_PINNED_REFUSAL_REPLAY_MS, + resetPinnedRuntimeRefusalsForTests +} from './ssh-relay-pinned-refusal-cache' import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' import { planHostNodeAddonRelay, type HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' +import { + ensureRemoteOrcadNodeRuntime, + RemoteNodeRuntimeSelfTestError +} from './orcad-remote-node-runtime' import { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' import { resetSshRemoteRuntimeTelemetryForTests } from './ssh-remote-runtime-telemetry' import { getSshTargetRegistryStore } from './ssh-target-registry' @@ -214,12 +237,9 @@ function queueInstalledPinnedLaunch(): void { .mockResolvedValueOnce('READY') } -function queueInstalledLegacyLaunch(options: { platformProbed?: boolean } = {}): void { - // A later ladder step reuses the platform the first step detected. - if (!options.platformProbed) { - vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } +function queueInstalledLegacyLaunch(): void { vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') .mockResolvedValueOnce('/home/user') .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') .mockResolvedValueOnce('') // launch namespace marker @@ -227,6 +247,21 @@ function queueInstalledLegacyLaunch(options: { platformProbed?: boolean } = {}): .mockResolvedValueOnce('READY') } +/** A Linux host whose host-Node relay is installed and launches, answered by command text. */ +function answerHostNodeLaunchByCommand(): void { + const answers: [string, string][] = [ + ['uname', '__ORCA_REMOTE_PLATFORM__ Linux x86_64'], + ['process.stdout.write("READY")', 'READY'], + ['ORCA-NATIVE-DEPS-OK', 'ORCA-NATIVE-DEPS-OK'], + ['test -S', 'DEAD'] + ] + vi.mocked(execCommand).mockImplementation(async (_conn, command) => + command === 'echo $HOME' + ? '/home/user' + : (answers.find(([needle]) => command.includes(needle))?.[1] ?? '') + ) +} + describe('deployAndLaunchRelay on the pinned Node runtime', () => { beforeEach(() => { vi.clearAllMocks() @@ -243,10 +278,25 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { .mockRejectedValue(new PinnedRelayFallbackError('libc_floor', 'addons refused')) vi.mocked(getSshTargetRegistryStore).mockReset().mockReturnValue(null) resetSshRemoteRuntimeTelemetryForTests() + resetPinnedRuntimeRefusalsForTests() }) - it('keeps the legacy host-Node path when the host has no runtime setting', async () => { + it('runs the pinned ladder when the host has no runtime setting and nothing recorded', async () => { const conn = makeConnection() + queueInstalledPinnedLaunch() + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(result.nodePath).toBe(PINNED_NODE) + expect(result.serverBuildId).toBe(PINNED_VERSION) + expect( + vi.mocked(execCommand).mock.calls.some(([, cmd]) => String(cmd).includes('NATIVE-DEPS')) + ).toBe(false) + }) + + it('keeps the host-npm path for a host that opts into Host Node', async () => { + const conn = makeConnection('legacy') queueInstalledLegacyLaunch() const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') @@ -254,6 +304,16 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { expect(planPinnedNodeRelay).not.toHaveBeenCalled() expect(result.nodePath).toBe('/usr/bin/node') expect(result.serverBuildId).toBe('0.1.0+abcdef012345') + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) + }) + + it('clears the Host Node marker once a later connect launches on the ladder', async () => { + recordSshRelayRuntimeStep('target-1', true) + queueInstalledPinnedLaunch() + + await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') + + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(false) }) it('launches from the runtime-folded version dir with the pinned Node and no host Node probe', async () => { @@ -293,7 +353,7 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { }) it('leaves the runtime store alone on the legacy host-Node path', async () => { - const conn = makeConnection() + const conn = makeConnection('legacy') queueInstalledLegacyLaunch() await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') @@ -303,33 +363,210 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { expect(gcRemoteNodeRuntimeStore).not.toHaveBeenCalled() }) - it('steps down the ladder to the host-npm relay on classified refusals', async () => { - const conn = makeConnection('pinned-node') + it('falls back to the host-Node relay, as before the ladder, once A, B and C all refuse', async () => { + const conn = makeConnection() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( new PinnedRelayFallbackError('missing_lib', 'libstdc++.so.6: cannot open') ) vi.mocked(planPinnedNodeRelay) .mockResolvedValueOnce(pinnedPlan()) - .mockResolvedValueOnce({ kind: 'host-node', fallbackReason: 'artifacts_unavailable' }) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - queueInstalledLegacyLaunch({ platformProbed: true }) + .mockResolvedValueOnce({ kind: 'host-node', fallbackReason: 'missing_lib' }) + answerHostNodeLaunchByCommand() const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') expect(result.nodePath).toBe('/usr/bin/node') - expect(result.serverBuildId).toBe('0.1.0+abcdef012345') - expect(detachedLaunchCommand(conn)).toContain("'/usr/bin/node' relay.js --detached") + // The fallback's strict probe found Node once; the launch reuses it. + expect(resolveRemoteNodePath).toHaveBeenCalledOnce() + expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung A unavailable (missing_lib)')) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('rung B unavailable (artifacts_unavailable)') - ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung B unavailable (missing_lib)')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung C unavailable (libc_floor)')) warn.mockRestore() }) + it('keeps the host-Node relay, never rung D or a persisted decision, when this build lacks the orcad template', async () => { + const registry = { + getTarget: vi.fn(() => ({ id: 'target-1' })), + updateTarget: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. + vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const missing = 'The packaged orcad deployment template is missing' + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'artifacts_unavailable' + }) + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockRejectedValueOnce(new PinnedRelayFallbackError('artifacts_unavailable', missing)) + const conn = makeConnection() + queueInstalledLegacyLaunch() + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(result.nodePath).toBe('/usr/bin/node') + expect(detachedLaunchCommand(conn)).toContain("'/usr/bin/node' relay.js --detached") + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) + expect(registry.updateTarget).not.toHaveBeenCalled() + expect(track).toHaveBeenCalledWith( + 'ssh_remote_runtime_resolved', + expect.objectContaining({ rung: 'legacy', first_refusal: 'artifacts_unavailable' }) + ) + }) + + it('settles rung D after a client gap only once the host-Node fallback proves no Node', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'artifacts_unavailable' + }) + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockRejectedValueOnce(new PinnedRelayFallbackError('host_node_missing', 'no Node 18+')) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) + vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + + const failure = await deployAndLaunchRelay( + makeConnection(), + undefined, + undefined, + 'target-1' + ).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) + expect(String(failure)).toContain('could not prepare its bundled Node.js') + expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ reason: 'no_runtime' }) + expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) + }) + + function queueWindowsPlatformProbe(): SshConnection { + const conn = makeConnection() + Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) + vi.mocked(execCommand) + .mockRejectedValueOnce(new Error('uname not found')) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + return conn + } + + it('settles rung D with Windows wording when a Windows host has no Node to fall back to', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'security_software' + }) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) + + const failure = await deployAndLaunchRelay( + queueWindowsPlatformProbe(), + undefined, + 300, + 'target-1' + ).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) + expect(String(failure)).toContain('Windows host') + expect(String(failure)).toContain('install Node.js 18+') + expect(String(failure)).not.toContain('mounted noexec') + expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ + reason: 'no_runtime', + host: { platform: 'win32' } + }) + }) + + it('keeps an unanswered host-Node probe a retryable failure, not rung D', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'security_software' + }) + const lost = new Error('channel closed') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(lost) + + await expect( + deployAndLaunchRelay(queueWindowsPlatformProbe(), undefined, 300, 'target-1') + ).rejects.toBe(lost) + }) + + it('replays a Windows security-software refusal for a day, then re-proves rung A', async () => { + const winSha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 + const stored: Partial = {} + const registry = { + getTarget: vi.fn(() => ({ id: 'target-1', ...stored })), + updateTarget: vi.fn((_id: string, updates: Partial) => { + Object.assign(stored, updates) + return null + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. + vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) + vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ target: 'win32-x64', glibc: null }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'security_software' + }) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) + + await deployAndLaunchRelay(queueWindowsPlatformProbe(), undefined, 300, 'target-1').catch( + () => undefined + ) + + // Persisted with its proof time, so a restart doesn't re-upload to a host that still blocks it. + expect(stored.remoteRuntimeResolution).toMatchObject({ + pinnedRefusal: 'security_software', + runtimeSha256: winSha, + refusedAt: expect.any(Number) + }) + const persisted = vi.mocked(planPinnedNodeRelay).mock.calls[0]![0].persistedRefusal + const winFacts = { target: 'win32-x64' as const, glibc: null } + expect(persisted?.(winFacts)).toBe('security_software') + stored.remoteRuntimeResolution = { + ...stored.remoteRuntimeResolution!, + refusedAt: Date.now() - MUTABLE_PINNED_REFUSAL_REPLAY_MS + } + expect(persisted?.(winFacts)).toBeNull() + }) + + it.each(['missing_lib', 'security_software', 'noexec', 'artifacts_unavailable'] as const)( + 'keeps a Windows host on the host-Node relay after rung A refuses for %s', + async (reason) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const conn = makeConnection() + Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: reason + }) + vi.mocked(execCommand) + .mockRejectedValueOnce(new Error('uname not found')) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + .mockResolvedValueOnce('C:\\Users\\me') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('') // no persisted active pipe + .mockResolvedValueOnce('WAITING') // named pipe probe + .mockResolvedValueOnce('') // WMI relay launch + .mockResolvedValueOnce('READY') // named pipe poll + .mockResolvedValueOnce('') // persist active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-1') + + expect(resolveRemoteNodePath).toHaveBeenCalled() + expect(result.serverBuildId).toBe('0.1.0+abcdef012345') + expect(planHostNodeAddonRelay).not.toHaveBeenCalled() + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) + } + ) + it('runs rung B on the glibc 2.17 compat runtime when rung A is below its glibc floor', async () => { const conn = makeConnection('pinned-node') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -358,35 +595,6 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { warn.mockRestore() }) - it('runs the ladder to rung B where this connect just recorded that managed orcad cannot run', async () => { - const registry = { - getTarget: vi.fn(() => ({ - id: 'target-1', - managedServerUnavailable: { reason: 'runtime_self_test', appVersion: '1.0.0' } - })), - updateTarget: vi.fn(() => null) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. - vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const oldGlibc = { major: 2, minor: 17 } - vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ - target: 'linux-x64-glibc', - glibc: oldGlibc - }) - vi.mocked(planPinnedNodeRelay) - .mockResolvedValueOnce({ kind: 'host-node', fallbackReason: 'libc_floor' }) - .mockResolvedValueOnce({ ...pinnedPlan(), target: 'linux-x64-glibc217', glibc: oldGlibc }) - queueInstalledPinnedLaunch() - - // The connection's own target predates the record and carries no runtime setting. - const result = await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(result.nodePath).toBe(COMPAT_NODE) - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - }) - it('skips rung B on a current glibc when rung A refused for a reason B cannot answer', async () => { const conn = makeConnection('pinned-node') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -394,9 +602,14 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { kind: 'host-node', fallbackReason: 'illegal_instruction' }) - queueInstalledLegacyLaunch() + vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + await expect( + deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + ).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) expect(planPinnedNodeRelay).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith( @@ -508,12 +721,64 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { ) }) - it('goes straight to rung D on noexec, with the classified reason and no launch', async () => { + it("steps past rung A to the host's Node when NixOS's stub loader refuses the pinned Node", async () => { + const conn = makeConnection() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const actualInstall = await vi.importActual( + './ssh-relay-pinned-node-install' + ) + vi.mocked(ensurePinnedRelayRuntime).mockImplementationOnce( + actualInstall.ensurePinnedRelayRuntime + ) + vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( + new RemoteNodeRuntimeSelfTestError( + 127, + `Could not start dynamically linked executable: ${PINNED_NODE}\n` + + 'NixOS cannot run dynamically linked executables intended for generic\n' + + 'linux environments out of the box. For more information, see:\n' + + 'https://nix.dev/permalink/stub-ld' + ) + ) + const nixNode = '/run/current-system/sw/bin/node' + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockResolvedValueOnce({ ...hostNodePlan(), nodePath: nixNode }) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('uname')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('process.stdout.write("READY")')) { + return 'READY' + } + return command.includes('test -S') ? 'DEAD' : '' + }) + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce() + expect(result.nodePath).toBe(nixNode) + expect(detachedLaunchCommand(conn)).toContain(`'${nixNode}' relay.js --detached`) + expect( + vi.mocked(execCommand).mock.calls.some(([, cmd]) => /NATIVE-DEPS|npm /.test(String(cmd))) + ).toBe(false) + expect(track).toHaveBeenCalledWith( + 'ssh_remote_runtime_resolved', + expect.objectContaining({ rung: 'c', first_refusal: 'wrong_libc' }) + ) + }) + + it('tries the host-Node fallback after a proved noexec, reaching D only on its answer', async () => { const conn = makeConnection('pinned-node') vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( new PinnedRelayFallbackError('noexec', 'exit 126: Permission denied') ) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) vi.mocked(execCommand) .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') .mockResolvedValueOnce('/home/user') @@ -529,8 +794,9 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { reason: 'home_noexec', repairable: false }) + // B and C load addons from the same tree, so only the fallback can disprove the noexec. expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) expect(detachedLaunchCommand(conn)).toBeUndefined() expect(track).toHaveBeenCalledWith( 'ssh_remote_runtime_resolved', @@ -538,6 +804,38 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { ) }) + it('steps down on an answered but unclassified install failure (ENOSPC), not abort', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const enospc = Object.assign( + new Error('Command "tar -xzf" failed (exit 2): tar: write error: No space left on device'), + { exitCode: 2, stdout: '' } + ) + vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(enospc) + answerHostNodeLaunchByCommand() + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockRejectedValueOnce(new PinnedRelayFallbackError('host_node_missing', 'no Node 18+')) + + const result = await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') + + expect(result.nodePath).toBe('/usr/bin/node') + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) + }) + + it('keeps an unanswered install failure retryable rather than stepping down', async () => { + const lost = Object.assign(new Error('channel lost'), { sshChannelCloseConfirmed: false }) + vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(lost) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + + await expect( + deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') + ).rejects.toBe(lost) + expect(planHostNodeAddonRelay).not.toHaveBeenCalled() + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + }) + it('persists the rung A refusal under the host key and skips A on the next connect', async () => { const stored: Partial = {} const registry = { @@ -556,12 +854,16 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { vi.mocked(execCommand) .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') .mockResolvedValueOnce('/home/user') - queueInstalledLegacyLaunch({ platformProbed: true }) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) - await deployAndLaunchRelay(makeConnection('pinned-node'), undefined, undefined, 'target-1') + await expect( + deployAndLaunchRelay(makeConnection('pinned-node'), undefined, undefined, 'target-1') + ).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) expect(stored.remoteRuntimeResolution).toMatchObject({ - rung: 'legacy', + rung: 'D', pinnedRefusal: 'illegal_instruction', glibc: '2.31', runtimeSha256: RUNTIME_SHA diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts index 26ba5a2d990..063414ea8b4 100644 --- a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' vi.mock('electron', () => ({ @@ -137,8 +137,11 @@ function stageCommandResponse(command: string): string | undefined { } describe('deployAndLaunchRelay staged uploads', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ write: vi.fn(), diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts index 1a49ec11590..1036385fac1 100644 --- a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts +++ b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts @@ -1,6 +1,6 @@ // Windows named-pipe endpoint deploys, split out of ssh-relay-deploy.test.ts: that file sits at // the max-lines cap, and these four share only the deploy harness with the rest of it. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' vi.mock('electron', () => ({ @@ -146,8 +146,11 @@ function makeMockConnection(): SshConnection { } describe('deployAndLaunchRelay on Windows remotes', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ write: vi.fn(), diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 27a70f60fdb..db38b195145 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' vi.mock('electron', () => ({ @@ -165,8 +165,11 @@ function detachedLaunchCommand(conn: SshConnection): string | undefined { } describe('deployAndLaunchRelay', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(ensureRemoteOpenCodeRuntime).mockReset().mockResolvedValue('ready') vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index b615cd8053c..e5f94701c47 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -2,7 +2,9 @@ import { existsSync } from 'node:fs' import { app } from 'electron' import { relayBundleCandidates } from './relay-bundle-paths' -import { PinnedRelayFallbackError, resolveConnectRemoteRuntime } from './ssh-relay-pinned-node' +import { PinnedRelayFallbackError, resolveSshRemoteRuntime } from './ssh-relay-pinned-node' +import { recordSshRelayRuntimeStep } from './ssh-host-node-runtime-mode' +import { isAnsweredHostFailure } from './ssh-relay-host-answered-failure' import { ensurePinnedRelayRuntime, prebuiltRelayNodePath, @@ -10,7 +12,7 @@ import { } from './ssh-relay-pinned-node-install' import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' import { - nextRelayRuntimeStep, + relayRuntimeStepAfterRefusal, relayRuntimeLadder, relayRuntimeStorePins, type RelayRuntimeStep @@ -429,21 +431,24 @@ async function deployAndLaunchRelayInner( ): Promise { const target = typeof conn.getTarget === 'function' ? conn.getTarget() : undefined const registry = getSshTargetRegistryStore() - const ladder = relayRuntimeLadder( - resolveConnectRemoteRuntime(target, target && registry?.getTarget(target.id)) - ) + const ladder = relayRuntimeLadder(resolveSshRemoteRuntime(target)) const run = new RelayRuntimeLadderRun( target?.id ?? relayInstanceId ?? '', - target && registry ? sshTargetRelayRuntimeDecisionStore(registry) : null + target && registry ? sshTargetRelayRuntimeDecisionStore(registry) : null, + ladder.length > 1 ) let step = ladder[0] ?? 'legacy' while (true) { deploySignal?.throwIfAborted() try { if (step === 'D') { + if (target) { + recordSshRelayRuntimeStep(target.id, false) + } run.settle('D') throw remoteRuntimeUnavailableError(run) } + run.launchStarted = false const result = await deployAndLaunchRelayAttempt( conn, onProgress, @@ -453,22 +458,26 @@ async function deployAndLaunchRelayInner( { step, run } ) // Why only a laddered pass: a plain host-npm connect has no rung decision to record. - if (ladder.length > 1) { + if (run.laddered) { run.settle(step) } + if (target) { + recordSshRelayRuntimeStep(target.id, step === 'legacy') + } return result } catch (err) { - if (err instanceof PinnedRelayFallbackError && step !== 'legacy' && step !== 'D') { + const refusal = ladderRefusal(err, step, run) + if (refusal) { console.warn( - `[ssh-relay] Relay runtime rung ${step} unavailable (${err.reason}): ${err.detail}` + `[ssh-relay] Relay runtime rung ${step} unavailable (${refusal.reason}): ${refusal.detail}` ) - run.refused(step, err.reason, err.remembered) - step = nextRelayRuntimeStep(ladder, step, err.reason, err.remembered) + run.refused(step, refusal.reason, refusal.remembered) + step = relayRuntimeStepAfterRefusal(ladder, step, refusal.reason, refusal.remembered) run.enter(step) continue } if (!(err instanceof RelayDirectoryGcConflictError)) { - if (ladder.length > 1 && !deploySignal?.aborted) { + if (run.laddered && !deploySignal?.aborted) { run.unresolved(step) } throw err @@ -479,6 +488,32 @@ async function deployAndLaunchRelayInner( } } +/** + * The refusal a failed ladder step steps past, or null when it must propagate. Any failure the + * host answered steps down, classified or not; opt-in legacy is the user's choice, with no D below. + */ +function ladderRefusal( + err: unknown, + step: RelayRuntimeStep, + run: RelayRuntimeLadderRun +): PinnedRelayFallbackError | null { + if (step === 'D' || (step === 'legacy' && !run.laddered)) { + return null + } + if (err instanceof PinnedRelayFallbackError) { + return err + } + // Why not after launch: a relay may be running, and a second rung would launch beside it. + if ( + run.launchStarted || + err instanceof RelayDirectoryGcConflictError || + !isAnsweredHostFailure(err) + ) { + return null + } + return new PinnedRelayFallbackError('install_failed', err instanceof Error ? err.message : '') +} + type RelayRuntimeRequest = { step: RelayRuntimeStep; run: RelayRuntimeLadderRun } async function deployAndLaunchRelayAttempt( @@ -489,7 +524,7 @@ async function deployAndLaunchRelayAttempt( deploySignal?: AbortSignal, runtimeRequest: RelayRuntimeRequest = { step: 'legacy', - run: new RelayRuntimeLadderRun(relayInstanceId ?? '', null) + run: new RelayRuntimeLadderRun(relayInstanceId ?? '', null, false) } ): Promise { onProgress?.('Detecting remote platform...') @@ -577,7 +612,15 @@ async function deployAndLaunchRelayOnRuntime({ prebuilt, deploySignal ) - : await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) + : run.hostNodePath + ? // Why: the fallback's strict probe already found this Node; don't resolve it twice. + { + ...(await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { + signal: deploySignal + })), + nodePath: run.hostNodePath + } + : await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) const pinnedContext = prebuilt @@ -791,6 +834,7 @@ async function deployAndLaunchRelayOnRuntime({ onProgress?.('Starting relay...') console.log('[ssh-relay] Launching relay...') // A failed launch retains its fences until stale recovery can establish liveness. + run.launchStarted = true const launched = await launchRelay( conn, remoteRelayDir, diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts index 18d99a926a9..9c65ab76fc6 100644 --- a/src/main/ssh/ssh-relay-gc-retry.test.ts +++ b/src/main/ssh/ssh-relay-gc-retry.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) vi.mock('fs', () => ({ @@ -78,8 +78,11 @@ function makeConnection(): SshConnection { } describe('relay GC deploy retry', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') }) it('recomputes install state when GC wins before a healthy relay launch', async () => { diff --git a/src/main/ssh/ssh-relay-host-answered-failure.test.ts b/src/main/ssh/ssh-relay-host-answered-failure.test.ts new file mode 100644 index 00000000000..f2622ee944a --- /dev/null +++ b/src/main/ssh/ssh-relay-host-answered-failure.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + RemoteNodeRuntimeSecurityModifiedError, + RemoteNodeRuntimeSelfTestError +} from './orcad-remote-node-runtime-report' +import { isAnsweredHostFailure, RelayHostAnsweredError } from './ssh-relay-host-answered-failure' +import { SSH_EXEC_TIMEOUT_CODE } from './ssh-relay-exec-command' +import { SystemSshCommandExitError } from './system-ssh-operation-lifecycle' + +const exitError = (code: number, message: string): Error => + Object.assign(new Error(message), { exitCode: code, stdout: '' }) + +describe('isAnsweredHostFailure', () => { + it.each([ + ['tar ENOSPC', exitError(2, 'tar: write error: No space left on device')], + ['extract marker', exitError(1, 'ORCA_NODE_RUNTIME_EXTRACT_FAILED')], + ['hash mismatch marker', exitError(1, 'ORCA_NODE_RUNTIME_HASH_MISMATCH')], + ['SFTP write failure', Object.assign(new Error('Failure'), { code: 4 })], + ['SFTP permission denied', Object.assign(new Error('Permission denied'), { code: 3 })], + ['unverified promote', new RelayHostAnsweredError('The host did not verify the runtime')], + ['unclassified self-test', new RemoteNodeRuntimeSelfTestError(1, 'boom')], + ['security modified', new RemoteNodeRuntimeSecurityModifiedError('node.exe')], + ['system ssh exit', new SystemSshCommandExitError('promote', 1, 'Access denied')] + ])('steps down on an answered %s', (_label, error) => { + expect(isAnsweredHostFailure(error)).toBe(true) + }) + + it.each([ + ['abort', Object.assign(new Error('cancelled'), { name: 'AbortError' })], + [ + 'channel open failure', + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 }) + ], + ['session limit', Object.assign(new Error('open failed'), { reason: 4 })], + ['timeout', Object.assign(new Error('timed out'), { code: SSH_EXEC_TIMEOUT_CODE })], + [ + 'unconfirmed termination', + Object.assign(exitError(1, 'lost'), { sshChannelCloseConfirmed: false }) + ], + ['SFTP connection lost', Object.assign(new Error('Connection lost'), { code: 7 })], + ['system ssh transport', new SystemSshCommandExitError('promote', 255, 'Connection reset')], + ['system ssh signal', new SystemSshCommandExitError('promote', null, '')], + ['plain error', new Error('something else')] + ])('stays retryable on %s', (_label, error) => { + expect(isAnsweredHostFailure(error)).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-relay-host-answered-failure.ts b/src/main/ssh/ssh-relay-host-answered-failure.ts new file mode 100644 index 00000000000..1bfd575a69f --- /dev/null +++ b/src/main/ssh/ssh-relay-host-answered-failure.ts @@ -0,0 +1,43 @@ +/** + * Whether a failed relay-runtime attempt got an answer from the host. An answered failure is a + * verdict on that rung, so the ladder steps down; an unanswered one says nothing about the host, + * so the connect fails retryably and never launches a second relay beside one that may be running. + */ +import { isSshCommandExitError } from './ssh-relay-exec-command' +import { isSshSessionLimitError } from './ssh-session-limit-error' +import { + isHostAnsweredSystemSshExit, + SystemSshCommandExitError +} from './system-ssh-operation-lifecycle' + +/** SFTP statuses the server sent about the request itself; NO_CONNECTION/CONNECTION_LOST are transport. */ +const ANSWERED_SFTP_STATUSES: ReadonlySet = new Set([1, 2, 3, 4, 5, 8]) + +/** A host-side step that answered with something other than success, carrying what it said. */ +export class RelayHostAnsweredError extends Error { + constructor(message: string) { + super(message) + this.name = 'RelayHostAnsweredError' + } +} + +function isSftpStatusError(err: Error): boolean { + const code = 'code' in err ? err.code : undefined + return typeof code === 'number' && ANSWERED_SFTP_STATUSES.has(code) +} + +export function isAnsweredHostFailure(err: unknown): boolean { + if (!(err instanceof Error) || err.name === 'AbortError' || isSshSessionLimitError(err)) { + return false + } + // Why the close check: a channel whose close was never confirmed may still be running. + if (/channel open failure/i.test(err.message) || 'sshChannelCloseConfirmed' in err) { + return false + } + if (err instanceof SystemSshCommandExitError) { + return isHostAnsweredSystemSshExit(err) + } + return ( + err instanceof RelayHostAnsweredError || isSshCommandExitError(err) || isSftpStatusError(err) + ) +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts index 8d7a1988a97..10b1ed620f3 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts @@ -103,8 +103,11 @@ describe('relay native-deps cache on the deploy path', () => { let warnSpy: ReturnType const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 diff --git a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts index e8616e7c230..3a21573fbd3 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ @@ -93,8 +93,11 @@ describe('installNativeDeps staged uploads', () => { execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index d48fa1c1e80..f6d16eaed8e 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -115,8 +115,11 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') // mockReset because clearAllMocks keeps queued mockResolvedValueOnce entries, so a leaked response would bleed into the next test. vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts index 69c7ae1d2a5..43ac50dbf52 100644 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -2,7 +2,7 @@ // channel rm -rf'd node-pty on a healthy relay and forced a node-gyp rebuild. Verdicts are // ok / blocked / unverifiable — see docs/reference/ssh-execution-boundary.md. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ @@ -104,8 +104,11 @@ describe('native-deps repair probe verdicts', () => { const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } let warnSpy: ReturnType + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts index 7eda53e684d..e43516fe0c5 100644 --- a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts +++ b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts @@ -152,8 +152,11 @@ describe('spawn-time node-pty repair through the locked deploy path', () => { execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') sftpCapture.paths.length = 0 for (const key of Object.keys(sftpCapture.contents)) { diff --git a/src/main/ssh/ssh-relay-pinned-node-install.test.ts b/src/main/ssh/ssh-relay-pinned-node-install.test.ts index 2b1cee1a989..748052b41c1 100644 --- a/src/main/ssh/ssh-relay-pinned-node-install.test.ts +++ b/src/main/ssh/ssh-relay-pinned-node-install.test.ts @@ -8,13 +8,17 @@ import { RemoteNodeRuntimeSecurityModifiedError, RemoteNodeRuntimeSelfTestError } from './orcad-remote-node-runtime' +import { + REMOTE_NODE_RUNTIME_EXIT_PREFIX, + REMOTE_NODE_RUNTIME_SELFTEST_FAILED +} from './orcad-remote-node-runtime-report' import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' import { PinnedRelayFallbackError, planPinnedNodeRelay, - resetPinnedRuntimeRefusalsForTests, type PinnedRelayPlan } from './ssh-relay-pinned-node' +import { resetPinnedRuntimeRefusalsForTests } from './ssh-relay-pinned-refusal-cache' import { runPinnedRuntimeSelfTest } from './ssh-relay-runtime-self-test' import type { HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' import { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' @@ -73,6 +77,27 @@ describe('ensurePinnedRelayRuntime', () => { expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() }) + it.each([ + [ + 'a library removed since the install', + 127, + 'libatomic.so.1: cannot open shared object file', + 'missing_lib' + ], + ['an exec policy that now denies it', 126, 'sh: node: Permission denied', 'noexec'] + ] as const)( + 'refuses a cached runtime that no longer runs (%s) before any launch', + async (_label, status, output, reason) => { + vi.mocked(execCommand).mockResolvedValueOnce( + `${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}\n${REMOTE_NODE_RUNTIME_EXIT_PREFIX}${status}\n${output}\n` + ) + const failure = await ensurePinnedRelayRuntime(context, true).catch((e: unknown) => e) + expect(failure).toMatchObject({ reason }) + expect(String(vi.mocked(execCommand).mock.calls[0]?.[1])).toContain('--version') + expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() + } + ) + it('reinstalls a runtime that disappeared from under an installed relay', async () => { vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) await ensurePinnedRelayRuntime(context, true) @@ -95,6 +120,21 @@ describe('ensurePinnedRelayRuntime', () => { ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'noexec', remembered: true }) }) + it("steps down when NixOS's stub loader refuses the generic Linux runtime", async () => { + vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( + new RemoteNodeRuntimeSelfTestError( + 127, + 'Could not start dynamically linked executable: /home/u/.orca-remote/runtimes/node-x/bin/node\n' + + 'NixOS cannot run dynamically linked executables intended for generic\n' + + 'linux environments out of the box. For more information, see:\n' + + 'https://nix.dev/permalink/stub-ld' + ) + ) + const failure = await ensurePinnedRelayRuntime(context, false).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(PinnedRelayFallbackError) + expect(failure).toMatchObject({ reason: 'wrong_libc' }) + }) + it('keeps an unclassified runtime failure as an error, not a step down', async () => { const failure = new RemoteNodeRuntimeSelfTestError(1, 'something unexpected') vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce(failure) @@ -152,7 +192,7 @@ describe('verifyPinnedRelayInstall', () => { it('self-tests rung C on the host Node without the pinned version check or a cached refusal', async () => { vi.mocked(withRuntimeStoreLock).mockClear() vi.mocked(execCommand).mockResolvedValue('') - const run = new RelayRuntimeLadderRun('target-1', null) + const run = new RelayRuntimeLadderRun('target-1', null, true) const hostPlan: HostNodeAddonRelayPlan = { kind: 'host-node-addons', target: 'linux-x64-glibc', diff --git a/src/main/ssh/ssh-relay-pinned-node-install.ts b/src/main/ssh/ssh-relay-pinned-node-install.ts index e2a241fd23c..bc0c400218a 100644 --- a/src/main/ssh/ssh-relay-pinned-node-install.ts +++ b/src/main/ssh/ssh-relay-pinned-node-install.ts @@ -8,18 +8,23 @@ import { RemoteNodeRuntimeSelfTestError, REMOTE_NODE_RUNTIME_READY } from './orcad-remote-node-runtime' +import { + assertRemoteNodeRuntimePromoted, + REMOTE_NODE_RUNTIME_SELFTEST_FAILED +} from './orcad-remote-node-runtime-report' import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' import { execCommand } from './ssh-relay-deploy-helpers' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { RelayHostAnsweredError } from './ssh-relay-host-answered-failure' import { isPinnedRuntimeRefusal, PinnedRelayFallbackError, pinnedRelayNodePath, - recordPinnedRuntimeRefusal, type PinnedRelayPlan } from './ssh-relay-pinned-node' +import { recordPinnedRuntimeRefusal } from './ssh-relay-pinned-refusal-cache' import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' import { @@ -62,28 +67,34 @@ export function prebuiltRelayNodePath(context: { } /** - * The warm path only checks the verified marker; hashing ~120 MiB on every reconnect would - * spend the warm-reconnect budget (design D10 G2.6). A cold install verifies in full. + * The warm path checks the verified marker and runs `--version` in the same command; hashing + * ~120 MiB on every reconnect would spend the warm-reconnect budget (design D10 G2.6). A cold + * install verifies in full. */ export async function ensurePinnedRelayRuntime( context: PinnedInstallContext & { plan: PinnedRelayPlan }, relayAlreadyInstalled: boolean ): Promise { const { conn, host, remoteRelayDir, plan, signal, run } = context - if (relayAlreadyInstalled) { - const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) - const present = await execCommand(conn, remoteNodeRuntimePresentCommand(host, runtimeDir), { - signal, - wrapCommand: !isWindowsRemoteHost(host) - }) - if (present.trim() === REMOTE_NODE_RUNTIME_READY) { - if (run && run.runtimeTransfer === 'none') { - run.runtimeTransfer = 'cached' - } - return - } - } try { + if (relayAlreadyInstalled) { + const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) + // Why run it here: exec policy or a library can change after the install was verified. + const present = await execCommand( + conn, + remoteNodeRuntimePresentCommand(host, runtimeDir, true), + { signal, wrapCommand: !isWindowsRemoteHost(host) } + ) + if (present.trim() === REMOTE_NODE_RUNTIME_READY) { + if (run && run.runtimeTransfer === 'none') { + run.runtimeTransfer = 'cached' + } + return + } + if (present.includes(REMOTE_NODE_RUNTIME_SELFTEST_FAILED)) { + assertRemoteNodeRuntimePromoted(present) + } + } const { transfer } = await ensureRemoteOrcadNodeRuntime({ conn, host, @@ -189,6 +200,8 @@ export async function verifyPinnedRelayInstall(context: PinnedInstallContext): P `The relay runtime self-test at ${remoteRelayDir} is unverifiable; retrying on the next connect: ${verdict.detail}` ) case 'failed': - throw new Error(`The relay runtime self-test at ${remoteRelayDir} failed: ${verdict.detail}`) + throw new RelayHostAnsweredError( + `The relay runtime self-test at ${remoteRelayDir} failed: ${verdict.detail}` + ) } } diff --git a/src/main/ssh/ssh-relay-pinned-node.test.ts b/src/main/ssh/ssh-relay-pinned-node.test.ts index 17cca644fe8..d7367578ef2 100644 --- a/src/main/ssh/ssh-relay-pinned-node.test.ts +++ b/src/main/ssh/ssh-relay-pinned-node.test.ts @@ -20,13 +20,16 @@ import { pinnedRelayAddonFiles, pinnedRelayNodePath, planPinnedNodeRelay, - recordPinnedRuntimeRefusal, RELAY_RUNTIME_REF_PREFIX, - resetPinnedRuntimeRefusalsForTests, resolveSshRemoteRuntime, SSH_REMOTE_RUNTIME_ENV, stagePinnedRelayAddons } from './ssh-relay-pinned-node' +import { + MUTABLE_PINNED_REFUSAL_REPLAY_MS, + recordPinnedRuntimeRefusal, + resetPinnedRuntimeRefusalsForTests +} from './ssh-relay-pinned-refusal-cache' import { getRemoteHostPlatform } from './ssh-remote-platform' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) @@ -65,37 +68,23 @@ afterEach(() => { }) describe('remote runtime setting', () => { - it('defaults to legacy, lets the host setting win, and accepts only known env values', () => { - expect(resolveSshRemoteRuntime(undefined, {})).toBe('legacy') - expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'pinned-node' })).toBe( - 'pinned-node' - ) - expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'bun' })).toBe('legacy') + it('defaults to the pinned ladder, lets the host setting win, and accepts only known env values', () => { + expect(resolveSshRemoteRuntime(undefined, {})).toBe('pinned-node') + expect(resolveSshRemoteRuntime({}, {})).toBe('pinned-node') + expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'legacy' })).toBe('legacy') + expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'bun' })).toBe('pinned-node') expect( resolveSshRemoteRuntime( { remoteRuntime: 'legacy' }, { [SSH_REMOTE_RUNTIME_ENV]: 'pinned-node' } ) ).toBe('legacy') - }) - - it('runs the pinned ladder where managed orcad was found unable to run, unless the host opts out', () => { - const unavailable = { reason: 'runtime_self_test', appVersion: '1.0.0' } - expect(resolveSshRemoteRuntime({ managedServerUnavailable: unavailable }, {})).toBe( - 'pinned-node' - ) expect( resolveSshRemoteRuntime( - { managedServerUnavailable: unavailable, remoteRuntime: 'legacy' }, - {} - ) - ).toBe('legacy') - expect( - resolveSshRemoteRuntime( - { managedServerUnavailable: unavailable }, + { remoteRuntime: 'pinned-node' }, { [SSH_REMOTE_RUNTIME_ENV]: 'legacy' } ) - ).toBe('legacy') + ).toBe('pinned-node') }) }) @@ -307,6 +296,46 @@ describe('planPinnedNodeRelay', () => { expect(materializeOrcad).not.toHaveBeenCalled() }) + it('re-proves a repaired Windows host once a security-software refusal has aged out', async () => { + vi.useFakeTimers() + try { + recordPinnedRuntimeRefusal('t', 'win32-x64', 'security_software') + const plan = (): ReturnType => + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('win32-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad: async (target) => fakeOrcadSlot(target) + }) + await expect(plan()).resolves.toMatchObject({ remembered: true }) + vi.advanceTimersByTime(MUTABLE_PINNED_REFUSAL_REPLAY_MS) + await expect(plan()).resolves.toMatchObject({ kind: 'pinned-node', target: 'win32-x64' }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps replaying an immutable refusal past the mutable window', async () => { + vi.useFakeTimers() + try { + recordPinnedRuntimeRefusal('t', 'linux-x64-glibc', 'illegal_instruction') + vi.advanceTimersByTime(MUTABLE_PINNED_REFUSAL_REPLAY_MS * 10) + vi.mocked(execCommand).mockResolvedValue('ldd (GNU libc) 2.31') + await expect( + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('linux-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad: vi.fn() + }) + ).resolves.toMatchObject({ fallbackReason: 'illegal_instruction', remembered: true }) + } finally { + vi.useRealTimers() + } + }) + it('plans a pinned relay with a runtime-folded version for a supported host', async () => { vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.28') const plan = await planPinnedNodeRelay({ diff --git a/src/main/ssh/ssh-relay-pinned-node.ts b/src/main/ssh/ssh-relay-pinned-node.ts index def3d8b36fa..a7d7095b339 100644 --- a/src/main/ssh/ssh-relay-pinned-node.ts +++ b/src/main/ssh/ssh-relay-pinned-node.ts @@ -2,9 +2,9 @@ * Plan a relay launch on Orca's pinned Node with the orcad slot's prebuilt addons, instead * of the host's Node plus a host-side npm install (design D5, D6 rung A, D8.1). * - * Opt-in per host (`SshTarget.remoteRuntime`), and on by default where managed orcad can't run. + * The default for every relay connect; a host opts out with `SshTarget.remoteRuntime: 'legacy'`. * Anything this module cannot establish on the client, and every classified refusal from the - * host, falls back to the legacy host-Node path with a logged reason. + * host, steps down the ladder with a logged reason. */ import { createHash } from 'node:crypto' import { chmod, copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' @@ -42,6 +42,10 @@ import { remoteNodeRuntimeDir } from './orcad-remote-node-runtime' import { fileSha256, materializeNodeRuntimeArchive } from './pinned-runtime-materializer' import type { SshConnection } from './ssh-connection' import { PINNED_RUNTIME_REFUSALS, type PinnedRuntimeRefusal } from './ssh-relay-runtime-self-test' +import { + recordPinnedRuntimeRefusal, + rememberedPinnedRuntimeRefusal +} from './ssh-relay-pinned-refusal-cache' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' /** Content-keyed like ripgrep's refs, so runtime GC can find holders without a new concept (D5). */ @@ -52,25 +56,14 @@ export const PINNED_NODE_GLIBC_FLOOR: GlibcVersion = { major: 2, minor: 28 } export const SSH_REMOTE_RUNTIME_ENV = 'ORCA_SSH_REMOTE_RUNTIME' export function resolveSshRemoteRuntime( - target: Pick | undefined, + target: Pick | undefined, env: NodeJS.ProcessEnv = process.env ): SshRemoteRuntime { if (target?.remoteRuntime) { return target.remoteRuntime } const fromEnv = SSH_REMOTE_RUNTIMES.find((runtime) => runtime === env[SSH_REMOTE_RUNTIME_ENV]) - // Why: where managed orcad can't run the relay is the host's server; the ladder still ends at legacy. - return fromEnv ?? (target?.managedServerUnavailable ? 'pinned-node' : DEFAULT_SSH_REMOTE_RUNTIME) -} - -/** Why `recorded`: this connect's host-server decision may have just recorded orcad unavailable. */ -export function resolveConnectRemoteRuntime( - target: SshTarget | undefined, - recorded: SshTarget | undefined -): SshRemoteRuntime { - const managedServerUnavailable = - recorded?.managedServerUnavailable ?? target?.managedServerUnavailable - return resolveSshRemoteRuntime(target && { ...target, managedServerUnavailable }) + return fromEnv ?? DEFAULT_SSH_REMOTE_RUNTIME } export function isGlibcBelow(version: GlibcVersion, floor: GlibcVersion): boolean { @@ -192,6 +185,8 @@ export type RelayRuntimeFallbackReason = | 'runtime_unavailable' /** Rung C found no host Node >= 18 with the addons' N-API level. */ | 'host_node_missing' + /** The host answered a rung's install, self-test or launch with an unclassified failure. */ + | 'install_failed' export type HostNodeRelayPlan = { kind: 'host-node' @@ -217,35 +212,11 @@ export function isPinnedRuntimeRefusal(reason: string): reason is PinnedRuntimeR return PINNED_RUNTIME_REFUSALS.some((refusal) => refusal === reason) } -// Why also in memory: the persisted decision is written only once the ladder settles. -const refusals = new Map() - -function refusalKey(targetId: string, target: NodeRuntimeTarget): string { - return `${targetId}\0${pinnedNodeRuntimeAsset(target).executableSha256}` -} - -export function recordPinnedRuntimeRefusal( - targetId: string, - target: NodeRuntimeTarget, - refusal: PinnedRuntimeRefusal -): void { - refusals.set(refusalKey(targetId, target), refusal) -} - -/** Forgets a refusal a later rung has disproved, so the next connect retries rung A. */ -export function forgetPinnedRuntimeRefusal(targetId: string, target: NodeRuntimeTarget): void { - refusals.delete(refusalKey(targetId, target)) -} - -export function resetPinnedRuntimeRefusalsForTests(): void { - refusals.clear() -} - export function logPinnedRelayFallback( reason: RelayRuntimeFallbackReason, detail: string ): HostNodeRelayPlan { - console.warn(`[ssh-relay] Pinned Node relay unavailable (${reason}): ${detail}; using host Node`) + console.warn(`[ssh-relay] Pinned Node relay unavailable (${reason}): ${detail}`) return { kind: 'host-node', fallbackReason: reason } } @@ -293,7 +264,7 @@ export async function planPinnedNodeRelay(options: { const { glibc } = facts const { compat } = options const target: NodeRuntimeTarget = compat?.target ?? facts.target - const cached = refusals.get(refusalKey(options.targetId, target)) + const cached = rememberedPinnedRuntimeRefusal(options.targetId, target) if (cached) { return { ...logPinnedRelayFallback(cached, 'refused earlier this session'), remembered: true } } diff --git a/src/main/ssh/ssh-relay-pinned-refusal-cache.ts b/src/main/ssh/ssh-relay-pinned-refusal-cache.ts new file mode 100644 index 00000000000..b4d8840a7a7 --- /dev/null +++ b/src/main/ssh/ssh-relay-pinned-refusal-cache.ts @@ -0,0 +1,78 @@ +/** Pinned-runtime refusals remembered for this session, keyed by host and runtime, and their expiry. */ +import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin' +import type { PinnedRuntimeRefusal } from './ssh-relay-runtime-self-test' + +/** + * Refusals the host can lose without Orca changing: a library installed, AV or application + * control relaxed. They replay for a day, persisted too, so a blocked host isn't re-uploaded to + * on every connect yet a repaired one gets rung A back. POSIX noexec is left out: rung C re-proves + * it and D forgets a replayed one; Windows has neither, so there it expires too. + */ +const MUTABLE_PINNED_RUNTIME_REFUSALS: ReadonlySet = new Set([ + 'missing_lib', + 'security_software' +]) +export const MUTABLE_PINNED_REFUSAL_REPLAY_MS = 24 * 60 * 60_000 + +export function isMutablePinnedRuntimeRefusal( + refusal: PinnedRuntimeRefusal, + target: NodeRuntimeTarget +): boolean { + return ( + MUTABLE_PINNED_RUNTIME_REFUSALS.has(refusal) || + (refusal === 'noexec' && target.startsWith('win32-')) + ) +} + +/** A mutable refusal with no proof time (an older record) is stale, so the host is re-proved. */ +export function isPinnedRefusalExpired( + refusal: PinnedRuntimeRefusal, + target: NodeRuntimeTarget, + provedAt: number | undefined, + now = Date.now() +): boolean { + return ( + isMutablePinnedRuntimeRefusal(refusal, target) && + (provedAt === undefined || now - provedAt >= MUTABLE_PINNED_REFUSAL_REPLAY_MS) + ) +} + +// Why also in memory: the persisted decision is written only once the ladder settles. +const refusals = new Map() + +function refusalKey(targetId: string, target: NodeRuntimeTarget): string { + return `${targetId}\0${pinnedNodeRuntimeAsset(target).executableSha256}` +} + +export function recordPinnedRuntimeRefusal( + targetId: string, + target: NodeRuntimeTarget, + refusal: PinnedRuntimeRefusal +): void { + refusals.set(refusalKey(targetId, target), { refusal, at: Date.now() }) +} + +export function rememberedPinnedRuntimeRefusal( + targetId: string, + target: NodeRuntimeTarget +): PinnedRuntimeRefusal | null { + const key = refusalKey(targetId, target) + const entry = refusals.get(key) + if (!entry) { + return null + } + if (isPinnedRefusalExpired(entry.refusal, target, entry.at)) { + refusals.delete(key) + return null + } + return entry.refusal +} + +/** Forgets a refusal a later rung has disproved, so the next connect retries rung A. */ +export function forgetPinnedRuntimeRefusal(targetId: string, target: NodeRuntimeTarget): void { + refusals.delete(refusalKey(targetId, target)) +} + +export function resetPinnedRuntimeRefusalsForTests(): void { + refusals.clear() +} diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts index f7fd4d65794..7e2387e0850 100644 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('./ssh-relay-opencode-runtime', () => ({ @@ -109,8 +109,11 @@ describe('relay pty fd-leak patch on the install path', () => { execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') sftpCapture.paths.length = 0 vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') diff --git a/src/main/ssh/ssh-relay-runtime-ladder.test.ts b/src/main/ssh/ssh-relay-runtime-ladder.test.ts index 4415786c0e8..23fe1d6b15b 100644 --- a/src/main/ssh/ssh-relay-runtime-ladder.test.ts +++ b/src/main/ssh/ssh-relay-runtime-ladder.test.ts @@ -2,44 +2,66 @@ import { describe, expect, it } from 'vitest' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS } from '../../shared/node-runtime-pin' import { compatRelayRuntimeFor, - nextRelayRuntimeStep, pinnedRuntimeTargetForHost, relayRuntimeLadder, + relayRuntimeStepAfterRefusal, relayRuntimeStorePins, - remoteRuntimeUnavailableMessage, - remoteRuntimeUnavailableReason, + remoteRuntimeUnavailable, + type RemoteRuntimeUnavailableState, rungBCompatRuntimeFor } from './ssh-relay-runtime-ladder' +import { resolveSshRemoteRuntime } from './ssh-relay-pinned-node' const COMPAT_SHA = NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256 describe('relay runtime ladder (design D6)', () => { - it('keeps the host-npm path alone for Auto and Host Node, and the full ladder for Orca-managed Node', () => { + it('keeps the host-npm path alone for Host Node, and before D in the Auto ladder', () => { + const auto = ['A', 'B', 'C', 'legacy', 'D'] expect(relayRuntimeLadder('legacy')).toEqual(['legacy']) - expect(relayRuntimeLadder('pinned-node')).toEqual(['A', 'B', 'C', 'legacy', 'D']) + expect(relayRuntimeLadder('pinned-node')).toEqual(auto) + expect(relayRuntimeLadder(resolveSshRemoteRuntime(undefined, {}))).toEqual(auto) }) it('steps to the next rung on an ordinary refusal', () => { const ladder = relayRuntimeLadder('pinned-node') - expect(nextRelayRuntimeStep(ladder, 'A', 'libc_floor')).toBe('B') - expect(nextRelayRuntimeStep(ladder, 'B', 'runtime_unavailable')).toBe('C') - expect(nextRelayRuntimeStep(ladder, 'C', 'missing_lib')).toBe('legacy') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'libc_floor', false)).toBe('B') + expect(relayRuntimeStepAfterRefusal(ladder, 'B', 'runtime_unavailable', false)).toBe('C') }) - it('goes straight to D on noexec, which defeats every rung in the same tree', () => { + it('walks a Windows host from a refused A through B and C, which refuse there, to host Node', () => { const ladder = relayRuntimeLadder('pinned-node') - expect(nextRelayRuntimeStep(ladder, 'A', 'noexec')).toBe('D') - expect(nextRelayRuntimeStep(ladder, 'C', 'noexec')).toBe('D') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'security_software', false)).toBe('B') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'missing_lib', true)).toBe('B') + expect(relayRuntimeStepAfterRefusal(ladder, 'B', 'runtime_unavailable', false)).toBe('C') + expect(relayRuntimeStepAfterRefusal(ladder, 'C', 'windows_host_unsupported', false)).toBe( + 'legacy' + ) }) it('lets a remembered noexec skip only its own rung, so a remounted home is re-proved', () => { const ladder = relayRuntimeLadder('pinned-node') - expect(nextRelayRuntimeStep(ladder, 'A', 'noexec', true)).toBe('B') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'noexec', true)).toBe('B') }) - it('skips the npm path when rung C found no host Node, since npm needs one too', () => { + it('falls back past a refused ladder to host Node, and to D only on proof', () => { const ladder = relayRuntimeLadder('pinned-node') - expect(nextRelayRuntimeStep(ladder, 'C', 'host_node_missing')).toBe('D') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'artifacts_unavailable', false)).toBe('B') + for (const reason of [ + 'artifacts_unavailable', + 'host_node_missing', + 'libc_floor', + 'target_unresolved' + ] as const) { + expect(relayRuntimeStepAfterRefusal(ladder, 'C', reason, false)).toBe('legacy') + } + // A noexec skips B and C (same tree) but still gets the fallback: exec denial can be per binary. + expect(relayRuntimeStepAfterRefusal(ladder, 'C', 'noexec', false)).toBe('legacy') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'noexec', false)).toBe('legacy') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'install_failed', false)).toBe('B') + // Only the fallback itself proving no host Node lands on D. + for (const reason of ['host_node_missing', 'install_failed', 'noexec'] as const) { + expect(relayRuntimeStepAfterRefusal(ladder, 'legacy', reason, false)).toBe('D') + } }) it('chooses rung B only when a listed compat runtime serves the host', () => { @@ -113,19 +135,73 @@ describe('relay runtime ladder (design D6)', () => { ) }) + const linuxD: RemoteRuntimeUnavailableState = { + firstRefusal: 'missing_lib', + hostNodeRefusal: 'host_node_missing', + noexec: null, + hostOs: 'linux' + } + it('names the rung D reason in words the user can act on', () => { - expect(remoteRuntimeUnavailableReason('noexec')).toBe('home_noexec') - expect(remoteRuntimeUnavailableReason('host_node_missing')).toBe('no_runtime') - expect(remoteRuntimeUnavailableMessage('home_noexec', 'noexec')).toContain('mounted noexec') - expect(remoteRuntimeUnavailableMessage('no_runtime', 'host_node_missing')).toContain( - 'Install Node.js 18+' + expect(remoteRuntimeUnavailable(linuxD)).toEqual({ + reason: 'no_runtime', + message: expect.stringContaining( + "Install Node.js 18+ and npm on the host, then reconnect. (Orca's Node: missing_lib)" + ) + }) + const noexec = remoteRuntimeUnavailable({ ...linuxD, firstRefusal: 'noexec', noexec: 'proved' }) + expect(noexec.reason).toBe('home_noexec') + expect(noexec.message).toContain('mounted noexec') + expect(noexec.message).not.toContain("Orca's Node:") + expect(remoteRuntimeUnavailable({ ...linuxD, firstRefusal: null }).message).not.toContain( + "Orca's Node:" ) }) it('never advises installing Node when a remembered noexec defeated the tree', () => { - expect(remoteRuntimeUnavailableReason('host_node_missing', true)).toBe('home_noexec') - const message = remoteRuntimeUnavailableMessage('home_noexec', 'noexec', true) + const { reason, message } = remoteRuntimeUnavailable({ ...linuxD, noexec: 'remembered' }) + expect(reason).toBe('home_noexec') expect(message).toContain('earlier connect found the home directory') expect(message).not.toContain('Install Node.js') }) + + it('blames this copy of Orca when its artifacts were missing and the host has no Node', () => { + const { message } = remoteRuntimeUnavailable({ + ...linuxD, + firstRefusal: 'artifacts_unavailable' + }) + expect(message).toContain('this copy of Orca could not prepare its bundled') + expect(message).toContain("(Orca's Node: artifacts_unavailable)") + }) + + it('reports a failed host-Node fallback install unless noexec explains it', () => { + const failed = { ...linuxD, hostNodeRefusal: 'install_failed' as const } + expect(remoteRuntimeUnavailable(failed).message).toContain( + "also failed to install. Check the host's disk space" + ) + expect(remoteRuntimeUnavailable({ ...failed, firstRefusal: null }).message).toContain( + "(Orca's Node: none)" + ) + expect(remoteRuntimeUnavailable({ ...failed, noexec: 'proved' }).message).toContain( + 'mounted noexec' + ) + const windows = remoteRuntimeUnavailable({ ...failed, hostOs: 'win32', noexec: 'proved' }) + expect(windows).toMatchObject({ reason: 'no_runtime' }) + expect(windows.message).toContain('also failed to install') + }) + + it('words a Windows rung D for Windows, never as a noexec mount', () => { + const { reason, message } = remoteRuntimeUnavailable({ + ...linuxD, + firstRefusal: 'noexec', + noexec: 'proved', + hostOs: 'win32' + }) + expect(reason).toBe('no_runtime') + expect(message).toContain('Windows host') + expect(message).toContain("Orca's Node.js through security software") + expect(message).toContain("(Orca's Node: noexec)") + expect(message).not.toContain('mounted') + expect(message).not.toContain('~/.orca-remote') + }) }) diff --git a/src/main/ssh/ssh-relay-runtime-ladder.ts b/src/main/ssh/ssh-relay-runtime-ladder.ts index 0a6ae2deab2..c75b7a078f9 100644 --- a/src/main/ssh/ssh-relay-runtime-ladder.ts +++ b/src/main/ssh/ssh-relay-runtime-ladder.ts @@ -1,14 +1,15 @@ /** * The design D6 fallback ladder for the relay runtime, as data plus a pure step function: * - * A Orca's pinned Node + slot prebuilds - * B a compat pinned Node + compat addons (chosen only when a compat runtime exists) - * C the host's Node >= 18 + Orca's N-API prebuilds, no npm - * legacy the host's Node + npm install (kept until the default flips) - * D nothing runs: plain SSH terminals and SFTP, recording the classified reason + * A Orca's pinned Node + slot prebuilds + * B a compat pinned Node + compat addons (chosen only when a compat runtime exists) + * C the host's Node >= 18 + Orca's N-API prebuilds, no npm + * legacy the host's Node + npm install, unsupported; the only rung when the user opts in + * D nothing runs: plain SSH terminals and SFTP, recording the classified reason * - * The ladder steps down only on a classified refusal (a `PinnedRelayFallbackError`); an - * unverifiable probe or self-test throws and the next connect retries the same rung. + * The ladder steps down on a classified refusal (a `PinnedRelayFallbackError`), including an + * answered host failure the deploy wraps as `install_failed`; an unverifiable probe or self-test + * throws and the next connect retries the same rung. */ import { COMPAT_SERVER_TARGET_BASES, @@ -20,6 +21,7 @@ import { } from '../../shared/node-runtime-pin' import type { SshRemoteRuntime, SshRemoteRuntimeRung } from '../../shared/ssh-types' import type { GlibcVersion } from './orcad-deployment-target' +import type { RemoteOperatingSystem } from './ssh-remote-platform' import { isGlibcBelow, PINNED_NODE_GLIBC_FLOOR, @@ -49,6 +51,7 @@ export const COMPAT_RELAY_RUNTIMES: readonly CompatRelayRuntime[] = [ ] export function relayRuntimeLadder(runtime: SshRemoteRuntime): readonly RelayRuntimeStep[] { + // Why legacy before D: the unsupported host-npm fallback keeps the never-worse invariant. return runtime === 'pinned-node' ? ['A', 'B', 'C', 'legacy', 'D'] : ['legacy'] } @@ -120,35 +123,30 @@ export function relayRuntimeStorePins( export type RelayRuntimeStepReason = RelayRuntimeFallbackReason /** - * noexec defeats every rung, because each loads addons from the same `~/.orca-remote` tree; - * no host Node rules out the npm path too, since it needs a host Node as well. A remembered - * refusal only skips its own rung: the mount may have changed since it was proved. + * The invariant: no host does worse than the pre-ladder default. A, B and C are tried first + * (no host compile where Orca's runtime works); any refusal past them falls back to exactly + * that default, the host-Node relay (`legacy`, marked unsupported). D is reached only when that + * fallback itself answered with a failure, so D is a superset of the default's outcome. Windows + * reaches legacy because B and C refuse there before any host I/O. */ -export function nextRelayRuntimeStep( +export function relayRuntimeStepAfterRefusal( ladder: readonly RelayRuntimeStep[], current: RelayRuntimeStep, reason: RelayRuntimeStepReason, - remembered = false + remembered: boolean ): RelayRuntimeStep { - if ((reason === 'noexec' && !remembered) || (current === 'C' && reason === 'host_node_missing')) { - return 'D' + // Why noexec skips B and C: they load addons from the same tree; only the fallback can disprove it. + // A remembered noexec only skips its own rung: the mount may have changed since it was proved. + if (reason === 'noexec' && !remembered && current !== 'legacy') { + return 'legacy' } - const index = ladder.indexOf(current) - return ladder[index + 1] ?? 'D' + return ladder[ladder.indexOf(current) + 1] ?? 'D' } /** The machine-readable part of a rung D failure; the message is what the user reads. */ export const REMOTE_RUNTIME_UNAVAILABLE_REASONS = ['home_noexec', 'no_runtime'] as const export type RemoteRuntimeUnavailableReason = (typeof REMOTE_RUNTIME_UNAVAILABLE_REASONS)[number] -/** A noexec seen anywhere in the pass, remembered or proved, rules out advising a host Node. */ -export function remoteRuntimeUnavailableReason( - lastReason: RelayRuntimeStepReason | null, - noexecSeen = false -): RemoteRuntimeUnavailableReason { - return lastReason === 'noexec' || noexecSeen ? 'home_noexec' : 'no_runtime' -} - const REMOTE_RUNTIME_UNAVAILABLE_MESSAGES: Record = { home_noexec: "Orca can't run its remote runtime on this host: the home directory is mounted noexec, so " + @@ -156,7 +154,8 @@ const REMOTE_RUNTIME_UNAVAILABLE_MESSAGES: Record = { relay: 2, plain_ssh: 1, failed: 0 } + +/** Before the ladder: every connect ran the host-Node relay, and any failure failed the connect. */ +function baseOutcome(host: HostCase): Outcome { + return host.hostNode === 'launch' ? { kind: 'relay', rung: 'legacy' } : { kind: 'failed' } +} + +/** The deploy loop: answered refusals step on; a launch failure propagates. */ +function ladderOutcome(host: HostCase): Outcome { + const ladder = relayRuntimeLadder('pinned-node') + let step: RelayRuntimeStep = ladder[0]! + for (let guard = 0; guard < 10; guard++) { + let answer: RungAnswer + switch (step) { + case 'D': + return { kind: 'plain_ssh' } + case 'legacy': + if (host.hostNode === 'launch') { + return { kind: 'relay', rung: 'legacy' } + } + if (host.hostNode === 'launch_fails') { + return { kind: 'failed' } + } + answer = host.hostNode === 'no_node' ? 'host_node_missing' : 'install_failed' + break + case 'A': + answer = host.a + break + case 'B': + answer = host.os === 'win32' ? 'runtime_unavailable' : host.b + break + case 'C': + answer = host.os === 'win32' ? 'windows_host_unsupported' : host.c + break + } + if (answer === 'launch') { + return { kind: 'relay', rung: step } + } + const remembered = step === 'A' ? host.rememberedA : step === 'B' ? host.rememberedB : false + step = relayRuntimeStepAfterRefusal(ladder, step, answer, remembered) + } + throw new Error(`ladder did not settle for ${host.id}`) +} + +const A_ANSWERS: readonly RungAnswer[] = [ + 'launch', + 'noexec', + 'missing_lib', + 'libc_floor', + 'illegal_instruction', + 'wrong_libc', + 'security_software', + 'target_unresolved', + 'artifacts_unavailable', + 'install_failed' +] +const B_ANSWERS: readonly RungAnswer[] = [ + 'launch', + 'noexec', + 'runtime_unavailable', + 'install_failed' +] +const C_ANSWERS: readonly RungAnswer[] = [ + 'launch', + 'host_node_missing', + 'noexec', + 'libc_floor', + 'artifacts_unavailable', + 'target_unresolved', + 'install_failed' +] +const HOST_NODE_ANSWERS: readonly HostNodeAnswer[] = [ + 'launch', + 'no_node', + 'install_fails', + 'launch_fails' +] +const OSES: readonly RemoteOperatingSystem[] = ['linux', 'win32'] +const REMEMBERED = [false, true] as const +const replayTag = (remembered: boolean): string => (remembered ? '(remembered)' : '') + +const CROSS_PRODUCT: HostCase[] = OSES.flatMap((os) => + A_ANSWERS.flatMap((a) => + B_ANSWERS.flatMap((b) => + C_ANSWERS.flatMap((c) => + HOST_NODE_ANSWERS.flatMap((hostNode) => + REMEMBERED.flatMap((rememberedA) => + REMEMBERED.map((rememberedB) => ({ + id: `${os} A:${a}${replayTag(rememberedA)} B:${b}${replayTag(rememberedB)} C:${c} host:${hostNode}`, + os, + a, + b, + c, + hostNode, + rememberedA, + rememberedB + })) + ) + ) + ) + ) + ) +) + +function violations(host: HostCase): string[] { + const base = baseOutcome(host) + const ladder = ladderOutcome(host) + const found: string[] = [] + if (RANK[ladder.kind] < RANK[base.kind]) { + found.push(`${host.id}: ${ladder.kind} is worse than base ${base.kind}`) + } + if (host.a === 'launch' && ladder.kind === 'relay' && ladder.rung !== 'A') { + found.push(`${host.id}: compiled on the host though rung A runs`) + } + // D only when the host-Node default itself answered with a failure. + if (ladder.kind === 'plain_ssh' && !['no_node', 'install_fails'].includes(host.hostNode)) { + found.push(`${host.id}: plain SSH without the default failing`) + } + return found +} + +describe('relay runtime ladder vs the pre-ladder host-Node default', () => { + it(`is never worse across all ${CROSS_PRODUCT.length} host combinations`, () => { + expect(CROSS_PRODUCT.flatMap(violations)).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-resolution.test.ts b/src/main/ssh/ssh-relay-runtime-resolution.test.ts index 98e225306e0..ac04575ea95 100644 --- a/src/main/ssh/ssh-relay-runtime-resolution.test.ts +++ b/src/main/ssh/ssh-relay-runtime-resolution.test.ts @@ -27,7 +27,7 @@ function memoryStore(): RelayRuntimeDecisionStore & { value?: SshRemoteRuntimeRe } function rememberedNoexecRun(store: RelayRuntimeDecisionStore): RelayRuntimeLadderRun { - const run = new RelayRuntimeLadderRun('ssh-1', store) + const run = new RelayRuntimeLadderRun('ssh-1', store, true) run.host = getRemoteHostPlatform('linux-x64') run.facts = facts run.refused('A', 'noexec') @@ -40,7 +40,7 @@ describe('RelayRuntimeLadderRun', () => { }) it('reports a rung whose self-test was unverifiable or failed, and nothing else', () => { - const run = new RelayRuntimeLadderRun('ssh-1', null) + const run = new RelayRuntimeLadderRun('ssh-1', null, true) run.host = getRemoteHostPlatform('linux-x64') run.facts = facts run.unresolved('A') @@ -63,7 +63,7 @@ describe('RelayRuntimeLadderRun', () => { const store = memoryStore() rememberedNoexecRun(store).settle('D') expect(store.value).toMatchObject({ rung: 'D', pinnedRefusal: 'noexec', glibc: '2.31' }) - const replay = new RelayRuntimeLadderRun('ssh-1', store) + const replay = new RelayRuntimeLadderRun('ssh-1', store, true) expect(replay.persistedPinnedRefusal(facts)).toBe('noexec') expect(replay.persistedPinnedRefusal({ ...facts, glibc: { major: 2, minor: 35 } })).toBeNull() }) @@ -85,7 +85,7 @@ describe('RelayRuntimeLadderRun', () => { it('drops a replayed noexec at rung D, so a remounted home is re-proved next connect', () => { const store = memoryStore() - const run = new RelayRuntimeLadderRun('ssh-1', store) + const run = new RelayRuntimeLadderRun('ssh-1', store, true) run.host = getRemoteHostPlatform('linux-x64') run.facts = facts run.refused('A', 'noexec', true) @@ -97,7 +97,7 @@ describe('RelayRuntimeLadderRun', () => { }) it('reports a remembered noexec at rung D without advising a host Node install', () => { - const run = new RelayRuntimeLadderRun('ssh-1', null) + const run = new RelayRuntimeLadderRun('ssh-1', null, true) run.host = getRemoteHostPlatform('linux-x64') run.facts = facts run.refused('A', 'noexec', true) @@ -109,8 +109,16 @@ describe('RelayRuntimeLadderRun', () => { expect(error).toMatchObject({ data: { reason: 'home_noexec' } }) }) + it('keeps a replayed noexec remembered when a later rung proves noexec again', () => { + const run = new RelayRuntimeLadderRun('ssh-1', null, true) + run.refused('A', 'noexec', true) + run.refused('legacy', 'noexec') + expect(run.noexec).toBe('remembered') + expect(remoteRuntimeUnavailableError(run).message).toContain('earlier connect found') + }) + it('still advises a host Node when rung A refused for another reason', () => { - const run = new RelayRuntimeLadderRun('ssh-1', null) + const run = new RelayRuntimeLadderRun('ssh-1', null, true) run.refused('A', 'libc_floor') run.refused('C', 'host_node_missing') expect(remoteRuntimeUnavailableError(run).message).toContain('Install Node.js 18+') diff --git a/src/main/ssh/ssh-relay-runtime-resolution.ts b/src/main/ssh/ssh-relay-runtime-resolution.ts index 19fcf28c4a9..b469531ef4c 100644 --- a/src/main/ssh/ssh-relay-runtime-resolution.ts +++ b/src/main/ssh/ssh-relay-runtime-resolution.ts @@ -11,14 +11,13 @@ import type { } from '../../shared/ssh-types' import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' import type { OrcadDeploymentTargetFacts } from './orcad-deployment-target' +import { isPinnedRuntimeRefusal, type RelayRuntimeFallbackReason } from './ssh-relay-pinned-node' import { forgetPinnedRuntimeRefusal, - isPinnedRuntimeRefusal, - type RelayRuntimeFallbackReason -} from './ssh-relay-pinned-node' + isPinnedRefusalExpired +} from './ssh-relay-pinned-refusal-cache' import { - remoteRuntimeUnavailableMessage, - remoteRuntimeUnavailableReason, + remoteRuntimeUnavailable, type RelayRuntimeStep, type RemoteRuntimeUnavailableReason } from './ssh-relay-runtime-ladder' @@ -71,7 +70,11 @@ export function persistedPinnedRefusal( if (!decision?.pinnedRefusal || !sameKey(decision, relayRuntimeDecisionKey(facts))) { return null } - return isPinnedRuntimeRefusal(decision.pinnedRefusal) ? decision.pinnedRefusal : null + const refusal = decision.pinnedRefusal + return isPinnedRuntimeRefusal(refusal) && + !isPinnedRefusalExpired(refusal, facts.target, decision.refusedAt) + ? refusal + : null } export class RelayRuntimeLadderRun { @@ -81,15 +84,25 @@ export class RelayRuntimeLadderRun { firstRefusal: RelayRuntimeFallbackReason | null = null lastRefusal: RelayRuntimeFallbackReason | null = null pinnedRefusal: PinnedRuntimeRefusal | null = null - /** A noexec this pass replayed from a cache rather than proved. */ - noexecRemembered = false + /** The pinned refusal was replayed from a cache rather than proved on this pass. */ + pinnedRefusalRemembered = false + /** A noexec this pass replayed from a cache, else proved; a replay is never downgraded. */ + noexec: 'remembered' | 'proved' | null = null + /** A rung refused because this client lacked Orca's artifacts; nothing it proves is the host's. */ + clientArtifactGap = false + /** The Node the fallback's strict probe found, reused by the launch. */ + hostNodePath: string | null = null + /** This step issued its relay launch; failures after it never step down. */ + launchStarted = false selfTest: RelayRuntimeSelfTestOutcome = 'not_run' runtimeTransfer: RelayRuntimeTransfer = 'none' hostNode: HostNodeVersion | null = null constructor( readonly targetId: string, - private readonly store: RelayRuntimeDecisionStore | null + private readonly store: RelayRuntimeDecisionStore | null, + /** A laddered legacy step is the host-Node fallback, so a host with no Node lands on D. */ + readonly laddered: boolean ) {} persistedPinnedRefusal(facts: OrcadDeploymentTargetFacts): PinnedRuntimeRefusal | null { @@ -99,11 +112,15 @@ export class RelayRuntimeLadderRun { refused(step: RelayRuntimeStep, reason: RelayRuntimeFallbackReason, remembered = false): void { this.firstRefusal ??= reason this.lastRefusal = reason - if (reason === 'noexec' && remembered) { - this.noexecRemembered = true + if (reason === 'artifacts_unavailable') { + this.clientArtifactGap = true + } + if (reason === 'noexec') { + this.noexec = remembered ? 'remembered' : (this.noexec ?? 'proved') } if (step === 'A' && isPinnedRuntimeRefusal(reason)) { this.pinnedRefusal = reason + this.pinnedRefusalRemembered = remembered } } @@ -119,14 +136,17 @@ export class RelayRuntimeLadderRun { // Why also at D on a replayed noexec: nothing connects there, so the next connect re-proves A // instead of the message's "allow exec" advice being unfixable. const disproved = rung === 'C' && this.selfTest === 'passed' - const replayedAtD = rung === 'D' && this.noexecRemembered + const replayedAtD = rung === 'D' && this.noexec === 'remembered' if ((disproved || replayedAtD) && this.pinnedRefusal === 'noexec') { this.pinnedRefusal = null if (this.facts) { forgetPinnedRuntimeRefusal(this.targetId, this.facts.target) } } - this.persist(rung) + // Why: a pass shaped by this client's missing artifacts is no decision about the host. + if (!this.clientArtifactGap) { + this.persist(rung) + } this.track(rung, 'resolved') } @@ -162,12 +182,18 @@ export class RelayRuntimeLadderRun { if (!this.store || !this.facts) { return } + const previous = this.store.read(this.targetId) + // Why keep the earlier time on a replay: a replayed refusal must still expire on schedule. + const refusedAt = + this.pinnedRefusalRemembered && previous?.pinnedRefusal === this.pinnedRefusal + ? (previous.refusedAt ?? Date.now()) + : Date.now() const next: SshRemoteRuntimeResolution = { rung, ...(this.pinnedRefusal ? { pinnedRefusal: this.pinnedRefusal } : {}), + ...(this.pinnedRefusal ? { refusedAt } : {}), ...relayRuntimeDecisionKey(this.facts) } - const previous = this.store.read(this.targetId) if (JSON.stringify(previous) !== JSON.stringify(next)) { try { this.store.write(this.targetId, next) @@ -181,14 +207,19 @@ export class RelayRuntimeLadderRun { /** Rung D: no runtime runs on this host. Carries the classified cause for structured readers. */ export class RemoteRuntimeUnavailableError extends Error { + readonly reason: RemoteRuntimeUnavailableReason readonly data: TerminalUnavailableCause - constructor( - readonly reason: RemoteRuntimeUnavailableReason, - run: RelayRuntimeLadderRun - ) { - super(remoteRuntimeUnavailableMessage(reason, run.firstRefusal, run.noexecRemembered)) + constructor(run: RelayRuntimeLadderRun) { + const { reason, message } = remoteRuntimeUnavailable({ + firstRefusal: run.firstRefusal, + hostNodeRefusal: run.lastRefusal, + noexec: run.noexec, + hostOs: run.host?.os ?? null + }) + super(message) this.name = 'RemoteRuntimeUnavailableError' + this.reason = reason const glibc = run.facts?.glibc this.data = { status: 'blocked', @@ -212,10 +243,7 @@ export class RemoteRuntimeUnavailableError extends Error { } export function remoteRuntimeUnavailableError(run: RelayRuntimeLadderRun): Error { - return new RemoteRuntimeUnavailableError( - remoteRuntimeUnavailableReason(run.lastRefusal, run.noexecRemembered), - run - ) + return new RemoteRuntimeUnavailableError(run) } export function sshTargetRelayRuntimeDecisionStore(registry: { diff --git a/src/main/ssh/ssh-relay-runtime-self-test.test.ts b/src/main/ssh/ssh-relay-runtime-self-test.test.ts index 475c38f4d3e..aa59dfbb825 100644 --- a/src/main/ssh/ssh-relay-runtime-self-test.test.ts +++ b/src/main/ssh/ssh-relay-runtime-self-test.test.ts @@ -47,6 +47,13 @@ function report(nonce: string, fields: Record): string { })}` } +const NIXOS_STUB_LD_OUTPUT = [ + 'Could not start dynamically linked executable: /home/u/.orca-remote/runtimes/node-x/bin/node', + 'NixOS cannot run dynamically linked executables intended for generic', + 'linux environments out of the box. For more information, see:', + 'https://nix.dev/permalink/stub-ld' +].join('\n') + describe('pinned runtime refusal classification', () => { it.each([ [132, '', 'illegal_instruction'], @@ -109,6 +116,13 @@ describe('pinned runtime refusal classification', () => { it('leaves an unrecognized failure unclassified', () => { expect(classifyPinnedRuntimeFailure(1, 'TypeError: something else')).toBeNull() }) + + it("classifies NixOS's stub loader refusing a generic Linux binary as wrong_libc", () => { + expect(classifyPinnedRuntimeFailure(127, NIXOS_STUB_LD_OUTPUT)).toBe('wrong_libc') + // Only the loader's own exit; an unrelated 127 stays unclassified. + expect(classifyPinnedRuntimeFailure(1, NIXOS_STUB_LD_OUTPUT)).toBeNull() + expect(classifyPinnedRuntimeFailure(127, 'relay.js exited')).toBeNull() + }) }) describe('self-test commands on a real shell', () => { diff --git a/src/main/ssh/ssh-relay-runtime-self-test.ts b/src/main/ssh/ssh-relay-runtime-self-test.ts index 90deca60915..371cee5f704 100644 --- a/src/main/ssh/ssh-relay-runtime-self-test.ts +++ b/src/main/ssh/ssh-relay-runtime-self-test.ts @@ -73,6 +73,10 @@ export function classifyPinnedRuntimeFailure( if (/\b(?:GLIBC|GLIBCXX|CXXABI)_[0-9.]+'? not found/.test(output)) { return 'libc_floor' } + // NixOS's stub loader answers in place of the generic ELF interpreter it does not ship. + if (exitStatus === 127 && /Could not start dynamically linked executable/.test(output)) { + return 'wrong_libc' + } // A glibc binary on musl (or the reverse) fails in the ELF interpreter or on relocation. if (/ld-linux[\w.-]*\.so|ld-musl[\w.-]*\.so/i.test(output)) { return 'wrong_libc' diff --git a/src/main/ssh/ssh-relay-runtime-step-plan.ts b/src/main/ssh/ssh-relay-runtime-step-plan.ts index 3495520f683..b9ae50026f4 100644 --- a/src/main/ssh/ssh-relay-runtime-step-plan.ts +++ b/src/main/ssh/ssh-relay-runtime-step-plan.ts @@ -9,6 +9,7 @@ import { import { rungBCompatRuntimeFor, type RelayRuntimeStep } from './ssh-relay-runtime-ladder' import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' import type { RemoteHostPlatform } from './ssh-remote-platform' +import { RemoteNodeNotFoundError, resolveRemoteNodePath } from './ssh-remote-node-resolution' import type { OrcadDeploymentTargetFacts } from './orcad-deployment-target' type StepPlanOptions = { @@ -37,6 +38,22 @@ async function ladderTargetFacts(options: StepPlanOptions): Promise { + try { + return await resolveRemoteNodePath(conn, host, { signal, strict: true }) + } catch (error) { + if (error instanceof RemoteNodeNotFoundError) { + throw new PinnedRelayFallbackError('host_node_missing', 'no host Node.js 18+ with npm') + } + throw error + } +} + /** Undefined for the host-npm path; throws `PinnedRelayFallbackError` when a rung cannot run. */ export async function planRelayRuntimeStep( options: StepPlanOptions @@ -45,6 +62,9 @@ export async function planRelayRuntimeStep( run.host = host switch (step) { case 'legacy': + if (run.laddered) { + run.hostNodePath = await proveHostNodeForFallback(conn, host, signal) + } return undefined case 'A': { const facts = await ladderTargetFacts(options) diff --git a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts b/src/main/ssh/ssh-relay-session-plain-ssh.test.ts index c76209cd621..d5077a2fbf4 100644 --- a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts +++ b/src/main/ssh/ssh-relay-session-plain-ssh.test.ts @@ -62,11 +62,11 @@ class FakeShellChannel extends EventEmitter { } function runtimeUnavailable(): RemoteRuntimeUnavailableError { - const run = new RelayRuntimeLadderRun('target-1', null) + const run = new RelayRuntimeLadderRun('target-1', null, true) run.host = getRemoteHostPlatform('linux-x64') run.refused('A', 'libc_floor') run.refused('C', 'host_node_missing') - return new RemoteRuntimeUnavailableError('no_runtime', run) + return new RemoteRuntimeUnavailableError(run) } function createConnection(systemSsh = false) { diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index c186e922e93..0e9e0c03579 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -26,6 +26,7 @@ export function createMockDeps(): SshRelaySessionTestDeps { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The relay fixture implements the Store methods exercised by session establishment and teardown. const mockStore = { getRepos: vi.fn().mockReturnValue([]), + getSshTarget: vi.fn().mockReturnValue(undefined), getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index e7f7c60a15c..b53000f8352 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -114,6 +114,7 @@ import { SshOwnerAdmissionBlockedError } from './ssh-owner-admission-blocked-error' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' import { acknowledgeRemoteOrcaCliPostOutput, parseRemoteOrcaCliPostOutput @@ -1581,6 +1582,15 @@ export class SshRelaySession { } } + // Why: read per invocation so turning the per-host opt-in off takes effect on the next command. + private remoteCliCallerScope(): SshBridgeCallerScope { + return { + kind: 'ssh-bridge', + targetId: this.targetId, + remoteCliControl: this.store.getSshTarget(this.targetId)?.allowRemoteCliControl === true + } + } + private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer, connectionIncarnation: string): void { mux.onRequest('orca.cli', async (params) => { if (!this.runtime) { @@ -1614,7 +1624,8 @@ export class SshRelaySession { env, ...(stdin !== undefined ? { stdin } : {}), ...(artifactInput ? { artifactInput } : {}), - runtimeAuthority + runtimeAuthority, + callerScope: this.remoteCliCallerScope() }) } finally { this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId) @@ -1644,7 +1655,8 @@ export class SshRelaySession { await acknowledgeRemoteOrcaCliPostOutput(this.runtime, { postOutput: parseRemoteOrcaCliPostOutput(params.postOutput), env, - runtimeAuthority + runtimeAuthority, + callerScope: this.remoteCliCallerScope() }) return { acknowledged: true } } finally { diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 5bf0cc3ab53..c950176dc70 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -323,8 +323,11 @@ describe('relay install writes on a split SFTP namespace', () => { let capture: Capture let warnSpy: ReturnType + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockImplementation((_sftp, _local, remote: string) => { capture.uploadTargets.push(remote) @@ -585,8 +588,11 @@ describe('relay repair writes on a split SFTP namespace', () => { let capture: Capture let warnSpy: ReturnType + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') diff --git a/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts b/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts new file mode 100644 index 00000000000..a8105f141e8 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts @@ -0,0 +1,170 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + +import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' +import { SshBridgeCredentialRegistry } from '../runtime/rpc/ssh-bridge-credentials' +import { runHostOrcaCliPassthrough } from './ssh-remote-cli-host-passthrough' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { HOST_BOUND_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' + +class FakeChild extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + readonly stdin = { end: vi.fn(), on: vi.fn() } + readonly kill = vi.fn() +} + +const RUNTIME_AUTHORITY = { + kind: 'ssh', + targetId: 'box-1', + connectionIncarnation: 'incarnation-1', + attachmentId: 'attachment-1' +} as const + +describe('the SSH bridge CLI child', () => { + it('holds a credential scoped to its SSH target, never the owner token, for its lifetime only', async () => { + const credentials = new SshBridgeCredentialRegistry() + const child = new FakeChild() + let spawnedEnv: NodeJS.ProcessEnv = {} + const spawn = vi.fn((_exec: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + spawnedEnv = options.env + return child + }) + + const pending = runHostOrcaCliPassthrough( + { + argv: ['terminal', 'list', '--json'], + cwd: '/home/alice/wt', + env: { ORCA_ENVIRONMENT: 'prod' }, + runtimeAuthority: RUNTIME_AUTHORITY, + callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE + }, + { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true, + hostEnv: { + PATH: '/usr/bin', + ORCA_ENVIRONMENT: 'owner-paired-server', + ORCA_PAIRING_CODE: 'owner-pairing-code', + [ORCA_SSH_BRIDGE_CREDENTIAL_ENV]: 'stale-or-forged' + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every ChildProcess member the passthrough touches. + spawn: spawn as never, + credentials + } + ) + + const token = spawnedEnv[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] + expect(token).toMatch(/^sshb_[0-9a-f]{48}$/) + expect(credentials.resolve(token ?? '')).toEqual(HOST_BOUND_SSH_BRIDGE_SCOPE) + // Why: this machine's paired-server selection would route the guest's command with owner credentials. + expect(spawnedEnv.ORCA_ENVIRONMENT).toBeUndefined() + expect(spawnedEnv.ORCA_PAIRING_CODE).toBeUndefined() + + child.emit('close', 0) + await pending + expect(credentials.resolve(token ?? '')).toBeNull() + }) + + it('revokes the credential when the child cannot be launched', async () => { + const credentials = new SshBridgeCredentialRegistry() + const child = new FakeChild() + let token = '' + const spawn = vi.fn((_exec: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + token = options.env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] ?? '' + return child + }) + const pending = runHostOrcaCliPassthrough( + { argv: ['status'], cwd: '/', env: {}, callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE }, + { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true, + hostEnv: {}, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every ChildProcess member the passthrough touches. + spawn: spawn as never, + credentials + } + ) + child.emit('error', new Error('ENOENT')) + await expect(pending).rejects.toThrow('Failed to launch') + expect(credentials.resolve(token)).toBeNull() + }) +}) + +describe('the in-process fallback bridge', () => { + const LEGACY_FALLBACK_OPTIONS = { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => false + } + + it('applies the host-bound scope to orchestration it would otherwise relay as owner', async () => { + const runtime = new OrcaRuntimeService() + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'send', '--to', 'term_local', '--subject', 'hi', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_remote' }, + callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE + }, + LEGACY_FALLBACK_OPTIONS + ) + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout).error.code).toBe('forbidden') + }) + + it("filters the fallback terminal list to the bridged host's terminals", async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [ + { + handle: 'term_local', + ptyId: 'pty-1', + worktreeId: 'repo::/Users/me/secret', + worktreePath: '/Users/me/secret', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'owner shell', + connected: true, + writable: true, + lastOutputAt: null, + preview: 'owner secrets', + executionHostId: 'local' + } + ], + totalCount: 1, + truncated: false + }) + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['terminal', 'list', '--json'], + cwd: '/home/alice/repo', + env: {}, + callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE + }, + LEGACY_FALLBACK_OPTIONS + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).not.toContain('owner secrets') + expect(JSON.parse(result.stdout).result.terminals).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts index cd0503348d0..b54a855596d 100644 --- a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts @@ -13,6 +13,7 @@ vi.mock('../persistence', () => ({ import { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' // Why: the SSH bridge captures the host CLI child's stdout and exit code without reparsing; this // pins that a typed refusal envelope and its nonzero exit reach the remote agent unchanged. @@ -42,6 +43,7 @@ it('relays typed dispatch refusal codes from the host CLI unchanged', async () = const resultPromise = runRemoteOrcaCli( new OrcaRuntimeService(), { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_w', '--json'], cwd: '/home/alice/repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index 811917cf715..fe0c8a4260d 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -29,6 +29,7 @@ import { ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV } from '../../shared/orchestration-compatibility-evidence' import { REMOTE_ARTIFACT_INPUT_ENV } from '../../shared/artifact-cli-bridge' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' type FakeChild = EventEmitter & { stdout: EventEmitter @@ -67,6 +68,7 @@ describe('resolveHostCliEntryPath', () => { describe('buildHostCliEnv', () => { it('forwards only Orca terminal-context vars from the remote env', () => { const env = buildHostCliEnv({ + bridgeCredential: 'sshb_test', hostEnv: { PATH: '/host/bin', NODE_OPTIONS: '--inspect' }, remoteEnv: { ORCA_TERMINAL_HANDLE: 'term_remote', @@ -120,6 +122,7 @@ describe('buildHostCliEnv', () => { ['Windows host', { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }] ])('pins %s recovery to the remote shim', (_name, hostEnv) => { const env = buildHostCliEnv({ + bridgeCredential: 'sshb_test', hostEnv, remoteEnv: { ORCA_CLI_COMMAND: 'untrusted-remote-command' }, userDataPath: '/host/user-data', @@ -133,6 +136,7 @@ describe('buildHostCliEnv', () => { // The host's env carries a session id when Orca was launched inside a structured session; the // remote shell's own is from another machine. Session identity is same-host only. const env = buildHostCliEnv({ + bridgeCredential: 'sshb_test', hostEnv: { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', ORCA_STRUCTURED_SESSION: '1' @@ -159,6 +163,7 @@ describe('buildHostCliEnv', () => { } const build = (targetId: string) => buildHostCliEnv({ + bridgeCredential: 'sshb_test', hostEnv: {}, remoteEnv: {}, userDataPath: '/host/user-data', @@ -289,6 +294,7 @@ describe('runHostOrcaCliPassthrough', () => { const resultPromise = runHostOrcaCliPassthrough( { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'task-create', '--spec', 'do the thing', '--json'], cwd: '/home/alice/wt', env: { ORCA_TERMINAL_HANDLE: 'term_remote' } @@ -333,6 +339,7 @@ describe('runHostOrcaCliPassthrough', () => { const resultPromise = runHostOrcaCliPassthrough( { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['linear', 'comment', 'add', 'ENG-1', '--body-file', '-'], cwd: '/home/alice/wt', env: {}, @@ -353,7 +360,12 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn(() => child) const resultPromise = runHostOrcaCliPassthrough( - { argv: ['worktree', 'show'], cwd: '/', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['worktree', 'show'], + cwd: '/', + env: {} + }, { ...BASE_OPTIONS, spawn: spawn as never } ) @@ -368,7 +380,7 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn() await expect( runHostOrcaCliPassthrough( - { argv: ['status'], cwd: '/', env: {} }, + { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, { ...BASE_OPTIONS, entryExists: () => false, spawn: spawn as never } ) ).rejects.toBeInstanceOf(HostCliUnavailableError) @@ -379,7 +391,7 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn() await expect( runHostOrcaCliPassthrough( - { argv: ['status'], cwd: '/', env: {} }, + { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 2_147_483_648 } ) ).rejects.toBeInstanceOf(RangeError) @@ -391,7 +403,7 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn(() => child) const resultPromise = runHostOrcaCliPassthrough( - { argv: ['status'], cwd: '/', env: {} }, + { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, { ...BASE_OPTIONS, spawn: spawn as never } ) @@ -408,7 +420,12 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn(() => child) const resultPromise = runHostOrcaCliPassthrough( - { argv: ['terminal', 'wait', '--for', 'exit'], cwd: '/', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['terminal', 'wait', '--for', 'exit'], + cwd: '/', + env: {} + }, { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 1000 } ) @@ -427,7 +444,12 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn(() => child) const resultPromise = runHostOrcaCliPassthrough( - { argv: ['terminal', 'read'], cwd: '/', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['terminal', 'read'], + cwd: '/', + env: {} + }, { ...BASE_OPTIONS, spawn: spawn as never } ) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 4490343504f..9c15287d166 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -15,6 +15,12 @@ import { } from '../../shared/orchestration-compatibility-evidence' import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' +import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' +import { + sshBridgeCredentials, + type SshBridgeCallerScope, + type SshBridgeCredentialRegistry +} from '../runtime/rpc/ssh-bridge-credentials' import { REMOTE_ARTIFACT_INPUT_ENV, sshArtifactSourceKey, @@ -35,6 +41,8 @@ export type RemoteOrcaCliRequest = { stdin?: string artifactInput?: RemoteArtifactInput runtimeAuthority?: SshCliRuntimeAuthority + // Why: required so no bridged invocation can run with owner authority over this Orca. + callerScope: SshBridgeCallerScope } export type RemoteOrcaCliResult = { @@ -66,6 +74,7 @@ export type HostCliPassthroughOptions = { spawn?: typeof nodeSpawn entryExists?: (path: string) => boolean killTimeoutMs?: number + credentials?: SshBridgeCredentialRegistry } /** Thrown when the host CLI entry cannot be launched at all; callers fall back @@ -103,6 +112,7 @@ export function buildHostCliEnv(args: { remoteEnv: Record userDataPath: string remoteCwd: string + bridgeCredential: string runtimeAuthority?: SshCliRuntimeAuthority artifactInput?: RemoteArtifactInput }): NodeJS.ProcessEnv { @@ -137,6 +147,11 @@ export function buildHostCliEnv(args: { // when Orca was launched inside a session, and identity by session id is same-host only. delete env[ORCA_AGENT_SESSION_ID_ENV] delete env[ORCA_STRUCTURED_SESSION_ENV] + // Why: this machine's paired-server selection would route the remote command with its credentials. + delete env.ORCA_PAIRING_CODE + delete env.ORCA_REMOTE_PAIRING + delete env.ORCA_ENVIRONMENT + env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] = args.bridgeCredential if (args.runtimeAuthority) { env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh' env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId @@ -196,16 +211,33 @@ export async function runHostOrcaCliPassthrough( throw new HostCliUnavailableError(`Orca CLI entry not found at ${cliEntryPath}`) } + const credential = (options.credentials ?? sshBridgeCredentials).mint(request.callerScope) const env = buildHostCliEnv({ hostEnv, remoteEnv: request.env, userDataPath, remoteCwd: request.cwd, + bridgeCredential: credential.token, runtimeAuthority: request.runtimeAuthority, artifactInput: request.artifactInput }) - return await new Promise((resolve, reject) => { + try { + return await spawnHostCli(spawn, execPath, cliEntryPath, request, env, killTimeoutMs) + } finally { + credential.revoke() + } +} + +function spawnHostCli( + spawn: typeof nodeSpawn, + execPath: string, + cliEntryPath: string, + request: RemoteOrcaCliRequest, + env: NodeJS.ProcessEnv, + killTimeoutMs: number +): Promise { + return new Promise((resolve, reject) => { let settled = false const child = spawn(execPath, [cliEntryPath, ...request.argv], { env, diff --git a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts index 1e3a9718239..44804c80b8a 100644 --- a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts +++ b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts @@ -13,6 +13,7 @@ vi.mock('../persistence', () => ({ import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' // Why: a missing CLI entry forces the legacy in-process bridge, the transport // an SSH-hosted agent actually reaches `terminal list` through. @@ -51,7 +52,12 @@ describe('remote CLI bridge terminal list', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['terminal', 'list', '--json'], cwd: '/home/alice/repo', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['terminal', 'list', '--json'], + cwd: '/home/alice/repo', + env: {} + }, LEGACY_FALLBACK_OPTIONS ) diff --git a/src/main/ssh/ssh-remote-linear-cli.test.ts b/src/main/ssh/ssh-remote-linear-cli.test.ts index 0b2495a5195..4efe8927cbd 100644 --- a/src/main/ssh/ssh-remote-linear-cli.test.ts +++ b/src/main/ssh/ssh-remote-linear-cli.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { isLinearProjectListResult } from './ssh-remote-linear-result-guards' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { runControlGrantedRemoteOrcaCli as runRemoteOrcaCli } from './ssh-bridge-caller-scope.test-fixture' function createRuntime() { const runtime = { diff --git a/src/main/ssh/ssh-remote-linear-save-issue.test.ts b/src/main/ssh/ssh-remote-linear-save-issue.test.ts index 9d54ea7aaa7..25bc3f337b2 100644 --- a/src/main/ssh/ssh-remote-linear-save-issue.test.ts +++ b/src/main/ssh/ssh-remote-linear-save-issue.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' function createRuntime() { const linearSaveIssue = vi.fn(async (request: unknown) => ({ @@ -29,6 +30,7 @@ describe('SSH remote Linear save issue', () => { it('forwards update fields, clears, stdin, and SSH context without losing types', async () => { const { runtime, linearSaveIssue } = createRuntime() const result = await runRemoteOrcaCli(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'linear', 'save-issue', @@ -83,6 +85,7 @@ describe('SSH remote Linear save issue', () => { it('forwards the team and title required for create mode', async () => { const { runtime, linearSaveIssue } = createRuntime() const result = await runRemoteOrcaCli(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['linear', 'save-issue', '--team', 'ENG', '--title', 'New issue', '--json'], cwd: '/home/alice/remote-repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } @@ -97,6 +100,7 @@ describe('SSH remote Linear save issue', () => { it('rejects remote body paths instead of reading from the wrong filesystem', async () => { const { runtime, linearSaveIssue } = createRuntime() const result = await runRemoteOrcaCli(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['linear', 'save-issue', 'ENG-123', '--body-file', 'body.md', '--json'], cwd: '/home/alice/remote-repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } diff --git a/src/main/ssh/ssh-remote-node-addon-resolution.test.ts b/src/main/ssh/ssh-remote-node-addon-resolution.test.ts index e721cc8096e..19f98afe05d 100644 --- a/src/main/ssh/ssh-remote-node-addon-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-addon-resolution.test.ts @@ -47,7 +47,9 @@ describe('resolveRemoteHostNodeForAddons (rung C)', () => { execCommandMock .mockResolvedValueOnce('') // path probe: nothing installed .mockResolvedValueOnce('/bin/bash\n') // $SHELL - .mockRejectedValueOnce(new Error('Command failed (exit 1): ')) // command -v node + .mockRejectedValueOnce( + Object.assign(new Error('Command failed (exit 1): '), { exitCode: 1, stdout: '' }) + ) // command -v node await expect(resolveRemoteHostNodeForAddons(conn, 8)).resolves.toBeNull() }) diff --git a/src/main/ssh/ssh-remote-node-addon-resolution.ts b/src/main/ssh/ssh-remote-node-addon-resolution.ts index a482610dfe3..7071d1074a0 100644 --- a/src/main/ssh/ssh-remote-node-addon-resolution.ts +++ b/src/main/ssh/ssh-remote-node-addon-resolution.ts @@ -7,6 +7,7 @@ import type { RemoteNodeResolutionOptions } from './ssh-remote-node-install-guid import { execCommand } from './ssh-relay-deploy-helpers' import { commandOptions, + memoizeCandidateCheck, throwIfAborted, tryResolveViaKnownPaths, tryResolveViaLoginShell, @@ -33,10 +34,12 @@ export async function resolveRemoteHostNodeForAddons( options?: RemoteNodeResolutionOptions ): Promise { const strict: ProbeOptions = { ...options, strict: true } - const addonCheck: CandidateCheck = async (candidate) => { - const facts = await probeHostNodeAddonFacts(conn, candidate, strict) - return hostNodeMeetsAddonRequirements(facts, requiredNapi) ? facts : null - } + const addonCheck: CandidateCheck = memoizeCandidateCheck( + async (candidate) => { + const facts = await probeHostNodeAddonFacts(conn, candidate, strict) + return hostNodeMeetsAddonRequirements(facts, requiredNapi) ? facts : null + } + ) const found = (await tryResolveViaKnownPaths(conn, addonCheck, strict)) ?? (await tryResolveViaLoginShell(conn, addonCheck, strict)) diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index fa259a5d620..86c1f0399df 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -14,7 +14,8 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ // Why: await import() is required so vi.mock() above registers before the // module under test is evaluated. Static import would bypass the mock. -const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution') +const { RemoteNodeNotFoundError, resolveRemoteNodePath } = + await import('./ssh-remote-node-resolution') const conn = {} as SshConnection @@ -684,4 +685,75 @@ describe('resolveRemoteNodePath', () => { const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string) expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/) }) + + describe('strict resolution, which settles rung D only on an answered "no Node"', () => { + const lostChannel = (): Error => + Object.assign(new Error('channel closed'), { sshChannelCloseConfirmed: false }) + + it('proves a Windows host has no Node when the discovery script answers none', async () => { + execCommandMock.mockRejectedValueOnce( + Object.assign(new Error('Node.js not found (exit 1)'), { exitCode: 1, stdout: '' }) + ) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { strict: true }) + ).rejects.toBeInstanceOf(RemoteNodeNotFoundError) + }) + + it('rethrows a refused channel (MaxSessions) instead of calling it "no Node"', async () => { + const refused = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 2 + }) + execCommandMock.mockRejectedValueOnce(refused) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { strict: true }) + ).rejects.toBe(refused) + }) + + it('rethrows a Windows probe the host never answered', async () => { + const lost = lostChannel() + execCommandMock.mockRejectedValueOnce(lost) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { strict: true }) + ).rejects.toBe(lost) + }) + + it('rethrows a lost POSIX version check rather than calling the candidate unusable', async () => { + const lost = lostChannel() + execCommandMock.mockResolvedValueOnce('/usr/local/bin/node\n').mockRejectedValueOnce(lost) + + await expect(resolveRemoteNodePath(conn, undefined, { strict: true })).rejects.toBe(lost) + }) + + it('probes a Node the login shell names again only once', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') // path probe + .mockResolvedValueOnce('__node__\nv20.0.0\n') // toolchain probe: npm missing + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('/usr/local/bin/node\n') // login shell: the same Node + .mockResolvedValueOnce('') // package manager hint probe + + await expect(resolveRemoteNodePath(conn, undefined, { strict: true })).rejects.toBeInstanceOf( + RemoteNodeNotFoundError + ) + const npmProbes = execCommandMock.mock.calls.filter(([, command]) => + String(command).includes('npm --version') + ) + expect(npmProbes).toHaveLength(1) + }) + + it('proves a POSIX host has no Node when every probe answered', async () => { + execCommandMock + .mockResolvedValueOnce('') // path probe: nothing + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('') // login shell: nothing + .mockResolvedValueOnce('') // package manager hint probe + + await expect(resolveRemoteNodePath(conn, undefined, { strict: true })).rejects.toBeInstanceOf( + RemoteNodeNotFoundError + ) + }) + }) }) diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index a9cf8a7cd47..4c758f77871 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -8,7 +8,7 @@ import { type RemoteNodeResolutionOptions } from './ssh-remote-node-install-guidance' import { execCommand } from './ssh-relay-deploy-helpers' -import { isSshExecTimeout } from './ssh-relay-exec-command' +import { isSshCommandExitError } from './ssh-relay-exec-command' import { buildPosixNodeToolchainProbe, buildWindowsNodeToolchainProbe, @@ -23,10 +23,19 @@ import { REMOTE_NODE_PATH_PROBE_SCRIPT } from './ssh-remote-node-probe-script' // hang a login shell, so keep this short. const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 +/** The probes answered and no usable Node.js + npm exists; only `strict` resolution proves it. */ +export class RemoteNodeNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = 'RemoteNodeNotFoundError' + } +} + +/** `strict` rethrows probes the host never answered, so a not-found is proof, not a lost channel. */ export async function resolveRemoteNodePath( conn: SshConnection, host?: RemoteHostPlatform, - options?: RemoteNodeResolutionOptions + options?: ProbeOptions ): Promise { if (host && isWindowsRemoteHost(host)) { return resolveRemoteWindowsNodePath(conn, options) @@ -37,8 +46,10 @@ export async function resolveRemoteNodePath( // This doesn't depend on shell startup-file semantics — bash -lc skips // .bashrc and zsh -lc skips .zshrc, but those are exactly the files where // nvm/mise/asdf hooks live. Probing directories directly is deterministic. - const npmCheck: CandidateCheck = async (candidate) => - (await nodeToolchainMeetsRequirements(conn, candidate, options)) || null + // Why memoized: the login shell often names the Node the path probe already rejected. + const npmCheck = memoizeCandidateCheck( + async (candidate) => (await nodeToolchainMeetsRequirements(conn, candidate, options)) || null + ) const probed = await tryResolveViaKnownPaths(conn, npmCheck, options) if (probed) { return probed.nodePath @@ -55,12 +66,35 @@ export async function resolveRemoteNodePath( } export type CandidateCheck = (candidate: string) => Promise + +/** Probes each candidate path once per resolution, however many strategies name it. */ +export function memoizeCandidateCheck(check: CandidateCheck): CandidateCheck { + const results = new Map>() + return (candidate) => { + let result = results.get(candidate) + if (!result) { + result = check(candidate) + results.set(candidate, result) + } + return result + } +} type ResolvedCandidate = { nodePath: string; result: T } /** `strict` rethrows unanswered probes rather than reading them as "no Node here". */ export type ProbeOptions = RemoteNodeResolutionOptions & { strict?: boolean } -function isUnansweredExec(err: unknown): boolean { - return isSshExecTimeout(err) || (err instanceof Error && 'sshChannelCloseConfirmed' in err) +/** + * Rethrows what is not a host-answered miss: an opted-in session limit, an abort, or (under + * `strict`) an exec that never exited. Only a command that ran and exited answered. + */ +function rethrowUnlessAnsweredMiss(err: unknown, options?: ProbeOptions): void { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + throwIfAborted(options) + if (options?.strict && !isSshCommandExitError(err)) { + throw err + } } // Probe the on-disk install directories of every common Node version manager @@ -142,14 +176,8 @@ export async function tryResolveViaLoginShell( return { nodePath: candidate, result } } } catch (err) { - if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { - throw err - } - throwIfAborted(options) - // Why only these: `command -v node` exits non-zero when the shell answered "none". - if (options?.strict && isUnansweredExec(err)) { - throw err - } + // Why: `command -v node` exits non-zero when the shell answered "none". + rethrowUnlessAnsweredMiss(err, options) // Fall through. } return null @@ -157,12 +185,10 @@ export async function tryResolveViaLoginShell( // Validates the same PATH-prepend + bare npm contract used during deployment. // This rejects missing npm (#8450) without requiring colocation (#9165). -// Caches nothing — this runs at most a few times per resolution (one per -// candidate), and the exec round-trip dominates. async function nodeToolchainMeetsRequirements( conn: SshConnection, nodePath: string, - options?: RemoteNodeResolutionOptions + options?: ProbeOptions ): Promise { try { const versionOutput = await execCommand( @@ -174,10 +200,7 @@ async function nodeToolchainMeetsRequirements( ) return nodeToolchainVersionsMeetRequirements(versionOutput) } catch (err) { - if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { - throw err - } - throwIfAborted(options) + rethrowUnlessAnsweredMiss(err, options) // Binary missing or fails to run — not usable. return false } @@ -185,7 +208,7 @@ async function nodeToolchainMeetsRequirements( async function resolveRemoteWindowsNodePath( conn: SshConnection, - options?: RemoteNodeResolutionOptions + options?: ProbeOptions ): Promise { const script = [ '$paths = @()', @@ -224,10 +247,8 @@ async function resolveRemoteWindowsNodePath( } } } catch (err) { - if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { - throw err - } - throwIfAborted(options) + // Why: the script exits 1 when it finds nothing, so only an unanswered probe is unknown. + rethrowUnlessAnsweredMiss(err, options) // Fall through to the shared error below. } @@ -237,7 +258,7 @@ async function resolveRemoteWindowsNodePath( async function windowsNodeToolchainMeetsRequirements( conn: SshConnection, nodePath: string, - options?: RemoteNodeResolutionOptions + options?: ProbeOptions ): Promise { try { const versionOutput = await execCommand( @@ -247,10 +268,7 @@ async function windowsNodeToolchainMeetsRequirements( ) return nodeToolchainVersionsMeetRequirements(versionOutput) } catch (err) { - if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { - throw err - } - throwIfAborted(options) + rethrowUnlessAnsweredMiss(err, options) return false } } @@ -262,12 +280,12 @@ async function throwNodeNotFound( throwIfAborted(options) const guidance = await buildPosixNodeInstallGuidance(conn, options) throwIfAborted(options) - throw new Error(guidance) + throw new RemoteNodeNotFoundError(guidance) } function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never { throwIfAborted(options) - throw new Error( + throw new RemoteNodeNotFoundError( [ 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.', '', diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 90e7830099d..32eddcc1c0b 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -16,6 +16,7 @@ import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { createRootDispatch } from '../runtime/orchestration/db/root-dispatch-test-fixture' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' // Why: pointing the passthrough at a missing CLI entry forces the legacy // in-process fallback, which is what these dispatch tests exercise. @@ -168,7 +169,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv, cwd: '/home/alice/repo', env: {} }, + { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv, cwd: '/home/alice/repo', env: {} }, LEGACY_FALLBACK_OPTIONS ) @@ -197,7 +198,12 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['status', '--json'], cwd: '/home/alice/repo', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['status', '--json'], + cwd: '/home/alice/repo', + env: {} + }, LEGACY_FALLBACK_OPTIONS ) @@ -217,6 +223,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], cwd: '/home/alice/repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } @@ -245,6 +252,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], cwd: '/home/alice/repo', env: { @@ -280,6 +288,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -338,6 +347,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -411,6 +421,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -451,6 +462,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -482,6 +494,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -517,6 +530,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -551,6 +565,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--all', '--json'], cwd: '/home/alice/repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } @@ -574,6 +589,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--all', '--json'], cwd: '/home/alice/repo', env: { @@ -600,6 +616,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--terminal', 'term_legacy_worker', '--all', '--json'], cwd: '/home/alice/repo', env: { @@ -623,6 +640,7 @@ describe('runRemoteOrcaCli', () => { const resultPromise = runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['worktree', 'create', '--repo', 'orca', '--branch', 'fix/x', '--json'], cwd: '/home/alice/repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } @@ -661,7 +679,12 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['serve'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['serve'], + cwd: '/home/alice', + env: {} + }, { ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never } ) @@ -677,7 +700,12 @@ describe('runRemoteOrcaCli', () => { const addResult = await runRemoteOrcaCli( runtime, - { argv: ['account', 'add'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['account', 'add'], + cwd: '/home/alice', + env: {} + }, { ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never } ) @@ -689,7 +717,12 @@ describe('runRemoteOrcaCli', () => { spawn.mockReturnValueOnce(child) const listPromise = runRemoteOrcaCli( runtime, - { argv: ['account', 'list'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['account', 'list'], + cwd: '/home/alice', + env: {} + }, { ...LEGACY_FALLBACK_OPTIONS, entryExists: () => true, @@ -715,7 +748,12 @@ describe('runRemoteOrcaCli', () => { const resultPromise = runRemoteOrcaCli( runtime, - { argv: ['account', 'add', '--help'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['account', 'add', '--help'], + cwd: '/home/alice', + env: {} + }, { ...LEGACY_FALLBACK_OPTIONS, entryExists: () => true, @@ -739,7 +777,12 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['serve', '--json'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['serve', '--json'], + cwd: '/home/alice', + env: {} + }, LEGACY_FALLBACK_OPTIONS ) @@ -757,7 +800,12 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['worktree', 'list'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['worktree', 'list'], + cwd: '/home/alice', + env: {} + }, LEGACY_FALLBACK_OPTIONS ) @@ -772,6 +820,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['emulator', 'launch', 'com.acme.app', '--activity', '.MainActivity'], cwd: '/home/alice', env: {} diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 3913a65cb9f..d6a9ed0e042 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -104,7 +104,11 @@ async function runLegacyRemoteOrcaCli( json: boolean, passthroughFailure: HostCliUnavailableError ): Promise { - const dispatcher = new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: ALL_RPC_METHODS, + callerScope: request.callerScope + }) const help = getRemoteLinearHelp(parsed) if (help) { return { stdout: `${help}\n`, stderr: '', exitCode: 0 } diff --git a/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts b/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts index b321dfba841..c1f379d8120 100644 --- a/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts +++ b/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts @@ -17,6 +17,7 @@ import type Database from '../sqlite/sync-database' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { acknowledgeRemoteOrcaCliPostOutput } from './ssh-remote-orchestration-post-output' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' import { createRootDispatch } from '../runtime/orchestration/db/root-dispatch-test-fixture' const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { @@ -137,6 +138,7 @@ describe('legacy SSH orchestration fallback', () => { subject: 'retained SSH mail' }) const request = { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--unread', '--inject', '--json'], cwd: '/home/alice/repo', env: WORKER_ENV, @@ -164,6 +166,7 @@ describe('legacy SSH orchestration fallback', () => { expect(db.getMessageById(message.id)?.read).toBe(0) await acknowledgeRemoteOrcaCliPostOutput(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, postOutput: first.postOutput!, env: WORKER_ENV, runtimeAuthority: RUNTIME_AUTHORITY @@ -192,6 +195,7 @@ describe('legacy SSH orchestration fallback', () => { const peek = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--peek', '--format', '--json'], cwd: '/home/alice/repo', env: WORKER_ENV, @@ -246,6 +250,7 @@ describe('legacy SSH orchestration fallback', () => { const checked = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, ...baseRequest, argv: ['orchestration', 'check', '--run', run.id] }, @@ -258,6 +263,7 @@ describe('legacy SSH orchestration fallback', () => { const checkedJson = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, ...baseRequest, argv: ['orchestration', 'check', '--run', run.id, '--json'] }, @@ -271,6 +277,7 @@ describe('legacy SSH orchestration fallback', () => { const acknowledged = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, ...baseRequest, argv: [ 'orchestration', @@ -310,6 +317,7 @@ describe('legacy SSH orchestration fallback', () => { '--json' ] const request = { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv, cwd: '/home/alice/repo', env: WORKER_ENV, @@ -366,6 +374,7 @@ describe('legacy SSH orchestration fallback', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'ask', '--resume', pending.question.message_id, '--json'], cwd: '/home/alice/repo', env: WORKER_ENV, @@ -389,6 +398,7 @@ describe('legacy SSH orchestration fallback', () => { expect(db.getMessageById(answer.message.id)?.read).toBe(0) await acknowledgeRemoteOrcaCliPostOutput(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, postOutput: result.postOutput!, env: WORKER_ENV, runtimeAuthority: RUNTIME_AUTHORITY @@ -418,6 +428,7 @@ describe('legacy SSH orchestration fallback', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -463,6 +474,7 @@ describe('legacy SSH orchestration fallback', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [...commandArgv, '--retry-request', '--json'], cwd: '/home/alice/repo', env: WORKER_ENV, diff --git a/src/main/ssh/ssh-remote-orchestration-post-output.ts b/src/main/ssh/ssh-remote-orchestration-post-output.ts index 3778ea2524d..05d39282df8 100644 --- a/src/main/ssh/ssh-remote-orchestration-post-output.ts +++ b/src/main/ssh/ssh-remote-orchestration-post-output.ts @@ -18,6 +18,7 @@ export async function acknowledgeRemoteOrcaCliPostOutput( postOutput: RemoteOrcaCliPostOutput env: Record runtimeAuthority?: RemoteOrcaCliRequest['runtimeAuthority'] + callerScope: RemoteOrcaCliRequest['callerScope'] } ): Promise { const inheritedEvidence = readOrchestrationCompatibilityEvidence(args.env) @@ -40,7 +41,11 @@ export async function acknowledgeRemoteOrcaCliPostOutput( answerMessageId: args.postOutput.answerMessageId }) } - const response = await new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatch({ + const response = await new RpcDispatcher({ + runtime, + methods: ALL_RPC_METHODS, + callerScope: args.callerScope + }).dispatch({ id: `remote-cli-post-output-${randomUUID()}`, authToken: 'remote-cli', method: 'orchestration.check', diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index ac3ff4f0308..509a93b22dd 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -22,7 +22,9 @@ function makeTarget(): SshTarget { configHost: 'fdpass-host', host: 'ignored.example.com', port: 22, - username: '' + username: '', + // The fixture pre-installs a host-Node relay tree. + remoteRuntime: 'legacy' } } diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts index 352386c3fb5..1abf3036f97 100644 --- a/src/main/ssh/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -1,5 +1,5 @@ import { DirectoryTransferBudget } from './ssh-directory-transfer-budget' -import { spawn } from 'node:child_process' +import { spawn, type ChildProcess } from 'node:child_process' import { lstat, opendir } from 'node:fs/promises' import { join as pathJoin } from 'node:path' import { pipeline } from 'node:stream/promises' @@ -20,6 +20,7 @@ import { throwIfAborted, waitForChannelClose, waitForProcess, + isHostAnsweredSystemSshExit, type ProcessResult } from './system-ssh-operation-lifecycle' import { @@ -80,11 +81,12 @@ export async function uploadDirectoryViaSystemSsh( killProcess(tarCreate) killProcess(sshExtract) }, - Promise.all([ + settleUploadPipeline( + tarCreate, waitForProcess(tarCreate, 'local tar relay upload'), - waitForProcess(sshExtract, 'system ssh relay upload'), + waitForProcess(sshExtract, 'system ssh relay upload', 'remote'), pipeline(tarCreate.stdout!, sshExtract.stdin!) - ]).then(([tar, ssh]) => [tar, ssh] as const) + ) ) } catch (err) { killProcess(tarCreate) @@ -100,6 +102,49 @@ export async function uploadDirectoryViaSystemSsh( } } +/** What local tar says when its output, not its input, failed: the far end went away. */ +const DOWNSTREAM_WRITE_FAILURE = /write error|cannot write|broken pipe|EPIPE/i + +/** + * Waits for all three, then names the cause; a failed ssh wins over a tar that succeeded. A remote + * exit the host answered wins over a local tar failure that only followed it: tar was signalled, + * failed after ssh closed, or reported a write/pipe error (BSD tar exits 1 with "Write error", no + * signal). A local tar that failed on its own input first (missing directory, unreadable file) is + * the client's fault. + */ +async function settleUploadPipeline( + tarProcess: ChildProcess, + local: Promise, + remote: Promise, + pipe: Promise +): Promise { + let settled = 0 + let localAt = 0 + let remoteAt = 0 + const stamp = (promise: Promise, record: (at: number) => void): Promise => + promise.finally(() => record(++settled)) + const [tar, ssh, piped] = await Promise.allSettled([ + stamp(local, (at) => (localAt = at)), + stamp(remote, (at) => (remoteAt = at)), + pipe + ]) + const remoteAnswered = ssh.status === 'rejected' && isHostAnsweredSystemSshExit(ssh.reason) + if (tar.status === 'rejected') { + const localFollowedRemote = + tarProcess.signalCode !== null || + remoteAt < localAt || + (tar.reason instanceof Error && DOWNSTREAM_WRITE_FAILURE.test(tar.reason.message)) + throw remoteAnswered && localFollowedRemote ? ssh.reason : tar.reason + } + if (ssh.status === 'rejected') { + throw ssh.reason + } + if (piped.status === 'rejected') { + throw piped.reason + } + return [tar.value, ssh.value] +} + export async function writeFileViaSystemSsh( target: SshTarget, remotePath: string, diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 148ccad8ff6..a3ecb27859d 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -3,6 +3,9 @@ import type { SystemSshCommandChannel } from './system-ssh-command' export type ProcessResult = { label: string; stderr: string } +/** OpenSSH's own exit status for a connection-level failure, never the remote command's. */ +const SYSTEM_SSH_TRANSPORT_EXIT_CODE = 255 + export class SystemSshCommandExitError extends Error { constructor( label: string, @@ -15,6 +18,15 @@ export class SystemSshCommandExitError extends Error { } } +/** The remote command, not OpenSSH, produced this exit. */ +export function isHostAnsweredSystemSshExit(err: unknown): err is SystemSshCommandExitError { + return ( + err instanceof SystemSshCommandExitError && + err.exitCode !== null && + err.exitCode !== SYSTEM_SSH_TRANSPORT_EXIT_CODE + ) +} + /** * `timeoutMs` bounds a remote consumer that never returns. Windows PowerShell 5.1 cannot drain a * large redirected stdin over a non-pty ssh exec (#16432): the remote process stays alive at idle @@ -76,7 +88,12 @@ export function waitForChannelClose( }) } -export function waitForProcess(proc: ChildProcess, label: string): Promise { +/** `remote`: the process is ssh running a host command, so its exit status is the host's answer. */ +export function waitForProcess( + proc: ChildProcess, + label: string, + origin: 'local' | 'remote' = 'local' +): Promise { return new Promise((resolve, reject) => { let stderr = '' const cleanup = (): void => { @@ -94,9 +111,14 @@ export function waitForProcess(proc: ChildProcess, label: string): Promise { settle(reject, err) } - const onClose = (code: number | null): void => { + const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { - settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) + settle( + reject, + origin === 'remote' + ? new SystemSshCommandExitError(label, code, stderr, signal) + : new Error(`${label} failed (exit ${code}): ${stderr.trim()}`) + ) return } settle(resolve, { label, stderr }) diff --git a/src/main/ssh/system-ssh-sftp-transfer.ts b/src/main/ssh/system-ssh-sftp-transfer.ts index c50375fa8eb..0c319a6e28c 100644 --- a/src/main/ssh/system-ssh-sftp-transfer.ts +++ b/src/main/ssh/system-ssh-sftp-transfer.ts @@ -13,7 +13,7 @@ import { toSftpRemotePath, UnsupportedSftpPathError } from './system-ssh-sftp-path' -import { throwIfAborted } from './system-ssh-operation-lifecycle' +import { SystemSshCommandExitError, throwIfAborted } from './system-ssh-operation-lifecycle' import { runProcess } from '../../shared/child-process/run-process' /** The host answered, but not with an sftp subsystem. The caller must fall back, not fail. */ @@ -167,9 +167,16 @@ export async function runSftpBatch( if (SUBSYSTEM_REFUSED_PATTERN.test(detail)) { throw new SftpSubsystemUnavailableError(detail) } - throw new Error(`sftp batch failed (exit ${result.code}): ${detail}`) + // Why typed only off-transport: the host answered this script; a dropped link said nothing. + if (CONTACT_LOST_PATTERN.test(detail)) { + throw new Error(`sftp batch failed (exit ${result.code}): ${detail}`) + } + throw new SystemSshCommandExitError('sftp batch', result.code, detail, result.signal) } +const CONTACT_LOST_PATTERN = + /connection (?:closed|reset|lost|timed out|refused)|broken pipe|timeout, server .* not responding/i + /** * Creates remote directories, parents first. * diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index d89ffcd8a0d..e7f3385ef04 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,4 +1,5 @@ import { app } from 'electron' +import { quitProcess } from './process-quit-request' import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' @@ -6,6 +7,7 @@ import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' import { DISABLED_CHROMIUM_FEATURES } from './disabled-chromium-features' import { readHttp1CompatibilityMarker } from './http1-compatibility-marker' +import { checkServeUserDataPath } from './serve-user-data-path-guard' import { hasMissingProfileStateDatabaseWithRetainedAuthority, readActiveProfileId, @@ -100,7 +102,7 @@ function getProcessPathDelimiter(): string { function requestDevParentShutdown(): void { devParentShutdownRequested = true - app.quit() + quitProcess() const forceExitTimer = setTimeout(() => { // Why: app.quit() may stall on macOS quit handlers or window-close guards, so force-exit after a grace period to avoid a hung dev app. @@ -230,8 +232,17 @@ function areSameE2EHomePath(left: string, right: string): boolean { } export function configureOrcaUserDataPathEnv(): void { + const userDataPath = app.getPath('userData') + // Why here: userData is final now and the instance lock is still ahead; preflight's catch exits serve. + const serveProfileRefusal = checkServeUserDataPath({ + isServeMode: process.argv.includes('--serve'), + userDataPath + }) + if (serveProfileRefusal) { + throw new Error(serveProfileRefusal) + } // Why: relaunches can inherit a stale ORCA_USER_DATA_PATH; canonicalize before CLI-shared modules build runtime-home paths. - process.env.ORCA_USER_DATA_PATH = app.getPath('userData') + process.env.ORCA_USER_DATA_PATH = userDataPath } export function shouldInstallManagedHooks(isDev: boolean): boolean { diff --git a/src/main/startup/headless-serve-ssh-registration.test.ts b/src/main/startup/headless-serve-ssh-registration.test.ts new file mode 100644 index 00000000000..42d92832bd2 --- /dev/null +++ b/src/main/startup/headless-serve-ssh-registration.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = await vi.hoisted(async () => { + const { createSshIpcMocks } = await import('../ipc/ssh-ipc-module-mocks') + return createSshIpcMocks() +}) + +vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) +vi.mock('electron', () => mocks.electron) +vi.mock('../ipc/ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) +vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) +vi.mock('../ipc/ssh-host-server-connect', () => mocks.hostServerConnect) +vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) +vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) +vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) +vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) +vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) +vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) +vi.mock('../ipc/pty', () => mocks.pty) +vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) +vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) +vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) +vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) +vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) + +import { resetSshHandlerStateForTests } from '../ipc/ssh' +import { connectRegisteredSshTarget, listRegisteredSshTargets } from '../ssh/ssh-target-registry' +import { createSshIpcHarness } from '../ipc/ssh-ipc-test-harness' +import type { SshTarget } from '../../shared/ssh-types' +import { mainProcessState } from './main-process-state' +import { registerHeadlessServeSshHandlers } from './headless-serve-ssh-registration' + +const { mockSshStore, mockConnectionManager } = mocks +const target: SshTarget = { + id: 'ssh-1', + label: 'build box', + host: 'build.example', + port: 22, + username: 'deploy' +} + +describe('SSH registration on a headless `orca serve`', () => { + const harness = createSshIpcHarness(mocks) + + beforeEach(async () => { + await harness.reset() + // Why: a headless serve never attached a window, so nothing registered the SSH layer. + await resetSshHandlerStateForTests() + mainProcessState.mainWindow = null + }) + + it('reproduces the unregistered host before bootstrap (#25886, #8489)', async () => { + expect(listRegisteredSshTargets()).toEqual([]) + await expect(connectRegisteredSshTarget('ssh-1')).rejects.toThrow('ssh_handlers_not_registered') + }) + + it('lists and connects persisted SSH targets with no window', async () => { + mockSshStore.listTargets.mockReturnValue([target]) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SSH harness store implements every method registerSshHandlers reads. + registerHeadlessServeSshHandlers(harness.mockStore as never, {} as never) + + expect(listRegisteredSshTargets()).toEqual([target]) + await expect(connectRegisteredSshTarget('ssh-1')).resolves.toMatchObject({ + targetId: 'ssh-1', + status: 'connected' + }) + expect(harness.mockWindow.webContents.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/headless-serve-ssh-registration.ts b/src/main/startup/headless-serve-ssh-registration.ts new file mode 100644 index 00000000000..d3bb7c32fe2 --- /dev/null +++ b/src/main/startup/headless-serve-ssh-registration.ts @@ -0,0 +1,14 @@ +import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { registerSshHandlers } from '../ipc/ssh' +import { mainProcessState as state } from './main-process-state' + +/** + * Loads the profile's SSH targets on an `orca serve` host before any window exists. + * Why: only window attach registered the SSH layer, so a headless serve listed no targets and + * every connect threw ssh_handlers_not_registered (#25886, #8489). A later window attach + * re-registers idempotently; until then broadcasts are skipped because there is no window. + */ +export function registerHeadlessServeSshHandlers(store: Store, runtime: OrcaRuntimeService): void { + registerSshHandlers(store, () => state.mainWindow ?? null, runtime) +} diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts index 0de7780d9db..acde65f4e65 100644 --- a/src/main/startup/main-process-i18n-menu.ts +++ b/src/main/startup/main-process-i18n-menu.ts @@ -10,6 +10,7 @@ import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store import { mainProcessState as state } from './main-process-state' import { openSettingsFromSystemMenu, + quitFromUserCommand, runUserInitiatedUpdateCheck, sendOpenCrashReport, sendOpenFeatureTour, @@ -28,6 +29,7 @@ export async function initializeMainProcessI18nAndMenu(): Promise { logStartupMilestone('i18n-ready') registerAppMenu({ appMenuLabel: state.devInstanceIdentity?.name ?? app.name, + ...(state.isServeMode ? { onQuit: quitFromUserCommand } : {}), onCheckForUpdates: (options) => { ensureAutoUpdaterConfigured() runUserInitiatedUpdateCheck(options) diff --git a/src/main/startup/main-process-runtime-launch-activation.test.ts b/src/main/startup/main-process-runtime-launch-activation.test.ts index 6fd2ec6ec57..36f8aa69f04 100644 --- a/src/main/startup/main-process-runtime-launch-activation.test.ts +++ b/src/main/startup/main-process-runtime-launch-activation.test.ts @@ -23,12 +23,21 @@ vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/tmp/orca-user-data', migrateMobilePairingDataToCanonicalUserDataPath: vi.fn() })) +const launchOrder = vi.hoisted((): string[] => []) vi.mock('../runtime/runtime-rpc', () => ({ OrcaRuntimeRpcServer: class { - start = vi.fn(async () => {}) + start = vi.fn(async () => { + launchOrder.push('rpc-start') + }) setOnUnpairedDeviceAuthFailure = vi.fn() } })) +vi.mock('../runtime/headless-runtime-graph', () => ({ publishHeadlessRuntimeGraph: vi.fn() })) +vi.mock('./headless-serve-ssh-registration', () => ({ + registerHeadlessServeSshHandlers: vi.fn(() => { + launchOrder.push('ssh-registered') + }) +})) vi.mock('../ipc/mobile', () => ({ registerMobileHandlers: vi.fn() })) vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: vi.fn(), @@ -66,6 +75,7 @@ vi.mock('./windows-install-dir-acl-recovery', () => ({ }) })) vi.mock('./serve-signal-handlers', () => ({ registerServeSignalHandlers: vi.fn() })) +vi.mock('./serve-native-quit-guard', () => ({ installServeNativeQuitGuard: vi.fn() })) vi.mock('../runtime/runtime-rpc-startup-failure', () => ({ recordRuntimeRpcStartFailure: vi.fn(), showRuntimeRpcStartupFailureDialog: vi.fn() @@ -104,6 +114,11 @@ const { mainProcessState: state } = await import('./main-process-state') const { createServeDesktopActivationGate } = await import('./serve-desktop-activation') const { focusExistingMainWindow } = await import('../window/focus-existing-window') const { AGENT_LAUNCH_RECORD_WARMUP_DELAY_MS } = await import('./agent-launch-record-warmup') +const { getServeOptions } = await import('./main-process-serve') +const { registerServeSignalHandlers } = await import('./serve-signal-handlers') +const { installServeNativeQuitGuard } = await import('./serve-native-quit-guard') +const { quitProcess } = await import('./process-quit-request') +const { quitFromUserCommand } = await import('./main-window-actions') type FakeWindow = { id: number @@ -256,4 +271,40 @@ describe('desktop startup activation', () => { expect(windows).toHaveLength(0) expect(state.desktopActivationGate).toBeNull() }) + + it('loads SSH targets on a windowless serve before paired clients can connect (#25886)', async () => { + launchOrder.length = 0 + state.isServeMode = true + vi.mocked(getServeOptions).mockReturnValueOnce({ + json: false, + pairingAddress: null, + noPairing: true, + mobilePairing: false, + grantDesktopControl: false, + recipeJson: false, + projectRoot: null + }) + Object.assign(state.runtime!, { + refreshRestoredOrchestrationAuthority: vi.fn(async () => {}), + reconcileLegacyWorkerTerminals: vi.fn(async () => {}) + }) + + await initializeMainProcessReady({ + openMainWindow: () => { + throw new Error('serve must not open a window') + }, + handleMacAppActivation: vi.fn() + }) + + expect(windows).toHaveLength(0) + expect(launchOrder).toEqual(['ssh-registered', 'rpc-start']) + // Signals mark their quit, so the macOS native-quit guard lets them through (#15537). + expect(registerServeSignalHandlers).toHaveBeenCalledWith(process, quitProcess) + expect(installServeNativeQuitGuard).toHaveBeenCalledWith( + expect.objectContaining({ + platform: process.platform, + closeDesktopWindows: quitFromUserCommand + }) + ) + }) }) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index f78a86f297a..6204ca50dc7 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -26,7 +26,8 @@ import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' import { repairKnownPoisonedInstallDirBeforeWindow } from './windows-install-dir-acl-recovery' -import { registerServeSignalHandlers } from './serve-signal-handlers' +import { installServeQuitHandling } from './serve-quit-handling' +import { registerHeadlessServeSshHandlers } from './headless-serve-ssh-registration' import { settleServeDesktopActivation } from './serve-desktop-activation' import { recordRuntimeRpcStartFailure, @@ -158,6 +159,8 @@ async function launchServeMode( ) } publishHeadlessRuntimeGraph(runtime) + // Why before RPC binds: the first paired client must already see this host's SSH targets. + registerHeadlessServeSshHandlers(state.store!, runtime) await runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start headless RPC transport:', error) throw error @@ -167,8 +170,7 @@ async function launchServeMode( // it simply never receives a push, because nothing dispatches notifications here. startDesktopPushService(runtimeRpc) settleDesktopActivation() - // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. - registerServeSignalHandlers(process, () => app.quit()) + installServeQuitHandling() // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). if (process.platform === 'darwin' || process.platform === 'linux') { try { diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts index f582acfb421..5bcfc74cdbc 100644 --- a/src/main/startup/main-process-serve.ts +++ b/src/main/startup/main-process-serve.ts @@ -44,7 +44,8 @@ export async function printServeReady(options: ServeOptions): Promise { : runtimeRpc.createPairingOffer({ address: options.pairingAddress, name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, - scope: options.mobilePairing ? 'mobile' : 'runtime' + scope: options.mobilePairing ? 'mobile' : 'runtime', + grants: options.grantDesktopControl ? ['desktop-control'] : [] }) const pairingQr = pairing.available && options.mobilePairing diff --git a/src/main/startup/main-window-actions-user-quit.test.ts b/src/main/startup/main-window-actions-user-quit.test.ts new file mode 100644 index 00000000000..8cb20db1bdb --- /dev/null +++ b/src/main/startup/main-window-actions-user-quit.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electron = vi.hoisted(() => ({ quit: vi.fn() })) +type FakeWindow = { isDestroyed: () => boolean; close: () => void } +const popout = vi.hoisted((): { window: FakeWindow | null } => ({ window: null })) + +vi.mock('electron', () => ({ app: { quit: electron.quit } })) +vi.mock('../updater', () => ({ checkForUpdatesFromMenu: vi.fn(), isQuittingForUpdate: vi.fn() })) +vi.mock('../tray/system-tray', () => ({ + createSystemTray: vi.fn(), + setMacMenuBarIconVisible: vi.fn() +})) +vi.mock('../window/attach-main-window-services', () => ({ ensureAutoUpdaterConfigured: vi.fn() })) +vi.mock('../window/focus-existing-window', () => ({ + focusExistingMainWindow: vi.fn(), + safelyRevealWindow: vi.fn() +})) +vi.mock('../window/dashboard-popout-window', () => ({ + getDashboardPopoutWindow: () => popout.window +})) +vi.mock('../window/createMainWindow', () => ({ loadMainWindow: vi.fn() })) +vi.mock('./windows-install-dir-acl-recovery', () => ({ + describeInstallDirAclPoison: vi.fn(), + isBlockingInstallDirAclRepairInFlight: vi.fn() +})) +vi.mock('../window/renderer-recovery-prompt', () => ({ presentRendererRecoveryPrompt: vi.fn() })) +vi.mock('../window/renderer-launch-failure-probe', () => ({ probeRendererLaunchCapacity: vi.fn() })) + +import { quitFromUserCommand } from './main-window-actions' +import { mainProcessState as state } from './main-process-state' +import { consumeUserQuitWindowClose } from '../window/user-quit-window-close' + +function fakeWindow() { + return { isDestroyed: () => false, close: vi.fn<() => void>() } +} + +describe('quitFromUserCommand', () => { + beforeEach(() => { + electron.quit.mockReset() + state.isQuitting = false + popout.window = null + }) + + it('only closes the desktop windows of an `orca serve` host (#15537)', () => { + const main = fakeWindow() + const dashboard = fakeWindow() + popout.window = dashboard + state.isServeMode = true + Object.assign(state, { mainWindow: main }) + + quitFromUserCommand() + + expect(electron.quit).not.toHaveBeenCalled() + expect(state.isQuitting).toBe(false) + expect(main.close).toHaveBeenCalledOnce() + expect(dashboard.close).toHaveBeenCalledOnce() + // The main window's close carries the user-Quit intent that arms the frozen-renderer deadline. + expect(consumeUserQuitWindowClose(main)).toBe(true) + }) + + it('quits a desktop app as before', () => { + state.isServeMode = false + Object.assign(state, { mainWindow: fakeWindow() }) + + quitFromUserCommand() + + expect(electron.quit).toHaveBeenCalledOnce() + expect(state.isQuitting).toBe(true) + }) +}) diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index c4303b79986..eb9a3babf57 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -11,6 +11,8 @@ import { import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' import { focusExistingMainWindow, safelyRevealWindow } from '../window/focus-existing-window' import { mainProcessState as state } from './main-process-state' +import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' +import { markUserQuitWindowClose } from '../window/user-quit-window-close' import { loadMainWindow } from '../window/createMainWindow' import { describeInstallDirAclPoison, @@ -63,12 +65,33 @@ export function openSettingsFromSystemMenu(): void { state.pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) } +/** + * A user Quit (menu, tray, renderer-recovery prompt). Under `orca serve` the process is the + * server its paired clients use, so a user Quit only closes the desktop windows (#15537); + * signals, supervisor stops and update installs still quit through app.quit(). + */ +export function quitFromUserCommand(): void { + if (state.isServeMode) { + // Why not getAllWindows(): offscreen browser-automation windows belong to the runtime. + for (const window of [state.mainWindow, getDashboardPopoutWindow()]) { + if (window && !window.isDestroyed()) { + if (window === state.mainWindow) { + markUserQuitWindowClose(window) + } + window.close() + } + } + return + } + state.isQuitting = true + app.quit() +} + export function quitFromSystemTray(): void { if (state.mainWindow && !state.mainWindow.isDestroyed()) { showMainWindowFromTray() } - state.isQuitting = true - app.quit() + quitFromUserCommand() } export function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { @@ -184,9 +207,6 @@ export async function showRendererRecoveryPrompt( } loadMainWindow(state.mainWindow) }, - quit: () => { - state.isQuitting = true - app.quit() - } + quit: quitFromUserCommand }) } diff --git a/src/main/startup/process-quit-request.ts b/src/main/startup/process-quit-request.ts new file mode 100644 index 00000000000..4377ab85302 --- /dev/null +++ b/src/main/startup/process-quit-request.ts @@ -0,0 +1,25 @@ +import { app } from 'electron' + +// Sticky: will-quit's second pass and a deferred updater quit re-enter before-quit. +let processQuitRequested = false + +/** + * Ends the process for a reason other than a user Quit: a signal, a supervisor, a relaunch. + * Why not bare app.quit(): a serve host turns an unmarked macOS quit into closing its windows. + */ +export function quitProcess(): void { + processQuitRequested = true + app.quit() +} + +export function markProcessQuitRequested(): void { + processQuitRequested = true +} + +export function isProcessQuitRequested(): boolean { + return processQuitRequested +} + +export function resetProcessQuitRequestForTest(): void { + processQuitRequested = false +} diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts index 8441f116805..b42fad932ea 100644 --- a/src/main/startup/serve-mode-argv.ts +++ b/src/main/startup/serve-mode-argv.ts @@ -12,6 +12,7 @@ const CLI_TO_SERVE_FLAG = new Map([ ['--json', '--serve-json'], ['--no-pairing', '--serve-no-pairing'], ['--mobile-pairing', '--serve-mobile-pairing'], + ['--grant-desktop-control', '--serve-grant-desktop-control'], ['--recipe-json', '--serve-recipe-json'] ]) diff --git a/src/main/startup/serve-native-quit-guard.test.ts b/src/main/startup/serve-native-quit-guard.test.ts new file mode 100644 index 00000000000..8f9b6f996e5 --- /dev/null +++ b/src/main/startup/serve-native-quit-guard.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type Listener = (...args: any[]) => void +const electron = vi.hoisted(() => { + const beforeQuit: Listener[] = [] + const powerListeners: Record = {} + return { beforeQuit, powerListeners, quit: vi.fn() } +}) + +vi.mock('electron', () => ({ + app: { + quit: electron.quit, + prependListener: (event: string, listener: Listener) => { + if (event === 'before-quit') { + electron.beforeQuit.unshift(listener) + } + } + }, + powerMonitor: { + on: (event: string, listener: Listener) => { + electron.powerListeners[event] = listener + } + } +})) + +import { installServeNativeQuitGuard } from './serve-native-quit-guard' +import { quitProcess, resetProcessQuitRequestForTest } from './process-quit-request' + +function emitBeforeQuit(): { preventDefault: ReturnType } { + const event = { preventDefault: vi.fn() } + for (const listener of electron.beforeQuit) { + listener(event) + } + return event +} + +describe('installServeNativeQuitGuard', () => { + const closeDesktopWindows = vi.fn() + const isQuittingForUpdate = vi.fn(() => false) + + beforeEach(() => { + electron.beforeQuit.length = 0 + for (const key of Object.keys(electron.powerListeners)) { + delete electron.powerListeners[key] + } + electron.quit.mockReset() + closeDesktopWindows.mockReset() + isQuittingForUpdate.mockReturnValue(false) + resetProcessQuitRequestForTest() + }) + + it('turns a macOS Dock or app-switcher Quit into closing the desktop windows (#15537)', () => { + installServeNativeQuitGuard({ platform: 'darwin', isQuittingForUpdate, closeDesktopWindows }) + + const event = emitBeforeQuit() + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(closeDesktopWindows).toHaveBeenCalledOnce() + }) + + it('lets a signal, supervisor or relaunch quit through, and keeps it allowed on re-entry', () => { + installServeNativeQuitGuard({ platform: 'darwin', isQuittingForUpdate, closeDesktopWindows }) + + quitProcess() + expect(electron.quit).toHaveBeenCalledOnce() + // will-quit's second pass calls app.quit() again. + expect(emitBeforeQuit().preventDefault).not.toHaveBeenCalled() + expect(emitBeforeQuit().preventDefault).not.toHaveBeenCalled() + expect(closeDesktopWindows).not.toHaveBeenCalled() + }) + + it('lets an update install quit through', () => { + isQuittingForUpdate.mockReturnValue(true) + installServeNativeQuitGuard({ platform: 'darwin', isQuittingForUpdate, closeDesktopWindows }) + + expect(emitBeforeQuit().preventDefault).not.toHaveBeenCalled() + }) + + it('does not cancel a macOS logout or shutdown', () => { + installServeNativeQuitGuard({ platform: 'darwin', isQuittingForUpdate, closeDesktopWindows }) + + electron.powerListeners.shutdown?.() + + expect(emitBeforeQuit().preventDefault).not.toHaveBeenCalled() + expect(closeDesktopWindows).not.toHaveBeenCalled() + }) + + it.each(['linux', 'win32'] as const)('installs nothing on %s', (platform) => { + installServeNativeQuitGuard({ platform, isQuittingForUpdate, closeDesktopWindows }) + + expect(electron.beforeQuit).toHaveLength(0) + expect(electron.powerListeners).toEqual({}) + }) +}) diff --git a/src/main/startup/serve-native-quit-guard.ts b/src/main/startup/serve-native-quit-guard.ts new file mode 100644 index 00000000000..d096c3cf8b0 --- /dev/null +++ b/src/main/startup/serve-native-quit-guard.ts @@ -0,0 +1,29 @@ +import { app, powerMonitor, type Event } from 'electron' +import { isProcessQuitRequested, markProcessQuitRequested } from './process-quit-request' + +/** + * On macOS, Dock > Quit, Cmd-Q from the app switcher and Activity Monitor's Quit reach before-quit + * as a native terminate. On a serve host that would drop every paired client (#15537), so an + * unmarked quit only closes the desktop windows. Signals, supervisors, relaunches and update + * installs mark their quit first; logout and shutdown mark it through powerMonitor. + */ +export function installServeNativeQuitGuard(deps: { + platform: NodeJS.Platform + isQuittingForUpdate: () => boolean + closeDesktopWindows: () => void +}): void { + // Why macOS only: elsewhere every before-quit comes from an app.quit() call this code owns. + if (deps.platform !== 'darwin') { + return + } + // Why: macOS sends logout/shutdown as the same terminate; vetoing it would cancel the logout. + powerMonitor.on('shutdown', markProcessQuitRequested) + // Why prepend: veto before the updater guard and before startup listeners begin teardown. + app.prependListener('before-quit', (event: Event) => { + if (isProcessQuitRequested() || deps.isQuittingForUpdate()) { + return + } + event.preventDefault() + deps.closeDesktopWindows() + }) +} diff --git a/src/main/startup/serve-options.test.ts b/src/main/startup/serve-options.test.ts index 9e2bb06919d..12625e574cd 100644 --- a/src/main/startup/serve-options.test.ts +++ b/src/main/startup/serve-options.test.ts @@ -12,6 +12,7 @@ describe('getServeOptions', () => { pairingAddress: null, noPairing: true, mobilePairing: false, + grantDesktopControl: false, recipeJson: false, projectRoot: null }) @@ -142,6 +143,7 @@ describe('getServeOptions', () => { pairingAddress: null, noPairing: false, mobilePairing: false, + grantDesktopControl: false, recipeJson: false, projectRoot: null }) diff --git a/src/main/startup/serve-options.ts b/src/main/startup/serve-options.ts index c0398123797..c121abd3c49 100644 --- a/src/main/startup/serve-options.ts +++ b/src/main/startup/serve-options.ts @@ -9,6 +9,7 @@ export type ServeOptions = { pairingAddress: string | null noPairing: boolean mobilePairing: boolean + grantDesktopControl: boolean recipeJson: boolean projectRoot: string | null } @@ -118,6 +119,10 @@ export function getServeOptions(argv: readonly string[]): ServeOptions { ), noPairing: lastBooleanValue(optionsArgv, ['--serve-no-pairing', '--no-pairing']), mobilePairing: lastBooleanValue(optionsArgv, ['--serve-mobile-pairing', '--mobile-pairing']), + grantDesktopControl: lastBooleanValue(optionsArgv, [ + '--serve-grant-desktop-control', + '--grant-desktop-control' + ]), recipeJson: lastBooleanValue(optionsArgv, ['--serve-recipe-json', '--recipe-json']), projectRoot: valueAfter( optionsArgv, diff --git a/src/main/startup/serve-quit-handling.ts b/src/main/startup/serve-quit-handling.ts new file mode 100644 index 00000000000..66cb6694ba3 --- /dev/null +++ b/src/main/startup/serve-quit-handling.ts @@ -0,0 +1,20 @@ +import { isQuittingForUpdate } from '../updater' +import { quitFromUserCommand } from './main-window-actions' +import { quitProcess } from './process-quit-request' +import { installServeNativeQuitGuard } from './serve-native-quit-guard' +import { registerServeSignalHandlers } from './serve-signal-handlers' + +/** + * Signals end a serve host; a user or OS-native Quit only closes its desktop windows (#15537). + * Why after app ready: these Node listeners then preempt Electron's native SIGTERM/SIGINT quit, + * so the native-quit guard only ever sees user and OS terminates. + */ +export function installServeQuitHandling(): void { + // Why every attempt reaches app.quit(): a page beforeunload can veto an earlier signal. + registerServeSignalHandlers(process, quitProcess) + installServeNativeQuitGuard({ + platform: process.platform, + isQuittingForUpdate, + closeDesktopWindows: quitFromUserCommand + }) +} diff --git a/src/main/startup/serve-signal-handlers.test.ts b/src/main/startup/serve-signal-handlers.test.ts index 35c70ef87de..1ec9da580ad 100644 --- a/src/main/startup/serve-signal-handlers.test.ts +++ b/src/main/startup/serve-signal-handlers.test.ts @@ -1,13 +1,26 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' -import { registerServeSignalHandlers } from './serve-signal-handlers' +import { registerServeSignalHandlers, type ServeSignalSource } from './serve-signal-handlers' + +function signalSourceFor(emitter: EventEmitter): ServeSignalSource { + return { + on: (event, listener) => emitter.on(event, listener), + removeListener: (event, listener) => emitter.removeListener(event, listener), + listeners: (event) => + emitter + .listeners(event) + .filter( + (listener): listener is (signal: NodeJS.Signals) => void => typeof listener === 'function' + ) + } +} describe('registerServeSignalHandlers', () => { it('retries a vetoed quit on every delivered signal', () => { const signalSource = new EventEmitter() const quitApplication = vi.fn() - registerServeSignalHandlers(signalSource, quitApplication) + registerServeSignalHandlers(signalSourceFor(signalSource), quitApplication) signalSource.emit('SIGINT') signalSource.emit('SIGINT') signalSource.emit('SIGTERM') @@ -18,4 +31,26 @@ describe('registerServeSignalHandlers', () => { expect(signalSource.listenerCount('SIGTERM')).toBe(1) expect(signalSource.listenerCount('SIGHUP')).toBe(1) }) + + it('drops every earlier listener before re-adding it, so Node reinstalls its signal handler', () => { + const signalSource = new EventEmitter() + const earlier = vi.fn() + signalSource.on('SIGTERM', earlier) + const counts: number[] = [] + signalSource.on('removeListener', (event) => { + if (event === 'SIGTERM') { + counts.push(signalSource.listenerCount('SIGTERM')) + } + }) + const quitApplication = vi.fn() + + registerServeSignalHandlers(signalSourceFor(signalSource), quitApplication) + signalSource.emit('SIGTERM') + + // Node closes a signal's native handle only when its last listener is removed. + expect(counts).toEqual([0]) + expect(signalSource.listeners('SIGTERM')).toEqual([earlier, quitApplication]) + expect(earlier).toHaveBeenCalledOnce() + expect(quitApplication).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/startup/serve-signal-handlers.ts b/src/main/startup/serve-signal-handlers.ts index 0df48d5ea35..0f4981a587c 100644 --- a/src/main/startup/serve-signal-handlers.ts +++ b/src/main/startup/serve-signal-handlers.ts @@ -1,13 +1,28 @@ -type ServeSignalSource = { - on(event: 'SIGINT' | 'SIGTERM' | 'SIGHUP', listener: () => void): unknown +type ServeSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP' +type SignalListener = (signal: NodeJS.Signals) => void + +export type ServeSignalSource = { + on(event: ServeSignal, listener: SignalListener): unknown + removeListener(event: ServeSignal, listener: SignalListener): unknown + listeners(event: ServeSignal): SignalListener[] } export function registerServeSignalHandlers( signalSource: ServeSignalSource, quitApplication: () => void ): void { - // Keep every listener installed so duplicate delivery cannot fall through to default termination. - signalSource.on('SIGINT', quitApplication) - signalSource.on('SIGTERM', quitApplication) - signalSource.on('SIGHUP', quitApplication) + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { + // Why reclaim: Electron's native handler replaces Node's for any signal that already had a + // listener before app ready, so these never ran and a SIGTERM arrived as a native quit. + // Dropping the last listener releases Node's handler; re-adding installs it over Electron's. + const existing = signalSource.listeners(signal) + for (const listener of existing) { + signalSource.removeListener(signal, listener) + } + for (const listener of existing) { + signalSource.on(signal, listener) + } + // Keep every listener installed so duplicate delivery cannot fall through to default termination. + signalSource.on(signal, quitApplication) + } } diff --git a/src/main/startup/serve-user-data-path-guard.test.ts b/src/main/startup/serve-user-data-path-guard.test.ts new file mode 100644 index 00000000000..5030ced5b37 --- /dev/null +++ b/src/main/startup/serve-user-data-path-guard.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import { checkServeUserDataPath } from './serve-user-data-path-guard' + +describe('checkServeUserDataPath', () => { + it('refuses a serve that landed on a profile other than the one the CLI chose', () => { + const env: NodeJS.ProcessEnv = { ORCA_SERVE_USER_DATA_PATH: '/isolated/profile' } + expect( + checkServeUserDataPath({ isServeMode: true, userDataPath: '/default/orca', env }) + ).toContain('/isolated/profile') + expect(env.ORCA_SERVE_USER_DATA_PATH).toBeUndefined() + }) + + it('allows the chosen profile, including another spelling of it', () => { + expect( + checkServeUserDataPath({ + isServeMode: true, + userDataPath: '/isolated/profile', + env: { ORCA_SERVE_USER_DATA_PATH: '/isolated/./profile/' } + }) + ).toBeNull() + expect( + checkServeUserDataPath({ + isServeMode: true, + userDataPath: 'C:\\Users\\Me\\Orca', + env: { ORCA_SERVE_USER_DATA_PATH: 'c:\\users\\me\\orca' }, + platform: 'win32' + }) + ).toBeNull() + }) + + it('leaves launches without a CLI choice, or outside serve, alone', () => { + expect(checkServeUserDataPath({ isServeMode: true, userDataPath: '/a', env: {} })).toBeNull() + const env: NodeJS.ProcessEnv = { ORCA_SERVE_USER_DATA_PATH: '/b' } + expect(checkServeUserDataPath({ isServeMode: false, userDataPath: '/a', env })).toBeNull() + expect(env.ORCA_SERVE_USER_DATA_PATH).toBeUndefined() + }) +}) + +describe('configureOrcaUserDataPathEnv on a serve launch', () => { + it('throws before the instance lock instead of publishing a mismatched profile', async () => { + vi.doMock('electron', () => ({ app: { getPath: () => '/default/orca' } })) + const { configureOrcaUserDataPathEnv } = await import('./configure-process') + const argv = process.argv + const previous = process.env.ORCA_USER_DATA_PATH + process.argv = [...argv, '--serve'] + process.env.ORCA_SERVE_USER_DATA_PATH = '/isolated/profile' + process.env.ORCA_USER_DATA_PATH = '/isolated/profile' + try { + expect(() => configureOrcaUserDataPathEnv()).toThrow('/isolated/profile') + expect(process.env.ORCA_USER_DATA_PATH).toBe('/isolated/profile') + } finally { + process.argv = argv + delete process.env.ORCA_SERVE_USER_DATA_PATH + if (previous === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previous + } + vi.doUnmock('electron') + } + }) +}) diff --git a/src/main/startup/serve-user-data-path-guard.ts b/src/main/startup/serve-user-data-path-guard.ts new file mode 100644 index 00000000000..1bf58094cf1 --- /dev/null +++ b/src/main/startup/serve-user-data-path-guard.ts @@ -0,0 +1,27 @@ +import { SERVE_USER_DATA_PATH_ENV, isSameUserDataPath } from '../../shared/serve-user-data-path' + +/** + * Returns the refusal message when this serve launch landed on a profile other than the one + * `orca serve` resolved, or null when it may continue to the instance lock. + * + * Why before the lock: losing another profile's lock delivers a second-instance request to + * whatever app owns that profile, often the user's own desktop. + */ +export function checkServeUserDataPath(options: { + isServeMode: boolean + userDataPath: string + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform +}): string | null { + const env = options.env ?? process.env + const expected = env[SERVE_USER_DATA_PATH_ENV] + // Why consume: serve-hosted terminals must not inherit a stale expectation into a later launch. + delete env[SERVE_USER_DATA_PATH_ENV] + if (!options.isServeMode || !expected) { + return null + } + if (isSameUserDataPath(expected, options.userDataPath, options.platform)) { + return null + } + return `[serve] orca serve chose the profile at ${expected}, but this launch resolved ${options.userDataPath}; refusing to start so it cannot touch another profile's running Orca.` +} diff --git a/src/main/window/createMainWindow-user-quit-close.test.ts b/src/main/window/createMainWindow-user-quit-close.test.ts new file mode 100644 index 00000000000..fe9aa5a2beb --- /dev/null +++ b/src/main/window/createMainWindow-user-quit-close.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', async () => + (await import('./createMainWindow-test-harness')).electronModuleMock() +) +vi.mock('@electron-toolkit/utils', async () => + (await import('./createMainWindow-test-harness')).electronToolkitUtilsMock() +) +vi.mock('./macos-tahoe-release', async () => + (await import('./createMainWindow-test-harness')).macosTahoeReleaseMock() +) +vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock()) +vi.mock('../browser/browser-manager', async () => + (await import('./createMainWindow-test-harness')).browserManagerMock() +) + +import { ipcMain } from 'electron' +import { createMainWindow, WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS } from './createMainWindow' +import { resetExpectedTeardownStateForTest } from '../crash-reporting/expected-teardown-state' +import { browserWindowMock, resetMainWindowMocks } from './createMainWindow-test-harness' +import { markUserQuitWindowClose } from './user-quit-window-close' + +type Handler = (...args: any[]) => void + +function setupWindow() { + const windowHandlers: Record = {} + const ipcHandlers: Record = {} + vi.mocked(ipcMain.on).mockImplementation((channel, handler) => { + ipcHandlers[channel] = handler + return ipcMain + }) + const webContents = { + id: 42, + on: vi.fn((event: string, handler: Handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isCrashed: vi.fn(() => false) + } + const instance = { + webContents, + on: vi.fn((event: string, handler: Handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + isMinimized: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + destroy: vi.fn(), + loadFile: vi.fn(() => Promise.resolve()), + loadURL: vi.fn(() => Promise.resolve()) + } + browserWindowMock.mockImplementation(function () { + return instance + }) + return { windowHandlers, ipcHandlers, webContents, instance } +} + +function lastCloseRequestId(send: ReturnType): number { + const requests = send.mock.calls.filter(([channel]) => channel === 'window:close-requested') + const request: unknown = requests.at(-1)?.[1] + return typeof request === 'object' && request !== null && 'requestId' in request + ? Number(request.requestId) + : -1 +} + +describe('serve user-Quit window close', () => { + const originalPlatform = process.platform + + beforeEach(() => { + resetMainWindowMocks() + resetExpectedTeardownStateForTest() + vi.useFakeTimers() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + vi.useRealTimers() + }) + + it('destroys a frozen renderer after the quit ack deadline without quitting the process', async () => { + const { windowHandlers, webContents, instance } = setupWindow() + createMainWindow(null, { getIsQuitting: () => false }) + + markUserQuitWindowClose(instance) + windowHandlers.close({ preventDefault: vi.fn() }) + + expect(webContents.send).toHaveBeenCalledWith('window:close-requested', { + isQuitting: false, + requestId: expect.any(Number) + }) + await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS) + expect(instance.destroy).toHaveBeenCalledOnce() + }) + + it('leaves a responsive renderer free to veto the close', async () => { + const { windowHandlers, ipcHandlers, webContents, instance } = setupWindow() + createMainWindow(null, { getIsQuitting: () => false }) + + markUserQuitWindowClose(instance) + windowHandlers.close({ preventDefault: vi.fn() }) + ipcHandlers['window:close-request-received']?.( + { sender: { id: 42 } }, + lastCloseRequestId(webContents.send) + ) + await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS) + + expect(instance.destroy).not.toHaveBeenCalled() + }) + + it('keeps a plain window close without a deadline', async () => { + const { windowHandlers, instance } = setupWindow() + createMainWindow(null, { getIsQuitting: () => false }) + + markUserQuitWindowClose(instance) + windowHandlers.close({ preventDefault: vi.fn() }) + windowHandlers['will-prevent-unload']?.() + windowHandlers.close({ preventDefault: vi.fn() }) + // The first attempt's deadline is cleared by the veto; the second is a plain close. + await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS * 2) + + expect(instance.destroy).not.toHaveBeenCalled() + }) + + it('closes instead of hiding to the Windows tray', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const { windowHandlers, webContents, instance } = setupWindow() + const store = { + getUI: vi.fn(() => ({ trayMinimizeNoticeShown: true })), + getSettings: vi.fn(() => ({ windowBackgroundBlur: false, minimizeToTrayOnClose: true })), + updateUI: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path reads only getUI/getSettings/updateUI. + createMainWindow(store as never, { getIsQuitting: () => false }) + + markUserQuitWindowClose(instance) + windowHandlers.close({ preventDefault: vi.fn() }) + + expect(instance.hide).not.toHaveBeenCalled() + expect(webContents.send).toHaveBeenCalledWith( + 'window:close-requested', + expect.objectContaining({ isQuitting: false }) + ) + }) +}) diff --git a/src/main/window/main-window-close-lifecycle.ts b/src/main/window/main-window-close-lifecycle.ts index 0bf1c04ef88..d18c76cfccb 100644 --- a/src/main/window/main-window-close-lifecycle.ts +++ b/src/main/window/main-window-close-lifecycle.ts @@ -7,6 +7,7 @@ import type { CreateMainWindowOptions } from './main-window-contracts' import type { MainWindowFocusLifecycle } from './main-window-focus-lifecycle' import type { MainWindowStateLifecycle } from './main-window-state-lifecycle' import { syncTrafficLightPosition } from './main-window-visual-lifecycle' +import { consumeUserQuitWindowClose } from './user-quit-window-close' export const WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS = QUIT_RENDERER_ACK_TIMEOUT_MS @@ -90,8 +91,9 @@ export function installMainWindowCloseLifecycle(args: { } mainWindow.on('close', (e) => { + const userQuitClose = consumeUserQuitWindowClose(mainWindow) // Why: Alt+F4/programmatic closes hit the native event; apply the same minimize-to-tray guard the renderer-drawn X uses. - if (!windowCloseConfirmed && hideToTrayIfEnabled()) { + if (!windowCloseConfirmed && !userQuitClose && hideToTrayIfEnabled()) { e.preventDefault() return } @@ -115,7 +117,8 @@ export function installMainWindowCloseLifecycle(args: { e.preventDefault() const isQuitting = opts?.getIsQuitting?.() ?? false const requestId = ++closeRequestSequence - if (isQuitting) { + // Why userQuitClose: a serve host's user Quit only closes windows, yet a frozen renderer must not trap it. + if (isQuitting || userQuitClose) { armQuitRendererAckTimer(requestId) } // Why: renderer owns the close decision; the always-mounted App root subscription lets even pre-workspace states reply (#5144). diff --git a/src/main/window/user-quit-window-close.ts b/src/main/window/user-quit-window-close.ts new file mode 100644 index 00000000000..c65f86d0b51 --- /dev/null +++ b/src/main/window/user-quit-window-close.ts @@ -0,0 +1,20 @@ +import type { BrowserWindow } from 'electron' + +/** + * Marks a window close that a user Quit asked for on a host that stays running (`orca serve`). + * Why separate from isQuitting: the process is not quitting, but the close still needs the quit + * close semantics — the frozen-renderer ack deadline and no minimize-to-tray. + */ +/** Only the window's identity is used, so a narrow slice of BrowserWindow is enough. */ +export type UserQuitClosingWindow = Pick + +const pendingUserQuitCloses = new WeakSet() + +export function markUserQuitWindowClose(window: UserQuitClosingWindow): void { + pendingUserQuitCloses.add(window) +} + +/** One close attempt consumes the mark, so a later plain close keeps its own semantics. */ +export function consumeUserQuitWindowClose(window: UserQuitClosingWindow): boolean { + return pendingUserQuitCloses.delete(window) +} diff --git a/src/main/worktree-removal-close-records.test.ts b/src/main/worktree-removal-close-records.test.ts index 0b3722482c8..cfad31f4d20 100644 --- a/src/main/worktree-removal-close-records.test.ts +++ b/src/main/worktree-removal-close-records.test.ts @@ -198,7 +198,7 @@ describe('close records on workspace removal', () => { ]) }) - store.removeProject('repo-a') + store.removeProjectForHost('repo-a', 'local') expect( Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index 36859897938..b7eae7dbb03 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -24,8 +24,6 @@ export const reposApi = { getDefaultCreateProjectParent: (): Promise => ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), - remove: (args) => ipcRenderer.invoke('repos:remove', args), - removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), reorder: (args) => ipcRenderer.invoke('repos:reorder', args), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 409154f0c15..dd76d3f1b23 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -34,7 +34,6 @@ export type RepositoryApi = { kind?: 'git' | 'folder' displayName?: string }) => Promise<{ repo: Repo } | { error: string }> - remove: (args: { repoId: string }) => Promise // Forget a project on one execution host only, leaving the same repo id on other hosts intact. removeForHost: (args: { repoId: string; hostId: string }) => Promise reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 2dd6a57b55a..f9519ed859f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -174,12 +174,8 @@ const NativeChatComposerPane = forwardRef {continuation.offeredTurnItemId ?? 'none'} {String(continuation.view.continueAvailable)} - - {composerError ?? continuation.continueError?.text ?? 'none'} - + {composerError ?? continuation.continueError?.text ?? 'none'} diff --git a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx index 176eb8adad1..f190bdce7ad 100644 --- a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx @@ -120,6 +120,36 @@ export function SshHostAdvancedFields({ )} /> +
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.allowRemoteCliControlHelp', + 'When off, orca commands run on this host reach only its own terminals. Turn on to let them reach other hosts, orchestration, files, and integrations on this computer.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + allowRemoteCliControl: !f.allowRemoteCliControl + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.allowRemoteCliControl', + "Allow this host's orca CLI to control Orca" + )} + /> +
{ +async function renderCard( + state: SshConnectionState | undefined, + cardTarget: SshTarget = target +): Promise { const container = document.createElement('div') document.body.appendChild(container) await act(async () => { createRoot(container).render( { expect(container.textContent).not.toContain('Host key verification failed') }) }) + +describe('the Host Node runtime on an SSH target card', () => { + const connected: SshConnectionState = { + targetId: 'target-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + + it('marks a relay running on the opt-in Host Node runtime as unsupported', async () => { + const container = await renderCard( + { ...connected, hostNodeRuntime: true }, + { ...target, remoteRuntime: 'legacy' } + ) + + const note = container.querySelector('[data-ssh-host-node-runtime]') + expect(note?.textContent).toContain('Unsupported configuration') + expect(note?.textContent).toContain('Set Runtime to Auto') + }) + + it('marks an Auto host that fell back to Host Node without advice it already follows', async () => { + const container = await renderCard({ ...connected, hostNodeRuntime: true }) + + const note = container.querySelector('[data-ssh-host-node-runtime]') + expect(note?.textContent).toContain('Unsupported configuration') + expect(note?.textContent).toContain('Orca-managed Node isn’t available') + expect(note?.textContent).not.toContain('Set Runtime to Auto') + }) + + it('says nothing about the runtime on a default connect', async () => { + const container = await renderCard(connected) + + expect(container.querySelector('[data-ssh-host-node-runtime]')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx index fc9319600bf..847c610a1d6 100644 --- a/src/renderer/src/components/settings/SshTargetCard.tsx +++ b/src/renderer/src/components/settings/SshTargetCard.tsx @@ -321,6 +321,23 @@ export function SshTargetCard({ {state.plainSsh.message}

) : null} + {status === 'connected' && state?.hostNodeRuntime ? ( +

+ {/* Why two: only an opt-in can be undone in settings; Auto lands here when Orca-managed Node couldn't run. */} + {target.remoteRuntime === 'legacy' + ? translate( + 'auto.components.settings.SshTargetCard.hostNodeUnsupported', + 'Unsupported configuration: Orca runs on this host’s Node.js with terminal support installed by npm on the host. Set Runtime to Auto in this host’s SSH settings to use Orca-managed Node.' + ) + : translate( + 'auto.components.settings.SshTargetCard.hostNodeFallback', + 'Unsupported configuration: Orca-managed Node isn’t available for this connection, so Orca runs on this host’s Node.js with terminal support installed by npm on the host.' + )} +

+ ) : null}
diff --git a/src/renderer/src/components/settings/ssh-target-draft.ts b/src/renderer/src/components/settings/ssh-target-draft.ts index 38d68734f6a..6c0aed22cc2 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.ts @@ -25,6 +25,7 @@ export type EditingTarget = { relayGracePeriodSeconds: string relayKeepAliveUntilReset: boolean remoteRuntime: SshRemoteRuntimeChoice + allowRemoteCliControl: boolean } export const EMPTY_FORM: EditingTarget = { @@ -40,7 +41,8 @@ export const EMPTY_FORM: EditingTarget = { systemSshConnectionReuse: true, relayGracePeriodSeconds: String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS), relayKeepAliveUntilReset: DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS === 0, - remoteRuntime: 'auto' + remoteRuntime: 'auto', + allowRemoteCliControl: false } export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { @@ -65,7 +67,8 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { ), relayKeepAliveUntilReset: (target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) === 0, - remoteRuntime: target.remoteRuntime ?? 'auto' + remoteRuntime: target.remoteRuntime ?? 'auto', + allowRemoteCliControl: target.allowRemoteCliControl === true } } @@ -173,7 +176,8 @@ export function hasAdvancedConnectionValues(form: EditingTarget): boolean { form.proxyCommand.trim().length > 0 || form.jumpHost.trim().length > 0 || !form.systemSshConnectionReuse || - form.remoteRuntime !== 'auto' + form.remoteRuntime !== 'auto' || + form.allowRemoteCliControl ) } @@ -191,7 +195,8 @@ export function isSshTargetFormDirty(current: EditingTarget, baseline: EditingTa current.systemSshConnectionReuse !== baseline.systemSshConnectionReuse || current.relayGracePeriodSeconds !== baseline.relayGracePeriodSeconds || current.relayKeepAliveUntilReset !== baseline.relayKeepAliveUntilReset || - current.remoteRuntime !== baseline.remoteRuntime + current.remoteRuntime !== baseline.remoteRuntime || + current.allowRemoteCliControl !== baseline.allowRemoteCliControl ) } diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.test.ts b/src/renderer/src/components/settings/ssh-target-save-payload.test.ts index de2b0e22404..386a8e7113d 100644 --- a/src/renderer/src/components/settings/ssh-target-save-payload.test.ts +++ b/src/renderer/src/components/settings/ssh-target-save-payload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { EMPTY_FORM } from './ssh-target-draft' +import { EMPTY_FORM, getEditingTargetForSshTarget, isSshTargetFormDirty } from './ssh-target-draft' import { buildSshTargetSavePayload } from './ssh-target-save-payload' describe('buildSshTargetSavePayload', () => { @@ -105,4 +105,33 @@ describe('buildSshTargetSavePayload', () => { expect(result.error).toContain('Terminal timeout') } }) + + it('keeps remote CLI control off unless the user opts this host in, and clears it on update', () => { + const off = buildSshTargetSavePayload({ ...EMPTY_FORM, host: 'gpu.example.com' }) + const on = buildSshTargetSavePayload({ + ...EMPTY_FORM, + host: 'gpu.example.com', + allowRemoteCliControl: true + }) + if (!off.ok || !on.ok) { + throw new Error('payload rejected') + } + expect(off.payload.target).not.toHaveProperty('allowRemoteCliControl') + expect(off.payload.updates).toHaveProperty('allowRemoteCliControl', undefined) + expect(on.payload.target).toMatchObject({ allowRemoteCliControl: true }) + expect(on.payload.updates).toMatchObject({ allowRemoteCliControl: true }) + }) + + it('round-trips the remote CLI control opt-in through the edit form', () => { + const form = getEditingTargetForSshTarget({ + id: 'gpu', + label: 'gpu', + host: 'gpu.example.com', + port: 22, + username: 'me', + allowRemoteCliControl: true + }) + expect(form.allowRemoteCliControl).toBe(true) + expect(isSshTargetFormDirty({ ...form, allowRemoteCliControl: false }, form)).toBe(true) + }) }) diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.ts b/src/renderer/src/components/settings/ssh-target-save-payload.ts index 75434e72b09..f5cf2dc8d2d 100644 --- a/src/renderer/src/components/settings/ssh-target-save-payload.ts +++ b/src/renderer/src/components/settings/ssh-target-save-payload.ts @@ -72,7 +72,8 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay ...(proxyCommand ? { proxyCommand } : {}), ...(jumpHost ? { jumpHost } : {}), ...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}), - ...(remoteRuntime ? { remoteRuntime } : {}) + ...(remoteRuntime ? { remoteRuntime } : {}), + ...(form.allowRemoteCliControl ? { allowRemoteCliControl: true } : {}) } return { @@ -89,6 +90,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay jumpHost, systemSshConnectionReuse, remoteRuntime, + allowRemoteCliControl: form.allowRemoteCliControl || undefined, source: 'manual' } } diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index 0b5bd3cdb46..f1b8856e14f 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -13,7 +13,7 @@ import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId, - type ExecutionHostId + normalizeExecutionHostId } from '../../../../shared/execution-host' // Why: interpolated into the sentence so locales control where the name sits; @@ -29,7 +29,8 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { const isOpen = activeModal === 'confirm-remove-folder' const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : '' const displayName = typeof modalData.displayName === 'string' ? modalData.displayName : '' - const hostId = typeof modalData.hostId === 'string' ? (modalData.hostId as ExecutionHostId) : null + const hostId = + typeof modalData.hostId === 'string' ? normalizeExecutionHostId(modalData.hostId) : null // Why: for an SSH project the files live on the remote host's disk, not the // user's — "still on your disk" would be misleading. Name the host (using the @@ -75,11 +76,9 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { const [descriptionBeforeName, descriptionAfterName] = description.split(NAME_TOKEN) const handleConfirm = useCallback(() => { - if (repoId) { - void removeProject(repoId, { - ...(hostId ? { hostId } : {}), - errorFeedback: 'toast' - }) + // Why: without the opener's host a bare id could remove another host's row (#13071). + if (repoId && hostId) { + void removeProject(repoId, { hostId, errorFeedback: 'toast' }) } closeModal() }, [closeModal, hostId, removeProject, repoId]) diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index dafda9e5f5f..9d21a0c1212 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -10,7 +10,7 @@ import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import { resolveWorktreeBatchDeleteTargets, toWorktreeDeleteIdentities, @@ -54,12 +54,11 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt hostId: target.hostId, settings: state.settings }) - const hostId = repo ? getRepoExecutionHostId(repo) : target.hostId // Why: git refuses to delete the primary checkout; users can still remove the owning project from Orca (disk contents kept). state.openModal('confirm-remove-folder', { repoId: target.repoId, displayName: repo?.displayName ?? target.displayName, - ...(hostId ? { hostId } : {}) + hostId: getWorktreeExecutionHostId(target, repo ?? undefined) }) return } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts index 1f8a1c7d0fc..e872092eddf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts @@ -207,6 +207,25 @@ describe('fresh spawn leaves a local pane unbound', () => { ) }) + // #21195: connect() resolves empty while the remote transport still holds an armed retry. + it('leaves recovery to a transport that owns its own retry', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = Object.assign(createMockTransport(), { ownsRecovery: vi.fn(() => true) }) + transport.connect.mockImplementation(async () => undefined) + transportFactoryQueue.push(transport) + + const deps = createDeps({ tabId: 'tab-transport-retry' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocks cover every pane, manager, and deps member the connect path reads. + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(40) + + expect(transport.connect).toHaveBeenCalled() + expect(transport.ownsRecovery).toHaveBeenCalled() + expect(requestTerminalPaneRecovery).not.toHaveBeenCalledWith( + expect.objectContaining({ reason: 'spawn-left-pane-unbound' }) + ) + }) + it('does not remount when the spawn bound a PTY', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-bound') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 24c89af5181..14c2a0b98c3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -160,7 +160,8 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio ...(meta.snapshotCols !== undefined && meta.snapshotRows !== undefined ? { snapshotCols: meta.snapshotCols, snapshotRows: meta.snapshotRows } : {}), - ...(meta.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}) + ...(meta.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}), + ...(meta.keepsLocalScrollback ? { keepsLocalScrollback: true } : {}) } session.scheduleReplayDataDrain() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts new file mode 100644 index 00000000000..60fba81b1d9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -0,0 +1,112 @@ +import { useAppStore } from '@/store' +import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention' +import type { IpcPtyTransportOptions } from '../pty-transport-types' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** The spawn/attach options a pane hands its PTY transport. */ +export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPtyTransportOptions { + return { + terminalKittyKeyboardProtocol: + session.pane.terminal.options.vtExtensions?.kittyKeyboard === true, + cwd: session.deps.cwd, + ...(session.deps.cwdPromise || + session.deps.preconnectInput?.length || + session.buffersInputOnlyForReattach + ? { bufferInputUntilConnect: true } + : {}), + ...(session.deps.preconnectInput?.length + ? { preconnectInput: session.deps.preconnectInput } + : {}), + ...(session.deps.onPreconnectInput + ? { onPreconnectInput: session.deps.onPreconnectInput } + : {}), + // Why: only fresh local IPC spawns may recover from a saved startup cwd + // whose directory was deleted (#7239); remote-runtime and SSH spawns + // resolve cwd on another host and must keep exact cwd semantics. + ...(session.runtimeEnvironmentId === null && !session.connectionId + ? { cwdFallback: 'worktree' as const } + : {}), + env: session.paneEnv, + ...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}), + command: session.shouldDeliverStartupViaTerminalPaste + ? undefined + : session.paneStartup?.command, + ...(session.shouldUseProviderSshStartupDelivery + ? { commandDelivery: 'provider' as const } + : {}), + startupCommandDelivery: session.shouldDeliverStartupViaTerminalPaste + ? undefined + : session.connectionId && session.paneStartup?.command + ? 'shell-ready' + : session.paneStartup?.startupCommandDelivery, + connectionId: session.connectionId, + executionHostId: session.executionHostId, + worktreeId: session.deps.worktreeId, + // Why: closes the SIGKILL race documented in INVESTIGATION.md by letting + // main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before + // pty:spawn returns. Daemon-host-only: SSH path leaves these undefined + // and the main-side guard short-circuits. + tabId: session.deps.tabId, + leafId: session.pane.leafId, + ...(session.deps.placement ? { placement: session.deps.placement } : {}), + activate: session.deps.isActiveRef.current && session.deps.isVisibleRef.current, + ...(session.shellOverride ? { shellOverride: session.shellOverride } : {}), + ...(session.projectRuntime ? { projectRuntime: session.projectRuntime } : {}), + ...(session.terminalColorQueryReplies + ? { terminalColorQueryReplies: session.terminalColorQueryReplies } + : {}), + ...(session.paneStartup?.launchConfig + ? { launchConfig: session.paneStartup.launchConfig } + : {}), + ...(session.paneStartup?.resumeProviderSession + ? { resumeProviderSession: session.paneStartup.resumeProviderSession } + : {}), + ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) + ? { + agentPrompt: + session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt + } + : {}), + ...(session.paneStartup?.initialAgentStatus?.prompt + ? { agentPromptDelivery: 'auto-submit' as const } + : session.paneStartup?.draftPrompt + ? { agentPromptDelivery: 'draft' as const } + : {}), + ...(session.paneStartup?.agentArgsOverride !== undefined + ? { agentArgsOverride: session.paneStartup.agentArgsOverride } + : {}), + ...(session.agentLaunchPreferences + ? { agentLaunchPreferences: session.agentLaunchPreferences } + : {}), + ...(session.launchToken ? { launchToken: session.launchToken } : {}), + ...(session.paneStartup?.launchAgent ? { launchAgent: session.paneStartup.launchAgent } : {}), + ...(session.paneStartup?.telemetry ? { telemetry: session.paneStartup.telemetry } : {}), + onPtyExit: session.onExit, + onPtySpawn: session.onPtySpawn, + onPtyRebind: session.onPtyRebind, + retainDisposedSpawn: () => + shouldRetainDisposedPaneSpawn( + useAppStore.getState(), + session.deps.worktreeId, + session.deps.tabId, + session.pane.leafId, + session.executionHostId + ), + ...(session.mainSideEffectAuthority + ? {} + : { + onTitleChange: session.onTitleChange, + onBell: session.onBell, + onAgentBecameIdle: session.onAgentBecameIdle, + onAgentBecameWorking: session.onAgentBecameWorking, + onAgentExited: session.onAgentExited + }), + // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService + // parses OSC 9999 before renderer delivery and forwards through the hook + // server with local/SSH identity. Remote-runtime streams do not pass through + // local main, so the renderer remains their status owner for now. + ...(session.shouldOwnAgentStatusInRenderer + ? { onAgentStatus: session.handleRendererOwnedAgentStatus } + : {}) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index d6f8a6de9c6..37faa8d2cea 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -21,7 +21,7 @@ import { import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { TRANSPORT_CONNECT_SETTLE_GRACE_MS } from './pty-connect-limits' -import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention' +import { buildPaneTransportOptions } from './pane-transport-options' import { buffersInputOnlyForSshReattach } from './ssh-reattach-input-buffering' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -39,110 +39,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { : undefined session.agentLaunchPreferences = toAgentLaunchPreferences(session.paneStartup?.sessionOptions) session.buffersInputOnlyForReattach = buffersInputOnlyForSshReattach(session) - session.transportOptions = { - terminalKittyKeyboardProtocol: - session.pane.terminal.options.vtExtensions?.kittyKeyboard === true, - cwd: session.deps.cwd, - ...(session.deps.cwdPromise || - session.deps.preconnectInput?.length || - session.buffersInputOnlyForReattach - ? { bufferInputUntilConnect: true } - : {}), - ...(session.deps.preconnectInput?.length - ? { preconnectInput: session.deps.preconnectInput } - : {}), - ...(session.deps.onPreconnectInput - ? { onPreconnectInput: session.deps.onPreconnectInput } - : {}), - // Why: only fresh local IPC spawns may recover from a saved startup cwd - // whose directory was deleted (#7239); remote-runtime and SSH spawns - // resolve cwd on another host and must keep exact cwd semantics. - ...(session.runtimeEnvironmentId === null && !session.connectionId - ? { cwdFallback: 'worktree' as const } - : {}), - env: session.paneEnv, - ...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}), - command: session.shouldDeliverStartupViaTerminalPaste - ? undefined - : session.paneStartup?.command, - ...(session.shouldUseProviderSshStartupDelivery - ? { commandDelivery: 'provider' as const } - : {}), - startupCommandDelivery: session.shouldDeliverStartupViaTerminalPaste - ? undefined - : session.connectionId && session.paneStartup?.command - ? 'shell-ready' - : session.paneStartup?.startupCommandDelivery, - connectionId: session.connectionId, - executionHostId: session.executionHostId, - worktreeId: session.deps.worktreeId, - // Why: closes the SIGKILL race documented in INVESTIGATION.md by letting - // main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before - // pty:spawn returns. Daemon-host-only: SSH path leaves these undefined - // and the main-side guard short-circuits. - tabId: session.deps.tabId, - leafId: session.pane.leafId, - ...(session.deps.placement ? { placement: session.deps.placement } : {}), - activate: session.deps.isActiveRef.current && session.deps.isVisibleRef.current, - ...(session.shellOverride ? { shellOverride: session.shellOverride } : {}), - ...(session.projectRuntime ? { projectRuntime: session.projectRuntime } : {}), - ...(session.terminalColorQueryReplies - ? { terminalColorQueryReplies: session.terminalColorQueryReplies } - : {}), - ...(session.paneStartup?.launchConfig - ? { launchConfig: session.paneStartup.launchConfig } - : {}), - ...(session.paneStartup?.resumeProviderSession - ? { resumeProviderSession: session.paneStartup.resumeProviderSession } - : {}), - ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) - ? { - agentPrompt: - session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt - } - : {}), - ...(session.paneStartup?.initialAgentStatus?.prompt - ? { agentPromptDelivery: 'auto-submit' as const } - : session.paneStartup?.draftPrompt - ? { agentPromptDelivery: 'draft' as const } - : {}), - ...(session.paneStartup?.agentArgsOverride !== undefined - ? { agentArgsOverride: session.paneStartup.agentArgsOverride } - : {}), - ...(session.agentLaunchPreferences - ? { agentLaunchPreferences: session.agentLaunchPreferences } - : {}), - ...(session.launchToken ? { launchToken: session.launchToken } : {}), - ...(session.paneStartup?.launchAgent ? { launchAgent: session.paneStartup.launchAgent } : {}), - ...(session.paneStartup?.telemetry ? { telemetry: session.paneStartup.telemetry } : {}), - onPtyExit: session.onExit, - onPtySpawn: session.onPtySpawn, - onPtyRebind: session.onPtyRebind, - retainDisposedSpawn: () => - shouldRetainDisposedPaneSpawn( - useAppStore.getState(), - session.deps.worktreeId, - session.deps.tabId, - session.pane.leafId, - session.executionHostId - ), - ...(session.mainSideEffectAuthority - ? {} - : { - onTitleChange: session.onTitleChange, - onBell: session.onBell, - onAgentBecameIdle: session.onAgentBecameIdle, - onAgentBecameWorking: session.onAgentBecameWorking, - onAgentExited: session.onAgentExited - }), - // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService - // parses OSC 9999 before renderer delivery and forwards through the hook - // server with local/SSH identity. Remote-runtime streams do not pass through - // local main, so the renderer remains their status owner for now. - ...(session.shouldOwnAgentStatusInRenderer - ? { onAgentStatus: session.handleRendererOwnedAgentStatus } - : {}) - } + session.transportOptions = buildPaneTransportOptions(session) if (session.connectionOwnerHydrating) { // Why: this pane holds an inert transport until its host resolves; register it so // the repos:changed handler remounts it instead of leaving the terminal blank. @@ -317,7 +214,9 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { const connectStillSettling = session.transportConnectInFlightSince !== null && Date.now() - session.transportConnectInFlightSince < TRANSPORT_CONNECT_SETTLE_GRACE_MS - if (connectStillSettling || session.disposed) { + // Why: a transport that is retrying its own binding owns recovery; a remount would race it. + const transportOwnsRecovery = !providerRejected && session.transport.ownsRecovery?.() === true + if (connectStillSettling || transportOwnsRecovery || session.disposed) { return } const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index f748de0ea73..7f0e90347f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -105,7 +105,8 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { terminalOwner, snapshotCols, snapshotRows, - carriesNormalBuffer + carriesNormalBuffer, + keepsLocalScrollback } = payload session.pendingReplayData = null const isCurrentPayload = (): boolean => @@ -131,7 +132,11 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { if (!isCurrentPayload()) { continue } - const image = buildFoldedImageReplayWrites(data, session.isPaneOnAlternateScreen()) + const image = buildFoldedImageReplayWrites( + data, + session.isPaneOnAlternateScreen(), + keepsLocalScrollback === true + ) replayPayload = image.payload await session.writeReplayDataAsync(image.preamble) } else { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts index 749d8c73492..87c06cbbb24 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts @@ -7,6 +7,7 @@ vi.mock('../terminal-pane-recovery', () => ({ })) function buildSession(overrides: Record = {}): never { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bag names every session member settleSpawnThatLeftPaneUnbound reads. return { deps: { tabId: 'tab-1', worktreeId: 'wt-1', restoredLeafId: 'leaf-1' }, pane: { id: 4, leafId: 'pane-leaf' }, @@ -14,6 +15,7 @@ function buildSession(overrides: Record = {}): never { terminalRecoveryInstance: { id: 3 }, directSshRetryAttempt: undefined, settlePaneAttachAttempt: vi.fn(), + transport: {}, ...overrides } as never } @@ -47,6 +49,24 @@ describe('settleSpawnThatLeftPaneUnbound', () => { expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() }) + // #21195: a remount here would race the transport's own armed retry onto the same pane. + it('leaves recovery to a remote transport that armed its own retry', () => { + const settlePaneAttachAttempt = vi.fn() + + settleSpawnThatLeftPaneUnbound( + buildSession({ transport: { ownsRecovery: () => true }, settlePaneAttachAttempt }) + ) + + expect(settlePaneAttachAttempt).toHaveBeenCalledExactlyOnceWith(undefined, 'failed') + expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() + }) + + it('remounts when the transport reports no recovery of its own', () => { + settleSpawnThatLeftPaneUnbound(buildSession({ transport: { ownsRecovery: () => false } })) + + expect(requestTerminalPaneRecovery).toHaveBeenCalledOnce() + }) + it('settles the spawn as failed before remounting', () => { const settlePaneAttachAttempt = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts index a6dfd3c5a96..91b1e5ebd8b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts @@ -14,13 +14,15 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' * rebinds one. A remount reattaches over the still-live PTY and drains the * buffer. * - * A direct-SSH lease runs its own retry ledger, so it keeps ownership here and - * a second remount never races it. */ + * A direct-SSH lease runs its own retry ledger, and a transport that armed or + * parked its own retry owns the pane too, so a second remount never races + * either of them. */ export function settleSpawnThatLeftPaneUnbound(session: ConnectPanePtySession): void { // Read before settling: the settle clears the lease this branch tests. - const directSshRetryOwnsRecovery = Boolean(session.directSshRetryAttempt) + const retryOwnsRecovery = + Boolean(session.directSshRetryAttempt) || session.transport.ownsRecovery?.() === true session.settlePaneAttachAttempt(session.directSshRetryAttempt, 'failed') - if (directSshRetryOwnsRecovery) { + if (retryOwnsRecovery) { return } warnTerminalLifecycleAnomaly('fresh spawn left the pane unbound', { diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index 8903348ee16..970461d7507 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -41,6 +41,7 @@ export type ProcessPtyOutputOptions = { snapshotCols?: number snapshotRows?: number carriesNormalBuffer?: boolean + keepsLocalScrollback?: boolean } function removeSuppressedCursorNativeTitles( @@ -230,7 +231,8 @@ export function createPtyOutputProcessor({ ...(options.snapshotCols !== undefined && options.snapshotRows !== undefined ? { snapshotCols: options.snapshotCols, snapshotRows: options.snapshotRows } : {}), - ...(options.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}) + ...(options.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}), + ...(options.keepsLocalScrollback ? { keepsLocalScrollback: true } : {}) } if (Object.keys(replayMeta).length > 0) { callbacks.onReplayData(data, replayMeta) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index c45457dfbc1..7702a63a9a0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -72,6 +72,8 @@ export type PtyReplayDataMeta = { /** An image that starts on the normal buffer and enters alt itself; absent for * raw byte replays such as an SSH relay's ring buffer. */ carriesNormalBuffer?: boolean + /** The image carries no history, so replay clears the screen but keeps scrollback. */ + keepsLocalScrollback?: boolean } export type LocalPtySessionMetadata = { @@ -219,6 +221,8 @@ export type PtyTransport = { getRecoveryState?: () => PtyTransportRecoveryState /** Starts a fresh connection epoch while preserving the authoritative remote PTY identity. */ retryRecovery?: () => boolean + /** True while the transport has a retry armed or parked; pane-level remounts must defer to it. */ + ownsRecovery?: () => boolean /** Lets a wrapper retain input when recovery re-enters connect internally. */ setConnectForRecovery?: (connect: PtyTransport['connect']) => void /** The user dismissed the error surface; the next occurrence of the same message must surface again. */ diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts index 2cd40bd4a33..9ef1dac921b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts @@ -124,8 +124,8 @@ describe('remote transport snapshot source-grid threading', () => { deliverSnapshot({ cols: 154, rows: 68, seq: 9, source: 'headless' }, 'recovered') await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBe(2) expect(onReplayData).toHaveBeenLastCalledWith( - '\x1b[?2026l\x1b[2J\x1b[3J\x1b[Hrecovered', - expect.objectContaining({ snapshotCols: 154, snapshotRows: 68 }) + '\x1b[?2026l\x1b[2J\x1b[Hrecovered', + expect.objectContaining({ snapshotCols: 154, snapshotRows: 68, keepsLocalScrollback: true }) ) // A host that publishes no dimensions must read as unknown, not as a grid. diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 4ad4e950c81..f9e95fb7c8c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -191,7 +191,10 @@ describe('createRemoteRuntimePtyTransport', () => { }) ) - expect(onReplayData).toHaveBeenCalledWith('hello', { carriesNormalBuffer: true }) + expect(onReplayData).toHaveBeenCalledWith('hello', { + carriesNormalBuffer: true, + keepsLocalScrollback: true + }) expect(onConnect).toHaveBeenCalled() expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 })) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts index 58034193be8..ad9ef82a06d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts @@ -249,9 +249,11 @@ describe('createRemoteRuntimePtyTransport', () => { expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) const listCallsAfterBound = hostListCalls - await expect( - transport.sendInputAccepted?.('retry while reconnecting', 'driving') - ).resolves.toBe(false) + // Why: held for terminal-stale; the replacement handle below must never receive it (#10065). + const heldWhileReconnecting = transport.sendInputAccepted?.( + 'retry while reconnecting', + 'driving' + ) await vi.advanceTimersByTimeAsync(16_000) // The accepted-snapshot listener already owns recovery. User input must @@ -291,6 +293,7 @@ describe('createRemoteRuntimePtyTransport', () => { 'remote:env-1@@terminal-after-timeout', 'remote:env-1@@terminal-stale' ) + await expect(heldWhileReconnecting).resolves.toBe(false) expect(onPtyExit).not.toHaveBeenCalled() expect(transport.getPtyId()).toBe('remote:env-1@@terminal-after-timeout') expect(transport.isConnected()).toBe(false) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts index 30c03745abe..f0ead469a68 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts @@ -321,8 +321,8 @@ describe('createRemoteRuntimePtyTransport', () => { }) subscriptionSendBinary.mockClear() - // Why: replacement input stays disabled until terminal.resolvePane proves the new handle belongs to this pane. - expect(transport.sendInput('x', 'driving')).toBe(false) + // Why: replacement input is held for the new handle until terminal.resolvePane proves it belongs to this pane. + expect(transport.sendInput('x', 'driving')).toBe(true) vi.advanceTimersByTime(8) const inputFrames = subscriptionSendBinary.mock.calls diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts new file mode 100644 index 00000000000..578056d7e87 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createRemoteRuntimeTransportMocks, + readyHostSessionInventoryResponse, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' +import type { PtyTransport } from './pty-transport-types' + +let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null +let resolvedPaneHandle = 'terminal-1' + +const { + runtimeCall, + runtimeSubscribe, + latestSubscribePayload, + inputFrameTexts, + subscribeFrameCount, + emitSnapshot, + resetRemoteRuntimeTransport +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) + +const PAIRING_CHANGED = 'Runtime environment pairing changed; refresh and try again' + +type RevisionedRequest = { method?: string; expectedEnvironmentPairingRevision?: number } + +function requestRevision(request: unknown): number | undefined { + return typeof request === 'object' && + request !== null && + 'expectedEnvironmentPairingRevision' in request && + typeof request.expectedEnvironmentPairingRevision === 'number' + ? request.expectedEnvironmentPairingRevision + : undefined +} + +function subscribeRevisions(): (number | undefined)[] { + return runtimeSubscribe.mock.calls.map(([request]) => requestRevision(request)) +} + +/** A managed server's catalog row; its pairing handshake proves `hostKey`. */ +function managedServer(pairingRevision: number, hostKey: string) { + return { + id: 'env-1', + name: 'Box', + createdAt: 1, + updatedAt: pairingRevision, + pairingRevision, + hostKeyFingerprint: hostKey, + lastUsedAt: null, + runtimeId: null, + endpoints: [ + { id: 'ws', kind: 'websocket' as const, label: 'Box', endpoint: 'ws://127.0.0.1:46768/' } + ], + preferredEndpointId: 'ws', + orcadDeployment: { + sshTargetId: 'box', + sshTargetGeneration: 1, + localPort: 46768, + remotePort: 6768 + } + } +} + +/** + * Main's environment record, mirrored into the real catalog store. A managed-server update + * restarts the server and re-pairs it under the same environment id, so main refuses every request + * that still carries the old revision — before it reaches the host, whose terminals are untouched. + */ +async function installRepairableMain(): Promise<{ + repair: (hostKey?: string) => void + publishCatalog: () => void + setReachable: (reachable: boolean) => void + store: Awaited> +}> { + let mainRevision = 1 + let mainHostKey = 'host-A' + let reachable = true + const unreachable = (): Error => + Object.assign(new Error('Could not connect to the remote Orca runtime.'), { + code: 'remote_runtime_unavailable' + }) + const store = await createCatalogStore() + const publishCatalog = (): void => { + store.getState().setRuntimeEnvironments([managedServer(mainRevision, mainHostKey)]) + } + publishCatalog() + const { setRuntimeEnvironmentCatalogRefresher } = + await import('@/runtime/runtime-environment-pairing-refresh') + setRuntimeEnvironmentCatalogRefresher(async () => publishCatalog()) + const isStale = (request: RevisionedRequest): boolean => + request.expectedEnvironmentPairingRevision !== undefined && + request.expectedEnvironmentPairingRevision !== mainRevision + const hostCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (request: RevisionedRequest) => { + if (!reachable) { + throw unreachable() + } + if (isStale(request)) { + return { + ok: false, + error: { code: 'runtime_environment_changed', message: PAIRING_CHANGED } + } + } + if (request.method === 'session.tabs.list') { + return readyHostSessionInventoryResponse('terminal-1', 'host-tab-1') + } + return hostCall?.(request) + }) + const hostSubscribe = runtimeSubscribe.getMockImplementation() + runtimeSubscribe.mockImplementation(async (request: RevisionedRequest, callbacks: unknown) => { + if (!reachable) { + throw unreachable() + } + if (isStale(request)) { + throw new Error( + `Error invoking remote method 'runtimeEnvironments:subscribe': Error: ${PAIRING_CHANGED}` + ) + } + return hostSubscribe?.(request, callbacks) + }) + return { + repair: (hostKey = 'host-A') => { + mainRevision = 2 + mainHostKey = hostKey + }, + publishCatalog, + setReachable: (next) => { + reachable = next + }, + store + } +} + +async function createCatalogStore() { + vi.doMock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() } + })) + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const { resetDeferredPeerChecksForTests } = + await import('@/store/slices/runtime-environment-peer-replacement') + resetDeferredPeerChecksForTests() + return createTestStore() +} + +async function restartServerUnderLivePane(tabId: string): Promise<{ + transport: PtyTransport + onError: ReturnType + repair: (hostKey?: string) => void + publishCatalog: () => void + setReachable: (reachable: boolean) => void +}> { + const { repair, publishCatalog, setReachable } = await installRepairableMain() + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId, + leafId: 'pane:1' + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: { onError } + }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + return { transport, onError, repair, publishCatalog, setReachable } +} + +// A forced managed-server update re-pairs the environment while its terminals keep running. +describe('remote runtime pane across a pairing rotation', () => { + beforeEach(() => { + resetRemoteRuntimeTransport() + }) + + afterEach(async () => { + const { setRuntimeEnvironmentCatalogRefresher } = + await import('@/runtime/runtime-environment-pairing-refresh') + setRuntimeEnvironmentCatalogRefresher(null) + }) + + it('rebinds a pane to its still-live terminal on the new pairing and delivers typed keys', async () => { + const { transport, onError, repair } = await restartServerUnderLivePane('tab-1') + const firstCallbacks = subscriptionCallbacks + + repair() + firstCallbacks?.onClose?.() + expect(transport.sendInput('echo typed-after-update\r', 'driving')).toBe(true) + + await vi.waitFor(() => expect(subscribeRevisions().at(-1)).toBe(2)) + await vi.waitFor(() => expect(subscriptionCallbacks).not.toBe(firstCallbacks)) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') + expect(latestSubscribePayload().terminal).toBe('terminal-1') + await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('echo typed-after-update\r')) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + }) + + it('rebinds a pane whose stream reopened before main re-paired, instead of dropping its keys', async () => { + const { transport, onError, repair, publishCatalog } = await restartServerUnderLivePane('tab-1') + const firstCallbacks = subscriptionCallbacks + + // The server is back and the stream reopened on the old pairing; main re-pairs only afterwards. + repair() + publishCatalog() + + await vi.waitFor(() => expect(subscribeRevisions().at(-1)).toBe(2)) + await vi.waitFor(() => expect(subscriptionCallbacks).not.toBe(firstCallbacks)) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + expect(transport.sendInput('echo typed-after-repair\r', 'driving')).toBe(true) + + await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('echo typed-after-repair\r')) + expect(latestSubscribePayload().terminal).toBe('terminal-1') + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + }) + + it('rebinds a host session pane through its inventory on the new pairing', async () => { + const { transport, onError, repair } = + await restartServerUnderLivePane('web-terminal-host-tab-1') + const firstCallbacks = subscriptionCallbacks + + repair() + firstCallbacks?.onClose?.() + + await vi.waitFor(() => expect(subscribeRevisions().at(-1)).toBe(2), { timeout: 5_000 }) + await vi.waitFor(() => expect(subscriptionCallbacks).not.toBe(firstCallbacks)) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') + expect(latestSubscribePayload().terminal).toBe('terminal-1') + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + }) + + it('Reconnect rebinds a pane whose retries stopped while the server restarted re-paired', async () => { + vi.useFakeTimers() + try { + const { transport, onError, repair, publishCatalog, setReachable } = + await restartServerUnderLivePane('web-terminal-host-tab-1') + setReachable(false) + subscriptionCallbacks?.onClose?.() + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + repair() + setReachable(true) + // Another subscriber's refusal already brought the renderer catalog to the new revision. + publishCatalog() + const subscribesBefore = runtimeSubscribe.mock.calls.length + runtimeCall.mockClear() + expect(transport.retryRecovery?.()).toBe(true) + + await vi.waitFor(() => + expect(runtimeSubscribe.mock.calls.length).toBeGreaterThan(subscribesBefore) + ) + expect(subscribeRevisions().at(-1)).toBe(2) + await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') + for (const [request] of runtimeCall.mock.calls) { + expect(requestRevision(request)).toBe(2) + } + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['a host session pane', 'web-terminal-host-tab-1'], + ['a client-placed pane', 'tab-1'] + ])( + 'sends nothing from %s to a different machine re-paired under the same id', + async (_label, tabId) => { + vi.useFakeTimers() + try { + const { transport, repair, publishCatalog } = await restartServerUnderLivePane(tabId) + const callsBefore = runtimeCall.mock.calls.length + const subscribesBefore = runtimeSubscribe.mock.calls.length + + // A reinstalled or replacement server: same registration, different host key. + repair('host-B') + publishCatalog() + transport.sendInput('typed after the swap\r', 'driving') + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS * 2) + + const reachedNewMachine = [ + ...runtimeCall.mock.calls.slice(callsBefore), + ...runtimeSubscribe.mock.calls.slice(subscribesBefore) + ].filter(([request]) => requestRevision(request) === 2) + expect(reachedNewMachine).toEqual([]) + expect(inputFrameTexts()).toEqual([]) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + } + ) + + it('holds a pane while the re-paired host identity is unresolved, then follows it once proven', async () => { + vi.useFakeTimers() + try { + const { transport, repair, publishCatalog } = await restartServerUnderLivePane('tab-1') + const firstCallbacks = subscriptionCallbacks + repair('') + publishCatalog() + firstCallbacks?.onClose?.() + await vi.advanceTimersByTimeAsync(2_000) + expect(subscribeRevisions().filter((revision) => revision === 2)).toEqual([]) + + repair('host-A') + publishCatalog() + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(subscribeRevisions().at(-1)).toBe(2)) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts new file mode 100644 index 00000000000..64f954ff9dc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createRemoteRuntimeTransportMocks, + readyHostSessionInventoryResponse, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' + +let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null +let resolvedPaneHandle = 'terminal-1' + +const { + runtimeCall, + runtimeSubscribe, + latestSubscribePayload, + inputFrameTexts, + emitSnapshot, + resetRemoteRuntimeTransport +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) + +function unreachable(): Error { + return Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) +} + +// Every exit from binding a remote pane must leave one owner: the host's answer, or the transport's own retry. +describe('remote runtime pane binding ownership', () => { + beforeEach(() => { + resetRemoteRuntimeTransport() + }) + + it('settles an attach whose pane the host no longer knows instead of latching in connecting (#21344)', async () => { + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'terminal.resolvePane') { + return { ok: false, error: { code: 'runtime_error', message: 'terminal_not_found' } } + } + return defaultCall?.(args) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + onPtyExit + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: { onError } + }) + + await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalled()) + // Why -1: the host answered for its registry, but a restarting host can republish later. + expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1', -1) + expect(onError).toHaveBeenCalledWith('Remote terminal was closed.') + expect(transport.getRecoveryState?.().phase).toBe('ended') + expect(transport.ownsRecovery?.()).toBe(false) + expect(transport.sendInput('typed after close', 'driving')).toBe(false) + }) + + it('retries an attach whose pane lookup lost contact instead of latching in connecting', async () => { + vi.useFakeTimers() + try { + let hostReachable = false + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (!hostReachable) { + throw unreachable() + } + return defaultCall?.(args) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: { onError } + }) + await vi.advanceTimersByTimeAsync(0) + + expect(transport.getRecoveryState?.().phase).toBe('backoff') + expect(transport.ownsRecovery?.()).toBe(true) + expect(onError).not.toHaveBeenCalled() + // Typed while the host is unreachable: held for this pane's terminal, not dropped (#20802). + expect(transport.sendInput('echo held', 'driving')).toBe(true) + + hostReachable = true + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.advanceTimersByTimeAsync(20) + + expect(transport.isConnected()).toBe(true) + expect(transport.getRecoveryState?.().phase).toBe('connected') + expect(transport.ownsRecovery?.()).toBe(false) + expect(inputFrameTexts().join('')).toBe('echo held') + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reports owning recovery after a recoverable connect failure so the pane is not remounted (#21195)', async () => { + vi.useFakeTimers() + try { + runtimeCall.mockImplementation(async () => { + throw unreachable() + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + const result = await transport.connect({ + url: '', + sessionId: 'remote:env-1@@', + callbacks: {} + }) + + expect(result).toBeUndefined() + expect(transport.getPtyId()).toBeNull() + expect(transport.ownsRecovery?.()).toBe(true) + transport.destroy?.() + expect(transport.ownsRecovery?.()).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('delivers acknowledged input typed during a same-terminal recovery once it rebinds (#25784)', async () => { + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: { method: string }) => + args.method === 'terminal.send' + ? { ok: true, result: { send: { accepted: true } } } + : defaultCall?.(args) + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + await transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.isConnected()).toBe(true)) + const firstCallbacks = subscriptionCallbacks + + firstCallbacks?.onError?.({ + code: 'remote_runtime_unavailable', + message: 'Remote runtime connection closed.' + }) + expect(transport.isConnected()).toBe(false) + const accepted = transport.sendInputAccepted?.('git status\r', 'driving') + + await vi.waitFor(() => expect(subscriptionCallbacks).not.toBe(firstCallbacks)) + await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + + await expect(accepted).resolves.toBe(true) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.send', + params: expect.objectContaining({ terminal: 'terminal-1', text: 'git status\r' }) + }) + ) + transport.destroy?.() + }) + + it('does not close a pane on an inventory that was already in flight before its leaf published (#20923)', async () => { + let releaseStaleInventory: () => void = () => {} + const staleInventory = new Promise((resolve) => { + releaseStaleInventory = resolve + }) + const siblingOnly = readyHostSessionInventoryResponse('terminal-sibling', 'host-tab-1') + // Why: rewrite the leaf so only a sibling of the requested pane is present. + const staleSnapshot = JSON.parse(JSON.stringify(siblingOnly).replaceAll('pane:1', 'pane:2')) + let listCalls = 0 + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: { method: string; params?: unknown }) => { + if (args.method === 'session.tabs.activate') { + return { ok: true, result: { tabs: [] } } + } + if (args.method === 'session.tabs.list') { + listCalls += 1 + if (listCalls === 1) { + await staleInventory + return staleSnapshot + } + return readyHostSessionInventoryResponse('terminal-1', 'host-tab-1') + } + return defaultCall?.(args) + }) + const { listRemoteRuntimeSessionTabsDeduped } = + await import('@/runtime/remote-runtime-session-tabs-inflight') + // Another pane's inventory request starts before this leaf is published. + const preexisting = listRemoteRuntimeSessionTabsDeduped({ + environmentId: 'env-1', + worktreeId: 'wt-1', + load: async () => { + const response = await runtimeCall({ method: 'session.tabs.list' }) + return { snapshot: response.result } + } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + const connected = transport.connect({ url: '', callbacks: { onError } }) + await new Promise((resolve) => setTimeout(resolve, 400)) + releaseStaleInventory() + await preexisting + + await expect(connected).resolves.toMatchObject({ id: 'remote:env-1@@terminal-1' }) + expect(onError).not.toHaveBeenCalledWith('Remote terminal was closed.') + expect(listCalls).toBeGreaterThanOrEqual(2) + transport.destroy?.() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts index a947456c0e6..ce3f4445f32 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts @@ -303,6 +303,31 @@ describe('createRemoteRuntimePtyTransport', () => { } }) + it('ignores a hold whose grid the host does not know yet instead of shrinking the pane', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { getFitOverrideForPty } = await import('@/lib/pane-manager/mobile-fit-overrides') + const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' }) + await transport.connect({ url: '', cols: 120, rows: 40, callbacks: {} }) + const { streamId } = latestSubscribePayload() + const ptyId = transport.getPtyId() + expect(ptyId).not.toBeNull() + + // A just-restarted server answers before it has learned the adopted terminal's size. + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'fit-override-changed', + streamId, + mode: 'remote-desktop-fit', + cols: 0, + rows: 0 + } + }) + + expect(ptyId ? getFitOverrideForPty(ptyId) : null).toBeNull() + transport.destroy?.() + }) + it('gives separate paired viewers of the same host pane distinct refresh identities', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const first = createRemoteRuntimePtyTransport('env-1', { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts index 2ee83ace72b..65c0d38eb47 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts @@ -60,7 +60,8 @@ describe('createRemoteRuntimePtyTransport', () => { ) expect(onReplayData).toHaveBeenCalledWith('beforeafter\x1b]0;Remote title\x07\x07', { - carriesNormalBuffer: true + carriesNormalBuffer: true, + keepsLocalScrollback: true }) await vi.waitFor(() => expect(onTitleChange).toHaveBeenCalledWith('Remote title', 'Remote title') @@ -91,7 +92,10 @@ describe('createRemoteRuntimePtyTransport', () => { 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after' ) - expect(onReplayData).toHaveBeenCalledWith('beforeafter', { carriesNormalBuffer: true }) + expect(onReplayData).toHaveBeenCalledWith('beforeafter', { + carriesNormalBuffer: true, + keepsLocalScrollback: true + }) expect(onAgentStatus).not.toHaveBeenCalled() expect(onBell).not.toHaveBeenCalled() expect(onConnect).toHaveBeenCalled() @@ -183,7 +187,10 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) const { streamId } = latestSubscribePayload() emitSnapshot(streamId, 'initial') - expect(onReplayData).toHaveBeenCalledWith('initial', { carriesNormalBuffer: true }) + expect(onReplayData).toHaveBeenCalledWith('initial', { + carriesNormalBuffer: true, + keepsLocalScrollback: true + }) expect(onConnect).toHaveBeenCalled() const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 }) @@ -281,7 +288,10 @@ describe('createRemoteRuntimePtyTransport', () => { expect(latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)).toBeUndefined() emitSnapshot(streamId, 'initial replay') - expect(onReplayData).toHaveBeenCalledWith('initial replay', { carriesNormalBuffer: true }) + expect(onReplayData).toHaveBeenCalledWith('initial replay', { + carriesNormalBuffer: true, + keepsLocalScrollback: true + }) expect(onConnect).toHaveBeenCalled() await vi.waitFor(() => diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-fixtures.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-fixtures.ts index 978d0428060..a0635501676 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-fixtures.ts @@ -3,6 +3,7 @@ import { TerminalStreamOpcode, decodeTerminalStreamFrame, decodeTerminalStreamJson, + decodeTerminalStreamText, encodeTerminalStreamFrame, encodeTerminalStreamJson, encodeTerminalStreamText @@ -106,6 +107,21 @@ export function createTerminalStreamFixtures(bindings: { ) } + function inputFrameTexts(): string[] { + return bindings.sendBinary.mock.calls.flatMap(([bytes]) => { + const frame = decodeTerminalStreamFrame(bytes) + return frame?.opcode === TerminalStreamOpcode.Input + ? [decodeTerminalStreamText(frame.payload)] + : [] + }) + } + + function subscribeFrameCount(): number { + return bindings.sendBinary.mock.calls.filter( + ([bytes]) => decodeTerminalStreamFrame(bytes)?.opcode === TerminalStreamOpcode.Subscribe + ).length + } + function latestFrameForOpcode(opcode: TerminalStreamOpcode) { return bindings.sendBinary.mock.calls .map((call) => decodeTerminalStreamFrame(call[0])) @@ -137,6 +153,8 @@ export function createTerminalStreamFixtures(bindings: { emitOutput, emitSnapshot, latestFrameForOpcode, + inputFrameTexts, + subscribeFrameCount, emitSnapshotFrame } } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts index 1e68f55e7e5..59b28f07e32 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts @@ -22,6 +22,8 @@ const { emitOutput, emitSnapshot, latestFrameForOpcode, + inputFrameTexts, + subscribeFrameCount, resetRemoteRuntimeTransport } = createRemoteRuntimeTransportMocks({ getCallbacks: () => subscriptionCallbacks, @@ -205,13 +207,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) - await vi.waitFor(() => - expect( - subscriptionSendBinary.mock.calls - .map((call) => decodeTerminalStreamFrame(call[0])) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) - ).toHaveLength(2) - ) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) const reconnectStreamId = latestSubscribePayload().streamId emitSnapshot(reconnectStreamId, 'RECONNECT_SNAPSHOT') subscriptionCallbacks?.onResponse({ @@ -275,13 +271,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) - await vi.waitFor(() => - expect( - subscriptionSendBinary.mock.calls - .map((call) => decodeTerminalStreamFrame(call[0])) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) - ).toHaveLength(2) - ) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) const reconnectStreamId = latestSubscribePayload().streamId // An exited-but-preserved pane has nothing to push and will never emit live bytes, // so without the re-arm the pane stays blank until a visibility flip. @@ -300,13 +290,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(3)) - await vi.waitFor(() => - expect( - subscriptionSendBinary.mock.calls - .map((call) => decodeTerminalStreamFrame(call[0])) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) - ).toHaveLength(3) - ) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(3)) const populatedReconnectStreamId = latestSubscribePayload().streamId emitSnapshot(populatedReconnectStreamId, 'RECOVERY_SNAPSHOT') subscriptionCallbacks?.onResponse({ @@ -434,7 +418,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(unsubscribe).toHaveBeenCalledTimes(1) }) - it('recovers repeated partitions without changing PTY identity or accepting detached input', async () => { + it('recovers repeated partitions without changing PTY identity and delivers detached input once', async () => { const callbacksByEpoch: NonNullable[] = [] const unsubscribeByEpoch: ReturnType[] = [] runtimeSubscribe.mockImplementation( @@ -466,7 +450,8 @@ describe('createRemoteRuntimePtyTransport', () => { }) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput(`detached-${cycle}`, 'driving')).toBe(false) + // Why: keys typed while the same PTY recovers are held and delivered once it rebinds (#25784). + expect(transport.sendInput(`detached-${cycle}`, 'driving')).toBe(true) expect(unsubscribeByEpoch[cycle]).toHaveBeenCalledTimes(1) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(cycle + 2)) await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) @@ -478,6 +463,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(transport.sendInputImmediate(`input-${cycle}`)).toBe(true) expect(transport.getPtyId()).toBe(ptyId) + expect(inputFrameTexts().filter((text) => text.includes(`detached-${cycle}`))).toHaveLength(1) expect(onData).toHaveBeenCalledWith(`output-${cycle}`, expect.any(Object)) expect( decodeTerminalStreamText( @@ -551,12 +537,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(transport.retryRecovery?.()).toBe(true) expect(transport.retryRecovery?.()).toBe(false) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(callsAtCutoff + 1)) - await vi.waitFor(() => { - const subscribeFrames = subscriptionSendBinary.mock.calls - .map((call) => decodeTerminalStreamFrame(call[0])) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) - expect(subscribeFrames).toHaveLength(2) - }) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) const manualStream = latestSubscribePayload() expect(manualStream.terminal).toBe('terminal-1') emitSnapshot(manualStream.streamId, 'after manual reconnect') diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index ec4c4bcfe1c..e91e3579074 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,5 +1,10 @@ import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' import { withRemoteReattachInputBuffer } from './remote-reattach-input-buffer' +import { + createRemoteRuntimeRecoveryInputHold, + type RemoteRuntimeInputEndpoint +} from './remote-runtime-recovery-input-hold' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' /* eslint-disable max-lines -- Why: remote PTY transport keeps lifecycle, JSON fallback, and binary stream wiring together so reconnect/destroy ordering stays testable as one behavior surface. */ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' import { @@ -68,7 +73,7 @@ import { import { replaceFitOverridePtyId, setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides' import { replaceDriverPtyId, setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '@/runtime/web-terminal-surface-id' -import { listRemoteRuntimeSessionTabsDeduped } from '@/runtime/remote-runtime-session-tabs-inflight' +import { listRemoteRuntimeSessionTabsAfterCurrentInFlight } from '@/runtime/remote-runtime-session-tabs-inflight' import { subscribeAcceptedWebSessionTerminalHandle } from '@/runtime/web-session-terminal-handle-events' import { hostSnapshotAffirmsWorktreeContents } from '@/runtime/host-session-snapshot-authority' import { runRemoteAgentSessionLaunch } from '@/runtime/remote-agent-session-launch' @@ -84,7 +89,15 @@ import { ptyReplayHandlers, ptyShutdownLifecycleHandlers } from './pty-shutdown-data-suspension' -import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { + getRuntimeEnvironmentRevision, + resolveContinuedRuntimeEnvironmentRevision +} from '@/runtime/runtime-environment-revision' +import { + isRuntimeEnvironmentPairingChangedError, + refreshRuntimeEnvironmentsAfterPairingChange, + runtimeEnvironmentPairingChangedError +} from '@/runtime/runtime-environment-pairing-refresh' const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8 const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 @@ -103,6 +116,11 @@ const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_00 type HostHandleReplacementPolicy = 'reuse' | 'prefer-replacement' | 'require-replacement' +type PersistedHostPaneResolution = + | { kind: 'resolved'; terminal: RuntimeTerminalResolvePane } + | { kind: 'absent' } + | { kind: 'unavailable' } + type HostSessionHandleWaitResult = { handle: string | null | undefined inventoryFailed: boolean @@ -129,6 +147,25 @@ function isRemoteTerminalStaleMessage(message: string): boolean { return message.includes('terminal_handle_stale') } +// Why: lost contact, host back-pressure, or a re-minted handle all leave the pane resolvable later. +function isRetryableUnboundAttachError(error: unknown): boolean { + const clientError = toRemoteRuntimeClientErrorLike(error) + return ( + isRecoverableRemoteRuntimeConnectionError(clientError) || + isRuntimeRpcQueueOverloadError(clientError) || + isRemoteTerminalStaleMessage(runtimeTerminalErrorMessage(error)) + ) +} + +// Why: main refuses a stale pairing before dispatch (a managed server update re-pairs it), so the +// terminal is untouched and only the binding must retry on the re-read pairing. +function isRecoverablePaneBindingError(error: unknown): boolean { + return ( + isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) || + isRuntimeEnvironmentPairingChangedError(error) + ) +} + function isRemoteTerminalGoneMessage(message: string): boolean { return ( message.includes('terminal_exited') || @@ -186,8 +223,18 @@ export function createRemoteRuntimePtyTransport( let authoritativeExecutionHostId: ExecutionHostId | null = executionHostId ?? null let authoritativeHostPlatform: NodeJS.Platform | null = null let authoritativePtyIncarnationId: string | null = null - let currentRuntimeEnvironmentId = runtimeEnvironmentId - const runtimeEnvironmentPairingRevision = getRuntimeEnvironmentRevision(runtimeEnvironmentId) + // Why: never adopts a persisted existingPtyId's environment; the worktree owner chose this transport. + const currentRuntimeEnvironmentId = runtimeEnvironmentId + // Why: the pane belongs to the machine it was opened on; it may follow that machine's own + // re-pair (a server update) but never a same-id re-pair to another or unverified machine. + let paneRevision = resolveContinuedRuntimeEnvironmentRevision( + runtimeEnvironmentId, + getRuntimeEnvironmentRevision(runtimeEnvironmentId) + ) + function currentPaneRevision(): number | undefined { + paneRevision = resolveContinuedRuntimeEnvironmentRevision(runtimeEnvironmentId, paneRevision) + return paneRevision + } let multiplexedStream: RemoteRuntimeMultiplexedTerminal | null = null let multiplexedStreamHandle: string | null = null let desiredOutputPaused = false @@ -251,10 +298,15 @@ export function createRemoteRuntimePtyTransport( }) } + const recoveryInputHold = createRemoteRuntimeRecoveryInputHold() const recovery = new RemoteRuntimePtyRecoveryState(() => { if (recovery.currentPhase === 'disposed') { clearPublishedHandleWait() } + if (recovery.currentPhase === 'disconnected' || recovery.currentPhase === 'disposed') { + // Why: once auto-recovery gives up, held keys would land at an arbitrary later reconnect. + recoveryInputHold.discard() + } if (recovery.currentPhase === 'disconnected') { // Why: only the wall-clock deadline is evidence the window was spent; a UI latch from a fatal // resubscribe must not license reattaching a fenced same handle (#12683). @@ -652,10 +704,11 @@ export function createRemoteRuntimePtyTransport( const request = nextRequest let snapshot: RuntimeMobileSessionTabsResult try { + // Why: a joined pre-publication inventory cannot prove this leaf is gone (#20923). snapshot = request === 'list' ? ( - await listRemoteRuntimeSessionTabsDeduped({ + await listRemoteRuntimeSessionTabsAfterCurrentInFlight({ environmentId: currentRuntimeEnvironmentId, worktreeId, load: async () => ({ @@ -753,10 +806,7 @@ export function createRemoteRuntimePtyTransport( } return hostHandle } catch (error) { - if ( - !isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) || - !isCurrent() - ) { + if (!isRecoverablePaneBindingError(error) || !isCurrent()) { throw error } if (recoveryEpoch !== undefined && !recovery.isCurrent(recoveryEpoch)) { @@ -816,10 +866,11 @@ export function createRemoteRuntimePtyTransport( } const request = nextRequest try { + // Why: a joined pre-publication inventory cannot prove this leaf is gone (#20923). const listed = request === 'list' ? ( - await listRemoteRuntimeSessionTabsDeduped({ + await listRemoteRuntimeSessionTabsAfterCurrentInFlight({ environmentId: currentRuntimeEnvironmentId, worktreeId, load: async () => ({ @@ -887,7 +938,7 @@ export function createRemoteRuntimePtyTransport( return false } if (lastAttachOptions) { - transport.attach(lastAttachOptions) + attachPane(lastAttachOptions, true) return true } if (lastConnectOptions) { @@ -1039,9 +1090,15 @@ export function createRemoteRuntimePtyTransport( method, params, timeoutMs, - expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision + expectedEnvironmentPairingRevision: currentPaneRevision() }) - return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + try { + return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + } catch (error) { + // Why await: the caller's retry must read the new revision, not race the catalog re-read. + await refreshRuntimeEnvironmentsAfterPairingChange(error) + throw error + } } async function callRuntime( @@ -1214,13 +1271,13 @@ export function createRemoteRuntimePtyTransport( return null } - async function resolvePersistedHostPane(): Promise { + async function resolvePersistedHostPane(): Promise { if (!tabId || !leafId || !worktreeId) { - return null + return { kind: 'unavailable' } } const paneKey = `${tabId}:${leafId}` if (resolvePaneUnavailable) { - return null + return { kind: 'unavailable' } } let terminal: RuntimeTerminalResolvePane try { @@ -1233,10 +1290,14 @@ export function createRemoteRuntimePtyTransport( const message = runtimeTerminalErrorMessage(error) if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { resolvePaneUnavailable = true - return null + return { kind: 'unavailable' } } - if (message.includes('terminal_not_found') || message.includes('method_not_found')) { - return null + if (message.includes('terminal_not_found')) { + // Why: the host's own pane registry answered, so this is removal evidence, not lost contact. + return { kind: 'absent' } + } + if (message.includes('method_not_found')) { + return { kind: 'unavailable' } } throw error } @@ -1249,7 +1310,8 @@ export function createRemoteRuntimePtyTransport( } if (terminal.worktreeId === undefined) { const worktree = toRuntimeWorktreeSelector(worktreeId) - const { snapshot: listed } = await listRemoteRuntimeSessionTabsDeduped({ + // Why: an inventory that started before resolvePane answered can predate this handle. + const { snapshot: listed } = await listRemoteRuntimeSessionTabsAfterCurrentInFlight({ environmentId: currentRuntimeEnvironmentId, worktreeId, load: async () => ({ @@ -1266,7 +1328,7 @@ export function createRemoteRuntimePtyTransport( throw new Error('terminal_owner_mismatch') } } - return terminal + return { kind: 'resolved', terminal } } async function adoptResolvedHostPane( @@ -1393,6 +1455,58 @@ export function createRemoteRuntimePtyTransport( return recovery.isActive || recovery.currentPhase === 'disconnected' } + // Why: a pane binding or auto-recovering a known handle holds typing for it instead of dropping it (#25784). + function shouldHoldInput(): boolean { + return ( + !destroyed && + !terminalEnded && + handle !== null && + recovery.currentPhase !== 'disconnected' && + (recovery.isActive || (connecting && !connected) || recoveryInputHold.isHolding()) + ) + } + + function heldInputEndpoint(targetHandle: string) { + return { + handle: targetHandle, + incarnationId: authoritativePtyIncarnationId + } + } + + // Why: releases a stale hold first, so the hold check reads the handle that release left bound. + function releaseThenHoldEndpoint( + inputKind: TerminalInputKind + ): RemoteRuntimeInputEndpoint | null { + if (recoveryInputHold.isHolding()) { + releaseHeldInput() + } + return handle && inputKind !== 'query-reply' && shouldHoldInput() + ? heldInputEndpoint(handle) + : null + } + + function releaseHeldInput(): void { + const boundHandle = handle + if (destroyed || terminalEnded || !boundHandle) { + recoveryInputHold.discard() + return + } + if ( + !connected || + recoveryBlocksIo() || + !attachmentReady || + !getCurrentMultiplexedStream(boundHandle) + ) { + return + } + recoveryInputHold.release(heldInputEndpoint(boundHandle), { + isCurrent: () => connected && handle === boundHandle && !recoveryBlocksIo(), + sendInput: (data) => sendInputNow(data), + sendInputImmediate: (data) => sendInputImmediateNow(data), + sendInputAccepted: sendInputAcceptedToRuntime + }) + } + async function sendInputAcceptedToRuntime(data: string): Promise { const targetHandle = handle if (!connected || !targetHandle || recoveryBlocksIo()) { @@ -1566,6 +1680,7 @@ export function createRemoteRuntimePtyTransport( } function retireRemoteTerminalId(exitCode?: number): void { + recoveryInputHold.discard() recovery.cancel() resetRecoveryReplacementPolicy() resetSameHandleEndReuse() @@ -1595,6 +1710,8 @@ export function createRemoteRuntimePtyTransport( nextIncarnationId: string | null = null ): void { clearPublishedHandleWait() + // Why: keys typed at the replaced shell must not run in its successor (#10065). + recoveryInputHold.discard() const replacedPtyId = remotePtyId unregisterShutdownHandlers(replacedPtyId) handle = nextHandle @@ -1722,12 +1839,13 @@ export function createRemoteRuntimePtyTransport( scheduleCapacityPressureRetry() return } - if (isRecoverableRemoteRuntimeConnectionError(clientError)) { + if (isRecoverablePaneBindingError(error)) { // Why: a partition is attachment state, not a terminal failure; keep the red error surface for actionable fatal errors. scheduleResubscribeAfterTransportClose() return } connecting = false + recoveryInputHold.discard() emitRecoveryState() surfaceErrorMessage(message) } @@ -1744,7 +1862,7 @@ export function createRemoteRuntimePtyTransport( closeMultiplexedStream() } clearPendingViewportClaim() - if (!isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))) { + if (!isRecoverablePaneBindingError(error)) { return false } if (recovery.currentPhase === 'disconnected') { @@ -1828,7 +1946,8 @@ export function createRemoteRuntimePtyTransport( ) return } else if (tabId && leafId && worktreeId) { - const resolved = await resolvePersistedHostPane() + const resolution = await resolvePersistedHostPane() + const resolved = resolution.kind === 'resolved' ? resolution.terminal : null if (destroyed || !connected || handle !== previousHandle) { return } @@ -1914,8 +2033,7 @@ export function createRemoteRuntimePtyTransport( .catch((error) => { if (!destroyed && connected && handle && recovery.isCurrent(recoveryEpoch)) { clearPendingViewportClaim() - const clientError = toRemoteRuntimeClientErrorLike(error) - if (isRecoverableRemoteRuntimeConnectionError(clientError)) { + if (isRecoverablePaneBindingError(error)) { retryScheduled = recovery.schedule(recoveryEpoch, (nextEpoch) => { const currentReplacementPolicy = handle ? getRecoveryReplacementPolicy(handle) @@ -1989,6 +2107,10 @@ export function createRemoteRuntimePtyTransport( generation === subscriptionGeneration && (expectedRecoveryEpoch === undefined || recovery.ownsEpoch(expectedRecoveryEpoch)) && isCurrentRemoteTerminal(subscribedHandle, subscribedPtyId) + if (currentPaneRevision() !== getRuntimeEnvironmentRevision(currentRuntimeEnvironmentId)) { + // Why local: the shared stream would subscribe on a pairing not proven to be this pane's machine. + throw runtimeEnvironmentPairingChangedError() + } const nextStream = await getRemoteRuntimeTerminalMultiplexer( currentRuntimeEnvironmentId ).subscribeTerminal({ @@ -2037,6 +2159,7 @@ export function createRemoteRuntimePtyTransport( ...(meta?.cols !== undefined && meta.rows !== undefined ? { snapshotCols: meta.cols, snapshotRows: meta.rows } : {}), + ...(meta?.keepsLocalScrollback ? { keepsLocalScrollback: true } : {}), carriesNormalBuffer: true }) } @@ -2074,6 +2197,7 @@ export function createRemoteRuntimePtyTransport( storedCallbacks.onStreamRecovered?.() } storedCallbacks.onStatus?.('shell') + releaseHeldInput() }, onEnd: (verdict) => { if (!isCurrentSubscription()) { @@ -2092,6 +2216,7 @@ export function createRemoteRuntimePtyTransport( return } unregisterShutdownHandlers(subscribedPtyId) + recoveryInputHold.discard() connected = false connecting = false handle = null @@ -2151,6 +2276,7 @@ export function createRemoteRuntimePtyTransport( } } else { connecting = false + recoveryInputHold.discard() recovery.cancel() setAttachmentUnavailable() emitRecoveryState() @@ -2190,6 +2316,156 @@ export function createRemoteRuntimePtyTransport( } // Why: a live claim may already have cleared the flag, so drain on every install. flushPendingClaimInput(nextStream) + releaseHeldInput() + } + + // Why replay: a recovery retry re-enters attach inside its own epoch; cancelling it would restart + // the backoff ladder forever and the auto-recovery deadline could never latch (#18495). + function attachPane(options: Parameters[0], replay: boolean): void { + const attachLifecycleEpoch = ++lifecycleEpoch + const generation = ++attachGeneration + cancelTerminalCreateRetryWait() + if (!replay) { + recovery.cancel() + } + resetRecoveryReplacementPolicy() + resetSameHandleEndReuse() + clearPublishedHandleWait() + lastAttachOptions = options + storedCallbacks = options.callbacks + terminalEnded = false + connecting = true + emitRecoveryState(true) + const previousHandle = handle + const previousPtyId = remotePtyId + const nextHandle = getRemoteRuntimeTerminalHandle(options.existingPtyId) + if (previousHandle && previousHandle !== nextHandle) { + // Why: debounced input is scoped by the current terminal handle at flush time. + inputBatcher.clear() + recoveryInputHold.discard() + } + const persistedEnvironmentId = getRemoteRuntimePtyEnvironmentId(options.existingPtyId) + handle = nextHandle + unregisterShutdownHandlers(previousPtyId) + connected = false + remotePtyId = null + clearPendingViewportClaim() + closeMultiplexedStream() + if (!nextHandle) { + // Why: an empty parsed handle ('remote:') is falsy but not null; shouldHoldInput keys on null. + handle = null + connecting = false + emitRecoveryState() + surfaceErrorMessage('Remote runtime terminal id is invalid.') + return + } + const persistedHandle = nextHandle + void (async () => { + const adoptPersistedHandle = () => + adoptResolvedHostPane( + { + handle: persistedHandle, + tabId: tabId ?? '', + leafId: leafId ?? '', + ptyId: null, + worktreeId + }, + options, + false, + generation + ) + if (isWebTerminalSurfaceTabId(tabId ?? '')) { + await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch) + return + } + if (!tabId || !leafId || !worktreeId) { + await adoptPersistedHandle() + return + } + const resolution = await resolvePersistedHostPane() + if (generation !== attachGeneration || destroyed) { + return + } + if (resolution.kind === 'resolved') { + await adoptResolvedHostPane(resolution.terminal, options, false, generation) + return + } + if (resolvePaneUnavailable && persistedEnvironmentId === currentRuntimeEnvironmentId) { + await adoptPersistedHandle() + return + } + surfaceErrorMessage('Remote terminal was closed.') + if (resolution.kind === 'absent') { + // Why: the host answered, but its pane registry may still be rebuilding after a restart, + // so retire with the host-loss sentinel instead of closing the tab outright (#21344). + remotePtyId = options.existingPtyId + retireRemoteTerminalId(-1) + return + } + // Why: a host that cannot resolve this pane gives no later answer to wait for; settle offline. + connecting = false + emitRecoveryState() + })().catch((error) => { + if (generation !== attachGeneration || attachLifecycleEpoch !== lifecycleEpoch || destroyed) { + return + } + clearPendingViewportClaim() + if (!connected && isRetryableUnboundAttachError(error)) { + // Why: no handle is bound yet, so the resubscribe path cannot own this retry; replay attach + // instead of latching in 'connecting' with nothing armed (#18495). + connecting = false + scheduleConnectRetryAfterRecoverableFailure() + return + } + recovery.cancel() + handleRemoteTerminalError(error) + }) + } + + function sendInputNow(data: string): boolean { + if (!connected || !handle || recoveryBlocksIo()) { + return false + } + if (!data) { + return true + } + // Why: literal LF bytes from paste/programmatic input must survive; callers use \r or the enter flag for semantic Enter. + return inputBatcher.push(data) + } + + function sendInputImmediateNow(data: string): boolean { + const targetHandle = handle + const targetLifecycleEpoch = lifecycleEpoch + if (!connected || !targetHandle || recoveryBlocksIo()) { + return false + } + if (!data) { + return true + } + // Why: wait behind async validation, but keep the reply as its own host-classifiable write. + if (inputBatcher.hasPendingValidation()) { + inputBatcher.enqueueAfterValidation(() => { + if ( + !connected || + lifecycleEpoch !== targetLifecycleEpoch || + handle !== targetHandle || + recoveryBlocksIo() + ) { + return + } + const pending = inputBatcher.takePending() + if (pending) { + sendUnacknowledgedInput(pending) + } + sendUnacknowledgedInput(data, true) + }) + return true + } + const pending = inputBatcher.takePending() + if (pending && !sendUnacknowledgedInput(pending)) { + return false + } + return sendUnacknowledgedInput(data, true) } let connectForRecovery: PtyTransport['connect'] = (options) => transport.connect(options) @@ -2224,9 +2500,9 @@ export function createRemoteRuntimePtyTransport( if (options.sessionId && !getRemoteRuntimeTerminalHandle(options.sessionId)) { // Why: a HUB session persists host-native PTY ids; resolve its pane handle without exposing that SSH identity as a client transport id. - const terminal = await resolvePersistedHostPane() - if (terminal) { - return await adoptResolvedHostPane(terminal, options) + const resolution = await resolvePersistedHostPane() + if (resolution.kind === 'resolved') { + return await adoptResolvedHostPane(resolution.terminal, options) } } @@ -2369,7 +2645,7 @@ export function createRemoteRuntimePtyTransport( }) // Snapshot parity must not delay attachment to a terminal the host already created. void refreshWebRuntimeSessionTabsSnapshot(createEnvironmentId, worktreeId, { - expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision, + expectedEnvironmentPairingRevision: currentPaneRevision(), acceptCurrentSnapshot: true, confirmAgentSessionHandoff: { provisionalTabId: tabId, @@ -2431,9 +2707,7 @@ export function createRemoteRuntimePtyTransport( if (isRemoteTerminalGoneMessage(message)) { recovery.cancel() handleRemoteTerminalError(error) - } else if ( - isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) - ) { + } else if (isRecoverablePaneBindingError(error)) { scheduleConnectRetryAfterRecoverableFailure() } else { recovery.cancel() @@ -2446,107 +2720,13 @@ export function createRemoteRuntimePtyTransport( }, attach(options) { - const attachLifecycleEpoch = ++lifecycleEpoch - const generation = ++attachGeneration - cancelTerminalCreateRetryWait() - recovery.cancel() - resetRecoveryReplacementPolicy() - resetSameHandleEndReuse() - clearPublishedHandleWait() - lastAttachOptions = options - storedCallbacks = options.callbacks - terminalEnded = false - connecting = true - emitRecoveryState(true) - // Why: persisted ids are untrusted cache state; the worktree owner selected this transport and must remain authoritative. - currentRuntimeEnvironmentId = runtimeEnvironmentId - const previousHandle = handle - const previousPtyId = remotePtyId - const nextHandle = getRemoteRuntimeTerminalHandle(options.existingPtyId) - if (previousHandle && previousHandle !== nextHandle) { - // Why: debounced input is scoped by the current terminal handle at flush time. - inputBatcher.clear() - } - const persistedEnvironmentId = getRemoteRuntimePtyEnvironmentId(options.existingPtyId) - handle = nextHandle - unregisterShutdownHandlers(previousPtyId) - connected = false - remotePtyId = null - clearPendingViewportClaim() - closeMultiplexedStream() - if (!nextHandle) { - handle = null - connecting = false - emitRecoveryState() - surfaceErrorMessage('Remote runtime terminal id is invalid.') - return - } - const persistedHandle = nextHandle - void (async () => { - if (isWebTerminalSurfaceTabId(tabId ?? '')) { - await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch) - return - } - if (!tabId || !leafId || !worktreeId) { - await adoptResolvedHostPane( - { - handle: persistedHandle, - tabId: tabId ?? '', - leafId: leafId ?? '', - ptyId: null, - worktreeId - }, - options, - false, - generation - ) - return - } - const resolved = await resolvePersistedHostPane() - if (generation !== attachGeneration || destroyed) { - return - } - if ( - !resolved && - resolvePaneUnavailable && - persistedEnvironmentId === currentRuntimeEnvironmentId - ) { - await adoptResolvedHostPane( - { - handle: persistedHandle, - tabId: tabId ?? '', - leafId: leafId ?? '', - ptyId: null, - worktreeId - }, - options, - false, - generation - ) - return - } - if (!resolved) { - surfaceErrorMessage('Remote terminal was closed.') - return - } - await adoptResolvedHostPane(resolved, options, false, generation) - })().catch((error) => { - if ( - generation !== attachGeneration || - attachLifecycleEpoch !== lifecycleEpoch || - destroyed - ) { - return - } - clearPendingViewportClaim() - recovery.cancel() - handleRemoteTerminalError(error) - }) + attachPane(options, false) }, disconnect() { lifecycleEpoch += 1 attachGeneration += 1 + recoveryInputHold.discard() cancelTerminalCreateRetryWait() recovery.cancel() resetRecoveryReplacementPolicy() @@ -2584,6 +2764,7 @@ export function createRemoteRuntimePtyTransport( outputProcessor.disposePendingSideEffectGauge() lifecycleEpoch += 1 attachGeneration += 1 + recoveryInputHold.discard() cancelTerminalCreateRetryWait() recovery.cancel() resetRecoveryReplacementPolicy() @@ -2603,56 +2784,30 @@ export function createRemoteRuntimePtyTransport( storedCallbacks = {} }, - // Why no kind: terminal.send has no launch kind, and its query-reply kind is for mobile - // clients, so the host classifies a desktop's bytes itself. - sendInput(data: string): boolean { - if (!connected || !handle || recoveryBlocksIo()) { - return false + // Why: the kind only gates the local hold; terminal.send gets no kind, so the host classifies desktop bytes itself. + sendInput(data, inputKind): boolean { + const held = releaseThenHoldEndpoint(inputKind) + if (held) { + return !data || recoveryInputHold.enqueue(held, data, inputKind) } - if (!data) { - return true - } - // Why: literal LF bytes from paste/programmatic input must survive; callers use \r or the enter flag for semantic Enter. - return inputBatcher.push(data) + return sendInputNow(data) }, // Why: query replies (CPR/DSR/DA/OSC) are read in raw mode with a short timeout; the 8ms debounce would miss it and echo the reply onto the prompt (#7329). - sendInputImmediate(data: string): boolean { - const targetHandle = handle - const targetLifecycleEpoch = lifecycleEpoch - if (!connected || !targetHandle || recoveryBlocksIo()) { - return false + sendInputImmediate: (data: string): boolean => sendInputImmediateNow(data), + + sendInputAccepted(data, inputKind) { + const held = releaseThenHoldEndpoint(inputKind) + if (held && data) { + return recoveryInputHold.enqueueAccepted(held, data, inputKind) } - if (!data) { - return true - } - // Why: wait behind async validation, but keep the reply as its own host-classifiable write. - if (inputBatcher.hasPendingValidation()) { - inputBatcher.enqueueAfterValidation(() => { - if ( - !connected || - lifecycleEpoch !== targetLifecycleEpoch || - handle !== targetHandle || - recoveryBlocksIo() - ) { - return - } - const pending = inputBatcher.takePending() - if (pending) { - sendUnacknowledgedInput(pending) - } - sendUnacknowledgedInput(data, true) - }) - return true - } - const pending = inputBatcher.takePending() - if (pending && !sendUnacknowledgedInput(pending)) { - return false - } - return sendUnacknowledgedInput(data, true) + return sendInputAcceptedToRuntime(data) }, - sendInputAccepted: sendInputAcceptedToRuntime, + // Why: a transport with an armed retry or a parked one owns this pane's recovery; a remount would race it (#21195). + ownsRecovery() { + return !destroyed && !terminalEnded && recoveryBlocksIo() + }, claimViewport(cols: number, rows: number): boolean { if (!connected || !handle) { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts new file mode 100644 index 00000000000..4c24bfab1be --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRemoteRuntimeRecoveryInputHold } from './remote-runtime-recovery-input-hold' + +function createWriter() { + const sent: string[] = [] + return { + sent, + writer: { + isCurrent: () => true, + sendInput: vi.fn((data: string) => { + sent.push(data) + return true + }), + sendInputImmediate: vi.fn(() => true), + sendInputAccepted: vi.fn(async (data: string) => { + sent.push(data) + return true + }) + } + } +} + +const endpoint = { handle: 'terminal-1', incarnationId: 'inc-1' } + +describe('remote runtime recovery input hold', () => { + it('delivers held input in order to the terminal it was typed into', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const { sent, writer } = createWriter() + expect(hold.enqueue(endpoint, 'ls', 'driving')).toBe(true) + const accepted = hold.enqueueAccepted(endpoint, '\r', 'driving') + + hold.release(endpoint, writer) + + await expect(accepted).resolves.toBe(true) + expect(sent).toEqual(['ls', '\r']) + await vi.waitFor(() => expect(hold.isHolding()).toBe(false)) + }) + + it('drops held input when the pane rebinds to a different terminal', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const { sent, writer } = createWriter() + const accepted = hold.enqueueAccepted(endpoint, 'rm -rf build\r', 'driving') + + hold.release({ handle: 'terminal-2', incarnationId: null }, writer) + + await expect(accepted).resolves.toBe(false) + expect(sent).toEqual([]) + expect(hold.isHolding()).toBe(false) + }) + + it('drops held input when the same handle reports a new incarnation', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const { sent, writer } = createWriter() + hold.enqueue(endpoint, 'make\r', 'driving') + + hold.release({ handle: 'terminal-1', incarnationId: 'inc-2' }, writer) + + expect(sent).toEqual([]) + }) + + it('accepts new input right after a release drained the previous hold', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const { sent, writer } = createWriter() + hold.enqueue(endpoint, 'a', 'driving') + hold.release(endpoint, writer) + await vi.waitFor(() => expect(sent).toEqual(['a'])) + + expect(hold.enqueue(endpoint, 'b', 'driving')).toBe(true) + hold.release(endpoint, writer) + await vi.waitFor(() => expect(sent).toEqual(['a', 'b'])) + }) + + it('resolves held acknowledged input as undelivered on discard', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const accepted = hold.enqueueAccepted(endpoint, 'x', 'driving') + + hold.discard() + + await expect(accepted).resolves.toBe(false) + expect(hold.isHolding()).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts new file mode 100644 index 00000000000..99798cdaff8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts @@ -0,0 +1,104 @@ +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' +import { + createPtyPreconnectInputBuffer, + type PtyPreconnectInputBuffer +} from './pty-preconnect-input-buffer' + +/** The remote terminal a held keystroke was typed into. */ +export type RemoteRuntimeInputEndpoint = { + handle: string + incarnationId: string | null +} + +type HeldInputWriter = { + isCurrent: () => boolean + sendInput: (data: string, inputKind: TerminalInputKind) => boolean + sendInputImmediate: (data: string) => boolean + sendInputAccepted: (data: string, inputKind: TerminalInputKind) => Promise +} + +export type RemoteRuntimeRecoveryInputHold = { + isHolding: () => boolean + enqueue: ( + endpoint: RemoteRuntimeInputEndpoint, + data: string, + inputKind: TerminalInputKind + ) => boolean + enqueueAccepted: ( + endpoint: RemoteRuntimeInputEndpoint, + data: string, + inputKind: TerminalInputKind + ) => Promise + /** Delivers held input to the endpoint it was typed into, or drops it if the pane rebound elsewhere. */ + release: (bound: RemoteRuntimeInputEndpoint, writer: HeldInputWriter) => void + discard: () => void +} + +function isSameEndpoint( + held: RemoteRuntimeInputEndpoint, + bound: RemoteRuntimeInputEndpoint +): boolean { + // Why: an incarnation learned only after the hold began is not evidence of a respawn. + return ( + held.handle === bound.handle && + (held.incarnationId === null || + bound.incarnationId === null || + held.incarnationId === bound.incarnationId) + ) +} + +/** Keeps keystrokes typed while a remote pane is binding or recovering, scoped to one endpoint. */ +export function createRemoteRuntimeRecoveryInputHold(): RemoteRuntimeRecoveryInputHold { + let held: { + endpoint: RemoteRuntimeInputEndpoint + buffer: PtyPreconnectInputBuffer + } | null = null + + const discard = (): void => { + const current = held + held = null + current?.buffer.clear() + } + + const bufferFor = (endpoint: RemoteRuntimeInputEndpoint): PtyPreconnectInputBuffer => { + if (held && !isSameEndpoint(held.endpoint, endpoint)) { + // Why: input typed at one shell must never run in its replacement (#10065). + discard() + } + if (!held?.buffer.isBuffering()) { + // Why: a drained buffer refuses input; its release bookkeeping may still be settling. + held = { endpoint, buffer: createPtyPreconnectInputBuffer() } + } + return held.buffer + } + + return { + isHolding: () => held?.buffer.isBuffering() === true, + enqueue: (endpoint, data, inputKind) => + bufferFor(endpoint).enqueue(data, 'ordinary', inputKind), + enqueueAccepted: (endpoint, data, inputKind) => + bufferFor(endpoint).enqueueAccepted(data, inputKind), + release(bound, writer) { + const current = held + if (!current) { + return + } + if (!isSameEndpoint(current.endpoint, bound)) { + discard() + return + } + void current.buffer + .flush({ + ...writer, + isCurrent: () => held === current && writer.isCurrent() + }) + .catch(() => undefined) + .finally(() => { + if (held === current && !current.buffer.isBuffering()) { + held = null + } + }) + }, + discard + } +} diff --git a/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts b/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts index d5ed2ff5f49..9949f8966ff 100644 --- a/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts @@ -380,19 +380,23 @@ describe('host-published recovery snapshot onto a live alt screen', () => { // alt and show the host's normal screen. it('repaints from the normal buffer once the host TUI has exited', async () => { const { data, meta } = await publishHostRecovery(AGENT_EXIT) + // The ACK-overflow recovery is screen-only and says so. + expect(meta.keepsLocalScrollback).toBe(true) expect(meta.terminalOwner).toBe('shell') expect(meta.alternateScreen).toBe(false) const client = await render([LIVE_PANE, ...(await drainOntoLiveAltScreen(data, meta))]) const host = await render([LIVE_PANE, AGENT_EXIT]) - const fresh = await render([data]) try { expect(client.buffer.active.type).toBe('normal') expect(viewport(client, 'normal')).toEqual(viewport(host, 'normal')) - expect(bufferLines(client, 'normal')).toEqual(bufferLines(fresh, 'normal')) + // A screen-only image keeps the pane's history (#14593); the one row the missed output + // scrolled off the host's screen is the only thing it cannot restore. + expect(bufferLines(client, 'normal')).toEqual( + bufferLines(host, 'normal').filter((line) => line !== 'SETUP-OUTPUT-5') + ) } finally { client.dispose() host.dispose() - fresh.dispose() } }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-snapshot-scrollback-preservation.test.ts b/src/renderer/src/components/terminal-pane/remote-snapshot-scrollback-preservation.test.ts new file mode 100644 index 00000000000..28b7bf743f0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-snapshot-scrollback-preservation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { encodeTerminalStreamJson } from '../../../../shared/terminal-stream-protocol' +import { + decodeSnapshotInfo, + pushedSnapshotKeepsLocalScrollback +} from '../../runtime/remote-runtime-terminal-snapshot-state' +import { writeHeadlessTerminal } from './pty-connection-test-async' +import { buildFoldedImageReplayWrites } from './terminal-snapshot-replay-paint' + +const COLS = 40 +const ROWS = 5 + +function lines(term: Terminal): string[] { + const buffer = term.buffer.normal + return Array.from( + { length: buffer.length }, + (_, row) => buffer.getLine(row)?.translateToString(true) ?? '' + ).filter((line) => line.length > 0) +} + +async function paneWithHistory(count: number): Promise { + const term = new Terminal({ cols: COLS, rows: ROWS, scrollback: 1000, allowProposedApi: true }) + await writeHeadlessTerminal( + term, + Array.from({ length: count }, (_, i) => `HISTORY-${i}`).join('\r\n') + ) + return term +} + +async function replay(term: Terminal, image: string, keepScrollback: boolean): Promise { + const writes = buildFoldedImageReplayWrites(image, false, keepScrollback) + await writeHeadlessTerminal(term, writes.preamble) + await writeHeadlessTerminal(term, writes.payload) +} + +function snapshotInfo(fields: Record) { + return decodeSnapshotInfo(encodeTerminalStreamJson({ cols: COLS, rows: ROWS, ...fields })) +} + +describe('remote snapshot replay keeps history the image cannot replace (#14593)', () => { + it('reads scrollbackRows off the wire and treats absence as an older host', () => { + expect(snapshotInfo({ scrollbackRows: 1000 })?.scrollbackRows).toBe(1000) + expect(snapshotInfo({ scrollbackRows: -1 })?.scrollbackRows).toBeUndefined() + expect(snapshotInfo({ scrollbackRows: 1.5 })?.scrollbackRows).toBeUndefined() + expect(pushedSnapshotKeepsLocalScrollback(snapshotInfo({}))).toBe(true) + expect(pushedSnapshotKeepsLocalScrollback(snapshotInfo({ scrollbackRows: 0 }))).toBe(true) + expect(pushedSnapshotKeepsLocalScrollback(snapshotInfo({ scrollbackRows: 1000 }))).toBe(false) + }) + + it('a screen-only image repaints the screen and keeps the scrollback above it', async () => { + const term = await paneWithHistory(40) + await replay(term, 'SCREEN-A\r\nSCREEN-B', true) + const after = lines(term) + expect(after.slice(0, 35)).toEqual(Array.from({ length: 35 }, (_, i) => `HISTORY-${i}`)) + expect(after.slice(-2)).toEqual(['SCREEN-A', 'SCREEN-B']) + term.dispose() + }) + + it('an image that carries history still replaces the scrollback without duplicating it', async () => { + const term = await paneWithHistory(40) + const image = Array.from({ length: 12 }, (_, i) => `HOST-${i}`).join('\r\n') + await replay(term, image, false) + expect(lines(term)).toEqual(Array.from({ length: 12 }, (_, i) => `HOST-${i}`)) + term.dispose() + }) + + it('pre-TUI shell output survives a screen-only image of a running TUI (#6106)', async () => { + const term = await paneWithHistory(20) + // The host folds its normal screen in, then enters alt for the TUI frame. + await replay(term, 'SHELL-SCREEN\x1b[?1049h\x1b[2J\x1b[HTUI-FRAME', true) + expect(term.buffer.active.type).toBe('alternate') + expect(lines(term).slice(0, 15)).toEqual(Array.from({ length: 15 }, (_, i) => `HISTORY-${i}`)) + term.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index ef534946ef1..8be7cff5ea8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -77,7 +77,8 @@ export function buildSnapshotReplayPreamble( */ export function buildFoldedImageReplayWrites( data: string, - paneOnAlternateScreen: boolean + paneOnAlternateScreen: boolean, + keepScrollback = false ): { preamble: string; payload: string } { const split = paneOnAlternateScreen ? splitAtAlternateScreenEntry(data) : null if (split) { @@ -90,7 +91,11 @@ export function buildFoldedImageReplayWrites( } } return { - preamble: buildSnapshotReplayPreamble({ targetAlternateScreen: false, paneOnAlternateScreen }), + preamble: buildSnapshotReplayPreamble({ + targetAlternateScreen: false, + paneOnAlternateScreen, + keepScrollback + }), payload: data } } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index f9834ac2fe0..d7a0d5dc614 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3134,6 +3134,9 @@ "viewing": "Viewing: {{path}}" } }, + "menu": { + "quitApp": "Quit {{appName}}" + }, "runtimeRpc": { "startupFailure": { "guidance": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25baa79eb85..d1bfaf4285e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -332,6 +332,7 @@ "reportCrash": "Report Crash...", "file": "File", "exit": "Exit", + "quitApp": "Quit {{appName}}", "edit": "Edit", "appearance": "Appearance", "toggleLeftSidebar": "Toggle Left Sidebar", @@ -8817,7 +8818,9 @@ "c77f1abfe3": "Ending remote terminals", "18968ede9e": "Error", "f0871e6bfb": "connect", - "47e94bd6ba": "connected" + "47e94bd6ba": "connected", + "hostNodeFallback": "Unsupported configuration: Orca-managed Node isn’t available for this connection, so Orca runs on this host’s Node.js with terminal support installed by npm on the host.", + "hostNodeUnsupported": "Unsupported configuration: Orca runs on this host’s Node.js with terminal support installed by npm on the host. Set Runtime to Auto in this host’s SSH settings to use Orca-managed Node." }, "SshTargetDestructiveActions": { "7e66942808": "This will stop active terminal sessions on this SSH target. Reconnecting will not restore them.", @@ -8865,6 +8868,8 @@ "remoteRuntimeAuto": "Auto", "remoteRuntimePinnedNode": "Orca-managed Node", "remoteRuntimeHostNode": "Host Node", + "allowRemoteCliControl": "Allow this host's orca CLI to control Orca", + "allowRemoteCliControlHelp": "When off, orca commands run on this host reach only its own terminals. Turn on to let them reach other hosts, orchestration, files, and integrations on this computer.", "editTitle": "Edit SSH host", "addTitle": "Add SSH host", "editDescription": "Update connection details for this machine. Changes apply on next connect.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 020e9cf1c13..750fbef3167 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -164,6 +164,7 @@ "reportCrash": "Informar fallo...", "file": "Archivo", "exit": "Salir", + "quitApp": "Salir de {{appName}}", "edit": "Editar", "appearance": "Apariencia", "toggleLeftSidebar": "Alternar barra lateral izquierda", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 46622f0ffc1..92ac812a798 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -265,6 +265,7 @@ "reportCrash": "Signaler un plantage...", "file": "Fichier", "exit": "Quitter", + "quitApp": "Quitter {{appName}}", "edit": "Édition", "appearance": "Apparence", "toggleLeftSidebar": "Basculer la barre latérale gauche", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e5e78f750fe..caed57c905b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -164,6 +164,7 @@ "reportCrash": "クラッシュを報告…", "file": "ファイル", "exit": "終了", + "quitApp": "{{appName}} を終了", "edit": "編集", "appearance": "外観", "toggleLeftSidebar": "左サイドバーの表示/非表示", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3d9b8ed3e88..2684f911e6c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -164,6 +164,7 @@ "reportCrash": "크래시 신고...", "file": "파일", "exit": "종료", + "quitApp": "{{appName}} 종료", "edit": "편집", "appearance": "외관", "toggleLeftSidebar": "왼쪽 사이드바 표시/숨기기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 65da17b2c29..60f088cf0b4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -164,6 +164,7 @@ "reportCrash": "发送崩溃报告...", "file": "文件", "exit": "退出", + "quitApp": "退出 {{appName}}", "edit": "编辑", "appearance": "外观", "toggleLeftSidebar": "显示/隐藏左侧边栏", diff --git a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts index 2a3a24af8b5..7fc0a8e093d 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts @@ -15,9 +15,13 @@ import { clearResyncTimer, clearSnapshot, concatBytes, - decodeSnapshotInfo + decodeSnapshotInfo, + pushedSnapshotKeepsLocalScrollback } from './remote-runtime-terminal-snapshot-state' -import type { RemoteRuntimeMultiplexedTerminalState } from './remote-runtime-terminal-multiplexer-types' +import type { + RemoteRuntimeMultiplexedTerminalCallbacks, + RemoteRuntimeMultiplexedTerminalState +} from './remote-runtime-terminal-multiplexer-types' export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntimeTerminalResponseController { protected handleSnapshotOrErrorFrame( @@ -71,7 +75,23 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime (typeof info?.requestId === 'number' ? info.requestId === pendingRequest.requestId : stream.initialSnapshotReceived) + const keepsLocalScrollback = pushedSnapshotKeepsLocalScrollback(info) if (snapshotApplied) { + const pushedMeta: NonNullable< + Parameters[1] + > = { + pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, + seq: info?.seq, + kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner, + // Why: the image encodes wraps and cursor moves against the host's + // grid, so the restorer must replay it there — the request path has + // always carried these; the pushes silently dropped them. + cols: info?.cols, + rows: info?.rows, + keepsLocalScrollback + } if (matchesPendingRequest) { pendingRequest.resolve({ availability: classifySnapshotAvailability(stream.snapshotOverflowed, info), @@ -91,36 +111,18 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime }) clearPendingSnapshotRequest(stream) } else if (target === 'initial') { - stream.callbacks.onSnapshot(data ?? '', { - pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, - seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags, - alternateScreen: info?.alternateScreen, - terminalOwner: info?.terminalOwner, - // Why: the image encodes wraps and cursor moves against the host's - // grid, so the restorer must replay it there — the request path has - // always carried these; the pushes silently dropped them. - cols: info?.cols, - rows: info?.rows - }) + stream.callbacks.onSnapshot(data ?? '', pushedMeta) } else if (target === 'recovery') { // Why: a server-pushed recovery snapshot replaces terminal state - // mid-session; clear the screen and scrollback before applying it. + // mid-session; clear the screen, and the scrollback only when the + // image carries its own, before applying it. // An empty snapshot is still applied so stale dropped output does // not linger on a terminal the model says is blank. // RELEASE_SYNCHRONIZED_OUTPUT: \x1b[2J does not clear mode 2026, so a pane // holding an open latch would not paint this recovery snapshot at all. stream.callbacks.onSnapshot( - `${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, - { - pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, - seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags, - alternateScreen: info?.alternateScreen, - terminalOwner: info?.terminalOwner, - cols: info?.cols, - rows: info?.rows - } + `${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J${keepsLocalScrollback ? '' : '\x1b[3J'}\x1b[H${data ?? ''}`, + pushedMeta ) } } else if (matchesPendingRequest) { diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts index 0f591a50412..2f8512453ca 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -243,7 +243,7 @@ describe('remote terminal frame-drop resync', () => { expect(data).toEqual(['aaa']) expect(server.droppedFrames).toBe(1) // Instead, a fresh authoritative snapshot recovers the terminal. - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) server.replaySnapshotCoveredOutput('ccc') server.output('ddd') @@ -270,7 +270,7 @@ describe('remote terminal frame-drop resync', () => { expect(server.snapshotRequests).toEqual([undefined, undefined]) server.output('eee') - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) expect(data).toEqual(['aaa', 'eee']) } finally { vi.useRealTimers() @@ -298,7 +298,7 @@ describe('remote terminal frame-drop resync', () => { server.output('eee') expect(server.snapshotRequests).toEqual([undefined, undefined]) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) expect(data).toEqual(['aaa', 'eee']) }) @@ -321,7 +321,7 @@ describe('remote terminal frame-drop resync', () => { await expect(manualSnapshot).rejects.toThrow('stream failed') expect(server.snapshotRequests).toEqual([expect.any(Number), undefined]) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) server.output('ddd') expect(data).toEqual(['aaa', 'ddd']) @@ -342,7 +342,7 @@ describe('remote terminal frame-drop resync', () => { server.output('eee') expect(server.snapshotRequests).toEqual([undefined, undefined]) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) expect(data).toEqual(['aaa', 'eee']) } finally { vi.useRealTimers() @@ -452,7 +452,7 @@ describe('remote terminal frame-drop resync', () => { await Promise.resolve() expect(data).toEqual([]) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) }) it('uses UTF-16 sequence units when detecting gaps in multibyte output', async () => { @@ -466,7 +466,7 @@ describe('remote terminal frame-drop resync', () => { await Promise.resolve() expect(data).toEqual(['é']) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) }) it('defers recovery until an in-flight manual snapshot finishes', async () => { @@ -489,7 +489,7 @@ describe('remote terminal frame-drop resync', () => { await expect(manualSnapshot).resolves.toMatchObject({ data: 'MANUAL' }) expect(server.snapshotRequests).toHaveLength(2) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) server.output('ddd') expect(data).toEqual(['aaa', 'ddd']) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-base.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-base.ts index a9d27b7c6c9..5d53f5e76fd 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-base.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-base.ts @@ -20,6 +20,7 @@ import type { RuntimeEnvironmentSubscriptionHandle } from './remote-runtime-terminal-multiplexer-types' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { refreshRuntimeEnvironmentsAfterPairingChange } from './runtime-environment-pairing-refresh' export abstract class RemoteRuntimeTerminalMultiplexerBase { protected readonly streams = new Map() @@ -51,6 +52,10 @@ export abstract class RemoteRuntimeTerminalMultiplexerBase { this.handleClose('Runtime environment pairing changed.') } + closeForEnvironmentRetirement(): void { + this.handleClose('Runtime environment was removed or replaced by another machine.', false) + } + protected allocateStreamId(): number { const start = this.nextStreamId do { @@ -114,12 +119,14 @@ export abstract class RemoteRuntimeTerminalMultiplexerBase { this.subscription = subscription this.resolveReadyIfConnected() }) - .catch((error) => { + .catch(async (error) => { if (this.connectPromise === connectPromise) { this.connectPromise = null this.readyResolver = null this.readyRejecter = null } + // Why: a pane retries on this rejection; it must find the re-read pairing, not the stale one. + await refreshRuntimeEnvironmentsAfterPairingChange(error) reject(error instanceof Error ? error : new Error(String(error))) }) }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts index a8632c132a8..c072e315796 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts @@ -45,6 +45,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { * it, which must read as unknown so replay keeps the pane's own grid. */ cols?: number rows?: number + /** The image carries no history, so replay must keep the pane's own scrollback. */ + keepsLocalScrollback?: boolean } ) => void onSubscribed?: () => void @@ -193,6 +195,8 @@ export type RemoteRuntimeSnapshotInfo = { requestId?: number truncated?: boolean unavailable?: TerminalSnapshotUnavailableReason + /** History rows above the screen; absent from hosts that predate the field. */ + scrollbackRows?: number // Why: a mid-escape tail the emulator could not serialize; the transport // must write it AFTER the replay reset so the next live chunk completes it // instead of rendering literally (#7329). diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 06c2ed4736c..c106da12ce4 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -4,7 +4,11 @@ import { } from './remote-runtime-terminal-e2e-control' import type { RemoteRuntimeTerminalMultiplexerBase } from './remote-runtime-terminal-multiplexer-base' import { RemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer-implementation' -import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { + getRuntimeEnvironmentRevision, + onRuntimeEnvironmentRevisionsChanged, + onRuntimeEnvironmentsRetired +} from './runtime-environment-revision' export { REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS, @@ -25,6 +29,25 @@ export type { const multiplexers = new Map() +// Why: a removed or replaced machine's streams end for good, before its new revision is published. +onRuntimeEnvironmentsRetired((environmentIds) => { + for (const environmentId of environmentIds) { + multiplexers.get(environmentId)?.closeForEnvironmentRetirement() + } +}) + +// Why: a stream opened before main re-paired stays open but sendFrame refuses its stale revision, +// so input would vanish silently; a recoverable close lets each pane rebind, and the pane itself +// only follows the new pairing once it is proven to be the same machine. +onRuntimeEnvironmentRevisionsChanged((environmentIds) => { + for (const environmentId of environmentIds) { + const multiplexer = multiplexers.get(environmentId) + if (multiplexer && !multiplexer.matchesCurrentEnvironmentRevision()) { + multiplexer.closeForEnvironmentReplacement() + } + } +}) + function releaseRemoteRuntimeTerminalMultiplexer( environmentId: string, multiplexer: RemoteRuntimeTerminalMultiplexerBase diff --git a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts index 5682a1a3bdd..7453a85fa1d 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts @@ -103,7 +103,10 @@ export abstract class RemoteRuntimeTerminalResponseController extends RemoteRunt event.mode !== 'remote-desktop-fit' && event.mode !== 'desktop-fit') || typeof event.cols !== 'number' || - typeof event.rows !== 'number' + typeof event.rows !== 'number' || + // Why: a restarted host that has not learned the grid yet publishes a 0x0 hold; parking + // xterm there shrinks the pane to one cell, so keys typed into it never reach the shell. + (event.mode !== 'desktop-fit' && (event.cols <= 0 || event.rows <= 0)) ) { return } diff --git a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts index d8f930dc898..4919419af29 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts @@ -97,6 +97,7 @@ export function decodeSnapshotInfo( kittyKeyboardFlags?: unknown alternateScreen?: unknown terminalOwner?: unknown + scrollbackRows?: unknown }>(payload) if (!raw) { return null @@ -116,11 +117,27 @@ export function decodeSnapshotInfo( requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined, truncated: raw.truncated === true, unavailable: parseTerminalSnapshotUnavailableReason(raw.unavailable), + scrollbackRows: + typeof raw.scrollbackRows === 'number' && + Number.isSafeInteger(raw.scrollbackRows) && + raw.scrollbackRows >= 0 + ? raw.scrollbackRows + : undefined, pendingEscapeTailAnsi: typeof raw.pendingEscapeTailAnsi === 'string' ? raw.pendingEscapeTailAnsi : undefined } } +/** + * Whether a pushed (initial or recovery) image leaves the pane's history alone. + * Absent counts as none: hosts that predate the field pushed desktop images screen-only (#14593). + */ +export function pushedSnapshotKeepsLocalScrollback( + info: RemoteRuntimeSnapshotInfo | null +): boolean { + return (info?.scrollbackRows ?? 0) === 0 +} + export function retryWorthySnapshotOutcome( cause: RemoteRuntimeSnapshotRetryCause ): RemoteRuntimeSnapshotOutcome { diff --git a/src/renderer/src/runtime/runtime-environment-pairing-refresh.ts b/src/renderer/src/runtime/runtime-environment-pairing-refresh.ts index 3f124a574c8..a247aa248cc 100644 --- a/src/renderer/src/runtime/runtime-environment-pairing-refresh.ts +++ b/src/renderer/src/runtime/runtime-environment-pairing-refresh.ts @@ -15,6 +15,10 @@ export function setRuntimeEnvironmentCatalogRefresher(refresher: CatalogRefreshe refreshCatalog = refresher } +export function runtimeEnvironmentPairingChangedError(): Error { + return new Error(`${PAIRING_CHANGED_MESSAGE}; refresh and try again`) +} + export function isRuntimeEnvironmentPairingChangedError(error: unknown): boolean { const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' return message.includes(PAIRING_CHANGED_MESSAGE) diff --git a/src/renderer/src/runtime/runtime-environment-revision.ts b/src/renderer/src/runtime/runtime-environment-revision.ts index 3f08f22ee4b..9c0a2b7d8d3 100644 --- a/src/renderer/src/runtime/runtime-environment-revision.ts +++ b/src/renderer/src/runtime/runtime-environment-revision.ts @@ -1,8 +1,44 @@ const revisionByEnvironmentId = new Map() +// Pairings proven to continue the same machine: old revision → the revision that replaced it. +const continuedRevisionsByEnvironmentId = new Map>() +const retirementListeners = new Set<(environmentIds: readonly string[]) => void>() +const revisionChangeListeners = new Set<(environmentIds: readonly string[]) => void>() + +export type RuntimeEnvironmentContinuity = { + /** Ids now naming a different machine. */ + retired: readonly string[] + /** Re-pairs proven to be the same machine (same registration and host key). */ + sameHost: readonly { id: string; fromRevision: number; toRevision: number }[] +} export function replaceRuntimeEnvironmentRevisions( - environments: readonly { id: string; createdAt: number; pairingRevision?: number }[] + environments: readonly { id: string; createdAt: number; pairingRevision?: number }[], + continuity: RuntimeEnvironmentContinuity = { retired: [], sameHost: [] } ): void { + const previous = new Map(revisionByEnvironmentId) + const nextIds = new Set(environments.map((environment) => environment.id)) + const retired = new Set([ + ...continuity.retired, + ...[...previous.keys()].filter((id) => !nextIds.has(id)) + ]) + for (const id of retired) { + continuedRevisionsByEnvironmentId.delete(id) + } + // Why first: an old machine's transports must be fenced while the old revision still stands, + // so nothing they retry can be authenticated with the replacement's credentials. + if (retired.size > 0) { + for (const listener of retirementListeners) { + listener([...retired]) + } + } + for (const { id, fromRevision, toRevision } of continuity.sameHost) { + if (fromRevision === toRevision || retired.has(id)) { + continue + } + const continued = continuedRevisionsByEnvironmentId.get(id) ?? new Map() + continued.set(fromRevision, toRevision) + continuedRevisionsByEnvironmentId.set(id, continued) + } revisionByEnvironmentId.clear() for (const environment of environments) { revisionByEnvironmentId.set( @@ -10,12 +46,57 @@ export function replaceRuntimeEnvironmentRevisions( environment.pairingRevision ?? environment.createdAt ) } + const changed = [...previous].flatMap(([id, revision]) => + retired.has(id) || revisionByEnvironmentId.get(id) === revision ? [] : [id] + ) + if (changed.length > 0) { + for (const listener of revisionChangeListeners) { + listener(changed) + } + } +} + +/** Fires, before the new revisions are published, with ids removed or now naming another machine. */ +export function onRuntimeEnvironmentsRetired( + listener: (environmentIds: readonly string[]) => void +): () => void { + retirementListeners.add(listener) + return () => retirementListeners.delete(listener) +} + +/** Fires with the ids still present whose saved pairing moved. */ +export function onRuntimeEnvironmentRevisionsChanged( + listener: (environmentIds: readonly string[]) => void +): () => void { + revisionChangeListeners.add(listener) + return () => revisionChangeListeners.delete(listener) } export function getRuntimeEnvironmentRevision(environmentId: string): number | undefined { return revisionByEnvironmentId.get(environmentId) } +/** + * The newest pairing proven to be the same machine as `revision`. A holder of an older pairing may + * follow a same-host rotation, but never a re-pair whose host identity is different or unresolved. + */ +export function resolveContinuedRuntimeEnvironmentRevision( + environmentId: string, + revision: number | undefined +): number | undefined { + if (revision === undefined) { + return getRuntimeEnvironmentRevision(environmentId) + } + const continued = continuedRevisionsByEnvironmentId.get(environmentId) + let current = revision + const seen = new Set() + while (continued?.has(current) && !seen.has(current)) { + seen.add(current) + current = continued.get(current) ?? current + } + return current +} + export function captureRuntimeEnvironmentRequestRevision( environmentId: string, expectedRevision?: number diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 994cdbe5824..cfaa771af49 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -575,7 +575,9 @@ describe('remote runtime terminal multiplex ACK gate', () => { pendingEscapeTailAnsi: undefined, // The host's serialization grid; the restorer replays there, not at the pane's own. cols: 120, - rows: 40 + rows: 40, + // No scrollbackRows: an older host's screen-only image. + keepsLocalScrollback: true }) expect(onSubscribed).toHaveBeenCalledTimes(1) @@ -585,17 +587,19 @@ describe('remote runtime terminal multiplex ACK gate', () => { cols: 120, rows: 40, reason: 'ack-pending-overflow', - truncated: false + truncated: false, + scrollbackRows: 1000 }, 'recovered state' ) - // Why: an unsolicited recovery snapshot replaces terminal state, so it - // clears screen and scrollback first and must not replay the subscribe - // lifecycle. + // Why: an unsolicited recovery snapshot that carries history replaces + // terminal state, so it clears screen and scrollback first and must not + // replay the subscribe lifecycle. expect(onSnapshot).toHaveBeenCalledWith(`\x1b[?2026l\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, { pendingEscapeTailAnsi: undefined, cols: 120, - rows: 40 + rows: 40, + keepsLocalScrollback: false }) expect(onSubscribed).toHaveBeenCalledTimes(1) @@ -611,10 +615,12 @@ describe('remote runtime terminal multiplex ACK gate', () => { }, '' ) - expect(onSnapshot).toHaveBeenCalledWith('\x1b[?2026l\x1b[2J\x1b[3J\x1b[H', { + // A screen-only recovery (scrollbackRows absent or 0) keeps the pane's history (#14593). + expect(onSnapshot).toHaveBeenCalledWith('\x1b[?2026l\x1b[2J\x1b[H', { pendingEscapeTailAnsi: undefined, cols: 120, - rows: 40 + rows: 40, + keepsLocalScrollback: true }) expect(onSubscribed).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index c0f64fed827..3c1e79288cc 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -4,7 +4,6 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' import { selectProjectGroupRemovalTargets } from '../slices/project-group-removal-targets' import { - catalogOwnsHost, getProjectGroupHostId, projectGroupMatchesOwnerHost, resolveProjectGroupOwnerHostId, @@ -180,26 +179,36 @@ export function createProjectGroupMutationActions( const removedProjectIds: string[] = [] const failedProjectRemovals: ProjectRemovalFailure[] = [] - // Why: the group's catalog can hold rows from several hosts (a local catalog also owns SSH rows), - // so each project is removed on its own host rather than on the group's. - const findOwnedProjects = (projectId: string): Repo[] => - get().repos.filter( - (repo) => - repo.id === projectId && - (!ownerHostId || catalogOwnsHost(ownerHostId, getRepoExecutionHostId(repo))) - ) - for (const projectId of targets.projectIds) { - const ownedProjects = findOwnedProjects(projectId) - const projectHostId = - ownedProjects.length === 1 ? getRepoExecutionHostId(ownedProjects[0]) : undefined - try { - if (ownedProjects.length > 0) { - await get().removeProject(projectId, { hostId: projectHostId }) - } - } catch (err) { - console.error('Failed to remove contained project:', err) + // Why: members were captured before the cascade cleared membership; a same-id row on another + // host outside the group must survive, so each member row is removed on its own host. + const membersById = new Map() + for (const member of targets.projects) { + const row = findRepoForHost(get().repos, member.id, { hostId: member.hostId }) + if (row) { + membersById.set(member.id, [...(membersById.get(member.id) ?? []), row]) } - const stillExists = findOwnedProjects(projectId).length > 0 + } + for (const projectId of new Set(targets.projectIds)) { + const members = membersById.get(projectId) ?? [] + // Why: an unstamped group already routes to the focused host, so only that host's member is removed. + const focusedMember = ownerHostId + ? null + : findRepoForHost(members, projectId, { settings: get().settings }) + const removable = ownerHostId ? members : focusedMember ? [focusedMember] : [] + const removableHostIds = removable.map((row) => getRepoExecutionHostId(row)) + for (const hostId of removableHostIds) { + try { + await get().removeProject(projectId, { hostId }) + } catch (err) { + console.error('Failed to remove contained project:', err) + } + } + // Why: an ambiguous unstamped member with no focused-host row was left in place, so it counts as remaining. + const stillExists = + (members.length > 0 && removableHostIds.length === 0) || + removableHostIds.some((hostId) => + get().repos.some((repo) => repoMatchesHostIdentity(repo, projectId, hostId)) + ) if (stillExists) { failedProjectRemovals.push({ projectId, diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 572e8a78180..1943b3dd722 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -4,7 +4,7 @@ import type { AppState } from '../types' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency' import { omitSparsePresetsForRepos } from '../slices/sparse-presets' -import { findRepoForHost, repoMatchesHostIdentity } from '../slices/repo-host-identity' +import { repoMatchesHostIdentity } from '../slices/repo-host-identity' import { callRuntimeRpc, getActiveRuntimeTarget, @@ -57,11 +57,10 @@ export function createRepoRemovalActions( return { removeProject: async (projectId, options) => { try { - // Why: pass an explicit hostId so a duplicate id across hosts resolves to the intended row, not the focused-host fallback. - const ownerRepo = findRepoForHost(get().repos, projectId, { - settings: get().settings, - hostId: options?.hostId - }) + // Why: exact host match only; the unique-candidate/focused fallbacks could pick another host's row (#13071). + const ownerRepo = get().repos.find((repo) => + repoMatchesHostIdentity(repo, projectId, options.hostId) + ) if (!ownerRepo) { return } @@ -79,14 +78,8 @@ export function createRepoRemovalActions( ) } } - // Why: derive the target from the owner's settings (via options.hostId) so an SSH host removal never routes repo.rm to the focused runtime. - const target = getActiveRuntimeTarget( - settingsForRepoOwner(get(), projectId, options?.hostId) - ) - // Why: repos:remove is id-only and would delete every host's row; scope local removal to the owning host so cross-host duplicates keep other rows. - const idExistsOnOtherHost = get().repos.some( - (repo) => repo.id === projectId && getRepoExecutionHostId(repo) !== ownerHostId - ) + // Why: derive the target from the owner row's host so an SSH host removal never routes repo.rm to the focused runtime. + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId, ownerHostId)) // Why before the host call: its announcement can start a listing refresh that drops these tabs. const chatDraftKeys = captureWorkspaceChatDraftKeys( get(), @@ -96,10 +89,9 @@ export function createRepoRemovalActions( })) ) try { + // Why: always host-scoped; this catalog may be stale and miss a same-id row on another host (#13071). await (target.kind === 'local' - ? idExistsOnOtherHost - ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) - : window.api.repos.remove({ repoId: projectId }) + ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) } catch (err) { // Why: the owner already dropped this project, so purge the local ghost row instead of aborting (#11994). @@ -285,7 +277,7 @@ export function createRepoRemovalActions( } catch (err) { console.error('Failed to remove repo:', err) // Why: bulk and background callers aggregate their own failures, so only opted-in single-project entry points toast (#11994). - if (options?.errorFeedback === 'toast') { + if (options.errorFeedback === 'toast') { toast.error( translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'), { diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 16cf707b122..325930d0872 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -253,11 +253,11 @@ export type RepoSlice = { groupId: string | null, order?: number ) => Promise - // options.hostId disambiguates which host's row to remove when the id exists on multiple hosts; else the focused host is assumed. + // options.hostId is required: a bare id can resolve to another host's row (#13071). Removes nothing when that host has no row. // options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting. removeProject: ( projectId: string, - options?: { hostId?: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } + options: { hostId: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } ) => Promise updateProject: (projectId: string, updates: ProjectUpdate) => Promise // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the focused host is assumed. diff --git a/src/renderer/src/store/slices/project-group-removal-targets.ts b/src/renderer/src/store/slices/project-group-removal-targets.ts index 667d74ceb1f..3ff2e55b357 100644 --- a/src/renderer/src/store/slices/project-group-removal-targets.ts +++ b/src/renderer/src/store/slices/project-group-removal-targets.ts @@ -4,10 +4,14 @@ import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { catalogOwnsHost, getProjectGroupHostId } from './project-group-owner-routing' +export type ProjectGroupRemovalProject = { id: string; hostId: ExecutionHostId } + export type ProjectGroupRemovalTargets = { groupExists: boolean deletedGroupIds: Set projectIds: string[] + // Why: ids repeat across hosts and membership is per row, so removal must target the exact member rows. + projects: ProjectGroupRemovalProject[] } export function selectProjectGroupRemovalTargets( @@ -27,25 +31,30 @@ export function selectProjectGroupRemovalTargets( return { groupExists: false, deletedGroupIds: new Set(), - projectIds: [] + projectIds: [], + projects: [] } } const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) const projectIds: string[] = [] + const projects: ProjectGroupRemovalProject[] = [] for (const repo of repos) { + const repoHostId = getRepoExecutionHostId(repo) if ( repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId) && - ownsRowHost(getRepoExecutionHostId(repo)) + ownsRowHost(repoHostId) ) { projectIds.push(repo.id) + projects.push({ id: repo.id, hostId: repoHostId }) } } return { groupExists: true, deletedGroupIds, - projectIds + projectIds, + projects } } diff --git a/src/renderer/src/store/slices/project-removal-owner-host.test.ts b/src/renderer/src/store/slices/project-removal-owner-host.test.ts new file mode 100644 index 00000000000..4d798fbcf93 --- /dev/null +++ b/src/renderer/src/store/slices/project-removal-owner-host.test.ts @@ -0,0 +1,238 @@ +/** + * #13071: a project id can exist on several hosts. Removing one host's project must + * never resolve the bare id to another host's row (the unique-candidate or + * focused-host fallback), or the other host's live project is destroyed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { purgeOrphanedRuntimeSshProjects } from './worktrees/teardown/orphaned-runtime-ssh-project-purge' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const baseRepo: Repo = { + id: 'same-repo', + path: '/work/same', + displayName: 'Same', + badgeColor: '#000', + addedAt: 1 +} +const localRow: Repo = { ...baseRepo, executionHostId: 'local' } +const sshRow: Repo = { + ...baseRepo, + path: '/remote/same', + connectionId: 'target-1', + executionHostId: 'ssh:target-1' +} +const runtimeRow: Repo = { + ...baseRepo, + path: '/server/same', + executionHostId: 'runtime:env-b' +} + +const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + executionHostId: 'local' +} + +const reposRemove = vi.fn() +const reposRemoveForHost = vi.fn() +const projectGroupsDelete = vi.fn() +const runtimeCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + for (const mock of [reposRemove, reposRemoveForHost, projectGroupsDelete, runtimeCall]) { + mock.mockReset() + } + reposRemove.mockResolvedValue(undefined) + reposRemoveForHost.mockResolvedValue(undefined) + projectGroupsDelete.mockResolvedValue(true) + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + projectGroups: { delete: projectGroupsDelete }, + pty: { kill: vi.fn() }, + runtimeEnvironments: { call: runtimeCall }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + }) +}) + +describe('project removal stays on its owner host (#13071)', () => { + it('leaves the only remaining row alone when the named host has no row', async () => { + const store = createTestStore() + // Host A's row dropped from the catalog; host B's row is the unique candidate for the id. + store.setState({ repos: [runtimeRow] }) + + await store.getState().removeProject('same-repo', { hostId: 'local' }) + + expect(store.getState().repos).toEqual([runtimeRow]) + expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() + expect(runtimeCall).not.toHaveBeenCalled() + }) + + it('purges a destroyed SSH host row without touching the same id on the focused host', async () => { + const store = createTestStore() + store.setState({ repos: [localRow, sshRow] }) + + await purgeOrphanedRuntimeSshProjects(store.getState, ['target-1']) + + expect(store.getState().repos).toEqual([localRow]) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'same-repo', + hostId: 'ssh:target-1' + }) + expect(reposRemove).not.toHaveBeenCalled() + }) + + it('removes every row an owned group holds under the id, each on its own host', async () => { + const store = createTestStore() + store.setState({ + projectGroups: [group], + repos: [ + { ...localRow, projectGroupId: group.id }, + { ...sshRow, projectGroupId: group.id }, + runtimeRow + ] + }) + + const result = await store.getState().deleteProjectGroupWithContainedProjects(group.id, { + removeContainedProjects: true, + hostId: 'local' + }) + + expect(result).toMatchObject({ + removedProjectIds: ['same-repo'], + failedProjectRemovals: [] + }) + // The runtime host's row is outside the local catalog and must survive. + expect(store.getState().repos).toEqual([runtimeRow]) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'same-repo', + hostId: 'local' + }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'same-repo', + hostId: 'ssh:target-1' + }) + expect(runtimeCall).not.toHaveBeenCalled() + }) + + it('keeps a same-id row on another host that sits outside the deleted group', async () => { + const store = createTestStore() + const otherGroup: ProjectGroup = { ...group, id: 'group-2', name: 'Other' } + const outsideSshRow: Repo = { ...sshRow, projectGroupId: otherGroup.id } + store.setState({ + projectGroups: [group, otherGroup], + repos: [{ ...localRow, projectGroupId: group.id }, outsideSshRow] + }) + + const result = await store.getState().deleteProjectGroupWithContainedProjects(group.id, { + removeContainedProjects: true, + hostId: 'local' + }) + + expect(result).toMatchObject({ + removedProjectIds: ['same-repo'], + failedProjectRemovals: [] + }) + expect(store.getState().repos).toEqual([outsideSshRow]) + expect(reposRemoveForHost).toHaveBeenCalledTimes(1) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'local' }) + }) + + it('removes only the member row of an unstamped group, not the focused-host non-member', async () => { + const store = createTestStore() + const unstampedGroup: ProjectGroup = { ...group, executionHostId: undefined } + const memberSshRow: Repo = { ...sshRow, projectGroupId: unstampedGroup.id } + store.setState({ projectGroups: [unstampedGroup], repos: [localRow, memberSshRow] }) + + const result = await store.getState().deleteProjectGroupWithContainedProjects(group.id, { + removeContainedProjects: true + }) + + expect(result).toMatchObject({ + removedProjectIds: ['same-repo'], + failedProjectRemovals: [] + }) + expect(store.getState().repos).toEqual([localRow]) + expect(reposRemoveForHost).toHaveBeenCalledTimes(1) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'ssh:target-1' }) + }) +}) + +describe('removal stays host-scoped when this catalog is stale (#13071)', () => { + type MainRow = { id: string; hostId: string; kind: 'git' | 'folder' } + + // Mirrors the main catalog: id-only removal drops every host's row, removeForHost one row. + function stubMainCatalog(rows: MainRow[]): { current: MainRow[] } { + const main = { current: rows } + reposRemove.mockImplementation(async ({ repoId }: { repoId: string }) => { + main.current = main.current.filter((row) => row.id !== repoId) + }) + reposRemoveForHost.mockImplementation( + async ({ repoId, hostId }: { repoId: string; hostId: string }) => { + main.current = main.current.filter((row) => !(row.id === repoId && row.hostId === hostId)) + } + ) + return main + } + + it.each(['git', 'folder'] as const)( + 'keeps an unseen same-id SSH %s project when removing the local one', + async (kind) => { + const main = stubMainCatalog([ + { id: 'same-repo', hostId: 'local', kind }, + { id: 'same-repo', hostId: 'ssh:target-1', kind } + ]) + const store = createTestStore() + // Another window added the SSH twin; this renderer has not refreshed yet. + store.setState({ repos: [{ ...localRow, kind }] }) + + await store.getState().removeProject('same-repo', { hostId: 'local' }) + + expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'local' }) + expect(main.current).toEqual([{ id: 'same-repo', hostId: 'ssh:target-1', kind }]) + } + ) + + it.each(['git', 'folder'] as const)( + 'removes a direct SSH %s project without touching an unseen local twin', + async (kind) => { + const main = stubMainCatalog([ + { id: 'same-repo', hostId: 'local', kind }, + { id: 'same-repo', hostId: 'ssh:target-1', kind } + ]) + const store = createTestStore() + store.setState({ repos: [{ ...sshRow, kind }] }) + + await store.getState().removeProject('same-repo', { hostId: 'ssh:target-1' }) + + expect(reposRemove).not.toHaveBeenCalled() + expect(main.current).toEqual([{ id: 'same-repo', hostId: 'local', kind }]) + } + ) + + it('never falls back to id-only removal when the selected row is already gone in main', async () => { + const main = stubMainCatalog([{ id: 'same-repo', hostId: 'ssh:target-1', kind: 'git' }]) + const store = createTestStore() + store.setState({ repos: [localRow] }) + + await store.getState().removeProject('same-repo', { hostId: 'local' }) + + expect(reposRemove).not.toHaveBeenCalled() + expect(main.current).toEqual([{ id: 'same-repo', hostId: 'ssh:target-1', kind: 'git' }]) + }) +}) diff --git a/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts b/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts index d3cac1929aa..a8e4e049679 100644 --- a/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts +++ b/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts @@ -159,7 +159,7 @@ describe('deleting one host copy of a same-named project', () => { it('removes only the remote row when the same name exists locally', async () => { const store = seed([localTwin, remoteATwin], 'env-a') - await store.getState().removeProject('env-a-uuid') + await store.getState().removeProject('env-a-uuid', { hostId: 'runtime:env-a' }) expect(repoRmCalls()).toEqual([ expect.objectContaining({ @@ -177,7 +177,8 @@ describe('deleting one host copy of a same-named project', () => { await store.getState().removeProject('local-uuid', { hostId: 'local' }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'local-uuid' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'local-uuid', hostId: 'local' }) + expect(reposRemove).not.toHaveBeenCalled() expect(repoRmCalls()).toEqual([]) expect(remainingRepoIds(store)).toEqual(['env-a-uuid']) }) @@ -185,7 +186,7 @@ describe('deleting one host copy of a same-named project', () => { it('removes only remote A when the same name exists on remote B', async () => { const store = seed([remoteATwin, remoteBTwin], 'env-b') - await store.getState().removeProject('env-a-uuid') + await store.getState().removeProject('env-a-uuid', { hostId: 'runtime:env-a' }) expect(repoRmCalls()).toEqual([ expect.objectContaining({ diff --git a/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts b/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts index afc54b88af1..a6bceb41ef5 100644 --- a/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts +++ b/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts @@ -1,11 +1,12 @@ import { expect, it, vi } from 'vitest' import { toast } from 'sonner' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' import { ephemeralVmCleanup, ephemeralVmListRuntimes, installReposRuntimeRoutingHarness, - reposRemove, + reposRemoveForHost, sshRepo } from './repos-runtime-routing-fixture' import { createTestStore } from './store-test-helpers' @@ -38,10 +39,13 @@ it('retains a runtime-owned SSH project when VM cleanup fails', async () => { const store = createTestStore() store.setState({ repos: [runtimeRepo], activeRepoId: runtimeRepo.id }) - await store.getState().removeProject(runtimeRepo.id, { errorFeedback: 'toast' }) + await store.getState().removeProject(runtimeRepo.id, { + hostId: getRepoExecutionHostId(runtimeRepo), + errorFeedback: 'toast' + }) expect(store.getState().repos).toEqual([runtimeRepo]) - expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() expect(toast.error).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ description: expect.stringContaining('Retry cleanup') }) diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 9b6bf765d43..a67543a705a 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -301,7 +301,7 @@ describe('repo slice host identity routing', () => { ]) }) - it('removes only the focused host row and worktrees for duplicate repo ids', async () => { + it('removes only the named host row and worktrees for duplicate repo ids', async () => { const localWorktree = makeWorktree({ id: 'same-repo::/local/wt', repoId: 'same-repo' @@ -345,7 +345,7 @@ describe('repo slice host identity routing', () => { } }) - await store.getState().removeProject('same-repo') + await store.getState().removeProject('same-repo', { hostId: 'local' }) expect(store.getState().repos).toEqual([remoteDuplicate]) // Current contract: bare-ID UI selections clear even though the sibling host row survives. @@ -404,13 +404,13 @@ describe('repo slice host identity routing', () => { it('allows two removals of the same owner row to overlap', async () => { const { promise: firstRemoval, resolve: resolveFirstRemoval } = Promise.withResolvers() const { promise: secondRemoval, resolve: resolveSecondRemoval } = Promise.withResolvers() - reposRemove.mockReturnValueOnce(firstRemoval).mockReturnValueOnce(secondRemoval) + reposRemoveForHost.mockReturnValueOnce(firstRemoval).mockReturnValueOnce(secondRemoval) const store = createTestStore() store.setState({ repos: [localDuplicate] }) - const first = store.getState().removeProject(localDuplicate.id) - const second = store.getState().removeProject(localDuplicate.id) - expect(reposRemove).toHaveBeenCalledTimes(2) + const first = store.getState().removeProject(localDuplicate.id, { hostId: 'local' }) + const second = store.getState().removeProject(localDuplicate.id, { hostId: 'local' }) + expect(reposRemoveForHost).toHaveBeenCalledTimes(2) resolveSecondRemoval() resolveFirstRemoval() @@ -553,7 +553,7 @@ describe('repo slice host identity routing', () => { } }) - await store.getState().removeProject('same-repo') + await store.getState().removeProject('same-repo', { hostId: 'runtime:env-1' }) expect(store.getState().repos).toEqual([localDuplicate]) expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) diff --git a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts index 8d8093fc8fe..5e793f03a05 100644 --- a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts @@ -191,8 +191,9 @@ describe('project group deletion store routing', () => { failedProjectRemovals: [] }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'direct' }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'nested' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'direct', hostId: 'local' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'nested', hostId: 'local' }) + expect(reposRemove).not.toHaveBeenCalled() expect(store.getState().repos).toEqual([siblingRepo]) }) @@ -227,19 +228,18 @@ describe('project group deletion store routing', () => { groupId: projectGroup.id, requestedProjectIds: ['shared', 'shared'], removedProjectIds: ['shared'], - failedProjectRemovals: [ - { - projectId: 'shared', - reason: 'Project remained in Orca after removeProject completed.' - } - ] + failedProjectRemovals: [] }) expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'shared', hostId: 'local' }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'shared' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'shared', + hostId: 'ssh:ssh-1' + }) + expect(reposRemove).not.toHaveBeenCalled() expect(store.getState().repos).toEqual([]) }) @@ -270,7 +270,7 @@ describe('project group deletion store routing', () => { it('reports project removal failures by comparing store state after removeProject', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - reposRemove.mockImplementation(async ({ repoId }: { repoId: string }) => { + reposRemoveForHost.mockImplementation(async ({ repoId }: { repoId: string }) => { if (repoId === 'nested') { throw new Error('remove failed') } diff --git a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts index c936001cc3d..b18fd5743dd 100644 --- a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts @@ -61,7 +61,7 @@ beforeEach(() => { }) vi.stubGlobal('window', { api: { - repos: { remove: vi.fn() }, + repos: {}, projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } @@ -280,11 +280,10 @@ describe('project group state cascades stay scoped to the owner host', () => { it('removes contained projects only from the owner host', async () => { projectGroupsDelete.mockResolvedValue(true) - const reposRemove = vi.fn().mockResolvedValue(undefined) const reposRemoveForHost = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('window', { api: { - repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + repos: { removeForHost: reposRemoveForHost }, projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } diff --git a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts index 2c35bb35fb8..551e4363ee7 100644 --- a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts +++ b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts @@ -13,6 +13,7 @@ * grew monotonically. The single `removeWorktree` path already routes through * `purgeWorktreeTerminalState`; project removal did not. */ +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { describe, it, expect, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import type { Repo } from '../../../../shared/repo-types' @@ -28,7 +29,7 @@ beforeEach(() => { ptyKill.mockReset() vi.stubGlobal('window', { api: { - repos: { remove: reposRemove }, + repos: { removeForHost: reposRemove }, pty: { kill: ptyKill }, runtimeEnvironments: { call: vi.fn() } } @@ -63,7 +64,7 @@ describe('removeProject purges per-worktree state (leak regression)', () => { const store = createTestStore() seedTwoProjects(store) - await store.getState().removeProject(repo1.id) + await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) const s = store.getState() // Removed project's worktree is purged from every map. @@ -79,7 +80,7 @@ describe('removeProject purges per-worktree state (leak regression)', () => { const store = createTestStore() seedTwoProjects(store) - await store.getState().removeProject(repo1.id) + await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) const s = store.getState() // Surviving project's worktree state is untouched (guard over-eviction). diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts index f7dca6594ae..9879516226e 100644 --- a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -41,7 +41,7 @@ export const reposAdd: Mock = vi.fn() export const reposPickFolder: Mock = vi.fn() export const reposClone: Mock = vi.fn() export const reposCloneRemote: Mock = vi.fn() -export const reposRemove: Mock = vi.fn() +export const reposRemoveForHost: Mock = vi.fn() export const reposUpdate: Mock = vi.fn() export const reposReorder: Mock = vi.fn() export const reposReorderForHost: Mock = vi.fn() @@ -72,7 +72,7 @@ export function installReposRuntimeRoutingHarness(): void { reposPickFolder.mockReset() reposClone.mockReset() reposCloneRemote.mockReset() - reposRemove.mockReset() + reposRemoveForHost.mockReset() reposUpdate.mockReset() reposReorder.mockReset() reposReorderForHost.mockReset() @@ -101,7 +101,7 @@ export function installReposRuntimeRoutingHarness(): void { clone: reposClone, cloneRemote: reposCloneRemote, pickFolder: reposPickFolder, - remove: reposRemove, + removeForHost: reposRemoveForHost, update: reposUpdate, reorder: reposReorder, reorderForHost: reposReorderForHost diff --git a/src/renderer/src/store/slices/repos-stale-fetch.test.ts b/src/renderer/src/store/slices/repos-stale-fetch.test.ts index d3b04b1b078..8993c823ec0 100644 --- a/src/renderer/src/store/slices/repos-stale-fetch.test.ts +++ b/src/renderer/src/store/slices/repos-stale-fetch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { createTestStore, makeLayout, makeTab } from './store-test-helpers' import type { Repo } from '../../../../shared/repo-types' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' @@ -60,7 +61,7 @@ beforeEach(() => { api: { repos: { list: reposList, - remove: vi.fn().mockResolvedValue(undefined) + removeForHost: vi.fn().mockResolvedValue(undefined) } } }) @@ -119,7 +120,9 @@ describe('repos slice stale-fetch race (#7020)', () => { store.setState({ repos: [localRepo] }) const pending = store.getState().fetchRepos() - await store.getState().removeProject(localRepo.id) + await store + .getState() + .removeProject(localRepo.id, { hostId: getRepoExecutionHostId(localRepo) }) expect(store.getState().repos).toEqual([]) resolveCatalog([localRepo]) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index d5a246b5da5..4c9a2897201 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from '../github/cache-identity' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' @@ -17,7 +18,7 @@ import { reposCloneRemote, reposList, reposPickFolder, - reposRemove, + reposRemoveForHost, reposReorder, reposUpdate, runtimeEnvironmentCall, @@ -35,6 +36,8 @@ vi.mock('sonner', () => ({ installReposRuntimeRoutingHarness() +const ownerOf = (repo: Repo) => ({ hostId: getRepoExecutionHostId(repo) }) + describe('repo slice runtime routing', () => { it('fetches repos from local IPC when no remote environment is active', async () => { reposList.mockResolvedValue([localRepo]) @@ -658,7 +661,7 @@ describe('repo slice runtime routing', () => { activeRepoId: remoteRepo.id }) - await store.getState().removeProject(remoteRepo.id) + await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) expect(store.getState().repos).toEqual([]) expect(store.getState().activeRepoId).toBeNull() @@ -668,7 +671,7 @@ describe('repo slice runtime routing', () => { params: { repo: remoteRepo.id }, timeoutMs: 15_000 }) - expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() }) it('removes SSH-owned repos through local IPC even when a runtime is focused', async () => { @@ -683,11 +686,11 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(sshRepo.id) + await store.getState().removeProject(sshRepo.id, ownerOf(sshRepo)) expect(store.getState().repos).toEqual([]) expect(store.getState().activeRepoId).toBeNull() - expect(reposRemove).toHaveBeenCalledWith({ repoId: sshRepo.id }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: sshRepo.id, hostId: 'ssh:ssh-1' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) @@ -704,11 +707,11 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(sshRepo.id) + await store.getState().removeProject(sshRepo.id, ownerOf(sshRepo)) expect(store.getState().repos).toEqual([localRepo]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktreeId]: 200 }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: sshRepo.id }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: sshRepo.id, hostId: 'ssh:ssh-1' }) }) it('drops persisted visit timestamps for removed unhydrated runtime repos', async () => { @@ -731,7 +734,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(remoteRepo.id) + await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) expect(store.getState().repos).toEqual([localRepo]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktreeId]: 200 }) @@ -741,7 +744,7 @@ describe('repo slice runtime routing', () => { params: { repo: remoteRepo.id }, timeoutMs: 15_000 }) - expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() }) it('evicts GitHub caches for removed repos using repo id and legacy path keys', async () => { @@ -761,7 +764,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(localRepo.id) + await store.getState().removeProject(localRepo.id, ownerOf(localRepo)) expect(Object.keys(store.getState().workItemsCache)).toEqual([ workItemsCacheKey('other-repo', 20, '') @@ -793,7 +796,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(remoteRepo.id) + await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -839,7 +842,7 @@ describe('repo slice runtime routing', () => { activeWorktreeId: hiddenWorktree.id }) - await store.getState().removeProject(localRepo.id) + await store.getState().removeProject(localRepo.id, ownerOf(localRepo)) expect(store.getState().detectedWorktreesByRepo[localRepo.id]).toBeUndefined() expect(store.getState().tabsByWorktree[hiddenWorktree.id]).toBeUndefined() diff --git a/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts b/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts index e5e5316dab9..11b0a8da756 100644 --- a/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts +++ b/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { - peerReplacedEnvironmentIds, + classifyPeerReplacements, replacedRuntimeEnvironmentIds, resetDeferredPeerChecksForTests } from './runtime-environment-peer-replacement' @@ -25,11 +25,11 @@ function managed( } function retired(previous: ReturnType, next: ReturnType): string[] { - return peerReplacedEnvironmentIds( + return classifyPeerReplacements( [previous], [next], replacedRuntimeEnvironmentIds([previous], [next]) - ) + ).retired } describe('which re-paired environments name a different machine', () => { @@ -46,11 +46,11 @@ describe('which re-paired environments name a different machine', () => { it('defers a re-pair whose host key is not known yet instead of retiring it', () => { expect(retired(managed(), managed({ pairingRevision: 2, hostKey: null }))).toEqual([]) expect( - peerReplacedEnvironmentIds( + classifyPeerReplacements( [managed({ pairingRevision: 2, hostKey: null })], [managed({ pairingRevision: 2, hostKey: 'key-b' })], [] - ) + ).retired ).toEqual(['env-1']) }) @@ -61,7 +61,7 @@ describe('which re-paired environments name a different machine', () => { it('retires a re-paired environment that is not a managed server', () => { const paired = { id: 'env-1', createdAt: 1, pairingRevision: 1, hostKeyFingerprint: 'k' } expect( - peerReplacedEnvironmentIds([paired], [{ ...paired, pairingRevision: 2 }], ['env-1']) + classifyPeerReplacements([paired], [{ ...paired, pairingRevision: 2 }], ['env-1']).retired ).toEqual(['env-1']) }) @@ -69,3 +69,36 @@ describe('which re-paired environments name a different machine', () => { expect(replacedRuntimeEnvironmentIds([managed()], [managed()])).toEqual([]) }) }) + +describe('which re-pairs a live pane may follow', () => { + const classify = (previous: ReturnType, next: ReturnType) => + classifyPeerReplacements([previous], [next], replacedRuntimeEnvironmentIds([previous], [next])) + + it('reports a same-key update as a same-host rotation from the old pairing', () => { + expect(classify(managed(), managed({ pairingRevision: 2 }))).toEqual({ + retired: [], + sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }] + }) + }) + + it('never reports a changed key or a new registration as a same-host rotation', () => { + expect(classify(managed(), managed({ pairingRevision: 2, hostKey: 'key-b' })).sameHost).toEqual( + [] + ) + expect(classify(managed(), managed({ generation: 2, pairingRevision: 2 })).sameHost).toEqual([]) + }) + + it('reports a deferred re-pair only once its key proves the same host, from the original pairing', () => { + expect(classify(managed(), managed({ pairingRevision: 2, hostKey: null }))).toEqual({ + retired: [], + sameHost: [] + }) + expect( + classifyPeerReplacements( + [managed({ pairingRevision: 2, hostKey: null })], + [managed({ pairingRevision: 2 })], + [] + ) + ).toEqual({ retired: [], sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }] }) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts b/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts index d76f3657890..cdec9c9e401 100644 --- a/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts +++ b/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts @@ -14,17 +14,13 @@ export function replacedRuntimeEnvironmentIds( return next .filter((environment) => { const before = previousById.get(environment.id) - return ( - before !== undefined && - (before.pairingRevision ?? before.createdAt) !== - (environment.pairingRevision ?? environment.createdAt) - ) + return before !== undefined && pairingRevisionOf(before) !== pairingRevisionOf(environment) }) .map((environment) => environment.id) } -// Re-pairs of a managed server whose host key was not yet known: the host key it had before. -const deferredHostKeyById = new Map() +// Re-pairs of a managed server whose host key was not yet known: the host key and pairing it had before. +const deferredHostKeyById = new Map() export function resetDeferredPeerChecksForTests(): void { deferredHostKeyById.clear() @@ -44,20 +40,35 @@ function sameRegistration( ) } +function pairingRevisionOf(environment: CatalogEnvironment | undefined): number | null { + return environment ? (environment.pairingRevision ?? environment.createdAt) : null +} + +export type SameHostPairingRotation = { id: string; fromRevision: number; toRevision: number } + /** - * Ids that now name a different machine, whose workspaces and tabs are retired. A managed server - * re-pairs on every update and is the same machine while the host's key digest, which its pairing - * handshake proves, is unchanged under the same SSH target registration. A registration alone is - * no proof (a reinstall or a target that resolves elsewhere keeps it), so a re-pair whose key is - * not known yet is decided later, once a catalog carries it, rather than purged on a guess. + * `retired`: ids that now name a different machine, whose workspaces and tabs are retired. A managed + * server re-pairs on every update and is the same machine while the host's key digest, which its + * pairing handshake proves, is unchanged under the same SSH target registration. A registration + * alone is no proof (a reinstall or a target that resolves elsewhere keeps it), so a re-pair whose + * key is not known yet is decided later, once a catalog carries it, rather than purged on a guess. + * `sameHost`: re-pairs proven to be the same machine, with the pairing they continue. Re-pairs in + * neither list are still unresolved. */ -export function peerReplacedEnvironmentIds( +export function classifyPeerReplacements( previous: readonly CatalogEnvironment[], next: readonly CatalogEnvironment[], replacedIds: readonly string[] -): string[] { +): { retired: string[]; sameHost: SameHostPairingRotation[] } { const nextById = new Map(next.map((environment) => [environment.id, environment])) const retired: string[] = [] + const sameHost: SameHostPairingRotation[] = [] + const recordSameHost = (id: string, fromRevision: number | null): void => { + const toRevision = pairingRevisionOf(nextById.get(id)) + if (fromRevision !== null && toRevision !== null) { + sameHost.push({ id, fromRevision, toRevision }) + } + } for (const id of replacedIds) { const before = previous.find((environment) => environment.id === id) const after = nextById.get(id) @@ -66,18 +77,22 @@ export function peerReplacedEnvironmentIds( retired.push(id) continue } - const beforeKey = before?.hostKeyFingerprint ?? deferredHostKeyById.get(id) ?? null + const deferred = deferredHostKeyById.get(id) + const beforeKey = before?.hostKeyFingerprint ?? deferred?.hostKey ?? null + const fromRevision = deferred ? deferred.revision : pairingRevisionOf(before) const afterKey = after?.hostKeyFingerprint if (beforeKey && afterKey) { deferredHostKeyById.delete(id) if (beforeKey !== afterKey) { retired.push(id) + } else { + recordSameHost(id, fromRevision) } continue } - deferredHostKeyById.set(id, beforeKey) + deferredHostKeyById.set(id, { hostKey: beforeKey, revision: fromRevision }) } - for (const [id, beforeKey] of deferredHostKeyById) { + for (const [id, deferred] of deferredHostKeyById) { const after = nextById.get(id) if (replacedIds.includes(id) || !after?.hostKeyFingerprint) { if (!after) { @@ -87,9 +102,14 @@ export function peerReplacedEnvironmentIds( } deferredHostKeyById.delete(id) const before = previous.find((environment) => environment.id === id) - if (!sameRegistration(before, after) || (beforeKey && beforeKey !== after.hostKeyFingerprint)) { + if ( + !sameRegistration(before, after) || + (deferred.hostKey && deferred.hostKey !== after.hostKeyFingerprint) + ) { retired.push(id) + } else if (deferred.hostKey) { + recordSameHost(id, deferred.revision) } } - return retired + return { retired, sameHost } } diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 25f0aa9cb30..5d6fd0aa5e7 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -26,7 +26,7 @@ import { } from '@/runtime/restored-client-hosted-browser-host-attach' import { applyRuntimeHostStatusSnapshot } from './runtime-status-snapshot' import { - peerReplacedEnvironmentIds, + classifyPeerReplacements, replacedRuntimeEnvironmentIds } from './runtime-environment-peer-replacement' @@ -61,7 +61,14 @@ export const createRuntimeStatusSlice: StateCreator { const previousEnvironments = get().runtimeEnvironments const replacedEnvironmentIds = replacedRuntimeEnvironmentIds(previousEnvironments, environments) - replaceRuntimeEnvironmentRevisions(environments) + const peerReplacement = classifyPeerReplacements( + previousEnvironments, + environments, + replacedEnvironmentIds + ) + // Why classified first: transports may follow only a same-host rotation; a replaced peer's + // transports are fenced before its new revision is published. + replaceRuntimeEnvironmentRevisions(environments, peerReplacement) // Why: diff against the accumulated in-memory saved list (not a second disk // read) so a main-initiated removal that never calls setRuntimeEnvironments // still enters the diff on the next list read. #8881. @@ -140,12 +147,7 @@ export const createRuntimeStatusSlice: StateCreator 0) { evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(retiredEnvironmentIds) get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds) diff --git a/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts b/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts index 429e3e793d8..36bd54dff77 100644 --- a/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts +++ b/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts @@ -7,6 +7,7 @@ * so orphaned entries accumulated for the renderer's whole session. repoIds are * random UUIDs (never reused), so re-adding a repo cannot reclaim the old keys. */ +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { describe, it, expect, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import type { Repo } from '../../../../shared/repo-types' @@ -21,7 +22,7 @@ beforeEach(() => { reposRemove.mockReset().mockResolvedValue(undefined) vi.stubGlobal('window', { api: { - repos: { remove: reposRemove }, + repos: { removeForHost: reposRemove }, pty: { kill: vi.fn() }, runtimeEnvironments: { call: vi.fn() } } @@ -54,7 +55,7 @@ describe('removeProject purges the removed repo sparse-preset maps (leak regress const store = createTestStore() seed(store) - await store.getState().removeProject(repo1.id) + await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) const s = store.getState() // Removed repo: every sparse-preset map key is gone. diff --git a/src/renderer/src/store/slices/ssh-connection-state-equality.ts b/src/renderer/src/store/slices/ssh-connection-state-equality.ts index 6e2281547f1..9e43841cc16 100644 --- a/src/renderer/src/store/slices/ssh-connection-state-equality.ts +++ b/src/renderer/src/store/slices/ssh-connection-state-equality.ts @@ -13,6 +13,7 @@ export function sshConnectionStatesEqual( a?.connectionGeneration === b.connectionGeneration && a?.supportsFolderDownload === b.supportsFolderDownload && a?.remotePlatform === b.remotePlatform && + a?.hostNodeRuntime === b.hostNodeRuntime && // Why: a connect can end on the same status and epoch it set up under, changing only these. JSON.stringify(a?.managedServer ?? null) === JSON.stringify(b.managedServer ?? null) && JSON.stringify(a?.plainSsh ?? null) === JSON.stringify(b.plainSsh ?? null) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 1cdb873af10..c3c4c10fd7d 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -20,7 +20,7 @@ const mockApi = createStoreSessionMockApi() describe('removeProject cascade', () => { beforeEach(() => { vi.clearAllMocks() - mockApi.repos.remove.mockResolvedValue(undefined) + mockApi.repos.removeForHost.mockResolvedValue(undefined) mockApi.pty.kill.mockResolvedValue(undefined) }) @@ -58,7 +58,7 @@ describe('removeProject cascade', () => { activeTabId: 'tab1' }) - await store.getState().removeProject('repo1') + await store.getState().removeProject('repo1', { hostId: 'local' }) const s = store.getState() expect(s.repos).toEqual([]) diff --git a/src/renderer/src/store/slices/store-session-test-harness.ts b/src/renderer/src/store/slices/store-session-test-harness.ts index 0a1f08266b7..38fdd630c88 100644 --- a/src/renderer/src/store/slices/store-session-test-harness.ts +++ b/src/renderer/src/store/slices/store-session-test-harness.ts @@ -18,7 +18,13 @@ type UsageScannerMocks = { export type StoreSessionMockApi = { worktrees: { list: Mock; create: Mock; remove: Mock; updateMeta: Mock } - repos: { list: Mock; add: Mock; remove: Mock; update: Mock; pickFolder: Mock } + repos: { + list: Mock + add: Mock + removeForHost: Mock + update: Mock + pickFolder: Mock + } pty: { kill: Mock } gh: { prForBranch: Mock; issue: Mock } settings: { get: Mock; set: Mock } @@ -41,7 +47,7 @@ export function createStoreSessionMockApi(): StoreSessionMockApi { repos: { list: vi.fn().mockResolvedValue([]), add: vi.fn().mockResolvedValue({}), - remove: vi.fn().mockResolvedValue(undefined), + removeForHost: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue({}), pickFolder: vi.fn().mockResolvedValue(null) }, diff --git a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts index bac3dc738cd..4ddd81943bb 100644 --- a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts +++ b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { list: vi.fn(), remove: vi.fn(), updateMeta: vi.fn().mockResolvedValue({}) }, - repos: { remove: vi.fn() }, + repos: { removeForHost: vi.fn() }, folderWorkspaces: { delete: vi.fn() }, pty: { kill: vi.fn() }, runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } @@ -174,7 +174,7 @@ describe('workspace chat drafts on removal', () => { beforeEach(() => { vi.clearAllMocks() mockApi.worktrees.remove.mockResolvedValue(undefined) - mockApi.repos.remove.mockResolvedValue(undefined) + mockApi.repos.removeForHost.mockResolvedValue(undefined) clearNativeChatComposerDraftsForTests() clearNativeChatPendingAttachmentsForTests() }) @@ -286,7 +286,7 @@ describe('workspace chat drafts on removal', () => { } }) - await store.getState().removeProject('repo1') + await store.getState().removeProject('repo1', { hostId: 'local' }) expect(draftTexts()).toEqual(['', '', 'other']) }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts index 428479f8623..023889faaf4 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts @@ -1,4 +1,8 @@ -import { toSshExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' +import { + getRepoExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' import type { AppState } from '../../../types' // Why: an SSH per-workspace-env project's host is the runtime-owned SSH target; once that runtime is destroyed, remove the project or it lingers as a dead, never-connectable one. @@ -30,14 +34,12 @@ export async function purgeOrphanedRuntimeSshProjects( } } // A repo whose only host was the destroyed runtime can outlive its setup (pruned first by a projection refresh); remove it directly so no dead project lingers. - const orphanedRepoIds = get() - .repos.filter( - (repo) => destroyedTargetIds.has(repo.connectionId ?? '') && !purgedRepoIds.has(repo.id) - ) - .map((repo) => repo.id) - for (const repoId of orphanedRepoIds) { + const orphanedRepos = get().repos.filter( + (repo) => destroyedTargetIds.has(repo.connectionId ?? '') && !purgedRepoIds.has(repo.id) + ) + for (const repo of orphanedRepos) { try { - await get().removeProject(repoId) + await get().removeProject(repo.id, { hostId: getRepoExecutionHostId(repo) }) } catch (error) { console.error('Failed to purge orphaned per-workspace-env repo:', error) } diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts index beea1ecf193..1f2ad6e2d13 100644 --- a/src/renderer/src/web/preload-api/web-repositories-api.ts +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -25,10 +25,6 @@ export function createReposApi(): NonNullable['repos']> { ) return withRuntimeRepoMutationOwner(owned.result, owned.hostId) }, - remove: async ({ repoId }) => { - await callRuntimeResult('repo.rm', { repo: repoId }) - invalidateRuntimeWorktreeCaches() - }, // Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state. removeForHost: () => { throw new Error('Forgetting a host is unavailable in paired web clients.') diff --git a/src/shared/agent-tui-ansi-fuzz-stream.ts b/src/shared/agent-tui-ansi-fuzz-stream.ts index 885dd9c0d50..8d45fc750dc 100644 --- a/src/shared/agent-tui-ansi-fuzz-stream.ts +++ b/src/shared/agent-tui-ansi-fuzz-stream.ts @@ -15,7 +15,7 @@ // (headless-emulator.ts onQueryReply gating); replies are a separate // authority problem with its own pinned tests (session.test.ts). -/** Same seeded PRNG as retained-tail-redraw-window.equivalence.test.ts. */ +/** Seeded PRNG shared by the terminal fuzz and equivalence suites. */ export function mulberry32(seed: number): () => number { let a = seed >>> 0 return () => { diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index b450a5a98c8..03fe4a1d2fc 100644 --- a/src/shared/cli-argument-boundary.ts +++ b/src/shared/cli-argument-boundary.ts @@ -16,6 +16,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([ 'dry-run', 'enter', 'focus', + 'grant-desktop-control', 'force', 'fresh', 'full', diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 33b3e963c91..b245232410d 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -107,6 +107,8 @@ export const ORCAD_SERVER_ENTRY_FILENAME = 'orcad-server.js' export const ORCAD_FOREIGN_SQLITE_READER_ENTRY = 'foreign-sqlite-reader-entry.js' /** Worker thread that runs workspace port detection's probe commands off the event loop. */ export const ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY = 'port-scan-command-worker-entry.js' +// Equals AI_VAULT_SERVICE_ENTRY_FILENAME: the forked child that lists agent sessions. +export const ORCAD_SESSION_SCANNER_SERVICE_ENTRY = 'session-scanner-service-entry.js' // Kept here because build-orcad.mjs imports this manifest directly under Node type stripping. export const ORCAD_RIPGREP_ARTIFACTS = [ @@ -170,6 +172,8 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ // Worker thread that reads other apps' SQLite (the OpenCode binder and history) off the event loop. { filename: ORCAD_FOREIGN_SQLITE_READER_ENTRY }, { filename: ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY }, + // Forked so transcript parsing stays off the server's event loop; session search stays in-process. + { filename: ORCAD_SESSION_SCANNER_SERVICE_ENTRY }, // Target-specific even when the JavaScript bundle is shared across packaged slots. { filename: ORCAD_SERVER_TARGET_FILENAME }, // orcad never depends on a host runtime or host-installed native module. diff --git a/src/shared/serve-option-validation.ts b/src/shared/serve-option-validation.ts index 36f844f322f..12cbf823d81 100644 --- a/src/shared/serve-option-validation.ts +++ b/src/shared/serve-option-validation.ts @@ -3,6 +3,7 @@ import { levenshtein } from './edit-distance' export type ServeOptionValidationInput = { noPairing: boolean mobilePairing: boolean + grantDesktopControl?: boolean recipeJson: boolean projectRoot: string | null | undefined } @@ -11,6 +12,9 @@ export function getServeOptionValidationError(options: ServeOptionValidationInpu if (options.noPairing && options.mobilePairing) { return 'Use either --mobile-pairing or --no-pairing, not both.' } + if (options.grantDesktopControl && (options.noPairing || options.mobilePairing)) { + return '--grant-desktop-control applies only to the default runtime pairing offer.' + } if (options.recipeJson && options.noPairing) { return 'Recipe JSON output requires runtime pairing; remove --no-pairing.' } @@ -28,6 +32,8 @@ const SERVE_SECURITY_FLAG_NAMES = [ '--serve-no-pairing', '--mobile-pairing', '--serve-mobile-pairing', + '--grant-desktop-control', + '--serve-grant-desktop-control', '--recipe-json', '--serve-recipe-json', '--pairing-address', diff --git a/src/shared/serve-user-data-path.ts b/src/shared/serve-user-data-path.ts new file mode 100644 index 00000000000..8e418ec8bcb --- /dev/null +++ b/src/shared/serve-user-data-path.ts @@ -0,0 +1,27 @@ +import { realpathSync } from 'node:fs' +import { resolve } from 'node:path' + +/** + * The profile the `orca serve` CLI resolved for this launch. The Electron child refuses to serve + * on any other profile, so it can never take (or message the owner of) another profile's lock. + */ +export const SERVE_USER_DATA_PATH_ENV = 'ORCA_SERVE_USER_DATA_PATH' + +function canonicalUserDataPath(path: string, platform: NodeJS.Platform): string { + let canonical = resolve(path) + try { + // Why: /tmp vs /private/tmp style aliases must not read as two profiles. + canonical = realpathSync.native(canonical) + } catch { + // Not created yet; the resolved spelling is all there is to compare. + } + return platform === 'win32' ? canonical.toLowerCase() : canonical +} + +export function isSameUserDataPath( + left: string, + right: string, + platform: NodeJS.Platform = process.platform +): boolean { + return canonicalUserDataPath(left, platform) === canonicalUserDataPath(right, platform) +} diff --git a/src/shared/ssh-bridge-credential-env.ts b/src/shared/ssh-bridge-credential-env.ts new file mode 100644 index 00000000000..72985d339cd --- /dev/null +++ b/src/shared/ssh-bridge-credential-env.ts @@ -0,0 +1,12 @@ +/** + * Env var carrying the per-invocation credential a bridged SSH CLI presents instead of the owner token. + * + * Its presence also puts the CLI in bridge mode: no local-only commands and no paired-server routing, + * because those act with this machine's own authority rather than through the scoped runtime socket. + */ +export const ORCA_SSH_BRIDGE_CREDENTIAL_ENV = 'ORCA_SSH_BRIDGE_CREDENTIAL' + +export function readSshBridgeCredential(env: NodeJS.ProcessEnv = process.env): string | null { + const value = env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] + return typeof value === 'string' && value.length > 0 ? value : null +} diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts index f7616cbdad6..d55aa3b36c3 100644 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -94,6 +94,15 @@ describe('SSH retained payload admission', () => { expect(malformed).not.toHaveProperty('plainSsh') }) + it('admits only a literal Host Node runtime flag', () => { + const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 } + + const flagged = admitSshConnectionState({ ...state, hostNodeRuntime: true }, 'ssh-a') + expect(flagged?.hostNodeRuntime).toBe(true) + const malformed = admitSshConnectionState({ ...state, hostNodeRuntime: 'yes' }, 'ssh-a') + expect(malformed).not.toHaveProperty('hostNodeRuntime') + }) + it('admits only a literal move offer on a relay server status', () => { const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 } const relay = { kind: 'relay', reason: 'relay_terminals_live', terminals: 2 } diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts index debebc3082f..5bbea400393 100644 --- a/src/shared/ssh-retained-payload-admission.ts +++ b/src/shared/ssh-retained-payload-admission.ts @@ -103,6 +103,7 @@ export function admitSshConnectionState( ? { remotePlatform: input.remotePlatform } : {}), ...admitSshPlainSshMode(input.plainSsh), + ...(input.hostNodeRuntime === true ? { hostNodeRuntime: true } : {}), ...admitSshManagedServerStatus(input.managedServer) } } @@ -226,6 +227,7 @@ export function admitSshConnectionStateForAuthorityReconciliation( supportsFolderDownload: input.supportsFolderDownload, remotePlatform: input.remotePlatform, plainSsh: input.plainSsh, + hostNodeRuntime: input.hostNodeRuntime, managedServer: input.managedServer }, expectedTargetId diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 1b11c472e72..290083307d2 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -11,14 +11,14 @@ export const DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 0 export const SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD = 'relay.configureGraceTime' /** - * Which runtime executes the SSH relay. `legacy` runs it on the host's Node with native deps - * installed on the host; `pinned-node` uploads Orca's pinned Node and prebuilt addons (design D5). + * Which runtime executes the SSH relay. `pinned-node` runs the D6 ladder on Orca's uploaded Node and + * prebuilt addons (design D5); `legacy` is the opt-in host Node with native deps installed by npm. */ export const SSH_REMOTE_RUNTIMES = ['legacy', 'pinned-node'] as const export type SshRemoteRuntime = (typeof SSH_REMOTE_RUNTIMES)[number] -export const DEFAULT_SSH_REMOTE_RUNTIME: SshRemoteRuntime = 'legacy' +export const DEFAULT_SSH_REMOTE_RUNTIME: SshRemoteRuntime = 'pinned-node' -/** Where the design D6 fallback ladder landed; `legacy` is the host-npm path outside it. */ +/** Where the design D6 fallback ladder landed; `legacy` is the opt-in host-npm path outside it. */ export const SSH_REMOTE_RUNTIME_RUNGS = ['A', 'B', 'C', 'D', 'legacy'] as const export type SshRemoteRuntimeRung = (typeof SSH_REMOTE_RUNTIME_RUNGS)[number] @@ -30,6 +30,8 @@ export type SshRemoteRuntimeResolution = { rung: SshRemoteRuntimeRung /** The classified refusal that stepped off Orca's pinned Node, when one did. */ pinnedRefusal?: string + /** Epoch ms the refusal was proved; a refusal the host can lose expires from it. */ + refusedAt?: number glibc: string | null runtimeSha256: string orcaMajor: number @@ -70,6 +72,9 @@ export type SshTarget = { /** Grace period in seconds before relay shuts down after disconnect. * 0 disables expiry. Default: 0 (until reset). Max: 604800 (7 days). */ relayGracePeriodSeconds?: number + /** Lets this host's relayed `orca` CLI control this Orca beyond its own terminals (other hosts, + * orchestration, files). Off by default: the host is a guest, not an owner. */ + allowRemoteCliControl?: boolean /** Set to true after a successful connection that triggered a credential * prompt (passphrase or password). Persisted so startup reconnect can * partition targets into eager (no passphrase) vs deferred (passphrase) @@ -256,6 +261,8 @@ export type SshConnectionState = { remotePlatform?: SshRemotePlatform /** Set while connected without the Orca remote server (runtime ladder rung D). */ plainSsh?: SshPlainSshMode + /** Set while the relay runs on the opt-in host-Node runtime, an unsupported configuration. */ + hostNodeRuntime?: boolean /** Which server this host runs; optional so older clients simply ignore it. */ managedServer?: SshManagedServerStatus } diff --git a/src/shared/telemetry-ssh-runtime-event-schemas.ts b/src/shared/telemetry-ssh-runtime-event-schemas.ts index 7c334cbe6ac..936e7717102 100644 --- a/src/shared/telemetry-ssh-runtime-event-schemas.ts +++ b/src/shared/telemetry-ssh-runtime-event-schemas.ts @@ -68,7 +68,8 @@ export const SSH_RUNTIME_REFUSAL_VALUES = [ 'target_unresolved', 'artifacts_unavailable', 'runtime_unavailable', - 'host_node_missing' + 'host_node_missing', + 'install_failed' ] as const export const SSH_RUNTIME_DURATION_BUCKETS = ['lt_5s', '5s_15s', '15s_60s', 'gte_60s'] as const diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index d8c80f32494..ac6efd5c9ad 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -143,11 +143,16 @@ const REPLAY_BASELINE_BUFFER_RESET = '\x1b[r' export function buildSnapshotReplayPrologue(args: { targetAlternateScreen: boolean paneOnAlternateScreen: boolean + /** The payload carries no history, so the pane's own must survive. */ + keepScrollback?: boolean }): string { // Why explicit: `?1049h` does not clear the alt buffer (xterm's own // `1049 should clear altbuffer` FIXME); `\x1b[3J` is safe only for a - // normal-buffer payload, which carries its own history. - const clear = args.targetAlternateScreen ? '\x1b[2J\x1b[H' : '\x1b[2J\x1b[3J\x1b[H' + // normal-buffer payload that carries its own history. + const clear = + args.targetAlternateScreen || args.keepScrollback === true + ? '\x1b[2J\x1b[H' + : '\x1b[2J\x1b[3J\x1b[H' const ground = `${REPLAY_BASELINE_TERMINAL_RESET}${REPLAY_BASELINE_BUFFER_RESET}` if (args.paneOnAlternateScreen === args.targetAlternateScreen) { return `${ground}${clear}${SAVE_GROUNDED_CURSOR}` diff --git a/src/shared/terminal-partial-escape-tail-ground-scan.test.ts b/src/shared/terminal-partial-escape-tail-ground-scan.test.ts index 7dfa7e39873..b30642d69d4 100644 --- a/src/shared/terminal-partial-escape-tail-ground-scan.test.ts +++ b/src/shared/terminal-partial-escape-tail-ground-scan.test.ts @@ -30,15 +30,18 @@ describe('partial escape scanning between sequences', () => { ['\x1bPqpayload', '\x1b\\'], ['\x1bPqpayload\x1b', '\\', '\x1b'], ['\x1b(', 'B'] - ])('preserves %j through every split after ordinary text', (pending, completion, expected = pending) => { - const prefix = `\x1b[32m${'build output '.repeat(128)}\x1b[0m` - for (let split = 0; split <= pending.length; split += 1) { - const first = advancePartialEscapeTail('', prefix + pending.slice(0, split)) - const continued = advancePartialEscapeTail(first, pending.slice(split)) - expect(continued).toBe(expected) - expect(advancePartialEscapeTail(continued, `${completion}ready`)).toBe('') + ])( + 'preserves %j through every split after ordinary text', + (pending, completion, expected = pending) => { + const prefix = `\x1b[32m${'build output '.repeat(128)}\x1b[0m` + for (let split = 0; split <= pending.length; split += 1) { + const first = advancePartialEscapeTail('', prefix + pending.slice(0, split)) + const continued = advancePartialEscapeTail(first, pending.slice(split)) + expect(continued).toBe(expected) + expect(advancePartialEscapeTail(continued, `${completion}ready`)).toBe('') + } } - }) + ) it('handles aborts before returning to ordinary text', () => { const text = 'ordinary text '.repeat(128) diff --git a/src/shared/unix-socket-path-limit.ts b/src/shared/unix-socket-path-limit.ts index 1d29b8daf9c..3b4fc20fad7 100644 --- a/src/shared/unix-socket-path-limit.ts +++ b/src/shared/unix-socket-path-limit.ts @@ -9,3 +9,7 @@ const SUN_PATH_SIZE: Record<'linux' | 'darwin', number> = { linux: 108, darwin: export function unixSocketPathByteLimit(os: 'linux' | 'darwin'): number { return SUN_PATH_SIZE[os] - 1 } + +export function unixSocketPathFits(socketPath: string, os: 'linux' | 'darwin'): boolean { + return new TextEncoder().encode(socketPath).length <= unixSocketPathByteLimit(os) +} diff --git a/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts b/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts new file mode 100644 index 00000000000..e868149db8b --- /dev/null +++ b/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts @@ -0,0 +1,118 @@ +/** + * Paired remote server: a desktop client that opens a remote terminal after it has printed more + * than a screen must be able to scroll back through that history (#20158). + * + * The host used to serialize desktop subscribe snapshots screen-only, so the client pane held the + * last screenful and nothing above it. Orcad-backed SSH panes use the same subscribe path. + * + * Run: + * pnpm exec playwright test \ + * tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient +} from './helpers/paired-electron-client' +import { + callEnvironment, + createPairedHostTerminal, + openPairedClientTab, + readPairedPaneContent, + waitForPairedPaneMarker +} from './helpers/paired-host-terminal' + +const LINE_COUNT = 300 +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-initial-scrollback-')) +const fixturePath = path.join(scratch, 'history-terminal.mjs') +writeFileSync( + fixturePath, + [ + `for (let i = 1; i <= ${LINE_COUNT}; i += 1) {`, + " process.stdout.write(`HIST-${String(i).padStart(4, '0')}\\r\\n`)", + '}', + "process.stdout.write('HISTORY_DONE\\r\\n')", + 'setInterval(() => {}, 1 << 30)' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function fixtureCommand(): string { + const command = [process.execPath, fixturePath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map((value) => `'${value.replaceAll("'", `'\\''`)}'`).join(' ') +} + +test('a desktop client opening a remote terminal receives its scrollback, not just the screen', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(300_000) + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('host has no active worktree') + } + return id + }) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedElectronClient(offer, testInfo, 'initial-scrollback') + let terminal: string | null = null + try { + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'paired client never saw the host worktree' } + ) + .toBe(true) + const target = await createPairedHostTerminal( + client.page, + client.environmentId, + worktreeId, + fixtureCommand() + ) + terminal = target.terminal + // Let the host print everything before the client subscribes, so only the snapshot can carry it. + await expect + .poll( + async () => { + const read = await callEnvironment(client.page, client.environmentId, 'terminal.read', { + terminal: target.terminal + }) + return JSON.stringify(read).includes('HISTORY_DONE') + }, + { timeout: 60_000, message: 'host terminal never finished printing' } + ) + .toBe(true) + await openPairedClientTab(client.page, worktreeId, target.webTabId) + expect( + await waitForPairedPaneMarker(client.page, target.webTabId, 'HISTORY_DONE', 30_000) + ).toBe(true) + const content = await readPairedPaneContent(client.page, target.webTabId) + const firstLine = content.split(/\r?\n/).find((line) => line.startsWith('HIST-')) + console.log(`[initial-scrollback] first history line on client: ${firstLine ?? '(none)'}`) + expect(content).toContain('HIST-0001') + } finally { + if (terminal) { + await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', { + terminal + }).catch(() => undefined) + } + await client.dispose() + } +}) diff --git a/tests/e2e/windows-project-runtime-smoke.spec.ts b/tests/e2e/windows-project-runtime-smoke.spec.ts index 4e4cfcbc3dc..08703875258 100644 --- a/tests/e2e/windows-project-runtime-smoke.spec.ts +++ b/tests/e2e/windows-project-runtime-smoke.spec.ts @@ -178,7 +178,8 @@ test.describe('Windows project runtime smoke', () => { ).toBeVisible() await orcaPage.evaluate(async (repoId) => { - await window.api.repos.remove({ repoId }) + const repo = window.__store!.getState().repos.find((entry) => entry.id === repoId) + await window.api.repos.removeForHost({ repoId, hostId: repo?.executionHostId ?? 'local' }) }, smoke.wslRepoId) }) })