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'}
setComposerError(ATTACHMENTS)}>
compose
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.allowRemoteCliControl',
+ "Allow this host's orca CLI to control Orca"
+ )}
+
+
+ {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)
})
})