diff --git a/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs b/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs new file mode 100644 index 00000000000..051ea13cc46 --- /dev/null +++ b/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs @@ -0,0 +1,71 @@ +const { createHash } = require('node:crypto') +const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs') +const { join, resolve } = require('node:path') + +const EXPECTED_NODE_PTY_VERSION = '1.1.0' +const ORIGINAL_SOURCE_SHA256 = '0d010879bb6680a0253d44363183d53e631f42972594eb6dcb1fb842c8c85e52' +const PATCHED_SOURCE_SHA256 = '84df20cfe711a88d2bef35078615c58a6ce14f39348a4aef40e852b854dcd857' +const ORIGINAL_BODY = 'var consoleProcessList = getConsoleProcessList(shellPid);' +const PATCHED_BODY = `var consoleProcessList; +try { + consoleProcessList = getConsoleProcessList(shellPid); +} +catch (_a) { + // Why: AttachConsole can fail without a Win32 console; use node-pty's timeout fallback immediately. + consoleProcessList = [shellPid]; +}` + +function inspectNodePtyConsoleListAgent(relayDir = process.cwd()) { + const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty') + const packageJsonPath = join(nodePtyDir, 'package.json') + const agentPath = join(nodePtyDir, 'lib', 'conpty_console_list_agent.js') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) { + throw new Error( + `Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}` + ) + } + const source = readFileSync(agentPath, 'utf8') + return { agentPath, source } +} + +function assertPatchedNodePtyConsoleListAgent(relayDir = process.cwd()) { + const inspected = inspectNodePtyConsoleListAgent(relayDir) + if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) { + throw new Error('node-pty ConPTY console-list fallback is not installed') + } +} + +function patchNodePtyConsoleListAgent(relayDir = process.cwd()) { + const inspected = inspectNodePtyConsoleListAgent(relayDir) + const sourceHash = sourceSha256(inspected.source) + if (sourceHash === PATCHED_SOURCE_SHA256) { + return + } + if (sourceHash !== ORIGINAL_SOURCE_SHA256) { + throw new Error('Refusing to patch unexpected node-pty console-list agent source') + } + const patchedSource = inspected.source.replace(ORIGINAL_BODY, PATCHED_BODY) + const temporaryPath = `${inspected.agentPath}.orca-patch-${process.pid}` + // Why: a terminated remote install must leave either known source version recoverable on reconnect. + try { + writeFileSync(temporaryPath, patchedSource) + renameSync(temporaryPath, inspected.agentPath) + } finally { + rmSync(temporaryPath, { force: true }) + } + assertPatchedNodePtyConsoleListAgent(relayDir) +} + +function sourceSha256(source) { + return createHash('sha256').update(source).digest('hex') +} + +if (require.main === module) { + patchNodePtyConsoleListAgent() +} + +module.exports = { + assertPatchedNodePtyConsoleListAgent, + patchNodePtyConsoleListAgent +} diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3c49afcb582..60ad659d376 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-19", + "updatedAt": "2026-07-20", "policy": { "maturityLevels": [ "experimental", @@ -1224,7 +1224,8 @@ "PTY sizing", "split layout", "restore", - "hidden-to-visible transitions" + "hidden-to-visible transitions", + "window wake" ], "platforms": [ "macos", @@ -1241,7 +1242,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.", + "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage now includes settled window-wake reassertion and SSH relay applied-size readback. Live shell-visible SSH/remote geometry and Windows ConPTY readback remain non-blocking gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6644", "https://github.com/stablyai/orca/pull/6649", @@ -1252,15 +1253,19 @@ "https://github.com/stablyai/orca/pull/7192" ], "invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.", - "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, and hidden-to-visible activation, then asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, and visibility resume reasserts real drift without hot listSessions. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", + "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts", + "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts", "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", "src/main/ipc/pty.test.ts", - "src/main/runtime/orca-runtime.test.ts" + "src/main/providers/ssh-pty-provider.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/relay/pty-handler.test.ts" ], "assertionRefs": [ { @@ -1271,6 +1276,13 @@ "applied-size readback divergence is re-forwarded before handoff" ] }, + { + "file": "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts", + "assertions": [ + "only one applied-size read stays in flight while the grid changes", + "stale in-flight targets never resize the PTY back to an older grid" + ] + }, { "file": "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", "assertions": [ @@ -1279,11 +1291,26 @@ "hidden 0x0 background spawn is not forced to phantom desktop size" ] }, + { + "file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "assertions": [ + "window focus reasserts PTY size after fitting", + "the immediate and settled wake fits produce exactly one geometry-only reassertion" + ] + }, { "file": "src/main/ipc/pty.test.ts", "assertions": [ "accepted desktop resizes fan out to the runtime after provider resize", - "rejected desktop resizes do not fan out to the runtime" + "rejected desktop resizes do not fan out to the runtime", + "provider-owned null remains unverified instead of falling back to requested size" + ] + }, + { + "file": "src/main/providers/ssh-pty-provider.test.ts", + "assertions": [ + "SSH applied-size readback uses the relay with a one-second timeout", + "old-relay method-not-found is cached while transient failures remain retryable" ] }, { @@ -1292,17 +1319,24 @@ "the headless mirror resizes after an accepted desktop PTY resize (#7192)", "headless mirror resizes are ordered behind queued PTY writes (#7192)" ] + }, + { + "file": "src/relay/pty-handler.test.ts", + "assertions": [ + "relay readback reports the grid actually applied by node-pty", + "missing relay PTYs return an unverified null size" + ] } ], "evidenceRuns": [ { - "date": "2026-07-03", + "date": "2026-07-20", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts", "result": "passed", - "durationSeconds": 8.4, - "summary": "4 test file(s) passed, 777 tests passed on main@1282f5c2d in a clean checkout." + "durationSeconds": 18.54, + "summary": "8 test files passed, 1,272 tests passed on the PR branch." } ], "runtimeBudget": { @@ -1319,16 +1353,15 @@ }, "performanceBudget": { "required": true, - "evidence": "Must include resize/throughput budget so convergence loops do not add frame or SIGWINCH churn." + "evidence": "The wake hook count test permits two visual fits but exactly one applied-size read per visible pane and no broad visibility/agent lifecycle call. SSH readback has a one-second timeout, caches stable method-not-found, and retries only transient failures. No polling, listSessions scan, subprocess, or unbounded retry was added." }, "promotionCriteria": [ "Build deterministic fault hooks before adding broad E2E coverage.", "Keep stress variants non-blocking until runtime and flake history are known." ], "knownGaps": [ - "No live Electron geometry command yet beyond the local live PTY resize slice.", - "The runtime mirror geometry slice is unregistered: #7192's red tests (orca-runtime.test.ts headless-mirror resize and ordering, pty.test.ts desktop resize fan-out) exist on fresh main but not at this branch's merge-base, so they can only be registered during the rebase.", - "Does not yet prove shell-visible size, provider-applied size, and xterm size converge across SSH/remote paths.", + "Local and Docker-backed SSH Electron wake specs exist but remain manual/non-blocking pending runtime and flake history.", + "The deterministic gate proves the SSH provider/relay readback contract, not live shell-visible convergence across every remote path.", "Does not yet prove Windows ConPTY geometry/readback.", "Current command uses deterministic fake providers for the main/renderer contracts, not a real remote PTY." ], diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 6afd517af88..a63cce53e95 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -10,7 +10,7 @@ */ import { build } from 'esbuild' import { createHash } from 'node:crypto' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' const __dirname = import.meta.dirname @@ -26,6 +26,13 @@ const MANAGED_HOOK_RUNTIME_ENTRY = join( 'managed-hook-runtime.ts' ) const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js') +const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' +const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( + ROOT, + 'config', + 'relay-assets', + NODE_PTY_CONSOLE_LIST_PATCH_FILENAME +) const PLATFORMS = [ 'linux-x64', @@ -59,6 +66,13 @@ for (const platform of PLATFORMS) { } }) + if (platform.startsWith('win32-')) { + copyFileSync( + NODE_PTY_CONSOLE_LIST_PATCH_SOURCE, + join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME) + ) + } + await build({ entryPoints: [WATCHER_ENTRY], bundle: true, @@ -101,9 +115,12 @@ for (const platform of PLATFORMS) { .update(relayContent) .update(watcherContent) .update(managedHookRuntimeContent) - .digest('hex') - .slice(0, 12) - writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`) + // Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory. + if (platform.startsWith('win32-')) { + hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME))) + } + const contentHash = hash.digest('hex').slice(0, 12) + writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`) console.log(`Built relay for ${platform} → ${outDir}/relay.js`) } diff --git a/config/scripts/node-pty-console-list-agent-patch.test.mjs b/config/scripts/node-pty-console-list-agent-patch.test.mjs new file mode 100644 index 00000000000..c6c8677a6e9 --- /dev/null +++ b/config/scripts/node-pty-console-list-agent-patch.test.mjs @@ -0,0 +1,79 @@ +import { createRequire } from 'node:module' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + assertPatchedNodePtyConsoleListAgent, + patchNodePtyConsoleListAgent +} = require('../relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs') +const projectDir = resolve(import.meta.dirname, '..', '..') +const cleanupDirs = [] + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('Windows SSH relay node-pty console-list patch', () => { + it('installs and verifies the fallback idempotently', () => { + const fixture = writeNodePtyFixture('1.1.0', publishedAgentSource()) + + patchNodePtyConsoleListAgent(fixture.root) + const once = readFileSync(fixture.agentPath, 'utf8') + expect(once).toContain('consoleProcessList = [shellPid];') + expect(existsSync(`${fixture.agentPath}.orca-patch-${process.pid}`)).toBe(false) + expect(() => assertPatchedNodePtyConsoleListAgent(fixture.root)).not.toThrow() + + patchNodePtyConsoleListAgent(fixture.root) + expect(readFileSync(fixture.agentPath, 'utf8')).toBe(once) + }) + + it('refuses a different package version or unexpected agent source', () => { + const wrongVersion = writeNodePtyFixture('1.2.0-beta.11', publishedAgentSource()) + expect(() => patchNodePtyConsoleListAgent(wrongVersion.root)).toThrow('expected 1.1.0') + + const drifted = writeNodePtyFixture('1.1.0', `${publishedAgentSource()}\n// drift`) + expect(() => patchNodePtyConsoleListAgent(drifted.root)).toThrow('unexpected node-pty') + + const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource()) + patchNodePtyConsoleListAgent(tamperedPatch.root) + writeFileSync(tamperedPatch.agentPath, `${readFileSync(tamperedPatch.agentPath)}\n// drift`) + expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed') + }) +}) + +function writeNodePtyFixture(version, agentSource) { + const root = mkdtempSync(join(projectDir, '.node-pty-console-list-patch-test-')) + cleanupDirs.push(root) + const nodePtyDir = join(root, 'node_modules', 'node-pty') + const libDir = join(nodePtyDir, 'lib') + const agentPath = join(libDir, 'conpty_console_list_agent.js') + mkdirSync(libDir, { recursive: true }) + writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) + writeFileSync(agentPath, agentSource) + return { root, agentPath } +} + +function publishedAgentSource() { + return [ + '"use strict";', + '/**', + ' * Copyright (c) 2019, Microsoft Corporation (MIT License).', + ' *', + ' * This module fetches the console process list for a particular PID. It must be', + ' * called from a different process (child_process.fork) as there can only be a', + ' * single console attached to a process.', + ' */', + 'Object.defineProperty(exports, "__esModule", { value: true });', + 'var utils_1 = require("./utils");', + "var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;", + 'var shellPid = parseInt(process.argv[2], 10);', + 'var consoleProcessList = getConsoleProcessList(shellPid);', + 'process.send({ consoleProcessList: consoleProcessList });', + 'process.exit(0);', + '//# sourceMappingURL=conpty_console_list_agent.js.map' + ].join('\n') +} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 204ca03b2f9..97bbf5c7ae8 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -217,6 +217,23 @@ describe('Electron runtime package contract', () => { ) }) + it('packages and verifies the Windows SSH node-pty console-list fallback', () => { + const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') + const relayDeploy = readFileSync(join(projectDir, 'src/main/ssh/ssh-relay-deploy.ts'), 'utf8') + const patchAsset = readFileSync( + join(projectDir, 'config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs'), + 'utf8' + ) + + expect(relayBuild).toContain('copyFileSync(') + expect(relayBuild).toContain('hash.update(readFileSync') + expect(relayBuild).toContain('node-pty-1.1.0-console-list-agent-patch.cjs') + expect(relayDeploy).toContain('assertPatchedNodePtyConsoleListAgent') + expect(relayDeploy.match(/\$\{windowsNodePtyPatchCommand\(nodePath\)\}/g)).toHaveLength(2) + expect(patchAsset).toContain('consoleProcessList = [shellPid];') + expect(patchAsset).toContain('packageJson.version !== EXPECTED_NODE_PTY_VERSION') + }) + it('pins the Windows release builder to the VS 2022 runner image', () => { const releaseWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') diff --git a/config/scripts/run-multi-client-navigation-e2e.mjs b/config/scripts/run-multi-client-navigation-e2e.mjs new file mode 100644 index 00000000000..cf9365f6406 --- /dev/null +++ b/config/scripts/run-multi-client-navigation-e2e.mjs @@ -0,0 +1,41 @@ +import { spawnSync } from 'node:child_process' + +const rawExtraArgs = process.argv.slice(2) +const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const env = { + ...process.env, + ORCA_E2E_WEB_CLIENT: '1' +} + +// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. +const spawnOptions = { + stdio: 'inherit', + env, + shell: process.platform === 'win32' +} + +const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) + +if (runtime.status !== 0) { + process.exit(runtime.status ?? 1) +} + +const result = spawnSync( + pnpm, + [ + 'exec', + 'playwright', + 'test', + 'tests/e2e/multi-client-navigation-isolation.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...extraArgs + ], + spawnOptions +) + +process.exit(result.status ?? 1) diff --git a/config/scripts/windows-ssh-attach-console-repro.mjs b/config/scripts/windows-ssh-attach-console-repro.mjs new file mode 100644 index 00000000000..db3a7de23d2 --- /dev/null +++ b/config/scripts/windows-ssh-attach-console-repro.mjs @@ -0,0 +1,435 @@ +import { spawn, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createConnection } from 'node:net' +import { join, resolve } from 'node:path' + +const projectDir = resolve(import.meta.dirname, '..', '..') +const relayBuildDir = join(projectDir, 'out', 'relay', 'win32-x64') +const SENTINEL = Buffer.from('ORCA-RELAY v0.1.0 READY\n') +const HEADER_LENGTH = 13 +const REGRESSION_TIMEOUT_MS = 15_000 +const NODE_PTY_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' + +if (process.platform !== 'win32') { + console.log('SKIP: Windows ConPTY regression only runs on Windows.') + process.exit(0) +} + +const options = parseArgs(process.argv.slice(2)) +const nodePath = resolve(options.node ?? process.execPath) +const nodePtyDir = resolve(options.nodePty ?? join(projectDir, 'node_modules', 'node-pty')) +const expectFailure = options.expect === 'attach-console-failure' + +for (const required of [ + nodePath, + nodePtyDir, + join(relayBuildDir, 'relay.js'), + join(relayBuildDir, '.version') +]) { + if (!existsSync(required)) { + throw new Error(`Required regression input is missing: ${required}`) + } +} + +const runDir = mkdtempSync(join(projectDir, '.issue-9586-relay-repro-')) +const relayPath = join(runDir, 'relay.js') +const stdoutLog = join(runDir, 'relay.log') +const stderrLog = join(runDir, 'relay.err.log') +const socketPath = `\\\\.\\pipe\\orca-issue-9586-${process.pid}-${Date.now()}` +let relayPid + +try { + prepareRelayTree(runDir, nodePtyDir) + if (!options.skipRelayPatch) { + applyPackagedNodePtyPatch(nodePath, runDir) + } + relayPid = launchRelayWithoutConsole({ + nodePath, + relayPath, + runDir, + socketPath, + stdoutLog, + stderrLog + }) + await waitForPipe(socketPath, 5_000) + const observation = await exerciseRelayClient({ + nodePath, + relayPath, + runDir, + socketPath, + shell: options.shell + }) + await waitForExit(relayPid, 8_000) + + const relayStdout = readIfPresent(stdoutLog) + const relayStderr = readIfPresent(stderrLog) + const attachConsoleFailed = relayStderr.includes('Error: AttachConsole failed') + const installedNodePtyDir = join(runDir, 'node_modules', 'node-pty') + const agentPath = join(installedNodePtyDir, 'lib', 'conpty_console_list_agent.js') + const nativeBindingPath = join( + installedNodePtyDir, + 'prebuilds', + `${process.platform}-${process.arch}`, + 'conpty.node' + ) + const summary = { + node: observation.nodeVersion, + nodePty: JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')).version, + shell: options.shell, + relayPid, + handshake: observation.handshake, + ptySpawn: observation.ptySpawn, + ptyShutdown: observation.ptyShutdown, + relayAliveAfterPtyShutdown: observation.relayAliveAfterPtyShutdown, + bridgeExit: observation.bridgeExit, + relayPatchApplied: !options.skipRelayPatch, + agentSha256: fileSha256(agentPath), + nativeBindingPresent: existsSync(nativeBindingPath), + attachConsoleFailed, + relayExitedAfterClientDisconnect: !isProcessAlive(relayPid) + } + console.log(JSON.stringify(summary, null, 2)) + + if (expectFailure !== attachConsoleFailed) { + throw new Error( + expectFailure + ? `Expected the real console-list agent to fail AttachConsole. stderr:\n${relayStderr}` + : `The real console-list agent failed AttachConsole. stderr:\n${relayStderr}` + ) + } + if ( + !observation.handshake || + !observation.ptySpawn || + !observation.ptyShutdown || + !observation.relayAliveAfterPtyShutdown + ) { + throw new Error( + `Relay lifecycle did not complete. stdout:\n${relayStdout}\nstderr:\n${relayStderr}` + ) + } +} finally { + if (relayPid && isProcessAlive(relayPid)) { + stopExactProcess(relayPid, relayPath) + } + rmSync(runDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) +} + +function parseArgs(args) { + const parsed = { expect: 'clean', shell: 'cmd.exe', skipRelayPatch: false } + for (let index = 0; index < args.length; index++) { + const flag = args[index] + if (flag === '--skip-relay-patch') { + parsed.skipRelayPatch = true + continue + } + const value = args[index + 1] + if (!value || !['--node', '--node-pty', '--expect', '--shell'].includes(flag)) { + throw new Error( + 'Usage: node windows-ssh-attach-console-repro.mjs [--node PATH] [--node-pty DIR] [--shell PATH] [--skip-relay-patch] [--expect clean|attach-console-failure]' + ) + } + if (flag === '--node') { + parsed.node = value + } + if (flag === '--node-pty') { + parsed.nodePty = value + } + if (flag === '--expect') { + parsed.expect = value + } + if (flag === '--shell') { + parsed.shell = value + } + index++ + } + if (!['clean', 'attach-console-failure'].includes(parsed.expect)) { + throw new Error(`Unsupported expectation: ${parsed.expect}`) + } + return parsed +} + +function prepareRelayTree(runDir, nodePtyDir) { + for (const filename of [ + 'relay.js', + 'relay-watcher.js', + 'managed-hook-runtime.js', + NODE_PTY_PATCH_FILENAME, + '.version' + ]) { + copyFileSync(join(relayBuildDir, filename), join(runDir, filename)) + } + cpSync(nodePtyDir, join(runDir, 'node_modules', 'node-pty'), { recursive: true }) +} + +function applyPackagedNodePtyPatch(nodePath, runDir) { + const result = spawnSync(nodePath, [join(runDir, NODE_PTY_PATCH_FILENAME)], { + cwd: runDir, + encoding: 'utf8', + windowsHide: true + }) + if (result.status !== 0) { + throw new Error(`Packaged node-pty patch failed: ${result.stderr || result.stdout}`) + } +} + +function launchRelayWithoutConsole({ + nodePath, + relayPath, + runDir, + socketPath, + stdoutLog, + stderrLog +}) { + const relayArgs = [ + quoteWindowsArg(nodePath), + quoteWindowsArg(relayPath), + '--detached', + '--grace-time', + '5', + '--sock-path', + quoteWindowsArg(socketPath), + '--endpoint-dir', + quoteWindowsArg(join(runDir, 'endpoint')), + '--log-file', + quoteWindowsArg(stdoutLog), + `1>${quoteWindowsArg(stdoutLog)}`, + `2>${quoteWindowsArg(stderrLog)}` + ].join(' ') + const commandLine = `cmd.exe /d /s /c "${relayArgs}"` + const script = [ + `$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(commandLine)}; CurrentDirectory = ${powerShellLiteral(runDir)} }`, + `if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }`, + '$result.ProcessId' + ].join('; ') + const launched = runPowerShell(script) + const pid = Number(launched.stdout.trim()) + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error(`Could not parse detached relay pid from: ${launched.stdout}`) + } + return pid +} + +function exerciseRelayClient({ nodePath, relayPath, runDir, socketPath, shell }) { + return new Promise((resolvePromise, rejectPromise) => { + const bridge = spawn(nodePath, [relayPath, '--connect', '--sock-path', socketPath], { + cwd: runDir, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'] + }) + const observation = { + nodeVersion: readNodeVersion(nodePath), + handshake: false, + ptySpawn: false, + ptyShutdown: false, + relayAliveAfterPtyShutdown: false, + bridgeExit: null + } + let stderr = '' + let buffer = Buffer.alloc(0) + let sentinelRead = false + let outgoingSequence = 1 + let settled = false + const timeout = setTimeout( + () => finish(new Error(`Timed out waiting for relay client lifecycle. stderr:\n${stderr}`)), + REGRESSION_TIMEOUT_MS + ) + + bridge.stderr.on('data', (data) => { + stderr += data.toString() + }) + bridge.on('error', finish) + bridge.on('close', (code, signal) => { + observation.bridgeExit = { code, signal } + if (!settled && observation.relayAliveAfterPtyShutdown) { + finish() + } else if (!settled) { + finish(new Error(`Relay bridge closed before lifecycle completed. stderr:\n${stderr}`)) + } + }) + bridge.stdout.on('data', (data) => { + try { + buffer = Buffer.concat([buffer, data]) + if (!sentinelRead) { + const sentinelIndex = buffer.indexOf(SENTINEL) + if (sentinelIndex === -1) { + return + } + buffer = buffer.subarray(sentinelIndex + SENTINEL.length) + sentinelRead = true + observation.handshake = true + request(1, 'pty.spawn', { + shellOverride: shell, + cwd: projectDir, + cols: 80, + rows: 24, + env: {} + }) + } + for (const message of drainMessages()) { + if (message.id === 1) { + throwResponseError(message) + observation.ptySpawn = true + request(2, 'pty.shutdown', { id: message.result.id, immediate: true }) + } else if (message.id === 2) { + throwResponseError(message) + observation.ptyShutdown = true + request(3, 'relay.status', {}) + } else if (message.id === 3) { + throwResponseError(message) + observation.relayAliveAfterPtyShutdown = message.result.ptys.active === 0 + bridge.stdin.end() + } + } + } catch (error) { + finish(error) + } + }) + + function request(id, method, params) { + bridge.stdin.write(encodeRequestFrame({ id, method, params }, outgoingSequence++)) + } + + function drainMessages() { + const messages = [] + while (buffer.length >= HEADER_LENGTH) { + const type = buffer[0] + const payloadLength = buffer.readUInt32BE(9) + if (buffer.length < HEADER_LENGTH + payloadLength) { + break + } + const payload = buffer.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength) + buffer = buffer.subarray(HEADER_LENGTH + payloadLength) + if (type === 1) { + messages.push(JSON.parse(payload.toString('utf8'))) + } + } + return messages + } + + function finish(error) { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + bridge.kill() + rejectPromise(error) + } else { + resolvePromise(observation) + } + } + }) +} + +function waitForPipe(socketPath, timeoutMs) { + const deadline = Date.now() + timeoutMs + return new Promise((resolvePromise, rejectPromise) => { + const attempt = () => { + const socket = createConnection(socketPath) + let settled = false + const retry = () => { + if (settled) { + return + } + settled = true + socket.destroy() + if (Date.now() >= deadline) { + rejectPromise(new Error(`Detached relay did not listen on ${socketPath}`)) + } else { + setTimeout(attempt, 50) + } + } + socket.once('connect', () => { + if (settled) { + return + } + settled = true + socket.destroy() + resolvePromise() + }) + socket.once('error', retry) + socket.setTimeout(250, retry) + } + attempt() + }) +} + +function encodeRequestFrame({ id, method, params }, sequence) { + const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, method, params })) + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = 1 + header.writeUInt32BE(sequence, 1) + header.writeUInt32BE(0, 5) + header.writeUInt32BE(payload.length, 9) + return Buffer.concat([header, payload]) +} + +function throwResponseError(message) { + if (message.error) { + throw new Error(`Relay RPC failed: ${message.error.message}`) + } +} + +function readNodeVersion(nodePath) { + return spawnSync(nodePath, ['--version'], { encoding: 'utf8' }).stdout.trim() +} + +function waitForExit(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs + return new Promise((resolvePromise) => { + const poll = () => { + if (!isProcessAlive(pid) || Date.now() >= deadline) { + resolvePromise() + return + } + setTimeout(poll, 50) + } + poll() + }) +} + +function isProcessAlive(pid) { + const result = runPowerShell( + `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'ALIVE' }` + ) + return result.stdout.includes('ALIVE') +} + +function stopExactProcess(pid, relayPath) { + const script = [ + `$process = Get-CimInstance Win32_Process -Filter ${powerShellLiteral(`ProcessId = ${pid}`)}`, + `if ($process -and $process.CommandLine -like ${powerShellLiteral(`*${relayPath}*`)}) { Stop-Process -Id ${pid} -Force }` + ].join('; ') + runPowerShell(script) +} + +function runPowerShell(script) { + const encoded = Buffer.from(script, 'utf16le').toString('base64') + const result = spawnSync( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], + { encoding: 'utf8' } + ) + if (result.status !== 0) { + throw new Error(`PowerShell failed (${result.status}): ${result.stderr || result.stdout}`) + } + return result +} + +function quoteWindowsArg(value) { + return `"${value.replaceAll('"', '\\"')}"` +} + +function powerShellLiteral(value) { + return `'${value.replaceAll("'", "''")}'` +} + +function readIfPresent(path) { + return existsSync(path) ? readFileSync(path, 'utf8') : '' +} + +function fileSha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 11020ab0206..e8a1f036c10 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -13,6 +13,7 @@ "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", + "../src/main/claude/statusline-script.ts", "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index a21f01fca97..37447a0883f 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 6.7m + + downloads: 7.0m @@ -15,7 +15,7 @@ downloads downloads - 6.7m - 6.7m + 7.0m + 7.0m diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index e6267e84f8a..316f0759ea0 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -15,7 +15,7 @@ Install the AppImage runtime dependency and Xvfb: ```bash sudo apt-get update -sudo apt-get install -y curl libfuse2 xvfb +sudo apt-get install -y curl file libfuse2 xvfb ``` Download and make the AppImage executable: @@ -56,11 +56,14 @@ The command prints the runtime endpoint and pairing URL. Stop it with `Ctrl+C`. ## Systemd Service Create a dedicated service user and install directory. Run the service as this -user instead of root so the AppImage can keep Chromium's sandbox enabled. +user instead of root so the AppImage can keep Chromium's sandbox enabled. Keep +the install directory root-owned: the service needs to read and execute the +AppImage, but must not be able to replace it or the rollback artifacts. ```bash sudo useradd --system --create-home --shell /usr/sbin/nologin orca -sudo chown -R orca:orca /opt/orca +sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage +sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage ``` For most hosts, one `orca serve` service is enough because Orca starts Xvfb on @@ -165,6 +168,436 @@ If you later install the desktop CLI from Orca settings, use that CLI for normal shell workflows. Keep the AppImage path in systemd so service restarts do not depend on an interactive shell profile. +## Upgrade + +`orca serve` never updates itself. In headless mode Orca wires up no auto-updater +at all — the built-in updater only runs in the desktop GUI, and no paired mobile +or web client can trigger it remotely. Upgrading is always a deliberate step: +replace the AppImage and restart the service. + +Two facts make this safe and predictable: + +- **State lives in the service user's home, not next to the binary.** Persisted + data is under `/home/orca/.config/` (Orca uses both an `orca` and an `Orca` + directory there), fully independent of `/opt/orca/orca-linux.AppImage`. + Replacing the binary never touches projects, worktree metadata, terminal + history, orchestration state, or paired-device keys — so mobile and web + clients reconnect after an upgrade without re-pairing. +- **New builds migrate old state on load.** Orca loads older `orca-data.json` + state into the current schema and writes it back in the current shape, so a + forward upgrade needs no manual data step. + +Rolling back is the case that needs care — see [Roll back](#roll-back). + +### Record the version you deploy + +Orca has no headless version command: there is no `--version` flag or `version` +subcommand, and `orca serve` prints only its endpoint. Choose a release tag +explicitly instead of following the `latest` URL, and record it next to the +binary so upgrades are auditable. The steps below keep that record in +`/opt/orca/VERSION`. + +### Upgrade steps + +Never download straight onto `/opt/orca/orca-linux.AppImage`. The AppImage is +FUSE-mounted, so overwriting it in place while the service runs can crash or +corrupt the live process — and even with the service stopped, a failed or partial +download would clobber the working binary. Instead download to a temporary name +on the same filesystem, verify it, then swap it in with an atomic rename. + +Check capacity before starting: + +```bash +sudo chown root:root /opt/orca +sudo chmod 755 /opt/orca +sudo test ! -L /opt/orca/orca-linux.AppImage +sudo chown root:root /opt/orca/orca-linux.AppImage +sudo chmod 755 /opt/orca/orca-linux.AppImage +# Clear predictable staging names left by an older attempt after locking the directory +sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \ + /opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering +sudo du -sh /home/orca/.config +df -h /opt/orca /home/orca +``` + +`/opt/orca` needs room for the compressed Orca profile archive, the staged +build, and the rollback binary. A rollback extracts the old profile and preserves +the post-upgrade Orca profile directories, so `/home` needs room for both copies. + +Run the following block as one Bash script so its fail-fast and recovery traps +remain active for the whole operation: + +```bash +set -euo pipefail + +# Replace this example with the release tag you intend to deploy +ORCA_VERSION=v1.4.147 + +# Select the release asset on the server where Orca runs +case "$(uname -m)" in + x86_64) + ORCA_ASSET=orca-linux.AppImage + ORCA_FILE_MACHINE=x86-64 + ;; + aarch64 | arm64) + ORCA_ASSET=orca-linux-arm64.AppImage + ORCA_FILE_MACHINE='ARM aarch64' + ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +ORCA_ROLLBACK_NEW= +ORCA_ROLLBACK= +ORCA_SERVICE_STOPPED=0 +ORCA_BINARY_PROMOTED=0 +recover_failed_upgrade() { + exit_status=$? + trap - EXIT + set +e + if ((exit_status != 0)); then + sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \ + /opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering + fi + if ((exit_status != 0)) && [[ -n "$ORCA_ROLLBACK_NEW" ]] && \ + sudo test -d "$ORCA_ROLLBACK_NEW"; then + sudo rm -rf -- "$ORCA_ROLLBACK_NEW" + fi + if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then + recovery_ok=1 + if ((ORCA_BINARY_PROMOTED)); then + if ! sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" \ + /opt/orca/orca-linux.AppImage.recovering || \ + ! sudo mv -f /opt/orca/orca-linux.AppImage.recovering \ + /opt/orca/orca-linux.AppImage; then + recovery_ok=0 + fi + if sudo test -f "$ORCA_ROLLBACK/VERSION"; then + if ! sudo cp -a "$ORCA_ROLLBACK/VERSION" /opt/orca/VERSION.recovering || \ + ! sudo mv -f /opt/orca/VERSION.recovering /opt/orca/VERSION; then + recovery_ok=0 + fi + elif ! sudo rm -f /opt/orca/VERSION; then + recovery_ok=0 + fi + fi + sudo rm -f /opt/orca/orca-linux.AppImage.recovering \ + /opt/orca/VERSION.recovering + if ((recovery_ok)); then + sudo systemctl start orca-serve.service || true + else + echo 'Upgrade recovery failed; service remains stopped' >&2 + fi + fi + exit "$exit_status" +} +trap recover_failed_upgrade EXIT + +# 1. Stage and verify the new build while the server stays online +sudo curl -fL --retry 3 "https://github.com/stablyai/orca/releases/download/${ORCA_VERSION}/${ORCA_ASSET}" \ + -o /opt/orca/orca-linux.AppImage.new +sudo chown root:root /opt/orca/orca-linux.AppImage.new +sudo chmod 755 /opt/orca/orca-linux.AppImage.new + +# Both checks must match; either grep stops this fail-fast block otherwise +ORCA_FILE_INFO=$(LC_ALL=C file /opt/orca/orca-linux.AppImage.new) +grep 'ELF .* executable' <<<"$ORCA_FILE_INFO" +grep -F "$ORCA_FILE_MACHINE" <<<"$ORCA_FILE_INFO" + +# 2. Assemble the prior binary and version in a root-only rollback bundle +ORCA_ROLLBACK_BASE=/opt/orca/orca-rollback-$(date +%F-%H%M%S-%N) +ORCA_ROLLBACK_NEW=${ORCA_ROLLBACK_BASE}.new +ORCA_ROLLBACK=${ORCA_ROLLBACK_BASE}.ready +sudo install -d -m 700 "$ORCA_ROLLBACK_NEW" +sudo cp -a /opt/orca/orca-linux.AppImage "$ORCA_ROLLBACK_NEW/orca-linux.AppImage" +if sudo test -f /opt/orca/VERSION; then + sudo cp -a /opt/orca/VERSION "$ORCA_ROLLBACK_NEW/VERSION" +fi + +# Stage the new version record before the stop window +printf '%s\n' "$ORCA_VERSION" | sudo tee /opt/orca/VERSION.new >/dev/null +sudo chown root:root /opt/orca/VERSION.new +sudo chmod 644 /opt/orca/VERSION.new + +# 3. Stop the server so the profile backup is consistent +ORCA_SERVICE_STOPPED=1 +sudo systemctl stop orca-serve.service + +# Add only Orca-owned profile directories, then publish the complete bundle +ORCA_PROFILE_DIRS=() +for profile_dir in orca Orca; do + if sudo test -L "/home/orca/.config/$profile_dir"; then + echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2 + exit 1 + fi + if sudo test -d "/home/orca/.config/$profile_dir"; then + if [[ "$profile_dir" == Orca ]] && \ + sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then + continue + fi + ORCA_PROFILE_DIRS+=("$profile_dir") + fi +done +if ((${#ORCA_PROFILE_DIRS[@]} == 0)); then + echo 'No Orca profile directory found under /home/orca/.config' >&2 + exit 1 +fi +sudo tar czf "$ORCA_ROLLBACK_NEW/profile.tgz" \ + -C /home/orca/.config "${ORCA_PROFILE_DIRS[@]}" +sudo chmod 600 "$ORCA_ROLLBACK_NEW/profile.tgz" +sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK" + +# 4. Atomically replace the binary and version record, then start +ORCA_BINARY_PROMOTED=1 +sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage +sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION +sudo systemctl start orca-serve.service +ORCA_SERVICE_STOPPED=0 +trap - EXIT +``` + +The profile archive created in step 3 captures both Orca profile directory names +when present without rewinding unrelated tools under `/home/orca/.config`. The +`.ready` suffix is published only after the prior binary, version record, and +profile archive are complete. If you run the managed Xvfb unit, only +`orca-serve.service` needs restarting — leave `orca-xvfb.service` running. + +### Verify + +```bash +sudo journalctl -u orca-serve.service -f +``` + +A healthy start prints `Orca server ready: ws://0.0.0.0:6768` (with your port). +Confirm a client reconnects before you discard the backup. The timestamped +rollback bundles are not pruned automatically. After the new version satisfies +your retention policy, select and inspect the newest complete bundle before +removing it: + +```bash +shopt -s nullglob +ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready) +((${#ORCA_ROLLBACK_SETS[@]} > 0)) +ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]} +printf 'Removing rollback bundle: %s\n' "$ORCA_ROLLBACK" +sudo test -d "$ORCA_ROLLBACK" +sudo rm -rf -- "$ORCA_ROLLBACK" +``` + +Each `.ready` directory is a self-contained rollback generation; never combine +files from different bundles. + +### Roll back + +A rollback is **not** binary-only safe. Once a newer build has started, it can +rewrite `orca-data.json` in the current schema. If an older build then writes +that file, it can discard fields it does not recognize. The rolling +`orca-data.json.bak.*` files are corruption-recovery snapshots, not a dedicated +pre-upgrade copy, and normal writes can rotate them away. To roll back cleanly, +restore the backup from step 3 **and** swap the binary back. Run this block as one +Bash script: + +```bash +set -euo pipefail + +# Select and validate one complete generation before taking the service offline +shopt -s nullglob +ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready) +((${#ORCA_ROLLBACK_SETS[@]} > 0)) +ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]} +sudo test -f "$ORCA_ROLLBACK/orca-linux.AppImage" +sudo tar tzf "$ORCA_ROLLBACK/profile.tgz" >/dev/null + +# Extract and validate the old profile while the current server stays online +sudo test ! -L /home +ORCA_HOME_OWNER=$(sudo stat -c %u /home) +ORCA_HOME_MODE=$(sudo stat -c %a /home) +if [[ "$ORCA_HOME_OWNER" != 0 ]] || ((8#$ORCA_HOME_MODE & 0022)) || \ + sudo -u orca test -w /home; then + echo 'Refusing rollback because /home is not root-controlled' >&2 + exit 1 +fi +ORCA_RESTORE=$(sudo mktemp -d /home/.orca-restore.XXXXXX) +ORCA_SERVICE_STOPPED=0 +ORCA_MOVED_CURRENT_DIRS=() +ORCA_INSTALLED_RESTORE_DIRS=() +ORCA_CURRENT_BINARY_MOVED=0 +ORCA_CURRENT_VERSION_MOVED=0 +ORCA_VERSION_REPLACEMENT_STARTED=0 +ORCA_POST_UPGRADE= +ORCA_ROLLBACK_BINARY_STAGED= +ORCA_ROLLBACK_VERSION_STAGED= +ORCA_ROLLBACK_HAS_VERSION=0 +restart_after_rollback_error() { + exit_status=$? + trap - EXIT + set +e + if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then + recovery_ok=1 + if ((${#ORCA_INSTALLED_RESTORE_DIRS[@]})); then + for profile_dir in "${ORCA_INSTALLED_RESTORE_DIRS[@]}"; do + if sudo test -d "/home/orca/.config/$profile_dir"; then + if ! sudo mv "/home/orca/.config/$profile_dir" \ + "$ORCA_RESTORE/$profile_dir.failed"; then + recovery_ok=0 + fi + fi + done + fi + if ((${#ORCA_MOVED_CURRENT_DIRS[@]})); then + for profile_dir in "${ORCA_MOVED_CURRENT_DIRS[@]}"; do + if sudo test -d "$ORCA_POST_UPGRADE/$profile_dir"; then + if ! sudo mv "$ORCA_POST_UPGRADE/$profile_dir" /home/orca/.config/; then + recovery_ok=0 + fi + elif ! sudo test -d "/home/orca/.config/$profile_dir"; then + recovery_ok=0 + fi + done + fi + if [[ -n "$ORCA_POST_UPGRADE" ]]; then + sudo rmdir "$ORCA_POST_UPGRADE" 2>/dev/null || true + fi + if ((ORCA_CURRENT_BINARY_MOVED)); then + if sudo test -f "$ORCA_CURRENT_BINARY"; then + if ! sudo mv -f "$ORCA_CURRENT_BINARY" /opt/orca/orca-linux.AppImage; then + recovery_ok=0 + fi + elif ! sudo test -f /opt/orca/orca-linux.AppImage; then + recovery_ok=0 + fi + fi + if ((ORCA_CURRENT_VERSION_MOVED)); then + if sudo test -f "$ORCA_CURRENT_VERSION"; then + if ! sudo mv -f "$ORCA_CURRENT_VERSION" /opt/orca/VERSION; then + recovery_ok=0 + fi + elif ! sudo test -f /opt/orca/VERSION; then + recovery_ok=0 + fi + elif ((ORCA_VERSION_REPLACEMENT_STARTED)); then + if ! sudo rm -f /opt/orca/VERSION; then + recovery_ok=0 + fi + fi + if ((recovery_ok)); then + sudo systemctl start orca-serve.service || true + else + echo 'Rollback recovery failed; service remains stopped' >&2 + fi + fi + if [[ -n "$ORCA_ROLLBACK_BINARY_STAGED" ]]; then + sudo rm -f -- "$ORCA_ROLLBACK_BINARY_STAGED" + fi + if [[ -n "$ORCA_ROLLBACK_VERSION_STAGED" ]]; then + sudo rm -f -- "$ORCA_ROLLBACK_VERSION_STAGED" + fi + sudo rm -rf -- "$ORCA_RESTORE" + exit "$exit_status" +} +trap restart_after_rollback_error EXIT + +if [[ "$(sudo stat -c %d "$ORCA_RESTORE")" != \ + "$(sudo stat -c %d /home/orca/.config)" ]]; then + echo 'Refusing rollback because staging and the Orca profile are on different filesystems' >&2 + exit 1 +fi +sudo tar xzf "$ORCA_ROLLBACK/profile.tgz" -C "$ORCA_RESTORE" +ORCA_RESTORE_DIRS=() +for profile_dir in orca Orca; do + if sudo test -L "$ORCA_RESTORE/$profile_dir"; then + echo "Rollback bundle contains a symlinked profile: $profile_dir" >&2 + exit 1 + fi + if sudo test -d "$ORCA_RESTORE/$profile_dir"; then + if [[ "$profile_dir" == Orca ]] && \ + sudo test "$ORCA_RESTORE/orca" -ef "$ORCA_RESTORE/Orca"; then + continue + fi + ORCA_RESTORE_DIRS+=("$profile_dir") + fi +done +if ((${#ORCA_RESTORE_DIRS[@]} == 0)); then + echo "Rollback bundle has no Orca profile directories: $ORCA_ROLLBACK" >&2 + exit 1 +fi +for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do + sudo chown -R orca:orca "$ORCA_RESTORE/$profile_dir" +done + +ORCA_ROLLBACK_STAMP=$(date +%F-%H%M%S-%N) +ORCA_ROLLBACK_BINARY_STAGED=/opt/orca/orca-linux.AppImage.rollback-staged-$ORCA_ROLLBACK_STAMP +sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" "$ORCA_ROLLBACK_BINARY_STAGED" +if sudo test -f "$ORCA_ROLLBACK/VERSION"; then + ORCA_ROLLBACK_HAS_VERSION=1 + ORCA_ROLLBACK_VERSION_STAGED=/opt/orca/VERSION.rollback-staged-$ORCA_ROLLBACK_STAMP + sudo cp -a "$ORCA_ROLLBACK/VERSION" "$ORCA_ROLLBACK_VERSION_STAGED" +fi + +ORCA_SERVICE_STOPPED=1 +sudo systemctl stop orca-serve.service + +# Preserve and replace only Orca-owned profile directories +ORCA_CURRENT_DIRS=() +for profile_dir in orca Orca; do + if sudo test -L "/home/orca/.config/$profile_dir"; then + echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2 + exit 1 + fi + if sudo test -d "/home/orca/.config/$profile_dir"; then + if [[ "$profile_dir" == Orca ]] && \ + sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then + continue + fi + ORCA_CURRENT_DIRS+=("$profile_dir") + fi +done +ORCA_POST_UPGRADE=/home/orca/.config/orca-rollback-$ORCA_ROLLBACK_STAMP +sudo install -d -o orca -g orca -m 700 "$ORCA_POST_UPGRADE" +if ((${#ORCA_CURRENT_DIRS[@]})); then + for profile_dir in "${ORCA_CURRENT_DIRS[@]}"; do + ORCA_MOVED_CURRENT_DIRS+=("$profile_dir") + sudo mv "/home/orca/.config/$profile_dir" "$ORCA_POST_UPGRADE/" + done +fi +for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do + ORCA_INSTALLED_RESTORE_DIRS+=("$profile_dir") + sudo mv "$ORCA_RESTORE/$profile_dir" /home/orca/.config/ +done + +ORCA_CURRENT_BINARY=/opt/orca/orca-linux.AppImage.rollback-current-$ORCA_ROLLBACK_STAMP +ORCA_CURRENT_BINARY_MOVED=1 +sudo mv /opt/orca/orca-linux.AppImage "$ORCA_CURRENT_BINARY" +sudo mv -f "$ORCA_ROLLBACK_BINARY_STAGED" /opt/orca/orca-linux.AppImage + +ORCA_CURRENT_VERSION=/opt/orca/VERSION.rollback-current-$ORCA_ROLLBACK_STAMP +if sudo test -f /opt/orca/VERSION; then + ORCA_CURRENT_VERSION_MOVED=1 + sudo mv /opt/orca/VERSION "$ORCA_CURRENT_VERSION" +fi +ORCA_VERSION_REPLACEMENT_STARTED=1 +if ((ORCA_ROLLBACK_HAS_VERSION)); then + sudo mv -f "$ORCA_ROLLBACK_VERSION_STAGED" /opt/orca/VERSION +else + sudo rm -f /opt/orca/VERSION +fi +sudo systemctl start orca-serve.service +ORCA_SERVICE_STOPPED=0 +sudo rm -rf -- "$ORCA_RESTORE" +trap - EXIT +``` + +Restoring the backup is required, not optional: swapping only the binary leaves +the newer `orca-data.json` in place, where an older build can discard state it +does not understand. Keep the pre-upgrade backup until the new version is proven +on your host. The `orca-rollback-*` directory inside `.config` is also retained +deliberately. The post-upgrade binary and version record are retained in +`/opt/orca` with the same `rollback-current-` suffix. Inspect these +artifacts and remove them according to your retention policy after the rollback +is resolved. + ## Troubleshooting - `dlopen(): error loading libfuse.so.2`: install `libfuse2`. @@ -178,6 +611,9 @@ depend on an interactive shell profile. `orca` user and that `/opt/orca` is readable by that user. - Clients cannot connect: make sure `--pairing-address` is an address reachable from the client, and make sure firewalls allow the selected `--port`. +- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with + the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would + make the crashing version the next rollback binary. - Diagnosing other missing libraries: extract the AppImage without launching it with `./orca-linux.AppImage --appimage-extract`, then run `ldd squashfs-root/orca` to list any shared libraries the host is missing. diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md index 1a0c9922a35..e7b13f0fb29 100644 --- a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md +++ b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md @@ -2,7 +2,26 @@ Date: 2026-07-19 -Status: Final decision; implementation pending +Status: Implemented with the 2026-07-20 ownership amendment below + +## 2026-07-20 Ownership Amendment + +Fresh paired-runtime evidence showed that the display/controller platform is not a safe proxy for +the PTY backend. A macOS client can attach to a native ConPTY owned by a paired Windows runtime, so +renderer-side local/SSH/remote heuristics can transfer OSC 10/11 authority to the wrong responder. + +The PTY-owning process now classifies the backend from its own platform and the shell that actually +won spawn: `windows-conpty`, `windows-wsl`, or `posix-pty`. Native ConPTY consumes complete OSC 10/11 +queries before model, replay, or view delivery. During the bounded startup window it replies once +when validated theme colors are available; after the deadline, or without colors, it consumes the +query without replying. A consuming-view handshake does not transfer this authority. Split query +candidates remain bounded and private across authority-close, expiry, and snapshot barriers; a +candidate that proves malformed is released unchanged. + +WSL and POSIX PTYs continue to transfer authority to the normal visible/hidden responder after the +startup window. The same owner-side rule applies to local, daemon, SSH-relay, and paired-runtime +PTYs. This amendment supersedes contrary transfer/fallback language below; the echo projection is +selected only by the authoritative owner backend, never by renderer or connection metadata. ## Problem @@ -31,13 +50,13 @@ the failure requires an additional agent, timing, input-mode, or replay conditio ## Root Cause -The startup OSC responder currently runs in Electron main only after runtime ingestion -([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public -data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while -daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out -data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A -renderer-only filter can therefore hide the symptom without removing it from the authoritative -model, daemon history, snapshots, or remote delivery. +OSC responders downstream of the PTY owner do not know whether the bytes came from native ConPTY, +WSL, or a POSIX PTY. `LocalPtyProvider` calls the runtime before its public data listeners +([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while daemon `Session` +advances sequence state, writes its emulator, persists pending output, and fans out data before +Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A renderer-only +filter can therefore misclassify paired runtimes and hide the symptom without removing it from the +authoritative model, daemon history, snapshots, or remote delivery. The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met @@ -228,14 +247,15 @@ When registered for an agent spawn, it: hidden model nor a delivered renderer can answer it a second time; 6. begins echo recognition as soon as each individual reply is written. -If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged -to normal query authority. A reattach never registers startup response state, matching current -behavior. +If attributes or a provider are unavailable, native ConPTY consumes the query without replying; +WSL and POSIX PTYs pass it through unchanged to normal query authority. A reattach never registers +startup response state, matching current behavior, but native ConPTY ownership still prevents a +downstream reply. ### Exact authority transfer -Startup query interception opens only for a fresh session whose atomic creation installed the -transaction before buffered output release. It closes at the first of: +Startup query response authority opens only for a fresh session whose atomic creation installed the +transaction before buffered output release. For WSL and POSIX PTYs it closes at the first of: - both OSC 10 and OSC 11 slots have been answered; - the startup deadline expires; @@ -243,7 +263,9 @@ transaction before buffered output release. It closes at the first of: hidden-runtime ownership mark is established; - the spawn fails, is cancelled, reattaches, or exits. -Closing query interception does not discard already-written reply candidates. Echo recognition has +Native ConPTY does not transfer OSC 10/11 authority at those boundaries: the deadline stops source +replies, while complete queries remain consumed for the life of the PTY. Closing response authority +does not discard already-written reply candidates. Echo recognition has its own bounded lifetime and may finish or drain after normal authority takes over. A close and a provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7733aa0e520..956349c70cb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -239,6 +239,19 @@ export default defineConfig({ plugins: [react(), tailwindcss()], worker: { format: 'es' + }, + build: { + // Why: the pop-out dashboard is a second top-level window with its own + // React root. It gets its own HTML entry so it can boot independently of + // the main window while reusing the same preload/window.api. `index` must + // stay listed — overriding input otherwise drops electron-vite's default + // renderer entry. + rollupOptions: { + input: { + index: resolve('src/renderer/index.html'), + popout: resolve('src/renderer/popout.html') + } + } } } }) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 126744a984e..5a6760508f2 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -26,7 +26,8 @@ import { Filter, Check, UserCircle, - PanelLeftClose + PanelLeftClose, + SquareTerminal } from 'lucide-react-native' import type { RpcClient } from '../../../src/transport/rpc-client' import { loadHosts, updateLastConnected } from '../../../src/transport/host-store' @@ -57,6 +58,7 @@ import type { RepoIcon } from '../../../../src/shared/repo-icon' import { PickerModal } from '../../../src/components/PickerModal' import { ActionSheetContent } from '../../../src/components/ActionSheetModal' import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions' +import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace' import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' @@ -68,7 +70,7 @@ import { setCachedRepos } from '../../../src/cache/repo-cache' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { useResponsiveLayout } from '../../../src/layout/responsive-layout' import { leaveHostRoute } from '../../../src/host-route-exit' -import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat' +import { useHostStatusGates } from '../../../src/transport/host-status-gates' import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences' import { createInitialHostRouteActionState, @@ -97,7 +99,7 @@ import { WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS, WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../../../src/worktree/workspace-list-picker-options' -import type { DesktopStatus, RepoSummary } from '../../../src/worktree/host-worktree-rpc-types' +import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types' import type { WorkspaceStatusDefinition } from '../../../../src/shared/types' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses' @@ -155,7 +157,6 @@ export function HostScreen({ const [repoIconsByName, setRepoIconsByName] = useState>(new Map()) const [hostName, setHostName] = useState('') const [error, setError] = useState('') - const [compatVerdict, setCompatVerdict] = useState({ kind: 'ok' }) const [lastKnownWorktrees, setLastKnownWorktrees] = useState(initialCache ?? []) const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) @@ -175,7 +176,11 @@ export function HostScreen({ const [showGroupPicker, setShowGroupPicker] = useState(false) const [showFilterModal, setShowFilterModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) - const [hostCapabilities, setHostCapabilities] = useState([]) + const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({ + hostId, + client, + connState + }) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) const [routeActionState, setRouteActionState] = useState(() => @@ -315,7 +320,6 @@ export function HostScreen({ useEffect(() => { setHostName('') setError('') - setCompatVerdict({ kind: 'ok' }) setRepoColorsByName(new Map()) setRepoIconsByName(new Map()) repoMetadataFetchedAtRef.current = 0 @@ -483,52 +487,6 @@ export function HostScreen({ [client, connState, hostId] ) - // Why: re-evaluate protocol compat on connect; today's constants are wide-open so this never blocks yet. - useEffect(() => { - if (connState !== 'connected' || !client) { - // Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host. - setHostCapabilities([]) - return - } - let cancelled = false - const requestClient = client - void (async () => { - try { - const response = await requestClient.sendRequest('status.get') - if (cancelled || clientRef.current !== requestClient) { - return - } - if (!response.ok) { - setHostCapabilities([]) - return - } - const status = (response as RpcSuccess).result as DesktopStatus & { - capabilities?: string[] - } - setHostCapabilities(status.capabilities ?? []) - const verdict = evaluateCompat({ - desktopProtocolVersion: status.protocolVersion, - desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion - }) - setCompatVerdict(verdict) - if (verdict.kind === 'blocked') { - // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. - console.warn('[protocol-compat] blocked', { - reason: verdict.reason, - desktopVersion: verdict.desktopVersion, - requiredMobileVersion: verdict.requiredMobileVersion, - requiredDesktopVersion: verdict.requiredDesktopVersion - }) - } - } catch { - // Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict. - } - })() - return () => { - cancelled = true - } - }, [connState, client]) - useFocusEffect( useCallback(() => { // Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff). @@ -674,8 +632,7 @@ export function HostScreen({ router.push(target) return } - const targetPath = target.split('?')[0] ?? target - if (pathname === targetPath) { + if (pathname === (target.split('?')[0] ?? target)) { return } if (pathname === `/h/${hostId}`) { @@ -691,11 +648,11 @@ export function HostScreen({ (item: Worktree) => { setOptimisticActiveWorktreeId(item.worktreeId) if (client && connState === 'connected') { - // Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree. void client .sendRequest('worktree.activate', { worktree: `id:${item.worktreeId}`, - notifyClients: false + notifyClients: false, + navigation: 'caller' }) .catch(() => null) } @@ -705,6 +662,12 @@ export function HostScreen({ [client, connState, hostId, navigateFromHostList] ) + const openFloatingWorkspace = useCallback(() => { + // Why: no worktree.activate here — the floating sentinel has no worktree + // record; session.tabs.list hydrates its host-owned tabs on open. + navigateFromHostList(floatingWorkspaceSessionPath(hostId)) + }, [hostId, navigateFromHostList]) + const handleSortChange = useCallback( (value: MobileSortMode) => { persistViewSettings({ sortMode: value }) @@ -870,6 +833,24 @@ export function HostScreen({ ) })()} + {!embedded && floatingWorkspaceEnabled ? ( + + + + ) : null} {embedded && onHideSidebar ? ( + {floatingWorkspaceEnabled ? ( + + + + ) : null} + () const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') // Synthetic ids have no repo scope. + // Why: the floating sentinel has no repo/worktree, so repo-backed surfaces hide. + const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId) const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). @@ -833,11 +830,13 @@ export default function SessionScreen() { const { isWideLayout } = useResponsiveLayout() const [activePanel, setActivePanel] = useState(null) const [sessionContentRowWidth, setSessionContentRowWidth] = useState(0) - const canDockPanel = canDockSessionPanel({ - isWideLayout, - availableWidth: sessionContentRowWidth, - dockWidth: HOST_DOCK_MIN_WIDTH - }) + const canDockPanel = + !isFloatingWorkspaceRoute && + canDockSessionPanel({ + isWideLayout, + availableWidth: sessionContentRowWidth, + dockWidth: HOST_DOCK_MIN_WIDTH + }) // Why: if rotation/split-screen makes the docked row too narrow, clear activePanel so it doesn't survive into overlay/push mode. useEffect(() => { if (!canDockPanel && activePanel !== null) { @@ -847,7 +846,9 @@ export default function SessionScreen() { // GitHub remote probe gates the PR dock icon so non-GitHub providers can't open the hosted-review surface; skip the unused identity RPCs. const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } = useMobilePrBranchContext({ - client, + // Why: a null client parks the hook in its not-ready state — the floating + // sentinel has no repo to probe. + client: isFloatingWorkspaceRoute ? null : client, connState, worktreeId, includeBranchIdentity: false @@ -1933,7 +1934,7 @@ export default function SessionScreen() { ) const loadDiffComments = useCallback(async (): Promise => { - if (!client || connState !== 'connected' || !worktreeId) { + if (!client || connState !== 'connected' || !worktreeId || isFloatingWorkspaceRoute) { setDiffComments([]) return } @@ -1947,7 +1948,7 @@ export default function SessionScreen() { worktree?: { diffComments?: unknown } } setDiffComments(normalizeMobileDiffComments(result.worktree?.diffComments, worktreeId)) - }, [client, connState, worktreeId]) + }, [client, connState, worktreeId, isFloatingWorkspaceRoute]) const persistDiffComments = useCallback( async (comments: readonly DiffComment[]): Promise => { @@ -2621,12 +2622,13 @@ export default function SessionScreen() { showToast('Open Orca on the host to wake sleeping agents.', 3000) } } - if (client && created !== '1') { + if (client && created !== '1' && !isFloatingWorkspaceRoute) { // Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree. void client .sendRequest('worktree.activate', { worktree: `id:${worktreeId}`, - notifyClients: false + notifyClients: false, + navigation: 'caller' }) .then((response) => reportActivationOutcome(response.ok ? response : null)) .catch(() => null) @@ -2644,7 +2646,7 @@ export default function SessionScreen() { } addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750) addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500) - if (client && created === '1') { + if (client && created === '1' && !isFloatingWorkspaceRoute) { addTimer(() => { if (activeHandleRef.current) { return @@ -2653,7 +2655,8 @@ export default function SessionScreen() { const activationResponse = await client .sendRequest('worktree.activate', { worktree: `id:${worktreeId}`, - notifyClients: false + notifyClients: false, + navigation: 'caller' }) .catch(() => null) reportActivationOutcome(activationResponse?.ok ? activationResponse : null) @@ -2672,7 +2675,16 @@ export default function SessionScreen() { clearTimeout(t) } } - }, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId]) + }, [ + client, + connState, + created, + fetchSessionTabs, + fetchTerminals, + isFloatingWorkspaceRoute, + showToast, + worktreeId + ]) useEffect(() => { if (!client || connState !== 'connected') { @@ -2767,7 +2779,7 @@ export default function SessionScreen() { }, []) ) - // Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one; terminal.focus makes desktop follow. + // Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one. const switchTab = useCallback( (handle: string) => { triggerSelection() @@ -2795,13 +2807,12 @@ export default function SessionScreen() { } subscribeToTerminal(handle) if (client) { - void focusMobileTerminal(client, handle).catch(() => {}) if (matchingTab) { - // Why: persist selection for headless hosts; snapshot gate stops this phone-local ack from impersonating desktop focus. void activateMobileSessionTab(client, { worktree: `id:${worktreeId}`, tabId: matchingTab.id, - notifyClients: false + notifyClients: false, + navigation: 'caller' }).catch(() => {}) } } @@ -2840,7 +2851,8 @@ export default function SessionScreen() { void activateMobileSessionTab(client, { worktree: `id:${worktreeId}`, tabId: tab.id, - notifyClients: false + notifyClients: false, + navigation: 'caller' }).catch(() => {}) } return @@ -2863,7 +2875,8 @@ export default function SessionScreen() { void activateMobileSessionTab(client, { worktree: `id:${worktreeId}`, tabId: tab.id, - notifyClients: false + notifyClients: false, + navigation: 'caller' }).catch(() => {}) } if (tab.type === 'browser') { @@ -3231,13 +3244,15 @@ export default function SessionScreen() { if (handle !== activeHandleRef.current) { return } - if (terminalLinkOpenMode === 'phone-browser') { + // Why: browser.tabCreate resolves a real worktree, which the floating + // sentinel doesn't have — open taps in the phone browser instead. + if (terminalLinkOpenMode === 'phone-browser' || isFloatingWorkspaceRoute) { void Linking.openURL(url).catch(() => {}) return } void handleCreateBrowserRef.current?.(url) }, - [terminalLinkOpenMode] + [terminalLinkOpenMode, isFloatingWorkspaceRoute] ) const toggleLiveInput = useCallback(() => { @@ -3575,7 +3590,8 @@ export default function SessionScreen() { }, []) const getActiveWorktreeConnectionId = useCallback(async (): Promise => { - if (!client) { + // Why: the floating workspace always runs on the paired host itself, never an SSH repo target. + if (!client || isFloatingWorkspaceRoute) { return null } const repoId = getRepoIdFromMobileWorktreeId(worktreeId) @@ -3586,7 +3602,7 @@ export default function SessionScreen() { const repos = ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? [] return repos.find((repo) => repo.id === repoId)?.connectionId?.trim() || null - }, [client, worktreeId]) + }, [client, isFloatingWorkspaceRoute, worktreeId]) const refreshCanPaste = useCallback(() => { void Promise.all([ @@ -3683,43 +3699,14 @@ export default function SessionScreen() { setCreateTabAgentOptions([]) void (async () => { - const [settingsResponse, repoResponse] = await Promise.all([ - client.sendRequest('settings.get'), - client.sendRequest('repo.list') - ]) - if (!settingsResponse.ok) { - throw new Error((settingsResponse as RpcFailure).error.message) - } - const settings = ( - (settingsResponse as RpcSuccess).result as { - settings?: MobileNewTabAgentSettings - } - ).settings - if (!repoResponse.ok) { - throw new Error((repoResponse as RpcFailure).error.message) - } - const repoId = getRepoIdFromMobileWorktreeId(worktreeId) - if (!repoId) { - throw new Error('worktree_repo_missing') - } - const repos = - ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? [] - const repo = repos.find((candidate) => candidate.id === repoId) - if (!repo) { - throw new Error('worktree_repo_not_found') - } - const connectionId = repo.connectionId?.trim() || null - const detectedResponse = connectionId - ? await client.sendRequest('preflight.detectRemoteAgents', { connectionId }) - : await client.sendRequest('preflight.detectAgents') - if (!detectedResponse.ok) { - throw new Error((detectedResponse as RpcFailure).error.message) - } + const options = await loadMobileNewTabAgentOptions({ + client, + worktreeId + }) if (stale) { return } - const detectedAgentIds = (detectedResponse as RpcSuccess).result as unknown[] - setCreateTabAgentOptions(buildMobileNewTabAgentOptions(settings, detectedAgentIds)) + setCreateTabAgentOptions(options) setCreateTabAgentLoadState('loaded') })().catch(() => { if (!stale) { @@ -3763,7 +3750,10 @@ export default function SessionScreen() { ? { startupCommandDelivery: options.startupCommandDelivery } : {}), ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}), - ...(agent ? { agent } : {}) + ...(agent ? { agent } : {}), + activate: false, + select: true, + navigation: 'caller' }) if (response.ok) { const result = (response as RpcSuccess).result as TerminalCreateResult @@ -4158,7 +4148,8 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, tabId: activePendingTerminalTab.id, leafId: activePendingTerminalTab.leafId, - notifyClients: false + notifyClients: false, + navigation: 'caller' }) .then((response) => { if (!response.ok) { @@ -4380,9 +4371,9 @@ export default function SessionScreen() { router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`) } const showAgentSessionHistoryAction = - !isFolderWorkspaceRoute && agentSessionHistorySupported === true + !isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && agentSessionHistorySupported === true const showChecksAction = shouldShowSessionHeaderChecksAction({ - isFolderWorkspaceRoute, + isFolderWorkspaceRoute: isFolderWorkspaceRoute || isFloatingWorkspaceRoute, repoContextLoaded: prRepoContextLoaded, hostedChecksSupported: prIsGithubRepo }) @@ -4423,13 +4414,15 @@ export default function SessionScreen() { - handlePanelTap('files')} - /> - {!isFolderWorkspaceRoute && ( + {!isFloatingWorkspaceRoute && ( + handlePanelTap('files')} + /> + )} + {!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && ( setShowQuickCommands(false)} client={client} - repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null} + repoId={ + isFolderWorkspaceRoute || isFloatingWorkspaceRoute + ? null + : getRepoIdFromMobileWorktreeId(worktreeId) || null + } repoName={worktreeName || null} onLaunch={launchQuickCommand} /> @@ -5093,26 +5090,33 @@ export default function SessionScreen() { void handleCreateTerminal() } }, - { - label: 'Browser', - icon: Globe, - onPress: () => { - setShowCreateTabDrawer(false) - if (browserScreencastSupported !== true) { - showToast('Desktop update required for mobile browser streaming', 1600) - return - } - setShowCreateBrowserModal(true) - } - }, - { - label: 'Markdown Note', - icon: FileText, - onPress: () => { - setShowCreateTabDrawer(false) - void handleCreateMarkdownNote() - } - } + // Why: browser/markdown creation resolve a real worktree on the host + // (browser.tabCreate, files.createFile); the floating sentinel is + // terminal-only over RPC, so those options hide there. + ...(isFloatingWorkspaceRoute + ? [] + : [ + { + label: 'Browser', + icon: Globe, + onPress: () => { + setShowCreateTabDrawer(false) + if (browserScreencastSupported !== true) { + showToast('Desktop update required for mobile browser streaming', 1600) + return + } + setShowCreateBrowserModal(true) + } + }, + { + label: 'Markdown Note', + icon: FileText, + onPress: () => { + setShowCreateTabDrawer(false) + void handleCreateMarkdownNote() + } + } + ]) ]} onClose={() => setShowCreateTabDrawer(false)} /> diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 191a47038f2..a9d24ae637a 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -77,6 +77,15 @@ import { findRepoForGitHubProjectRepository, type GitHubRepoSlugCacheEntry } from '../../../src/tasks/github-project-repo-match' +import { + parseGitHubProjectInput as parseProjectInput, + type GitHubProjectOwnerType, + type GitHubProjectPartialFailure, + type GitHubProjectRef, + type GitHubProjectSettings, + type GitHubProjectSummary, + type GitHubProjectViewSummary +} from '../../../src/tasks/github-project-reference' import { extractGitHubIssueSourceFallback, extractGitHubIssueSourceError, @@ -130,11 +139,16 @@ import { } from '../../../src/tasks/mobile-task-copy-feedback-timer' import type { BaseRefSearchResult, + GitHubOwnerRepo, PersistedTrustedOrcaHooks, SparsePreset, TuiAgent } from '../../../../src/shared/types' import type { SshConnectionState } from '../../../../src/shared/ssh-types' +import { + githubProjectHost, + githubProjectIdentityKey as githubProjectKey +} from '../../../../src/shared/github-project-identity' type RepoSummary = { id: string @@ -148,11 +162,6 @@ type RepoSummary = { type IssueSourcePreference = 'upstream' | 'origin' | 'auto' -type GitHubOwnerRepo = { - owner: string - repo: string -} - type GitHubWorkItem = { id: string type: 'issue' | 'pr' @@ -444,34 +453,6 @@ type LinearStatusResponse = { activeWorkspaceId?: string | null } -type GitHubProjectOwnerType = 'organization' | 'user' -type GitHubProjectRef = { - owner: string - ownerType: GitHubProjectOwnerType - number: number -} -type GitHubProjectSettings = { - pinned: GitHubProjectRef[] - recent: Array - lastViewByProject: Record - activeProject: GitHubProjectRef | null -} -type GitHubProjectSummary = GitHubProjectRef & { - id: string - title: string - url: string - source: string -} -type GitHubProjectPartialFailure = { - owner: string - message: string -} -type GitHubProjectViewSummary = { - id: string - number: number - name: string - layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' -} type GitHubIssueType = { id: string name: string @@ -951,48 +932,6 @@ function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHu : 'issues' } -function githubProjectKey(project: GitHubProjectRef): string { - return `${project.ownerType}:${project.owner}:${project.number}` -} - -function parseProjectInput( - input: string -): { owner: string; number: number; viewNumber?: number } | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed) - if (short) { - return { owner: short[1]!, number: Number(short[2]) } - } - try { - const url = new URL(trimmed) - if (url.hostname !== 'github.com') { - return null - } - const parts = url.pathname.split('/').filter(Boolean) - if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) { - const number = Number(parts[3]) - if (!Number.isInteger(number) || number < 1) { - return null - } - const viewNumber = - parts[4] === 'views' && parts[5] && Number.isInteger(Number(parts[5])) - ? Number(parts[5]) - : undefined - return { - owner: parts[1]!, - number, - ...(viewNumber && viewNumber > 0 ? { viewNumber } : {}) - } - } - } catch { - return null - } - return null -} - function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null { if (row.itemType === 'ISSUE') { return 'issue' @@ -1014,6 +953,11 @@ function splitRepositorySlug(slug: string | null): { owner: string; repo: string return owner && repo ? { owner, repo } : null } +function projectRowGitHubRepository(row: GitHubProjectRow, host: string): GitHubOwnerRepo | null { + const slug = splitRepositorySlug(row.content.repository) + return slug ? { ...slug, host } : null +} + const GITHUB_PROJECT_OPTION_COLORS: Record = { GRAY: '#8b949e', RED: '#f85149', @@ -2468,6 +2412,10 @@ export default function MobileTasksScreen() { tasksSupportState.kind === 'unsupported' && tasksSupportState.client === client const taskUiReady = tasksSupported && taskStateHydrated + const activeGitHubProject = githubProjectSettings.activeProject + const activeGitHubProjectHost = githubProjectHost( + githubProjectTable?.project.host ?? activeGitHubProject?.host + ) const hostedRepos = useMemo(() => repos.filter(isHostedTaskRepo), [repos]) const workspaceRepos = useMemo(() => repos.filter((repo) => repo.kind !== 'folder'), [repos]) const reposById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) @@ -2483,9 +2431,10 @@ export default function MobileTasksScreen() { findRepoForGitHubProjectRepository( row.content.repository, hostedRepos, - githubRepoSlugCache + githubRepoSlugCache, + activeGitHubProjectHost ) as RepoSummary | null, - [githubRepoSlugCache, hostedRepos] + [activeGitHubProjectHost, githubRepoSlugCache, hostedRepos] ) const githubProjectRepoSlugReady = useMemo( () => @@ -2501,10 +2450,11 @@ export default function MobileTasksScreen() { ? (filterGitHubProjectRowsForRepos( githubProjectTable.rows, hostedRepos, - githubRepoSlugCache + githubRepoSlugCache, + activeGitHubProjectHost ) as GitHubProjectRow[]) : [], - [githubProjectTable, githubRepoSlugCache, hostedRepos] + [activeGitHubProjectHost, githubProjectTable, githubRepoSlugCache, hostedRepos] ) const visibleGitHubProjectGroups = useMemo(() => { if (!githubProjectTable) { @@ -2599,10 +2549,10 @@ export default function MobileTasksScreen() { return { repoId: repo.id, path: repo.path, - slug: result ? `${result.owner}/${result.repo}` : null + repository: result } } catch { - return { repoId: repo.id, path: repo.path, slug: null } + return { repoId: repo.id, path: repo.path, repository: null } } }).then((entries) => { if (cancelled) { @@ -2611,7 +2561,7 @@ export default function MobileTasksScreen() { setGithubRepoSlugCache((current) => { const next = { ...current } for (const entry of entries) { - next[entry.repoId] = { path: entry.path, slug: entry.slug } + next[entry.repoId] = { path: entry.path, repository: entry.repository } } return next }) @@ -2630,7 +2580,6 @@ export default function MobileTasksScreen() { taskStateHydrated, tasksSupported ]) - const activeGitHubProject = githubProjectSettings.activeProject const activeGitHubProjectKey = activeGitHubProject ? githubProjectKey(activeGitHubProject) : null const activeGitHubProjectViewId = activeGitHubProjectKey ? githubProjectSettings.lastViewByProject[activeGitHubProjectKey]?.viewId @@ -3738,7 +3687,9 @@ export default function MobileTasksScreen() { } setGithubProjectError('') setGithubProjectPartialFailures([]) - const response = await client.sendRequest('github.project.listAccessible', {}) + const response = await client.sendRequest('github.project.listAccessible', { + host: 'github.com' + }) if (!isSuccess(response)) { throw new Error(response.error.message) } @@ -3763,6 +3714,7 @@ export default function MobileTasksScreen() { } const response = await client.sendRequest('github.project.listViews', { owner: project.owner, + host: githubProjectHost(project.host), ownerType: project.ownerType, projectNumber: project.number }) @@ -3800,6 +3752,7 @@ export default function MobileTasksScreen() { 'github.project.viewTable', { owner: activeGitHubProject.owner, + host: activeGitHubProjectHost, ownerType: activeGitHubProject.ownerType, projectNumber: activeGitHubProject.number, viewId: activeGitHubProjectViewId, @@ -3838,7 +3791,14 @@ export default function MobileTasksScreen() { setGithubProjectLoading(false) } }, - [activeGitHubProject, activeGitHubProjectViewId, client, connState, tasksSupported] + [ + activeGitHubProject, + activeGitHubProjectHost, + activeGitHubProjectViewId, + client, + connState, + tasksSupported + ] ) const commitGitHubProjectView = useCallback( @@ -3932,7 +3892,8 @@ export default function MobileTasksScreen() { return } const input = githubProjectPasteInput.trim() - if (!parseProjectInput(input)) { + const parsed = parseProjectInput(input) + if (!parsed) { setGithubProjectPasteError('Expected a project URL or owner/number.') return } @@ -3940,7 +3901,10 @@ export default function MobileTasksScreen() { setGithubProjectPasteError('') setGithubProjectError('') try { - const response = await client.sendRequest('github.project.resolveRef', { input }) + const response = await client.sendRequest('github.project.resolveRef', { + input, + host: githubProjectHost(parsed.host) + }) if (!isSuccess(response)) { throw new Error(response.error.message) } @@ -3951,6 +3915,7 @@ export default function MobileTasksScreen() { ownerType: GitHubProjectOwnerType number: number title: string + host?: string viewNumber?: number } | { ok: false; error: { message: string } } @@ -3964,7 +3929,8 @@ export default function MobileTasksScreen() { { owner: result.owner, ownerType: result.ownerType, - number: result.number + number: result.number, + host: githubProjectHost(result.host ?? parsed.host) }, { viewNumber: result.viewNumber } ) @@ -4536,6 +4502,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: projectRowItem.content.number, type }, @@ -4610,7 +4577,14 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, githubProjectTable, projectRowDetailRefreshSeq, projectRowItem, tasksSupported]) + }, [ + activeGitHubProjectHost, + client, + githubProjectTable, + projectRowDetailRefreshSeq, + projectRowItem, + tasksSupported + ]) useEffect(() => { const slug = splitRepositorySlug(projectMetadataRepository) @@ -4628,7 +4602,7 @@ export default function MobileTasksScreen() { void client .sendRequest( 'github.project.listLabelsBySlug', - { owner: slug.owner, repo: slug.repo }, + { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, { timeoutMs: 30_000 } ) .then((response) => { @@ -4660,7 +4634,7 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectMetadataRepository, tasksSupported]) + }, [activeGitHubProjectHost, client, projectMetadataRepository, tasksSupported]) useEffect(() => { const slug = splitRepositorySlug(projectMetadataRepository) @@ -4681,6 +4655,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, ...(projectMetadataSeedLogins ? { seedLogins: projectMetadataSeedLogins.split(',') } : {}) }, { timeoutMs: 30_000 } @@ -4716,7 +4691,13 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectMetadataRepository, projectMetadataSeedLogins, tasksSupported]) + }, [ + activeGitHubProjectHost, + client, + projectMetadataRepository, + projectMetadataSeedLogins, + tasksSupported + ]) useEffect(() => { const slug = splitRepositorySlug(projectIssueTypeRepository) @@ -4734,7 +4715,7 @@ export default function MobileTasksScreen() { void client .sendRequest( 'github.project.listIssueTypesBySlug', - { owner: slug.owner, repo: slug.repo }, + { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, { timeoutMs: 30_000 } ) .then((response) => { @@ -4768,7 +4749,7 @@ export default function MobileTasksScreen() { return () => { stale = true } - }, [client, projectIssueTypeRepository, tasksSupported]) + }, [activeGitHubProjectHost, client, projectIssueTypeRepository, tasksSupported]) const getWorkspaceTargetRepo = useCallback( (item: ActionableTaskItem, repoIdOverride?: string): RepoSummary | null => { @@ -5729,6 +5710,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, updates }, @@ -5790,7 +5772,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const addProjectRowComment = useCallback( @@ -5810,6 +5792,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, body }, @@ -5838,7 +5821,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectCommentDraft, projectMutating] + [activeGitHubProjectHost, client, projectCommentDraft, projectMutating] ) const updateProjectRowComment = useCallback( @@ -5861,6 +5844,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, commentId, body }, @@ -5898,7 +5882,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectEditingCommentDraft, projectMutating] + [activeGitHubProjectHost, client, projectEditingCommentDraft, projectMutating] ) const deleteProjectRowComment = useCallback( @@ -5920,6 +5904,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, commentId }, { timeoutMs: 30_000 } @@ -5956,7 +5941,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectEditingCommentId, projectMutating] + [activeGitHubProjectHost, client, projectEditingCommentId, projectMutating] ) const toggleProjectGitHubReviewThread = useCallback( @@ -5979,6 +5964,7 @@ export default function MobileTasksScreen() { 'github.resolveReviewThread', { repo: `id:${repo.id}`, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), threadId: comment.threadId, resolve }, @@ -6010,7 +5996,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating] ) const replyToProjectGitHubComment = useCallback( @@ -6038,6 +6024,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), commentId: comment.id, body, threadId: comment.threadId, @@ -6051,6 +6038,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, number: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), body: `@${commentAuthor(comment)} ${body}`, type: projectRowType(row) ?? 'issue' }, @@ -6092,7 +6080,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, itemReplyDrafts, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, itemReplyDrafts, projectMutating] ) const mutateProjectRowMetadata = useCallback( @@ -6120,6 +6108,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, updates }, @@ -6204,7 +6193,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const mutateProjectRowField = useCallback( @@ -6223,11 +6212,13 @@ export default function MobileTasksScreen() { value === null ? { projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, itemId: row.id, fieldId: field.id } : { projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, itemId: row.id, fieldId: field.id, value @@ -6274,7 +6265,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, githubProjectTable, projectMutating] + [activeGitHubProjectHost, client, githubProjectTable, projectMutating] ) const mutateProjectRowIssueType = useCallback( @@ -6294,6 +6285,7 @@ export default function MobileTasksScreen() { { owner: slug.owner, repo: slug.repo, + host: activeGitHubProjectHost, number: row.content.number, issueTypeId: issueType?.id ?? null }, @@ -6329,7 +6321,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, projectMutating] + [activeGitHubProjectHost, client, projectMutating] ) const requestProjectGitHubReviewers = useCallback( @@ -6350,6 +6342,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), reviewers }, { timeoutMs: 30_000 } @@ -6397,7 +6390,14 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectReviewersDraft, projectRowDetail] + [ + activeGitHubProjectHost, + client, + findProjectRowRepo, + projectMutating, + projectReviewersDraft, + projectRowDetail + ] ) const refreshProjectGitHubChecks = useCallback( @@ -6420,6 +6420,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, noCache: true }, @@ -6441,7 +6442,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const rerunProjectGitHubChecks = useCallback( @@ -6464,6 +6465,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, failedOnly }, @@ -6483,7 +6485,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const toggleProjectGitHubFileViewed = useCallback( @@ -6504,6 +6506,7 @@ export default function MobileTasksScreen() { 'github.setPRFileViewed', { repo: `id:${repo.id}`, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), pullRequestId: projectRowDetail.pullRequestId, path: file.path, viewed @@ -6536,7 +6539,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating, projectRowDetail] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] ) const toggleProjectGitHubFileExpansion = useCallback( @@ -6570,6 +6573,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), path: file.path, oldPath: file.oldPath, status: file.status ?? 'modified', @@ -6593,7 +6597,14 @@ export default function MobileTasksScreen() { setPrFileLoadingPath(null) } }, - [client, expandedPrFilePath, findProjectRowRepo, prFileContents, projectRowDetail] + [ + activeGitHubProjectHost, + client, + expandedPrFilePath, + findProjectRowRepo, + prFileContents, + projectRowDetail + ] ) const addProjectGitHubFileReviewComment = useCallback( @@ -6625,6 +6636,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), commitId: projectRowDetail.headSha, path: file.path, line, @@ -6669,7 +6681,14 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, prFileCommentDrafts, projectMutating, projectRowDetail] + [ + activeGitHubProjectHost, + client, + findProjectRowRepo, + prFileCommentDrafts, + projectMutating, + projectRowDetail + ] ) const mergeProjectGitHubPullRequest = useCallback( @@ -6695,6 +6714,7 @@ export default function MobileTasksScreen() { { repo: `id:${repo.id}`, prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), method }, { timeoutMs: 60_000 } @@ -6731,7 +6751,7 @@ export default function MobileTasksScreen() { setProjectMutating(false) } }, - [client, findProjectRowRepo, projectMutating] + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating] ) const toggleGitHubStatus = useCallback( diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx new file mode 100644 index 00000000000..0cfdd822471 --- /dev/null +++ b/mobile/app/notification-opt-in.tsx @@ -0,0 +1,12 @@ +import { Redirect, useLocalSearchParams } from 'expo-router' +import { + legacyNotificationOptInDestination, + type LegacyNotificationOptInParams +} from '../src/onboarding/legacy-notification-opt-in-destination' + +export default function NotificationOptInRedirect() { + const params = useLocalSearchParams() + + // Why: restored stacks may contain this retired route; replacement keeps Back from reopening it. + return +} diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 8879b783687..add1892aee2 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -10,6 +10,7 @@ import type { RpcClient } from '../transport/rpc-client' import { getWorktreeLabel } from '../session/worktree-label' import { buildMobileAiVaultResumeLaunch, + prepareMobileAiVaultSessionResume, createMobileAiVaultResumeMutationRegistry, readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, @@ -197,8 +198,9 @@ export function MobileAgentSessionHistoryPanel({ return } + const preparedSession = await prepareMobileAiVaultSessionResume(client, session) const launch = buildMobileAiVaultResumeLaunch({ - session, + session: preparedSession, hostPlatform: platform, hostTerminalWindowsShell, settings diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index 4f5d186c7e4..ce1ef33cb96 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -10,7 +10,7 @@ import { PRCommentComposer } from './PRCommentComposer' import { formatPrCommentRelativeTime } from './pr-comment-time' import { prCommentsStyles as styles } from './pr-comments-styles' -export type PRCommentRepoSlug = { owner: string; repo: string } +export type PRCommentRepoSlug = { owner: string; repo: string; host?: string } // Action handlers are passed from the comment actions hook (stable callbacks), so // adding them keeps the memo'd card from re-rendering on unrelated timeline changes. diff --git a/mobile/src/onboarding/legacy-notification-opt-in-destination.ts b/mobile/src/onboarding/legacy-notification-opt-in-destination.ts new file mode 100644 index 00000000000..d3f1a2c33ef --- /dev/null +++ b/mobile/src/onboarding/legacy-notification-opt-in-destination.ts @@ -0,0 +1,19 @@ +export type LegacyNotificationOptInParams = { + hostId?: string | string[] +} + +export type LegacyNotificationOptInDestination = { + pathname: '/mobile-onboarding' + params: { steps: 'notifications'; hostId?: string } +} + +/** Maps restored navigation and old deep links onto the canonical notifications step. */ +export function legacyNotificationOptInDestination( + params: LegacyNotificationOptInParams +): LegacyNotificationOptInDestination { + const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId + return { + pathname: '/mobile-onboarding', + params: { steps: 'notifications', ...(hostId ? { hostId } : {}) } + } +} diff --git a/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts b/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts new file mode 100644 index 00000000000..c7efaaefd86 --- /dev/null +++ b/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import NotificationOptInRedirect from '../../app/notification-opt-in' +import { legacyNotificationOptInDestination } from './legacy-notification-opt-in-destination' + +const mocks = vi.hoisted(() => ({ + params: {} as Record, + ensureNotificationPermissions: vi.fn() +})) + +vi.mock('expo-router', () => ({ + Redirect: 'Redirect', + useLocalSearchParams: () => mocks.params +})) + +vi.mock('../notifications/mobile-notifications', () => ({ + ensureNotificationPermissions: mocks.ensureNotificationPermissions +})) + +describe('legacy notification opt-in destination', () => { + it('selects only notifications and preserves a scalar hostId', () => { + expect(legacyNotificationOptInDestination({ hostId: 'paired-host' })).toEqual({ + pathname: '/mobile-onboarding', + params: { steps: 'notifications', hostId: 'paired-host' } + }) + }) + + it.each([ + [['first-host', 'second-host'], 'first-host'], + [[], undefined], + [undefined, undefined] + ] as const)('normalizes hostId %j to %s', (hostId, expectedHostId) => { + expect(legacyNotificationOptInDestination({ hostId })).toEqual({ + pathname: '/mobile-onboarding', + params: { + steps: 'notifications', + ...(expectedHostId ? { hostId: expectedHostId } : {}) + } + }) + }) + + it('ignores unrelated legacy parameters', () => { + const legacyParams = { + hostId: 'paired-host', + steps: 'session-view', + returnTo: '/settings' + } + + expect(legacyNotificationOptInDestination(legacyParams)).toEqual({ + pathname: '/mobile-onboarding', + params: { steps: 'notifications', hostId: 'paired-host' } + }) + }) +}) + +describe('/notification-opt-in compatibility route', () => { + it('is present and synchronously returns a replace-style redirect to the canonical route', () => { + mocks.params = { + hostId: ['paired-host', 'ignored-host'], + steps: 'session-view', + unrelated: 'ignored' + } + + expect(NotificationOptInRedirect()).toMatchObject({ + type: 'Redirect', + props: { + href: { + pathname: '/mobile-onboarding', + params: { steps: 'notifications', hostId: 'paired-host' } + } + } + }) + expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index fa096be4a03..36d39ceb5ec 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -198,7 +198,10 @@ describe('resumeAiVaultSessionInTerminal', () => { agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } }, launchAgent: 'claude', - clientMutationId: 'resume-1' + clientMutationId: 'resume-1', + activate: false, + select: true, + navigation: 'caller' }) ).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' }) expect(sendRequest).toHaveBeenNthCalledWith( @@ -214,7 +217,10 @@ describe('resumeAiVaultSessionInTerminal', () => { agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } }, launchAgent: 'claude', - clientMutationId: 'resume-1' + clientMutationId: 'resume-1', + activate: false, + select: true, + navigation: 'caller' }, // Why: a socket drop mid-resume must reject within the request timeout // instead of parking on the reconnect waiter with the spinner pinned. diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index 2ae7463da5a..dce63378656 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -4,6 +4,10 @@ import { buildAiVaultResumeShellCommand, realHomeCodexResumeEnvDeletion } from '../../../src/shared/ai-vault-types' +import { + isAiVaultPrepareSessionResumeUnavailableError, + isLegacySharedCodexHome +} from '../../../src/shared/ai-vault-resume-preparation' import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume' import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume' import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup' @@ -151,6 +155,39 @@ function normalizeMobileAiVaultResumeCommandOverrides( // on the reconnect waiter for the full reconnect budget, pinning the spinner. export const RESUME_RPC_TIMEOUT_MS = 30_000 +export async function prepareMobileAiVaultSessionResume( + client: Pick, + session: AiVaultSession +): Promise { + if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) { + return session + } + const response = await client.sendRequest( + 'aiVault.prepareSessionResume', + { + agent: session.agent, + filePath: session.filePath, + codexHome: session.codexHome, + executionHostId: session.executionHostId + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + if (!response.ok) { + if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) { + // Why: older hosts cannot prepare, but their shared home still supports the legacy resume path. + return session + } + throw new Error( + response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.' + ) + } + const result = response.result as { useRealCodexHome?: unknown } | null + if (result?.useRealCodexHome !== true) { + return session + } + return { ...session, codexHome: null } +} + export async function resumeAiVaultSessionInTerminal( client: Pick, worktreeId: string, @@ -164,7 +201,10 @@ export async function resumeAiVaultSessionInTerminal( ...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}), ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}), - ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}) + ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}), + activate: false, + select: true, + navigation: 'caller' }, { timeoutMs: RESUME_RPC_TIMEOUT_MS } ) diff --git a/mobile/src/session/ai-vault-resume-preparation.test.ts b/mobile/src/session/ai-vault-resume-preparation.test.ts new file mode 100644 index 00000000000..12c75ab55b3 --- /dev/null +++ b/mobile/src/session/ai-vault-resume-preparation.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildMobileAiVaultResumeLaunch, + prepareMobileAiVaultSessionResume, + resumeAiVaultSessionInTerminal, + RESUME_RPC_TIMEOUT_MS +} from './ai-vault-resume-launch' + +const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + +function legacySession(overrides: Partial = {}): AiVaultSession { + return { + id: 'codex:legacy-1', + executionHostId: 'local', + agent: 'codex', + sessionId: 'legacy-1', + title: 'Resume me', + cwd: '/Users/ada/repo', + branch: 'main', + model: null, + filePath: `${LEGACY_CODEX_HOME}/sessions/2026/07/20/rollout-a.jsonl`, + codexHome: LEGACY_CODEX_HOME, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-07-20T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +describe('prepareMobileAiVaultSessionResume', () => { + it.each([ + { + code: 'forbidden', + message: "Method 'aiVault.prepareSessionResume' is not available to mobile clients" + }, + { + code: 'method_not_found', + message: 'Unknown method: aiVault.prepareSessionResume' + } + ])('uses the legacy command and environment on old-host $code', async (error) => { + const legacy = legacySession() + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ ok: false, error }) + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } }) + + const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy) + const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) + await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch) + + expect(prepared).toBe(legacy) + expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete') + expect(sendRequest).toHaveBeenNthCalledWith( + 3, + 'terminal.send', + { + terminal: 'pty-1', + text: `cd '/Users/ada/repo' && CODEX_HOME='${LEGACY_CODEX_HOME}' codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'legacy-1'`, + enter: true + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + }) + + it('uses the real Codex home when a supported desktop requests it', async () => { + const legacy = legacySession() + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { useRealCodexHome: true } + }) + + const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy) + const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) + + expect(sendRequest).toHaveBeenCalledWith( + 'aiVault.prepareSessionResume', + { + agent: 'codex', + filePath: legacy.filePath, + codexHome: LEGACY_CODEX_HOME, + executionHostId: 'local' + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + expect(prepared).toEqual({ ...legacy, codexHome: null }) + expect(launch.command).toBe( + "cd '/Users/ada/repo' && codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'legacy-1'" + ) + expect(launch.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME']) + }) + + it('preserves the legacy resume path when a supported desktop declines real-home use', async () => { + const legacy = legacySession() + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { useRealCodexHome: false } + }) + + const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy) + const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) + + expect(prepared).toBe(legacy) + expect(launch.command).toContain(`CODEX_HOME='${LEGACY_CODEX_HOME}'`) + expect(launch.envToDelete).toBeUndefined() + }) + + it.each([ + { agent: 'claude' as const, codexHome: null }, + { agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' }, + { + agent: 'codex' as const, + codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home' + }, + { agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' } + ])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => { + const current = legacySession(overrides) + const sendRequest = vi.fn() + + await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it.each([ + { + code: 'internal_error', + message: 'Retry resume after checking session folder permissions.' + }, + { + code: 'forbidden', + message: 'Session resume is forbidden' + } + ])('keeps genuine server error visible: $code', async (error) => { + const sendRequest = vi.fn().mockResolvedValue({ ok: false, error }) + + await expect( + prepareMobileAiVaultSessionResume({ sendRequest }, legacySession()) + ).rejects.toThrow(error.message) + }) + + it.each(['Request timed out: aiVault.prepareSessionResume', 'Connection interrupted'])( + 'keeps transport failure visible: %s', + async (message) => { + const failure = new Error(message) + const sendRequest = vi.fn().mockRejectedValue(failure) + + await expect( + prepareMobileAiVaultSessionResume({ sendRequest }, legacySession()) + ).rejects.toBe(failure) + } + ) +}) diff --git a/mobile/src/session/floating-workspace.test.ts b/mobile/src/session/floating-workspace.test.ts new file mode 100644 index 00000000000..8f29646b49b --- /dev/null +++ b/mobile/src/session/floating-workspace.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + FLOATING_WORKSPACE_WORKTREE_ID, + floatingWorkspaceSessionPath, + isFloatingWorkspaceWorktreeId +} from './floating-workspace' + +describe('floating workspace routing', () => { + it('matches only the desktop sentinel id', () => { + expect(isFloatingWorkspaceWorktreeId('global-floating-terminal')).toBe(true) + expect(isFloatingWorkspaceWorktreeId('repo-1::/worktree')).toBe(false) + expect(isFloatingWorkspaceWorktreeId('folder:group-1')).toBe(false) + expect(isFloatingWorkspaceWorktreeId(undefined)).toBe(false) + expect(isFloatingWorkspaceWorktreeId(null)).toBe(false) + }) + + it('builds the session route with an explicit title seed', () => { + expect(floatingWorkspaceSessionPath('host-1')).toBe( + `/h/host-1/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=Floating%20Workspace` + ) + }) +}) diff --git a/mobile/src/session/floating-workspace.ts b/mobile/src/session/floating-workspace.ts new file mode 100644 index 00000000000..acbc805ea33 --- /dev/null +++ b/mobile/src/session/floating-workspace.ts @@ -0,0 +1,15 @@ +// Mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts — the desktop +// Floating Workspace's synthetic id (no backing repo/worktree; always local runtime). +export const FLOATING_WORKSPACE_WORKTREE_ID = 'global-floating-terminal' + +export const FLOATING_WORKSPACE_TITLE = 'Floating Workspace' + +export function isFloatingWorkspaceWorktreeId(worktreeId: string | null | undefined): boolean { + return worktreeId === FLOATING_WORKSPACE_WORKTREE_ID +} + +// Route target for the host-header entry; the ?name param seeds the session +// screen title before tabs load. +export function floatingWorkspaceSessionPath(hostId: string | undefined): string { + return `/h/${hostId}/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=${encodeURIComponent(FLOATING_WORKSPACE_TITLE)}` +} diff --git a/mobile/src/session/github-pr-mutations.test.ts b/mobile/src/session/github-pr-mutations.test.ts index d7d722ea966..52a6ef824ff 100644 --- a/mobile/src/session/github-pr-mutations.test.ts +++ b/mobile/src/session/github-pr-mutations.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '../transport/types' import { + fetchAddIssueComment, + fetchAddPRReviewCommentReply, fetchDeleteIssueComment, fetchMergePR, fetchResolveReviewThread, @@ -29,6 +31,7 @@ function clientRejecting(message: string) { } const WORKTREE_ID = 'repo-42::/path/to/wt' +const ENTERPRISE_PR_REPO = { owner: 'o', repo: 'r', host: 'github.acme.test' } describe('fetchResolveReviewThread / fetchUpdatePRTitle — bare-boolean host result', () => { it('treats an explicit true as success', async () => { @@ -102,12 +105,51 @@ describe('mutation transport rejection normalization', () => { }) }) +describe('Enterprise PR repo forwarding', () => { + it('forwards the host through title, root-comment, and review-reply RPCs', async () => { + const titleClient = clientReturning(okResponse(true)) + await fetchUpdatePRTitle(titleClient, WORKTREE_ID, { + prNumber: 7, + title: 'New title', + prRepo: ENTERPRISE_PR_REPO + }) + expect(titleClient.sendRequest).toHaveBeenCalledWith( + 'github.updatePRTitle', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + + const rootClient = clientReturning(okResponse({ ok: true })) + await fetchAddIssueComment(rootClient, WORKTREE_ID, { + prNumber: 7, + body: 'Root comment', + prRepo: ENTERPRISE_PR_REPO + }) + expect(rootClient.sendRequest).toHaveBeenCalledWith( + 'github.addIssueComment', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + + const replyClient = clientReturning(okResponse({ ok: true })) + await fetchAddPRReviewCommentReply(replyClient, WORKTREE_ID, { + prNumber: 7, + commentId: 42, + body: 'Reply', + prRepo: ENTERPRISE_PR_REPO + }) + expect(replyClient.sendRequest).toHaveBeenCalledWith( + 'github.addPRReviewCommentReply', + expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO }) + ) + }) +}) + describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed envelope', () => { it('sends owner/repo/commentId(+body) and reads the { ok } envelope', async () => { const editClient = clientReturning(okResponse({ ok: true })) const edit = await fetchUpdateIssueComment(editClient, { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5, body: 'edited' }) @@ -115,16 +157,23 @@ describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed e expect(editClient.sendRequest).toHaveBeenCalledWith('github.project.updateIssueCommentBySlug', { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5, body: 'edited' }) const delClient = clientReturning(okResponse({ ok: true })) - const del = await fetchDeleteIssueComment(delClient, { owner: 'o', repo: 'r', commentId: 5 }) + const del = await fetchDeleteIssueComment(delClient, { + owner: 'o', + repo: 'r', + host: 'github.acme.test', + commentId: 5 + }) expect(del).toEqual({ ok: true }) expect(delClient.sendRequest).toHaveBeenCalledWith('github.project.deleteIssueCommentBySlug', { owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 5 }) }) diff --git a/mobile/src/session/github-pr-mutations.ts b/mobile/src/session/github-pr-mutations.ts index 7d79518f089..d9fa04ce06a 100644 --- a/mobile/src/session/github-pr-mutations.ts +++ b/mobile/src/session/github-pr-mutations.ts @@ -1,6 +1,6 @@ import type { GitHubPRMergeMethod } from '../../../src/shared/types' import type { RpcClient } from '../transport/rpc-client' -import { buildGithubPrParams, type GitHubPrRepoSlug } from './github-pr-rpc' +import { buildGithubPrParams, githubPrRepoSlugParam, type GitHubPrRepoSlug } from './github-pr-rpc' // Mutation wrappers for the github.* PR surface, split out so github-pr-rpc.ts // stays under the max-lines budget. They mirror the read wrappers' shape but @@ -101,16 +101,10 @@ export async function fetchUpdatePRTitle( args: { prNumber: number; title: string; prRepo?: GitHubPrRepoSlug | null } ): Promise { const params: Record = { prNumber: args.prNumber, title: args.title } - // updatePRTitle accepts prRepo for fork PRs, but it is not in the centralized - // METHODS_ACCEPTING_PR_REPO read allow-list — pass it explicitly so it reaches the - // host schema (which declares it optional/nullable). - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } const response = await sendRaw( client, 'github.updatePRTitle', - buildGithubPrParams('github.updatePRTitle', worktreeId, params) + buildGithubPrParams('github.updatePRTitle', worktreeId, params, { prRepo: args.prRepo }) ) if (!response.ok) { return { ok: false, error: response.error || 'Request failed: github.updatePRTitle' } @@ -147,48 +141,51 @@ export async function fetchSetPRAutoMerge( export async function fetchUpdatePRState( client: Pick, worktreeId: string, - args: { prNumber: number; state: 'open' | 'closed' } + args: { prNumber: number; state: 'open' | 'closed'; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // updatePRState does NOT accept prRepo (KTD3) — buildGithubPrParams omits it. return sendGithubPrMutation( client, 'github.updatePRState', - buildGithubPrParams('github.updatePRState', worktreeId, { - prNumber: args.prNumber, - updates: { state: args.state } - }) + buildGithubPrParams( + 'github.updatePRState', + worktreeId, + { prNumber: args.prNumber, updates: { state: args.state } }, + { prRepo: args.prRepo } + ) ) } export async function fetchRequestPRReviewers( client: Pick, worktreeId: string, - args: { prNumber: number; reviewers: string[] } + args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // requestPRReviewers does NOT accept prRepo (KTD3). return sendGithubPrMutation( client, 'github.requestPRReviewers', - buildGithubPrParams('github.requestPRReviewers', worktreeId, { - prNumber: args.prNumber, - reviewers: args.reviewers - }) + buildGithubPrParams( + 'github.requestPRReviewers', + worktreeId, + { prNumber: args.prNumber, reviewers: args.reviewers }, + { prRepo: args.prRepo } + ) ) } export async function fetchRemovePRReviewers( client: Pick, worktreeId: string, - args: { prNumber: number; reviewers: string[] } + args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null } ): Promise { - // removePRReviewers does NOT accept prRepo (KTD3). return sendGithubPrMutation( client, 'github.removePRReviewers', - buildGithubPrParams('github.removePRReviewers', worktreeId, { - prNumber: args.prNumber, - reviewers: args.reviewers - }) + buildGithubPrParams( + 'github.removePRReviewers', + worktreeId, + { prNumber: args.prNumber, reviewers: args.reviewers }, + { prRepo: args.prRepo } + ) ) } @@ -222,16 +219,12 @@ export async function fetchAddPRReviewCommentReply( if (typeof args.line === 'number') { params.line = args.line } - // addPRReviewCommentReply accepts prRepo for fork PRs, but it is not in the - // centralized METHODS_ACCEPTING_PR_REPO allow-list (read-focused) — pass it - // explicitly so it reaches the host schema, which declares it optional. - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } return sendGithubPrMutation( client, 'github.addPRReviewCommentReply', - buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params) + buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params, { + prRepo: args.prRepo + }) ) } @@ -246,13 +239,10 @@ export async function fetchAddIssueComment( body: args.body, type: 'pr' } - if (args.prRepo) { - params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo } - } return sendGithubPrMutation( client, 'github.addIssueComment', - buildGithubPrParams('github.addIssueComment', worktreeId, params) + buildGithubPrParams('github.addIssueComment', worktreeId, params, { prRepo: args.prRepo }) ) } @@ -262,15 +252,17 @@ export async function fetchAddIssueComment( export async function fetchResolveReviewThread( client: Pick, worktreeId: string, - args: { threadId: string; resolve: boolean } + args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null } ): Promise { const response = await sendRaw( client, 'github.resolveReviewThread', - buildGithubPrParams('github.resolveReviewThread', worktreeId, { - threadId: args.threadId, - resolve: args.resolve - }) + buildGithubPrParams( + 'github.resolveReviewThread', + worktreeId, + { threadId: args.threadId, resolve: args.resolve }, + { prRepo: args.prRepo } + ) ) if (!response.ok) { return { @@ -292,11 +284,10 @@ export async function fetchResolveReviewThread( // GitHubProjectMutationResult `{ ok }` envelope sendGithubPrMutation reads. export async function fetchUpdateIssueComment( client: Pick, - args: { owner: string; repo: string; commentId: number; body: string } + args: { owner: string; repo: string; host?: string; commentId: number; body: string } ): Promise { return sendGithubPrMutation(client, 'github.project.updateIssueCommentBySlug', { - owner: args.owner, - repo: args.repo, + ...githubPrRepoSlugParam(args), commentId: args.commentId, body: args.body }) @@ -305,11 +296,10 @@ export async function fetchUpdateIssueComment( // Delete a root conversation (issue) comment. Slug-addressed like the edit wrapper. export async function fetchDeleteIssueComment( client: Pick, - args: { owner: string; repo: string; commentId: number } + args: { owner: string; repo: string; host?: string; commentId: number } ): Promise { return sendGithubPrMutation(client, 'github.project.deleteIssueCommentBySlug', { - owner: args.owner, - repo: args.repo, + ...githubPrRepoSlugParam(args), commentId: args.commentId }) } @@ -317,9 +307,13 @@ export async function fetchDeleteIssueComment( export async function fetchRerunPRChecks( client: Pick, worktreeId: string, - args: { prNumber: number; headSha?: string | null; failedOnly?: boolean } + args: { + prNumber: number + headSha?: string | null + failedOnly?: boolean + prRepo?: GitHubPrRepoSlug | null + } ): Promise { - // rerunPRChecks does NOT accept prRepo (KTD3); headSha is a plain param here. const params: Record = { prNumber: args.prNumber } if (args.failedOnly !== undefined) { params.failedOnly = args.failedOnly @@ -330,6 +324,6 @@ export async function fetchRerunPRChecks( return sendGithubPrMutation( client, 'github.rerunPRChecks', - buildGithubPrParams('github.rerunPRChecks', worktreeId, params) + buildGithubPrParams('github.rerunPRChecks', worktreeId, params, { prRepo: args.prRepo }) ) } diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts index 56904e482cd..a287aa4f616 100644 --- a/mobile/src/session/github-pr-rpc.test.ts +++ b/mobile/src/session/github-pr-rpc.test.ts @@ -103,13 +103,17 @@ describe('readPRForBranch', () => { const parsed = readPRForBranch({ number: 3, state: 'open', - prRepo: { owner: 'forkOwner', repo: 'forkRepo' }, + prRepo: { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' }, mergeMethodSettings: { defaultMethod: 'squash', allowedMethods: { merge: false, squash: true, rebase: true } } }) - expect(parsed?.prRepo).toEqual({ owner: 'forkOwner', repo: 'forkRepo' }) + expect(parsed?.prRepo).toEqual({ + owner: 'forkOwner', + repo: 'forkRepo', + host: 'github.acme.test' + }) expect(parsed?.mergeMethodSettings).toEqual({ defaultMethod: 'squash', allowedMethods: { merge: false, squash: true, rebase: true } @@ -266,7 +270,7 @@ describe('readAssignableUsers', () => { }) describe('buildGithubPrParams — method-aware prRepo / headSha', () => { - const fork = { owner: 'forkOwner', repo: 'forkRepo' } + const fork = { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' } it('reuses mobileRepoSelectorFromWorktreeId for the repo selector', () => { const params = buildGithubPrParams('github.prChecks', WORKTREE_ID, { prNumber: 1 }) @@ -278,9 +282,20 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => { for (const method of [ 'github.prChecks', 'github.prCheckDetails', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updatePRState', + 'github.requestPRReviewers', + 'github.removePRReviewers', 'github.mergePR', 'github.setPRAutoMerge', - 'github.prComments' + 'github.updatePRTitle', + 'github.prComments', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.addIssueComment', + 'github.addPRReviewCommentReply' ]) { const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork }) expect(params.prRepo).toEqual(fork) @@ -288,13 +303,7 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => { }) it('omits prRepo for methods that reject it', () => { - for (const method of [ - 'github.updatePRState', - 'github.requestPRReviewers', - 'github.removePRReviewers', - 'github.listAssignableUsers', - 'github.rerunPRChecks' - ]) { + for (const method of ['github.repoSlug', 'github.prForBranch', 'github.listAssignableUsers']) { const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork }) expect('prRepo' in params).toBe(false) } @@ -380,12 +389,15 @@ describe('fetch wrappers', () => { expect('headSha' in (params as object)).toBe(false) }) - it('fetchGithubRepoSlug returns the slug for a github repo, null otherwise', async () => { + it('fetchGithubRepoSlug preserves an Enterprise host, and returns null otherwise', async () => { const found = await fetchGithubRepoSlug( - mockClient(okResponse({ owner: 'o', repo: 'r' })).client, + mockClient(okResponse({ owner: 'o', repo: 'r', host: 'github.acme.test' })).client, WORKTREE_ID ) - expect(found).toEqual({ ok: true, result: { owner: 'o', repo: 'r' } }) + expect(found).toEqual({ + ok: true, + result: { owner: 'o', repo: 'r', host: 'github.acme.test' } + }) const none = await fetchGithubRepoSlug(mockClient(okResponse(null)).client, WORKTREE_ID) expect(none).toEqual({ ok: true, result: null }) }) diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index 8fd11f710a8..0ae009d926a 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -31,20 +31,35 @@ export { // Why: a fork PR's head lives in a different owner/repo; the host's SlugRepo // (`{ owner, repo }`) identifies it. Only a subset of github.* methods accept it. -export type GitHubPrRepoSlug = { owner: string; repo: string } +// Why: `host` must survive the RPC boundary or GHES actions on the host fall +// back to a same-named github.com repo (src/shared/types.ts identity contract). +export type GitHubPrRepoSlug = { owner: string; repo: string; host?: string } + +export function githubPrRepoSlugParam(slug: GitHubPrRepoSlug): Record { + return { owner: slug.owner, repo: slug.repo, ...(slug.host ? { host: slug.host } : {}) } +} export type GitHubPrReadOutcome = { ok: true; result: T } | { ok: false; error: string } -// Why: `prRepo` is method-asymmetric (KTD3). These are the only github.* methods -// whose host schema (SlugRepo on PullRequest/PullRequestChecks/PullRequestCheckDetails) -// accepts it; the rest reject the key. Centralizing the allow-list keeps a fork's -// prRepo from leaking into a schema that would reject it. +// Why: `prRepo` remains method-asymmetric. Keep the RPC schema allow-list here +// so fork/GHES identity reaches every PR-scoped read or mutation that accepts it. const METHODS_ACCEPTING_PR_REPO = new Set([ 'github.prChecks', 'github.prCheckDetails', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updatePRState', + 'github.requestPRReviewers', + 'github.removePRReviewers', 'github.mergePR', 'github.setPRAutoMerge', - 'github.prComments' + 'github.updatePRTitle', + 'github.prComments', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.addIssueComment', + 'github.addPRReviewCommentReply' ]) // Why: only github.prChecks declares a `headSha` param (PullRequestCheckDetails @@ -62,7 +77,7 @@ export function buildGithubPrParams( ...params } if (options?.prRepo && METHODS_ACCEPTING_PR_REPO.has(method) && !('prRepo' in built)) { - built.prRepo = { owner: options.prRepo.owner, repo: options.prRepo.repo } + built.prRepo = githubPrRepoSlugParam(options.prRepo) } if (options?.headSha && METHODS_ACCEPTING_HEAD_SHA.has(method) && !('headSha' in built)) { built.headSha = options.headSha @@ -107,7 +122,10 @@ export async function fetchGithubRepoSlug( const record = value as Record const owner = record.owner const repo = record.repo - return typeof owner === 'string' && typeof repo === 'string' ? { owner, repo } : null + const host = record.host + return typeof owner === 'string' && typeof repo === 'string' + ? { owner, repo, ...(typeof host === 'string' && host ? { host } : {}) } + : null } ) } diff --git a/mobile/src/session/github-pr-value-readers.test.ts b/mobile/src/session/github-pr-value-readers.test.ts index cfd3142cff7..0b78d11dbb1 100644 --- a/mobile/src/session/github-pr-value-readers.test.ts +++ b/mobile/src/session/github-pr-value-readers.test.ts @@ -9,6 +9,14 @@ describe('readRepoIdentity', () => { }) }) + it('preserves an Enterprise host', () => { + expect(readRepoIdentity({ owner: 'octo', repo: 'orca', host: 'github.acme.test' })).toEqual({ + owner: 'octo', + repo: 'orca', + host: 'github.acme.test' + }) + }) + it('drops a non-record value', () => { expect(readRepoIdentity(null)).toBeUndefined() expect(readRepoIdentity('octo/orca')).toBeUndefined() diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index 6ba8ccd6954..1756d51c59a 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -153,7 +153,10 @@ export function readRepoIdentity(value: unknown): GitHubRepositoryIdentity | und if (!owner || !repo) { return undefined } - return { owner, repo } + // Why: dropping `host` here would strip the GHES identity before every + // subsequent PR RPC, forcing the host to re-derive it per call. + const host = readString(value.host) + return { owner, repo, ...(host ? { host } : {}) } } function readMergeMethod(value: unknown): GitHubPRMergeMethod | undefined { diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts new file mode 100644 index 00000000000..b9f5d9e174e --- /dev/null +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' +import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader' + +function createClient( + handler: (method: string, params?: unknown) => Promise +): RpcClient & { sendRequest: ReturnType } { + return { + sendRequest: vi.fn(handler), + subscribe: vi.fn(() => () => {}) + } as unknown as RpcClient & { sendRequest: ReturnType } +} + +describe('mobile new-tab agent loading', () => { + it('detects agents locally for the floating workspace without listing repos', async () => { + const client = createClient(async (method) => { + if (method === 'settings.get') { + return { + ok: true, + result: { settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] } } + } + } + if (method === 'preflight.detectAgents') { + return { ok: true, result: ['claude', 'codex'] } + } + throw new Error(`unexpected request: ${method}`) + }) + + await expect( + loadMobileNewTabAgentOptions({ + client, + worktreeId: FLOATING_WORKSPACE_WORKTREE_ID + }) + ).resolves.toEqual([ + { agent: 'codex', label: 'Codex' }, + { agent: 'claude', label: 'Claude' } + ]) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'preflight.detectAgents', + 'settings.get' + ]) + }) + + it('detects agents through the worktree repo connection for SSH sessions', async () => { + const client = createClient(async (method, params) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: {} } } + } + if (method === 'repo.list') { + return { ok: true, result: { repos: [{ id: 'repo-1', connectionId: 'ssh-1' }] } } + } + if (method === 'preflight.detectRemoteAgents') { + expect(params).toEqual({ connectionId: 'ssh-1' }) + return { ok: true, result: ['claude'] } + } + throw new Error(`unexpected request: ${method}`) + }) + + await expect( + loadMobileNewTabAgentOptions({ + client, + worktreeId: 'repo-1::/remote/worktree' + }) + ).resolves.toEqual([{ agent: 'claude', label: 'Claude' }]) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'repo.list', + 'settings.get', + 'preflight.detectRemoteAgents' + ]) + }) +}) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts new file mode 100644 index 00000000000..ea87e74ba0e --- /dev/null +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -0,0 +1,62 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcSuccess } from '../transport/types' +import { isFloatingWorkspaceWorktreeId } from './floating-workspace' +import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' +import { + buildMobileNewTabAgentOptions, + type MobileNewTabAgentOption, + type MobileNewTabAgentSettings +} from './mobile-new-tab-agent-options' + +type RuntimeRepoSummary = { + id: string + connectionId?: string | null +} + +export async function loadMobileNewTabAgentOptions(args: { + client: RpcClient + worktreeId: string +}): Promise { + const { client, worktreeId } = args + // Why: the floating workspace runs on the paired host, so it has no repo connection to resolve. + const detectedAgentsRequest = isFloatingWorkspaceWorktreeId(worktreeId) + ? client.sendRequest('preflight.detectAgents') + : loadWorkspaceDetectedAgents(client, worktreeId) + const [settingsResponse, detectedResponse] = await Promise.all([ + client.sendRequest('settings.get'), + detectedAgentsRequest + ]) + if (!settingsResponse.ok) { + throw new Error((settingsResponse as RpcFailure).error.message) + } + if (!detectedResponse.ok) { + throw new Error((detectedResponse as RpcFailure).error.message) + } + const settings = ( + (settingsResponse as RpcSuccess).result as { + settings?: MobileNewTabAgentSettings + } + ).settings + return buildMobileNewTabAgentOptions( + settings, + (detectedResponse as RpcSuccess).result as unknown[] + ) +} + +async function loadWorkspaceDetectedAgents(client: RpcClient, worktreeId: string) { + const repoResponse = await client.sendRequest('repo.list') + if (!repoResponse.ok) { + throw new Error((repoResponse as RpcFailure).error.message) + } + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + const repos = + ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? [] + const repo = repos.find((candidate) => candidate.id === repoId) + if (!repo) { + throw new Error('worktree_repo_not_found') + } + const connectionId = repo.connectionId?.trim() || null + return connectionId + ? client.sendRequest('preflight.detectRemoteAgents', { connectionId }) + : client.sendRequest('preflight.detectAgents') +} diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index c4083b1a545..58454c1d981 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -36,7 +36,10 @@ describe('mobile session startup', () => { ) expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'") + expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)") + expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)") expect(startupEffect).toContain('notifyClients: false') + expect(startupEffect).toContain("navigation: 'caller'") expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'") expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan( startupEffect.indexOf('await fetchSessionTabs()') @@ -62,21 +65,23 @@ describe('mobile session startup', () => { expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id') expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId') expect(pendingActivationEffect).toContain('notifyClients: false') + expect(pendingActivationEffect).toContain("navigation: 'caller'") expect(pendingActivationEffect).toContain( 'applySessionTabs((response as RpcSuccess).result as SessionTabsResult)' ) expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()') }) - it('mirrors ready terminal taps while persisting them for headless hosts', () => { + it('keeps ready terminal taps local while publishing caller selection', () => { const readyTerminalSwitch = sliceBetween( 'const switchTab = useCallback(', 'const switchSessionTab = useCallback(' ) - expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)') + expect(readyTerminalSwitch).not.toContain('focusMobileTerminal(client, handle)') expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,') expect(readyTerminalSwitch).toContain('notifyClients: false') + expect(readyTerminalSwitch).toContain("navigation: 'caller'") }) it('keeps background and pending session-tab activation local to the phone', () => { @@ -85,6 +90,7 @@ describe('mobile session startup', () => { expect(activationRequests).toHaveLength(4) for (const request of activationRequests) { expect(request.slice(0, request.indexOf('})'))).toContain('notifyClients: false') + expect(request.slice(0, request.indexOf('})'))).toContain("navigation: 'caller'") } }) diff --git a/mobile/src/session/mobile-session-tab-activation.test.ts b/mobile/src/session/mobile-session-tab-activation.test.ts index d448d7e2f53..5e7503f24d4 100644 --- a/mobile/src/session/mobile-session-tab-activation.test.ts +++ b/mobile/src/session/mobile-session-tab-activation.test.ts @@ -25,8 +25,14 @@ describe('mobile session tab activation', () => { } ) expect(sendRequest).toHaveBeenCalledTimes(2) - expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' }) - expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' }) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { + terminal: 'terminal-1', + navigation: 'host' + }) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { + terminal: 'terminal-1', + navigation: 'host' + }) }) it('retries session-tab activation with the same target after cutover', async () => { @@ -38,7 +44,8 @@ describe('mobile session tab activation', () => { worktree: 'id:worktree-1', tabId: 'tab-1', leafId: 'leaf-1', - notifyClients: false as const + notifyClients: false as const, + navigation: 'caller' as const } await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({ @@ -67,7 +74,8 @@ describe('mobile session tab activation', () => { activateMobileSessionTab(clientWith(sendRequest), { worktree: 'id:worktree-1', tabId: 'tab-1', - notifyClients: false + notifyClients: false, + navigation: 'caller' }) ).rejects.toBeInstanceOf(LogicalClientCutoverError) expect(sendRequest).toHaveBeenCalledTimes(2) diff --git a/mobile/src/session/mobile-session-tab-activation.ts b/mobile/src/session/mobile-session-tab-activation.ts index 4b07bff1eee..8a8bd80fc96 100644 --- a/mobile/src/session/mobile-session-tab-activation.ts +++ b/mobile/src/session/mobile-session-tab-activation.ts @@ -14,6 +14,7 @@ type MobileSessionTabActivationParams = { tabId: string leafId?: string notifyClients: false + navigation: 'caller' } async function retryIdempotentActivationAfterCutover( @@ -72,7 +73,7 @@ export function focusMobileTerminal( terminal: string ): Promise { return retryIdempotentActivationAfterCutover( - () => client.sendRequest('terminal.focus', { terminal }), + () => client.sendRequest('terminal.focus', { terminal, navigation: 'host' }), 'terminal.focus', terminal ) diff --git a/mobile/src/session/pr-action-mutation-contract.ts b/mobile/src/session/pr-action-mutation-contract.ts new file mode 100644 index 00000000000..a2fcdb271df --- /dev/null +++ b/mobile/src/session/pr-action-mutation-contract.ts @@ -0,0 +1,38 @@ +import type { GitHubPRMergeMethod } from '../../../src/shared/types' +import type { GitHubPrMutationOutcome } from './github-pr-mutations' +import type { GitHubPrRepoSlug } from './github-pr-rpc' + +export type PrActionMutations = { + mergePR: (args: { + prNumber: number + method?: GitHubPRMergeMethod + prRepo?: GitHubPrRepoSlug | null + }) => Promise + setPRAutoMerge: (args: { + prNumber: number + enabled: boolean + method?: GitHubPRMergeMethod + prRepo?: GitHubPrRepoSlug | null + }) => Promise + updatePRState: (args: { + prNumber: number + state: 'open' | 'closed' + prRepo?: GitHubPrRepoSlug | null + }) => Promise + requestReviewers: (args: { + prNumber: number + reviewers: string[] + prRepo?: GitHubPrRepoSlug | null + }) => Promise + removeReviewers: (args: { + prNumber: number + reviewers: string[] + prRepo?: GitHubPrRepoSlug | null + }) => Promise + rerunChecks: (args: { + prNumber: number + headSha?: string | null + failedOnly?: boolean + prRepo?: GitHubPrRepoSlug | null + }) => Promise +} diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts index d879454c50c..3656b2732f6 100644 --- a/mobile/src/session/pr-actions-engine.test.ts +++ b/mobile/src/session/pr-actions-engine.test.ts @@ -198,4 +198,43 @@ describe('PrActionsEngine — PR identity changes', () => { await action expect(refetch).not.toHaveBeenCalled() }) + + it('clears in-flight state when only the GitHub host changes', async () => { + const slow = deferred() + const mutations: PrActionMutations = { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => slow.promise, + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + } + const refetch = vi.fn() + const onChange = vi.fn() + const baseConfig = { + mutations, + prNumber: 1, + refetch, + onChange + } + const engine = new PrActionsEngine({ + ...baseConfig, + prRepo: { owner: 'acme', repo: 'widgets' } + }) + + const action = engine.setAutoMerge(true) + expect(engine.resolveAutoMerge(false)).toBe(true) + expect(engine.isBusy({ kind: 'autoMerge' })).toBe(true) + + engine.updateConfig({ + ...baseConfig, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } + }) + expect(engine.resolveAutoMerge(false)).toBe(false) + expect(engine.busy).toBeNull() + + slow.resolve({ ok: true }) + await action + expect(refetch).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts index 7c34954534d..ce19c9bbbf9 100644 --- a/mobile/src/session/pr-actions-engine.ts +++ b/mobile/src/session/pr-actions-engine.ts @@ -1,45 +1,18 @@ import type { GitHubPRMergeMethod, PRState } from '../../../src/shared/types' +import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key' import { classifyPrSidebarFailure } from './mobile-pr-sidebar-state' import { createOptimisticField, type OptimisticField } from './optimistic-write-sequence' import type { GitHubPrMutationOutcome } from './github-pr-mutations' import type { GitHubPrRepoSlug } from './github-pr-rpc' +import type { PrActionMutations } from './pr-action-mutation-contract' + +export type { PrActionMutations } from './pr-action-mutation-contract' // Pure (React-free) engine for the PR mutation actions: owns optimistic fields, // busy/error/blocked state, and the success/transient/permanent routing. The hook // is a thin adapter that subscribes to `onChange` and exposes these methods. Kept // React-free so the U6 action logic is unit-testable with injected fakes. -export type PrActionMutations = { - mergePR: (args: { - prNumber: number - method?: GitHubPRMergeMethod - prRepo?: GitHubPrRepoSlug | null - }) => Promise - setPRAutoMerge: (args: { - prNumber: number - enabled: boolean - method?: GitHubPRMergeMethod - prRepo?: GitHubPrRepoSlug | null - }) => Promise - updatePRState: (args: { - prNumber: number - state: 'open' | 'closed' - }) => Promise - requestReviewers: (args: { - prNumber: number - reviewers: string[] - }) => Promise - removeReviewers: (args: { - prNumber: number - reviewers: string[] - }) => Promise - rerunChecks: (args: { - prNumber: number - headSha?: string | null - failedOnly?: boolean - }) => Promise -} - export type PrActionBusyKey = | { kind: 'merge' } | { kind: 'autoMerge' } @@ -68,7 +41,7 @@ export type PrActionsEngineConfig = { } function prActionsIdentity(cfg: PrActionsEngineConfig): string { - const repo = cfg.prRepo ? `${cfg.prRepo.owner}/${cfg.prRepo.repo}` : '' + const repo = cfg.prRepo ? githubRepoIdentityKey(cfg.prRepo) : '' return `${cfg.prNumber}:${repo}` } @@ -260,7 +233,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.updatePRState({ prNumber: cfg.prNumber, - state + state, + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => this.stateField.settleSuccess(seq), @@ -281,7 +255,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.requestReviewers({ prNumber: cfg.prNumber, - reviewers: [login] + reviewers: [login], + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => field.settleSuccess(seq), @@ -302,7 +277,8 @@ export class PrActionsEngine { try { const outcome = await cfg.mutations.removeReviewers({ prNumber: cfg.prNumber, - reviewers: [login] + reviewers: [login], + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => field.settleSuccess(seq), @@ -322,7 +298,8 @@ export class PrActionsEngine { const outcome = await cfg.mutations.rerunChecks({ prNumber: cfg.prNumber, headSha: cfg.headSha, - failedOnly: true + failedOnly: true, + prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} }) } finally { diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts index 97cf15f2dce..c2273c9046e 100644 --- a/mobile/src/session/pr-ai-triage-launch.test.ts +++ b/mobile/src/session/pr-ai-triage-launch.test.ts @@ -24,7 +24,10 @@ describe('createTerminalAndSendPrompt', () => { await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing') expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', { - worktree: 'id:wt-1' + worktree: 'id:wt-1', + activate: false, + select: true, + navigation: 'caller' }) expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', { terminal: 'term-1', diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index acf75524cfe..6d6c0bbc8b2 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -16,7 +16,10 @@ export async function createTerminalAndSendPrompt( prompt: string ): Promise { const created = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}` + worktree: `id:${worktreeId}`, + activate: false, + select: true, + navigation: 'caller' }) if (!created.ok) { throw new Error(created.error?.message || 'Failed to create terminal') diff --git a/mobile/src/session/pr-comment-actions.test.ts b/mobile/src/session/pr-comment-actions.test.ts index 4d70428845d..bb6f1758b24 100644 --- a/mobile/src/session/pr-comment-actions.test.ts +++ b/mobile/src/session/pr-comment-actions.test.ts @@ -134,13 +134,19 @@ describe('canEditComment / canDeleteComment', () => { describe('buildEditCommentParams / buildDeleteCommentParams', () => { it('build slug-addressed params', () => { - const slug = { owner: 'o', repo: 'r' } + const slug = { owner: 'o', repo: 'r', host: 'github.acme.test' } expect(buildEditCommentParams(slug, 42, 'new body')).toEqual({ owner: 'o', repo: 'r', + host: 'github.acme.test', commentId: 42, body: 'new body' }) - expect(buildDeleteCommentParams(slug, 42)).toEqual({ owner: 'o', repo: 'r', commentId: 42 }) + expect(buildDeleteCommentParams(slug, 42)).toEqual({ + owner: 'o', + repo: 'r', + host: 'github.acme.test', + commentId: 42 + }) }) }) diff --git a/mobile/src/session/pr-comment-actions.ts b/mobile/src/session/pr-comment-actions.ts index 195d2480d87..012500c62da 100644 --- a/mobile/src/session/pr-comment-actions.ts +++ b/mobile/src/session/pr-comment-actions.ts @@ -1,4 +1,4 @@ -import type { PRComment, PRState } from '../../../src/shared/types' +import type { GitHubOwnerRepo, PRComment, PRState } from '../../../src/shared/types' // Pure helpers for the interactive PR comment timeline (reply / resolve / add // root comment). Kept free of React/native imports so they unit-test under the @@ -87,35 +87,46 @@ export function isMutablePRConversationComment( // can hide the affordance rather than firing a doomed request. export function canEditComment( comment: Pick, - prRepo: { owner: string; repo: string } | null | undefined + prRepo: GitHubOwnerRepo | null | undefined ): boolean { return Boolean(prRepo) && isMutablePRConversationComment(comment) } export function canDeleteComment( comment: Pick, - prRepo: { owner: string; repo: string } | null | undefined + prRepo: GitHubOwnerRepo | null | undefined ): boolean { return Boolean(prRepo) && isMutablePRConversationComment(comment) } -export type EditCommentParams = { owner: string; repo: string; commentId: number; body: string } +export type EditCommentParams = GitHubOwnerRepo & { commentId: number; body: string } export function buildEditCommentParams( - prRepo: { owner: string; repo: string }, + prRepo: GitHubOwnerRepo, commentId: number, body: string ): EditCommentParams { - return { owner: prRepo.owner, repo: prRepo.repo, commentId, body } + return { + owner: prRepo.owner, + repo: prRepo.repo, + ...(prRepo.host ? { host: prRepo.host } : {}), + commentId, + body + } } -export type DeleteCommentParams = { owner: string; repo: string; commentId: number } +export type DeleteCommentParams = GitHubOwnerRepo & { commentId: number } export function buildDeleteCommentParams( - prRepo: { owner: string; repo: string }, + prRepo: GitHubOwnerRepo, commentId: number ): DeleteCommentParams { - return { owner: prRepo.owner, repo: prRepo.repo, commentId } + return { + owner: prRepo.owner, + repo: prRepo.repo, + ...(prRepo.host ? { host: prRepo.host } : {}), + commentId + } } // The composer disables submit on empty/whitespace input (host rejects empty body). diff --git a/mobile/src/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts index 97927c6b7d9..8ac565c7684 100644 --- a/mobile/src/session/use-live-worktree-name.test.ts +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -166,4 +166,86 @@ describe('useLiveWorktreeName request volume', () => { expect(unsubscribeStream).toHaveBeenCalledTimes(1) }) + + it('never calls worktree.show for the floating workspace sentinel', async () => { + let name = '' + function FloatingHarness(): null { + name = useLiveWorktreeName({ + client, + connState: 'connected', + routeName: undefined, + worktreeId: 'global-floating-terminal' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(FloatingHarness)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + + expect(sendRequest).not.toHaveBeenCalled() + expect(subscribe).not.toHaveBeenCalled() + expect(name).toBe('Floating Workspace') + }) + + it('does not render stale names while switching through the floating route', async () => { + const firstNameByWorktree = new Map() + let renderer: ReactTestRenderer | null = null + + function RouteHarness(props: { routeName?: string; worktreeId: string }): null { + const name = useLiveWorktreeName({ + client, + connState: 'connected', + routeName: props.routeName, + worktreeId: props.worktreeId + }) + if (!firstNameByWorktree.has(props.worktreeId)) { + firstNameByWorktree.set(props.worktreeId, name) + } + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(RouteHarness, { + routeName: 'Previous workspace', + worktreeId: 'repo-1::/worktree' + }) + ) + await Promise.resolve() + }) + await act(async () => { + renderer?.update( + createElement(RouteHarness, { + worktreeId: 'global-floating-terminal' + }) + ) + }) + await act(async () => { + renderer?.update( + createElement(RouteHarness, { + routeName: 'Next workspace', + worktreeId: 'repo-2::/worktree' + }) + ) + }) + } finally { + restoreConsoleError() + act(() => renderer?.unmount()) + } + + expect(firstNameByWorktree.get('global-floating-terminal')).toBe('Floating Workspace') + expect(firstNameByWorktree.get('repo-2::/worktree')).toBe('Next workspace') + }) }) diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index bd21ec8f156..53ef1cf1bbf 100644 --- a/mobile/src/session/use-live-worktree-name.ts +++ b/mobile/src/session/use-live-worktree-name.ts @@ -5,6 +5,7 @@ import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name' +import { FLOATING_WORKSPACE_TITLE, isFloatingWorkspaceWorktreeId } from './floating-workspace' const WORKTREE_NAME_FALLBACK_POLL_MS = 3000 @@ -16,15 +17,26 @@ type Params = { } export function useLiveWorktreeName({ client, connState, routeName, worktreeId }: Params): string { - const [worktreeName, setWorktreeName] = useState(() => routeName?.trim() ?? '') + // Why: the floating sentinel has no worktree record, so worktree.show would + // fail forever and keep the 3s fallback poll alive; its title is fixed. + const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId) + const routeNameHint = routeName?.trim() ?? '' + const [worktreeName, setWorktreeName] = useState(() => ({ + worktreeId, + name: routeNameHint + })) useEffect(() => { - setWorktreeName(routeName?.trim() ?? '') - }, [routeName, worktreeId]) + setWorktreeName((current) => + current.worktreeId === worktreeId && current.name === routeNameHint + ? current + : { worktreeId, name: routeNameHint } + ) + }, [routeNameHint, worktreeId]) useFocusEffect( useCallback(() => { - if (!client || connState !== 'connected') { + if (isFloatingWorkspace || !client || connState !== 'connected') { return } let stale = false @@ -58,7 +70,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } ? getLiveWorktreeDisplayName([result.worktree], worktreeId) : null if (liveName) { - setWorktreeName((current) => (current === liveName ? current : liveName)) + setWorktreeName((current) => + current.worktreeId === worktreeId && current.name === liveName + ? current + : { worktreeId, name: liveName } + ) } hasSuccessfulRefresh = true if (eventStreamReady) { @@ -128,8 +144,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } stopFallbackPoll() unsubscribe() } - }, [client, connState, worktreeId]) + }, [client, connState, worktreeId, isFloatingWorkspace]) ) - return worktreeName + if (isFloatingWorkspace) { + return FLOATING_WORKSPACE_TITLE + } + return worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint } diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts index 5300100a476..8c4f34e821d 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts @@ -99,7 +99,10 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { throw new Error('Waiting for desktop...') } const response = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}` + worktree: `id:${worktreeId}`, + activate: false, + select: true, + navigation: 'caller' }) if (!response.ok) { throw new Error(response.error?.message || 'Failed to create terminal') diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts index 46feaf79217..41d08c3c32c 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.test.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' describe('useMobileNativeChatReadability', () => { @@ -18,15 +19,19 @@ describe('useMobileNativeChatReadability', () => { renderer = null }) - async function mount(connectionId: string | null): Promise { + async function mount( + connectionId: string | null, + worktreeId = 'repo::/worktree' + ): Promise> { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { repos: [{ id: 'repo', connectionId }] } + }) const client = { - sendRequest: vi.fn().mockResolvedValue({ - ok: true, - result: { repos: [{ id: 'repo', connectionId }] } - }) + sendRequest } as unknown as RpcClient function Harness(): null { - readable = useMobileNativeChatReadability(client, 'repo::/worktree') + readable = useMobileNativeChatReadability(client, worktreeId) return null } const original = console.error @@ -44,6 +49,7 @@ describe('useMobileNativeChatReadability', () => { } finally { consoleSpy.mockRestore() } + return sendRequest } it('admits local and runtime-owned transcript hosts', async () => { @@ -61,6 +67,13 @@ describe('useMobileNativeChatReadability', () => { expect(readable).toBe(false) }) + it('treats the host-local floating workspace as readable without listing repos', async () => { + const sendRequest = await mount(null, FLOATING_WORKSPACE_WORKTREE_ID) + + expect(readable).toBe(true) + expect(sendRequest).not.toHaveBeenCalled() + }) + it('fails closed immediately while a reused route resolves its new worktree', async () => { let resolveNext: (response: unknown) => void = () => {} const client = { diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts index e952335fe23..e3729022d38 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' +import { isFloatingWorkspaceWorktreeId } from './floating-workspace' import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' @@ -10,12 +11,17 @@ export function useMobileNativeChatReadability( client: RpcClient | null, worktreeId: string ): boolean { + const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId) const [state, setState] = useState({ client: null, worktreeId: '', readable: false }) useEffect(() => { + // Why: the floating workspace always runs on the paired host and has no repo connection to resolve. + if (isFloatingWorkspace) { + return + } let active = true if (!client) { setState({ client, worktreeId, readable: false }) @@ -46,7 +52,10 @@ export function useMobileNativeChatReadability( return () => { active = false } - }, [client, worktreeId]) + }, [client, isFloatingWorkspace, worktreeId]) + if (isFloatingWorkspace) { + return true + } // Why: route reuse renders before its new effect resolves; never expose the // previous repo's readability under a different client/worktree key. return state.client === client && state.worktreeId === worktreeId ? state.readable : false diff --git a/mobile/src/session/use-mobile-pr-actions.test.ts b/mobile/src/session/use-mobile-pr-actions.test.ts index e8efa60094d..dd022bb25f7 100644 --- a/mobile/src/session/use-mobile-pr-actions.test.ts +++ b/mobile/src/session/use-mobile-pr-actions.test.ts @@ -10,6 +10,7 @@ import { } from './github-pr-mutations' const WORKTREE_ID = 'repo-42::/path/to/wt' +const ENTERPRISE_PR_REPO = { owner: 'fork', repo: 'proj', host: 'github.acme.test' } function okStatus(): RpcResponse { return { id: 'x', ok: true, result: { ok: true }, _meta: { runtimeId: 'r' } } @@ -46,13 +47,17 @@ function fakeMutations(overrides: Partial = {}): PrActionMuta } } -function makeEngine(mutations: PrActionMutations, refetch = vi.fn(async () => {})) { +function makeEngine( + mutations: PrActionMutations, + refetch = vi.fn(async () => {}), + prRepo: typeof ENTERPRISE_PR_REPO | null = null +) { const onChange = vi.fn() const engine = new PrActionsEngine({ mutations, prNumber: 7, headSha: 'abc123', - prRepo: null, + prRepo, refetch, onChange }) @@ -67,7 +72,7 @@ describe('mutation wrappers — prRepo + param shapes', () => { await fetchMergePR(client, WORKTREE_ID, { prNumber: 7, method: 'squash', - prRepo: { owner: 'fork', repo: 'proj' } + prRepo: ENTERPRISE_PR_REPO }) expect(sendRequest).toHaveBeenCalledWith( 'github.mergePR', @@ -75,17 +80,25 @@ describe('mutation wrappers — prRepo + param shapes', () => { repo: 'id:repo-42', prNumber: 7, method: 'squash', - prRepo: { owner: 'fork', repo: 'proj' } + prRepo: ENTERPRISE_PR_REPO }) ) }) - it('updatePRState close carries NO prRepo and the state update', async () => { + it('updatePRState carries the host-qualified PR repo and state update', async () => { const { client, sendRequest } = mockClient(okStatus()) - await fetchUpdatePRState(client, WORKTREE_ID, { prNumber: 7, state: 'closed' }) + await fetchUpdatePRState(client, WORKTREE_ID, { + prNumber: 7, + state: 'closed', + prRepo: ENTERPRISE_PR_REPO + }) const [, params] = sendRequest.mock.calls[0] - expect(params).toEqual({ repo: 'id:repo-42', prNumber: 7, updates: { state: 'closed' } }) - expect(params).not.toHaveProperty('prRepo') + expect(params).toEqual({ + repo: 'id:repo-42', + prNumber: 7, + updates: { state: 'closed' }, + prRepo: ENTERPRISE_PR_REPO + }) }) it('updatePRState reopen passes state:open', async () => { @@ -95,27 +108,43 @@ describe('mutation wrappers — prRepo + param shapes', () => { expect((params.updates as { state: string }).state).toBe('open') }) - it('request/remove reviewers carry NO prRepo even when a fork is in play', async () => { + it('request/remove reviewers carry the host-qualified PR repo', async () => { const { client, sendRequest } = mockClient(okStatus()) - await fetchRequestPRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['alice'] }) - await fetchRemovePRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['bob'] }) - for (const [, params] of sendRequest.mock.calls) { - expect(params).not.toHaveProperty('prRepo') - } - expect(sendRequest.mock.calls[0][1]).toMatchObject({ reviewers: ['alice'] }) - expect(sendRequest.mock.calls[1][1]).toMatchObject({ reviewers: ['bob'] }) + await fetchRequestPRReviewers(client, WORKTREE_ID, { + prNumber: 7, + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) + await fetchRemovePRReviewers(client, WORKTREE_ID, { + prNumber: 7, + reviewers: ['bob'], + prRepo: ENTERPRISE_PR_REPO + }) + expect(sendRequest.mock.calls[0][1]).toMatchObject({ + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) + expect(sendRequest.mock.calls[1][1]).toMatchObject({ + reviewers: ['bob'], + prRepo: ENTERPRISE_PR_REPO + }) }) - it('rerunPRChecks carries failedOnly + headSha and NO prRepo', async () => { + it('rerunPRChecks carries failedOnly, headSha, and the host-qualified PR repo', async () => { const { client, sendRequest } = mockClient(okStatus()) await fetchRerunPRChecks(client, WORKTREE_ID, { prNumber: 7, headSha: 'abc', - failedOnly: true + failedOnly: true, + prRepo: ENTERPRISE_PR_REPO }) const [, params] = sendRequest.mock.calls[0] - expect(params).toMatchObject({ prNumber: 7, failedOnly: true, headSha: 'abc' }) - expect(params).not.toHaveProperty('prRepo') + expect(params).toMatchObject({ + prNumber: 7, + failedOnly: true, + headSha: 'abc', + prRepo: ENTERPRISE_PR_REPO + }) }) it('a host { ok:false, error } result surfaces as a failure outcome', async () => { @@ -155,14 +184,14 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => { const mutations = fakeMutations({ setPRAutoMerge: vi.fn(async () => ({ ok: false as const, error: 'connection lost' })) }) - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) // authoritative = false; user enables. await engine.setAutoMerge(true, 'squash') expect(mutations.setPRAutoMerge).toHaveBeenCalledWith({ prNumber: 7, enabled: true, method: 'squash', - prRepo: null + prRepo: ENTERPRISE_PR_REPO }) // Reverted to authoritative after transient failure. expect(engine.resolveAutoMerge(false)).toBe(false) @@ -182,20 +211,32 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => { describe('PrActionsEngine — updateState close/reopen', () => { it('close fires state:closed and reopen fires state:open', async () => { const mutations = fakeMutations() - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.updateState('closed') await engine.updateState('open') - expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, { prNumber: 7, state: 'closed' }) - expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, { prNumber: 7, state: 'open' }) + expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, { + prNumber: 7, + state: 'closed', + prRepo: ENTERPRISE_PR_REPO + }) + expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, { + prNumber: 7, + state: 'open', + prRepo: ENTERPRISE_PR_REPO + }) }) }) describe('PrActionsEngine — reviewers', () => { - it('requestReviewer adds via requestReviewers with a single login (no prRepo at engine level)', async () => { + it('requestReviewer adds via requestReviewers with a host-qualified PR repo', async () => { const mutations = fakeMutations() - const { engine } = makeEngine(mutations) + const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.requestReviewer('alice') - expect(mutations.requestReviewers).toHaveBeenCalledWith({ prNumber: 7, reviewers: ['alice'] }) + expect(mutations.requestReviewers).toHaveBeenCalledWith({ + prNumber: 7, + reviewers: ['alice'], + prRepo: ENTERPRISE_PR_REPO + }) }) it('removeReviewer optimistic revert on transient failure', async () => { @@ -213,12 +254,13 @@ describe('PrActionsEngine — reviewers', () => { describe('PrActionsEngine — rerun checks', () => { it('fires failedOnly:true with headSha and refetches on success', async () => { const mutations = fakeMutations() - const { engine, refetch } = makeEngine(mutations) + const { engine, refetch } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO) await engine.rerunFailingChecks() expect(mutations.rerunChecks).toHaveBeenCalledWith({ prNumber: 7, headSha: 'abc123', - failedOnly: true + failedOnly: true, + prRepo: ENTERPRISE_PR_REPO }) expect(refetch).toHaveBeenCalledOnce() }) diff --git a/mobile/src/session/use-mobile-pr-comment-actions.ts b/mobile/src/session/use-mobile-pr-comment-actions.ts index f0909b9e03a..38096bad833 100644 --- a/mobile/src/session/use-mobile-pr-comment-actions.ts +++ b/mobile/src/session/use-mobile-pr-comment-actions.ts @@ -30,7 +30,11 @@ export type PrCommentMutations = { line?: number prRepo?: GitHubPrRepoSlug | null }) => Promise - resolveThread: (args: { threadId: string; resolve: boolean }) => Promise + resolveThread: (args: { + threadId: string + resolve: boolean + prRepo?: GitHubPrRepoSlug | null + }) => Promise addRootComment: (args: { prNumber: number body: string @@ -39,12 +43,14 @@ export type PrCommentMutations = { editComment: (args: { owner: string repo: string + host?: string commentId: number body: string }) => Promise deleteComment: (args: { owner: string repo: string + host?: string commentId: number }) => Promise } @@ -169,9 +175,9 @@ export function useMobilePrCommentActions(input: PrCommentActionsInput) { if (!mutations || !params) { return Promise.resolve(false) } - return run(resolveKey(params.threadId), () => mutations.resolveThread(params)) + return run(resolveKey(params.threadId), () => mutations.resolveThread({ ...params, prRepo })) }, - [mutations, run] + [mutations, prRepo, run] ) const addRootComment = useCallback( diff --git a/mobile/src/tasks/github-project-host-routing-source.test.ts b/mobile/src/tasks/github-project-host-routing-source.test.ts new file mode 100644 index 00000000000..05781d9f6f8 --- /dev/null +++ b/mobile/src/tasks/github-project-host-routing-source.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('../../app/h/[hostId]/tasks.tsx', import.meta.url), 'utf8') + +describe('mobile GitHub Project host routing boundary', () => { + it('host-qualifies every Project RPC request', () => { + const calls = [...source.matchAll(/['"](github\.project\.[^'"]+)['"]/g)] + expect(calls.length).toBeGreaterThan(10) + for (const call of calls) { + const request = source.slice(call.index, call.index + 700) + expect(request, `${call[1]} must carry a host`).toMatch(/\bhost\s*:/) + } + }) + + it('pins Project-row PR actions to the row repository identity', () => { + const actions = source.slice(source.indexOf('const toggleProjectGitHubReviewThread')) + for (const method of [ + 'github.resolveReviewThread', + 'github.addPRReviewCommentReply', + 'github.addIssueComment', + 'github.requestPRReviewers', + 'github.prChecks', + 'github.rerunPRChecks', + 'github.setPRFileViewed', + 'github.prFileContents', + 'github.addPRReviewComment', + 'github.mergePR' + ]) { + const offset = actions.indexOf(`'${method}'`) + expect(offset, `${method} must remain wired in the Project action path`).toBeGreaterThan(-1) + expect(actions.slice(offset, offset + 700), `${method} must carry prRepo`).toContain( + 'prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost)' + ) + } + }) + + it('pins discovery to github.com while pasted URLs supply their parsed host', () => { + expect(source).toContain("'github.project.listAccessible', {\n host: 'github.com'") + expect(source).toContain('host: githubProjectHost(parsed.host)') + }) +}) diff --git a/mobile/src/tasks/github-project-reference.test.ts b/mobile/src/tasks/github-project-reference.test.ts new file mode 100644 index 00000000000..d083d96ccc7 --- /dev/null +++ b/mobile/src/tasks/github-project-reference.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { parseGitHubProjectInput } from './github-project-reference' + +describe('parseGitHubProjectInput', () => { + it('keeps legacy owner/number input hostless', () => { + expect(parseGitHubProjectInput('Acme/42')).toEqual({ owner: 'Acme', number: 42 }) + }) + + it('preserves GitHub Enterprise hosts and view numbers', () => { + expect( + parseGitHubProjectInput('https://GitHub.Acme-Corp.com:8443/orgs/Acme/projects/42/views/7') + ).toEqual({ + owner: 'Acme', + number: 42, + host: 'github.acme-corp.com:8443', + viewNumber: 7 + }) + }) + + it('accepts github.com user Project URLs', () => { + expect(parseGitHubProjectInput('http://github.com/users/octocat/projects/3')).toEqual({ + owner: 'octocat', + number: 3, + host: 'github.com' + }) + }) + + it('rejects credentials, invalid routes, and unsafe integers', () => { + for (const input of [ + 'https://user:token@github.acme.test/orgs/acme/projects/1', + 'https://github.acme.test/acme/projects/1', + 'https://github.acme.test/orgs/acme/projects/1/files', + 'https://github.acme.test/orgs/acme/projects/9007199254740992', + 'https://github.acme.test/orgs/acme/projects/1/views/0' + ]) { + expect(parseGitHubProjectInput(input)).toBeNull() + } + }) +}) diff --git a/mobile/src/tasks/github-project-reference.ts b/mobile/src/tasks/github-project-reference.ts new file mode 100644 index 00000000000..00047f15f53 --- /dev/null +++ b/mobile/src/tasks/github-project-reference.ts @@ -0,0 +1,84 @@ +import type { GitHubProjectIdentity } from '../../../src/shared/github-project-identity' + +export type GitHubProjectOwnerType = GitHubProjectIdentity['ownerType'] +export type GitHubProjectRef = GitHubProjectIdentity +export type GitHubProjectSettings = { + pinned: GitHubProjectRef[] + recent: Array + lastViewByProject: Record + activeProject: GitHubProjectRef | null +} +export type GitHubProjectSummary = GitHubProjectRef & { + id: string + title: string + url: string + source: string +} +export type GitHubProjectPartialFailure = { owner: string; message: string } +export type GitHubProjectViewSummary = { + id: string + number: number + name: string + layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' +} + +export type ParsedGitHubProjectInput = { + owner: string + number: number + host?: string + viewNumber?: number +} + +const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ + +function positiveInteger(value: string | undefined): number | null { + if (!value || !/^\d+$/.test(value)) { + return null + } + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null +} + +export function parseGitHubProjectInput(input: string): ParsedGitHubProjectInput | null { + const trimmed = input.trim() + const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed) + if (short) { + const number = positiveInteger(short[2]) + return number ? { owner: short[1]!, number } : null + } + + try { + const url = new URL(trimmed) + if ( + (url.protocol !== 'https:' && url.protocol !== 'http:') || + url.username || + url.password || + !url.host + ) { + return null + } + const parts = url.pathname.split('/').filter(Boolean) + const hasView = parts.length === 6 && parts[4] === 'views' + if ( + (parts[0] !== 'orgs' && parts[0] !== 'users') || + !OWNER_RE.test(parts[1] ?? '') || + parts[2] !== 'projects' || + (parts.length !== 4 && !hasView) + ) { + return null + } + const number = positiveInteger(parts[3]) + const viewNumber = hasView ? positiveInteger(parts[5]) : null + if (!number || (hasView && !viewNumber)) { + return null + } + return { + owner: parts[1]!, + number, + host: url.host.toLowerCase(), + ...(viewNumber ? { viewNumber } : {}) + } + } catch { + return null + } +} diff --git a/mobile/src/tasks/github-project-repo-match.test.ts b/mobile/src/tasks/github-project-repo-match.test.ts index 7979f690d6e..bce71393dab 100644 --- a/mobile/src/tasks/github-project-repo-match.test.ts +++ b/mobile/src/tasks/github-project-repo-match.test.ts @@ -20,7 +20,10 @@ describe('GitHub project repo matching', () => { it('matches project rows by resolved repo slug before path/display heuristics', () => { expect( findRepoForGitHubProjectRepository('stablyai/orca', repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + } }) ).toBe(repos[0]) }) @@ -28,8 +31,14 @@ describe('GitHub project repo matching', () => { it('does not pick a repo when resolved slugs are ambiguous', () => { expect( findRepoForGitHubProjectRepository('stablyai/orca', repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }, - 'repo-2': { path: '/Users/me/other', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + }, + 'repo-2': { + path: '/Users/me/other', + repository: { owner: 'stablyai', repo: 'orca' } + } }) ).toBeNull() }) @@ -56,7 +65,10 @@ describe('GitHub project repo matching', () => { 'stablyai/orca', [{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }], { - 'repo-1': { path: '/Users/me/stablyai/orca', slug: 'fork/orca' } + 'repo-1': { + path: '/Users/me/stablyai/orca', + repository: { owner: 'fork', repo: 'orca' } + } } ) ).toBeNull() @@ -71,8 +83,46 @@ describe('GitHub project repo matching', () => { expect( filterGitHubProjectRowsForRepos(rows, repos, { - 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' } + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca' } + } }).map((row) => row.id) ).toEqual(['row-1']) }) + + it('matches same-named repositories only on the active Project host', () => { + expect( + findRepoForGitHubProjectRepository( + 'stablyai/orca', + repos, + { + 'repo-1': { + path: '/Users/me/orca', + repository: { owner: 'stablyai', repo: 'orca', host: 'github.com' } + }, + 'repo-2': { + path: '/Users/me/other', + repository: { + owner: 'stablyai', + repo: 'orca', + host: 'github.acme-corp.com' + } + } + }, + 'github.acme-corp.com' + ) + ).toBe(repos[1]) + }) + + it('does not use hostless path heuristics for Enterprise Project rows', () => { + expect( + findRepoForGitHubProjectRepository( + 'stablyai/orca', + [{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }], + {}, + 'github.acme-corp.com' + ) + ).toBeNull() + }) }) diff --git a/mobile/src/tasks/github-project-repo-match.ts b/mobile/src/tasks/github-project-repo-match.ts index bf70f71dab7..c4d1e1d2ea6 100644 --- a/mobile/src/tasks/github-project-repo-match.ts +++ b/mobile/src/tasks/github-project-repo-match.ts @@ -1,3 +1,8 @@ +import { + githubRepoIdentityKey, + isDefaultGitHubHost +} from '../../../src/shared/github-repository-identity-key' + export type GitHubProjectRepoMatch = { id: string path: string @@ -6,13 +11,13 @@ export type GitHubProjectRepoMatch = { export type GitHubRepoSlugCacheEntry = { path: string - slug: string | null + repository: { owner: string; repo: string; host?: string } | null } type CachedSlugState = | { status: 'missing' } | { status: 'stale' } - | { status: 'resolved'; slug: string | null } + | { status: 'resolved'; repository: GitHubRepoSlugCacheEntry['repository'] } export function normalizeGitHubRepositorySlug(value: string | null | undefined): string | null { const trimmed = value?.trim() @@ -37,13 +42,14 @@ function cachedSlugStateForRepo( if (cached.path !== repo.path) { return { status: 'stale' } } - return { status: 'resolved', slug: normalizeGitHubRepositorySlug(cached.slug) } + return { status: 'resolved', repository: cached.repository } } export function findRepoForGitHubProjectRepository( repository: string | null | undefined, repos: GitHubProjectRepoMatch[], - slugsByRepoId: Record = {} + slugsByRepoId: Record = {}, + projectHost?: string ): GitHubProjectRepoMatch | null { const slug = normalizeGitHubRepositorySlug(repository) if (!slug) { @@ -53,9 +59,18 @@ export function findRepoForGitHubProjectRepository( const slugStates = new Map( repos.map((repo) => [repo.id, cachedSlugStateForRepo(repo, slugsByRepoId)]) ) + const requestedIdentityKey = githubRepoIdentityKey({ + owner: slug.split('/')[0]!, + repo: slug.split('/')[1]!, + ...(projectHost ? { host: projectHost } : {}) + }) const slugMatches = repos.filter((repo) => { const state = slugStates.get(repo.id) - return state?.status === 'resolved' && state.slug === slug + return ( + state?.status === 'resolved' && + state.repository !== null && + githubRepoIdentityKey(state.repository) === requestedIdentityKey + ) }) if (slugMatches.length === 1) { return slugMatches[0]! @@ -64,10 +79,16 @@ export function findRepoForGitHubProjectRepository( return null } + if (!isDefaultGitHubHost(projectHost)) { + // Why: display names and local paths contain no host evidence, so using + // them for GHES rows could bind an Enterprise item to a github.com repo. + return null + } + return ( repos.find((repo) => { const state = slugStates.get(repo.id) - if (state?.status === 'resolved' && state.slug !== null) { + if (state?.status === 'resolved' && state.repository !== null) { return false } const display = repo.displayName.trim().toLowerCase() @@ -82,9 +103,12 @@ export function filterGitHubProjectRowsForRepos< >( rows: readonly Row[], repos: GitHubProjectRepoMatch[], - slugsByRepoId: Record = {} + slugsByRepoId: Record = {}, + projectHost?: string ): Row[] { return rows.filter((row) => - Boolean(findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId)) + Boolean( + findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId, projectHost) + ) ) } diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts index f7c724e7637..4cd34cb0057 100644 --- a/mobile/src/tasks/smart-source-paste-intent.test.ts +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { deriveRepoSlug, findRepoMatchingSlug, findRepoMatchingSlugForPaste, + lookupGitHubItemByOwnerRepo, resolvePasteIntent, type PasteRepoCandidate } from './smart-source-paste-intent' @@ -12,7 +13,7 @@ describe('resolvePasteIntent', () => { it('classifies a GitHub issue/PR URL as a github-link', () => { expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({ kind: 'github-link', - link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' } + link: { slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, number: 12, type: 'pr' } }) }) @@ -47,6 +48,14 @@ describe('deriveRepoSlug', () => { }) }) + it('preserves an Enterprise upstream host', () => { + expect( + deriveRepoSlug({ + upstream: { owner: 'up', repo: 'stream', host: 'github.corp.example' } + }) + ).toEqual({ owner: 'up', repo: 'stream', host: 'github.corp.example' }) + }) + it('parses an SSH remote URL', () => { expect( deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } }) @@ -78,6 +87,16 @@ describe('findRepoMatchingSlug', () => { expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() }) + it('does not match a GHES link to a same-named github.com repo', () => { + expect( + findRepoMatchingSlug(repos, { + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example' + }) + ).toBeNull() + }) + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { const calls: string[] = [] const client = { @@ -86,7 +105,10 @@ describe('findRepoMatchingSlug', () => { calls.push(repo) return { ok: true, - result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + result: + repo === 'id:b' + ? { owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' } + : null } } } as unknown as RpcClient @@ -94,7 +116,7 @@ describe('findRepoMatchingSlug', () => { findRepoMatchingSlugForPaste( client, repos, - { owner: 'enterprise', repo: 'widgets' }, + { owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' }, new Map() ) ).resolves.toMatchObject({ id: 'b' }) @@ -139,4 +161,26 @@ describe('findRepoMatchingSlug', () => { ) ).resolves.toBeNull() }) + + it('sends the pasted host through the exact owner/repo lookup RPC', async () => { + const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: null }) + const client = { sendRequest } as unknown as RpcClient + + await lookupGitHubItemByOwnerRepo( + client, + 'repo-1', + { owner: 'acme', repo: 'widgets', host: 'github.corp.example' }, + 7, + 'pr' + ) + + expect(sendRequest).toHaveBeenCalledWith('github.workItemByOwnerRepo', { + repo: 'id:repo-1', + owner: 'acme', + ownerRepo: 'widgets', + host: 'github.corp.example', + number: 7, + type: 'pr' + }) + }) }) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts index 72c23a3737f..00f4c0d9ee7 100644 --- a/mobile/src/tasks/smart-source-paste-intent.ts +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -9,6 +9,7 @@ import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitl import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' +import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key' // A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived // best-effort from the repo's remote metadata. @@ -58,11 +59,15 @@ export function resolvePasteIntent(query: string): PasteIntent { // Pure: derive an owner/repo slug from a repo's remote metadata so a pasted // cross-repo URL can be matched to a locally known repo. export function deriveRepoSlug(repo: { - upstream?: { owner: string; repo: string } | null + upstream?: { owner: string; repo: string; host?: string } | null gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null }): RepoSlug | null { if (repo.upstream?.owner && repo.upstream.repo) { - return { owner: repo.upstream.owner, repo: repo.upstream.repo } + return { + owner: repo.upstream.owner, + repo: repo.upstream.repo, + ...(repo.upstream.host ? { host: repo.upstream.host } : {}) + } } const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? '' const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source) @@ -76,9 +81,7 @@ function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean { if (!a || !b) { return false } - return ( - a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() - ) + return githubRepoIdentityKey(a) === githubRepoIdentityKey(b) } export function findRepoMatchingSlug( @@ -148,6 +151,7 @@ export async function lookupGitHubItemByOwnerRepo( repo: `id:${repoId}`, owner: slug.owner, ownerRepo: slug.repo, + ...(slug.host ? { host: slug.host } : {}), number, type }) diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts index 1d0c88dafd5..42edcd48e8a 100644 --- a/mobile/src/tasks/use-smart-workspace-source.ts +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -22,7 +22,11 @@ const DEBOUNCE_MS = 200 const RESULT_LIMIT = 36 export type SmartCrossRepoPrompt = { - link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' } + link: { + slug: { owner: string; repo: string; host?: string } + number: number + type: 'issue' | 'pr' + } matchingRepo: PasteRepoCandidate } @@ -73,7 +77,9 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { // the mode/repo changes so one provider's rows never render under another tab. const scopeRef = useRef('') const dismissedPasteRef = useRef('') - const repoSlugCacheRef = useRef>(new Map()) + const repoSlugCacheRef = useRef< + Map + >(new Map()) useEffect(() => { if (!client || !enabled || mode === 'text') { @@ -185,7 +191,7 @@ async function runSmartSearch(args: { linearWorkspaceId: string | null | undefined repos: readonly PasteRepoCandidate[] dismissedPasteRef: { current: string } - repoSlugCache: Map + repoSlugCache: Map }): Promise<{ fan: SmartFanOutResult paste: PasteResolved diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 121d0e42661..eacb68ab0b9 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -128,6 +128,90 @@ beforeEach(() => { }) describe('useHostClient', () => { + it('rebinds when Expo reuses a screen between two connected cached hosts', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + const client1 = makeFakeClient('connected') + const client2 = makeFakeClient('connected') + connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let selectedHostId = HOST.id + let selectedClient: RpcClient | null = null + let selectedState: ConnectionState = 'disconnected' + let renderer: ReactTestRenderer | null = null + function Probe(): null { + const selected = useHostClient(selectedHostId) + selectedClient = selected.client + selectedState = selected.state + useHostClient(host2.id) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(selectedClient).toBe(client1) + expect(selectedState).toBe('connected') + + selectedHostId = host2.id + client2.emitState('disconnected') + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + + expect(selectedClient).toBe(client2) + expect(selectedState).toBe('disconnected') + expect(connectMock).toHaveBeenCalledTimes(2) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('stays disconnected while a reused screen resolves an uncached host', async () => { + const client = makeFakeClient('connected') + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValueOnce([HOST]).mockReturnValueOnce(new Promise(() => {})) + + let selectedHostId = HOST.id + let renderTick = 0 + const stateByRenderTick = new Map() + let renderer: ReactTestRenderer | null = null + function Probe(): null { + stateByRenderTick.set(renderTick, useHostClient(selectedHostId).state) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(stateByRenderTick.get(0)).toBe('connected') + + selectedHostId = 'missing-host' + renderTick = 1 + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(stateByRenderTick.get(1)).toBe('disconnected') + + renderTick = 2 + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(stateByRenderTick.get(2)).toBe('disconnected') + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + it('drops the closed client when the host entry is removed', async () => { const fake = makeFakeClient('connected') connectMock.mockReturnValue(fake) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 80ec7f11c1f..eec5ba40469 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -344,13 +344,16 @@ export function useHostClient(hostId: string | undefined): { hostId ? ctx.getState(hostId) : 'disconnected' ) const clientRef = useRef(null) + const clientHostIdRef = useRef(hostId) useEffect(() => { if (!hostId) { clientRef.current = null + clientHostIdRef.current = undefined setState('disconnected') return } + clientHostIdRef.current = hostId let cancelled = false // Subscribe before acquire so any state change during open is captured. const unsub = ctx.subscribeHostState(hostId, (next) => { @@ -370,28 +373,29 @@ export function useHostClient(hostId: string | undefined): { } }) const initial = ctx.acquire(hostId) + clientRef.current = initial + setState(ctx.getState(hostId)) if (initial) { - clientRef.current = initial - setState(ctx.getState(hostId)) + // Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client. + force((n) => n + 1) } return () => { cancelled = true unsub() ctx.release(hostId) clientRef.current = null + clientHostIdRef.current = undefined } }, [ctx, hostId]) - return { client: clientRef.current, state } + // Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render. + const bound = clientHostIdRef.current === hostId + const boundState = bound ? state : hostId ? ctx.getState(hostId) : 'disconnected' + return { client: bound ? clientRef.current : null, state: boundState } } // Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. -export function useAllHostClients(hostIds: string[]): Array<{ - hostId: string - client: RpcClient - state: ConnectionState - path: MobileConnectionPath -}> { +export function useAllHostClients(hostIds: string[]) { const ctx = useRpcClientContext() // Stable key so we don't tear down on every render of the array. const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts new file mode 100644 index 00000000000..ce5974b1f84 --- /dev/null +++ b/mobile/src/transport/host-status-gates.test.ts @@ -0,0 +1,179 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from './rpc-client' +import { useHostStatusGates, type HostStatusGates } from './host-status-gates' + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +describe('useHostStatusGates', () => { + it('clears every prior-host gate and ignores its late response while the client is replaced', async () => { + let resolveOldStatus: ((response: unknown) => void) | null = null + const pendingOldStatus = new Promise((resolve) => { + resolveOldStatus = resolve + }) + const oldSendRequest = vi.fn().mockReturnValue(pendingOldStatus) + const oldClient = { sendRequest: oldSendRequest } as unknown as RpcClient + const newSendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + capabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + } + }) + const newClient = { sendRequest: newSendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + const firstRenderByHost = new Map() + let renderer: ReactTestRenderer | null = null + + function Probe({ hostId, client }: { hostId: string; client: RpcClient }): null { + gates = useHostStatusGates({ hostId, client, connState: 'connected' }) + if (!firstRenderByHost.has(hostId)) { + firstRenderByHost.set(hostId, gates) + } + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { hostId: 'host-1', client: oldClient })) + }) + + await act(async () => { + renderer?.update(createElement(Probe, { hostId: 'host-2', client: newClient })) + await Promise.resolve() + }) + expect(firstRenderByHost.get('host-2')).toMatchObject({ + hostCapabilities: [], + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' } + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + + await act(async () => { + resolveOldStatus?.({ + ok: true, + result: { + capabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true + } + }) + await pendingOldStatus + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + expect(oldSendRequest).toHaveBeenCalledOnce() + expect(newSendRequest).toHaveBeenCalledOnce() + } finally { + restore() + renderer?.unmount() + } + }) + + it('loads gates from the connected host', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + capabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true + } + }) + const client = { sendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + let renderer: ReactTestRenderer | null = null + + function Probe({ hostId }: { hostId: string }): null { + gates = useHostStatusGates({ hostId, client, connState: 'connected' }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { hostId: 'host-1' })) + await Promise.resolve() + }) + expect(gates).toMatchObject({ + hostCapabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true + }) + + expect(sendRequest).toHaveBeenCalledOnce() + } finally { + restore() + renderer?.unmount() + } + }) + + it('fails closed while the same client reconnects', async () => { + let resolveReconnect: ((response: unknown) => void) | null = null + const pendingReconnect = new Promise((resolve) => { + resolveReconnect = resolve + }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { capabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true } + }) + .mockReturnValueOnce(pendingReconnect) + const client = { sendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + let renderer: ReactTestRenderer | null = null + + function Probe({ connState }: { connState: 'connected' | 'disconnected' }): null { + gates = useHostStatusGates({ hostId: 'host-1', client, connState }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { connState: 'connected' })) + await Promise.resolve() + }) + expect(gates?.floatingWorkspaceEnabled).toBe(true) + + await act(async () => { + renderer?.update(createElement(Probe, { connState: 'disconnected' })) + }) + await act(async () => { + renderer?.update(createElement(Probe, { connState: 'connected' })) + }) + expect(gates).toMatchObject({ + hostCapabilities: [], + floatingWorkspaceEnabled: false + }) + + await act(async () => { + resolveReconnect?.({ + ok: true, + result: { capabilities: ['terminal.quick-commands.v1'], floatingWorkspaceEnabled: true } + }) + await pendingReconnect + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + } finally { + restore() + renderer?.unmount() + } + }) +}) diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts new file mode 100644 index 00000000000..7a02cc87abb --- /dev/null +++ b/mobile/src/transport/host-status-gates.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, RpcSuccess } from './types' +import { evaluateCompat, type CompatVerdict } from './protocol-compat' +import type { DesktopStatus } from '../worktree/host-worktree-rpc-types' + +export type HostStatusGates = { + hostCapabilities: string[] + floatingWorkspaceEnabled: boolean + compatVerdict: CompatVerdict +} + +type LoadedHostStatusGates = HostStatusGates & { + hostId: string | undefined + client: RpcClient +} + +const EMPTY_HOST_CAPABILITIES: string[] = [] + +// Reads status.get on connect for capabilities, protocol-compat verdict, and the +// floating-workspace flag. Compat constants are wide-open today so this never blocks yet. +export function useHostStatusGates(args: { + hostId: string | undefined + client: RpcClient | null + connState: ConnectionState +}): HostStatusGates { + const { hostId, client, connState } = args + const [loaded, setLoaded] = useState(null) + + useEffect(() => { + if (connState !== 'connected' || !client) { + // Why: reconnecting the same host/client must revalidate gates instead of reviving its prior status response. + setLoaded(null) + return + } + let cancelled = false + const requestClient = client + void (async () => { + try { + const response = await requestClient.sendRequest('status.get') + if (cancelled) { + return + } + if (!response.ok) { + return + } + const status = (response as RpcSuccess).result as DesktopStatus & { + capabilities?: string[] + } + const verdict = evaluateCompat({ + desktopProtocolVersion: status.protocolVersion, + desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion + }) + setLoaded({ + hostId, + client: requestClient, + hostCapabilities: status.capabilities ?? [], + floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, + compatVerdict: verdict + }) + if (verdict.kind === 'blocked') { + // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. + console.warn('[protocol-compat] blocked', { + reason: verdict.reason, + desktopVersion: verdict.desktopVersion, + requiredMobileVersion: verdict.requiredMobileVersion, + requiredDesktopVersion: verdict.requiredDesktopVersion + }) + } + } catch { + // Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden. + } + })() + return () => { + cancelled = true + } + }, [client, connState, hostId]) + + // Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse. + if ( + connState !== 'connected' || + !client || + !loaded || + loaded.hostId !== hostId || + loaded.client !== client + ) { + return { + hostCapabilities: EMPTY_HOST_CAPABILITIES, + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' } + } + } + return { + hostCapabilities: loaded.hostCapabilities, + floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled, + compatVerdict: loaded.compatVerdict + } +} diff --git a/mobile/src/worktree/host-worktree-rpc-types.ts b/mobile/src/worktree/host-worktree-rpc-types.ts index 9d0d1a4f66d..037e4dcfa87 100644 --- a/mobile/src/worktree/host-worktree-rpc-types.ts +++ b/mobile/src/worktree/host-worktree-rpc-types.ts @@ -5,6 +5,9 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' export type DesktopStatus = { protocolVersion?: number minCompatibleMobileVersion?: number + // Why: absent on hosts that predate the mobile Floating Workspace entry; + // treat absence as unsupported and hide the entry. + floatingWorkspaceEnabled?: boolean } export type RepoSummary = { diff --git a/mobile/src/worktree/mobile-worktree-activation-source.test.ts b/mobile/src/worktree/mobile-worktree-activation-source.test.ts index d32d8c4cdaa..0b06f21f70b 100644 --- a/mobile/src/worktree/mobile-worktree-activation-source.test.ts +++ b/mobile/src/worktree/mobile-worktree-activation-source.test.ts @@ -20,5 +20,6 @@ describe('mobile worktree activation', () => { expect(openSession).toContain("sendRequest('worktree.activate'") expect(openSession).toContain('notifyClients: false') + expect(openSession).toContain("navigation: 'caller'") }) }) diff --git a/notes/skill-auto-update-design.md b/notes/skill-auto-update-design.md deleted file mode 100644 index eb51c9b592c..00000000000 --- a/notes/skill-auto-update-design.md +++ /dev/null @@ -1,709 +0,0 @@ -# Skill Auto-Update Design - -Status: SUPERSEDED in direction by `skill-freshness-design.md` (2026-07-13): Phases 2-4 -(background writes, WSL, SSH reconcilers) are retired; Phase-1 detection and all empirical -findings below remain valid inputs. Original status: revised after design, OSS review, and -macOS + Windows + Linux empirical validation (2026-07-12). Phase 1 + background updates were -implemented on PR #8496 (branch brennanb2025/skill-auto-update-research), now archived as -reference for the write machinery. Windows validation confirmed the skills CLI writes CRLF (drove the -text-normalized package-identity rule); Linux confirmed verbatim-LF installs and the -`XDG_STATE_HOME` lock-location rule. - -## Problem - -Orca ships agent skills in `skills/` (orca-cli, orchestration, computer-use, orca-linear, -linear-tickets, orca-per-workspace-env, orca-emulator, orca-emulator-android). Users install -them with the skills CLI: - -```sh -npx skills add https://github.com/stablyai/orca --skill --global -``` - -Nothing then keeps those installations aligned with the Orca release they describe. Settings -surface a manual `npx skills update --global` command, but users receive no update -signal. A stale skill can therefore tell an agent to use commands that are wrong or unsafe for -the Orca binary it is driving. - -## Goals - -1. Keep an Orca skill current after Orca has safely adopted or installed that physical copy. -2. Never overwrite a modified, unknown, externally managed, project-scoped, or third-party - skill. -3. Never install a skill the user did not request. -4. Work on macOS, Linux, Windows, WSL, and SSH, with reconciliation performed on the host - where the agent reads the skill. -5. Coexist with the skills CLI, symlink and copy installs, dotfile managers, read-only or - generated configurations, and multiple Orca builds sharing a home directory. -6. Keep skill content compatible with the Orca app release that is allowed to manage it. - -Non-goals: updating third-party skills; replacing the skills CLI as the normal install path; -managing repo-scoped `.agents/skills` or `.claude/skills`; mutating plugin caches; merging user -edits into a new skill release. - -## Research conclusion - -The common package-manager pattern is not to scan arbitrary same-named directories and infer -that they are writable. The updater manages packages inside an ownership boundary established -at install or adoption time, records package identity outside user content, stages a complete -replacement, and treats external links/custom installations as unmanaged. - -The skills CLI lock is useful supporting evidence, but it is not an ownership ledger: - -- The global lock is `$XDG_STATE_HOME/skills/.skill-lock.json` when `XDG_STATE_HOME` is set, - otherwise `~/.agents/.skill-lock.json`. -- A v3 entry records source, source URL/type, Git ref, path within the repo, an upstream folder - hash, and timestamps. -- It does not record a physical install path, symlink/copy topology, per-skill global agent - placements, the current on-disk hash, local modifications, or app compatibility. -- `skills update` compares the stored source hash with upstream and then re-runs installation. - It does not prove that installed files are unchanged first. - -Orca therefore reads supported lock versions as a provenance hint but never treats a lock -entry by itself as permission to write. Orca never writes the foreign lock format. - -## Decision: detect, adopt, then manage - -Only a physical destination recorded in Orca's management ledger is eligible for background -writes. A destination enters that ledger in one of two ways: - -1. Orca's install UI invokes the skills CLI and then verifies and records the resulting - physical installation. -2. A legacy installation is adopted after its complete on-disk package matches a known, - released Orca snapshot and its topology is eligible. Phase 1 makes this an explicit - “Manage and update” action. Background auto-update never silently claims a newly - discovered path. - -This one-time adoption cost is intentional. Without it, no app can distinguish an official -copy from a same-named user copy or establish which manager is allowed to replace it. - -Do not rely on users discovering adoption passively in settings. When the bounded inventory -finds exact official snapshots in eligible topologies, show one non-repeating banner/toast: -“N installed Orca skills can be kept up to date,” with a one-click review/adoption action. -Prioritize recording ownership immediately after every successful Orca-driven skills CLI -install so new installations never require a later adoption step. - -“Non-repeating” is scoped per eligible destination snapshot, not globally or per session. Store -a dismissed-adoption tuple containing host identity, physical destination identity, skill name, -and matched snapshot digest. Do not prompt again for that unchanged tuple, but allow a future -prompt when a newly installed skill or genuinely different official snapshot creates a tuple -the user has never dismissed. Removing another candidate alone does not clear prior dismissals. - -## Build and release artifacts - -Package `skills/` into app resources on every supported platform and generate a current bundle -manifest. For each skill it contains: - -- canonical name and repo-relative source path; -- release revision and Orca app version; -- deterministic whole-package digest (composed from the per-file identities below); -- every regular file's relative path, size, executable bit, a per-file text/binary - classification, an exact-byte SHA-256, and — for text files — a text-normalized SHA-256 with - line endings folded to LF; -- the upstream Git tree SHA when available; -- minimum/maximum compatible app version if a skill is not backward-compatible; -- schema version. - -The build rejects absolute paths, traversal, case-colliding paths, special files, and symlinks -inside the shipped package. Executable modes and the exact bytes of binary files are part of -package identity. For text files, identity is the line-ending-normalized content, not the exact -bytes: supported installers apply platform- and Git-config-dependent EOL translation, so an -exact-byte hash is not stable across hosts. This is validated, not hypothetical — the skills CLI -writes CRLF on a default Windows Git install (see Empirical validation), so a macOS-built -exact-byte hash never matches a Windows install and would misclassify every Windows copy as -modified. Because the whole-package digest composes the per-file normalized-or-exact hashes, one -official snapshot has a single identity across macOS, Linux, and Windows and across -`core.autocrlf` settings. - -Maintain a compact, checked-in registry of every generated Orca skill snapshot plus a separate -release mapping that identifies which revisions actually shipped. Historical file bytes are -unnecessary; historical paths and hashes are sufficient to prove that an existing package is -an exact official snapshot and to map a skills CLI folder hash to an Orca release. Only a -revision present in the release mapping is eligible as legacy-install provenance; an -unreleased candidate cannot be adopted merely because it appeared on main. - -Release revision assignment is mechanical, not a hand-edited field. The manifest generator -compares each package digest with the latest generated registry entry: unchanged content keeps -its revision; changed content appends the next integer. The generator is the only writer of -registry entries, and existing entries are immutable. Pull-request CI verifies generated -output, but the same generation/monotonicity check must rerun against the merge-queue head and -on main pushes so two independently green PRs cannot record different content with the same -revision. Release creation adds the current revisions to the release mapping and fails unless -main's generated registry is current and the packaged manifest exactly matches it. - -Do not rely on `metadata.version` inside `SKILL.md` as the authority. Installers can transform -frontmatter, users can edit it, and a value inside the package cannot prove the rest of the -package is intact. - -The exact-snapshot model depends on supported skills CLI installations preserving the shipped -package. Add a release CI round trip on macOS, Linux, and Windows that installs representative -single- and multi-file Orca skills through a pinned supported CLI version in both symlink and -copy/fallback shapes, then compares paths, bytes, and applicable executable modes with the -generated bundle manifest. Also exercise the newest CLI as an early-warning job. The bundle -manifest remains generated from Orca's shipped source; if an installer intentionally transforms -content, model that installation shape explicitly or mark it ineligible rather than silently -changing the authoritative digest. A mismatch blocks background-update rollout for that shape. -macOS verbatim behavior and Windows CRLF translation are both confirmed (see Empirical -validation); the round trip must assert LF-normalized identity holds — not exact bytes — and -cover `core.autocrlf` on/off plus the copy-fallback and junction shapes as a regression guard. - -## Eligible roots and topology - -Use an explicit registry of global, user-owned skill roots. Do not derive writable roots from -all discovery sources: discovery also includes repo roots and the Codex plugin cache, neither -of which this feature may mutate. - -Classify every discovered physical destination before offering adoption: - -| Installation topology | Behavior | -| --- | --- | -| Canonical `~/.agents/skills/` with a known official snapshot | Eligible for adoption | -| Provider symlink/junction resolving to that canonical copy | Dedupe; manage the canonical copy once | -| Independent copy in an approved global provider root with a known official snapshot | Eligible for separate adoption | -| Modified, incomplete, or unknown same-named copy | Never auto-write; show diff/replacement action | -| Symlink/junction into dotfiles, a checkout, Nix/Home Manager output, network storage, or another external tree | Unmanaged; never write through the link | -| Broken/dangling provider symlink (target missing) | Inaccessible; never adopt or write through; offer provenance-verified repair only with consent | -| Read-only/generated root | Detection only | -| Repo-scoped skill or plugin cache | Out of scope; never mutate | - -Hardlinks, directory junctions, case-insensitive aliases, and symlinked parent directories must -be deduplicated by physical identity where the host API exposes it, with normalized real paths -as the fallback. Revalidate the entry type and resolved parent immediately before every -mutation so a link swap cannot redirect a verified write. - -Management and agent visibility are separate. Updating a canonical `~/.agents/skills` copy -does not make it visible to an agent that reads only its provider-specific root. If no verified -provider link/copy exists, settings must say “managed but not visible to ” rather than -reporting the provider as current. Creating or repairing a provider link is a separate, -provenance-verified, user-approved follow-up; auto-update never invents a missing placement. - -## Orca management ledger - -Store state in Orca-owned application state on the execution host, never inside a user skill -directory. Use one record per adopted physical destination: - -- stable execution-host identity and user/home identity; -- logical root kind and unresolved destination path; -- last verified physical identity, entry type, and resolved path; -- skill name, source, source path, and source ref/hash evidence; -- installed release revision and whole-package digest; -- per-file paths, hashes, modes, and the digest Orca last wrote; -- last attempted bundle fingerprint, outcome, and error category; -- adoption source and timestamp. - -Local, WSL distro, and SSH records are isolated. SSH identity must include the persisted target -identity plus the resolved remote user/home; an in-memory provider object or relay connection -ID is not durable identity. Host-side state lets a remote remember ownership across desktop -reinstalls and reconnects. - -Corrupt, missing, migrated, or mismatched state fails closed. It can be reconstructed only by -the same exact-snapshot adoption rules; it never grants ownership from a name alone. - -## Detection and adoption - -For each Orca skill found in an approved global root: - -1. `lstat` the logical path and classify its topology without following external targets for - mutation. -2. Dedupe aliases that resolve to the same eligible canonical destination. -3. Read the supported skills CLI lock as optional evidence. Bind a lock entry only to the - canonical installation shape it describes; never apply one name-level entry to every - same-named copy. -4. Hash the complete physical package — exact bytes for binary/executable files, LF-normalized - content for text files — and compare with the released-manifest registry, so installer EOL - translation never misclassifies an official Windows install as modified. -5. Classify it as current, update available, newer known release, modified, unknown, - externally managed, or inaccessible. -6. Offer “Manage and update” only for an exact known official snapshot in an eligible - topology. Show a diff and explicit destructive replacement action for modified/unknown - content; that action is not adoption and must preserve a backup until success. - -Never send skill contents, diffs, paths, or user edits to telemetry or normal logs. - -## Reconcile algorithm for adopted destinations - -For each adopted physical destination whose bundle revision is newer and app-compatible: - -1. Acquire an Orca transaction lock scoped to the execution host and destination. Dedupe - in-flight work within the process as well. Other managers do not honor this lock, so also - revalidate immediately before publish. -2. Re-read topology and hash the installed package. Continue only when it equals the exact - digest in the ledger. Any local edit, extra file, missing file, skills CLI update, or link - change turns the destination into a conflict and cancels the write. -3. Stage the complete bundled package in a unique directory under the reserved transaction - workspace on the same filesystem. Write exact bytes and modes, then validate the staged - package against the manifest. -4. Recheck the live destination digest and identity after staging. If either changed, delete - the staging directory and report a concurrent modification. -5. Publish using the strongest package-level replacement supported by that host. Consumers - require a fixed skill path, so a universally atomic directory swap is not possible, - especially on Windows. Where package-level replacement is unavailable, first preserve a - complete rollback copy, then use this required order: - 1. publish every new/changed non-`SKILL.md` file with same-directory temp + rename; - 2. publish `SKILL.md` with same-directory temp + rename as the semantic commit marker; - 3. unlink only obsolete files recorded in the old ledger, rechecking that each still has - its old verified hash, then remove only empty recorded directories. - This order ensures a removed file cannot make the new digest permanently unreachable and - avoids deleting an asset while the new entry point is not yet present. It does not make a - multi-file update atomic: a reader may briefly observe the old entry point with new assets, - or the new entry point with harmless obsolete assets. Skills requiring cross-file atomicity - must use package-level replacement; if the host cannot provide it, skip and retry rather - than use the in-place fallback. Do not claim stronger consistency than the host provides. -6. Verify the live package digest. Only then update the destination ledger and remove the - backup. On failure, restore the old package when possible, retain the prior ledger digest, - and leave the destination retryable. -7. Notify discovery and show one aggregated toast for successfully updated skills. Running - agent sessions pick changes up at their next skill load/session start. - -Never repair equal-revision drift automatically. Equal revision plus different content is a -conflict, not proof of a partial write. Never downgrade a known newer release. - -Line endings are a rendering of text content, not part of it. The bundle ships LF, but supported -installers write platform-native endings (validated: CRLF on default Windows Git). Compare text -provenance and drift on LF-normalized content; stage and publish text files in the destination's -existing EOL convention, defaulting to what a fresh supported install would produce on that host -when adopting a copy that has none; write binary and executable files as exact bytes. This keeps -an updated file byte-shaped like a fresh CLI install, so an EOL difference alone never counts as -a conflict and Orca and the skills CLI do not reclassify each other's writes. - -### Transaction workspace and crash recovery - -Staging and rollback packages must be on the same filesystem/volume as the live destination, -but must not appear as candidate skills. Use an Orca-reserved transaction root adjacent to the -skill root when possible (for example, beside `skills/`, not as another child skill), verify -same-filesystem identity, and fall back to a reserved child only when the skill root is itself -a mount boundary. Both general skill discovery and updater inventory must hard-exclude the -reserved transaction root; a leading dot alone is not an exclusion rule. - -Each transaction directory contains an Orca marker with schema version, transaction ID, -destination identity, creation time, and an atomically advanced transaction phase before it -receives skill files. On host startup and before reconciliation, sweep only marked orphan -transactions whose owning lock is absent/stale: restore a verified rollback package when the -marker/ledger phase says publication was incomplete, otherwise remove the verified -staging/backup directory. Never delete an unmarked directory based on its name, age, or -resemblance to a skill. Coordinate cleanup with the same destination lock so one Orca process -cannot sweep another process's live transaction. - -### Removed files and retired skills - -Package-level replacement naturally omits files removed by a newer managed package. The -in-place fallback explicitly removes old-ledger files after publishing the new `SKILL.md`, as -specified above. Never delete an unrecorded extra file from a live destination; its presence -causes the pre-publish digest check to fail before any write. - -Retired skills are detection/prompt-only in the initial implementation. A future cleanup may -delete only individually recorded, unchanged files and then empty directories. It must never -recursively delete a skill directory or follow a link target. - -## Fast path and triggers - -State is per destination, not one success bit for an entire host. A failed, inaccessible, or -partially reconciled destination remains retryable even when other destinations succeeded. - -At launch, after first paint: - -1. Perform a bounded inventory of approved global roots to detect newly installed, removed, - or topology-changed Orca skills. -2. For adopted destinations, skip content hashing only when that destination already records - successful reconciliation with the current bundle and its cheap identity/stat signature is - unchanged. -3. Hash only new, changed, failed, or bundle-mismatched candidates. - -Also run/invalidate on: - -- successful Orca-driven skill installation; -- `notifyInstalledAgentSkillsChanged()` after an install/update terminal exits; -- WSL distro first activation; -- SSH connection after the host runtime is ready; -- restart into a newly installed Orca app version. - -Do not reconcile on the updater's “download complete” event: the running process still owns -the old app resources until restart. Coalesce triggers and cap concurrency so launch, WSL, and -SSH activation cannot fan out unbounded filesystem or network work. - -## Host execution - -- **macOS/Linux:** use Node filesystem APIs and platform app-state paths; do not shell out. -- **Windows:** use `path` APIs, preserve exact bytes, support junction/copy topology, handle - case-insensitive identity and long/UNC paths, and retry bounded `EPERM`/`EBUSY` replacement - failures. A skipped destination remains retryable and never advances its ledger digest. A - long-running agent may keep an obsolete file open; if its hash-verified unlink still fails - after bounded retries, roll back the whole update and retry after the handle is released. -- **WSL:** run reconciliation inside the selected distro through a host-side runtime/RPC - operation. Do not mix Windows UNC mutation semantics with Linux locks, modes, and renames. - State is scoped to distro plus Linux user/home. -- **SSH:** run discovery, hashing, staging, locking, and publication on the remote host through - the runtime/filesystem abstraction. Transfer only the selected bundled package and manifest. - Do not assemble shell commands. Support Linux, macOS, and Windows SSH targets, and fail closed - when the remote runtime lacks a required safe filesystem primitive. - -## Multiple writers and app compatibility - -The same global roots may be touched by stable Orca, a development build, and the skills CLI. -Orca cannot guarantee that one shared global package simultaneously matches two incompatible -app binaries. - -- Production stable Orca is the only automatic writer by default. -- Main-process runtime identity is authoritative: `app.isPackaged`, the signed release channel, - and the resolved user-data/home roots determine whether writes are allowed. Renderer build - flags alone are insufficient. An unpackaged build or development channel is detection-only - unless both skill home and user-data roots are explicitly isolated from production. -- A stable app writes only a bundle declared compatible with that app version. -- A newer known installed release is never downgraded. -- If `npx skills update` changes an adopted package, the next Orca check sees a ledger-digest - mismatch and stops managing it until the new content matches a known released snapshot and - is explicitly re-adopted. -- Provenance and drift comparison fold text line endings to LF, so a stable app and the skills - CLI never treat each other's platform-native EOL output as a conflict; only real content - changes do. -- Skills should remain backward-compatible across supported stable app versions where - practical; compatibility metadata is still required for exceptions. - -This avoids version ping-pong. A monotonic number inside user content alone cannot solve -multiple incompatible writers. - -## Consent and settings UX - -The background setting is “Keep managed Orca agent skills up to date.” It controls only -already adopted/Orca-installed destinations and may default on. It does not authorize claiming -new paths. - -Settings show each physical installation as one of: - -- managed and current; -- managed, update available; -- known official copy, available to manage; -- modified/unknown, review required; -- externally managed/read-only; -- inaccessible or update failed. - -Managed and known-snapshot rows show the released skill revision, Orca app release, and a short -digest for human/support diagnosis. Do not add a second, non-authoritative version marker to -`SKILL.md`; it can be transformed independently of the package and mistaken for write -authority. - -Every successful background batch produces one toast. Conflicts and failures remain visible in -settings without repeated error toasts. Explicit replacement shows a local diff, warns that it -discards edits, and keeps a rollback backup until verification succeeds. - -## Empirical validation (2026-07-12, macOS + Windows + Linux) - -Both load-bearing assumptions were tested on a real developer machine against live installed -skills, not deferred to Phase 1 telemetry. Results are recorded so the evidence travels with -the design. - -- **Verbatim install (decisive).** A clean-room `npx skills add https://github.com/stablyai/orca - --skill orca-cli orchestration --global --yes` into a throwaway home produced files - byte-identical to `origin/main`: equal Git blob hashes, equal byte counts, zero CRLF, exactly - one `SKILL.md` per skill, and no injected or stripped files. The CLI does not transform content - on macOS, so exact-content provenance is viable. -- **Historical-snapshot match on real stale installs.** The machine's genuinely stale `orca-cli`, - `computer-use`, and `orchestration` have on-disk bytes that exist verbatim as committed Git - blobs in repo history. Exact-content adoption against a released-snapshot registry would - recognize real, messy installs — not just freshly installed ones. -- **Release-mapping guard justified by data.** Those stale blobs are reachable at commits dated - after their recorded install time, i.e. identical bytes existed in a checkout/branch before or - independently of shipping. Presence in history is therefore not proof of an official release; - adoption must gate on the release mapping, and content identity — never timestamps — is the - arbiter. This is exactly the hole the separate release mapping closes. -- **Topology and dedup.** Provider skills under `~/.claude/skills` are symlinks (both relative - `../../.agents/...` and absolute forms) into canonical `~/.agents/skills`; realpath resolution - collapses provider and canonical to one physical destination, confirming physical-identity - dedup yields a single write. A live broken/dangling provider symlink was also present (target - missing), which is why the topology table has an explicit inaccessible row. -- **Lockfile corroboration present (macOS).** Lock entries carry `source: stablyai/orca` and a - folder hash, usable only as corroboration, consistent with the design. - -**Windows (2026-07-12, validated on a real machine via the handoff below).** A clean-room -`npx skills add ... --skill orca-cli --global` on a default, non-Developer-Mode Windows install -produced: - -- **CRLF translation, not byte-identical.** Installed `SKILL.md` was 21180 bytes with 318 CR - bytes; repo-main source was 20862 bytes with 0 CR. The size delta equals the CR count exactly, - i.e. a pure LF→CRLF translation with identical text. This is why text-file package identity is - LF-normalized rather than exact-byte; an exact-byte model would have adopted nothing on Windows. -- **Copy-fallback shape.** `.agents\skills\orca-cli` was a plain directory copy (no link), and no - `.claude` provider copy was created (Developer Mode off, process unelevated). Confirms the - Windows copy path and the need to manage independent per-root copies, not only a canonical - symlink target. -- **Same file set.** Only `SKILL.md` in both source and install — no injected or stripped files. -- **Lockfile populated.** The lock recorded `orca-cli`; the known Windows empty-lockfile bug did - not reproduce on this machine/version. Corroboration signal is therefore sometimes available on - Windows, but the design still relies on content-match as primary since it is not guaranteed. - -Still gated behind the cross-platform CI round trip as a regression guard, and untested: -`core.autocrlf=false` on Windows (would install LF), and the junction/symlink shape under -Developer Mode. LF-normalized identity covers the autocrlf variance by construction. - -**Linux (2026-07-12, throwaway Docker container, reached over SSH).** Ran the same clean-room -install on `Linux 6.12 aarch64` (node 22, npx 10.9, git 2.39). The container served SSH (sshd -listening); the CLI check was executed on the box, since SSH transport does not change what the -CLI writes to disk: - -- **Verbatim LF, like macOS.** `orca-cli` and `orchestration` installed byte-identical to - `origin/main`: equal SHA-256, exact byte counts (20862 / 22850), **CR=0**, only `SKILL.md` in - each dir. Linux needs no separate manifest shape — it is covered by the LF identity. -- **Symlink topology.** `~/.claude/skills/orca-cli` is a relative symlink - (`../../.agents/skills/orca-cli`) into the canonical `~/.agents/skills` copy, matching macOS; - realpath dedup collapses provider and canonical to one write. -- **XDG lockfile path confirmed, with a sharper rule.** With `XDG_STATE_HOME` unset the lock is - `~/.agents/.skill-lock.json` (`source: stablyai/orca`); with `XDG_STATE_HOME` set the lock is - at `$XDG_STATE_HOME/skills/.skill-lock.json` and **not** at `~/.agents` — it moves, it is not - duplicated. So the corroboration reader must resolve `XDG_STATE_HOME` and read the single - correct location; checking only `~/.agents` finds no lock at all on such hosts. This is a - property of the Linux/host environment, not of Orca's SSH transport, so it applies equally to - native Linux, WSL, and SSH Linux targets — the reconciler must resolve the remote host's - `XDG_STATE_HOME` when reading lock corroboration remotely. - -Not exercised here: Orca's own remote reconciler over SSH (Phase 4, unbuilt — nothing to drive -yet). This validated the Linux CLI install shape and the remote lock-location rule the reconciler -will depend on. - -## Windows validation handoff - -Result (2026-07-12): **FAIL — CRLF translation**, resolved by LF-normalized text identity (see -Build and release artifacts and Empirical validation). The procedure is retained for CI -regression and for the still-untested `core.autocrlf=false` and Developer-Mode junction shapes. - -Give this to an agent on a Windows machine. It is self-contained, non-destructive (sandboxes the -skills CLI to a throwaway profile), and requires only PowerShell, Node/npx, and Git. It answers -one question: does `npx skills add` on Windows write skill bytes identical to the repo source, or -does it translate line endings / change the file set — and where does the lockfile land. - -Goal and pass/fail: - -- PASS (design safe as written): the installed `SKILL.md` is byte-identical to the repo source - (equal SHA-256, equal byte count, zero CRLF), and the skill directory contains the same file - set as the source. -- FAIL (design must model the Windows shape explicitly or mark it ineligible): the installed file - differs only by CRLF/line endings, or the file set differs, or the lockfile `skills` object is - empty after a successful install (the known Windows lockfile-not-written failure), which means - lockfile corroboration is unavailable on Windows and content-match must carry provenance alone. - -Run this in PowerShell and paste the full transcript back: - -```powershell -$ErrorActionPreference = 'Stop' -$sandbox = Join-Path $env:TEMP ("orca-skilltest-" + [Guid]::NewGuid().ToString('N')) -New-Item -ItemType Directory -Path $sandbox | Out-Null -# Sandbox the CLI's global install to a throwaway profile so real skills are untouched. -$old = @{ USERPROFILE=$env:USERPROFILE; HOME=$env:HOME; XDG_STATE_HOME=$env:XDG_STATE_HOME } -$env:USERPROFILE = $sandbox; $env:HOME = $sandbox; Remove-Item Env:XDG_STATE_HOME -ErrorAction SilentlyContinue -try { - npx --yes skills add https://github.com/stablyai/orca --skill orca-cli --global --yes 2>&1 | Tee-Object "$sandbox\install.log" | Out-Null - - $installed = Join-Path $sandbox '.agents\skills\orca-cli\SKILL.md' - $truth = Join-Path $sandbox 'truth-SKILL.md' - # Ground truth = exact bytes Git stores on main (LF), fetched without transformation. - Invoke-WebRequest 'https://raw.githubusercontent.com/stablyai/orca/main/skills/orca-cli/SKILL.md' -OutFile $truth - - function Info($label,$f){ - if(!(Test-Path $f)){ Write-Host "$label`: MISSING"; return } - $bytes=[IO.File]::ReadAllBytes($f) - $crlf=($bytes | Where-Object {$_ -eq 13}).Count - Write-Host ("{0}: sha256={1} bytes={2} CR={3}" -f $label,(Get-FileHash $f -Algorithm SHA256).Hash.Substring(0,16),$bytes.Length,$crlf) - } - Write-Host "`n=== byte fidelity ===" - Info 'installed' $installed - Info 'repo-main ' $truth - $same = (Get-FileHash $installed -Algorithm SHA256).Hash -eq (Get-FileHash $truth -Algorithm SHA256).Hash - Write-Host ("VERDICT: {0}" -f ($(if($same){'VERBATIM (pass)'}else{'DIFFERS (inspect CR counts: CRLF-only diff = autocrlf translation)'}))) - - Write-Host "`n=== link shape (junction/symlink/copy) for provider + canonical ===" - foreach($p in @("$sandbox\.claude\skills\orca-cli","$sandbox\.agents\skills\orca-cli")){ - if(Test-Path $p){ $i=Get-Item $p; Write-Host ("{0} -> LinkType={1} Target={2}" -f $p,$i.LinkType,($i.Target -join ',')) } - else { Write-Host "$p -> (absent)" } - } - - Write-Host "`n=== file set in installed skill dir (extra/stripped files?) ===" - Get-ChildItem (Join-Path $sandbox '.agents\skills\orca-cli') -Recurse -File | ForEach-Object { $_.FullName.Substring($sandbox.Length) } - - Write-Host "`n=== lockfile location + whether skills object populated (Windows #-not-written bug) ===" - foreach($lp in @("$sandbox\.agents\.skill-lock.json", "$env:XDG_STATE_HOME\skills\.skill-lock.json")){ - if($lp -and (Test-Path $lp)){ - $j=Get-Content $lp -Raw | ConvertFrom-Json - Write-Host ("{0} -> skills keys: {1}" -f $lp, (($j.skills.PSObject.Properties.Name) -join ',')) - } - } -} finally { - $env:USERPROFILE=$old.USERPROFILE; $env:HOME=$old.HOME; if($old.XDG_STATE_HOME){$env:XDG_STATE_HOME=$old.XDG_STATE_HOME} - Remove-Item $sandbox -Recurse -Force -ErrorAction SilentlyContinue - Write-Host "`n(sandbox removed; your real skills were never touched)" -} -``` - -Also report, in words: (1) is Windows Developer Mode on (decides whether junctions or copy -fallback occurred)? (2) the VERDICT line, (3) whether CR counts differ between installed and -repo-main (CRLF-only difference = `core.autocrlf` translation → design must treat the Windows -install shape as its own manifest or mark it ineligible), (4) the link shapes, (5) the file set, -(6) whether any lockfile `skills` object was populated. If Developer Mode can be toggled, run the -block once with it on and once off to capture both junction and copy-fallback shapes. - -## Historical rollout (superseded; do not implement) - -The steps below preserve the retired write-based rollout for research context only. The -active rollout is defined in `skill-freshness-design.md` and uses read-only detection plus a -user-invoked, targeted `npx skills update --global` command; it has no ledger, -adoption flow, background updater, transactional writer, WSL reconciler, or SSH reconciler. - -1. **Detection and adoption:** ship current/historical manifests, lockfile parsing, topology - classification, destination-scoped ledger, settings states, and an explicit “Manage and - update” action. No background writes. -2. **Local background updates:** enable adopted destinations on native macOS/Linux/Windows, - including transaction, rollback, concurrency, and restart tests. -3. **WSL:** move the same host-side reconciler into the distro runtime and validate Linux - semantics independently of UNC discovery. -4. **SSH:** expose the reconciler through remote runtime/RPC and validate Linux/macOS/Windows - remote hosts, reconnects, and multiple desktop clients. - -Do not advance phases based only on unit tests. Each phase needs a real-host package update, -failure injection between every transaction boundary, and proof that modified/external content -was not written. - -## Implementation touchpoints - -Grounded in the current codebase (verified 2026-07-12); an implementer starts here. Follow the -repo naming rule — concrete domain names, no `helpers`/`utils`. - -Main process (skill engine): - -- `src/main/skills/skill-discovery-sources.ts` — the approved writable-root registry lives here - or beside it. Today it enumerates `~/.codex/skills`, `~/.agents/skills`, `~/.claude/skills` - (sourceKind `home`) plus a Codex plugin cache (`plugin`) and repo roots. The updater must - include only `home` roots and exclude the plugin cache and repo roots. -- `src/main/skills/discovery.ts` and `src/main/ipc/skills.ts` (`registerSkillsHandlers(store)`, - wired in `src/main/ipc/register-core-handlers.ts`) — extend discovery to classify topology and - provenance, and add IPC for ledger states, the adoption action, and explicit replace. The - handler already receives the persistence `store`. -- New modules, e.g. `src/main/skills/skill-manifest.ts`, `skill-ledger.ts`, `skill-reconcile.ts` - — manifest load, content identity (LF-normalized text / exact-byte binary), transaction - workspace, publish/rollback. -- `src/shared/skill-metadata.ts` — existing top-level-only frontmatter parser; reuse for - name/description in settings. It need not read a version (identity is manifest-based, not - `metadata.version`). - -State and host identity: - -- `src/main/persistence.ts` (`store`, host-partitioned `orca-data.json`) — home for the - management ledger; already host-aware and passed to the skills handler. -- `src/shared/execution-host.ts` — `ExecutionHostId = 'local' | ssh: | runtime:`; add a - `wsl:` variant (none today) so ledger records key per host. Do not key off - `src/main/git/git-capability-state.ts`: it is in-memory only and scopes SSH by provider object - identity, which is not durable across reconnects or restarts. - -Renderer and UX: - -- The ~13 setting/feature surfaces that today print raw `npx skills ...` strings from - `src/shared/agent-feature-install-commands.ts` (CliSection, OrchestrationPane, BrowserUsePane, - EphemeralVmsPane, ComputerUseSkillSetupPanel, the linear/emulator CTAs, and the feature-wall / - feature-tip cards) become ledger-state rows with adoption/update actions. -- `src/renderer/src/hooks/useInstalledAgentSkills.ts` — `notifyInstalledAgentSkillsChanged()` is - the post-write refresh signal, already listened to on focus and on the install event. - -Build and CI: - -- `config/electron-builder.config.cjs` (+ `config/scripts/electron-builder-config.test.mjs`) — - extraResources is already contract-tested; add `skills/` bundling and the generated manifest. -- Manifest generator, released-snapshot registry, and the monotonic-revision check run on PR and - re-run on merge-queue / main pushes (the localization-catalog check in `pnpm lint` is the - precedent for a generated-output gate). -- Cross-platform round trip asserts LF-normalized identity (not exact bytes) for text files and - exact bytes for binary/executable files. - -Tests: - -- `tests/e2e/settings-skill-detection.spec.ts` — extend for update-available / adoption / - conflict / rollback states. -- Keep filesystem-transaction and host-isolation coverage as deterministic integration tests - below E2E, per the Test matrix. - -### Phase 1 definition of done - -Ship-ready, with no background writes, when: - -- The bundled manifest and released-snapshot registry are generated and CI-verified (monotonic - per-skill revisions, immutable history, release mapping). -- Discovery classifies every `home`-root Orca skill as current / update-available / newer-known / - modified / unknown / externally-managed / inaccessible, using LF-normalized text identity. -- The ledger records adopted destinations in the host-partitioned store, keyed by - `ExecutionHostId` (plus `wsl:` where applicable). -- Settings shows those states; a proactive non-repeating adoption nudge exists; "Manage and - update" and explicit destructive replace (with backup and diff) work on the local host. -- Orca-driven installs auto-record ownership. -- No path is written in the background and no path is adopted silently. - -Phase 1 exists to validate the two field assumptions before the background writer is built: -the byte/EOL identity match rate on real installs, and the adoption take-rate on the nudge. - -## Test matrix - -### Content and provenance - -- exact current and historical official snapshot; -- edited, missing, extra, truncated, and mode-changed file; -- foreign same-name skill; -- equal revision/different content; -- newer known and unknown release; -- missing, corrupt, old-version, XDG-located, and spoofed skills CLI lock; -- lost/corrupt Orca ledger and app reinstall; -- source ref/path changes and repo/skill rename; -- supported pinned and newest skills CLI round trips match bundle identity on macOS, Linux, and - Windows, including symlink and copy/fallback installation: exact bytes/modes for binary and - executable files, LF-normalized content for text files; -- CRLF vs LF install (`core.autocrlf` on and off) adopts and stays managed via normalized - identity; an EOL-only difference is never a conflict; a real content change still is; -- Orca-published text files keep the destination's existing EOL convention and do not trigger a - skills-CLI re-update loop. -- two same-skill PRs that independently change content cannot pass the merge queue/main - monotonicity gate with one revision; -- adoption dismissal suppresses the same destination snapshot but a newly installed official - destination remains eligible for one proactive prompt. - -### Topology - -- canonical copy with provider symlinks; -- independent copy mode and Windows symlink-to-copy fallback; -- parent-directory symlink, relative/absolute skill symlink, Windows junction, hardlink, and - case-variant alias; -- external dotfiles/chezmoi/stow target; -- Nix/Home Manager/generated and read-only roots; -- network/UNC home and long Windows paths; -- repo-scoped and plugin-cache same-name skills remain untouched; -- partial provider presence and custom provider home. - -### Transactions and concurrency - -- failure after stage, backup, publish, verify, ledger write, and cleanup; -- crash-orphaned staging and rollback directories are excluded from discovery, recovered or - swept from their markers, and never offered for adoption; -- unmarked lookalike directories under or near the reserved transaction path are never swept; -- `EPERM`, `EBUSY`, disk full, permission loss, and process crash; -- two Orca windows and duplicate triggers; -- stable/dev attempts and isolated dev home; -- skills CLI or user mutation before stage, during stage, and immediately before publish; -- failed destinations retry without reprocessing successful siblings. - -### Hosts and E2E - -- native macOS, Linux, and Windows; -- multiple WSL distros/users, distro shutdown mid-update, and state isolation; -- SSH Linux/macOS/Windows, disconnect/reconnect mid-update, old remote runtime, two desktop - clients, and host/user identity changes; -- settings update-available, adoption, managed-current, conflict, rollback, and retry states; -- proactive adoption nudge is non-repeating, opens review, and never claims a path by itself; -- canonical-only installs report provider visibility accurately and provider-link repair stays - separately consented; -- post-restart app update uses the new bundle, never the pre-restart bundle. - -Extend `tests/e2e/settings-skill-detection.spec.ts`, but keep filesystem transaction and -host-isolation coverage below E2E as deterministic integration tests. - -## Open questions - -- Exact platform paths and schema migration policy for the host-local management ledger. -- Whether the skills CLI can expose a supported machine-readable placement/ownership API in - the future; until then its private lock remains read-only supporting evidence. -- Which custom agent homes Orca can identify from the actual launch environment rather than - ambient desktop environment variables. -- Exact provider-link repair scope after a canonical install is managed but not visible to an - agent; this remains separately consented from content updates. -- Toast copy and whether settings should link to a per-skill changelog. diff --git a/notes/skill-freshness-design.md b/notes/skill-freshness-design.md deleted file mode 100644 index 8d12f8915a7..00000000000 --- a/notes/skill-freshness-design.md +++ /dev/null @@ -1,262 +0,0 @@ -# Skill Freshness: Thin Stubs + Read-Only Detection - -Status: adopted direction 2026-07-13. Phase 1 (guide sources + binary-served CLI) is -implemented, pending release; detection and stub migration are not implemented. This is the -authoritative plan. It supersedes the write phases of `skill-auto-update-design.md` (Phases -2–4: background updates, WSL, SSH) and the migration section of -`skill-guide-indirection-design.md`. The detection and content-identity research in those -notes still applies and is referenced below. - -## Problem (unchanged) - -Orca ships agent skills that teach coding agents to drive the `orca` CLI. Users install them -with `npx skills add stablyai/orca --global`. Installed copies are frozen files; the Orca -binary keeps moving. A stale skill tells an agent to use commands that are wrong or unsafe -for the binary it is driving. - -## Decision - -Three moves, replacing the in-app write machinery entirely: - -1. **Structural fix — content lives in the binary.** All version-sensitive skill content is - served by the CLI (`orca skills get `), compiled at build time from authoritative - `skill-guides/` sources. Generated `skills//SKILL.md` files are the installable - discovery surface and become permanent thin stubs. Staleness becomes impossible for the - content that matters, rather than mitigated. -2. **Residual freshness — read-only detection, ecosystem-rail updates.** Orca detects - outdated official copies (content-addressed, LF-normalized identity) and pre-fills a - targeted `npx skills update --global` command in a terminal. The user reviews - and runs the skills CLI's own update command; Orca never submits it automatically and - never writes a byte into a skill directory. -3. **No persistent ownership or update state.** No ownership ledger, no adoption consent, - no background writer, no transactional publish/rollback stack, no settings toggle. The - one content migration (fat skill → stub) rides the same user-invoked npx rail. A small - dismissed-nudge set remains in app state; it grants no write authority. - -### Why the write machinery was dropped (decision record) - -- **Maintenance-per-use.** The transactional mutation stack (staging, rollback, crash - recovery, orphan sweeping, ownership ledger, installer attribution) is ~3.2K production - lines before its tests. It is the most correctness-critical code in the tree, built to - write into user-owned directories forever. Under the stub model it would run meaningfully - once. Every module is permanent Windows/WSL/SSH edge-case surface for a one-shot job. -- **Trust posture.** Ecosystem discourse (2026) consistently favors pinning and reviewable, - user-invoked updates over silent writes into `$HOME`; Orca has first-hand precedent of - user backlash from writing into user-owned config directories. Read-only detection has no - trust cost at all. -- **The rail already exists.** `npx skills update --global` is the ecosystem's - documented remedy, and once `skills/` contains stubs it delivers the migration without an - Orca-owned writer. The rail is treated as an external dependency with a tested contract, - not assumed trustworthy from its lock file alone. -- The full write implementation exists, reviewed and green, on branch - `brennanb2025/skill-auto-update-research` (PR #8496, closed as superseded). If in-app - writes are ever genuinely needed, start from that branch, not from scratch. - -## Design - -### A. Binary-served guides - -```sh -orca skills list # one line per topic: name + when to use -orca skills get # full version-matched guide, markdown to stdout -orca skills get --full # include bundled reference docs, if any -``` - -- Topics are the skill names (orca-cli, orchestration, computer-use, …). -- Full version-sensitive content lives in `skill-guides/.md`. A generator embeds it - in a concrete CLI module and emits the installable `skills//SKILL.md` projection. - During the pre-stub release this projection may remain fat; after migration it is a stub. -- The generated-output-current check uses the existing generated-artifact-gate pattern used - by the manifest verifier. It asserts that generated guide data and installable projections - match their sources and that every stub topic resolves against the compiled guide table. - No network or runtime filesystem lookup is required. Unknown topic → nonzero exit + topic - list. -- Authoritative guides, generated projections, and embedded TypeScript are pinned to LF, and - the generator normalizes input before embedding it. Detection still normalizes text identity - because already-installed Windows copies may retain the historical CRLF shape. -- Topic names are append-only and aliased forever: a stub installed in 2026 must still - resolve in 2028. Renames add an alias, never remove one. -- Verb is `skills get` (not `guide`) to match the convention agents are already taught by - other tools in the wild. - -### B. Stub format and command resolution - -One stub per skill — frontmatter descriptions are the agent-routing layer and stay -per-skill registry entries. - -```markdown ---- -name: orca-cli -description: -allowed-tools: ---- - -# Orca CLI - -This file is a discovery stub, not the usage guide. The full, version-matched reference -lives in the `orca` binary itself. - -Before using Orca commands, resolve the Orca CLI for this session and load the guide once: - - skills get orca-cli - -Don't guess subcommands or flags from memory or from cached copies of this skill — they -change between Orca releases; the command above always matches the installed binary that -will handle subsequent Orca commands. -``` - -Rules: - -- The permanent body says when to engage Orca, how to resolve its CLI, and where to fetch - the version-matched guide. It does not carry the changing command reference. -- A stub must never blindly invoke bare `orca` outside an Orca-managed terminal on Linux; - that name commonly resolves to the GNOME Orca screen reader. The contract must cover - packaged `orca`, Linux/WSL `orca-ide`, SSH relay `orca`, and development `orca-dev`, and - `allowed-tools` must cover every command the resolution contract can select. -- **Linux command decision (2026-07-13):** do not install a uniform global bare `orca` alias; - it would shadow or risk launching the GNOME Orca screen reader. Keep `orca-ide` outside - managed Linux terminals, the existing managed-terminal/SSH `orca` shims, and `orca-dev` for - development. The permanent stub therefore needs the short resolver exercised by the spike. -- First-generation stubs are hybrid: a minimal safe bootstrap plus the guide pointer. Thin - them further only after pointer compliance and old-binary behavior are measured. If - `skills get` is unavailable, the hybrid must provide a bounded legacy workflow and tell the - user that updating Orca restores the full version-matched guide; it must not dead-end or - invite the agent to guess the missing command surface. - -### C. Read-only detection (kept from Phase 1, slimmed) - -Kept as-is: - -- Bundled `skills/` packages + current manifest + released-snapshot registry + release - mapping, with the generation script and merge-queue monotonicity gate (static data + CI, - not runtime machinery). -- LF-normalized text identity / exact-byte binary identity (the Windows CRLF finding - stands: exact-byte matching would misclassify every Windows install as modified). -- Bounded inventory work limits, topology classification (symlink dedup, external links, - plugin caches and repo scopes excluded), and the launch / focus / post-install triggers. -- The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests - to historical-fat-install → targeted global update → stub migration. The matrix covers - LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy - observation that accepts only unchanged historical or exact-current bytes. Post-update - bytes, not exit status, decide. - -Slimmed: - -- Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot), - `newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly - distinguish a locally modified official copy from unrelated same-named content; - `unrecognized` says it may be edited or from another source. All `managed-*` states, the - ledger, adoption eligibility, and attribution are removed. -- Status and action eligibility are separate. External links, read-only locations, plugin - caches, repo scopes, and unsupported topologies remain informational even when their bytes - match an official snapshot. -- Dismissal state for the nudge is a simple local dismissed-set keyed by - (physical identity, skill, bundled revision) in app state — not a consent ledger. - -### D. Surfacing - -- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the - existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace - Environments. Their installed pills carry safe freshness status, while their existing - Update and Re-check actions remain the per-skill path. The Skills page was - de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed — - so it is no longer a venue; the freshness surface moved off it entirely. The behavior - contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory - triggers) are unchanged; only the venue moved. -- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`, - `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in - the modal's collapsed **Details** section (auto-expanded when a placement is blocked). -- **Name-scoped update eligibility:** eligibility is computed across all discovered placements - of a name, not per row. Offer a name only when at least one placement is `outdated` and every - placement is an exact `current` or `outdated` official snapshot in a topology the validated - rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and - provider aliases to it. Independent provider copies are informational and poison the offer: - empirical copy-mode testing produced both stale and converged provider copies in otherwise - equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One - `newer-known`, unrecognized, external, read-only, - inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the - update offer for that name entirely. -- The action combines only eligible outdated Orca names into - `npx skills update --global` and opens the update modal's editable terminal with - that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and - never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only - observed bytes, not the skills CLI exit status, determine success. When the eligible set - empties and every placement is `current`, the modal shows an up-to-date state; if placements - remain outdated-but-blocked or unrecognized, it says so honestly instead. -- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the - same targeted action, which opens the update modal. The toast lingers (no auto-close) until the - user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a - still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks - the offered tuple retracts the stale toast without recording a dismissal. An outdated name - poisoned by another placement remains visible in the modal's Details but never produces an - unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only - on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision - may prompt once more. No - toggle — nothing automatic happens that would need one. - -### E. Migration (fat → stub) - -1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide - sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks, - and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first. -2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the - name-scoped targeted update action and the real migration-rail CI. Keep distributed - skills fat. -3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout - artifact. The binary must be publicly released before a stub PR merges because the skills - CLI installs from repository main, independently of Orca's desktop release train. -4. In one PR, convert only `orca-cli` to a first-generation hybrid stub and keep any final - thinning of that stub in the same change. This bumps its registry revision like any content - change. Existing users see an `outdated` exact snapshot and may run the targeted global - update; users of pre-guide binaries retain the hybrid bootstrap. -5. Cut an RC before the stable release and use that validation window to measure compliance, - task success, old-binary behavior, and token cost. Ship the thin form in stable only if those - gates pass; otherwise retain the hybrid. Convert the remaining skills gradually in later PRs. - Users who ignore the nudge keep working with their existing fat skills. - -## Spike gate (before any stub ships) - -Using the released guide-serving binary, install the proposed hybrid `orca-cli` stub in a -test home and run real agents (Claude Code, Codex) on representative Orca tasks. Measure: - -- how often the agent resolves the correct packaged/Linux/WSL/SSH/dev command and fetches the - guide before its first Orca command; -- task success versus the fat skill; -- old-binary failure behavior; and -- net token cost (stub preload + one fetch versus fat preload). - -Then test a thinner stub against the same corpus. Nothing converts, and the hybrid stub does -not thin, until the relevant variant passes. - -## Risks and open questions - -- **npx rail reliability.** The skills CLI update path has had false "up to date" results, - global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode - topology changes. The historical-fat → targeted-global-update → stub CI is a release gate, - not an early-warning job. Detection always re-checks bytes after the user updates, so a - failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is - 1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence - still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream - fixes before broadening eligibility. -- **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing - installs only when users run the npx command. Acceptable at stub-change cadence; the - compiled guides (the content that matters) are exempt by construction. -- **Multi-file skills.** Current shipped packages are single-file. If a future skill needs - scripts/assets, either the binary serves them (`--full` / `--script`) or that skill - accepts the fat-file decay model. Decide when it happens. -- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue: - SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command - surface that will handle subsequent requests. Remote stub installs can lag on trigger - copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are - retired, not deferred. -- **Agent Skills spec evolution** (frontmatter fields, allowed-tools syntax) is the most - likely future cause of a real stub update wave; the nudge path covers it. - -## Relationship to prior notes - -- `skill-auto-update-design.md`: Problem statement, empirical CLI-behavior findings - (verbatim-LF mac/linux, CRLF Windows, XDG lock location, symlink topology, released-blob - provenance) and the Phase-1 detection design remain valid inputs. Phases 2–4 (background - writes, WSL, SSH reconcilers) are retired by this document. -- `skill-guide-indirection-design.md`: The stub/CLI contract and prior-art survey are - folded in here; its migration-via-in-app-updater section is superseded by §E. diff --git a/notes/skill-guide-indirection-design.md b/notes/skill-guide-indirection-design.md deleted file mode 100644 index 56de827685e..00000000000 --- a/notes/skill-guide-indirection-design.md +++ /dev/null @@ -1,148 +0,0 @@ -# Skill Guide Indirection (Thin Stubs + `orca skills get`) - -Status: FOLDED INTO `skill-freshness-design.md` (2026-07-13) — read that instead. The stub/CLI -contract and prior-art survey carried over; the migration-via-in-app-updater section here is -superseded (migration now rides `npx skills update`, no in-app writes). - -## Principle - -Version-sensitive content must not live in distributed files; only discovery metadata should. -Every hard problem in the current system — staleness, adoption consent, installer attribution, -transactional replacement, remote-host reconciliation — descends from shipping full skill -bodies as mutable files that must track the installed Orca binary. Move the bodies into the -binary and the problems shrink to a residue the existing machinery already handles. - -## Design - -### 1. The binary serves the instructions - -New CLI surface (topic names match skill names): - -```sh -orca skills list # enumerate available guides, one line each -orca skills get # full version-matched guide for one skill, markdown to stdout -orca skills get --full # include bundled reference docs, if any -``` - -- Content is authored in `skill-guides/.md`. A generator embeds those authoritative - sources in the CLI and emits `skills//SKILL.md` as an installable projection; - `skills/` is generated output, not an authoring source. -- Output contract: plain markdown on stdout, exit 0; unknown topic exits nonzero with the - topic list. No network, no filesystem reads outside the binary's own resources. -- Verb choice: `skills get` (not `guide`) to match the convention agents are already being - taught by other tools (see Prior art). - -### 2. Historical stub sketch (superseded; do not copy) - -This sketch records the indirection idea only. The resolver and first-generation hybrid stub -contract in `skill-freshness-design.md` are authoritative and must cover packaged `orca`, -Linux/WSL `orca-ide`, SSH `orca`, and development `orca-dev` without blindly invoking bare -`orca` on Linux. - -```markdown ---- -name: orca-cli -description: -allowed-tools: ---- - -# Orca CLI - -This file is a discovery stub, not the usage guide. The full, version-matched reference -lives in the `orca` binary itself. - -Before using Orca commands, resolve the CLI for this session and load the guide once: - - skills get orca-cli - -Don't guess subcommands or flags from memory or from cached copies of this skill — they -change between Orca releases; the command above always matches the installed binary. -``` - -Stub rules: -- Body is deliberately version-independent: it says when to engage Orca and where to fetch - the how — never the how itself. A stub should survive many releases unchanged. -- `allowed-tools` must cover every executable that the authoritative resolver can select. -- Stub must not ship before the binary that serves its topic: gate stub rollout on the - release that includes `skills get` (a stub pointing at a command that does not exist - is worse than a fat skill). Enforce with a build check: every stub topic must resolve - against the compiled guide table. -- Stub should degrade honestly when no supported Orca command is on PATH and must retain a - bounded legacy bootstrap for binaries that predate `skills get`. - -### 3. What this retires, what it keeps - -Retired / collapsed: -- The ownership ledger, adoption and installer-attribution flows, background updater, - transactional publish/rollback/orphan sweep, and all automatic writes into user-owned - skill directories. -- Phases 3–4 of skill-auto-update-design.md (WSL/SSH remote file reconcilers). Wherever the - skill is useful the `orca` binary is present, and the remote binary serves the guide - matching its own host's version. No remote file-sync problem remains. - -Kept (read-only): -- Bounded discovery, LF-normalized content identities, the released-snapshot registry, - release mapping, and CI gates. Statuses are `current`, `outdated`, `newer-known`, - `unrecognized`, and `inaccessible`; no ledger is needed to compute them. -- Name-scoped eligibility across every placement. One newer, unrecognized, external, - read-only, repo-scoped, plugin, or inaccessible placement poisons the update offer for - that skill name. -- The skills-CLI round-trip CI, extended to prove historical fat installs migrate to stubs - through targeted global updates across supported hosts and topologies. -- Read-only settings rows and a dismissible nudge that pre-fill a targeted - `npx skills update --global` command. Orca never submits it or writes - into a skill directory. - -## Prior art (verified live 2026-07-13) - -- vercel-labs/agent-browser — canonical stub + `agent-browser skills get core`; docs frame - it explicitly: "the installed SKILL.md rarely changes, while the CLI always serves content - matching its own version." Stub self-describes as a discovery stub that "cannot change - between releases." -- Canner/WrenAI (skills/wren/SKILL.md) — independent (non-Vercel) adopter: "The actual - workflow guides … live inside the `wren` CLI itself, so they always match the installed - wrenai version (no skill cache, no version drift)." Uses `wren skills list` / - `wren skills get ` / `--full` — the verb convention to match. -- vercel-labs/zerolang (skills/zero/SKILL.md) — "This file is only a discovery stub… ask the - installed compiler for the skill content that matches that exact binary." Adds the nuance - of warning agents not to replace a pinned binary. -- vercel/next.js (skills/next-dev-loop/SKILL.md) — consumes the pattern: instructs agents to - "run `agent-browser skills get core` once for the version-matched usage guide — don't - guess subcommands from memory." Normalization signal. -- Ecosystem discourse (Snyk threat model, HN, vercel-labs/skills issues #500/#542, Anthropic - skill-trust guidance) demands pinning + reviewable updates and condemns silent pulls from - mutable remotes. Stub indirection satisfies the audit-once trust model: the audited file - never changes meaning; served content is exactly as trusted as the installed binary. - -## Migration plan - -0. Release `orca skills list/get` first from authoritative `skill-guides/` sources while - distributed skills remain fat. No stub may reach repository main before a public binary - can serve it. -1. Add read-only freshness detection, name-scoped update eligibility, the targeted - user-invoked `npx skills update --global` action, and migration-rail CI. Keep - distributed skills fat. -2. Spike pointer compliance against the released guide-serving binary with Claude Code and - Codex, including Linux/WSL/SSH/dev command resolution, old-binary fallback, task success, - and token cost. -3. Convert only `orca-cli` to a first-generation hybrid stub. Existing exact official fat - copies become eligible for the targeted ecosystem update rail; users who ignore the - nudge retain their existing skills. -4. Cut an RC, measure the gates, and thin the hybrid only if it passes. Convert remaining - skills gradually in later PRs. - -## Open questions - -- Compliance failure mode: if agents skim the stub and skip the fetch, options are stronger - stub wording, frontmatter `description` nudging ("requires running orca skills get"), - or hybrid stubs carrying a minimal command table plus the pointer. Spike decides. -- Multi-file skills: current shipped packages are single-file; if a future skill needs - scripts/assets, decide whether the binary serves them (`--script ` like WrenAI) or - they stay in the package (then that skill keeps the fat-update path). -- Topic/verb naming: `orca skills get` collides conceptually with the `skills` installer - CLI; confirm no confusion in agent behavior during the spike. -- Old binaries: a user can hold a stub while running an older orca without `skills get` - (downgrade case). Stub wording should fail gracefully ("if the command is missing, update - Orca"); acceptable residual. -- Whether settings should surface "guide served by binary" as a distinct row state so - support can tell stub-era installs from fat-era ones at a glance. diff --git a/package.json b/package.json index 77b11ed0f1c..2422dcf48af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.147-rc.4", + "version": "1.4.148-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -77,6 +77,7 @@ "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", + "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 1359f5cce13..f8c9d482858 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -45,7 +45,8 @@ const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000 const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => { const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', { - terminal: await getTerminalHandle(flags, cwd, client) + terminal: await getTerminalHandle(flags, cwd, client), + navigation: 'host' }) printResult(result, json, formatTerminalFocus) } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index d464c17af83..5804c95510e 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -191,7 +191,10 @@ describe('command aliases dispatch to the canonical handler', () => { await main(['terminal', 'focus', '--terminal', 'term_abc', '--json'], '/tmp/repo') - expect(callMock).toHaveBeenCalledWith('terminal.focus', expect.objectContaining({})) + expect(callMock).toHaveBeenCalledWith( + 'terminal.focus', + expect.objectContaining({ navigation: 'host' }) + ) }) it('serves `agent-context --json` without contacting the runtime', async () => { diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index a54a8e55f85..902ec543287 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -13,7 +13,10 @@ export function buildPosixHookPayloadCapture( } export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' -export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = '"%SystemRoot%\\System32\\more.com" >nul 2>nul' +// Why: qualify the stdin reader because Windows searches the worktree for +// executables before PATH and hook payloads must not reach repo-local code. +export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"' +export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul` // Why: batch payloads stream directly to curl and cannot be buffered safely in // environment variables, so guard failures share one EOF-draining epilogue. @@ -27,11 +30,5 @@ export function buildWindowsHookEnvironmentGuardLines(): string[] { } export function buildWindowsHookStdinDrainEpilogue(): string[] { - return [ - `:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, - // Why: qualify the inbox reader because Windows searches the worktree for - // executables before PATH and hook payloads must not reach repo-local code. - WINDOWS_HOOK_STDIN_DRAIN_COMMAND, - 'exit /b 0' - ] + return [`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, WINDOWS_HOOK_STDIN_DRAIN_COMMAND, 'exit /b 0'] } diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 92ea4d01572..0645de342e5 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -207,8 +207,11 @@ async function generatePosixScripts(): Promise> { const generated = [...memory.fs.files.entries()].filter( ([path]) => path.includes('/.orca/agent-hooks/') && path.endsWith('.sh') ) - expect(generated, `${entry.agent} generated scripts`).toHaveLength(1) - scripts.set(entry.agent, generated[0][1]) + // Why: Claude ships a second managed script (the statusline usage feed); the stdin lifecycle contract applies to every generated script. + expect(generated.length, `${entry.agent} generated scripts`).toBeGreaterThan(0) + for (const [path, script] of generated) { + scripts.set(`${entry.agent} ${path.split('/').pop()}`, script) + } } return scripts } @@ -377,14 +380,13 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () it('accepts a large payload without Orca environment or a broken writer', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { - const extraEnv = - agent === 'command-code' - ? { - ORCA_AGENT_HOOK_PORT: '1', - ORCA_AGENT_HOOK_TOKEN: 'test-token', - ORCA_PANE_KEY: 'test-pane' - } - : {} + const extraEnv = agent.startsWith('command-code') + ? { + ORCA_AGENT_HOOK_PORT: '1', + ORCA_AGENT_HOOK_TOKEN: 'test-token', + ORCA_PANE_KEY: 'test-pane' + } + : {} const result = await runPosixHook(script, extraEnv) expect(result.exitCode, `${agent} exit code`).toBe(0) expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0) @@ -392,7 +394,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () }) it('drains before Claude skips hooks imported by Devin', async () => { - const script = (await generatePosixScripts()).get('claude') + const script = (await generatePosixScripts()).get('claude claude-hook.sh') expect(script).toBeDefined() const result = await runPosixHook(script!, { DEVIN_PROJECT_DIR: '/tmp/devin-project' }) expect(result.exitCode).toBe(0) diff --git a/src/main/agent-hooks/managed-hook-timeout.test.ts b/src/main/agent-hooks/managed-hook-timeout.test.ts index c7d374573ff..599d9a93b31 100644 --- a/src/main/agent-hooks/managed-hook-timeout.test.ts +++ b/src/main/agent-hooks/managed-hook-timeout.test.ts @@ -112,6 +112,8 @@ const JSON_INSTALLERS = [ ] as const const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/' +// Why: statusLine is not a hook — Claude's schema has no timeout field (type/command/padding/refreshInterval), and a slow statusline can't block agent turns. +const STATUSLINE_SCRIPT_NEEDLE = '-statusline.' // Walk the parsed config and assert every Orca-managed command carrier (a node // with a `command`/`bash`/`powershell` string pointing at the managed script @@ -121,8 +123,13 @@ const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/' function countManagedCarriersWithTimeout( node: unknown, expectedTimeout: number, - isManagedCarrier = (value: string): boolean => - value.replaceAll('\\', '/').includes(MANAGED_HOOKS_DIR_NEEDLE) + isManagedCarrier = (value: string): boolean => { + const normalized = value.replaceAll('\\', '/') + return ( + normalized.includes(MANAGED_HOOKS_DIR_NEEDLE) && + !normalized.includes(STATUSLINE_SCRIPT_NEEDLE) + ) + } ): number { if (Array.isArray(node)) { return node.reduce( diff --git a/src/main/agent-hooks/server-claude-statusline.test.ts b/src/main/agent-hooks/server-claude-statusline.test.ts new file mode 100644 index 00000000000..b40edb397d2 --- /dev/null +++ b/src/main/agent-hooks/server-claude-statusline.test.ts @@ -0,0 +1,77 @@ +// Why: locks the /statusline/claude loopback contract — form-encoded posts from the +// managed statusline script must reach the listener, and junk must fail open (204). +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentHookServer } from './server' +import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' + +describe('AgentHookServer /statusline/claude', () => { + let server: AgentHookServer + + beforeEach(async () => { + server = new AgentHookServer() + await server.start({ env: 'production' }) + }) + + afterEach(() => { + server.stop() + }) + + function post(body: string, token?: string): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/statusline/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': token ?? env.ORCA_AGENT_HOOK_TOKEN + }, + body + }) + } + + it('forwards parsed rate limits to the statusline listener', async () => { + const events: ClaudeStatusLineRateLimits[] = [] + server.setClaudeStatusLineListener((event) => { + events.push(event) + }) + + const payload = JSON.stringify({ + rate_limits: { + five_hour: { used_percentage: 12.5, resets_at: 1738425600 }, + seven_day: { used_percentage: 40, resets_at: 1712059200 } + } + }) + const body = new URLSearchParams({ + paneKey: 'pane-1', + configDir: '/home/dev/managed', + payload + }).toString() + + await expect(post(body)).resolves.toMatchObject({ status: 204 }) + expect(events).toEqual([ + { + configDir: '/home/dev/managed', + fiveHour: { used_percentage: 12.5, resets_at: 1738425600 }, + sevenDay: { used_percentage: 40, resets_at: 1712059200 } + } + ]) + }) + + it('rejects posts with a bad token and ignores payloads without rate limits', async () => { + const events: ClaudeStatusLineRateLimits[] = [] + server.setClaudeStatusLineListener((event) => { + events.push(event) + }) + + await expect(post('payload={}', 'wrong-token')).resolves.toMatchObject({ status: 403 }) + + const noLimits = new URLSearchParams({ + paneKey: 'pane-1', + payload: JSON.stringify({ context_window: { used_percentage: 8 } }) + }).toString() + await expect(post(noLimits)).resolves.toMatchObject({ status: 204 }) + + await expect(post('payload=not-json')).resolves.toMatchObject({ status: 204 }) + + expect(events).toEqual([]) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index d02642b1111..62d588d5df5 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -32,6 +32,11 @@ import { type HookListenerState } from '../../shared/agent-hook-listener' import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { + CLAUDE_STATUSLINE_PATHNAME, + parseClaudeStatusLineBody, + type ClaudeStatusLineRateLimits +} from '../../shared/claude-statusline-rate-limits' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusClearIpcPayload, @@ -445,6 +450,7 @@ export class AgentHookServer { // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. private env = 'production' private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null + private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null private onPaneStatusCleared: PaneStatusClearListener | null = null private statusChangeListeners = new Set() // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). @@ -486,6 +492,13 @@ export class AgentHookServer { } } + // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. + setClaudeStatusLineListener( + listener: ((event: ClaudeStatusLineRateLimits) => void) | null + ): void { + this.onClaudeStatusLine = listener + } + subscribeStatusChanges(listener: StatusChangeListener): () => void { this.statusChangeListeners.add(listener) return () => { @@ -1472,6 +1485,15 @@ export class AgentHookServer { try { const body = await readRequestBody(req) const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + if (pathname === CLAUDE_STATUSLINE_PATHNAME) { + const statusLineEvent = parseClaudeStatusLineBody(body) + if (statusLineEvent) { + this.onClaudeStatusLine?.(statusLineEvent) + } + res.writeHead(204) + res.end() + return + } const source = resolveHookSource(pathname) if (!source) { res.writeHead(404) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 9c7f9122389..2a979c60e27 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -137,6 +137,71 @@ describe('markCopilotFolderTrusted', () => { }) describe('markCodexProjectTrusted', () => { + it('trusts the main repository root for a linked worktree without reading commondir', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-')) + const repository = join(fixtureRoot, 'repo') + const workspace = join(fixtureRoot, 'worktrees', 'feature') + const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature') + try { + mkdirSync(worktreeGitDir, { recursive: true }) + mkdirSync(workspace, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8') + writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8') + + markCodexProjectTrusted(workspace) + + const repositoryRoot = realpathSync.native(repository) + const workspaceRoot = realpathSync.native(workspace) + const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + const runtimeConfigPath = join( + testState.userDataDir, + 'codex-runtime-home', + 'home', + 'config.toml' + ) + for (const written of [ + readFileSync(configPath, 'utf-8'), + readFileSync(runtimeConfigPath, 'utf-8') + ]) { + expect(written).toContain(`[projects."${escapeTomlBasicString(repositoryRoot)}"]`) + expect(written).not.toContain(`[projects."${escapeTomlBasicString(workspaceRoot)}"]`) + } + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } + }) + + it('does not broaden trust through arbitrary or adversarial Git metadata', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-')) + const workspace = join(fixtureRoot, 'workspace') + const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature') + const unrelatedRoot = join(fixtureRoot, 'unrelated') + try { + mkdirSync(arbitraryGitDir, { recursive: true }) + mkdirSync(workspace, { recursive: true }) + mkdirSync(unrelatedRoot, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8') + writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8') + + markCodexProjectTrusted(workspace) + const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature') + mkdirSync(structuredGitDir, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8') + writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8') + markCodexProjectTrusted(workspace) + + const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` + ) + expect(written).not.toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(unrelatedRoot))}"]` + ) + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } + }) + it('writes ~/.codex/config.toml with the project marked trusted', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) try { diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 9ba45a35b33..3b441d6d1fe 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { join } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' @@ -109,7 +109,7 @@ export function markCopilotFolderTrusted(workspacePath: string): void { * codex-rs/core/src/config/config_tests.rs in the Codex CLI source. */ export function markCodexProjectTrusted(workspacePath: string): void { - const absPath = canonicalize(workspacePath) + const absPath = resolveCodexProjectTrustRoot(workspacePath) const configPath = join(homedir(), '.codex', 'config.toml') upsertProjectTrustLevel(configPath, absPath, 'trusted') // Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust @@ -117,6 +117,42 @@ export function markCodexProjectTrusted(workspacePath: string): void { upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted') } +function resolveCodexProjectTrustRoot(workspacePath: string): string { + const absPath = canonicalize(workspacePath) + try { + const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim() + if (!gitDirReference.startsWith('gitdir:')) { + return absPath + } + const gitDirPath = gitDirReference.slice('gitdir:'.length).trim() + if (!gitDirPath) { + return absPath + } + const gitDir = resolve(absPath, gitDirPath) + const worktreesDir = dirname(gitDir) + if (basename(worktreesDir) !== 'worktrees') { + return absPath + } + // Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link. + const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim() + if (!gitDirBacklink) { + return absPath + } + const resolvedBacklink = resolve(gitDir, gitDirBacklink) + const workspaceGitFile = join(absPath, '.git') + if ( + resolvedBacklink !== workspaceGitFile && + canonicalize(resolvedBacklink) !== canonicalize(workspaceGitFile) + ) { + return absPath + } + // Why: mirror Codex's validated .git/worktrees/ traversal instead of trusting arbitrary commondir contents. + return canonicalize(dirname(dirname(worktreesDir))) + } catch { + return absPath + } +} + function canonicalize(p: string): string { // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but // both Cursor and Copilot's trust comparators run realpath() before the diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 07ed14298ed..d690ea10d8a 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -14,8 +14,11 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: mocks.callRuntimeEnvironment })) -const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } = - await import('./runtime-session-scanner') +const { + getSavedRuntimeAiVaultHostInfos, + prepareRuntimeAiVaultSessionResume, + scanRuntimeAiVaultSessions +} = await import('./runtime-session-scanner') beforeEach(() => { vi.clearAllMocks() @@ -108,6 +111,42 @@ describe('runtime AI Vault session scanner', () => { }) ]) }) + + it('prepares a resume on the transcript-owning runtime', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: { useRealCodexHome: true } + }) + const args = { + agent: 'codex' as const, + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-1' as const + } + + await expect(prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', args)).resolves.toEqual({ + useRealCodexHome: true + }) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'aiVault.prepareSessionResume', + args + ) + }) + + it('fails retryably when runtime preparation returns an invalid result', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} }) + + await expect( + prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-1' + }) + ).rejects.toThrow('Invalid aiVault.prepareSessionResume response') + }) }) function result( diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index b2f318d8e40..659cacc8810 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -9,6 +9,10 @@ import { import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host' import { listEnvironments } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' export type RuntimeAiVaultHostInfo = { environmentId: string @@ -99,6 +103,8 @@ const aiVaultListResultSchema = z.object({ scannedAt: z.string() }) +const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean() }) + export function getSavedRuntimeAiVaultHostInfos( userDataPath: string ): readonly RuntimeAiVaultHostInfo[] { @@ -151,6 +157,29 @@ export async function scanRuntimeAiVaultSessions( }) } +export async function prepareRuntimeAiVaultSessionResume( + userDataPath: string, + environmentId: string, + args: AiVaultPrepareSessionResumeArgs +): Promise { + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'aiVault.prepareSessionResume', + args + ) + if (response.ok !== true) { + throw new Error(response.error.message) + } + const parsed = aiVaultPrepareSessionResumeResultSchema.safeParse(response.result) + if (!parsed.success) { + throw new Error( + `Invalid aiVault.prepareSessionResume response: ${parsed.error.issues[0]?.message ?? 'unexpected result shape'}` + ) + } + return parsed.data +} + function withRuntimeExecutionHost( result: AiVaultListResult, executionHostId: `runtime:${string}` diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index 4629c844f08..5df35b0d3c8 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -67,7 +67,8 @@ import { // inside a try/catch. Override the private method to inject our mock. // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(AgentBrowserBridge.prototype as any).getWebContents = function (id: number) { - return webContentsFromIdMock(id) ?? null + const target = webContentsFromIdMock(id) + return target && !target.isDestroyed() ? target : null } function mockBrowserManager( @@ -89,10 +90,14 @@ function mockBrowserManager( } function mockWebContents(id: number, url = 'https://example.com', title = 'Example') { + let currentUrl = url return { id, - getURL: () => url, + getURL: () => currentUrl, getTitle: () => title, + loadURL: vi.fn(async (nextUrl: string) => { + currentUrl = nextUrl + }), isDestroyed: () => false, invalidate: vi.fn(), focus: vi.fn(), @@ -308,9 +313,9 @@ describe('AgentBrowserBridge', () => { expect(args[args.indexOf('--session') + 1]).toBe('orca-tab-tab-1') }) - // ── --cdp first-use only ── + // ── Embedded CDP ownership ── - it('passes --cdp only on first command for a session', async () => { + it('passes --cdp on every helper command family so a restarted daemon cannot launch Chrome', async () => { succeedWith({ snapshot: '...' }) await bridge.snapshot() @@ -322,16 +327,24 @@ describe('AgentBrowserBridge', () => { const cdpIdx = (snapshotCall![1] as string[]).indexOf('--cdp') expect((snapshotCall![1] as string[])[cdpIdx + 1]).toBe('9222') - succeedWith({ clicked: '@e1' }) await bridge.click('@e1') + await bridge.mouseMove(10, 20) + await bridge.setOffline('on') + await bridge.consoleLog() + await bridge.exec('get title') - const clickCall = execFileMock.mock.calls.find((c: unknown[]) => - (c[1] as string[]).includes('click') - ) - expect(clickCall![1]).not.toContain('--cdp') + for (const command of ['click', 'mouse', 'set', 'console', 'get']) { + const call = execFileMock.mock.calls.find((candidate: unknown[]) => + (candidate[1] as string[]).includes(command) + ) + expect(call).toBeDefined() + const args = call![1] as string[] + expect(args).toContain('--cdp') + expect(args[args.indexOf('--cdp') + 1]).toBe('9222') + } }) - it('continues when stale agent-browser session close hangs during session creation', async () => { + it('fails closed when stale agent-browser session ownership cannot be reset', async () => { vi.useFakeTimers() try { const closeKill = vi.fn() @@ -349,17 +362,17 @@ describe('AgentBrowserBridge', () => { ) const promise = bridge.snapshot() - let settled = false - void promise.finally(() => { - settled = true + const rejection = expect(promise).rejects.toMatchObject({ + code: 'browser_owner_unavailable', + message: + 'Could not reset stale helper session orca-tab-tab-1; retry after agent-browser exits' }) await vi.advanceTimersByTimeAsync(3_000) - await Promise.resolve() - expect(settled).toBe(true) - await expect(promise).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'ready' }) + await rejection expect(closeKill).toHaveBeenCalled() + expect(execFileMock.mock.calls.some((call) => call[1].includes('snapshot'))).toBe(false) } finally { vi.useRealTimers() } @@ -876,6 +889,8 @@ describe('AgentBrowserBridge', () => { ) expect(routeCalls).toHaveLength(2) expect(routeCalls.at(-1)).toContain('https://old.example/**') + expect(routeCalls.at(-1)).toContain('--cdp') + expect(routeCalls.at(-1)).toContain('9222') }) it('clears stale sessions after direct CDP visibility re-registration', async () => { @@ -1119,7 +1134,11 @@ describe('AgentBrowserBridge', () => { const killedError = Object.assign(new Error('timeout'), { killed: true }) execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: Function) => { + if (args.includes('close')) { + cb(null, JSON.stringify({ success: true, data: null }), '') + return + } cb(killedError, '', '') } ) @@ -1588,13 +1607,282 @@ describe('AgentBrowserBridge', () => { // ── goto command ── - it('passes url to goto command', async () => { - succeedWith({ url: 'https://example.com', title: 'Example' }) - await bridge.goto('https://example.com') + it('navigates the registered webContents without spawning agent-browser', async () => { + const wc = mockWebContents(100, 'https://example.com/start', 'Example') + webContentsFromIdMock.mockReturnValue(wc) - const args = execFileMock.mock.calls.at(-1)![1] as string[] - expect(args).toContain('goto') - expect(args).toContain('https://example.com') + await expect(bridge.goto('https://example.com/next')).resolves.toEqual({ + url: 'https://example.com/next', + title: 'Example' + }) + + expect(wc.loadURL).toHaveBeenCalledWith('https://example.com/next') + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('preserves scheme-less navigation semantics at the direct WebContents boundary', async () => { + const wc = mockWebContents(100) + webContentsFromIdMock.mockReturnValue(wc) + + await expect(bridge.goto('example.com')).resolves.toEqual({ + url: 'https://example.com/', + title: 'Example' + }) + expect(wc.loadURL).toHaveBeenCalledWith('https://example.com/') + }) + + it('rejects unsupported direct navigation URLs without falling back to agent-browser', async () => { + const wc = mockWebContents(100) + webContentsFromIdMock.mockReturnValue(wc) + + await expect(bridge.goto('javascript:alert(1)')).rejects.toMatchObject({ + code: 'invalid_argument', + message: 'Unsupported browser URL: javascript:alert(1)' + }) + expect(wc.loadURL).not.toHaveBeenCalled() + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('fails closed and releases the command queue when direct navigation never settles', async () => { + vi.useFakeTimers() + try { + const wc = mockWebContents(100) + wc.loadURL.mockReturnValue(new Promise(() => {})) + webContentsFromIdMock.mockReturnValue(wc) + + const navigation = bridge.goto('https://example.com/hangs') + const rejection = expect(navigation).rejects.toMatchObject({ + code: 'browser_error', + message: 'Failed to navigate browser page tab-1: Browser navigation timed out after 30000ms' + }) + await vi.advanceTimersByTimeAsync(30_000) + + await rejection + expect(execFileMock).not.toHaveBeenCalled() + expect( + (bridge as unknown as { commandQueues: Map }).commandQueues.size + ).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('fails closed when direct navigation is aborted', async () => { + const wc = mockWebContents(100, 'https://example.com/current', 'Current') + wc.loadURL.mockRejectedValue( + Object.assign(new Error('ERR_ABORTED (-3)'), { code: 'ERR_ABORTED' }) + ) + webContentsFromIdMock.mockReturnValue(wc) + + await expect(bridge.goto('https://example.com/download')).rejects.toMatchObject({ + code: 'browser_error', + message: 'Failed to navigate browser page tab-1: ERR_ABORTED (-3)' + }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it.each([ + { + command: 'goto', + run: (b: AgentBrowserBridge) => b.goto('https://embedded.example/next'), + helperArg: 'goto', + directMethod: 'loadURL' + }, + { + command: 'evaluate', + run: (b: AgentBrowserBridge) => b.evaluate('document.title'), + helperArg: 'eval', + directMethod: 'Runtime.evaluate' + } + ])( + 'does not route $command to another browser when the helper session is stale', + async ({ run, helperArg, directMethod }) => { + const wc = mockWebContents(100, 'https://embedded.example/current', 'Embedded') + wc.debugger.sendCommand.mockImplementation(async (_method: string, params?: unknown) => ({ + result: { + value: + (params as { expression?: string } | undefined)?.expression === 'location.origin' + ? 'https://embedded.example' + : 'Embedded' + } + })) + webContentsFromIdMock.mockReturnValue(wc) + const wrongOwnerCalls: string[][] = [] + let helperSessionIsStale = false + + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: Function) => { + if (args.includes('close')) { + cb(null, JSON.stringify({ success: true, data: null }), '') + } else if (args.includes('snapshot')) { + cb(null, JSON.stringify({ success: true, data: { snapshot: 'ready' } }), '') + } else { + if (helperSessionIsStale && !args.includes('--cdp')) { + wrongOwnerCalls.push(args) + } + cb( + null, + JSON.stringify({ + success: true, + data: { url: 'https://external.example', title: 'External', result: 'external' } + }), + '' + ) + } + return { kill: vi.fn() } + } + ) + + await bridge.snapshot() + helperSessionIsStale = true + await run(bridge) + + expect(wrongOwnerCalls).toEqual([]) + expect( + execFileMock.mock.calls.some((call) => (call[1] as string[]).includes(helperArg)) + ).toBe(false) + if (directMethod === 'loadURL') { + expect(wc.loadURL).toHaveBeenCalledWith('https://embedded.example/next') + } else { + expect(wc.debugger.sendCommand).toHaveBeenCalledWith( + 'Runtime.evaluate', + expect.objectContaining({ expression: 'document.title' }) + ) + } + } + ) + + it('returns direct evaluation value and full page URL semantics without spawning agent-browser', async () => { + const wc = mockWebContents(100, 'https://example.com/path?query=1') + wc.debugger.sendCommand.mockResolvedValue({ result: { value: 42 } }) + webContentsFromIdMock.mockReturnValue(wc) + + await expect(bridge.evaluate('6 * 7')).resolves.toEqual({ + result: '42', + origin: 'https://example.com/path?query=1' + }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it.each([ + [{ answer: 42 }, '{"answer":42}'], + [['a', 'b'], '["a","b"]'] + ])('preserves structured direct evaluation values as JSON text', async (value, expected) => { + const wc = mockWebContents(100) + wc.debugger.sendCommand.mockResolvedValue({ result: { value } }) + webContentsFromIdMock.mockReturnValue(wc) + + await expect(bridge.evaluate('structuredValue')).resolves.toMatchObject({ result: expected }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('surfaces direct evaluation exceptions without falling back to agent-browser', async () => { + const wc = mockWebContents(100) + wc.debugger.sendCommand.mockResolvedValue({ + result: { type: 'object' }, + exceptionDetails: { + text: 'Uncaught', + exception: { description: 'ReferenceError: missingValue is not defined' } + } + }) + webContentsFromIdMock.mockReturnValue(wc) + + await expect(bridge.evaluate('missingValue')).rejects.toMatchObject({ + code: 'browser_eval_error', + message: 'ReferenceError: missingValue is not defined' + }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('fails closed when the registered webContents debugger is stale', async () => { + const wc = mockWebContents(100) + wc.debugger.sendCommand.mockRejectedValue(new Error('Debugger is detached')) + webContentsFromIdMock.mockReturnValue(wc) + + await expect(bridge.evaluate('document.title')).rejects.toMatchObject({ + code: 'browser_error', + message: 'Failed to evaluate in browser page tab-1: Debugger is detached' + }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it.each([ + ['goto', (b: AgentBrowserBridge) => b.goto('https://example.com/next', undefined, 'tab-1')], + ['evaluate', (b: AgentBrowserBridge) => b.evaluate('document.title', undefined, 'tab-1')] + ])('fails closed when direct %s targets a destroyed webContents', async (_command, run) => { + const wc = mockWebContents(100) + wc.isDestroyed = () => true + webContentsFromIdMock.mockReturnValue(wc) + const unregisterGuest = vi.fn() + const b = new AgentBrowserBridge( + mockBrowserManager(new Map([['tab-1', 100]]), new Map(), { unregisterGuest }) + ) + b.setActiveTab(100) + + await expect(run(b)).rejects.toMatchObject({ + code: 'browser_tab_not_found', + message: 'Browser page tab-1 is no longer available' + }) + expect(unregisterGuest).toHaveBeenCalledWith('tab-1') + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('returns navigation state from a replacement registered during load', async () => { + const tabs = new Map([['tab-1', 100]]) + const oldWc = mockWebContents(100, 'https://example.com/start', 'Old') + const replacementWc = mockWebContents(200, 'https://example.com/final', 'Replacement') + oldWc.loadURL.mockImplementation(async () => { + tabs.set('tab-1', 200) + }) + webContentsFromIdMock.mockImplementation((id: number) => + id === 100 ? oldWc : id === 200 ? replacementWc : null + ) + const b = new AgentBrowserBridge(mockBrowserManager(tabs)) + b.setActiveTab(100) + + await expect(b.goto('https://example.com/next')).resolves.toEqual({ + url: 'https://example.com/final', + title: 'Replacement' + }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('routes direct commands to the replacement registration, not the stale session owner', async () => { + const tabs = new Map([['tab-1', 100]]) + const oldWc = mockWebContents(100, 'https://old.example', 'Old') + const replacementWc = mockWebContents(200, 'https://new.example', 'New') + replacementWc.debugger.sendCommand.mockImplementation( + async (_method: string, params?: unknown) => ({ + result: { + value: + (params as { expression?: string } | undefined)?.expression === 'location.origin' + ? 'https://new.example' + : 'New' + } + }) + ) + webContentsFromIdMock.mockImplementation((id: number) => + id === 100 ? oldWc : id === 200 ? replacementWc : null + ) + const b = new AgentBrowserBridge(mockBrowserManager(tabs)) + b.setActiveTab(100) + + succeedWith({ snapshot: 'ready' }) + await b.snapshot() + tabs.set('tab-1', 200) + await b.onProcessSwap('tab-1', 200, 100) + execFileMock.mockClear() + + await expect(b.evaluate('document.title', undefined, 'tab-1')).resolves.toEqual({ + result: 'New', + origin: 'https://new.example' + }) + expect(replacementWc.debugger.sendCommand).toHaveBeenCalledWith( + 'Runtime.evaluate', + expect.objectContaining({ expression: 'document.title' }) + ) + expect(oldWc.debugger.sendCommand).not.toHaveBeenCalled() + expect(execFileMock).not.toHaveBeenCalled() }) it('rejects oversized browser clipboard writes before spawning agent-browser', async () => { @@ -2121,7 +2409,11 @@ describe('AgentBrowserBridge', () => { it('passes stderr through as error message on execFile failure', async () => { execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: Function) => { + if (args.includes('close')) { + cb(null, JSON.stringify({ success: true, data: null }), '') + return + } cb(new Error('exit code 1'), '', 'daemon crashed: segfault') } ) @@ -2130,7 +2422,11 @@ describe('AgentBrowserBridge', () => { it('falls back to error.message when stderr is empty', async () => { execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: Function) => { + if (args.includes('close')) { + cb(null, JSON.stringify({ success: true, data: null }), '') + return + } cb(new Error('Command failed'), '', '') } ) diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 0ce3b7f1342..3011fd56b92 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -48,6 +48,7 @@ import type { BrowserCookie } from '../../shared/runtime-types' import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' // Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. @@ -55,6 +56,7 @@ const EXEC_TIMEOUT_MS = 90_000 const CONSECUTIVE_TIMEOUT_LIMIT = 3 const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES @@ -766,9 +768,53 @@ export class AgentBrowserBridge { } async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['goto', url])) as BrowserGotoResult - }) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + const navigationUrl = normalizeBrowserNavigationUrl(url) + if (!navigationUrl) { + throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) + } + let navigationTimeout: ReturnType | null = null + try { + await Promise.race([ + wc.loadURL(navigationUrl), + new Promise((_resolve, reject) => { + navigationTimeout = setTimeout( + () => + reject( + new Error( + `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + EMBEDDED_NAVIGATION_TIMEOUT_MS + ) + navigationTimeout.unref?.() + }) + ]) + } catch (error) { + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`) + } + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + if (navigationTimeout) { + clearTimeout(navigationTimeout) + } + } + + // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. + const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) + return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } + }, + { ensureSession: false } + ) } async fill( @@ -1403,9 +1449,62 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['eval', expression])) as BrowserEvalResult - }) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + let releaseDebugger = (): void => {} + try { + releaseDebugger = acquireElectronDebugger(wc).release + const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true + })) as { + result: { value?: unknown; description?: string } + exceptionDetails?: { text: string; exception?: { description?: string } } + } + if (exceptionDetails) { + throw new BrowserError( + 'browser_eval_error', + exceptionDetails.exception?.description ?? exceptionDetails.text + ) + } + + const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (currentTarget.webContentsId !== target.webContentsId) { + throw new BrowserError( + 'browser_tab_changed', + `Browser page ${target.browserPageId} changed while evaluating; retry the command` + ) + } + return { + result: + result.value !== undefined + ? typeof result.value === 'object' && result.value !== null + ? JSON.stringify(result.value) + : String(result.value) + : (result.description ?? ''), + origin: wc.getURL() + } + } catch (error) { + if (error instanceof BrowserError) { + throw error + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`) + } + throw new BrowserError( + 'browser_error', + `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + releaseDebugger() + } + }, + { ensureSession: false } + ) } async hover( @@ -2283,12 +2382,9 @@ export class AgentBrowserBridge { const managesInterceptRoutes = commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') - // Why: --cdp is init-only; pass the port (not a ws:// URL) so agent-browser's /json discovery sees only the proxy's webview, not the host renderer page. const needsInit = !session.initialized - if (needsInit) { - const port = session.proxy.getPort() - args.push('--cdp', String(port)) - } + // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. + args.push('--cdp', String(session.proxy.getPort())) // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. for (const commandArg of commandArgs) { @@ -2323,6 +2419,8 @@ export class AgentBrowserBridge { await this.runAgentBrowserRaw(sessionName, [ '--session', sessionName, + '--cdp', + String(session.proxy.getPort()), 'network', 'route', urlPattern, @@ -2368,23 +2466,32 @@ export class AgentBrowserBridge { } private closeStaleAgentBrowserSession(sessionName: string): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let child: ReturnType | null = null let settled = false - const finish = (): void => { + const finish = (error?: Error): void => { if (settled) { return } settled = true clearTimeout(timeout) - resolve() + if (error) { + reject(error) + } else { + resolve() + } } - // Why: best-effort daemon cleanup — a wedged close must not block the real browser action. + // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. const timeout = setTimeout(() => { child?.kill() - finish() + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` + ) + ) }, STALE_SESSION_CLOSE_TIMEOUT_MS) try { @@ -2392,10 +2499,23 @@ export class AgentBrowserBridge { this.agentBrowserBin, ['--session', sessionName, 'close'], { timeout: STALE_SESSION_CLOSE_TIMEOUT_MS }, - finish + (error) => + finish( + error + ? new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error.message}` + ) + : undefined + ) + ) + } catch (error) { + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` + ) ) - } catch { - finish() } }) } @@ -2525,10 +2645,19 @@ export class AgentBrowserBridge { return null } + private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`) + } + return wc + } + private getWebContents(webContentsId: number): Electron.WebContents | null { try { const { webContents } = require('electron') - return webContents.fromId(webContentsId) ?? null + const target = webContents.fromId(webContentsId) + return target && !target.isDestroyed() ? target : null } catch { return null } diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index c6c2043deab..a78bc99ec1c 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -3537,6 +3537,72 @@ describe('ClaudeRuntimeAuthService', () => { }) }) + it('uses the global WSL runtime for untargeted Claude preparation under auto', async () => { + setPlatform('win32') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const settings = createSettings({ + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account' } + } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + provenance: 'managed:ubuntu-account:wsl:Ubuntu' + }) + }) + + it('ignores a persisted WSL account-runtime pin on non-Windows hosts', async () => { + setPlatform('darwin') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const settings = createSettings({ + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'host', + wslDistro: null, + provenance: 'system', + stripAuthEnv: false + }) + }) + it('keeps untargeted Claude preparation on host when account runtime is host', async () => { setPlatform('win32') vi.doMock('../wsl', () => ({ diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 3d901f872c0..58328222528 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -13,6 +13,7 @@ import { writeClaudeManagedAuthFile } from './managed-auth-path' import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl' import { buildEncodedWslBashCommand } from '../wsl-bash-command' import { hasLiveClaudePtys } from './live-pty-gate' @@ -683,9 +684,10 @@ export class ClaudeRuntimeAuthService { private getDefaultAccountSelectionTarget( settings = this.store.getSettings() ): ClaudeAccountSelectionTarget { - if (process.platform === 'win32' && settings.localAccountRuntime === 'wsl') { - // Why: auth defaults follow account runtime settings, not legacy terminal WSL settings that can outlive the Terminal UI. - return { runtime: 'wsl', wslDistro: settings.localAccountWslDistro ?? null } + // Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host. + const resolved = resolveLocalAccountRuntimeTarget(settings) + if (process.platform === 'win32' && resolved.runtime === 'wsl') { + return { runtime: 'wsl', wslDistro: resolved.wslDistro } } return { runtime: 'host' } } diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 1c07cf38942..cd31d5577f5 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -20,6 +20,8 @@ import { ClaudeHookService } from './hook-service' import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' +const STATUSLINE_SCRIPT_FILE_NAME = + process.platform === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh' const OPENCLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = @@ -181,6 +183,130 @@ describe('ClaudeHookService.install', () => { } }) + it('installs the managed statusLine command and forwards rate_limits posts', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse( + readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') + ) as { statusLine?: { type: string; command: string } } + expect(settings.statusLine?.type).toBe('command') + expect(settings.statusLine?.command).toContain('claude-statusline') + + const script = readFileSync( + join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME), + 'utf-8' + ) + expect(script).toContain('/statusline/claude') + // Why: non-subscriber sessions never carry rate_limits; both branches must guard before spawning curl. + if (process.platform === 'win32') { + expect(script).toContain('findstr.exe" /c:\\"rate_limits\\"') + expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"') + } else { + expect(script).toContain('"rate_limits"') + expect(script).toContain('--data-urlencode "payload@-"') + } + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('never overwrites a user-owned statusLine command', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-user-statusline-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const settingsPath = join(tmpHome, '.claude', 'settings.json') + mkdirSync(join(tmpHome, '.claude'), { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify({ statusLine: { type: 'command', command: '/usr/local/bin/my-statusline' } }) + ) + + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(settings.statusLine).toEqual({ + type: 'command', + command: '/usr/local/bin/my-statusline' + }) + + // remove() must also leave the user's statusLine untouched. + new ClaudeHookService().remove() + const afterRemove = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(afterRemove.statusLine).toEqual({ + type: 'command', + command: '/usr/local/bin/my-statusline' + }) + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('removes the managed statusLine on remove()', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-remove-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + new ClaudeHookService().install() + new ClaudeHookService().remove() + const settings = JSON.parse(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')) + expect(settings.statusLine).toBeUndefined() + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('does not re-install a managed statusLine the user deleted, until remove() resets the opt-out', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-optout-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const settingsPath = join(tmpHome, '.claude', 'settings.json') + new ClaudeHookService().install() + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy() + + // The user deletes the managed statusLine from settings.json (e.g. via /statusline or an editor). + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) + delete settings.statusLine + writeFileSync(settingsPath, JSON.stringify(settings)) + + // A later install (app restart) must respect the deletion — statusLine is opportunistic, not required. + new ClaudeHookService().install() + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined() + + // An Orca-level remove() resets the opt-out memory, so a fresh install re-adds it. + new ClaudeHookService().remove() + new ClaudeHookService().install() + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy() + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('keeps refreshing a still-managed statusLine across installs', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-refresh-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const settingsPath = join(tmpHome, '.claude', 'settings.json') + new ClaudeHookService().install() + new ClaudeHookService().install() + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(settings.statusLine?.command).toContain('claude-statusline') + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + // Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path // with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed // command must use an encoded launcher so Git Bash/cmd.exe never splits or @@ -279,6 +405,10 @@ describe('ClaudeHookService.installRemote', () => { expect(script).toContain('--data-urlencode "payload@-"') expect(script).not.toContain('--data-urlencode "payload=${payload}"') expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) + // Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay + // listener doesn't route /statusline/claude and whose accounts aren't attributable locally. + expect(parsed.statusLine).toBeUndefined() + expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-statusline.sh')).toBeUndefined() }) it('reports parse error when remote settings.json cannot be parsed', async () => { @@ -364,6 +494,8 @@ describe('OpenClaudeHookService-compatible install', () => { expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).not.toContain('DEVIN_PROJECT_DIR') + // Why: the statusline usage feed is Claude-only; OpenClaude installs must not set statusLine. + expect(parsed.statusLine).toBeUndefined() expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) } finally { vi.unstubAllEnvs() diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 95304f19fc6..96cbe301dcf 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,10 +1,12 @@ +import { existsSync, rmSync, writeFileSync } from 'node:fs' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildWindowsAgentHookCurlPostCommand, readHooksJson, writeHooksJson, - writeManagedScript + writeManagedScript, + type HooksConfig } from '../agent-hooks/installer-utils' import { readHooksJsonRemote, @@ -17,8 +19,10 @@ import { buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_LABEL } from '../agent-hooks/hook-stdin-contract' +import { getManagedStatusLineScript } from './statusline-script' import { applyManagedHooks, + applyManagedStatusLine, CLAUDE_EVENTS, CLAUDE_HOOK_SETTINGS, getManagedScriptFileName, @@ -28,7 +32,12 @@ import { getPosixManagedScriptFileName, getRemoteConfigPath, getRemoteManagedCommand, + getStatusLineInstallMarkerPath, + getStatusLineScriptFileName, + getStatusLineScriptPath, + getStatusLineSlotState, removeManagedHooks, + removeManagedStatusLine, type ClaudeCompatibleHookSettings } from './hook-settings' @@ -175,7 +184,7 @@ export class ClaudeHookService { } const command = getManagedCommand(scriptPath) - const nextConfig = applyManagedHooks( + let nextConfig = applyManagedHooks( config, command, getManagedScriptFileName(this.options.settings) @@ -184,10 +193,38 @@ export class ClaudeHookService { scriptPath, getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) ) + // Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider. + if (this.options.agent === 'claude') { + nextConfig = this.installManagedStatusLine(nextConfig) + } writeHooksJson(configPath, nextConfig) return this.getStatus() } + // Why: the statusline feed is opportunistic (usage display, not agent status); a user who deleted the + // managed entry has opted out, and the marker distinguishes that deletion from a first install. + private installManagedStatusLine(config: HooksConfig): HooksConfig { + const scriptFileName = getStatusLineScriptFileName(this.options.settings) + const markerPath = getStatusLineInstallMarkerPath(this.options.settings) + const slot = getStatusLineSlotState(config, scriptFileName) + if (slot === 'user' || (slot === 'empty' && existsSync(markerPath))) { + return config + } + const statusLineScriptPath = getStatusLineScriptPath(this.options.settings) + writeManagedScript(statusLineScriptPath, getManagedStatusLineScript('local')) + const next = applyManagedStatusLine( + config, + getManagedCommand(statusLineScriptPath), + scriptFileName + ) + try { + writeFileSync(markerPath, '') + } catch { + // Best-effort: a missing marker only means one future user deletion gets re-installed once. + } + return next + } + // Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred). async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { // Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this. @@ -218,6 +255,9 @@ export class ClaudeHookService { remoteScriptPath, getManagedScript('posix', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) ) + // Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook + // listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different + // account than the locally selected one, so its usage must not feed the local bar (live feed is host-local only). await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) return { @@ -250,13 +290,25 @@ export class ClaudeHookService { detail: `Could not parse ${this.options.displayName} settings.json` } } - const { config: nextConfig, changed } = removeManagedHooks( + const { config: hooksRemoved, changed: hooksChanged } = removeManagedHooks( config, getManagedScriptFileName(this.options.settings) ) - if (changed) { + const { config: nextConfig, changed: statusLineChanged } = removeManagedStatusLine( + hooksRemoved, + getStatusLineScriptFileName(this.options.settings) + ) + if (hooksChanged || statusLineChanged) { writeHooksJson(configPath, nextConfig) } + if (this.options.agent === 'claude') { + try { + // Why: an Orca-level uninstall resets the opt-out memory so a later re-enable installs the statusline again. + rmSync(getStatusLineInstallMarkerPath(this.options.settings), { force: true }) + } catch { + // ignore — marker cleanup is best-effort + } + } return this.getStatus() } } diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 8392d07f1b9..1059a9a0945 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -4,6 +4,7 @@ import { buildManagedCommandHook, createManagedCommandMatcher, getSharedManagedScriptPath, + isPlainObject, removeManagedCommands, wrapPosixHookCommand, wrapWindowsGitBashHookCommand, @@ -64,6 +65,24 @@ export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { return join(homedir(), settings.configDirName, 'settings.json') } +export function getStatusLineScriptBaseName(settings = CLAUDE_HOOK_SETTINGS): string { + return settings.scriptBaseName.replace(/-hook$/, '-statusline') +} + +export function getStatusLineScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { + return process.platform === 'win32' + ? `${getStatusLineScriptBaseName(settings)}.cmd` + : getPosixStatusLineScriptFileName(settings) +} + +export function getPosixStatusLineScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { + return `${getStatusLineScriptBaseName(settings)}.sh` +} + +export function getStatusLineScriptPath(settings = CLAUDE_HOOK_SETTINGS): string { + return getSharedManagedScriptPath(getStatusLineScriptFileName(settings)) +} + export function getManagedScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { return process.platform === 'win32' ? `${settings.scriptBaseName}.cmd` @@ -113,6 +132,58 @@ export function applyManagedHooks( return { ...config, hooks: nextHooks } } +export type StatusLineSlotState = 'managed' | 'user' | 'empty' + +// Why: install policy needs "user owns the slot" vs "slot is empty" vs "ours" — an empty slot +// after a prior install means the user deleted the managed entry, which install must respect. +export function getStatusLineSlotState( + config: HooksConfig, + scriptFileName = getStatusLineScriptFileName() +): StatusLineSlotState { + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const current = config.statusLine + const currentCommand = + isPlainObject(current) && typeof current.command === 'string' ? current.command : null + if (!currentCommand) { + return 'empty' + } + return isManagedCommand(currentCommand) ? 'managed' : 'user' +} + +// Why: records that the managed statusline was installed once, so a later empty slot reads as user opt-out. +export function getStatusLineInstallMarkerPath(settings = CLAUDE_HOOK_SETTINGS): string { + return getSharedManagedScriptPath(`${getStatusLineScriptBaseName(settings)}.installed`) +} + +// Why: statusLine is a single settings slot, not a hooks array — never overwrite a +// user-owned status line; the usage feed then simply falls back to the OAuth poll. +export function applyManagedStatusLine( + config: HooksConfig, + command: string, + scriptFileName = getStatusLineScriptFileName() +): HooksConfig { + if (getStatusLineSlotState(config, scriptFileName) === 'user') { + return config + } + return { ...config, statusLine: { type: 'command', command } } +} + +export function removeManagedStatusLine( + config: HooksConfig, + scriptFileName = getStatusLineScriptFileName() +): { config: HooksConfig; changed: boolean } { + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const current = config.statusLine + const currentCommand = + isPlainObject(current) && typeof current.command === 'string' ? current.command : null + if (!currentCommand || !isManagedCommand(currentCommand)) { + return { config, changed: false } + } + const next = { ...config } + delete next.statusLine + return { config: next, changed: true } +} + export function removeManagedHooks( config: HooksConfig, scriptFileName = getManagedScriptFileName() diff --git a/src/main/claude/statusline-script.test.ts b/src/main/claude/statusline-script.test.ts new file mode 100644 index 00000000000..36261adfc48 --- /dev/null +++ b/src/main/claude/statusline-script.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { getManagedStatusLineScript } from './statusline-script' + +const ORIGINAL_PLATFORM = process.platform + +function stubPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: ORIGINAL_PLATFORM }) +}) + +describe('getManagedStatusLineScript (posix)', () => { + it('guards on rate_limits before sourcing the endpoint or spawning curl', () => { + stubPlatform('darwin') + const script = getManagedStatusLineScript('local') + expect(script).toBe(getManagedStatusLineScript('posix')) + const guardIndex = script.indexOf('*\'"rate_limits"\'*') + const endpointIndex = script.indexOf('ORCA_AGENT_HOOK_ENDPOINT') + const curlIndex = script.indexOf('curl -sS') + expect(guardIndex).toBeGreaterThan(-1) + expect(guardIndex).toBeLessThan(endpointIndex) + expect(endpointIndex).toBeLessThan(curlIndex) + expect(script).toContain('/statusline/claude') + expect(script).toContain('--data-urlencode "payload@-"') + }) + + it('returns the posix script even on win32 when targeting a remote', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('posix') + expect(script).toContain('#!/bin/sh') + expect(script).not.toContain('curl.exe') + }) +}) + +describe('getManagedStatusLineScript (win32 local)', () => { + it('guards on rate_limits via findstr before the endpoint call and curl spawn', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('local') + const captureIndex = script.indexOf('more.com') + // Why: the \"-escaped needle makes findstr match the quoted JSON key, not any path containing rate_limits. + const guardIndex = script.indexOf('findstr.exe" /c:\\"rate_limits\\"') + const endpointIndex = script.indexOf('call "%ORCA_AGENT_HOOK_ENDPOINT%"') + const curlIndex = script.indexOf('curl.exe') + expect(captureIndex).toBeGreaterThan(-1) + expect(guardIndex).toBeGreaterThan(captureIndex) + expect(guardIndex).toBeLessThan(endpointIndex) + expect(endpointIndex).toBeLessThan(curlIndex) + expect(script).toContain('if errorlevel 1 goto :orca_statusline_cleanup') + }) + + it('posts the buffered payload file and deletes it afterwards', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('local') + // Why: the temp file is per-pane (pane key with ":" mapped to "_") because %RANDOM% + // collides across cmd instances spawned in the same second. + expect(script).toContain( + 'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_PANE_KEY::=_%.tmp"' + ) + expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"') + expect(script).not.toContain('payload@-') + const curlIndex = script.indexOf('curl.exe') + const delIndex = script.indexOf('del "%ORCA_STATUSLINE_PAYLOAD_FILE%"') + expect(delIndex).toBeGreaterThan(curlIndex) + }) + + it('never posts a literal %CLAUDE_CONFIG_DIR% token when the var is unset', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('local') + // Why: the posted field comes from an always-defined variable so an unset + // CLAUDE_CONFIG_DIR yields "configDir=" (matching POSIX + the null snapshot). + expect(script).toContain('set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="') + expect(script).toContain( + 'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"' + ) + expect(script).toContain('--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"') + expect(script).not.toContain('"configDir=%CLAUDE_CONFIG_DIR%"') + }) + + it('drains stdin before exiting when the pane key is missing', () => { + stubPlatform('win32') + const script = getManagedStatusLineScript('local') + const paneGuardIndex = script.indexOf( + 'if "%ORCA_PANE_KEY%"=="" goto :orca_agent_hook_drain_stdin' + ) + const captureIndex = script.indexOf('more.com') + expect(paneGuardIndex).toBeGreaterThan(-1) + expect(paneGuardIndex).toBeLessThan(captureIndex) + expect(script).toContain(':orca_agent_hook_drain_stdin') + }) +}) diff --git a/src/main/claude/statusline-script.ts b/src/main/claude/statusline-script.ts new file mode 100644 index 00000000000..36c20f5f7a1 --- /dev/null +++ b/src/main/claude/statusline-script.ts @@ -0,0 +1,85 @@ +import { + buildPosixHookPayloadCapture, + buildWindowsHookStdinDrainEpilogue, + WINDOWS_HOOK_STDIN_DRAIN_LABEL, + WINDOWS_HOOK_STDIN_READER +} from '../agent-hooks/hook-stdin-contract' +import { CLAUDE_STATUSLINE_PATHNAME } from '../../shared/claude-statusline-rate-limits' + +const STATUSLINE_CLEANUP_LABEL = 'orca_statusline_cleanup' + +// Why: Claude Code pipes `rate_limits` to the statusLine command on every turn; forwarding +// it gives Orca live usage without spending the OAuth usage endpoint's tight budget. +// Emits no stdout so the in-terminal status line stays visually unchanged. +export function getManagedStatusLineScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: pane key is static PTY env (the endpoint file never sets it), so it can gate before stdin is consumed. + `if "%ORCA_PANE_KEY%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, + // Why: cmd has no builtin stdin capture, so buffer the payload in a per-pane temp file + // (%RANDOM% collides across same-second cmd spawns) to guard before any curl spawn. + 'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_PANE_KEY::=_%.tmp"', + `${WINDOWS_HOOK_STDIN_READER} >"%ORCA_STATUSLINE_PAYLOAD_FILE%" 2>nul`, + // Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; the + // statusline ticks ~3x/sec during streaming, so skip the endpoint call and curl spawn otherwise. + // Why: \" is the MSVC argv escape — findstr sees the quoted JSON key, so a cwd containing rate_limits can't false-match (POSIX guard parity). + '"%SystemRoot%\\System32\\findstr.exe" /c:\\"rate_limits\\" "%ORCA_STATUSLINE_PAYLOAD_FILE%" >nul 2>nul', + `if errorlevel 1 goto :${STATUSLINE_CLEANUP_LABEL}`, + // Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing. + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + `if "%ORCA_AGENT_HOOK_PORT%"=="" goto :${STATUSLINE_CLEANUP_LABEL}`, + `if "%ORCA_AGENT_HOOK_TOKEN%"=="" goto :${STATUSLINE_CLEANUP_LABEL}`, + // Why: pre-build the field from an always-defined variable so an unset CLAUDE_CONFIG_DIR posts + // empty (matching POSIX and the null attribution snapshot), never a literal %VAR% token. + 'set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="', + 'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"', + [ + '"%SystemRoot%\\System32\\curl.exe" -sS -X POST', + `"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%${CLAUDE_STATUSLINE_PATHNAME}"`, + '--connect-timeout 0.5 --max-time 1.5', + '-H "Content-Type: application/x-www-form-urlencoded"', + '-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"', + '--data-urlencode "paneKey=%ORCA_PANE_KEY%"', + '--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"', + '--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"', + '--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"', + '--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"', + '>nul 2>&1' + ].join(' '), + `:${STATUSLINE_CLEANUP_LABEL}`, + 'del "%ORCA_STATUSLINE_PAYLOAD_FILE%" >nul 2>nul', + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + ...buildPosixHookPayloadCapture(), + // Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; skip the post (and its curl spawn) otherwise. + 'case "$payload" in', + ' *\'"rate_limits"\'*) ;;', + ' *) exit 0 ;;', + 'esac', + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + `printf '%s' "$payload" | curl -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}${CLAUDE_STATUSLINE_PATHNAME}" \\`, + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "configDir=${CLAUDE_CONFIG_DIR}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 3e180872eb0..c0617eaea50 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -14,7 +14,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' -import type { GlobalSettings } from '../../shared/types' +import type { CodexRateLimitAccountsState, GlobalSettings } from '../../shared/types' import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust' @@ -938,6 +938,179 @@ describe('CodexAccountService config sync', () => { warnSpy.mockRestore() }) + it.each([ + { + label: 'the active account', + accountId: 'account-1', + outcome: 'success', + expectedActiveAccountId: 'account-1', + expectedUpdateCount: 2 + }, + { + label: 'a different account', + accountId: 'account-2', + outcome: 'success', + expectedActiveAccountId: 'account-1', + expectedUpdateCount: 2 + }, + { + label: 'a login that fails', + accountId: 'account-1', + outcome: 'login-failure', + expectedActiveAccountId: null, + expectedUpdateCount: 1 + }, + { + label: 'credentials rejected by runtime validation', + accountId: 'account-1', + outcome: 'runtime-validation-failure', + expectedActiveAccountId: null, + expectedUpdateCount: 3 + } + ])('keeps host selection semantics when reauthenticating $label', async (testCase) => { + vi.resetModules() + + const hostAccounts = ['account-1', 'account-2'].map((id, index) => ({ + id, + email: `${id}@example.com`, + managedHomePath: createManagedHome( + testState.userDataDir, + id, + '', + createCodexAuthJson(`${id}@example.com`, `provider-${id}`, `refresh-${id}`) + ), + providerAccountId: `provider-${id}`, + workspaceLabel: null, + workspaceAccountId: `provider-${id}`, + createdAt: index + 1, + updatedAt: index + 1, + lastAuthenticatedAt: index + 1 + })) + const wslAccount = { + id: 'account-wsl', + email: 'account-wsl@example.com', + managedHomePath: createManagedHome( + testState.userDataDir, + 'account-wsl', + '', + createCodexAuthJson('account-wsl@example.com', 'provider-wsl', 'refresh-wsl') + ), + managedHomeRuntime: 'wsl' as const, + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/test/.local/share/orca/codex-accounts/account-wsl/home', + providerAccountId: 'provider-wsl', + workspaceLabel: null, + workspaceAccountId: 'provider-wsl', + createdAt: 3, + updatedAt: 3, + lastAuthenticatedAt: 3 + } + const settings = createSettings({ + codexManagedAccounts: [...hostAccounts, wslAccount], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { + host: 'account-1', + wsl: { Ubuntu: 'account-wsl' } + } + }) + const store = createStore(settings) + const runtimeHome = createRuntimeHome() + const spawnMock = vi.fn( + (_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const current = store.getSettings() + store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...current.activeCodexManagedAccountIdsByRuntime!, + host: null, + wsl: { Ubuntu: null } + } + }) + if (testCase.outcome === 'login-failure') { + queueMicrotask(() => child.emit('close', 1)) + return child + } + writeFileSync( + join(options.env.CODEX_HOME!, 'auth.json'), + createCodexAuthJson('reauthenticated@example.com', 'provider-new', 'refresh-new'), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + } + ) + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), + spawn: spawnMock + })) + vi.doMock('../codex-cli/command', () => ({ + resolveCodexCommand: () => 'codex' + })) + + runtimeHome.syncForCurrentSelection.mockImplementation(() => { + if (testCase.outcome !== 'runtime-validation-failure') { + return + } + const current = store.getSettings() + store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...current.activeCodexManagedAccountIdsByRuntime!, + host: null + } + }) + }) + const rateLimits = createRateLimits() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + let result: CodexRateLimitAccountsState | null = null + if (testCase.outcome === 'login-failure') { + await expect(service.reauthenticateAccount(testCase.accountId)).rejects.toThrow( + 'Codex login exited with code 1.' + ) + } else { + result = await service.reauthenticateAccount(testCase.accountId) + } + + expect(result?.activeAccountId ?? null).toBe(testCase.expectedActiveAccountId) + if (result) { + expect(result.activeAccountIdsByRuntime).toEqual({ + host: testCase.expectedActiveAccountId, + wsl: { Ubuntu: null } + }) + } + expect(store.getSettings()).toMatchObject({ + activeCodexManagedAccountId: testCase.expectedActiveAccountId, + activeCodexManagedAccountIdsByRuntime: { + host: testCase.expectedActiveAccountId, + wsl: { Ubuntu: null } + } + }) + const completedLogin = testCase.outcome !== 'login-failure' + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(completedLogin ? 1 : 0) + if (completedLogin) { + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) + expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledWith(undefined, { + runtime: 'host' + }) + } + expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(completedLogin ? 1 : 0) + expect(store.updateSettings).toHaveBeenCalledTimes(testCase.expectedUpdateCount) + }) + it('does not recreate a missing managed home at a different account path', async () => { vi.resetModules() const managedHomePath = join(testState.userDataDir, 'codex-accounts', 'other-account', 'home') @@ -1252,6 +1425,7 @@ describe('CodexAccountService config sync', () => { } return '' }) + let clearSelectionDuringLogin = (): void => {} const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) @@ -1263,6 +1437,7 @@ describe('CodexAccountService config sync', () => { child.stdout = new PassThrough() child.stderr = new PassThrough() child.kill = vi.fn() + clearSelectionDuringLogin() writeFileSync( join(wslManagedHomePath, 'auth.json'), JSON.stringify({ @@ -1307,9 +1482,22 @@ describe('CodexAccountService config sync', () => { lastAuthenticatedAt: 1 } ], - activeCodexManagedAccountId: 'account-1' + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } }) const store = createStore(settings) + clearSelectionDuringLogin = () => { + const current = store.getSettings() + store.updateSettings({ + activeCodexManagedAccountIdsByRuntime: { + ...current.activeCodexManagedAccountIdsByRuntime!, + wsl: { Ubuntu: null } + } + }) + } const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() @@ -1328,7 +1516,20 @@ describe('CodexAccountService config sync', () => { managedHomeRuntime: 'wsl', wslDistro: 'Ubuntu' }) - expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + expect(result.activeAccountId).toBe(null) + expect(result.activeAccountIdsByRuntime).toEqual({ + host: null, + wsl: { Ubuntu: 'account-1' } + }) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledWith(undefined, { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(store.updateSettings).toHaveBeenCalledTimes(2) } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -1555,12 +1756,14 @@ describe('CodexAccountService config sync', () => { const store = createStore(settings) const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() + const onHostSystemDefaultSelected = vi.fn() const { CodexAccountService } = await import('./service') const service = new CodexAccountService( store as never, rateLimits as never, - runtimeHome as never + runtimeHome as never, + { onHostSystemDefaultSelected } ) const result = await service.selectAccount(null) @@ -1568,6 +1771,7 @@ describe('CodexAccountService config sync', () => { expect(result.activeAccountId).toBe(null) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalled() + expect(onHostSystemDefaultSelected).toHaveBeenCalledOnce() }) it('selectAccount immediately rewrites the shared runtime auth for existing terminals', async () => { diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 75dd28c0eb2..54b676aa9c1 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -79,6 +79,10 @@ export type CodexAccountAddTarget = { wslDistro?: string | null } +export type CodexAccountServiceLifecycle = { + onHostSystemDefaultSelected?: () => void +} + type ManagedHomeLocation = { managedHomePath: string managedHomeRuntime: 'host' | 'wsl' @@ -158,7 +162,8 @@ export class CodexAccountService { constructor( private readonly store: Store, private readonly rateLimits: RateLimitService, - private readonly runtimeHome: CodexRuntimeHomeService + private readonly runtimeHome: CodexRuntimeHomeService, + private readonly lifecycle: CodexAccountServiceLifecycle = {} ) { this.safeSyncCanonicalConfigToManagedHomes() } @@ -258,6 +263,11 @@ export class CodexAccountService { private async doReauthenticateAccount(accountId: string): Promise { const account = this.requireAccount(accountId) const managedHomePath = this.ensureManagedHomeForReauthentication(account) + const accountTarget = getCodexSelectionTargetForAccount(account) + const selectedAccountId = getSelectedCodexAccountIdForTarget( + this.store.getSettings(), + accountTarget + ) this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, undefined, account.id) await this.runCodexLogin(managedHomePath) @@ -281,19 +291,24 @@ export class CodexAccountService { } : entry ) + const activeSelection = setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + selectedAccountId, + accountTarget + ) + // Why: login can transiently clear this runtime's selection; unrelated runtime validation must remain authoritative. this.store.updateSettings({ - codexManagedAccounts: updatedAccounts + codexManagedAccounts: updatedAccounts, + activeCodexManagedAccountId: activeSelection.host, + activeCodexManagedAccountIdsByRuntime: activeSelection }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(accountId) - this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account)) + this.runtimeHome.syncForCurrentSelection(accountTarget) // Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota. - await this.rateLimits.refreshForCodexAccountChange( - undefined, - getCodexSelectionTargetForAccount(account) - ) + await this.rateLimits.refreshForCodexAccountChange(undefined, accountTarget) return this.getSnapshot() } @@ -314,6 +329,9 @@ export class CodexAccountService { activeCodexManagedAccountIdsByRuntime: nextSelection }) this.runtimeHome.syncForCurrentSelection() + if (account.managedHomeRuntime === 'host' && nextSelection.host === null) { + this.lifecycle.onHostSystemDefaultSelected?.() + } this.safeRemoveManagedHome(account.managedHomePath, account.id) // Why: a removed account can no longer appear in the switcher dropdown, @@ -361,6 +379,12 @@ export class CodexAccountService { }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.syncForCurrentSelection(effectiveTarget) + if ( + accountId === null && + normalizeCodexAccountSelectionTarget(effectiveTarget).runtime === 'host' + ) { + this.lifecycle.onHostSystemDefaultSelected?.() + } await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() @@ -510,6 +534,9 @@ export class CodexAccountService { activeCodexManagedAccountId: nextSelection.host, activeCodexManagedAccountIdsByRuntime: nextSelection }) + if (selection.host !== null && nextSelection.host === null) { + this.lifecycle.onHostSystemDefaultSelected?.() + } } } diff --git a/src/main/codex/codex-legacy-session-resume.test.ts b/src/main/codex/codex-legacy-session-resume.test.ts new file mode 100644 index 00000000000..852da6b2c91 --- /dev/null +++ b/src/main/codex/codex-legacy-session-resume.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' + +describe('prepareLegacySharedCodexSessionResume', () => { + let root: string + let legacyHome: string + let systemHome: string + let rolloutPath: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-legacy-codex-resume-')) + legacyHome = join(root, 'codex-runtime-home', 'home') + systemHome = join(root, 'real-codex-home') + rolloutPath = join( + legacyHome, + 'sessions', + '2026', + '07', + '20', + 'rollout-2026-07-20T12-00-00-session.jsonl' + ) + mkdirSync(join(rolloutPath, '..'), { recursive: true }) + writeFileSync(rolloutPath, '{"type":"session_meta"}\n', 'utf-8') + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('materializes an immediate legacy resume into the real home', async () => { + const result = await prepare() + const targetPath = targetRolloutPath() + + expect(result).toEqual({ useRealCodexHome: true }) + expect(readFileSync(targetPath, 'utf-8')).toBe('{"type":"session_meta"}\n') + expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino) + }) + + it('materializes a compressed legacy rollout without changing its representation', async () => { + const compressedPath = `${rolloutPath}.zst` + rmSync(rolloutPath) + rolloutPath = compressedPath + writeFileSync(rolloutPath, 'compressed-rollout', 'utf-8') + + await expect(prepare()).resolves.toEqual({ useRealCodexHome: true }) + expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('compressed-rollout') + }) + + it('coalesces concurrent resumes of the same legacy rollout', async () => { + const [first, second] = await Promise.all([prepare(), prepare()]) + + expect(first.useRealCodexHome).toBe(true) + expect(second.useRealCodexHome).toBe(true) + expect(readFileSync(targetRolloutPath(), 'utf-8')).toContain('session_meta') + }) + + it('fails clearly and retryably instead of approving a missing rollout', async () => { + rmSync(rolloutPath) + + await expect(prepare()).rejects.toThrow(/Retry resume/) + expect(existsSync(targetRolloutPath())).toBe(false) + }) + + it('fails closed when a different rollout already owns the target path', async () => { + mkdirSync(join(targetRolloutPath(), '..'), { recursive: true }) + writeFileSync(targetRolloutPath(), '{"different":true}\n', 'utf-8') + + await expect(prepare()).rejects.toThrow(/Retry resume/) + expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('{"different":true}\n') + }) + + it.each([ + ['per-account home', join(rootPlaceholder(), 'codex-accounts', 'account-1', 'home'), 'local'], + ['custom home', join(rootPlaceholder(), 'custom-codex-home'), 'local'], + ['WSL home', '\\\\wsl.localhost\\Ubuntu\\home\\me\\.codex', 'local'], + ['SSH session', rootPlaceholder(), 'ssh:server-1'] + ])( + 'preserves %s without materializing it', + async (_label, codexHomeTemplate, executionHostId) => { + const codexHome = codexHomeTemplate.replace(rootPlaceholder(), root) + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: rolloutPath, + codexHome, + executionHostId: executionHostId as 'local' + }, + options() + ) + + expect(result).toEqual({ useRealCodexHome: false }) + expect(existsSync(targetRolloutPath())).toBe(false) + } + ) + + it.each(['managed account', 'custom CODEX_HOME'])( + 'preserves the legacy home while the %s lane is selected', + async () => { + const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), { + ...options(), + isHostSystemDefaultRealHome: () => false + }) + + expect(result).toEqual({ useRealCodexHome: false }) + expect(existsSync(targetRolloutPath())).toBe(false) + } + ) + + function prepare() { + return prepareLegacySharedCodexSessionResume(legacyArgs(), options()) + } + + function legacyArgs() { + return { + agent: 'codex' as const, + filePath: rolloutPath, + codexHome: legacyHome, + executionHostId: 'local' as const + } + } + + function options() { + return { + isHostSystemDefaultRealHome: () => true, + legacyCodexHomePath: legacyHome, + systemCodexHomePath: systemHome + } + } + + function targetRolloutPath(): string { + return join(systemHome, 'sessions', '2026', '07', '20', basename(rolloutPath)) + } +}) + +function rootPlaceholder(): string { + return '__ROOT__' +} diff --git a/src/main/codex/codex-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts new file mode 100644 index 00000000000..3976b4a0d8d --- /dev/null +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -0,0 +1,175 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { link, lstat, mkdir } from 'node:fs/promises' +import { dirname, join, relative, resolve, sep } from 'node:path' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + appendCodexSessionHealAuditRecord, + createCodexSessionBackfillAuditWriter +} from './codex-session-backfill-audit' +import { + copySessionFileWithoutOverwrite, + isAtomicNoReplaceUnsupportedError +} from './codex-session-backfill-copy' +import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' + +const RETRYABLE_RESUME_ERROR = + 'Orca could not safely move this legacy Codex session into your system Codex home. Retry resume; if it still fails, check that both Codex session folders are readable and writable.' + +const materializations = new Map>() + +export async function prepareLegacySharedCodexSessionResume( + args: AiVaultPrepareSessionResumeArgs, + options: { + isHostSystemDefaultRealHome: () => boolean + legacyCodexHomePath?: string + systemCodexHomePath?: string + } +): Promise { + const paths = resolveCodexSessionBackfillPaths(options.systemCodexHomePath) + const legacyCodexHomePath = options.legacyCodexHomePath ?? dirname(paths.managedSessionsRoot) + const managedSessionsRoot = join(legacyCodexHomePath, 'sessions') + if ( + args.agent !== 'codex' || + args.executionHostId !== LOCAL_EXECUTION_HOST_ID || + !args.codexHome || + !sameRuntimePath(args.codexHome, legacyCodexHomePath) || + !options.isHostSystemDefaultRealHome() + ) { + return { useRealCodexHome: false } + } + + const sourcePath = resolve(args.filePath) + const relativePath = relative(resolve(managedSessionsRoot), sourcePath) + if (!isLegacyRolloutRelativePath(relativePath)) { + throw new Error(RETRYABLE_RESUME_ERROR) + } + const targetPath = join(paths.systemSessionsRoot, relativePath) + const key = `${normalizeRuntimePathForComparison(sourcePath)}\0${normalizeRuntimePathForComparison(targetPath)}` + let task = materializations.get(key) + if (!task) { + task = materializeLegacyRollout(sourcePath, targetPath, paths.auditLogPath) + materializations.set(key, task) + void task.then( + () => { + if (materializations.get(key) === task) { + materializations.delete(key) + } + }, + () => { + if (materializations.get(key) === task) { + materializations.delete(key) + } + } + ) + } + + try { + await task + } catch (error) { + console.warn('[codex-legacy-session-resume] Targeted session migration failed:', error) + throw new Error(RETRYABLE_RESUME_ERROR, { cause: error }) + } + return { useRealCodexHome: true } +} + +async function materializeLegacyRollout( + sourcePath: string, + targetPath: string, + auditLogPath: string +): Promise { + const sourceStat = await lstat(sourcePath) + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + throw new Error('Legacy rollout source is not a regular file.') + } + await mkdir(dirname(targetPath), { recursive: true }) + try { + await link(sourcePath, targetPath) + } catch (linkError) { + if (isExistsError(linkError)) { + await assertMatchingExistingTarget(sourcePath, targetPath) + } else { + try { + await copySessionFileWithoutOverwrite(sourcePath, targetPath) + } catch (copyError) { + if (isExistsError(copyError)) { + await assertMatchingExistingTarget(sourcePath, targetPath) + } else { + if (isAtomicNoReplaceUnsupportedError(copyError)) { + throw new Error('The target filesystem cannot safely install this rollout.', { + cause: copyError + }) + } + throw copyError + } + } + } + } + + const summary = { + stopped: false, + scannedFiles: 1, + linkedFiles: 0, + copiedFiles: 0, + skippedExistingFiles: 0, + skippedUnexpectedFiles: 0, + skippedSymlinkFiles: 0, + skippedUnsupportedFilesystemFiles: 0, + failedDirectories: 0, + failedFiles: 0, + failedHealAuditRecords: 0 + } + await appendCodexSessionHealAuditRecord( + createCodexSessionBackfillAuditWriter(auditLogPath), + summary, + { action: 'targeted-resume', source: sourcePath, target: targetPath } + ) +} + +async function assertMatchingExistingTarget(sourcePath: string, targetPath: string): Promise { + const [sourceStat, targetStat] = await Promise.all([lstat(sourcePath), lstat(targetPath)]) + if ( + !targetStat.isFile() || + targetStat.isSymbolicLink() || + sourceStat.size !== targetStat.size || + ((sourceStat.dev !== targetStat.dev || sourceStat.ino !== targetStat.ino) && + (await fileDigest(sourcePath)) !== (await fileDigest(targetPath))) + ) { + throw new Error('A different rollout already occupies the real-home target path.') + } +} + +async function fileDigest(filePath: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk as Buffer) + } + return hash.digest('hex') +} + +function isLegacyRolloutRelativePath(relativePath: string): boolean { + if (!relativePath || relativePath.startsWith('..') || resolve(relativePath) === relativePath) { + return false + } + const parts = relativePath.split(sep) + return ( + parts.length === 4 && + /^\d{4}$/.test(parts[0] ?? '') && + /^\d{2}$/.test(parts[1] ?? '') && + /^\d{2}$/.test(parts[2] ?? '') && + /^rollout-.+\.jsonl(?:\.zst)?$/.test(parts[3] ?? '') + ) +} + +function sameRuntimePath(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + +function isExistsError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index 6532f6ee5c9..8b0a8dd6f2e 100644 --- a/src/main/codex/codex-session-file-listing.ts +++ b/src/main/codex/codex-session-file-listing.ts @@ -58,6 +58,34 @@ export async function* listCodexSessionJsonlFilesIncrementally( rootPath: string, options: CodexSessionBridgeIncrementalOptions, onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + yield* listCodexSessionFilesIncrementally( + rootPath, + options, + (fileName) => fileName.endsWith('.jsonl'), + onDirectoryError + ) +} + +/** Yields both physical representations that current Codex can resume. */ +export async function* listCodexSessionRolloutFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions, + onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + yield* listCodexSessionFilesIncrementally( + rootPath, + options, + (fileName) => fileName.endsWith('.jsonl') || fileName.endsWith('.jsonl.zst'), + onDirectoryError + ) +} + +async function* listCodexSessionFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions, + isSessionFile: (fileName: string) => boolean, + onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise ): AsyncGenerator { const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) @@ -75,7 +103,7 @@ export async function* listCodexSessionJsonlFilesIncrementally( const childPath = join(currentDirectory, entry.name) if (entry.isDirectory()) { pendingDirectories.push(childPath) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + } else if (entry.isFile() && isSessionFile(entry.name)) { yield childPath } entriesSinceYield += 1 diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts new file mode 100644 index 00000000000..54094e022f1 --- /dev/null +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler' + +describe('createCodexSessionMigrationScheduler', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it('runs after a managed-account startup switches to host system default', async () => { + let eligible = false + const startBackfill = vi.fn().mockResolvedValue(null) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal + }) + + scheduler.scheduleInitialRun() + await vi.advanceTimersByTimeAsync(15_000) + expect(startBackfill).not.toHaveBeenCalled() + + eligible = true + scheduler.requestRun() + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenCalledOnce() + }) + + it('coalesces concurrent run requests and stops before index heal after opt-out', async () => { + let eligible = true + let releaseBackfill: (() => void) | undefined + const startBackfill = vi.fn( + () => + new Promise((resolve) => { + releaseBackfill = resolve + }) + ) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => '/custom/history', + startBackfill, + startIndexHeal + }) + + scheduler.requestRun() + scheduler.requestRun() + expect(startBackfill).toHaveBeenCalledOnce() + expect(startBackfill).toHaveBeenCalledWith(expect.any(Object), '/custom/history') + + eligible = false + releaseBackfill?.() + await Promise.resolve() + await Promise.resolve() + expect(startIndexHeal).not.toHaveBeenCalled() + }) + + it('reruns after a stopping migration becomes eligible again', async () => { + let eligible = true + let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined + const startBackfill = vi + .fn() + .mockImplementationOnce( + (_options) => + new Promise<{ stopped: boolean }>((resolve) => { + releaseFirstBackfill = resolve + }) + ) + .mockResolvedValueOnce({ stopped: false }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal + }) + + scheduler.requestRun() + const firstRunOptions = startBackfill.mock.calls[0]?.[0] + eligible = false + expect(firstRunOptions?.shouldStop()).toBe(true) + eligible = true + scheduler.requestRun() + releaseFirstBackfill?.({ stopped: true }) + + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts new file mode 100644 index 00000000000..908f77a6a83 --- /dev/null +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -0,0 +1,87 @@ +import type { CodexSessionBackfillOptions } from './codex-session-backfill-types' + +type MigrationRun = ( + options: CodexSessionBackfillOptions, + systemCodexHomePathOverride?: string +) => Promise + +export type CodexSessionMigrationScheduler = { + scheduleInitialRun(): void + requestRun(): void +} + +export function createCodexSessionMigrationScheduler(args: { + isEligible: () => boolean + isQuitting: () => boolean + resolveSystemCodexHomePathOverride: () => string | undefined + startBackfill: MigrationRun + startIndexHeal: MigrationRun + initialDelayMs?: number +}): CodexSessionMigrationScheduler { + let initialTimer: ReturnType | null = null + let migrationTask: Promise | null = null + let activeRunStopObserved = false + let rerunRequested = false + + const requestRun = (): void => { + if (args.isQuitting() || !args.isEligible()) { + return + } + if (migrationTask) { + // Why: an account transition can re-enable migration while the prior run is still stopping. + rerunRequested ||= activeRunStopObserved + return + } + activeRunStopObserved = false + rerunRequested = false + const shouldStop = (): boolean => { + const stopped = args.isQuitting() || !args.isEligible() + activeRunStopObserved ||= stopped + return stopped + } + const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride() + let stoppedBackfill = false + const task = args + .startBackfill({ shouldStop }, systemCodexHomePathOverride) + .then((result) => { + stoppedBackfill = isStoppedMigrationResult(result) + if (stoppedBackfill || shouldStop()) { + return + } + return args.startIndexHeal({ shouldStop }, systemCodexHomePathOverride) + }) + .catch((error: unknown) => { + console.warn('[codex-session-migration] Background session migration failed:', error) + }) + .then(() => undefined) + migrationTask = task + void task.finally(() => { + if (migrationTask === task) { + migrationTask = null + const shouldRerun = rerunRequested || stoppedBackfill + rerunRequested = false + activeRunStopObserved = false + if (shouldRerun) { + requestRun() + } + } + }) + } + + return { + scheduleInitialRun(): void { + if (initialTimer) { + return + } + initialTimer = setTimeout(() => { + initialTimer = null + requestRun() + }, args.initialDelayMs ?? 15_000) + }, + requestRun + } +} + +function isStoppedMigrationResult(result: unknown): boolean { + return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) +} diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts new file mode 100644 index 00000000000..4f55dec236d --- /dev/null +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -0,0 +1,226 @@ +import { mkdirSync, 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 { + findTrustedCodexSessionResume, + resolveTrustedCodexSessionResumeHome +} from './codex-session-resume-home' + +const tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('resolveTrustedCodexSessionResumeHome', () => { + it('returns the trusted home containing a persisted rollout', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl', + trustedCodexHomes: ['/managed/account/home', '/Users/example/.codex'], + fileIsRegular: () => true + }) + ).toBe('/Users/example/.codex') + }) + + it('accepts Windows paths case-insensitively', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl', + trustedCodexHomes: ['c:\\users\\example\\.codex'], + fileIsRegular: () => true + }) + ).toBe('c:\\users\\example\\.codex') + }) + + it('rejects paths outside trusted homes or outside the rollout layout', () => { + const fileIsRegular = vi.fn((): boolean => true) + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/tmp/sessions/2026/07/20/rollout-a.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/index.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: + '/Users/example/.codex/sessions/2026/07/20/rollout-a/../../../../outside.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect(fileIsRegular).not.toHaveBeenCalled() + }) + + it('rejects a trusted-looking path when the rollout no longer exists', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular: () => false + }) + ).toBeNull() + }) + + it('requires the transcript provenance to name a regular rollout file', () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const rolloutDirectory = join(homePath, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') + mkdirSync(rolloutDirectory, { recursive: true }) + + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: rolloutDirectory, + trustedCodexHomes: [homePath] + }) + ).toBeNull() + + const rolloutFile = join(homePath, 'sessions', '2026', '07', '20', 'rollout-b.jsonl') + writeFileSync(rolloutFile, '{}\n') + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: rolloutFile, + trustedCodexHomes: [homePath] + }) + ).toBe(homePath) + }) + + it('follows Codex when a persisted plain rollout was compressed in place', async () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const plainPath = join( + homePath, + 'sessions', + '2026', + '07', + '20', + 'rollout-2026-07-20T12-00-00-session.jsonl' + ) + const compressedPath = `${plainPath}.zst` + mkdirSync(join(plainPath, '..'), { recursive: true }) + writeFileSync(compressedPath, 'compressed-rollout') + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath: plainPath, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) + + writeFileSync(plainPath, 'active-rollout') + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath: compressedPath, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: plainPath }) + }) + + it('finds compressed rollouts for legacy records without transcript provenance', async () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const compressedPath = join( + homePath, + 'sessions', + '2026', + '07', + '20', + `rollout-2026-07-20T12-00-00-${sessionId}.jsonl.zst` + ) + mkdirSync(join(compressedPath, '..'), { recursive: true }) + writeFileSync(compressedPath, 'compressed-rollout') + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) + }) + + it('finds older saved sessions by id when transcript provenance is absent', async () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const rolloutPath = `/managed/account/home/sessions/2026/07/20/rollout-2026-07-20T15-50-19-${sessionId}.jsonl` + const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable { + if (sessionsRoot === '/managed/account/home/sessions') { + yield `/managed/account/home/sessions/misplaced-${sessionId}.jsonl` + yield rolloutPath + } + } + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'], + listSessionFiles + }) + ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath }) + }) + + it('does not scan session trees when exact transcript provenance is valid', async () => { + const transcriptPath = + '/managed/account/home/sessions/2026/07/20/rollout-2026-07-20-session.jsonl' + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath, + trustedCodexHomes: ['/managed/account/home'], + fileIsRegular: () => true, + listSessionFiles + }) + ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath }) + expect(listSessionFiles).not.toHaveBeenCalled() + }) + + it('does not replace rejected transcript provenance with a same-id rollout from another home', async () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: `/managed/origin/home/sessions/2026/07/20/rollout-${sessionId}.jsonl`, + trustedCodexHomes: ['/managed/origin/home', '/managed/other/home'], + fileIsRegular: () => false, + listSessionFiles + }) + ).resolves.toBeNull() + expect(listSessionFiles).not.toHaveBeenCalled() + }) + + it('does not scan homes for an untrusted legacy session id shape', async () => { + const listSessionFiles = (): AsyncIterable => { + throw new Error('must not scan') + } + await expect( + findTrustedCodexSessionResume({ + sessionId: '../session', + transcriptPath: undefined, + trustedCodexHomes: ['/Users/example/.codex'], + listSessionFiles + }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts new file mode 100644 index 00000000000..aebaf6a157c --- /dev/null +++ b/src/main/codex/codex-session-resume-home.ts @@ -0,0 +1,135 @@ +import { existsSync, lstatSync } from 'node:fs' +import { join } from 'node:path' +import { + getRuntimePathBasename, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' +import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' + +// Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials. +const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl(?:\.zst)?$/ + +function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean { + const relativePath = relativePathInsideRoot(sessionsRoot, filePath) + return Boolean(relativePath && ROLLOUT_RELATIVE_PATH.test(relativePath.replace(/\\/g, '/'))) +} + +function isRegularFile(filePath: string): boolean { + try { + return lstatSync(filePath).isFile() + } catch { + return false + } +} + +function resolveExistingRolloutPath( + transcriptPath: string, + fileIsRegular: (filePath: string) => boolean +): string | null { + const plainPath = transcriptPath.endsWith('.jsonl.zst') + ? transcriptPath.slice(0, -'.zst'.length) + : transcriptPath.endsWith('.jsonl') + ? transcriptPath + : null + if (!plainPath) { + return fileIsRegular(transcriptPath) ? transcriptPath : null + } + if (fileIsRegular(plainPath)) { + return plainPath + } + const compressedPath = `${plainPath}.zst` + return fileIsRegular(compressedPath) ? compressedPath : null +} + +function resolveTrustedCodexSessionResume(args: { + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean +}): { homePath: string; transcriptPath: string } | null { + const persistedPath = args.transcriptPath?.trim() + if (!persistedPath) { + return null + } + + for (const homePath of args.trustedCodexHomes) { + const sessionsRoot = join(homePath, 'sessions') + if (!isCodexRolloutInsideSessionsRoot(sessionsRoot, persistedPath)) { + continue + } + const transcriptPath = resolveExistingRolloutPath( + persistedPath, + args.fileIsRegular ?? isRegularFile + ) + if (transcriptPath) { + return { homePath, transcriptPath } + } + } + return null +} + +export function resolveTrustedCodexSessionResumeHome(args: { + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean +}): string | null { + return resolveTrustedCodexSessionResume(args)?.homePath ?? null +} + +export async function findTrustedCodexSessionResume(args: { + sessionId: string + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean + listSessionFiles?: (sessionsRoot: string) => AsyncIterable +}): Promise<{ homePath: string; transcriptPath: string } | null> { + const directSession = resolveTrustedCodexSessionResume(args) + if (directSession) { + return directSession + } + if (args.transcriptPath?.trim()) { + // Why: stale/rejected provenance must not select a same-id rollout under different account credentials; scanning is legacy-only. + return null + } + if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(args.sessionId)) { + return null + } + + const listSessionFiles = + args.listSessionFiles ?? + ((sessionsRoot: string) => + listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 })) + const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase() + const seenHomes = new Set() + for (const homePath of args.trustedCodexHomes) { + const comparisonHome = normalizeRuntimePathForComparison(homePath) + if (seenHomes.has(comparisonHome)) { + continue + } + seenHomes.add(comparisonHome) + const sessionsRoot = join(homePath, 'sessions') + if (!args.listSessionFiles && !existsSync(sessionsRoot)) { + continue + } + for await (const filePath of listSessionFiles(sessionsRoot)) { + const plainFilePath = filePath.endsWith('.jsonl.zst') + ? filePath.slice(0, -'.zst'.length) + : filePath + // Why: the directory entry already proves the compressed file exists; only probe its preferred plain sibling. + const preferredFilePath = + plainFilePath !== filePath && (args.fileIsRegular ?? isRegularFile)(plainFilePath) + ? plainFilePath + : filePath + const plainFileName = getRuntimePathBasename(preferredFilePath) + .toLowerCase() + .replace(/\.zst$/, '') + if ( + isCodexRolloutInsideSessionsRoot(sessionsRoot, preferredFilePath) && + plainFileName.endsWith(expectedSuffix) + ) { + return { homePath, transcriptPath: preferredFilePath } + } + } + } + return null +} diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 028fb8f0c4c..8e8a483dfd1 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,9 +3,10 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(24) + expect(PROTOCOL_VERSION).toBe(25) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24) }) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 119a3bb14d7..6b5dfe8c376 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,10 +1,10 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -export const PROTOCOL_VERSION = 24 -export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24 +export const PROTOCOL_VERSION = 25 +export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 9b022a44211..eb9d5d2755e 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -151,10 +151,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { rows: 24, startupIngress: { colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000, - ...(process.platform === 'win32' - ? { echoProjection: 'windows-conpty-esc-stripped' as const } - : {}) + deadlineMs: 5_000 } }) const query = '\x1b]10;?\x07' diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index bf7077ee774..b6588e44e69 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -184,6 +184,9 @@ describe('DaemonServer', () => { isNew: true, pid: 55555 }) + await expect( + c.request('closeStartupQueryAuthority', { sessionId: 'test-session' }) + ).resolves.toEqual({ appliedSeq: 0 }) }) it('keeps RPC responsive and creates one subprocess while spawn preparation is pending', async () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 8d529911c54..9da8c2e48e3 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -22,7 +22,6 @@ import { checkPtySpawnHealth } from './pty-subprocess' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' import { isTuiAgent } from '../../shared/tui-agent-config' import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' -import { isNativeWindowsLocalPtySpawn } from '../runtime/terminal-model-query-authority' import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, @@ -699,13 +698,7 @@ export class DaemonServer { terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, historySeed: p.historySeed, - startupIngress: parsePtyStartupIngressIntent(p.startupIngress, { - allowWindowsEchoProjection: isNativeWindowsLocalPtySpawn({ - connectionId: null, - cwd: p.cwd, - shellOverride: p.shellOverride - }) - }), + startupIngress: parsePtyStartupIngressIntent(p.startupIngress), ...(p.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } : {}), @@ -779,6 +772,11 @@ export class DaemonServer { this.cancelPendingPtySpawnPreparations(request.payload.sessionId) return {} + case 'closeStartupQueryAuthority': + return { + appliedSeq: this.host.closeStartupQueryAuthority(request.payload.sessionId) + } + case 'write': try { this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now()) diff --git a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts index 937dca27bc4..d63acd1161a 100644 --- a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts @@ -40,10 +40,12 @@ vi.mock('../providers/windows-powershell-executable', () => ({ })) vi.mock('../providers/agent-foreground-process', () => ({ - resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => ({ - available: true, - processName: await resolveAgentForegroundProcessMock(...args) - }) + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => { + const value = await resolveAgentForegroundProcessMock(...args) + return value && typeof value === 'object' && 'available' in value + ? value + : { available: true, processName: value } + } })) import { createPtySubprocess } from './pty-subprocess' @@ -185,4 +187,55 @@ describe('daemon pty foreground scan cadence', () => { // Scans at t=0s, 6s, 12s, 18s, 24s, 30s — the cheap `ps` path is unchanged. expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(6) }) + + it.each(['darwin', 'win32'] as const)( + 'keeps wrapped pi reads on the cached omp owner between bounded %s scans', + async (targetPlatform) => { + resolveAgentForegroundProcessMock.mockResolvedValue('omp') + const { handle } = spawnShellSubprocess('pi', targetPlatform) + + const reads: (string | null)[] = [] + for (let atMs = 0; atMs <= 3_000; atMs += 250) { + reads.push(await readForegroundAt(handle, atMs)) + } + + // The first synchronous read precedes enrichment; every later read stays on + // OMP even when its 1s cache entry expires while the next scan is in flight. + expect(reads).toEqual(['pi', ...Array.from({ length: 12 }, () => 'omp')]) + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(4) + + await expect(handle.confirmForegroundProcess!()).resolves.toBe('omp') + expect(resolveAgentForegroundProcessMock).toHaveBeenLastCalledWith( + 12345, + 'pi', + expect.objectContaining({ fresh: true }) + ) + } + ) + + it.each(['darwin', 'win32'] as const)( + 'keeps authoritative %s omp reads on the zero-scan path', + async (targetPlatform) => { + const { handle } = spawnShellSubprocess('omp', targetPlatform) + + for (let atMs = 0; atMs <= 3_000; atMs += 250) { + expect(await readForegroundAt(handle, atMs)).toBe('omp') + } + expect(resolveAgentForegroundProcessMock).not.toHaveBeenCalled() + } + ) + + it.each(['darwin', 'win32'] as const)( + 'keeps the cached omp owner when a %s wrapper scan is unavailable', + async (targetPlatform) => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce('omp') + .mockResolvedValue({ available: false, processName: 'pi' }) + const { handle } = spawnShellSubprocess('pi', targetPlatform) + + expect(await readForegroundAt(handle, 0)).toBe('pi') + expect(await readForegroundAt(handle, 1_000)).toBe('omp') + expect(await readForegroundAt(handle, 2_500)).toBe('omp') + } + ) }) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 9f983708f07..1930b6e23b5 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1218,6 +1218,55 @@ describe('createPtySubprocess', () => { expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined() }) + it('does not inherit NODE_ENV from the daemon process env', () => { + // Why: a dev-mode Orca forks the daemon with NODE_ENV=development; leaking + // Orca's build mode into user shells breaks `next build` and Vitest. + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.NODE_ENV).toBeUndefined() + expect(env.PATH).toBe(process.env.PATH) + }) + + it('keeps an explicitly requested NODE_ENV for daemon PTY shells', () => { + // Why: only the ambient value is stripped; a caller-supplied NODE_ENV still wins. + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { NODE_ENV: 'production' } + }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.NODE_ENV).toBe('production') + }) + it('does not inherit AppImage runtime env into daemon PTY shells', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 16ec128ab68..8066770c526 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -31,6 +31,7 @@ import { isPwshAvailable } from '../pwsh' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' +import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' import { @@ -53,6 +54,7 @@ import { recognizeAgentProcess, recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { shouldInspectOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import { shouldUseShellReadyStartupDelivery, type StartupCommandDelivery @@ -82,6 +84,11 @@ const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2 const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 +function shouldInspectOuterWrapperFallback(processName: string | null): boolean { + const recognized = recognizeAgentProcess(processName) + return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized) +} + function composeGuardedDaemonGitConfigEnv( env: Record, explicitEnv: Record | undefined, @@ -553,7 +560,7 @@ function spawnDaemonPtyWithWindowsFallback(args: { export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle { const size = normalizePtySize(opts.cols, opts.rows) const env: Record = { - ...mergeGitConfigEnvProtocol(process.env, opts.env), + ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', @@ -883,6 +890,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl fallbackProcess !== null && (isShellProcess(fallbackProcess) || isAgentForegroundWrapperProcess(fallbackProcess) || + shouldInspectOuterWrapperFallback(fallbackProcess) || // Why: agent-spawned helpers can become the PTY foreground child, but the Unix process tree still identifies the parent agent. process.platform !== 'win32') const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => { @@ -890,9 +898,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return } const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess) + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) if ( !fallbackProcess || - recognizeAgentProcess(fallbackProcess) || + (fallbackRecognition !== null && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || !shouldInspectFallbackForegroundProcess(fallbackProcess) ) { return @@ -990,7 +1000,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } try { const fallbackProcess = getFallbackForegroundProcess() - if (fallbackProcess && recognizeAgentProcess(fallbackProcess)) { + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + const inspectOuterWrapper = + fallbackRecognition !== null && + shouldInspectOuterWrapperForegroundProcess(fallbackRecognition) + if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) { cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() } startupAgentForeground = null return fallbackProcess @@ -1009,6 +1023,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl cachedAgentForeground && fallbackProcess !== null && (isAgentForegroundWrapperProcess(fallbackProcess) || + inspectOuterWrapper || (process.platform === 'win32' && isShellProcess(fallbackProcess))) ) { return cachedAgentForeground.processName @@ -1028,9 +1043,12 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } try { const fallbackProcess = getFallbackForegroundProcess() + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) if ( !fallbackProcess || - (recognizeAgentProcess(fallbackProcess) && process.platform !== 'win32') || + (fallbackRecognition !== null && + process.platform !== 'win32' && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || (process.platform !== 'win32' && !shouldInspectFallbackForegroundProcess(fallbackProcess)) ) { return fallbackProcess diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index b5446524ebf..1f709330e2d 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -109,8 +109,8 @@ describe('Session', () => { startupIngress?: { colors: { foreground: string; background: string } deadlineMs: number - echoProjection?: 'windows-conpty-esc-stripped' } + ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl' wslDistro?: string }): Session { session = new Session({ @@ -120,6 +120,7 @@ describe('Session', () => { ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), wslDistro: opts?.wslDistro, subprocess, + ...(opts?.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), shellReadySupported: opts?.shellReadySupported ?? false, ...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}), ...(opts?.shellReadyTimeoutMs !== undefined @@ -196,10 +197,10 @@ describe('Session', () => { it('classifies startup queries and cooked echoes before model, persistence, and fanout', () => { createSession({ + ownerBackend: 'windows-conpty', startupIngress: { colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000, - echoProjection: 'windows-conpty-esc-stripped' + deadlineMs: 5_000 } }) const onData = vi.fn() @@ -229,10 +230,10 @@ describe('Session', () => { it('releases a held cooked-echo prefix before taking a snapshot', () => { createSession({ + ownerBackend: 'windows-conpty', startupIngress: { colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000, - echoProjection: 'windows-conpty-esc-stripped' + deadlineMs: 5_000 } }) subprocess.simulateData('\x1b]10;?\x07') @@ -243,6 +244,53 @@ describe('Session', () => { expect(snapshot?.snapshotAnsi).toContain(']10;rgb:2e2e/') expect(snapshot?.outputSequence).toBe('\x1b]10;?\x07]10;rgb:2e2e/'.length) }) + + it('reproduces the legacy paired-runtime leak and removes its downstream producer', () => { + const query = '\x1b]10;?\x07' + const reply = '\x1b]10;rgb:2e2e/3434/3434\x1b\\' + const projectedEcho = ']10;rgb:2e2e/3434/3434\\' + createSession({ ownerBackend: 'posix-pty' }) + session.closeStartupQueryAuthority() + const legacyReplyProducers: string[] = [] + const legacyOnData = vi.fn((data: string) => { + if (data === query) { + legacyReplyProducers.push('remote-visible-renderer') + session.write(reply) + if (subprocess.written.at(-1) === reply) { + subprocess.simulateData(projectedEcho) + } + } + }) + session.attachClient({ onData: legacyOnData, onExit: () => {} }) + + subprocess.simulateData(query) + + expect(legacyReplyProducers).toEqual(['remote-visible-renderer']) + expect(subprocess.written).toEqual([reply]) + expect(legacyOnData.mock.calls).toEqual([[query], [projectedEcho]]) + expect(session.getSnapshot()?.snapshotAnsi).toContain(projectedEcho) + session.dispose() + + subprocess = createMockSubprocess() + createSession({ ownerBackend: 'windows-conpty' }) + session.closeStartupQueryAuthority() + const fixedReplyProducers: string[] = [] + const fixedOnData = vi.fn((data: string) => { + if (data === query) { + fixedReplyProducers.push('remote-visible-renderer') + session.write(reply) + } + }) + session.attachClient({ onData: fixedOnData, onExit: () => {} }) + + subprocess.simulateData(query) + subprocess.simulateData('prompt') + + expect(fixedReplyProducers).toEqual([]) + expect(subprocess.written).toEqual([]) + expect(fixedOnData.mock.calls).toEqual([['', query.length, true, query.length], ['prompt']]) + expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb') + }) }) describe('write', () => { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index c88061403a4..67a51d6a37d 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -24,6 +24,7 @@ import type { TakePendingOutputResult, TerminalSnapshot } from './types' +import type { PtyOwnerBackend } from '../../shared/pty-owner-backend' const SHELL_READY_TIMEOUT_MS = 15_000 // Why: Codex skips marker-gated command delivery; this only bounds older daemon/local paths that still report shell-ready for Codex. @@ -85,6 +86,7 @@ export type SessionOptions = { // a reaper, dead sessions and their scrollback emulators accumulate for the daemon's lifetime. onExit?: (code: number) => void startupIngress?: PtyStartupIngressIntent + ownerBackend?: PtyOwnerBackend } type AttachedClient = { @@ -158,6 +160,7 @@ export class Session { this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue()) this.startupIngress = new PtyStartupIngress({ ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), + ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), onEmission: (emission) => this.emitSubprocessOutput(emission) }) diff --git a/src/main/daemon/terminal-host-pty-owner-backend.test.ts b/src/main/daemon/terminal-host-pty-owner-backend.test.ts new file mode 100644 index 00000000000..446e0923f39 --- /dev/null +++ b/src/main/daemon/terminal-host-pty-owner-backend.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session' +import { TerminalHost } from './terminal-host' + +const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: killWithDescendantSweepMock +})) + +type TestSubprocess = SubprocessHandle & { + emitData: (data: string) => void +} + +function createSubprocess(shellPath: string): TestSubprocess { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + return { + pid: 99_999, + shellPath, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => onExit?.(0)), + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData: (callback) => { + onData = callback + }, + onExit: (callback) => { + onExit = callback + }, + dispose: vi.fn(), + emitData: (data) => onData?.(data) + } +} + +describe('TerminalHost PTY owner backend', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + let host: TerminalHost + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(async () => { + await host?.dispose() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + async function createSession( + shellPath: string, + requestedWslDistro?: string, + onData = vi.fn() + ): Promise { + const subprocess = createSubprocess(shellPath) + host = new TerminalHost({ spawnSubprocess: () => subprocess }) + await host.createOrAttach({ + sessionId: 'owner-test', + cols: 80, + rows: 24, + ...(requestedWslDistro + ? { shellOverride: 'wsl.exe', terminalWindowsWslDistro: requestedWslDistro } + : { shellOverride: 'powershell.exe' }), + streamClient: { onData, onExit: vi.fn() } + }) + return subprocess + } + + it('uses the spawned native shell over stale requested WSL metadata', async () => { + const replyProducers: string[] = [] + const onData = vi.fn((data: string) => { + if (data === '\x1b]10;?\x07') { + replyProducers.push('renderer') + host.write('owner-test', '\x1b]10;rgb:ffff/ffff/ffff\x1b\\') + } + }) + const subprocess = await createSession('powershell.exe', 'Ubuntu', onData) + + subprocess.emitData('\x1b]10;?\x07') + + expect(replyProducers).toEqual([]) + expect(onData).toHaveBeenCalledWith('', '\x1b]10;?\x07'.length, true, '\x1b]10;?\x07'.length) + expect(subprocess.write).not.toHaveBeenCalled() + }) + + it('keeps replies for an actually spawned WSL shell', async () => { + const reply = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\' + const replyProducers: string[] = [] + const onData = vi.fn((data: string) => { + if (data === '\x1b]10;?\x07') { + replyProducers.push('renderer') + host.write('owner-test', reply) + } + }) + const subprocess = await createSession('wsl.exe', undefined, onData) + + subprocess.emitData('\x1b]10;?\x07') + + expect(replyProducers).toEqual(['renderer']) + expect(subprocess.write).toHaveBeenCalledWith(reply) + }) +}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index ea1093572aa..e6e0eb01145 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,6 +1,7 @@ import { Session } from './session' import { normalizePtySize } from './daemon-pty-size' import { shellPathSupportsPtyStartupBarrier } from './shell-ready' +import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { resolveProcessCwd } from '../providers/process-cwd' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { @@ -111,6 +112,11 @@ export class TerminalHost { terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, launchAgent: opts.launchAgent, subprocess, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: subprocess.shellPath, + wslDistro + }), shellReadySupported, historySeed: opts.historySeed, ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts index 777110630c1..402e9253f21 100644 --- a/src/main/ghostty/mapper-extended.test.ts +++ b/src/main/ghostty/mapper-extended.test.ts @@ -200,15 +200,15 @@ describe('mapGhosttyToOrca — cursor-text', () => { }) describe('mapGhosttyToOrca — bold-color', () => { - it('maps valid hex to terminalColorOverrides.bold', () => { + // Why: xterm.js ITheme has no bold color slot (xtermjs/xterm.js#6032), so bold-color can never + // render; the importer must list it as unsupported rather than claim it was applied (#8595). + it('reports valid bold-color as unsupported and does not apply it', () => { const result = mapGhosttyToOrca({ 'bold-color': '#ff0000' }) - expect(result.diff).toEqual({ - terminalColorOverrides: { bold: '#ff0000' } - }) - expect(result.unsupportedKeys).toEqual([]) + expect(result.diff).toEqual({}) + expect(result.unsupportedKeys).toEqual(['bold-color']) }) - it('rejects invalid bold-color', () => { + it('reports invalid bold-color as unsupported', () => { const result = mapGhosttyToOrca({ 'bold-color': 'red' }) expect(result.diff).toEqual({}) expect(result.unsupportedKeys).toEqual(['bold-color']) diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts index 09ddec24c83..c3734cd04c5 100644 --- a/src/main/ghostty/mapper.ts +++ b/src/main/ghostty/mapper.ts @@ -199,13 +199,6 @@ export function mapGhosttyToOrca( return { colorOverrides: { cursorAccent: normalizeHex(v) } } }, - 'bold-color': (v) => { - if (!HEX_COLOR_RE.test(v)) { - return null - } - return { colorOverrides: { bold: normalizeHex(v) } } - }, - 'mouse-hide-while-typing': (v) => { if (v !== 'true' && v !== 'false') { return null @@ -289,6 +282,13 @@ export function mapGhosttyToOrca( continue } + // Why: xterm.js ITheme has no bold color slot (xtermjs/xterm.js#6032), so importing + // bold-color would render as a no-op; report it as unsupported instead of silently dropping. + if (key === 'bold-color') { + unsupportedKeys.push(key) + continue + } + if (!value.trim()) { unsupportedKeys.push(key) continue diff --git a/src/main/git/gh-rate-limit-breaker.test.ts b/src/main/git/gh-rate-limit-breaker.test.ts index 0975dd325ae..8fa8d667dc5 100644 --- a/src/main/git/gh-rate-limit-breaker.test.ts +++ b/src/main/git/gh-rate-limit-breaker.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + _getGhRateLimitBlockCount, _resetGhRateLimitBreaker, classifyGhRateLimitBucket, createGhRateLimitBlockedError, @@ -7,6 +8,7 @@ import { isGhPrimaryRateLimitStderr, isGhRateLimitProbe, notifyGhPrimaryRateLimit, + parseGhRateLimitScopeKey, recordGhPrimaryRateLimit, registerGhRateLimitResetProbe } from './gh-rate-limit-breaker' @@ -41,6 +43,7 @@ describe('isGhRateLimitProbe', () => { it('recognizes the exempt rate_limit endpoint only', () => { expect(isGhRateLimitProbe(['api', 'rate_limit'])).toBe(true) expect(isGhRateLimitProbe(['api', '/rate_limit'])).toBe(true) + expect(isGhRateLimitProbe(['api', '--hostname', 'github.com', 'rate_limit'])).toBe(true) expect(isGhRateLimitProbe(['api', 'search/issues?q=x'])).toBe(false) expect(isGhRateLimitProbe(['pr', 'list'])).toBe(false) }) @@ -58,6 +61,36 @@ describe('isGhPrimaryRateLimitStderr', () => { }) }) +describe('parseGhRateLimitScopeKey', () => { + it('parses native and wsl scopes, keeping ported hosts intact', () => { + expect(parseGhRateLimitScopeKey('native:github.com')).toEqual({ + runtime: 'native', + host: 'github.com' + }) + expect(parseGhRateLimitScopeKey('native:ghe.corp:8443')).toEqual({ + runtime: 'native', + host: 'ghe.corp:8443' + }) + expect(parseGhRateLimitScopeKey('wsl:ubuntu:github.com')).toEqual({ + runtime: 'wsl', + wslDistro: 'ubuntu', + host: 'github.com' + }) + expect(parseGhRateLimitScopeKey('wsl:ubuntu:ghe.corp:8443')).toEqual({ + runtime: 'wsl', + wslDistro: 'ubuntu', + host: 'ghe.corp:8443' + }) + }) + + it('rejects malformed scopes', () => { + expect(parseGhRateLimitScopeKey('native:')).toBeNull() + expect(parseGhRateLimitScopeKey('wsl:ubuntu')).toBeNull() + expect(parseGhRateLimitScopeKey('wsl:ubuntu:')).toBeNull() + expect(parseGhRateLimitScopeKey('ssh:github.com')).toBeNull() + }) +}) + describe('breaker state', () => { it('blocks until the recorded time, then expires', () => { const now = 1_000_000 @@ -76,14 +109,68 @@ describe('breaker state', () => { expect(getGhRateLimitBlockedUntilMs('core', now)).toBe(now + 60_000) }) + it('isolates blocks by host and execution runtime scope', () => { + const now = 1_000_000 + recordGhPrimaryRateLimit('core', now + 60_000, 'native:github.com') + + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:github.com')).toBe(now + 60_000) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:github.acme-corp.com')).toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', now, 'wsl:ubuntu:github.com')).toBeNull() + }) + + it('bounds retained blocks across user-supplied Enterprise host scopes', () => { + const now = Date.now() + for (let i = 0; i < 1_200; i += 1) { + recordGhPrimaryRateLimit('core', now + 60_000, `native:ghe-${i}.example.test`) + } + + expect(_getGhRateLimitBlockCount()).toBe(1024) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:ghe-1199.example.test')).toBe( + now + 60_000 + ) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:ghe-0.example.test')).toBeNull() + }) + + it('keeps a recently-read active scope when the bounded map evicts', () => { + const now = Date.now() + const hotScope = 'native:active.example.test' + recordGhPrimaryRateLimit('core', now + 60_000, hotScope) + for (let i = 0; i < 1_023; i += 1) { + recordGhPrimaryRateLimit('core', now + 60_000, `native:cold-${i}.example.test`) + } + + expect(getGhRateLimitBlockedUntilMs('core', now, hotScope)).toBe(now + 60_000) + recordGhPrimaryRateLimit('core', now + 60_000, 'native:new.example.test') + + expect(getGhRateLimitBlockedUntilMs('core', now, hotScope)).toBe(now + 60_000) + expect(getGhRateLimitBlockedUntilMs('core', now, 'native:cold-0.example.test')).toBeNull() + }) + it('notifyGhPrimaryRateLimit applies a fallback block and fires the reset probe', () => { const probe = vi.fn() registerGhRateLimitResetProbe(probe) notifyGhPrimaryRateLimit('search') - expect(probe).toHaveBeenCalledWith('search') + expect(probe).toHaveBeenCalledWith('search', 'native:github.com') expect(getGhRateLimitBlockedUntilMs('search')).toBeGreaterThan(Date.now()) }) + it('fires the reset probe with the tripping scope for non-default scopes', () => { + // Why: a WSL-only Windows install (or GHES) would otherwise stay on the + // blunt 5-minute fallback forever — the probe must learn which scope to + // query. + const probe = vi.fn() + registerGhRateLimitResetProbe(probe) + + notifyGhPrimaryRateLimit('core', 'native:github.acme-corp.com') + notifyGhPrimaryRateLimit('core', 'wsl:ubuntu:github.com') + + expect(probe).toHaveBeenNthCalledWith(1, 'core', 'native:github.acme-corp.com') + expect(probe).toHaveBeenNthCalledWith(2, 'core', 'wsl:ubuntu:github.com') + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).not.toBeNull() + }) + it('creates an error that classifies as rate_limited, not permission_denied', () => { const error = createGhRateLimitBlockedError('search', Date.now() + 30_000) expect(error.stderr.toLowerCase()).toContain('rate limit') diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts index 94bef46ff7a..8bc4276490a 100644 --- a/src/main/git/gh-rate-limit-breaker.ts +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -25,8 +25,45 @@ const FALLBACK_BLOCK_MS: Record = { graphql: 5 * 60_000 } -const blockedUntilMsByBucket = new Map() -let resetProbe: ((bucket: GhRateLimitBucket) => void) | null = null +// Why: the runner derives scopes with this builder too — a format drift would +// silently split blocks from the default-scope reads below. +export function ghRateLimitScopeKey(runtime: string, host: string): string { + return `${runtime}:${host.toLowerCase()}` +} + +export type GhRateLimitScopeParts = + | { runtime: 'native'; host: string } + | { runtime: 'wsl'; wslDistro: string; host: string } + +// Why: the reset probe must spawn gh against the tripping scope's runtime and +// host. Parsing lives next to the builder so the key format stays private to +// this module instead of being string-split at call sites. +export function parseGhRateLimitScopeKey(scope: string): GhRateLimitScopeParts | null { + if (scope.startsWith('native:')) { + const host = scope.slice('native:'.length) + return host ? { runtime: 'native', host } : null + } + if (scope.startsWith('wsl:')) { + // Hosts may carry a port (host:port), so only the first colon after the + // distro is a separator. + const rest = scope.slice('wsl:'.length) + const sep = rest.indexOf(':') + if (sep <= 0 || sep === rest.length - 1) { + return null + } + return { runtime: 'wsl', wslDistro: rest.slice(0, sep), host: rest.slice(sep + 1) } + } + return null +} + +const DEFAULT_SCOPE = ghRateLimitScopeKey('native', 'github.com') +const GH_RATE_LIMIT_BLOCK_MAX_ENTRIES = 1024 +const blockedUntilMsByScopeAndBucket = new Map() +let resetProbe: ((bucket: GhRateLimitBucket, scope: string) => void) | null = null + +function breakerKey(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): string { + return `${scope}\0${bucket}` +} // gh api flags that take a separate value, so the endpoint arg can be found. const GH_API_VALUE_FLAGS = new Set([ @@ -103,43 +140,77 @@ export function isGhPrimaryRateLimitStderr(stderr: string): boolean { return s.includes('api rate limit exceeded') && !s.includes('secondary rate limit') } -export function recordGhPrimaryRateLimit(bucket: GhRateLimitBucket, blockedUntilMs: number): void { - const existing = blockedUntilMsByBucket.get(bucket) ?? 0 - blockedUntilMsByBucket.set(bucket, Math.max(existing, blockedUntilMs)) +export function recordGhPrimaryRateLimit( + bucket: GhRateLimitBucket, + blockedUntilMs: number, + scope = DEFAULT_SCOPE +): void { + const key = breakerKey(bucket, scope) + const existing = blockedUntilMsByScopeAndBucket.get(key) ?? 0 + // Why: host scopes can originate from pasted GHES URLs. Refresh insertion + // order and cap retained blocks so a stream of failing hosts cannot grow the + // main-process map for the lifetime of the app. + blockedUntilMsByScopeAndBucket.delete(key) + blockedUntilMsByScopeAndBucket.set(key, Math.max(existing, blockedUntilMs)) + while (blockedUntilMsByScopeAndBucket.size > GH_RATE_LIMIT_BLOCK_MAX_ENTRIES) { + const oldestKey = blockedUntilMsByScopeAndBucket.keys().next().value + if (oldestKey === undefined) { + break + } + blockedUntilMsByScopeAndBucket.delete(oldestKey) + } } -export function clearGhRateLimitBlock(bucket: GhRateLimitBucket): void { - blockedUntilMsByBucket.delete(bucket) +export function clearGhRateLimitBlock(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void { + blockedUntilMsByScopeAndBucket.delete(breakerKey(bucket, scope)) } export function getGhRateLimitBlockedUntilMs( bucket: GhRateLimitBucket, - nowMs: number = Date.now() + nowMs: number = Date.now(), + scope = DEFAULT_SCOPE ): number | null { - const blockedUntil = blockedUntilMsByBucket.get(bucket) + for (const [key, blockedUntil] of blockedUntilMsByScopeAndBucket) { + if (blockedUntil <= nowMs) { + blockedUntilMsByScopeAndBucket.delete(key) + } + } + const key = breakerKey(bucket, scope) + const blockedUntil = blockedUntilMsByScopeAndBucket.get(key) if (blockedUntil === undefined) { return null } if (blockedUntil <= nowMs) { - blockedUntilMsByBucket.delete(bucket) + blockedUntilMsByScopeAndBucket.delete(key) return null } + // Why: active blocks are hot entries; refresh their insertion order so a + // stream of one-off hostile hosts evicts cold scopes before an active one. + blockedUntilMsByScopeAndBucket.delete(key) + blockedUntilMsByScopeAndBucket.set(key, blockedUntil) return blockedUntil } +/** @internal — cache-bound tests only. */ +export function _getGhRateLimitBlockCount(): number { + return blockedUntilMsByScopeAndBucket.size +} + /** Register the (single) callback that refreshes precise reset times after a * breaker trip. Registered by the github rate-limit prober at module load. */ export function registerGhRateLimitResetProbe( - probe: ((bucket: GhRateLimitBucket) => void) | null + probe: ((bucket: GhRateLimitBucket, scope: string) => void) | null ): void { resetProbe = probe } /** Called by the runner when a gh spawn came back with a primary 403. */ -export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket): void { - recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket]) +export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void { + recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket], scope) + // The probe receives the tripping scope so it can run gh against that + // runtime/host and refine the fallback block into the real reset time. try { - resetProbe?.(bucket) + resetProbe?.(bucket, scope) } catch { // best-effort refinement; the fallback block stands } @@ -161,6 +232,6 @@ export function createGhRateLimitBlockedError( /** @internal — test-only */ export function _resetGhRateLimitBreaker(): void { - blockedUntilMsByBucket.clear() + blockedUntilMsByScopeAndBucket.clear() resetProbe = null } diff --git a/src/main/git/runner-gh-host-args.test.ts b/src/main/git/runner-gh-host-args.test.ts new file mode 100644 index 00000000000..0bee44eac74 --- /dev/null +++ b/src/main/git/runner-gh-host-args.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import { applyGhHostToArgs } from './runner' + +describe('applyGhHostToArgs', () => { + it('returns args unchanged when no host is given', () => { + const args = ['api', 'repos/a/b/pulls'] + expect(applyGhHostToArgs(args)).toBe(args) + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'])).toEqual([ + 'pr', + 'list', + '--repo', + 'a/b' + ]) + }) + + it('injects --hostname right after `api` for any host', () => { + expect(applyGhHostToArgs(['api', 'rate_limit'], 'github.com')).toEqual([ + 'api', + '--hostname', + 'github.com', + 'rate_limit' + ]) + expect(applyGhHostToArgs(['api', 'repos/a/b/pulls'], 'github.acme-corp.com')).toEqual([ + 'api', + '--hostname', + 'github.acme-corp.com', + 'repos/a/b/pulls' + ]) + }) + + it('does not double-inject when --hostname is already present', () => { + expect( + applyGhHostToArgs(['api', '--hostname', 'github.com', 'rate_limit'], 'github.acme-corp.com') + ).toEqual(['api', '--hostname', 'github.com', 'rate_limit']) + expect( + applyGhHostToArgs(['api', '--hostname=github.com', 'rate_limit'], 'github.acme-corp.com') + ).toEqual(['api', '--hostname=github.com', 'rate_limit']) + }) + + it('qualifies bare owner/repo in --repo / -R for a GHES host', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'github.acme-corp.com/a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '-R', 'a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-R', + 'github.acme-corp.com/a/b' + ]) + }) + + it('qualifies the inline --repo= form for a GHES host', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo=a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '--repo=github.acme-corp.com/a/b' + ]) + }) + + it('leaves combined -R short forms untouched (indistinguishable from free-text values)', () => { + expect(applyGhHostToArgs(['pr', 'list', '-R=a/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-R=a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '-Ra/b'], 'github.acme-corp.com')).toEqual([ + 'pr', + 'list', + '-Ra/b' + ]) + }) + + it('does not rewrite free-text flag values that merely start with -R', () => { + expect( + applyGhHostToArgs( + ['pr', 'edit', '7', '--title', '-Refactor foo/bar', '--repo', 'a/b'], + 'github.acme-corp.com' + ) + ).toEqual([ + 'pr', + 'edit', + '7', + '--title', + '-Refactor foo/bar', + '--repo', + 'github.acme-corp.com/a/b' + ]) + }) + + it('qualifies --repo for the github.com host so GH_HOST cannot redirect it', () => { + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'github.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'github.com/a/b' + ]) + expect(applyGhHostToArgs(['pr', 'list', '--repo', 'a/b'], 'GitHub.com')).toEqual([ + 'pr', + 'list', + '--repo', + 'GitHub.com/a/b' + ]) + }) + + it('passes through URL and already-qualified 3-part --repo values', () => { + expect( + applyGhHostToArgs( + ['pr', 'list', '--repo', 'https://github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '--repo', 'https://github.acme-corp.com/a/b']) + expect( + applyGhHostToArgs( + ['pr', 'list', '--repo', 'github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '--repo', 'github.acme-corp.com/a/b']) + expect( + applyGhHostToArgs( + ['pr', 'list', '-R', 'https://github.acme-corp.com/a/b'], + 'github.acme-corp.com' + ) + ).toEqual(['pr', 'list', '-R', 'https://github.acme-corp.com/a/b']) + }) + + it('injects --hostname and qualifies --repo together for GHES api calls', () => { + expect( + applyGhHostToArgs(['api', 'repos/a/b/pulls', '--repo', 'a/b'], 'github.acme-corp.com') + ).toEqual([ + 'api', + '--hostname', + 'github.acme-corp.com', + 'repos/a/b/pulls', + '--repo', + 'github.acme-corp.com/a/b' + ]) + }) +}) diff --git a/src/main/git/runner-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts index dc22bb54f4e..17e14b8e7d6 100644 --- a/src/main/git/runner-gh-rate-limit-breaker.test.ts +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -13,7 +13,11 @@ vi.mock('child_process', () => ({ })) import { ghExecFileAsync } from './runner' -import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' +import { + _resetGhRateLimitBreaker, + getGhRateLimitBlockedUntilMs, + registerGhRateLimitResetProbe +} from './gh-rate-limit-breaker' const PRIMARY_RATE_LIMIT_STDERR = 'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)' @@ -71,11 +75,124 @@ describe('ghExecFileAsync rate-limit breaker', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) + it('keeps other GitHub hosts and WSL runtimes working when github.com is blocked', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() + + // The breaker scope now comes from options.host / GH_HOST / wslDistro, not + // from sniffing argv — so an Enterprise host must be routed via options.host. + mockGhSuccess('[]') + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { host: 'github.acme-corp.com' }) + ).resolves.toMatchObject({ stdout: '[]' }) + await expect( + ghExecFileAsync(['pr', 'list', '--repo', 'a/b'], { host: 'github.acme-corp.com' }) + ).resolves.toMatchObject({ stdout: '[]' }) + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { + env: { ...process.env, GH_HOST: 'github.acme-corp.com' } + }) + ).resolves.toMatchObject({ stdout: '[]' }) + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ stdout: '[]' }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + expect(execFileMock).toHaveBeenCalledTimes(5) + }) + + it.each([ + ['separate', ['--hostname', 'github.com']], + ['inline', ['--hostname=github.com']] + ])('scopes an explicit %s hostname ahead of process GH_HOST', async (_name, hostnameArgs) => { + const originalGhHost = process.env.GH_HOST + process.env.GH_HOST = 'github.acme-corp.com' + try { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', ...hostnameArgs, 'repos/a/b/pulls'])).rejects.toThrow( + 'rate limit' + ) + + expect(getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.com')).not.toBeNull() + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).toBeNull() + + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toMatchObject({ + stdout: '[]' + }) + } finally { + if (originalGhHost === undefined) { + delete process.env.GH_HOST + } else { + process.env.GH_HOST = originalGhHost + } + } + }) + + it.each([ + ['--repo', ['pr', 'list', '--repo', 'github.acme-corp.com/acme/widgets']], + ['-R', ['pr', 'list', '-R', 'github.acme-corp.com/acme/widgets']], + ['--repo=', ['pr', 'list', '--repo=github.acme-corp.com/acme/widgets']] + ])('scopes a pre-qualified %s value to its repository host', async (_name, args) => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + + await expect( + ghExecFileAsync(args, { env: { ...process.env, GH_HOST: 'github.com' } }) + ).rejects.toThrow('rate limit') + + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + ).not.toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.com')).toBeNull() + }) + + it('scopes a WSL UNC-cwd 403 to the distro runtime and probes that scope', async () => { + // Why: a \\wsl.localhost\... cwd routes gh through wsl.exe, so the 403 + // belongs to the distro's account — the native scope must stay usable and + // the reset probe must be told which scope tripped. + const probe = vi.fn() + registerGhRateLimitResetProbe(probe) + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + }) + ).rejects.toThrow() + + expect(probe).toHaveBeenCalledWith('core', 'wsl:ubuntu:github.com') + expect( + getGhRateLimitBlockedUntilMs('core', Date.now(), 'wsl:ubuntu:github.com') + ).not.toBeNull() + expect(getGhRateLimitBlockedUntilMs('core', Date.now())).toBeNull() + + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toMatchObject({ + stdout: '[]' + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('never blocks the exempt rate_limit probe', async () => { mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() mockGhSuccess('{"resources":{}}') - await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toMatchObject({ + await expect( + ghExecFileAsync(['api', '--hostname', 'github.com', 'rate_limit']) + ).resolves.toMatchObject({ stdout: '{"resources":{}}' }) expect(execFileMock).toHaveBeenCalledTimes(2) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 8b802c2f0d6..4cd81cda230 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -21,6 +21,10 @@ vi.mock('../wsl', async (importOriginal) => ({ })) import { ghExecFileAsync, glabExecFileAsync } from './runner' +import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' + +const PRIMARY_RATE_LIMIT_STDERR = + 'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)' type MockChildProcess = EventEmitter & { pid: number @@ -44,6 +48,7 @@ describe('ghExecFileAsync WSL fallback', () => { spawnMock.mockReset() getDefaultWslDistroMock.mockReset() getDefaultWslDistroMock.mockReturnValue(null) + _resetGhRateLimitBreaker() Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' @@ -52,6 +57,7 @@ describe('ghExecFileAsync WSL fallback', () => { afterEach(() => { vi.useRealTimers() + _resetGhRateLimitBreaker() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -187,6 +193,42 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('falls back for repo view with an explicit positional repository', async () => { + execFileMock.mockImplementation((binary, _args, options, callback) => { + if (typeof options === 'function') { + callback = options + } + if (binary === 'wsl.exe') { + callback( + Object.assign(new Error('Command failed: wsl.exe'), { + stdout: '', + stderr: 'bash: line 1: gh: command not found\n' + }) + ) + return + } + callback(null, { stdout: '{"isFork":false}', stderr: '' }) + }) + + await expect( + ghExecFileAsync( + ['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'], + { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`, + host: 'github.acme-corp.com' + } + ) + ).resolves.toEqual({ stdout: '{"isFork":false}', stderr: '' }) + + expect(execFileMock).toHaveBeenNthCalledWith( + 2, + 'gh', + ['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('does not fall back for gh api calls that depend on repo-context placeholders', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( @@ -227,6 +269,43 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) + it('retries a host-pinned idempotent gh GraphQL query after host injection', async () => { + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback( + Object.assign(new Error('HTTP 502 Bad Gateway'), { + stdout: '', + stderr: 'HTTP 502 Bad Gateway' + }) + ) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: '{"data":{}}', stderr: '' }) + }) + + await expect( + ghExecFileAsync(['api', 'graphql', '-f', 'query=query { viewer { login } }'], { + host: 'github.acme-corp.com' + }) + ).resolves.toEqual({ stdout: '{"data":{}}', stderr: '' }) + + expect(execFileMock).toHaveBeenCalledTimes(2) + expect(execFileMock).toHaveBeenNthCalledWith( + 1, + 'gh', + [ + 'api', + '--hostname', + 'github.acme-corp.com', + 'graphql', + '-f', + 'query=query { viewer { login } }' + ], + expect.any(Object), + expect.any(Function) + ) + }) + it('does not retry non-idempotent gh API transient failures', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( @@ -307,6 +386,63 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('checks a blocked WSL scope before repeating a native-to-WSL fallback', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock.mockImplementation((binary, _args, _options, callback) => { + if (binary === 'gh') { + callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT', stderr: '' })) + return + } + callback( + Object.assign(new Error(PRIMARY_RATE_LIMIT_STDERR), { + stdout: '', + stderr: PRIMARY_RATE_LIMIT_STDERR + }) + ) + }) + + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toThrow('rate limit') + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toMatchObject({ + ghRateLimitBlocked: true + }) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock.mock.calls.map(([binary]) => binary)).toEqual(['gh', 'wsl.exe', 'gh']) + }) + + it('checks a blocked native scope before repeating a WSL-to-native fallback', async () => { + execFileMock.mockImplementation((binary, _args, _options, callback) => { + if (binary === 'wsl.exe') { + callback( + Object.assign(new Error('Command failed: wsl.exe'), { + stdout: '', + stderr: 'bash: line 1: gh: command not found\n' + }) + ) + return + } + callback( + Object.assign(new Error(PRIMARY_RATE_LIMIT_STDERR), { + stdout: '', + stderr: PRIMARY_RATE_LIMIT_STDERR + }) + ) + }) + + const options = { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa` + } + await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options)).rejects.toThrow( + 'rate limit' + ) + await expect( + ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options) + ).rejects.toMatchObject({ ghRateLimitBlocked: true }) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock.mock.calls.map(([binary]) => binary)).toEqual(['wsl.exe', 'gh', 'wsl.exe']) + }) + it('does not retry non-idempotent glab transient failures', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index a4b51ff0624..ae4e93174e1 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -21,9 +21,11 @@ import { classifyGhRateLimitBucket, createGhRateLimitBlockedError, getGhRateLimitBlockedUntilMs, + ghRateLimitScopeKey, isGhPrimaryRateLimitStderr, isGhRateLimitProbe, - notifyGhPrimaryRateLimit + notifyGhPrimaryRateLimit, + type GhRateLimitBucket } from './gh-rate-limit-breaker' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { addWslEnvKeys } from '../wsl-env' @@ -111,6 +113,17 @@ function argsUseGhApiPlaceholders(args: string[]): boolean { ) } +function hasExplicitRepoViewTarget(args: string[]): boolean { + const target = args[2] + return ( + args[0] === 'repo' && + args[1] === 'view' && + typeof target === 'string' && + !target.startsWith('-') && + target.includes('/') + ) +} + function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { if (hasExplicitRepoArg(args)) { return true @@ -118,7 +131,7 @@ function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { if (args[0] === 'api') { return !argsUseGhApiPlaceholders(args) } - return args[0] === 'auth' + return args[0] === 'auth' || hasExplicitRepoViewTarget(args) } function isMissingCommandInWsl(stderr: string, command: string): boolean { @@ -1120,6 +1133,11 @@ type GhExecOptions = Omit & { cwd?: string wslDistro?: string idempotent?: boolean + // Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's + // default host, not the repo's remote. Carrying the host here lets the + // runner qualify every spawn once, so call sites can't silently fall back + // to github.com for GHES repos; it also scopes the rate-limit breaker. + host?: string } const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) @@ -1257,6 +1275,128 @@ function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.Proce } } +function hasGhHostnameFlag(args: readonly string[]): boolean { + return args.some((arg) => arg === '--hostname' || arg.startsWith('--hostname=')) +} + +function hostQualifiedGhRepoValue(value: string, host: string): string { + // URLs and already-qualified HOST/OWNER/REPO values pass through untouched. + if (value.includes('://') || value.split('/').length !== 2) { + return value + } + return `${host}/${value}` +} + +/** + * Host-qualify a gh invocation from `options.host`: `--hostname` for `api` + * calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run + * gh with no cwd, so this is their only host signal (#8312). + * + * @internal exported for tests. + */ +export function applyGhHostToArgs(args: string[], host?: string): string[] { + if (!host) { + return args + } + let result = args + if (result[0] === 'api' && !hasGhHostnameFlag(result)) { + result = ['api', '--hostname', host, ...result.slice(1)] + } + // Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST + // when set — so github.com must be qualified too, not just GHES, or a + // process-level GH_HOST redirects pinned github.com commands. + // Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten: + // no call site uses them, and prefix-matching `-R` corrupts free-text values + // of other flags (e.g. a --title that happens to start with `-R`). + const qualified: string[] = [] + for (let i = 0; i < result.length; i += 1) { + const arg = result[i] + if (arg === '--repo' || arg === '-R') { + qualified.push(arg) + const value = result[i + 1] + if (value !== undefined) { + qualified.push(hostQualifiedGhRepoValue(value, host)) + i += 1 + } + continue + } + if (arg.startsWith('--repo=')) { + qualified.push(`--repo=${hostQualifiedGhRepoValue(arg.slice('--repo='.length), host)}`) + continue + } + qualified.push(arg) + } + return qualified +} + +function explicitGhHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--hostname') { + const value = args[i + 1]?.trim() + return value || undefined + } + if (args[i].startsWith('--hostname=')) { + const value = args[i].slice('--hostname='.length).trim() + return value || undefined + } + } + return undefined +} + +function explicitGhRepoHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + let value: string | undefined + if (args[i] === '--repo' || args[i] === '-R') { + value = args[i + 1] + } else if (args[i].startsWith('--repo=')) { + value = args[i].slice('--repo='.length) + } + const parts = value?.trim().split('/') + if (parts?.length === 3 && parts.every(Boolean)) { + return parts[0] + } + } + return undefined +} + +function ghRateLimitScope( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand +): string { + const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native' + // Why: an explicit argv hostname controls the actual gh request even when + // GH_HOST or options.host disagree, so breaker state must follow that host. + const host = + explicitGhHostname(args) ?? + options.host ?? + explicitGhRepoHostname(args) ?? + options.env?.GH_HOST ?? + process.env.GH_HOST ?? + 'github.com' + return ghRateLimitScopeKey(runtime, host) +} + +function assertGhRateLimitScopeAvailable( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand, + bucket: GhRateLimitBucket, + exemptProbe: boolean +): void { + if (exemptProbe) { + return + } + const blockedUntilMs = getGhRateLimitBlockedUntilMs( + bucket, + Date.now(), + ghRateLimitScope(args, options, resolved) + ) + if (blockedUntilMs !== null) { + throw createGhRateLimitBlockedError(bucket, blockedUntilMs) + } +} + /** * Async gh CLI execution. Drop-in replacement for * `execFileAsync('gh', args, { cwd, encoding, ... })`. @@ -1268,15 +1408,15 @@ export async function ghExecFileAsync( args: string[], options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. - const rateLimitBucket = classifyGhRateLimitBucket(args) - if (!isGhRateLimitProbe(args)) { - const blockedUntilMs = getGhRateLimitBlockedUntilMs(rateLimitBucket) - if (blockedUntilMs !== null) { - throw createGhRateLimitBlockedError(rateLimitBucket, blockedUntilMs) - } - } + // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + args = applyGhHostToArgs(args, options.host) let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) + // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. + // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. + const rateLimitBucket = classifyGhRateLimitBucket(args) + const rateLimitProbe = isGhRateLimitProbe(args) + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) let lastError: unknown let attemptedHostFallback = false let attemptedDefaultWslFallback = false @@ -1295,7 +1435,7 @@ export async function ghExecFileAsync( lastError = err const { stderr } = extractExecError(err) if (isGhPrimaryRateLimitStderr(stderr)) { - notifyGhPrimaryRateLimit(rateLimitBucket) + notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved)) } if ( process.platform === 'win32' && @@ -1310,6 +1450,7 @@ export async function ghExecFileAsync( // Why: WSL-only Windows installs have no host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. resolved = wslResolved attemptedDefaultWslFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) attempt = -1 continue } @@ -1317,12 +1458,11 @@ export async function ghExecFileAsync( if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { resolved = resolveHostGitHubCli('gh', args) attemptedHostFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) attempt = -1 continue } const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - // Why: only retry idempotent calls; a transient error can arrive after a write already applied, so retrying would duplicate it. - const idempotent = options.idempotent ?? argsLookIdempotent(args) if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { // Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC. const retryAfterMs = parseRetryAfterMs(stderr) diff --git a/src/main/github/auth-diagnose.test.ts b/src/main/github/auth-diagnose.test.ts index 35e3775b478..7735910dc83 100644 --- a/src/main/github/auth-diagnose.test.ts +++ b/src/main/github/auth-diagnose.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from 'vitest' -import { parseAuthStatus } from './auth-diagnose' +import { describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn() })) + +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock +})) +import { diagnoseGhAuth, parseAuthStatus } from './auth-diagnose' describe('parseAuthStatus', () => { it('parses an env-shadowed login alongside a keyring login (real gh output)', () => { @@ -82,6 +88,18 @@ ghe.acme.io expect(accounts[1].scopes).toContain('project') }) + it('parses a ported GHES auth-status section', () => { + const text = `ghe.acme.io:8443 + ✓ Logged in to ghe.acme.io:8443 account bob (keyring) + - Active account: true + - Token scopes: 'project', 'repo' +` + + expect(parseAuthStatus(text)).toEqual([ + expect.objectContaining({ host: 'ghe.acme.io:8443', user: 'bob', active: true }) + ]) + }) + it('recovers host from the Logged-in line when the section header is missing', () => { // gh prints a colon after the host on some versions; we tolerate it, // but if the regex ever fails to match the header we still want @@ -95,3 +113,48 @@ ghe.acme.io expect(accounts[0].host).toBe('github.acme.io') }) }) + +describe('diagnoseGhAuth host scoping', () => { + const TWO_HOST_STATUS = `github.com + ✓ Logged in to github.com account alice (keyring) + - Active account: true + - Token scopes: 'project', 'read:org', 'repo' +ghe.acme.io + ✓ Logged in to ghe.acme.io account bob (keyring) + - Active account: true + - Token scopes: 'repo' +` + + it('reports an unauthenticated required host and keeps scope advice host-scoped', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth('github.other-corp.com') + + expect(diag.requiredHost).toBe('github.other-corp.com') + expect(diag.requiredHostAuthenticated).toBe(false) + expect(diag.activeAccount).toBeNull() + }) + + it("scopes the diagnosis to the required host's account", async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth('GHE.Acme.IO') + + expect(diag.requiredHost).toBe('ghe.acme.io') + expect(diag.requiredHostAuthenticated).toBe(true) + expect(diag.activeAccount?.user).toBe('bob') + // Why: bob's GHES token lacks project/read:org even though alice's + // github.com token has them — advice must reflect the required host. + expect(diag.missingScopes).toEqual(['project', 'read:org']) + }) + + it('keeps legacy host-less behavior byte-identical', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: TWO_HOST_STATUS, stderr: '' }) + + const diag = await diagnoseGhAuth() + + expect(diag.requiredHost).toBeNull() + expect(diag.requiredHostAuthenticated).toBeNull() + expect(diag.activeAccount?.user).toBe('alice') + }) +}) diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts index aeaf4b9e284..8051af7f8b4 100644 --- a/src/main/github/auth-diagnose.ts +++ b/src/main/github/auth-diagnose.ts @@ -39,7 +39,7 @@ export function parseAuthStatus(text: string): GhAuthAccount[] { // (internal GHES like `github` or `ghe-internal`); we also recover the // host from the `Logged in to ` line below if this header was // missed, so a parser miss never silently drops every account. - const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*)\s*:?\s*$/i) + const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*(?::\d+)?)\s*:?\s*$/i) if (hostMatch && !/^logged\b/i.test(line)) { currentHost = hostMatch[1] continue @@ -90,7 +90,7 @@ export function parseAuthStatus(text: string): GhAuthAccount[] { return accounts } -export async function diagnoseGhAuth(): Promise { +export async function diagnoseGhAuth(requiredHost?: string): Promise { let raw = '' let ghAvailable = true try { @@ -118,7 +118,16 @@ export async function diagnoseGhAuth(): Promise { } } const accounts = parseAuthStatus(raw) - const active = accounts.find((a) => a.active) ?? accounts[0] ?? null + // Why: when the caller names a host (a GHES origin), scope the diagnosis to + // that host's account — the github.com account's scopes are irrelevant to it. + const normalizedRequiredHost = requiredHost?.trim().toLowerCase() || null + const hostAccounts = normalizedRequiredHost + ? accounts.filter((a) => a.host.toLowerCase() === normalizedRequiredHost) + : accounts + const active = + hostAccounts.find((a) => a.active) ?? + hostAccounts[0] ?? + (normalizedRequiredHost ? null : (accounts.find((a) => a.active) ?? accounts[0] ?? null)) const envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null = process.env.GH_TOKEN ? 'GH_TOKEN' : process.env.GITHUB_TOKEN @@ -141,6 +150,8 @@ export async function diagnoseGhAuth(): Promise { envTokenInProcess, missingScopes, requiredScopes: [...REQUIRED_SCOPES], - hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active) + hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active), + requiredHost: normalizedRequiredHost, + requiredHostAuthenticated: normalizedRequiredHost ? hostAccounts.length > 0 : null } } diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 34b0d801aeb..c3efb986bad 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -57,11 +57,20 @@ vi.mock('../git/runner', () => ({ })) vi.mock('./github-enterprise-repository', () => ({ - getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) })) import { createGitHubPullRequest } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('createGitHubPullRequest', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -167,7 +176,7 @@ describe('createGitHubPullRequest', () => { expect(args[args.indexOf('--head') + 1]).toBe('my-branch') }) - it('host-qualifies --repo for a GHES remote so gh targets the Enterprise server (#8312)', async () => { + it('routes --repo to the Enterprise server via options.host for a GHES remote (#8312)', async () => { // github.com-only slug parsing misses GHES, so creation comes from the // enterprise resolver, which carries the host. getOwnerRepoMock.mockResolvedValueOnce(null) @@ -194,13 +203,14 @@ describe('createGitHubPullRequest', () => { url: 'https://github.acme-corp.com/team/orca/pull/7' }) - const [args] = ghExecFileAsyncMock.mock.calls[0] - // Bare "team/orca" would resolve against gh's default host (github.com); - // the host prefix pins the command to the Enterprise server. - expect(args[args.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + const [args, options] = ghExecFileAsyncMock.mock.calls[0] + // The runner host-qualifies argv at spawn time from options.host, so the + // mocked call sees a bare owner/repo plus the host in exec options. + expect(args[args.indexOf('--repo') + 1]).toBe('team/orca') + expect(options).toMatchObject({ host: 'github.acme-corp.com' }) }) - it('host-qualifies --repo for the GHES existing-PR fallback lookup (#8312)', async () => { + it('routes the GHES existing-PR fallback lookup through options.host (#8312)', async () => { getOwnerRepoMock.mockResolvedValue(null) getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'team', @@ -234,9 +244,10 @@ describe('createGitHubPullRequest', () => { existingReview: { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } }) - const [listArgs] = ghExecFileAsyncMock.mock.calls[1] + const [listArgs, listOptions] = ghExecFileAsyncMock.mock.calls[1] expect(listArgs).toEqual(expect.arrayContaining(['pr', 'list'])) - expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('team/orca') + expect(listOptions).toMatchObject({ host: 'github.acme-corp.com' }) }) it('runs local WSL project pull request creation through the selected distro', async () => { @@ -301,7 +312,12 @@ describe('createGitHubPullRequest', () => { url: 'https://github.com/acme/widgets/pull/45' }) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/remote/repo-root', 'origin', 'ssh-1') + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/remote/repo-root', + 'origin', + 'ssh-1', + {} + ) const [args, options] = ghExecFileAsyncMock.mock.calls[0] expect(args).toEqual( expect.arrayContaining([ @@ -459,7 +475,9 @@ describe('createGitHubPullRequest', () => { '--json', 'number,url' ], - { cwd: '/repo-root' } + // Why: dotcom slugs resolve with host:'github.com' so creation stays + // pinned against a process-level GH_HOST. + { cwd: '/repo-root', host: 'github.com' } ]) }) diff --git a/src/main/github/client-file-viewed.test.ts b/src/main/github/client-file-viewed.test.ts index c4345ffb6b6..a52a479e9f5 100644 --- a/src/main/github/client-file-viewed.test.ts +++ b/src/main/github/client-file-viewed.test.ts @@ -1,7 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { ghExecFileAsyncMock, acquireMock, releaseMock } = vi.hoisted(() => ({ +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() })) @@ -10,9 +18,18 @@ vi.mock('./gh-utils', () => ({ execFileAsync: vi.fn(), ghExecFileAsync: ghExecFileAsyncMock, gitExecFileAsync: vi.fn(), - getOwnerRepo: vi.fn(), + getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), - getOwnerRepoForRemote: vi.fn(), + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), resolveIssueSource: vi.fn(), ghRepoExecOptions: vi.fn((context) => context.connectionId @@ -31,11 +48,28 @@ vi.mock('./gh-utils', () => ({ _resetOwnerRepoCache: vi.fn() })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + import { setPRFileViewed } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('setPRFileViewed', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -59,7 +93,10 @@ describe('setPRFileViewed', () => { expect(args.find((arg: string) => arg.startsWith('query='))).toContain('markFileAsViewed') expect(args).toContain('pullRequestId=PR_kwDO123') expect(args).toContain('path=src/app.ts') - expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ cwd: '/repo-root' }) + expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ + cwd: '/repo-root', + host: 'github.com' + }) expect(releaseMock).toHaveBeenCalledTimes(1) }) @@ -95,7 +132,54 @@ describe('setPRFileViewed', () => { expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ cwd: '/repo-root', + host: 'github.com', wslDistro: 'Ubuntu' }) }) + + it('routes SSH-backed GitHub Enterprise file-viewed mutations to the Enterprise host', async () => { + getOwnerRepoMock.mockResolvedValueOnce(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) + + await expect( + setPRFileViewed({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + pullRequestId: 'PR_enterprise', + path: 'src/enterprise.ts', + viewed: true + }) + ).resolves.toBe(true) + + // The runner injects --hostname at spawn time from options.host, so the + // mocked call sees unqualified argv plus the Enterprise host in options. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['api', 'graphql', 'pullRequestId=PR_enterprise']), + { host: 'github.acme-corp.com' } + ) + const [args] = ghExecFileAsyncMock.mock.calls[0] + expect(args).not.toContain('--hostname') + }) + + it('refuses an unresolved local file-viewed mutation', async () => { + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect( + setPRFileViewed({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca' }, + pullRequestId: 'PR_unresolved', + path: 'src/app.ts', + viewed: true + }) + ).resolves.toBe(false) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index a828a7759f7..5b867489797 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' const { @@ -7,6 +8,7 @@ const { getOwnerRepoMock, getIssueOwnerRepoMock, getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidatesMock, resolveIssueSourceMock, rateLimitGuardMock, noteRateLimitSpendMock, @@ -18,6 +20,7 @@ const { getOwnerRepoMock: vi.fn(), getIssueOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), + resolvePRRepositoryCandidatesMock: vi.fn(), resolveIssueSourceMock: vi.fn(), rateLimitGuardMock: vi.fn(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), @@ -44,9 +47,48 @@ vi.mock('./gh-utils', async () => { vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, - getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions), + resolveGitHubApiRepositoryCandidates: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions) + } +}) + import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' const PR_LIST_FIELDS = @@ -107,6 +149,7 @@ describe('GitHub issue source split', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + resolvePRRepositoryCandidatesMock.mockReset() resolveIssueSourceMock.mockReset() rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) @@ -123,15 +166,17 @@ describe('GitHub issue source split', () => { source: await getIssueOwnerRepoMock(), fellBack: false })) - // Why: since #7331 `resolvePrWorkItemSource` fetches origin through - // `getOwnerRepoForRemote` (getOwnerRepo became upstream-first). Route the - // origin probe to `getOwnerRepoMock` so existing tests keep defining the - // origin candidate through it; default upstream to null. Tests that care - // about upstream override with their own implementation. + // Why: keep origin on the legacy mock while hosted candidate tests opt in + // to upstream behavior explicitly. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null ) + resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { + const origin = await getOwnerRepoMock(repoPath, connectionId) + const repository = origin ? { host: 'github.com', ...origin } : null + return { candidates: repository ? [repository] : [], headRepo: repository } + }) _resetOwnerRepoCache() }) @@ -426,11 +471,80 @@ describe('GitHub issue source split', () => { '--json', expect.stringContaining('reviewDecision') ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(item?.type).toBe('pr') }) + it('probes the upstream repository for a typed fork PR before origin', async () => { + const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' } + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [upstream, origin], + headRepo: origin + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Upstream PR', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/42', + labels: [], + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' }, + isDraft: false + }) + }) + + const item = await getWorkItem('/repo-root', 42, 'pr') + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '42', + '--repo', + 'stablyai/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root', host: 'github.com' } + ) + expect(item?.prRepo).toEqual(upstream) + }) + + it('does not run a bare gh lookup for an SSH repo without candidates', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) + + await expect(getWorkItem('/remote/repo', 42, 'pr', 'ssh-1')).resolves.toBeNull() + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not probe a second PR repository after a non-not-found failure', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [ + { owner: 'stablyai', repo: 'orca', host: 'github.com' }, + { owner: 'fork', repo: 'orca', host: 'github.com' } + ], + headRepo: { owner: 'fork', repo: 'orca', host: 'github.com' } + }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 500: server error')) + .mockRejectedValueOnce(new Error('HTTP 500: server error')) + + await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toBeNull() + + // The first candidate uses `pr view` and its REST compatibility fallback; + // neither failure is a 404, so a same-number PR on origin is never queried. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => + (args as string[]).some((arg) => arg.includes('fork/orca')) + ) + ).toBe(false) + }) + it('raw number lookup tries upstream issue before origin PR', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) // Why: simulate a real gh 404 (the only error type that should fall through). @@ -469,7 +583,7 @@ describe('GitHub issue source split', () => { '--json', expect.stringContaining('reviewDecision') ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(item?.type).toBe('pr') }) diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index 429969b52f1..b092b01eb94 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository' const { ghExecFileAsyncMock, getOwnerRepoMock, rateLimitGuardMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), @@ -16,6 +17,16 @@ vi.mock('./gh-utils', () => ({ ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), extractExecError: vi.fn((err: unknown) => ({ stderr: String(err), stdout: '' })), acquire: vi.fn(), release: vi.fn(), @@ -26,9 +37,17 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: vi.fn() + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: vi.fn(() => true) })) import { getPRCheckDetails, _resetOwnerRepoCache } from './client' @@ -129,17 +148,17 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', 'repos/acme/widgets/check-runs/88'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/acme/widgets/check-runs/88/annotations?per_page=20'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', 'repos/acme/widgets/actions/runs/77/jobs?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -206,8 +225,64 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 4, ['api', 'repos/acme/widgets/actions/jobs/8801/logs'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4) }) + + it('isolates failed-job log tails for the same job ID on different GitHub hosts', async () => { + ghExecFileAsyncMock.mockImplementation(async (args: string[], options?: { host?: string }) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + const enterprise = options?.host === 'github.acme-corp.com' + if (endpoint.endsWith('/check-runs/99')) { + return { + stdout: JSON.stringify({ + name: 'verify', + status: 'completed', + conclusion: 'failure', + check_suite: { workflow_run: { id: 77 } } + }) + } + } + if (endpoint.endsWith('/check-runs/99/annotations?per_page=20')) { + return { stdout: '[]' } + } + if (endpoint.endsWith('/actions/runs/77/jobs?per_page=100')) { + return { + stdout: JSON.stringify({ + jobs: [ + { + id: 9901, + name: 'verify', + status: 'completed', + conclusion: 'failure', + steps: [] + } + ] + }) + } + } + if (endpoint.endsWith('/actions/jobs/9901/logs')) { + return { stdout: enterprise ? 'enterprise log' : 'github.com log' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const githubDotCom = await getPRCheckDetails('/repo-root', { + checkRunId: 99, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + const enterprise = await getPRCheckDetails('/repo-root', { + checkRunId: 99, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + }) + + expect(githubDotCom?.jobs[0]?.logTail).toBe('github.com log') + expect(enterprise?.jobs[0]?.logTail).toBe('enterprise log') + const logCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => + args.some((arg) => arg.endsWith('/actions/jobs/9901/logs')) + ) + expect(logCalls).toHaveLength(2) + expect(logCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) + }) }) diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 151bd94fef6..72ca0f77cff 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -8,8 +8,9 @@ const { gitExecFileAsyncMock, extractExecErrorMock, getRateLimitMock, - rateLimitGuardMock, - noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuotaMock, acquireMock, releaseMock } = vi.hoisted(() => ({ @@ -29,8 +30,11 @@ const { return { stderr: String(err), stdout: '' } }), getRateLimitMock: vi.fn(), - rateLimitGuardMock: vi.fn(() => ({ blocked: false })), - noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn< + (repository?: { host?: string } | null, options?: { wslDistro?: string }) => boolean + >(() => true), acquireMock: vi.fn(), releaseMock: vi.fn() })) @@ -53,6 +57,16 @@ vi.mock('./gh-utils', () => ({ }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, + // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : Promise.resolve(null), extractExecError: extractExecErrorMock, acquire: acquireMock, release: releaseMock, @@ -65,12 +79,21 @@ vi.mock('../git/runner', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock })) import { getPRChecks, rerunPRChecks, _resetOwnerRepoCache } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function graphQLChecksResponse({ contexts = [], checkSuites = [], @@ -194,9 +217,14 @@ describe('getPRChecks', () => { extractExecErrorMock.mockClear() getRateLimitMock.mockReset() getRateLimitMock.mockResolvedValue({ resources: {} }) - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockImplementation( + (repository?: { host?: string } | null, options?: { wslDistro?: string }) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && !options?.wslDistro + ) acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -413,12 +441,12 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/status?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(checks).toEqual([ { @@ -496,7 +524,7 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(checks).toEqual([ { @@ -533,7 +561,11 @@ describe('getPRChecks', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'], - { cwd: '/repo-root', env: { ...process.env, GH_PROMPT_DISABLED: '1' } } + { + cwd: '/repo-root', + env: { ...process.env, GH_PROMPT_DISABLED: '1' }, + host: 'github.com' + } ) }) @@ -581,6 +613,12 @@ describe('getPRChecks', () => { env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) ) + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + expect.objectContaining({ cwd: '/repo-root', wslDistro: 'Ubuntu', host: 'github.com' }) + ) }) it('uses explicit PR repo for rollup and gh pr checks fallback', async () => { @@ -591,14 +629,18 @@ describe('getPRChecks', () => { stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }]) }) - await getPRChecks('/repo-root', 42, undefined, { owner: 'acme', repo: 'widgets' }) + await getPRChecks('/repo-root', 42, undefined, { + owner: 'acme', + repo: 'widgets', + host: 'github.com' + }) expect(getOwnerRepoMock).not.toHaveBeenCalled() expectGraphQLRollupCall() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index 9da948f43c4..07a69a6a4ee 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -10,6 +10,7 @@ const { getOwnerRepoMock, getIssueOwnerRepoMock, getOwnerRepoForRemoteMock, + getEnterpriseGitHubRepoSlugMock, resolvePRRepositoryCandidatesMock, getRemoteUrlForRepoMock, gitExecFileAsyncMock, @@ -24,6 +25,7 @@ const { getOwnerRepoMock: vi.fn(), getIssueOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), resolvePRRepositoryCandidatesMock: vi.fn(), getRemoteUrlForRepoMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -76,6 +78,12 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) @@ -87,15 +95,27 @@ vi.mock('./conflict-summary', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: ( + repository?: { host?: string } | null, + executionOptions?: { wslDistro?: string } + ) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && + !executionOptions?.wslDistro })) import { addPRReviewComment, addPRReviewCommentReply, + getPRCheckDetails, + getPRChecks, + getWorkItemByOwnerRepo, getPRComments, mergePR, removePRReviewers, + rerunPRChecks, requestPRReviewers, resolveReviewThread, setPRAutoMerge, @@ -104,6 +124,14 @@ import { updatePRTitle } from './client' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('GitHub PR local runtime routing', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -111,6 +139,13 @@ describe('GitHub PR local runtime routing', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: host-less prRepo backfill and origin resolution use getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) resolvePRRepositoryCandidatesMock.mockReset() getRemoteUrlForRepoMock.mockReset() gitExecFileAsyncMock.mockReset() @@ -127,12 +162,28 @@ describe('GitHub PR local runtime routing', () => { it('routes PR details and mutations through the selected WSL distro', async () => { const localGitOptions = { wslDistro: 'Ubuntu' } const prRepo = { owner: 'acme', repo: 'orca' } + rateLimitGuardMock.mockReturnValue({ + blocked: true, + remaining: 0, + limit: 5000, + resetAt: 1_800_000_000 + }) getOwnerRepoMock.mockResolvedValue(prRepo) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { const endpoint = args.find((arg) => arg.startsWith('repos/acme/orca/')) ?? '' const query = args.find((arg) => arg.startsWith('query=')) ?? '' if (args[0] === 'pr' && args[1] === 'view') { + const jsonFields = args[args.indexOf('--json') + 1] + if (jsonFields === 'id,headRefOid,baseRefName') { + return { + stdout: JSON.stringify({ + id: 'PR_local', + headRefOid: 'head-oid', + baseRefName: 'main' + }) + } + } return { stdout: JSON.stringify({ id: 'PR_kwDO123', @@ -184,7 +235,7 @@ describe('GitHub PR local runtime routing', () => { await getPRComments('/repo-root', 7, { prRepo }, null, localGitOptions) await expect( - resolveReviewThread('/repo-root', 'thread-1', true, null, localGitOptions) + resolveReviewThread('/repo-root', 'thread-1', true, null, prRepo, localGitOptions) ).resolves.toBe(true) await expect( addPRReviewCommentReply( @@ -205,6 +256,7 @@ describe('GitHub PR local runtime routing', () => { repoPath: '/repo-root', connectionId: null, localGitOptions, + prRepo, prNumber: 7, body: 'Inline', commitId: 'head-oid', @@ -219,13 +271,13 @@ describe('GitHub PR local runtime routing', () => { updatePRDetails('/repo-root', 7, { body: 'New body' }, null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - updatePRState('/repo-root', 7, { state: 'closed' }, null, localGitOptions) + updatePRState('/repo-root', 7, { state: 'closed' }, null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - requestPRReviewers('/repo-root', 7, ['octo'], null, localGitOptions) + requestPRReviewers('/repo-root', 7, ['octo'], null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( - removePRReviewers('/repo-root', 7, ['octo'], null, localGitOptions) + removePRReviewers('/repo-root', 7, ['octo'], null, prRepo, localGitOptions) ).resolves.toEqual({ ok: true }) await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', null, prRepo, localGitOptions) @@ -238,5 +290,290 @@ describe('GitHub PR local runtime routing', () => { expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( true ) + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(rateLimitGuardMock).not.toHaveBeenCalled() + expect(noteRateLimitSpendMock).not.toHaveBeenCalled() + }) + + it('never falls through to the default gh host for an unresolved SSH repository', async () => { + const legacyRepo = { owner: 'team', repo: 'orca' } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect(getPRComments('/remote/repo', 7, { prRepo: legacyRepo }, 'ssh-1')).rejects.toThrow( + 'GitHub remote' + ) + await expect( + getPRChecks('/remote/repo', 7, undefined, legacyRepo, undefined, 'ssh-1') + ).rejects.toThrow('GitHub remote') + await expect(mergePR('/remote/repo', 7, 'squash', 'ssh-1', legacyRepo)).resolves.toMatchObject({ + ok: false + }) + await expect( + setPRAutoMerge('/remote/repo', 7, true, 'squash', 'ssh-1', legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect(updatePRTitle('/remote/repo', 7, 'New title', 'ssh-1', legacyRepo)).resolves.toBe( + false + ) + await expect(requestPRReviewers('/remote/repo', 7, ['octo'], 'ssh-1')).resolves.toMatchObject({ + ok: false + }) + await expect(removePRReviewers('/remote/repo', 7, ['octo'], 'ssh-1')).resolves.toMatchObject({ + ok: false + }) + await expect( + addPRReviewCommentReply( + '/remote/repo', + 7, + 11, + 'Reply', + undefined, + undefined, + undefined, + 'ssh-1', + legacyRepo + ) + ).resolves.toMatchObject({ ok: false }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('refuses unresolved local PR mutations instead of using ambient gh defaults', async () => { + const legacyRepo = { owner: 'team', repo: 'orca' } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + await expect( + resolveReviewThread('/repo-root', 'thread-1', true, null, legacyRepo) + ).resolves.toBe(false) + await expect(updatePRTitle('/repo-root', 7, 'New title', null, legacyRepo)).resolves.toBe(false) + await expect( + requestPRReviewers('/repo-root', 7, ['octo'], null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect( + removePRReviewers('/repo-root', 7, ['octo'], null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + await expect(mergePR('/repo-root', 7, 'squash', null, legacyRepo)).resolves.toMatchObject({ + ok: false + }) + await expect( + setPRAutoMerge('/repo-root', 7, true, 'squash', null, legacyRepo) + ).resolves.toMatchObject({ ok: false }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('host-qualifies SSH-backed GitHub Enterprise review reads and mutations', async () => { + const enterpriseRepo = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepo) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/team/orca/')) ?? '' + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (args[0] === 'pr' && args[1] === 'checks') { + return { stdout: '[]' } + } + if (args[0] === 'pr' && args[1] === 'view') { + if (args.includes('id,headRefOid,baseRefName')) { + return { + stdout: JSON.stringify({ + id: 'PR_enterprise', + headRefOid: 'head-sha', + baseRefName: 'main' + }) + } + } + return { + stdout: JSON.stringify({ + number: 7, + title: 'Enterprise PR', + state: 'OPEN', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: { login: 'pr-author' }, + headRefName: 'feature', + baseRefName: 'main' + }) + } + } + if (query.includes('reviewThreads')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } + } + if ( + endpoint.endsWith('/issues/7/comments?per_page=100') || + endpoint.endsWith('/pulls/7/reviews?per_page=100') + ) { + return { stdout: '[]' } + } + if (endpoint.endsWith('/commits/head-sha/check-runs?per_page=100')) { + return { + stdout: JSON.stringify({ + check_runs: [ + { + id: 88, + name: 'lint', + status: 'completed', + conclusion: 'failure', + details_url: 'https://github.acme-corp.com/team/orca/actions/runs/77/job/88' + } + ] + }) + } + } + if (endpoint.endsWith('/commits/head-sha/status?per_page=100')) { + return { stdout: JSON.stringify({ statuses: [] }) } + } + if (endpoint.endsWith('/commits/head-sha/check-suites?per_page=100')) { + return { stdout: JSON.stringify({ check_suites: [] }) } + } + if (endpoint.endsWith('/check-runs/88')) { + return { + stdout: JSON.stringify({ + id: 88, + name: 'lint', + status: 'completed', + conclusion: 'failure', + details_url: 'https://github.acme-corp.com/team/orca/actions/runs/77/job/88', + output: { title: 'Lint failed', summary: 'One error' } + }) + } + } + if (endpoint.endsWith('/check-runs/88/annotations?per_page=20')) { + return { stdout: '[]' } + } + if (query) { + return { stdout: JSON.stringify({ data: { repository: {} } }) } + } + return { + stdout: JSON.stringify({ + id: 13, + user: null, + body: 'Enterprise inline comment' + }) + } + }) + + await expect( + getWorkItemByOwnerRepo('/remote/repo', enterpriseRepo, 7, 'pr', 'ssh-1') + ).resolves.toMatchObject({ number: 7, title: 'Enterprise PR' }) + await expect( + getWorkItemByOwnerRepo('/remote/repo', { owner: 'team', repo: 'orca' }, 7, 'pr', 'ssh-1') + ).resolves.toMatchObject({ number: 7, title: 'Enterprise PR' }) + await expect( + getPRComments('/remote/repo', 7, { prRepo: enterpriseRepo }, 'ssh-1') + ).resolves.toEqual([]) + await expect( + getPRChecks('/remote/repo', 7, undefined, enterpriseRepo, undefined, 'ssh-1') + ).resolves.toEqual([]) + await expect( + getPRCheckDetails('/remote/repo', { checkRunId: 88, prRepo: enterpriseRepo }, 'ssh-1') + ).resolves.toMatchObject({ name: 'lint', conclusion: 'failure' }) + await expect( + rerunPRChecks( + '/remote/repo', + 7, + { headSha: 'head-sha', failedOnly: true, prRepo: enterpriseRepo }, + 'ssh-1' + ) + ).resolves.toEqual({ ok: true, count: 1 }) + await expect( + resolveReviewThread('/remote/repo', 'thread-1', true, 'ssh-1', enterpriseRepo) + ).resolves.toBe(true) + await expect( + addPRReviewCommentReply( + '/remote/repo', + 7, + 11, + 'Enterprise reply', + 'thread-1', + 'src/enterprise.ts', + 10, + 'ssh-1', + enterpriseRepo + ) + ).resolves.toMatchObject({ ok: true }) + await expect( + addPRReviewComment({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + prRepo: enterpriseRepo, + prNumber: 7, + body: 'Enterprise inline comment', + commitId: 'head-sha', + path: 'src/enterprise.ts', + line: 10 + }) + ).resolves.toMatchObject({ ok: true }) + await expect( + updatePRTitle('/remote/repo', 7, 'New title', 'ssh-1', enterpriseRepo) + ).resolves.toBe(true) + await expect( + updatePRDetails('/remote/repo', 7, { body: 'New body' }, 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect( + updatePRState('/remote/repo', 7, { state: 'closed' }, 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + requestPRReviewers('/remote/repo', 7, ['octo'], 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + removePRReviewers('/remote/repo', 7, ['octo'], 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ + ok: true + }) + await expect( + setPRAutoMerge('/remote/repo', 7, true, 'squash', 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect( + setPRAutoMerge('/remote/repo', 7, false, 'squash', 'ssh-1', enterpriseRepo) + ).resolves.toEqual({ ok: true }) + await expect(mergePR('/remote/repo', 7, 'squash', 'ssh-1', enterpriseRepo)).resolves.toEqual({ + ok: true + }) + + const prViewCall = ghExecFileAsyncMock.mock.calls.find( + ([args]) => args[0] === 'pr' && args[1] === 'view' + ) + // The runner host-qualifies argv at spawn time from options.host, so the + // mocked call sees the unqualified --repo plus the host in exec options. + expect(prViewCall?.[0]).toEqual(expect.arrayContaining(['--repo', 'team/orca'])) + expect(prViewCall?.[1]).toEqual({ host: 'github.acme-corp.com' }) + const prCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr') + expect( + prCalls.every( + ([args]) => args.includes('--repo') && args[args.indexOf('--repo') + 1] === 'team/orca' + ) + ).toBe(true) + const apiCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'api') + expect(apiCalls.length).toBeGreaterThan(0) + expect( + apiCalls.every( + ([args, options]) => + !args.includes('--hostname') && + options.host === 'github.acme-corp.com' && + options.cwd === undefined && + options.wslDistro === undefined + ) + ).toBe(true) }) }) diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts new file mode 100644 index 00000000000..f9cf934e24e --- /dev/null +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' + +const { + execFileAsyncMock, + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolveIssueSourceMock, + gitExecFileAsyncMock, + rateLimitGuardMock, + noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuotaMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn((_repo: unknown, bucket: string) => + rateLimitGuardMock(bucket) + ), + noteRepositoryRateLimitSpendMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn(() => true), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + execFileAsync: execFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock, + githubRepoContext: ( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} + ) => ({ + repoPath, + connectionId: connectionId ?? null, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }), + ghRepoExecOptions: (context: { + repoPath: string + connectionId?: string | null + wslDistro?: string + }) => + context.connectionId + ? {} + : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock, + _resetOwnerRepoCache: vi.fn(), + classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), + classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }) +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: rateLimitGuardMock, + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + // Mirror production: shared-scope calls delegate to the global guard/spend. + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock +})) + +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions) + } +}) + +import { + countWorkItems, + listWorkItems, + _resetMergeQueueCacheForTests, + _resetOwnerRepoCache +} from './client' +import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) +// Why: split from client-work-items.test.ts to keep both suites under the +// max-lines cap; this file owns query/paging request-shaping cases. +describe('listWorkItems query paging', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + resolveIssueSourceMock.mockReset() + gitExecFileAsyncMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockClear() + noteRepositoryRateLimitSpendMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockReturnValue(true) + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + // Why: preference-aware `listWorkItems` calls `resolveIssueSource`. + // Route through the same `getIssueOwnerRepoMock` so existing tests that + // only set up `getIssueOwnerRepoMock` continue to work. + resolveIssueSourceMock.mockImplementation(async () => ({ + source: await getIssueOwnerRepoMock(), + fellBack: false + })) + getOwnerRepoForRemoteMock.mockResolvedValue(null) + _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() + }) + + it('returns zero for oversized count queries before resolving repo sources', async () => { + const secret = 'main-github-work-items-secret' + const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) + + await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0) + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(acquireMock).not.toHaveBeenCalled() + expect(releaseMock).not.toHaveBeenCalled() + }) + + it('uses an implicit WSL UNC cwd for search quota accounting', async () => { + const repository = { owner: 'acme', repo: 'widgets', host: 'github.com' } + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\me\widgets` + getIssueOwnerRepoMock.mockResolvedValueOnce(repository) + getOwnerRepoMock.mockResolvedValueOnce(repository) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '3' }) + spendsSharedGitHubComQuotaMock.mockReturnValue(false) + + await expect(countWorkItems(repoPath, 'is:issue is:open')).resolves.toBe(3) + + const executionOptions = { cwd: repoPath, host: 'github.com' } + expect(spendsSharedGitHubComQuotaMock).toHaveBeenCalledWith(repository, executionOptions) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + repository, + 'search', + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + repository, + 'search', + 1, + executionOptions + ) + }) + + it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'review-requested:@me is:open') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( + expect.arrayContaining(['--review-requested']), + expect.anything() + ) + }) + + it('uses the requested numbered Search API page for issues', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, + '--jq', + '.items' + ], + { cwd: '/repo-root' } + ) + }) + + it('fetches and slices stable PR results for the requested numbered page', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify( + [1, 2, 3, 4].map((number) => ({ + number, + title: `PR ${number}`, + state: 'OPEN', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: `2026-07-0${number}T00:00:00Z`, + author: { login: 'octocat' }, + isDraft: false, + headRefName: `feature/${number}`, + headRefOid: `head-${number}`, + baseRefName: 'main' + })) + ) + }) + + const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), + { cwd: '/repo-root' } + ) + expect(items.map((item) => item.number)).toEqual([4, 3]) + }) + + it('filters pull request rows out of issue Search API results', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 2, + title: 'PR-shaped search row', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/2', + labels: [], + updated_at: '2026-07-02T00:00:00Z', + user: { login: 'octocat' }, + pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } + }, + { + number: 1, + title: 'Issue row', + state: 'open', + html_url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updated_at: '2026-07-01T00:00:00Z', + user: { login: 'octocat' } + } + ]) + }) + + const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') + + expect(items.map((item) => item.id)).toEqual(['issue:1']) + }) +}) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index fa4f90dcd29..0226b2af42f 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' const { execFileAsyncMock, @@ -20,7 +21,7 @@ const { getOwnerRepoForRemoteMock: vi.fn(), resolveIssueSourceMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -64,18 +65,62 @@ vi.mock('../git/runner', () => ({ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, - getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + // Mirror production: shared-scope calls delegate to the global guard/spend. + repositoryRateLimitGuard: vi.fn((_repo: unknown, bucket: string) => rateLimitGuardMock(bucket)), + noteRepositoryRateLimitSpend: vi.fn((_repo: unknown, bucket: string, cost?: number) => + noteRateLimitSpendMock(bucket, cost) + ), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks. + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions) + } +}) + import { countWorkItems, listWorkItems, _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' -import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('listWorkItems', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -108,25 +153,53 @@ describe('listWorkItems', () => { ) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() - _resetGhCwdRepoNegativeCache() }) - it('stops re-spawning gh for a repo whose cwd resolution already failed with no remotes', async () => { + it('routes GHES work-item listing through the Enterprise host', async () => { + const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + getIssueOwnerRepoMock.mockResolvedValue(ghes) + getOwnerRepoMock.mockResolvedValue(ghes) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10) + + const calls = ghExecFileAsyncMock.mock.calls + expect(calls.length).toBeGreaterThan(0) + // Why: every list spawn must pin options.host so the runner targets the + // Enterprise server instead of gh's default host. + expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true) + }) + + it('does not run unscoped work-item queries when a local repository has no GitHub source', async () => { getIssueOwnerRepoMock.mockResolvedValue(null) getOwnerRepoMock.mockResolvedValue(null) - ghExecFileAsyncMock.mockRejectedValue( - Object.assign(new Error('Command failed: gh pr list\nno git remotes found'), { - stderr: 'no git remotes found' - }) + + // Why: unresolved sources must return empty null-source envelopes (not throw, not query) so the renderer can + // distinguish "no GitHub source detected" from genuine zero via `sources` (#9660 follow-up). + await expect(listWorkItems('/private-repo', 36)).resolves.toMatchObject({ + items: [], + sources: { issues: null, prs: null } + }) + await expect(listWorkItems('/private-repo', 36, 'is:open')).resolves.toMatchObject({ + items: [], + sources: { issues: null, prs: null } + }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('queries only the resolved PR source when the issue source is unavailable', async () => { + getIssueOwnerRepoMock.mockResolvedValue(null) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) + + await expect(listWorkItems('/private-repo', 36)).resolves.toMatchObject({ items: [] }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['pr', 'list', '--repo', 'acme/widgets']), + { cwd: '/private-repo' } ) - - await expect(listWorkItems('/no-remote-repo', 36)).rejects.toThrow('no git remotes found') - // The first refresh pays the two cwd-fallback spawns (issue + pr list). - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - - await expect(listWorkItems('/no-remote-repo', 36)).rejects.toThrow('no git remotes found') - // The second refresh is served from the negative cache — zero new spawns. - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => { @@ -292,12 +365,9 @@ describe('listWorkItems', () => { null, localGitOptions ) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( - '/repo-root', - 'origin', - null, - localGitOptions - ) + // Why: the suite bridge routes origin through getOwnerRepoMock and non-origin + // remotes through getOwnerRepoForRemoteMock. + expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( '/repo-root', 'upstream', @@ -595,120 +665,6 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) - it('returns zero for oversized count queries before resolving repo sources', async () => { - const secret = 'main-github-work-items-secret' - const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) - - await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0) - - expect(resolveIssueSourceMock).not.toHaveBeenCalled() - expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(getOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(acquireMock).not.toHaveBeenCalled() - expect(releaseMock).not.toHaveBeenCalled() - }) - - it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'review-requested:@me is:open') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( - expect.arrayContaining(['--review-requested']), - expect.anything() - ) - }) - - it('uses the requested numbered Search API page for issues', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - - await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, - '--jq', - '.items' - ], - { cwd: '/repo-root' } - ) - }) - - it('fetches and slices stable PR results for the requested numbered page', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify( - [1, 2, 3, 4].map((number) => ({ - number, - title: `PR ${number}`, - state: 'OPEN', - url: `https://github.com/acme/widgets/pull/${number}`, - labels: [], - updatedAt: `2026-07-0${number}T00:00:00Z`, - author: { login: 'octocat' }, - isDraft: false, - headRefName: `feature/${number}`, - headRefOid: `head-${number}`, - baseRefName: 'main' - })) - ) - }) - - const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) - - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), - { cwd: '/repo-root' } - ) - expect(items.map((item) => item.number)).toEqual([4, 3]) - }) - - it('filters pull request rows out of issue Search API results', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 2, - title: 'PR-shaped search row', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/2', - labels: [], - updated_at: '2026-07-02T00:00:00Z', - user: { login: 'octocat' }, - pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } - }, - { - number: 1, - title: 'Issue row', - state: 'open', - html_url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updated_at: '2026-07-01T00:00:00Z', - user: { login: 'octocat' } - } - ]) - }) - - const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') - - expect(items.map((item) => item.id)).toEqual(['issue:1']) - }) - it('returns open issues and PRs for the all-open preset query', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 21fc7cc60b8..70c4ea276b4 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -1,5 +1,6 @@ /* oxlint-disable max-lines -- Why: GitHub client fixtures cover local and SSH repo identity paths in one suite so mocked CLI behavior stays consistent. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' type RateLimitGuardResult = | { blocked: false } @@ -75,6 +76,9 @@ vi.mock('./gh-utils', () => ({ if (lower.includes('rate limit')) { return { type: 'rate_limited', message: stderr } } + if (lower.includes('resource not accessible')) { + return { type: 'permission_denied', message: stderr } + } return { type: 'unknown', message: stderr } }, parseGitHubOwnerRepo: (remoteUrl: string) => { @@ -101,9 +105,66 @@ vi.mock('./local-git-config-signature', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + // Why: the repository-scoped guards share the same bucket-keyed budget as the + // legacy ones, so delegate to the existing mocks to keep per-bucket blocking + // and spend assertions working unchanged. + repositoryRateLimitGuard: (_repository: unknown, bucket: string) => rateLimitGuardMock(bucket), + noteRepositoryRateLimitSpend: (_repository: unknown, bucket: string) => + noteRateLimitSpendMock(bucket), + spendsSharedGitHubComQuota: () => true })) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: these suites inject repo identities through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks so per-test setups + // keep driving resolution without real enterprise probes. + resolveGitHubApiRepositoryCandidates: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null + ) => getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId), + getOriginGitHubApiRepository: async ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => { + // Prefer the remote-specific mock (production origin path); fall back for + // suites that only configure getOwnerRepo. + const fromRemote = await getOwnerRepoForRemoteMock( + repoPath, + 'origin', + connectionId, + localGitOptions + ) + const slug = fromRemote ?? (await getOwnerRepoMock(repoPath, connectionId, localGitOptions)) + // Mirror production: dotcom origin slugs come back pinned to github.com. + return slug ? { host: 'github.com', ...slug } : slug + }, + getIssueGitHubApiRepository: async (repoPath: string, connectionId?: string | null) => { + const slug = await getIssueOwnerRepoMock(repoPath, connectionId) + // Mirror production: issue slugs come back host-qualified to github.com. + return slug ? { host: 'github.com', ...slug } : slug + }, + resolveIssueGitHubApiRepositorySource: async ( + repoPath: string, + _preference: unknown, + connectionId?: string | null + ) => { + const slug = await getIssueOwnerRepoMock(repoPath, connectionId) + return { source: slug ? { host: 'github.com', ...slug } : slug, fellBack: false } + } + } +}) + import { checkOrcaStarred, getPRComments, @@ -112,6 +173,7 @@ import { getRepoSlug, getRepoUpstream, getWorkItem, + getWorkItemByOwnerRepo, getPullRequestPushTarget, mergePR, resolveReviewThread, @@ -128,6 +190,14 @@ import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('checkOrcaStarred', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -174,6 +244,11 @@ describe('getPRForBranch', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: resolveGitHubRepoExecution probes origin via getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -3546,17 +3621,27 @@ describe('getPRForBranch', () => { ? { owner: 'fsdwen', repo: 'orca' } : { owner: 'stablyai', repo: 'orca' } ) + // Why: getRepoSlug imports getOriginGitHubApiRepository; the suite bridge + // prefers getOwnerRepoForRemote for origin, so set both seams. + getOwnerRepoMock.mockResolvedValue({ owner: 'fsdwen', repo: 'orca' }) - await expect(getRepoSlug('/repo-root')).resolves.toEqual({ owner: 'fsdwen', repo: 'orca' }) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined) + await expect(getRepoSlug('/repo-root')).resolves.toEqual({ + owner: 'fsdwen', + repo: 'orca', + host: 'github.com' + }) }) it('resolves a distinct upstream remote as the repo upstream', async () => { // getRepoUpstream probes origin then upstream via getOwnerRepoForRemote (#7331). - getOwnerRepoForRemoteMock - .mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' }) - .mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValue({ owner: 'tmchow', repo: 'orca' }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'origin' + ? { owner: 'tmchow', repo: 'orca' } + : { owner: 'stablyai', repo: 'orca' } + ) + // Why: the suite bridge returns getOwnerRepoForRemote fixtures as-is (no host pin). await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' @@ -3566,9 +3651,12 @@ describe('getPRForBranch', () => { }) it('does not treat a same-repository upstream remote as a fork', async () => { - getOwnerRepoForRemoteMock - .mockResolvedValueOnce({ owner: 'StablyAI', repo: 'Orca' }) - .mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValue({ owner: 'StablyAI', repo: 'Orca' }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'origin' + ? { owner: 'StablyAI', repo: 'Orca' } + : { owner: 'stablyai', repo: 'orca' } + ) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ isFork: false, parent: null }) }) @@ -3576,8 +3664,10 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['repo', 'view', 'StablyAI/Orca', '--json', 'isFork,parent'], - { cwd: '/repo-root', timeout: 10_000 } + // Why: positional slugs are explicit about github.com too, so GH_HOST + // cannot redirect them. + ['repo', 'view', 'github.com/StablyAI/Orca', '--json', 'isFork,parent'], + { cwd: '/repo-root', host: 'github.com', timeout: 10_000 } ) }) @@ -3588,7 +3678,7 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(getOwnerRepoForRemoteMock).toHaveBeenCalledTimes(1) - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined, {}) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -3605,7 +3695,75 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', - repo: 'orca' + repo: 'orca', + // Why: fork parents live on the same server as the fork's origin. + host: 'github.com' + }) + }) + + it('routes GHES push-target probes through the Enterprise host', async () => { + const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [ghes], + headRepo: ghes + }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce(ghes) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + head: { + ref: 'feature', + repo: { + full_name: 'team/orca', + name: 'orca', + clone_url: 'https://github.acme-corp.com/team/orca.git', + ssh_url: 'git@github.acme-corp.com:team/orca.git', + owner: { login: 'team' } + } + } + }) + }) + + await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ + pushTarget: { remoteName: 'origin', branchName: 'feature' } + }) + // Why: the candidate probe must pin options.host so the runner targets the + // Enterprise server instead of gh's default host. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'repos/team/orca/pulls/7'], + expect.objectContaining({ host: 'github.acme-corp.com' }) + ) + }) + + it('does not confuse same-slug PR repositories across GitHub hosts', async () => { + const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + const dotCom = { owner: 'team', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [enterprise, dotCom], + headRepo: dotCom + }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce(dotCom) + getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:team/orca.git') + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + head: { + ref: 'feature', + repo: { + full_name: 'team/orca', + name: 'orca', + clone_url: 'https://github.acme-corp.com/team/orca.git', + ssh_url: 'git@github.acme-corp.com:team/orca.git', + owner: { login: 'team' } + } + } + }) + }) + + await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ + pushTarget: { + remoteName: 'pr-team-orca', + branchName: 'feature', + remoteUrl: 'git@github.acme-corp.com:team/orca.git' + } }) }) @@ -3651,6 +3809,45 @@ describe('getPRForBranch', () => { ) }) + it('probes the next PR work-item candidate after a permission denial', async () => { + const upstream = { owner: 'upstream', repo: 'orca', host: 'github.com' } + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [upstream, origin], + headRepo: origin + }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) + .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Origin PR', + state: 'OPEN', + url: 'https://github.com/fork/orca/pull/42', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: { login: 'octo' }, + isDraft: false + }) + }) + .mockRejectedValueOnce(new Error('metadata unavailable')) + + await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({ + number: 42, + title: 'Origin PR', + prRepo: origin + }) + + expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'upstream/orca']) + ) + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual(['api', 'repos/upstream/orca/pulls/42']) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) + ) + }) + it('normalizes reviewer avatars from REST pull request payloads', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ @@ -3687,6 +3884,12 @@ describe('updatePRState', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + // Why: updatePRState resolves origin through getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() acquireMock.mockReset() @@ -3705,7 +3908,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(acquireMock).toHaveBeenCalledTimes(1) expect(releaseMock).toHaveBeenCalledTimes(1) @@ -3721,7 +3924,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'close', '3977', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3737,7 +3940,7 @@ describe('updatePRState', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], - {} + { host: 'github.com' } ) }) }) @@ -3749,6 +3952,11 @@ describe('GitHub GraphQL rate-limit guard', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: getPRComments and mutations resolve origin via getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -3815,18 +4023,23 @@ describe('GitHub GraphQL rate-limit guard', () => { }) .mockResolvedValueOnce({ stdout: '[]' }) - await getPRComments('/repo-root', 7, { prRepo: { owner: 'stablyai', repo: 'orca' } }, undefined) + await getPRComments( + '/repo-root', + 7, + { prRepo: { owner: 'stablyai', repo: 'orca', host: 'github.com' } }, + undefined + ) expect(getOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', '--cache', '60s', 'repos/stablyai/orca/issues/7/comments?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/stablyai/orca/pulls/7/reviews?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3850,10 +4063,18 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValue({ stdout: '', stderr: '' }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: true }) await expect( - updatePRTitle('/repo-root', 7, 'New title', undefined, { owner: 'stablyai', repo: 'orca' }) + updatePRTitle('/repo-root', 7, 'New title', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toBe(true) expect(getOwnerRepoMock).not.toHaveBeenCalled() @@ -3868,20 +4089,21 @@ describe('GitHub GraphQL rate-limit guard', () => { '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -3895,20 +4117,22 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/remote/repo-root', 7, true, 'squash', 'ssh-1', { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) await expect( setPRAutoMerge('/remote/repo-root', 7, false, 'squash', 'ssh-1', { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], - {} + { host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, @@ -3923,14 +4147,16 @@ describe('GitHub GraphQL rate-limit guard', () => { 'expectedHeadOid=head-oid' ]), expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], expect.objectContaining({ - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd') @@ -3946,7 +4172,8 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) @@ -3973,7 +4200,8 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: false, @@ -3994,21 +4222,23 @@ describe('GitHub GraphQL rate-limit guard', () => { await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', - repo: 'orca' + repo: 'orca', + host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', - env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' }) ) expect( @@ -4039,7 +4269,11 @@ describe('GitHub GraphQL rate-limit guard', () => { }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: 'This pull request requires review approval before it can be merged.' @@ -4074,7 +4308,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: @@ -4163,6 +4401,82 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(_getMergeQueueCacheSizeForTests()).toBe(256) }) + it('isolates merge metadata for the same slug on different GitHub hosts', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [ + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + ], + headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/7', + statusCheckRollup: [], + updatedAt: '2026-07-16T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock.mockImplementation(async (args, options) => { + if (args.includes('graphql')) { + const enterprise = options?.host === 'github.acme-corp.com' + return { + stdout: JSON.stringify({ + data: { + repository: { + mergeQueue: null, + autoMergeAllowed: !enterprise + } + } + }) + } + } + return { stdout: JSON.stringify(prView) } + }) + + const githubDotCom = await getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 7, + 'pr' + ) + const enterprise = await getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, + 7, + 'pr' + ) + + expect(githubDotCom?.autoMergeAllowed).toBe(true) + expect(enterprise?.autoMergeAllowed).toBe(false) + const graphqlCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('graphql')) + expect(graphqlCalls).toHaveLength(2) + expect(graphqlCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) + }) + + it('rejects explicit work-item lookups outside configured repository candidates', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [{ owner: 'acme', repo: 'widgets', host: 'github.com' }], + headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + + await expect( + getWorkItemByOwnerRepo( + '/repo-root', + { owner: 'victim', repo: 'secrets', host: 'evil.example.test' }, + 7, + 'pr' + ) + ).resolves.toBeNull() + + expect(acquireMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -4187,7 +4501,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) await expect( - mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + mergePR('/repo-root', 7, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: false, error: @@ -4220,7 +4538,11 @@ describe('GitHub GraphQL rate-limit guard', () => { .mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( - mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca' }) + mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index d67b3e4405b..5229c187f11 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -52,11 +52,6 @@ import { gitExecFileAsync, acquire, release, - getOwnerRepo, - getIssueOwnerRepo, - getOwnerRepoForRemote, - resolvePRRepositoryCandidates, - resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, @@ -80,12 +75,20 @@ import { import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' import { readLocalGitConfigSignature } from './local-git-config-signature' import { - getRememberedGhCwdResolutionFailure, - isGhCwdRepoResolutionFailure, - rememberGhCwdResolutionFailure -} from './gh-cwd-repo-negative-cache' -import type { GitHubRepoContext } from './github-repository-identity' -import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository' + getGitHubApiRepositoryForRemote, + getIssueGitHubApiRepository, + getOriginGitHubApiRepository, + githubHostExecOptions, + githubRepositorySlugArg, + githubRepositoryWebHost, + resolveGitHubApiRepository, + resolveGitHubApiRepositoryCandidates, + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource, + type GitHubRepoExecOptions, + type GitHubApiRepository +} from './github-api-repository' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -109,12 +112,13 @@ import { import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions' import { getRateLimit, - noteRateLimitSpend, - rateLimitGuard, + noteRepositoryRateLimitSpend, + repositoryRateLimitGuard, + spendsSharedGitHubComQuota, type RateLimitBucketKind } from './rate-limit' -type GhExecOptions = ReturnType +type GhExecOptions = GitHubRepoExecOptions type HostedReviewLocalGitOptions = ReturnType const ORCA_REPO = 'stablyai/orca' @@ -175,9 +179,15 @@ function pruneRepositoryMergeMetadataCache(now = Date.now()): void { } } -async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise { - await getRateLimit() - const guard = rateLimitGuard(bucket) +async function assertRateLimitBudget( + bucket: RateLimitBucketKind, + repository?: GitHubApiRepository | null, + executionOptions?: Pick +): Promise { + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + await getRateLimit() + } + const guard = repositoryRateLimitGuard(repository, bucket, executionOptions) if (guard.blocked) { throw new Error( `GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}` @@ -281,7 +291,7 @@ export async function getPullRequestPushTarget( ): Promise { const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { candidates } = await resolvePRRepositoryCandidates( + const { candidates } = await resolveGitHubApiRepositoryCandidates( repoPath, connectionId, localGitOptions @@ -293,15 +303,15 @@ export async function getPullRequestPushTarget( await acquire() try { let prStdout = '' + let matchedRepository: GitHubApiRepository | null = null for (const candidate of candidates) { try { const { stdout } = await ghExecFileAsync( ['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`], - { - ...ghOptions - } + { ...ghOptions, ...githubHostExecOptions(candidate) } ) prStdout = stdout + matchedRepository = candidate break } catch (error) { // Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up. @@ -311,10 +321,15 @@ export async function getPullRequestPushTarget( throw error } } - if (!prStdout) { + if (!prStdout || !matchedRepository) { return null } - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + const origin = await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ) const pr = JSON.parse(prStdout) as { maintainer_can_modify?: boolean head?: { @@ -341,8 +356,8 @@ export async function getPullRequestPushTarget( } if ( origin && - origin.owner.toLowerCase() === owner.toLowerCase() && - origin.repo.toLowerCase() === repo.toLowerCase() + githubRepoIdentityKey(origin) === + githubRepoIdentityKey({ owner, repo, host: matchedRepository.host }) ) { return { pushTarget: { remoteName: 'origin', branchName }, @@ -723,7 +738,15 @@ function mapPullRequestWorkItem( ? String((item.base as { ref?: unknown }).ref ?? '') : String(item.baseRefName ?? ''), ...(headSha ? { headSha } : {}), - ...(baseOwnerRepo ? { prRepo: { owner: baseOwnerRepo.owner, repo: baseOwnerRepo.repo } } : {}), + ...(baseOwnerRepo + ? { + prRepo: { + owner: baseOwnerRepo.owner, + repo: baseOwnerRepo.repo, + ...(baseOwnerRepo.host ? { host: baseOwnerRepo.host } : {}) + } + } + : {}), ...(additions !== undefined ? { additions } : {}), ...(deletions !== undefined ? { deletions } : {}), ...(changedFiles !== undefined ? { changedFiles } : {}), @@ -787,12 +810,15 @@ async function hydrateWorkItemRepositoryMergeMetadata( async function fetchIssueWorkItem( repoPath: string, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (ownerRepo) { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`], @@ -805,6 +831,12 @@ async function fetchIssueWorkItem( return mapIssueWorkItem(item) } + if (connectionId) { + // Why: SSH-backed gh has no repository cwd. A bare lookup could honor the + // local process GH_REPO/GH_HOST and return an unrelated repository item. + return null + } + const { stdout } = await ghExecFileAsync( ['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'], ghOptions @@ -817,7 +849,7 @@ const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' async function fetchPullRequestReviewFields( number: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise> { try { @@ -849,12 +881,15 @@ async function fetchPullRequestReviewFields( async function fetchPullRequestWorkItem( repoPath: string, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (ownerRepo) { try { const { stdout } = await ghExecFileAsync( @@ -902,6 +937,12 @@ async function fetchPullRequestWorkItem( } } + if (connectionId) { + // Why: connection-backed gh cannot infer a repository from cwd. Refuse a + // bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup. + return null + } + const { stdout } = await ghExecFileAsync( ['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS], ghOptions @@ -909,6 +950,40 @@ async function fetchPullRequestWorkItem( return mapPullRequestWorkItem(JSON.parse(stdout) as Record) } +async function fetchPullRequestWorkItemFromCandidates( + repoPath: string, + number: number, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { candidates } = await resolveGitHubApiRepositoryCandidates( + repoPath, + connectionId, + localGitOptions + ) + if (candidates.length === 0) { + return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions) + } + for (const candidate of candidates) { + try { + return await fetchPullRequestWorkItem( + repoPath, + candidate, + number, + connectionId, + localGitOptions + ) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const classification = classifyGhError(message).type + if (classification !== 'not_found' && classification !== 'permission_denied') { + throw err + } + } + } + return null +} + type WorkItemListRequest = { args: string[] offset: number @@ -920,7 +995,7 @@ function normalizeWorkItemPage(page: number | undefined): number { function buildWorkItemListRequest(args: { kind: 'issue' | 'pr' - ownerRepo: OwnerRepo | null + ownerRepo: OwnerRepo limit: number query: ParsedTaskQuery page: number @@ -928,7 +1003,7 @@ function buildWorkItemListRequest(args: { const { kind, ownerRepo, limit, query, page } = args const searchParts: string[] = [] - if (kind === 'issue' && ownerRepo) { + if (kind === 'issue') { searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`) } searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr') @@ -995,9 +1070,7 @@ function buildWorkItemListRequest(args: { '--json', WORK_ITEM_PR_LIST_JSON_FIELDS ] - if (ownerRepo) { - out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } + out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) out.push('--search', searchParts.join(' ')) return { args: out, offset: (page - 1) * limit } } @@ -1033,39 +1106,14 @@ async function resolvePrWorkItemSource( localGitOptions: LocalGitExecOptions = {} ): Promise { const [originCandidate, upstreamCandidate] = await Promise.all([ - // Why: this caller combines both remotes itself, so it needs origin specifically (getOwnerRepo is upstream-first since #7331). - getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions), - getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) + getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions) ]) const source = preference === 'upstream' ? (upstreamCandidate ?? originCandidate) : originCandidate return { source, originCandidate, upstreamCandidate } } -/** - * gh exec relying on gh's own cwd→repo resolution. Serves a remembered resolution failure - * without spawning, so a remote-less repo costs one gh spawn per config change/TTL, not two per refresh. - */ -async function ghCwdResolvedExec( - context: GitHubRepoContext, - args: string[], - ghOptions: GhExecOptions -): Promise<{ stdout: string; stderr: string }> { - const remembered = await getRememberedGhCwdResolutionFailure(context) - if (remembered !== null) { - throw new Error(remembered) - } - try { - return await ghExecFileAsync(args, ghOptions) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - if (isGhCwdRepoResolutionFailure(message)) { - await rememberGhCwdResolutionFailure(context, message) - } - throw err - } -} - async function listRecentWorkItems( repoPath: string, issueOwnerRepo: OwnerRepo | null, @@ -1076,98 +1124,87 @@ async function listRecentWorkItems( noCache?: boolean, localGitOptions: LocalGitExecOptions = {} ): Promise { - const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(repoContext) - const requiresExplicitRepo = Boolean(connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const recentQuery = parseTaskQuery('is:open') - const issueRequest = buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query: recentQuery, - page - }) - const prRequest = buildWorkItemListRequest({ - kind: 'pr', - ownerRepo: prOwnerRepo, - limit, - query: recentQuery, - page - }) - if (noCache) { + const issueRequest = issueOwnerRepo + ? buildWorkItemListRequest({ + kind: 'issue', + ownerRepo: issueOwnerRepo, + limit, + query: recentQuery, + page + }) + : null + const prRequest = prOwnerRepo + ? buildWorkItemListRequest({ + kind: 'pr', + ownerRepo: prOwnerRepo, + limit, + query: recentQuery, + page + }) + : null + if (noCache && issueRequest) { issueRequest.args.splice(1, 2) } - if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) { - // Why: allSettled so a 403 on the issue side doesn't zero the PR half — partial results + banner (parent doc §2). - const [issuesSettled, prsSettled] = await Promise.allSettled([ - issueOwnerRepo - ? ghExecFileAsync(issueRequest.args, ghOptions) - : requiresExplicitRepo - ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), - prOwnerRepo - ? ghExecFileAsync(prRequest.args, ghOptions) - : requiresExplicitRepo - ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) - ]) - - let issues: MainWorkItem[] = [] - let issuesError: ClassifiedError | undefined - if (issuesSettled.status === 'fulfilled') { - issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out. - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - } else { - const stderr = - issuesSettled.reason instanceof Error - ? issuesSettled.reason.message - : String(issuesSettled.reason) - issuesError = classifyListIssuesError(stderr) - } - - let prs: MainWorkItem[] = [] - if (prsSettled.status === 'fulfilled') { - prs = (JSON.parse(prsSettled.value.stdout) as Record[]) - .slice(prRequest.offset, prRequest.offset + limit) - .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) - prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions) - } else { - // Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076). - // Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop. - if (issuesError) { - console.warn( - 'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:', - issuesError.type, - issuesError.message - ) - } - throw prsSettled.reason - } - - return { - items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), - issuesError - } - } - - // Why: non-GitHub remotes have no sources for a partial-failure banner, so keep Promise.all (reject-all) instead of allSettled. - const [issuesResult, prsResult] = await Promise.all([ - ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), - ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) + // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660). + // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner). + const [issuesSettled, prsSettled] = await Promise.allSettled([ + issueRequest && issueOwnerRepo + ? ghExecFileAsync(issueRequest.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) + : Promise.resolve({ stdout: '[]' }), + prRequest && prOwnerRepo + ? ghExecFileAsync(prRequest.args, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) + : Promise.resolve({ stdout: '[]' }) ]) - const issues = (JSON.parse(issuesResult.stdout) as Record[]) - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - const prs = (JSON.parse(prsResult.stdout) as Record[]) - .slice(prRequest.offset, prRequest.offset + limit) - .map((item) => mapPullRequestWorkItem(item, null)) + let issues: MainWorkItem[] = [] + let issuesError: ClassifiedError | undefined + if (issuesSettled.status === 'fulfilled') { + issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) + // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out. + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) + } else { + const stderr = + issuesSettled.reason instanceof Error + ? issuesSettled.reason.message + : String(issuesSettled.reason) + issuesError = classifyListIssuesError(stderr) + } + + let prs: MainWorkItem[] = [] + if (prsSettled.status === 'fulfilled') { + prs = (JSON.parse(prsSettled.value.stdout) as Record[]) + .slice(prRequest?.offset ?? 0, (prRequest?.offset ?? 0) + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) + prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) + } else { + // Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076). + // Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop. + if (issuesError) { + console.warn( + 'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:', + issuesError.type, + issuesError.message + ) + } + throw prsSettled.reason + } return { - items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit) + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), + issuesError } } @@ -1181,9 +1218,7 @@ async function listQueriedWorkItems( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(repoContext) - const requiresExplicitRepo = Boolean(connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const hasPrOnlyFilter = query.state === 'merged' || @@ -1201,7 +1236,7 @@ async function listQueriedWorkItems( if (!issueScope) { return { items: [] } } - if (requiresExplicitRepo && !issueOwnerRepo) { + if (!issueOwnerRepo) { return { items: [] } } const request = buildWorkItemListRequest({ @@ -1212,9 +1247,10 @@ async function listQueriedWorkItems( page: page ?? 1 }) try { - const { stdout } = issueOwnerRepo - ? await ghExecFileAsync(request.args, ghOptions) - : await ghCwdResolvedExec(repoContext, request.args, ghOptions) + const { stdout } = await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(issueOwnerRepo) + }) const items = (JSON.parse(stdout) as Record[]) .filter((item) => !('pull_request' in item)) .map(mapIssueWorkItem) @@ -1235,7 +1271,7 @@ async function listQueriedWorkItems( if (!prScope) { return [] } - if (requiresExplicitRepo && !prOwnerRepo) { + if (!prOwnerRepo) { return [] } const request = buildWorkItemListRequest({ @@ -1246,13 +1282,17 @@ async function listQueriedWorkItems( page: page ?? 1 }) try { - const { stdout } = prOwnerRepo - ? await ghExecFileAsync(request.args, ghOptions) - : await ghCwdResolvedExec(repoContext, request.args, ghOptions) + const { stdout } = await ghExecFileAsync(request.args, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) const mapped = (JSON.parse(stdout) as Record[]) .slice(request.offset, request.offset + limit) .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) - const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions) + const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, { + ...ghOptions, + ...githubHostExecOptions(prOwnerRepo) + }) successfulRequestCount += 1 if (query.state === 'closed') { return hydrated.filter((item) => item.state !== 'merged') @@ -1305,7 +1345,7 @@ export async function listWorkItems( } } const [issueResolved, prResolved] = await Promise.all([ - resolveIssueSource(repoPath, preference, connectionId, localGitOptions), + resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source @@ -1409,7 +1449,10 @@ async function countWorkItemsForQuery( localGitOptions: LocalGitExecOptions = {} ): Promise { const searchQ = buildSearchQueryString(ownerRepo, query) - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } const { stdout } = await ghExecFileAsync( [ 'api', @@ -1422,16 +1465,13 @@ async function countWorkItemsForQuery( ghOptions ) // Why: over-counting cache hits is the safe direction — the next probe corrects the estimate. - noteRateLimitSpend('search') + noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions) return Number.parseInt(stdout.trim(), 10) || 0 } function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean { - // Why: GitHub owner/repo names are case-insensitive, so differently-cased remotes are the same repo (don't split into two searches). - return ( - left?.owner.toLowerCase() === right?.owner.toLowerCase() && - left?.repo.toLowerCase() === right?.repo.toLowerCase() - ) + // Why: casing does not distinguish GitHub repos, but the same slug on different hosts does. + return Boolean(left && right && githubRepoIdentityKey(left) === githubRepoIdentityKey(right)) } function defaultOpenWorkItemQuery(): ParsedTaskQuery { @@ -1461,7 +1501,7 @@ export async function countWorkItems( return 0 } const [issueResolved, prResolved] = await Promise.all([ - resolveIssueSource(repoPath, preference, connectionId, localGitOptions), + resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions), resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source @@ -1473,10 +1513,16 @@ export async function countWorkItems( const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } // Why: counts are decorative, so stop when the 30/min search budget is gone rather than spawn into 403s (getRateLimit is 30s-cached). - await getRateLimit() - if (rateLimitGuard('search').blocked) { + if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { + await getRateLimit() + } + if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { return 0 } @@ -1548,13 +1594,11 @@ export async function getRepoSlug( repoPath: string, connectionId?: string | null, options: HostedReviewExecutionOptions = {} -): Promise<{ owner: string; repo: string } | null> { - // Why: the slug is the checkout's own identity, so it must stay origin-based (getOwnerRepo is upstream-first since #7331). - return getOwnerRepoForRemote( +): Promise { + return getOriginGitHubApiRepository( repoPath, - 'origin', connectionId, - ...hostedReviewLocalGitOptionArgs(options) + getHostedReviewLocalGitOptions(options) ) } @@ -1570,27 +1614,33 @@ export async function getRepoUpstream( ): Promise { const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} - // Why: must be origin — the fast-path compares it against upstream (getOwnerRepo is upstream-first since #7331). - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, ...localGitArgs) + const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + undefined, + connectionId, + localGitOptions + ) if (!origin) { return null } - const upstreamRemote = await getOwnerRepoForRemote( + const upstreamRemote = await getGitHubApiRepositoryForRemote( repoPath, 'upstream', connectionId, - ...localGitArgs + localGitOptions ) if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) { return upstreamRemote } await acquire() try { + // Why: positional slugs bypass the runner's --repo qualifier, so the slug + // itself must carry the Enterprise host. const { stdout } = await ghExecFileAsync( - ['repo', 'view', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'], - // Why: cap latency so a stalled gh process can't hold up repo creation. + ['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'], + // Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation. { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...ghOptions, timeout: 10_000 } ) @@ -1600,7 +1650,10 @@ export async function getRepoUpstream( } const owner = data.parent?.owner?.login const repo = data.parent?.name - return data.isFork && owner && repo ? { owner, repo } : null + // Why: a fork parent lives on the same server as the fork. + return data.isFork && owner && repo + ? { owner, repo, ...(origin.host ? { host: origin.host } : {}) } + : null } catch { return null } finally { @@ -1681,16 +1734,9 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } | return { number: Number(urlMatch[1]), url: urlMatch[0] } } -// Why: bare OWNER/REPO resolves against gh's default host, so qualify with host for GHES; the only host signal for cwd-less SSH repos (#8312). -function ghRepoArg(slug: { owner: string; repo: string; host?: string }): string { - return slug.host && slug.host.toLowerCase() !== 'github.com' - ? `${slug.host}/${slug.owner}/${slug.repo}` - : `${slug.owner}/${slug.repo}` -} - async function findOpenPRByHeadBase(args: { repoPath: string - repoArg: string + repo: GitHubApiRepository head: string base: string connectionId?: string | null @@ -1702,7 +1748,7 @@ async function findOpenPRByHeadBase(args: { 'pr', 'list', '--repo', - args.repoArg, + `${args.repo.owner}/${args.repo.repo}`, '--head', args.head, '--base', @@ -1716,7 +1762,8 @@ async function findOpenPRByHeadBase(args: { ], { ...ghRepoExecOptions(context), - ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)) + ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)), + ...githubHostExecOptions(args.repo) } ) const list = JSON.parse(stdout) as { number?: number; url?: string }[] @@ -1775,15 +1822,12 @@ export async function createGitHubPullRequest( } } - // Why: github.com-only slug parsing returns null for GHES; fall back to the enterprise resolver (#8312). - // Why: use origin, not the fork parent — the unqualified head branch was pushed there (getOwnerRepo upstream-first since #7331). - const ownerRepo = - (await getOwnerRepoForRemote( - repoPath, - 'origin', - connectionId, - ...hostedReviewLocalGitOptionArgs(options) - )) ?? (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) + // Why: creation targets the origin owning the unqualified head branch; the shared resolver preserves its host (#7331, #8312). + const ownerRepo = await getOriginGitHubApiRepository( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) if (!ownerRepo) { return { ok: false, @@ -1791,8 +1835,8 @@ export async function createGitHubPullRequest( error: 'Creating pull requests requires a GitHub remote.' } } - // Host-qualified for GHES so gh targets the Enterprise server, not github.com. - const repoArg = ghRepoArg(ownerRepo) + // The runner host-qualifies --repo from options.host for GHES (#8312). + const repoArg = `${ownerRepo.owner}/${ownerRepo.repo}` const base = normalizeHostedReviewBaseRef(input.base) const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined @@ -1844,6 +1888,7 @@ export async function createGitHubPullRequest( const { stdout } = await ghExecFileAsync(createArgs, { ...ghRepoExecOptions(context), ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)), + ...githubHostExecOptions(ownerRepo), timeout: 60_000, idempotent: false }) @@ -1854,7 +1899,7 @@ export async function createGitHubPullRequest( const found = head ? await findOpenPRByHeadBase({ repoPath, - repoArg, + repo: ownerRepo, head, base, connectionId, @@ -1878,7 +1923,7 @@ export async function createGitHubPullRequest( ) { const existing = await findOpenPRByHeadBase({ repoPath, - repoArg, + repo: ownerRepo, head, base, connectionId, @@ -1913,16 +1958,15 @@ export async function getWorkItem( if (type === 'issue') { return await fetchIssueWorkItem( repoPath, - await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions ) } if (type === 'pr') { - return await fetchPullRequestWorkItem( + return await fetchPullRequestWorkItemFromCandidates( repoPath, - await getOwnerRepo(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1932,7 +1976,7 @@ export async function getWorkItem( try { const issue = await fetchIssueWorkItem( repoPath, - await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1947,9 +1991,8 @@ export async function getWorkItem( throw err } } - return await fetchPullRequestWorkItem( + return await fetchPullRequestWorkItemFromCandidates( repoPath, - await getOwnerRepo(repoPath, connectionId, localGitOptions), number, connectionId, localGitOptions @@ -1963,20 +2006,47 @@ export async function getWorkItem( export async function getWorkItemByOwnerRepo( repoPath: string, - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, number: number, type: 'issue' | 'pr', connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + const requestedHost = ownerRepo.host?.trim().toLowerCase() + const requestedRepository = requestedHost + ? { ...ownerRepo, host: requestedHost } + : await resolveGitHubApiRepository(repoPath, ownerRepo, connectionId, localGitOptions) + if (!requestedRepository) { + return null + } + const { candidates } = await resolveGitHubApiRepositoryCandidates( + repoPath, + connectionId, + localGitOptions + ) + const requestedKey = githubRepoIdentityKey(requestedRepository) + const matchedRepository = candidates.find( + (candidate) => githubRepoIdentityKey(candidate) === requestedKey + ) + // Why: this lookup is reachable from pasted links. Restricting it to a + // configured remote prevents gh from sending credentials to an arbitrary host. + if (!matchedRepository) { + return null + } await acquire() try { if (type === 'issue') { - return await fetchIssueWorkItem(repoPath, ownerRepo, number, connectionId, localGitOptions) + return await fetchIssueWorkItem( + repoPath, + matchedRepository, + number, + connectionId, + localGitOptions + ) } return await fetchPullRequestWorkItem( repoPath, - ownerRepo, + matchedRepository, number, connectionId, localGitOptions @@ -2142,19 +2212,17 @@ function cacheRepositoryMergeMetadata( } async function detectRepositoryMergeMetadata( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, branchName: string | undefined, ghOptions: GhExecOptions ): Promise { - const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${ - branchName ?? '__repo__' - }` + const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { return cached.value } - const guard = rateLimitGuard('graphql') + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) if (guard.blocked) { return { mergeQueueRequired: null, autoMergeAllowed: null } } @@ -2179,7 +2247,7 @@ async function detectRepositoryMergeMetadata( } }` try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const args = [ 'api', 'graphql', @@ -2193,7 +2261,10 @@ async function detectRepositoryMergeMetadata( if (branchName) { args.push('-f', `branch=${branchName}`) } - const { stdout } = await ghExecFileAsync(args, ghOptions) + const { stdout } = await ghExecFileAsync(args, { + ...ghOptions, + ...githubHostExecOptions(ownerRepo) + }) const parsed = JSON.parse(stdout) as { data?: { repository?: { @@ -2271,7 +2342,7 @@ async function hydrateBranchLookupWithExactPR( } async function getRestPRForBranch( - prRepo: OwnerRepo, + prRepo: GitHubApiRepository, headOwner: string, branchName: string, ghOptions: ReturnType @@ -2279,7 +2350,7 @@ async function getRestPRForBranch( const head = encodeURIComponent(`${headOwner}:${branchName}`) const { stdout } = await ghExecFileAsync( ['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`], - ghOptions + { ...ghOptions, ...githubHostExecOptions(prRepo) } ) const list = JSON.parse(stdout) as RestPullRequest[] const pr = list[0] @@ -2287,7 +2358,7 @@ async function getRestPRForBranch( } async function getFallbackPRListForBranch( - prRepo: OwnerRepo, + prRepo: GitHubApiRepository, branchName: string, ghOptions: ReturnType ): Promise { @@ -2306,7 +2377,7 @@ async function getFallbackPRListForBranch( '--json', PR_BRANCH_LIST_JSON_FIELDS ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(prRepo) } ) const list = JSON.parse(stdout) as PullRequestLookupData[] return list[0] ?? null @@ -2395,10 +2466,6 @@ function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch return parsed } -function prOwnerRepoKey(ownerRepo: OwnerRepo): string { - return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}` -} - function shouldRetryTrackedUpstreamBranch( upstreamBranch: TrackedUpstreamBranch, branchName: string, @@ -2411,7 +2478,7 @@ function shouldRetryTrackedUpstreamBranch( if (!headRepo) { return true } - return prOwnerRepoKey(upstreamHeadRepo) !== prOwnerRepoKey(headRepo) + return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo) } async function getTrackedUpstreamBranch( @@ -2695,19 +2762,19 @@ async function lookupPRByBranchName(args: { } async function getRestPRByNumber( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, number: number, ghOptions: ReturnType ): Promise { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) } async function getPRByNumber( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, number: number, ghOptions: ReturnType ): Promise { @@ -2722,7 +2789,7 @@ async function getPRByNumber( '--json', PR_LOOKUP_JSON_FIELDS ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) return hydratePullRequestLookupData( ownerRepo, @@ -2832,10 +2899,10 @@ export async function getPRForBranch( return outcome.kind === 'found' ? outcome.pr : null } -// Why: exact-linked fallback returns dataRepo=null; derive the PR's repo from its web URL (host-agnostic for GHE) so the merged-PR membership probe can run. +// Why: exact-linked fallback has no dataRepo; derive its host-aware identity from the web URL for merged-PR membership checks. function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null { - const match = url.match(/^https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) - return match ? { owner: match[1], repo: match[2] } : null + const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) + return match ? { owner: match[2], repo: match[3], host: match[1] } : null } export async function getPRForBranchOutcome( @@ -2858,11 +2925,16 @@ export async function getPRForBranchOutcome( await acquire() try { - const { candidates, headRepo } = await resolvePRRepositoryCandidates( + const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates( repoPath, connectionId, - ...localGitArgs + localGitOptions ) + // Why: connection-backed gh runs without a repository cwd. A bare lookup + // here can honor process GH_REPO/GH_HOST and return an unrelated PR. + if (connectionId && candidates.length === 0) { + return { kind: 'no-pr', fetchedAt: Date.now() } + } let data: PullRequestLookupData | null = null let dataRepo: OwnerRepo | null = null let dataHeadRepo: OwnerRepo | null = headRepo @@ -2972,11 +3044,11 @@ export async function getPRForBranchOutcome( ) if (upstreamBranch) { const upstreamHeadRepo = - (await getOwnerRepoForRemote( + (await getGitHubApiRepositoryForRemote( repoPath, upstreamBranch.remoteName, connectionId, - ...localGitArgs + localGitOptions )) ?? headRepo if ( upstreamHeadRepo && @@ -3321,7 +3393,7 @@ function mapRestCommitStatus(status: RestCommitStatus): PRCheckDetail | null { } function mapGraphQLPendingApprovalCheckSuite( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, suite: GraphQLCheckSuite, headSha: string | null | undefined, index: number @@ -3338,7 +3410,7 @@ function mapGraphQLPendingApprovalCheckSuite( } function mapGraphQLPRChecksResponse( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, response: GraphQLPRChecksResponse ): PRCheckDetail[] | null { const pullRequest = response.data?.repository?.pullRequest @@ -3380,7 +3452,7 @@ function mapGraphQLPRChecksResponse( } async function getPRChecksViaRestFallback( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, headSha: string | undefined, ghOptions: GhExecOptions, noCache?: boolean @@ -3389,7 +3461,7 @@ async function getPRChecksViaRestFallback( return null } try { - await assertRateLimitBudget('core') + await assertRateLimitBudget('core', ownerRepo, ghOptions) } catch (err) { console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err) return null @@ -3407,7 +3479,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const checkRunData = JSON.parse(stdout) as { check_runs?: RestCheckRun[] } @@ -3424,7 +3496,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const statusData = JSON.parse(statusResult.stdout) as { statuses?: RestCommitStatus[] } @@ -3446,7 +3518,7 @@ async function getPRChecksViaRestFallback( ], ghOptions ) - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const suitesData = JSON.parse(suitesResult.stdout) as { check_suites?: RestCheckSuite[] } @@ -3481,16 +3553,23 @@ export async function getPRChecks( repoPath: string, prNumber: number, headSha?: string, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, options?: { noCache?: boolean }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { void headSha - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (connectionId && !ownerRepo) { + throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) + } const fallbackToPRChecks = async (): Promise => { - await assertRateLimitBudget('graphql') + await assertRateLimitBudget('graphql', ownerRepo, ghOptions) await acquire() try { const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link'] @@ -3505,7 +3584,7 @@ export async function getPRChecks( } throw err }) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const data = JSON.parse(stdout) as { name: string; state: string; link: string }[] return data.map((d) => ({ name: d.name, @@ -3522,7 +3601,7 @@ export async function getPRChecks( if (ownerRepo) { let canUseGraphQLRollup = true try { - await assertRateLimitBudget('graphql') + await assertRateLimitBudget('graphql', ownerRepo, ghOptions) } catch (err) { canUseGraphQLRollup = false console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err) @@ -3548,7 +3627,7 @@ export async function getPRChecks( ], ghOptions ) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const checks = mapGraphQLPRChecksResponse( ownerRepo, JSON.parse(stdout) as GraphQLPRChecksResponse @@ -3608,11 +3687,11 @@ function getPendingApprovalCheckSuiteName( } function getPendingApprovalCheckSuiteUrl( - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, headSha: string, suiteId: number | null | undefined ): string { - const base = `https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks` + const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks` return typeof suiteId === 'number' && Number.isFinite(suiteId) ? `${base}#check-suite-${suiteId}` : base @@ -3695,7 +3774,7 @@ function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): str async function attachFailedJobLogTails( jobs: PRCheckRunDetails['jobs'], - ownerRepo: OwnerRepo, + ownerRepo: GitHubApiRepository, ghOptions: GhExecOptions ): Promise { const failedJobs = jobs @@ -3707,7 +3786,8 @@ async function attachFailedJobLogTails( // Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads. for (const job of failedJobs) { - const cacheKey = getCheckJobLogTailCacheKey(job) + const jobCacheKey = getCheckJobLogTailCacheKey(job) + const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null if (!cacheKey) { continue } @@ -3751,13 +3831,17 @@ export async function getPRCheckDetails( workflowRunId?: number checkName?: string url?: string | null - prRepo?: OwnerRepo | null + prRepo?: GitHubApiRepository | null }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = args.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + args.prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return null } @@ -3845,12 +3929,20 @@ function parseActionsRunId(url: string | null | undefined): number | undefined { export async function rerunPRChecks( repoPath: string, prNumber: number, - options: { headSha?: string; failedOnly?: boolean } = {}, + options: { + headSha?: string + failedOnly?: boolean + prRepo?: GitHubApiRepository | null + } = {}, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + options.prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -3983,14 +4075,21 @@ query($owner: String!, $repo: String!, $pr: Int!) { export async function getPRComments( repoPath: string, prNumber: number, - options?: { noCache?: boolean; prRepo?: OwnerRepo | null }, + options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = options?.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + options?.prRepo, + connectionId, + localGitOptions + ) + if (connectionId && !ownerRepo) { + throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) + } if (ownerRepo) { - await assertRateLimitBudget('core') + await assertRateLimitBudget('core', ownerRepo, ghOptions) } await acquire() try { @@ -4000,12 +4099,12 @@ export async function getPRComments( const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}` // Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero. - const reviewThreadsGuard = rateLimitGuard('graphql') + const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null> if (reviewThreadsGuard.blocked) { reviewThreadsFetch = Promise.resolve(null) } else { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) reviewThreadsFetch = ghExecFileAsync( [ 'api', @@ -4034,7 +4133,7 @@ export async function getPRComments( ghOptions ) ]) - noteRateLimitSpend('core', 2) + noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) // Parse issue comments (REST) type RESTComment = { @@ -4184,7 +4283,7 @@ export async function getPRComments( ['pr', 'view', String(prNumber), '--json', 'comments'], ghOptions ) - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const data = JSON.parse(stdout) as { comments: { author: { login: string } @@ -4216,13 +4315,20 @@ export async function setPRFileViewed(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null pullRequestId: string path: string viewed: boolean }): Promise { - const ghOptions = ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions ) + if (!ownerRepo) { + return false + } const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed' const query = `mutation($pullRequestId: ID!, $path: String!) { ${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) { @@ -4261,12 +4367,21 @@ export async function resolveReviewThread( threadId: string, resolve: boolean, connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread' const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }` - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const guard = rateLimitGuard('graphql') + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) if (guard.blocked) { console.warn( `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` @@ -4275,7 +4390,7 @@ export async function resolveReviewThread( } await acquire() try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) await ghExecFileAsync( ['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`], ghOptions @@ -4329,11 +4444,15 @@ export async function addPRReviewCommentReply( path?: string, line?: number, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4372,10 +4491,12 @@ export async function addPRReviewComment( localGitOptions?: LocalGitExecOptions } ): Promise { - const ghOptions = ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions ) - const ownerRepo = await getOwnerRepo(args.repoPath, args.connectionId, args.localGitOptions) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4433,11 +4554,18 @@ export async function mergePR( prNumber: number, method: 'merge' | 'squash' | 'rebase' = 'squash', connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } await acquire() try { const mergeBlocker = await getPRMergeBlocker( @@ -4477,11 +4605,18 @@ export async function setPRAutoMerge( enabled: boolean, method: GitHubPRMergeMethod = 'squash', connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } await acquire() try { if (enabled) { @@ -4521,7 +4656,7 @@ type PRAutoMergeIdentity = { async function getPRAutoMergeIdentity( prNumber: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS] @@ -4540,7 +4675,7 @@ async function getPRAutoMergeIdentity( async function runPRAutoMergeCommand( prNumber: number, method: GitHubPRMergeMethod, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`] @@ -4555,7 +4690,7 @@ async function runPRAutoMergeCommand( async function shouldUseMergeQueueAutoMerge( pr: PRAutoMergeIdentity, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise { if (!ownerRepo || !pr.baseRefName) { @@ -4568,7 +4703,7 @@ async function shouldUseMergeQueueAutoMerge( async function enablePRAutoMerge( prNumber: number, method: GitHubPRMergeMethod, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise<{ ok: true } | { ok: false; error: string }> { const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions) @@ -4612,7 +4747,7 @@ async function enablePRAutoMerge( async function getPRMergeBlocker( repoPath: string, prNumber: number, - ownerRepo: OwnerRepo | null, + ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} @@ -4679,11 +4814,15 @@ export async function updatePRState( prNumber: number, updates: GitHubPullRequestStateUpdate, connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -4713,14 +4852,22 @@ export async function requestPRReviewers( prNumber: number, reviewers: string[], connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) if (logins.length === 0) { return { ok: false, error: 'Enter at least one reviewer' } } - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } await acquire() try { const args = ['pr', 'edit', String(prNumber), '--add-reviewer', logins.join(',')] @@ -4746,14 +4893,22 @@ export async function removePRReviewers( prNumber: number, reviewers: string[], connectionId?: string | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) if (logins.length === 0) { return { ok: false, error: 'Enter at least one reviewer' } } - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } await acquire() try { const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')] @@ -4782,11 +4937,18 @@ export async function updatePRTitle( prNumber: number, title: string, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } await acquire() try { const args = ['pr', 'edit', String(prNumber), '--title', title] @@ -4810,11 +4972,15 @@ export async function updatePRDetails( prNumber: number, updates: { title?: string; body?: string }, connectionId?: string | null, - prRepo?: OwnerRepo | null, + prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index e9a783b75ad..23f3eb8b96e 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -209,6 +209,11 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() + // Why: getPRForBranch resolves origin through getOwnerRepoForRemote. + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) @@ -246,7 +251,7 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open // The head-branch lookup still runs (state=all) and sees the closed PR… expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/acme/widgets/pulls?head=acme%3Amaster&state=all&per_page=1'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) // …but the default-branch guard discards it: no PR on the trunk (#9171). expect(pr).toBeNull() diff --git a/src/main/github/gh-cwd-repo-negative-cache.test.ts b/src/main/github/gh-cwd-repo-negative-cache.test.ts deleted file mode 100644 index 240711143f1..00000000000 --- a/src/main/github/gh-cwd-repo-negative-cache.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { readLocalGitConfigSignatureMock } = vi.hoisted(() => ({ - readLocalGitConfigSignatureMock: vi.fn() -})) - -vi.mock('./local-git-config-signature', () => ({ - readLocalGitConfigSignature: readLocalGitConfigSignatureMock -})) - -import { - _resetGhCwdRepoNegativeCache, - getRememberedGhCwdResolutionFailure, - isGhCwdRepoResolutionFailure, - rememberGhCwdResolutionFailure -} from './gh-cwd-repo-negative-cache' - -const context = { repoPath: '/repo', connectionId: null } - -afterEach(() => { - _resetGhCwdRepoNegativeCache() - readLocalGitConfigSignatureMock.mockReset() -}) - -describe('isGhCwdRepoResolutionFailure', () => { - it('matches gh cwd-resolution failures only', () => { - expect(isGhCwdRepoResolutionFailure('Command failed: gh\nno git remotes found')).toBe(true) - expect( - isGhCwdRepoResolutionFailure( - 'none of the git remotes configured for this repository point to a known GitHub host' - ) - ).toBe(true) - expect(isGhCwdRepoResolutionFailure('gh: API rate limit exceeded (HTTP 403)')).toBe(false) - expect(isGhCwdRepoResolutionFailure('gh: Not Found (HTTP 404)')).toBe(false) - }) -}) - -describe('remembered failures', () => { - it('serves the remembered failure while the config signature is unchanged', async () => { - readLocalGitConfigSignatureMock.mockResolvedValue('sig-1') - await rememberGhCwdResolutionFailure(context, 'no git remotes found') - await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBe('no git remotes found') - }) - - it('invalidates when the git config signature changes (remote added)', async () => { - readLocalGitConfigSignatureMock.mockResolvedValueOnce('sig-1') - await rememberGhCwdResolutionFailure(context, 'no git remotes found') - readLocalGitConfigSignatureMock.mockResolvedValue('sig-2') - await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBeNull() - // The stale entry is gone, not just skipped. - readLocalGitConfigSignatureMock.mockResolvedValue('sig-1') - await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBeNull() - }) - - it('separates repos and runtimes by cache key', async () => { - readLocalGitConfigSignatureMock.mockResolvedValue('sig') - await rememberGhCwdResolutionFailure(context, 'no git remotes found') - await expect( - getRememberedGhCwdResolutionFailure({ repoPath: '/other-repo', connectionId: null }) - ).resolves.toBeNull() - await expect( - getRememberedGhCwdResolutionFailure({ repoPath: '/repo', connectionId: 'ssh-1' }) - ).resolves.toBeNull() - }) -}) diff --git a/src/main/github/gh-cwd-repo-negative-cache.ts b/src/main/github/gh-cwd-repo-negative-cache.ts deleted file mode 100644 index 97a810f5e22..00000000000 --- a/src/main/github/gh-cwd-repo-negative-cache.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { readLocalGitConfigSignature } from './local-git-config-signature' -import type { GitHubRepoContext } from './github-repository-identity' - -/** - * Negative cache for repos where gh's cwd-based repo resolution fails - * ("no git remotes found" etc). Orca only falls back to cwd resolution when - * it could not resolve an owner/repo itself, so for a remote-less repo the - * failure is deterministic — yet the Tasks page re-spawned gh for every such - * repo on every refresh (×90 repos in the reported storm). Remember the - * failure and re-throw it without a subprocess until the repo's git config - * changes (signature check) or the TTL lapses. - */ -const NEGATIVE_CACHE_TTL_MS = 5 * 60_000 -const MAX_ENTRIES = 512 - -type NegativeCacheEntry = { - message: string - configSignature: string | undefined - expiresAt: number -} - -const entries = new Map() - -function negativeCacheKey(context: GitHubRepoContext): string { - const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}` - return `${runtimeKey}\0${context.repoPath}` -} - -export function isGhCwdRepoResolutionFailure(text: string): boolean { - const s = text.toLowerCase() - return ( - s.includes('no git remotes found') || - s.includes('not a git repository') || - s.includes('unable to determine base repository') || - s.includes('none of the git remotes configured') - ) -} - -export async function getRememberedGhCwdResolutionFailure( - context: GitHubRepoContext -): Promise { - const key = negativeCacheKey(context) - const entry = entries.get(key) - if (!entry) { - return null - } - if (entry.expiresAt <= Date.now()) { - entries.delete(key) - return null - } - // Why: adding a remote is the way out of this failure; the config signature - // (mtime/size of the repo's git config chain) changes when that happens. - const signature = await readLocalGitConfigSignature(context) - if (signature !== entry.configSignature) { - entries.delete(key) - return null - } - return entry.message -} - -export async function rememberGhCwdResolutionFailure( - context: GitHubRepoContext, - message: string -): Promise { - const configSignature = await readLocalGitConfigSignature(context) - entries.set(negativeCacheKey(context), { - message, - configSignature, - expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS - }) - while (entries.size > MAX_ENTRIES) { - const oldest = entries.keys().next() - if (oldest.done) { - break - } - entries.delete(oldest.value) - } -} - -/** @internal — test-only */ -export function _resetGhCwdRepoNegativeCache(): void { - entries.clear() -} diff --git a/src/main/github/github-api-repository.test.ts b/src/main/github/github-api-repository.test.ts new file mode 100644 index 00000000000..fdf921a92ad --- /dev/null +++ b/src/main/github/github-api-repository.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GitHubEnterpriseRepository from './github-enterprise-repository' +import type * as GhUtils from './gh-utils' + +const { + getEnterpriseGitHubRepoSlugMock, + getOwnerRepoMock, + getOwnerRepoForRemoteMock, + isGitHubHostAuthenticatedMock +} = vi.hoisted(() => ({ + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + isGitHubHostAuthenticatedMock: vi.fn() +})) + +vi.mock('./gh-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getOwnerRepo: getOwnerRepoMock, + // Why: origin resolution uses getOwnerRepoForRemote, not getOwnerRepo. + getOwnerRepoForRemote: getOwnerRepoForRemoteMock +})) + +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: isGitHubHostAuthenticatedMock +})) + +import { + _resetOriginGitHubApiRepositoryCache, + getOriginGitHubApiRepository, + githubHostExecOptions, + resolveGitHubApiRepository, + resolveGitHubRepoExecution +} from './github-api-repository' + +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() + getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null) + getOwnerRepoMock.mockReset().mockResolvedValue(null) + getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null) + isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false) +}) + +describe('githubHostExecOptions', () => { + it('pins every known repository host', () => { + expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.com' })).toEqual({ + host: 'github.com' + }) + expect( + githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }) + ).toEqual({ host: 'github.acme-corp.com' }) + }) + + it('does not invent a host when repository identity is unavailable or legacy', () => { + expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets' })).toEqual({}) + expect(githubHostExecOptions(null)).toEqual({}) + }) +}) + +describe('resolveGitHubRepoExecution', () => { + it('combines local repository and GitHub host execution options', async () => { + const ownerRepo = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } + isGitHubHostAuthenticatedMock.mockResolvedValue(true) + + await expect( + resolveGitHubRepoExecution('/repo', ownerRepo, null, { wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ + ownerRepo, + ghOptions: { + cwd: '/repo', + wslDistro: 'Ubuntu', + host: 'github.acme-corp.com' + } + }) + expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith( + 'github.acme-corp.com', + '/repo', + null, + { wslDistro: 'Ubuntu' } + ) + }) + + it('rejects an explicit Enterprise host absent from the local gh auth inventory', async () => { + await expect( + resolveGitHubApiRepository( + '/remote/repo', + { + owner: 'acme', + repo: 'widgets', + host: 'evil.example.test' + }, + 'ssh-1' + ) + ).resolves.toBeNull() + + expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith( + 'evil.example.test', + '/remote/repo', + 'ssh-1', + {} + ) + }) + + it.each([ + { owner: 'acme%2Fadmin', repo: 'widgets' }, + { owner: 'acme', repo: '..' } + ])('rejects repository overrides that could alter a gh REST path: %o', async (repository) => { + await expect( + resolveGitHubApiRepository('/repo', { + ...repository, + host: 'github.com' + }) + ).resolves.toBeNull() + + expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('normalizes github.com without spending an auth inventory probe', async () => { + await expect( + resolveGitHubApiRepository('/repo', { + owner: 'acme', + repo: 'widgets', + host: ' GitHub.COM ' + }) + ).resolves.toEqual({ owner: 'acme', repo: 'widgets', host: 'github.com' }) + + expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('backfills the origin host for a host-less caller-specific resolver', async () => { + const ownerRepo = { owner: 'upstream', repo: 'widgets' } + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'fork', repo: 'widgets' }) + + await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({ + ownerRepo: { ...ownerRepo, host: 'github.com' }, + ghOptions: { cwd: '/repo', host: 'github.com' } + }) + }) + + it('backfills an Enterprise origin host for a host-less caller-specific resolver', async () => { + const ownerRepo = { owner: 'upstream', repo: 'widgets' } + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'fork', + repo: 'widgets', + host: 'github.acme-corp.com' + }) + + await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({ + ownerRepo: { ...ownerRepo, host: 'github.acme-corp.com' }, + ghOptions: { cwd: '/repo', host: 'github.acme-corp.com' } + }) + }) + + it('rejects a host-less caller-specific resolver for a connection-backed repository', async () => { + await expect( + resolveGitHubRepoExecution( + '/remote/repo', + async () => ({ owner: 'upstream', repo: 'widgets' }), + 'ssh-1' + ) + ).resolves.toEqual({ ownerRepo: null, ghOptions: {} }) + }) + + it('rejects a host-less caller-specific resolver for an unresolved local repository', async () => { + await expect( + resolveGitHubRepoExecution('/repo', async () => ({ owner: 'upstream', repo: 'widgets' })) + ).resolves.toEqual({ ownerRepo: null, ghOptions: { cwd: '/repo' } }) + }) + + it('preserves an authoritative null from a caller-specific resolver', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'origin', repo: 'widgets' }) + + await expect(resolveGitHubRepoExecution('/repo', async () => null)).resolves.toEqual({ + ownerRepo: null, + ghOptions: { cwd: '/repo' } + }) + + expect(getOwnerRepoMock).not.toHaveBeenCalled() + }) +}) + +describe('origin repository cache', () => { + it('does not cache an indeterminate Enterprise auth probe', async () => { + const enterprise = { + owner: 'acme', + repo: 'widgets', + host: 'github.acme-corp.com' + } + getEnterpriseGitHubRepoSlugMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(enterprise) + + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + await expect(getOriginGitHubApiRepository('/repo')).resolves.toEqual(enterprise) + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) + }) + + it('still caches a definitive negative Enterprise probe', async () => { + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts new file mode 100644 index 00000000000..7ef11c9f8fb --- /dev/null +++ b/src/main/github/github-api-repository.ts @@ -0,0 +1,360 @@ +import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' +import { + githubRepoIdentityKey, + isDefaultGitHubHost +} from '../../shared/github-repository-identity-key' +import { + getOwnerRepoForRemote, + ghRepoExecOptions, + githubRepoContext, + type LocalGitExecOptions +} from './gh-utils' +import { + getEnterpriseGitHubRepoSlug, + getEnterpriseGitHubRepoSlugForRemote, + isGitHubHostAuthenticated +} from './github-enterprise-repository' + +export type GitHubApiRepository = GitHubOwnerRepo +export type GitHubRepoExecOptions = ReturnType & { host?: string } +export type GitHubRepoExecution = { + ownerRepo: GitHubApiRepository | null + ghOptions: GitHubRepoExecOptions +} + +type GitHubApiRepositoryResolution = + | GitHubApiRepository + | null + | undefined + | (() => Promise) + +// Why: renderer/RPC repository overrides are interpolated into REST paths. +// Reject path syntax before an authenticated gh process can target it. +const GITHUB_OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ +const GITHUB_REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ + +function isValidGitHubApiRepository(repository: GitHubApiRepository): boolean { + return ( + GITHUB_OWNER_SLUG_RE.test(repository.owner) && + GITHUB_REPO_SLUG_RE.test(repository.repo) && + repository.repo !== '.' && + repository.repo !== '..' + ) +} + +// Why: the enterprise branch spawns an uncached `git remote get-url` (an SSH +// round trip on connection-backed repos) — hot paths like per-file contents +// and viewed-state toggles resolve per call, so cache like ownerRepoCache does. +const ORIGIN_REPO_CACHE_TTL_MS = 30_000 +const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512 +const originRepoCache = new Map() +const originRepoInFlight = new Map>() + +function originRepoCacheKey( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return `${connectionId ?? 'local'}\0${localGitOptions.wslDistro ?? ''}\0${repoPath}\0${remoteName}` +} + +/** @internal - exposed for tests only */ +export function _resetOriginGitHubApiRepositoryCache(): void { + originRepoCache.clear() + originRepoInFlight.clear() +} + +/** @internal - exposed for cache-bound tests only */ +export function _getOriginGitHubApiRepositoryCacheSize(): number { + return originRepoCache.size +} + +function pruneOriginRepoCache(now: number): void { + for (const [key, entry] of originRepoCache) { + if (entry.expiresAt <= now) { + originRepoCache.delete(key) + } + } + while (originRepoCache.size > ORIGIN_REPO_CACHE_MAX_ENTRIES) { + const oldestKey = originRepoCache.keys().next().value + if (oldestKey === undefined) { + return + } + originRepoCache.delete(oldestKey) + } +} + +/** + * Host-qualified repository identity for one remote: github.com remotes come + * from the cached slug parser; any other GitHub-shaped host is auth-gated so a + * non-GitHub forge never routes to the GitHub provider. + */ +export async function getGitHubApiRepositoryForRemote( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + // Why: generic PR resolution prefers upstream, but this API represents the + // caller-selected remote exactly (#7331). + const ownerRepo = await getOwnerRepoForRemote( + repoPath, + remoteName, + connectionId, + localGitOptions + ) + if (ownerRepo) { + return { ...ownerRepo, host: 'github.com' } + } + const cacheKey = originRepoCacheKey(repoPath, remoteName, connectionId, localGitOptions) + const now = Date.now() + pruneOriginRepoCache(now) + const cached = originRepoCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.value + } + const inFlight = originRepoInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + const probe = (async () => { + const enterpriseOptions = + Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {} + const slug = + remoteName === 'origin' + ? await getEnterpriseGitHubRepoSlug(repoPath, connectionId, enterpriseOptions) + : await getEnterpriseGitHubRepoSlugForRemote( + repoPath, + remoteName, + connectionId, + enterpriseOptions + ) + // Why: undefined means the gh auth inventory could not be read. Caching it + // as a negative would turn a transient spawn failure into a 30-second miss. + if (slug !== undefined) { + originRepoCache.set(cacheKey, { + value: slug, + expiresAt: Date.now() + ORIGIN_REPO_CACHE_TTL_MS + }) + pruneOriginRepoCache(Date.now()) + } + return slug ?? null + })() + originRepoInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (originRepoInFlight.get(cacheKey) === probe) { + originRepoInFlight.delete(cacheKey) + } + } +} + +export async function getOriginGitHubApiRepository( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +/** Hosted mirror of getIssueOwnerRepo: issues prefer `upstream` over `origin`. */ +export async function getIssueGitHubApiRepository( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const upstream = await getGitHubApiRepositoryForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return upstream + } + return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +export type GitHubApiRepositoryCandidates = { + candidates: GitHubApiRepository[] + headRepo: GitHubApiRepository | null +} + +/** Hosted mirror of resolvePRRepositoryCandidates: upstream first, then origin. */ +export async function resolveGitHubApiRepositoryCandidates( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const [upstream, origin] = await Promise.all([ + getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions), + getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions) + ]) + const seen = new Set() + const candidates: GitHubApiRepository[] = [] + for (const candidate of [upstream, origin]) { + if (!candidate) { + continue + } + const key = githubRepoIdentityKey(candidate) + if (seen.has(key)) { + continue + } + seen.add(key) + candidates.push(candidate) + } + return { candidates, headRepo: origin } +} + +export type ResolvedGitHubApiRepositorySource = { + source: GitHubApiRepository | null + /** True when explicit upstream is gone and resolver fell back to origin. */ + fellBack: boolean +} + +/** Hosted mirror of resolveIssueSource — same preference semantics. */ +export async function resolveIssueGitHubApiRepositorySource( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'upstream') { + const upstream = await getGitHubApiRepositoryForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return { source: upstream, fellBack: false } + } + const origin = await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ) + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { + source: await getGitHubApiRepositoryForRemote( + repoPath, + 'origin', + connectionId, + localGitOptions + ), + fellBack: false + } + } + return { + source: await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + fellBack: false + } +} + +export async function resolveGitHubApiRepository( + repoPath: string, + repository?: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (repository && !isValidGitHubApiRepository(repository)) { + return null + } + if (repository?.host) { + const host = repository.host.trim().toLowerCase() + if (!host) { + return null + } + if (isDefaultGitHubHost(host)) { + return { ...repository, host } + } + // Why: client-supplied hosts must match gh's local auth inventory before + // they can receive ambient Enterprise credentials from a host-pinned call. + const authenticated = await isGitHubHostAuthenticated( + host, + repoPath, + connectionId, + localGitOptions + ) + return authenticated ? { ...repository, host } : null + } + const originRepository = await getOriginGitHubApiRepository( + repoPath, + connectionId, + localGitOptions + ) + if (!repository) { + return originRepository + } + // Why: older clients only send owner/repo. The origin still supplies the + // execution host for fork-base slugs on the same GitHub Enterprise server. + if (originRepository?.host) { + return { ...repository, host: originRepository.host } + } + // Why: a host-less identity can honor ambient GH_HOST even with a local cwd. + // Only a resolved origin may supply the execution host for legacy clients. + return null +} + +export function isGitHubDotComRepository(repository: GitHubApiRepository): boolean { + return isDefaultGitHubHost(repository.host) +} + +// Why: the gh runner host-qualifies argv from `options.host`, so every known +// host must be carried through. Pinning github.com prevents a process-level +// GH_HOST from silently redirecting an otherwise unambiguous API request. +export function githubHostExecOptions(repository: GitHubApiRepository | null | undefined): { + host?: string +} { + return repository?.host ? { host: repository.host } : {} +} + +export async function resolveGitHubRepoExecution( + repoPath: string, + repository?: GitHubApiRepositoryResolution, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + // Why: issue-scoped paths retain their upstream-first resolver while sharing + // the same repo-scoped and host-scoped gh execution option construction. + const requestedRepository = typeof repository === 'function' ? await repository() : repository + // Why: normalize host-less resolver results without replacing an + // authoritative null with the generic origin fallback. + const ownerRepo = + typeof repository === 'function' && !requestedRepository + ? null + : await resolveGitHubApiRepository( + repoPath, + requestedRepository, + connectionId, + localGitOptions + ) + return { + ownerRepo, + ghOptions: { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + } +} + +export function githubRepositoryWebHost(repository: GitHubApiRepository): string { + return repository.host ?? 'github.com' +} + +/** + * Positional `HOST/OWNER/REPO` argv value (e.g. `gh repo view `). + * Positional slugs bypass the runner's `--repo` qualifier, so they must be + * qualified here whenever the host is known. + */ +export function githubRepositorySlugArg(repository: GitHubApiRepository): string { + const slug = `${repository.owner}/${repository.repo}` + // Why: github.com must be explicit too; otherwise process-level GH_HOST can + // redirect positional `gh repo view OWNER/REPO` calls to an Enterprise host. + return repository.host ? `${repository.host}/${slug}` : slug +} diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index 73de02ddae9..0a361ea1323 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -15,7 +15,8 @@ vi.mock('../git/runner', () => ({ import { _resetGitHubHostAuthCache, getEnterpriseGitHubRepoSlug, - isGitHubHostAuthenticated + isGitHubHostAuthenticated, + isGitHubHostAuthenticatedForGlobalCli } from './github-enterprise-repository' function mockOriginRemote(url: string): void { @@ -61,11 +62,9 @@ describe('getEnterpriseGitHubRepoSlug', () => { repo: 'orca', host: 'github.acme-corp.com' }) - // The auth probe targets the remote's host, not a hardcoded github.com. - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - { cwd: '/repo' } - ) + // Why: inventory configured gh hosts without targeting the untrusted remote; + // passing it as --hostname could expose an ambient enterprise token. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { cwd: '/repo' }) }) it('resolves a GHES SCP-style SSH remote', async () => { @@ -79,6 +78,28 @@ describe('getEnterpriseGitHubRepoSlug', () => { }) }) + it('uses the unique ported auth host for a hostname-only SSH remote', async () => { + mockOriginRemote('git@ghe.acme.com:team/orca.git') + mockHostAuthenticated('ghe.acme.com:8443') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com:8443' + }) + }) + + it('uses the auth inventory host when an HTTPS remote includes a non-default port', async () => { + mockOriginRemote('https://ghe.acme.com:8443/team/orca.git') + mockHostAuthenticated('ghe.acme.com') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com' + }) + }) + it('probes gh in the repository WSL runtime, not the host/default distro', async () => { mockOriginRemote('https://github.acme-corp.com/team/orca.git') mockHostAuthenticated() @@ -87,10 +108,10 @@ describe('getEnterpriseGitHubRepoSlug', () => { localGitExecOptions: { wslDistro: 'Ubuntu' } }) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - { cwd: '/repo', wslDistro: 'Ubuntu' } - ) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) }) it('leaves github.com to getOwnerRepo without probing gh auth', async () => { @@ -120,6 +141,22 @@ describe('getEnterpriseGitHubRepoSlug', () => { await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() }) + + it('preserves an indeterminate auth inventory failure so a later probe can recover', async () => { + mockOriginRemote('git@ghe.acme.com:team/orca.git') + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('not installed'), { stdout: '', stderr: '' }) + ) + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeUndefined() + + mockHostAuthenticated('ghe.acme.com:8443') + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'ghe.acme.com:8443' + }) + }) }) describe('isGitHubHostAuthenticated', () => { @@ -135,10 +172,7 @@ describe('isGitHubHostAuthenticated', () => { await expect( isGitHubHostAuthenticated('github.acme-corp.com', '/remote/repo', 'ssh-1') ).resolves.toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.acme-corp.com'], - {} - ) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {}) }) it('caches per runtime+host so detection polling does not re-spawn gh', async () => { @@ -149,6 +183,28 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) + it('coalesces concurrent probes for the same runtime and host', async () => { + let finishProbe: (() => void) | undefined + ghExecFileAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = () => + resolve({ + stdout: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + }) + }) + ) + + const first = isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + const second = isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + finishProbe?.() + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + }) + it('does not share cache state across WSL distros', async () => { mockHostAuthenticated() @@ -157,6 +213,84 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('derives cache scope from an implicit WSL UNC repository path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + ghExecFileAsyncMock.mockImplementation(async (_args: string[], options: { cwd?: string }) => ({ + stdout: options.cwd?.includes('Ubuntu') + ? 'github.com\n ✓ Logged in to github.com account other (keyring)' + : 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + })) + + try { + await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/native/repo')).resolves.toBe( + true + ) + await expect( + isGitHubHostAuthenticated( + 'github.acme-corp.com', + String.raw`\\wsl.localhost\Ubuntu\home\me\repo` + ) + ).resolves.toBe(false) + await expect( + isGitHubHostAuthenticated( + 'github.acme-corp.com', + String.raw`\\wsl.localhost\Debian\home\me\repo` + ) + ).resolves.toBe(true) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('shares the native gh auth probe across SSH connections', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/remote/a', 'ssh-1') + await isGitHubHostAuthenticated('github.acme-corp.com', '/remote/b', 'ssh-2') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not target an unconfigured remote host with ambient credentials', async () => { + mockHostAuthenticated('github.acme-corp.com') + + await expect(isGitHubHostAuthenticated('evil.example.test', '/repo')).resolves.toBe(false) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { cwd: '/repo' }) + expect(ghExecFileAsyncMock.mock.calls.flat()).not.toContain('evil.example.test') + }) + + it('does not guess between multiple ported auth hosts for one SSH hostname', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: `ghe.acme.com:8443 + ✓ Logged in to ghe.acme.com:8443 account kelora (keyring) +ghe.acme.com:9443 + ✓ Logged in to ghe.acme.com:9443 account other (keyring)`, + stderr: '' + }) + + await expect(isGitHubHostAuthenticated('ghe.acme.com', '/repo')).resolves.toBe(false) + }) + + it('treats default web ports as the same auth host', async () => { + mockHostAuthenticated('ghe.acme.com:443') + + await expect(isGitHubHostAuthenticated('ghe.acme.com', '/repo')).resolves.toBe(true) + }) + + it('validates a global project host by inventory without targeting it', async () => { + mockHostAuthenticated('github.acme-corp.com') + + await expect(isGitHubHostAuthenticatedForGlobalCli('evil.example.test')).resolves.toBe(false) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {}) + expect(ghExecFileAsyncMock.mock.calls.flat()).not.toContain('evil.example.test') + }) + it('treats a listed host as authenticated even when gh exits non-zero', async () => { ghExecFileAsyncMock.mockRejectedValue( Object.assign(new Error('exit 1'), { diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 94cffe725fa..b6b28e62f4e 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -12,6 +12,7 @@ import { parseGitHubRemoteIdentity, type LocalGitExecOptions } from './github-repository-identity' +import { parseWslPath } from '../wsl' export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } @@ -20,25 +21,47 @@ export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } // mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a // GHES remote is not left to fall through to Gitea (#8312). const HOST_AUTH_TTL_MS = 60_000 +const HOST_AUTH_CACHE_MAX_ENTRIES = 512 type HostAuthCacheEntry = { - authenticated: boolean + authenticatedHost: string | null expiresAt: number } const hostAuthCache = new Map() +const hostAuthInFlight = new Map>() -// Why: gh's authenticated hosts live in per-runtime config — a WSL distro and an -// SSH host each carry their own `hosts.yml` — so cache state must be keyed by the -// runtime that executes gh, not shared under one "local" bucket. Mirrors the -// runtime scoping used by owner/repo resolution. -function runtimeCacheKey(connectionId?: string | null, wslDistro?: string): string { - return connectionId ?? `local:${wslDistro ?? 'host'}` +// Why: connection-backed Git operations execute remotely, but gh intentionally +// executes on the native host. Only WSL selects a distinct gh config/runtime. +function runtimeCacheKey(repoPath: string, wslDistro?: string): string { + const resolvedDistro = wslDistro ?? parseWslPath(repoPath)?.distro + return `local:${resolvedDistro?.toLowerCase() ?? 'host'}` } /** @internal - exposed for tests only */ export function _resetGitHubHostAuthCache(): void { hostAuthCache.clear() + hostAuthInFlight.clear() +} + +/** @internal - exposed for cache-bound tests only */ +export function _getGitHubHostAuthCacheSize(): number { + return hostAuthCache.size +} + +function pruneHostAuthCache(now: number): void { + for (const [key, entry] of hostAuthCache) { + if (entry.expiresAt <= now) { + hostAuthCache.delete(key) + } + } + while (hostAuthCache.size > HOST_AUTH_CACHE_MAX_ENTRIES) { + const oldestKey = hostAuthCache.keys().next().value + if (oldestKey === undefined) { + return + } + hostAuthCache.delete(oldestKey) + } } // Only gh's own stdout/stderr — not the Error.message — counts as an @@ -52,14 +75,117 @@ function ghCommandOutput(error: unknown): string { .join('\n') } +type NormalizedGitHubHost = { + hostname: string + port: string | null + authority: string +} + +function normalizeGitHubHost(host: string): NormalizedGitHubHost | null { + const match = host + .trim() + .toLowerCase() + .match(/^([a-z0-9][a-z0-9.-]*)(?::(\d+))?$/i) + if (!match) { + return null + } + const hostname = match[1] + const rawPort = match[2] ?? null + // Why: default web ports do not distinguish a gh auth host from the same + // hostname without an explicit port. + const port = rawPort === '80' || rawPort === '443' ? null : rawPort + return { hostname, port, authority: port ? `${hostname}:${port}` : hostname } +} + +function authenticatedHostFromInventory(host: string, output: string): string | null { + const requested = normalizeGitHubHost(host) + if (!requested) { + return null + } + const inventory = Array.from( + new Map( + parseAuthStatus(output) + .map((account) => normalizeGitHubHost(account.host)) + .filter((candidate): candidate is NormalizedGitHubHost => candidate !== null) + .map((candidate) => [candidate.authority, candidate]) + ).values() + ) + const exact = inventory.find((candidate) => candidate.authority === requested.authority) + if (exact) { + return exact.authority + } + const compatible = inventory.filter( + (candidate) => + candidate.hostname === requested.hostname && (!requested.port || candidate.port === null) + ) + // Why: an SSH remote has no API port. Only a unique auth-inventory host can + // safely supply it; multiple ported endpoints on one hostname are ambiguous. + return compatible.length === 1 ? compatible[0].authority : null +} + +async function resolveAuthenticatedGitHubHost( + host: string, + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase() + const cacheKey = `${runtimeCacheKey(repoPath, localGitOptions.wslDistro)}\0${normalizedHost}` + const now = Date.now() + pruneHostAuthCache(now) + const cached = hostAuthCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.authenticatedHost + } + const inFlight = hostAuthInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + // Why: provider detection and review loading can probe the same runtime at + // once; coalesce them so one host never spawns duplicate auth subprocesses. + const probe = (async () => { + const execOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + } + let authenticatedHost: string | null + try { + const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'], execOptions) + authenticatedHost = authenticatedHostFromInventory(host, `${stdout}\n${stderr}`) + } catch (error) { + const output = ghCommandOutput(error) + if (!output) { + // Indeterminate (gh missing / spawn failure) — do not cache so a later + // probe (gh installed, tunnel ready, token added) can recover. + return undefined + } + // gh exits non-zero when a host has a token problem but still prints the + // per-host status; trust only hosts that are actually listed. + authenticatedHost = authenticatedHostFromInventory(host, output) + } + hostAuthCache.set(cacheKey, { + authenticatedHost, + expiresAt: Date.now() + HOST_AUTH_TTL_MS + }) + pruneHostAuthCache(Date.now()) + return authenticatedHost + })() + hostAuthInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (hostAuthInFlight.get(cacheKey) === probe) { + hostAuthInFlight.delete(cacheKey) + } + } +} + /** * Whether `gh` is authenticated to `host` from the repository's own runtime. * - * The probe runs `gh auth status --hostname ` with the repo's execution - * options (cwd / WSL distro, or SSH-local like the create path), so a GHES login - * stored only in that runtime's gh config — or a `GH_ENTERPRISE_TOKEN` inferred - * from it — is honored instead of the host/default-distro gh. Cached briefly per - * runtime+host so provider-detection polling does not re-spawn gh each time. + * The probe inventories gh's configured hosts and matches `host` locally. It + * deliberately does not pass an untrusted remote host to gh because ambient + * enterprise tokens could otherwise be sent to that host during validation. + * Cached briefly per runtime+host so provider-detection polling stays cheap. */ export async function isGitHubHostAuthenticated( host: string, @@ -67,52 +193,35 @@ export async function isGitHubHostAuthenticated( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const normalizedHost = host.toLowerCase() - const cacheKey = `${runtimeCacheKey(connectionId, localGitOptions.wslDistro)}\0${normalizedHost}` - const now = Date.now() - const cached = hostAuthCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return cached.authenticated - } - const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - let authenticated: boolean - try { - await ghExecFileAsync(['auth', 'status', '--hostname', normalizedHost], execOptions) - authenticated = true - } catch (error) { - const output = ghCommandOutput(error) - if (!output) { - // Indeterminate (gh missing / spawn failure) — do not cache so a later - // probe (gh installed, tunnel ready, token added) can recover. - return false - } - // gh exits non-zero when a host has a token problem but still prints the - // per-host status; treat the host as GitHub only when it is actually listed. - authenticated = parseAuthStatus(output).some( - (account) => account.host.toLowerCase() === normalizedHost - ) - } - hostAuthCache.set(cacheKey, { authenticated, expiresAt: now + HOST_AUTH_TTL_MS }) - return authenticated + return Boolean( + await resolveAuthenticatedGitHubHost(host, repoPath, connectionId, localGitOptions) + ) +} + +/** Safely validate a project-selected host without giving the untrusted host + * to gh. Global project calls have no repository cwd, so they use native gh. */ +export function isGitHubHostAuthenticatedForGlobalCli(host: string): Promise { + return isGitHubHostAuthenticated(host, '', 'project-host-validation') } /** - * Resolve owner/repo for a GitHub Enterprise Server `origin` remote — a custom - * host the user is gh-authenticated to. Returns null for github.com (already - * handled by {@link getOwnerRepo}) and for hosts gh is not logged in to + * Resolve owner/repo for a GitHub Enterprise Server remote — a custom host the + * user is gh-authenticated to. Returns null for github.com (already handled by + * {@link getOwnerRepo}) and for hosts gh is not logged in to * (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider * without a GitHub provider stealing another forge's remote. */ -export async function getEnterpriseGitHubRepoSlug( +export async function getEnterpriseGitHubRepoSlugForRemote( repoPath: string, + remoteName: string, connectionId?: string | null, options: HostedReviewExecutionOptions = {} -): Promise { +): Promise { const localGitOptions = getHostedReviewLocalGitOptions(options) const context = githubRepoContext(repoPath, connectionId, localGitOptions) let remoteUrl: string | null try { - remoteUrl = await getRemoteUrlForRepo(context, 'origin') + remoteUrl = await getRemoteUrlForRepo(context, remoteName) } catch { return null } @@ -120,11 +229,24 @@ export async function getEnterpriseGitHubRepoSlug( if (!identity || identity.host === 'github.com') { return null } - const authenticated = await isGitHubHostAuthenticated( + const authenticatedHost = await resolveAuthenticatedGitHubHost( identity.host, repoPath, connectionId, localGitOptions ) - return authenticated ? { owner: identity.owner, repo: identity.repo, host: identity.host } : null + if (authenticatedHost === undefined) { + return undefined + } + return authenticatedHost + ? { owner: identity.owner, repo: identity.repo, host: authenticatedHost } + : null +} + +export async function getEnterpriseGitHubRepoSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getEnterpriseGitHubRepoSlugForRemote(repoPath, 'origin', connectionId, options) } diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts new file mode 100644 index 00000000000..c0b8b599520 --- /dev/null +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing' + +describe('parseGitHubRemoteIdentity', () => { + it('parses a plain github.com https remote', () => { + expect(parseGitHubRemoteIdentity('https://github.com/team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('parses an SCP-style github.com remote', () => { + expect(parseGitHubRemoteIdentity('git@github.com:team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('preserves a custom port on a GHES https remote', () => { + // The port IS the Enterprise web/API endpoint, so gh must target + // ghe.acme.com:8443, not the portless hostname. + expect(parseGitHubRemoteIdentity('https://ghe.acme.com:8443/team/orca.git')).toEqual({ + host: 'ghe.acme.com:8443', + owner: 'team', + repo: 'orca' + }) + }) + + it('preserves a custom port on a GHES http remote', () => { + expect(parseGitHubRemoteIdentity('http://ghe.acme.com:8080/team/orca.git')).toEqual({ + host: 'ghe.acme.com:8080', + owner: 'team', + repo: 'orca' + }) + }) + + it('drops the default https port so github.com stays bare', () => { + // WHATWG URL omits default ports, so :443 never leaks into the host. + expect(parseGitHubRemoteIdentity('https://github.com:443/team/orca.git')?.host).toBe( + 'github.com' + ) + }) + + it('drops the default https port on a GHES host', () => { + expect(parseGitHubRemoteIdentity('https://ghe.acme.com:443/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('normalizes ssh.github.com:443 (SSH-over-HTTPS) to github.com without a port', () => { + // :443 here is the ssh transport port, not an endpoint — it must not survive. + expect(parseGitHubRemoteIdentity('ssh://git@ssh.github.com:443/team/orca.git')).toEqual({ + host: 'github.com', + owner: 'team', + repo: 'orca' + }) + }) + + it('drops a custom ssh transport port on a GHES ssh remote', () => { + // ssh://…:2222 is a transport port; gh routes by hostname, so keep only the host. + expect(parseGitHubRemoteIdentity('ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('drops the transport port for git+ssh and git remotes', () => { + expect(parseGitHubRemoteIdentity('git+ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + expect(parseGitHubRemoteIdentity('git://ghe.acme.com:9418/team/orca.git')?.host).toBe( + 'ghe.acme.com' + ) + }) + + it('lowercases the host while keeping the port', () => { + expect(parseGitHubRemoteIdentity('https://GHE.Acme.COM:8443/team/orca.git')?.host).toBe( + 'ghe.acme.com:8443' + ) + }) + + it('returns null for an unparseable remote', () => { + expect(parseGitHubRemoteIdentity('not-a-remote')).toBeNull() + }) +}) + +describe('parseGitHubOwnerRepo', () => { + it('returns owner/repo for github.com', () => { + expect(parseGitHubOwnerRepo('https://github.com/team/orca.git')).toEqual({ + owner: 'team', + repo: 'orca' + }) + }) + + it('returns null for a custom-port GHES remote (not github.com)', () => { + // GHES is handled by the enterprise resolver, not the github.com fast path, + // and the port must not make a github.com remote look like GHES either. + expect(parseGitHubOwnerRepo('https://ghe.acme.com:8443/team/orca.git')).toBeNull() + }) + + it('still recognizes github.com even with an explicit default port', () => { + expect(parseGitHubOwnerRepo('https://github.com:443/team/orca.git')).toEqual({ + owner: 'team', + repo: 'orca' + }) + }) +}) diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index e7bd21242f1..6f13e62014d 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -4,10 +4,17 @@ export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } function normalizeGitHubRemoteHost(host: string): string { const normalizedHost = host.toLowerCase() - // Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos. + // Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos. return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost } +// Why: HTTP ports identify the GHES web/API endpoint; SSH and git ports are +// transport-only and must not leak into gh's host identity. +function hostFromRemoteUrl(url: URL): string { + const protocol = url.protocol.toLowerCase() + return protocol === 'http:' || protocol === 'https:' ? url.host : url.hostname +} + function parseGitHubRemotePath(path: string): Pick | null { const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') if (parts.length !== 2) { @@ -34,7 +41,7 @@ export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdenti return null } const path = parseGitHubRemotePath(url.pathname) - return path ? { host: normalizeGitHubRemoteHost(url.hostname), ...path } : null + return path ? { host: normalizeGitHubRemoteHost(hostFromRemoteUrl(url)), ...path } : null } catch { return null } diff --git a/src/main/github/github-repository-identity.fork-owner-repo.test.ts b/src/main/github/github-repository-identity.fork-owner-repo.test.ts index 62750e02458..7613baaf8fd 100644 --- a/src/main/github/github-repository-identity.fork-owner-repo.test.ts +++ b/src/main/github/github-repository-identity.fork-owner-repo.test.ts @@ -128,7 +128,8 @@ describe('issue #7331: fork PR owner/repo resolution', () => { // fast-path would be skipped. const upstream = await getRepoUpstream(FORK_PATH) - expect(upstream).toEqual({ owner: 'stablyai', repo: 'orca' }) + // Why: origin resolution pins github.com so host-scoped execution is explicit. + expect(upstream).toEqual({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index 28b7b0676c7..1996d7b2b54 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -8,6 +8,7 @@ import { type GitHubRemoteIdentity } from './github-remote-identity-parsing' import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' export type OwnerRepo = GitHubOwnerRepo @@ -234,7 +235,7 @@ export type PRRepositoryCandidates = { } function ownerRepoKey(ownerRepo: OwnerRepo): string { - return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}` + return githubRepoIdentityKey(ownerRepo) } export async function resolvePRRepositoryCandidates( diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 0f65fc1572c..88d61c1bcc9 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository' import type * as GhUtils from './gh-utils' const { @@ -27,6 +29,45 @@ vi.mock('./gh-utils', async () => { } }) +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + const withDotComHost = (repo: T) => + repo ? { host: 'github.com' as const, ...repo } : repo + return { + ...actual, + // Why: these suites drive source resolution through the legacy gh-utils + // mocks; bridge the hosted seams onto the same mocks and pin github.com so + // host-less fixtures still pass resolveGitHubApiRepository's host gate. + getIssueGitHubApiRepository: async ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => withDotComHost(await getIssueOwnerRepoMock(repoPath, connectionId, localGitOptions)), + resolveIssueGitHubApiRepositorySource: async ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => { + const result = await resolveIssueSourceMock( + repoPath, + preference, + connectionId, + localGitOptions + ) + return { + ...result, + source: withDotComHost(result?.source) + } + } + } +}) + import { addIssueComment, createIssue, @@ -37,6 +78,14 @@ import { updateIssue } from './issues' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('issue source operations', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -69,7 +118,7 @@ describe('issue source operations', () => { await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '--cache', '300s', 'repos/stablyai/orca/issues/923'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -138,6 +187,38 @@ describe('issue source operations', () => { ) }) + it('routes PR conversation comments to the supplied Enterprise host', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 9, + user: { login: 'octo', avatar_url: '', type: 'User' }, + body: 'Enterprise comment', + created_at: '2026-07-16T00:00:00.000Z', + html_url: 'https://github.acme-corp.com/team/orca/pull/7#issuecomment-9' + }) + }) + + await expect( + addIssueComment('/remote/repo', 7, 'Enterprise comment', 'ssh-1', { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + ).resolves.toMatchObject({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '-X', + 'POST', + 'repos/team/orca/issues/7/comments', + '--raw-field', + 'body=Enterprise comment' + ], + expect.objectContaining({ host: 'github.acme-corp.com' }) + ) + }) + it('lists issues from the issue owner/repo', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) @@ -151,7 +232,7 @@ describe('issue source operations', () => { '120s', 'repos/stablyai/orca/issues?per_page=5&state=open&sort=updated&direction=desc' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -195,7 +276,7 @@ describe('issue source operations', () => { '--raw-field', 'body=Body' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -235,7 +316,7 @@ describe('issue source operations', () => { '--raw-field', 'assignees[]=octo' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -274,17 +355,17 @@ describe('issue source operations', () => { 'labels[]=bug', 'assignees[]=octo' ]), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining(['body=', 'title=Image issue', 'labels[]=bug', 'assignees[]=octo']), - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/926', '--raw-field', `body=${body}`], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -405,7 +486,7 @@ describe('issue source operations', () => { }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/924', '--raw-field', 'body=Updated body'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -430,17 +511,17 @@ describe('issue source operations', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) @@ -453,7 +534,7 @@ describe('issue source operations', () => { }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'reopen', '924', '--repo', 'stablyai/orca'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) }) }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index b0f3fa6cb89..45ed0b1a99e 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -15,8 +15,13 @@ import type { } from '../../shared/types' import { mapIssueInfo } from './mappers' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' +import { + getIssueGitHubApiRepository, + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource +} from './github-api-repository' // prettier-ignore -import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils' +import { ghExecFileAsync, acquire, release, classifyGhError, classifyListIssuesError, extractExecError } from './gh-utils' // Why: distinguishes a successful-empty listing from a failed fetch. The // previous `catch { return [] }` conflated a 403 on a private upstream with an @@ -54,9 +59,17 @@ export async function getIssue( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + connectionId, + localGitOptions + ) + // Why: a connection-backed request has no local cwd, so the non-GitHub + // fallback below would let gh target its default repository. Refuse instead. + if (connectionId && !ownerRepo) { + return null + } await acquire() try { if (ownerRepo) { @@ -104,14 +117,31 @@ export async function listIssues( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) + // Why: a connection-backed request has no local cwd, so the non-GitHub + // fallback below would let gh list its default repository. Refuse instead. + if (connectionId && !ownerRepo) { + return { + items: [], + error: { + type: 'not_found', + message: 'Could not resolve GitHub owner/repo for this repository' + } + } + } await acquire() try { if (ownerRepo) { @@ -173,11 +203,17 @@ export async function createIssue( if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) @@ -286,9 +322,12 @@ export async function updateIssue( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -409,10 +448,13 @@ export async function addIssueComment( ownerRepoOverride?: OwnerRepo | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const ownerRepo = - ownerRepoOverride ?? (await getIssueOwnerRepo(repoPath, connectionId, localGitOptions)) + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + ownerRepoOverride ?? + (() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)), + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -463,11 +505,17 @@ export async function listLabels( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) @@ -503,11 +551,17 @@ export async function listAssignableUsers( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource( + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - preference, + async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source, connectionId, localGitOptions ) diff --git a/src/main/github/merged-pr-commit-membership.ts b/src/main/github/merged-pr-commit-membership.ts index 0bb390f6495..7350416ca6f 100644 --- a/src/main/github/merged-pr-commit-membership.ts +++ b/src/main/github/merged-pr-commit-membership.ts @@ -1,5 +1,7 @@ import { ghExecFileAsync } from './gh-utils' -import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' +import { githubHostExecOptions } from './github-api-repository' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' import type { OwnerRepo } from './github-repository-identity' type GhExecOptions = Parameters[1] @@ -59,7 +61,8 @@ export async function isCommitPartOfMergedPR(args: { } const owner = args.ownerRepo.owner const repo = args.ownerRepo.repo - const cacheKey = `${owner.toLowerCase()}/${repo.toLowerCase()}#${args.prNumber}@${oid}` + const cacheKey = `${githubRepoIdentityKey(args.ownerRepo)}#${args.prNumber}@${oid}` + const ghOptions = { ...args.ghOptions, ...githubHostExecOptions(args.ownerRepo) } const now = Date.now() pruneMergedPRCommitMembershipCache(now) const cached = membershipCache.get(cacheKey) @@ -68,7 +71,7 @@ export async function isCommitPartOfMergedPR(args: { } // Why blocked stays unknown: hiding a transient branch match is safe, but // callers must not clear a durable linked PR when the probe never ran. - if (rateLimitGuard('core').blocked) { + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { return 'unknown' } try { @@ -79,20 +82,20 @@ export async function isCommitPartOfMergedPR(args: { // found (contained) or a short page proves absence (not-contained); only the // pathological all-full case up to the cap stays 'unknown'. for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) { - if (page > 1 && rateLimitGuard('core').blocked) { + if (page > 1 && repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { membershipCache.set(cacheKey, { value: 'unknown', expiresAt: now + MEMBERSHIP_ERROR_TTL_MS }) return 'unknown' } - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', `repos/${owner}/${repo}/commits/${oid}/pulls?per_page=${COMMIT_PULLS_PAGE_SIZE}&page=${page}` ], - args.ghOptions + ghOptions ) const parsed = JSON.parse(stdout) as unknown // Why: a non-array success payload is a shape mismatch, not an empty page; diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 5ee1a882a62..7298c36af6d 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -2,21 +2,28 @@ request timestamps, and follow-up scheduling against shared module state. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types' +import { isWslUncPath } from '../../shared/wsl-paths' const { sendMock, sendToTrustedUIRendererMock, getAllWebContentsMock, getPRForBranchOutcomeMock, + getOriginGitHubApiRepositoryMock, getRateLimitMock, - rateLimitGuardMock + noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuardMock, + spendsSharedGitHubComQuotaMock } = vi.hoisted(() => ({ sendMock: vi.fn(), sendToTrustedUIRendererMock: vi.fn(), getAllWebContentsMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), + getOriginGitHubApiRepositoryMock: vi.fn(), getRateLimitMock: vi.fn(), - rateLimitGuardMock: vi.fn() + noteRepositoryRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(), + spendsSharedGitHubComQuotaMock: vi.fn() })) vi.mock('electron', () => ({ @@ -29,10 +36,15 @@ vi.mock('./client', () => ({ getPRForBranchOutcome: getPRForBranchOutcomeMock })) +vi.mock('./github-api-repository', () => ({ + getOriginGitHubApiRepository: getOriginGitHubApiRepositoryMock +})) + vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, - noteRateLimitSpend: vi.fn(), - rateLimitGuard: rateLimitGuardMock + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock })) vi.mock('../ipc/ui', () => ({ @@ -94,9 +106,23 @@ describe('pr-refresh-coordinator', () => { }) getAllWebContentsMock.mockReset() getPRForBranchOutcomeMock.mockReset() + getOriginGitHubApiRepositoryMock.mockReset() + getOriginGitHubApiRepositoryMock.mockResolvedValue({ + owner: 'acme', + repo: 'widgets', + host: 'github.com' + }) getRateLimitMock.mockReset() - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + spendsSharedGitHubComQuotaMock.mockReset() + spendsSharedGitHubComQuotaMock.mockImplementation( + (repository: { host?: string } | null, options?: { cwd?: string; wslDistro?: string }) => + (!repository?.host || repository.host.toLowerCase() === 'github.com') && + !options?.wslDistro && + !(options?.cwd && isWslUncPath(options.cwd)) + ) getAllWebContentsMock.mockReturnValue([ { id: 1, @@ -569,6 +595,75 @@ describe('pr-refresh-coordinator', () => { expect(pausedEvents).toHaveLength(0) }) + it.each([ + { + scope: 'GitHub Enterprise', + repository: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, + repoPath: '/repo', + localGitOptions: undefined + }, + { + scope: 'WSL', + repository: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + repoPath: '/repo', + localGitOptions: { wslDistro: 'Ubuntu' } + }, + { + scope: 'implicit WSL UNC', + repository: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + repoPath: String.raw`\\wsl.localhost\Ubuntu\home\me\widgets`, + localGitOptions: undefined + } + ])('bypasses the shared budget for $scope background refreshes', async (testCase) => { + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + getOriginGitHubApiRepositoryMock.mockResolvedValue(testCase.repository) + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'no-pr', + fetchedAt: Date.now() + }) + const executionOptions = { + cwd: testCase.repoPath, + ...testCase.localGitOptions + } + + enqueuePRRefresh( + makeCandidate({ + repoPath: testCase.repoPath, + localGitOptions: testCase.localGitOptions + }), + 'active', + 80, + 1 + ) + + await vi.advanceTimersByTimeAsync(0) + + expect(getRateLimitMock).not.toHaveBeenCalled() + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + testCase.repository, + 'core', + executionOptions + ) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + testCase.repository, + 'graphql', + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + testCase.repository, + 'core', + 1, + executionOptions + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + testCase.repository, + 'graphql', + 1, + executionOptions + ) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + }) + it('does not consume active burst slots for rate-limit pauses', async () => { const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') // Why: keyed on drained items (one getRateLimit call each) rather than @@ -579,7 +674,7 @@ describe('pr-refresh-coordinator', () => { drainedItems += 1 return { ok: true } }) - rateLimitGuardMock.mockImplementation(() => + repositoryRateLimitGuardMock.mockImplementation(() => drainedItems <= 3 ? { blocked: true, remaining: 0, limit: 5000, resetAt: 61 } : { blocked: false } diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 592bea4870b..70b13ec2e69 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -9,7 +9,14 @@ import type { PRRefreshOutcome } from '../../shared/types' import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' -import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { getOriginGitHubApiRepository } from './github-api-repository' +import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { + getRateLimit, + noteRepositoryRateLimitSpend, + repositoryRateLimitGuard, + spendsSharedGitHubComQuota +} from './rate-limit' import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { sendToTrustedUIRenderer } from '../ipc/ui' @@ -728,11 +735,26 @@ async function drainQueue(): Promise { ) if (isBackground(next.reason)) { - // Why: the probe only warms rateLimitGuard's cache, so it must fail open — GHES with rate limiting disabled 404s every probe (#7553). - await getRateLimit() + const executionOptions = ghRepoExecOptions( + githubRepoContext( + next.candidate.repoPath, + next.candidate.connectionId, + next.candidate.localGitOptions + ) + ) + const repository = await getOriginGitHubApiRepository( + next.candidate.repoPath, + next.candidate.connectionId, + executionOptions + ) + // Why: only native github.com uses the singleton snapshot; scoped breakers protect GHES and WSL. + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + // Why: the probe only warms the cache, so failures must fail open (#7553). + await getRateLimit() + } const buckets = backgroundRefreshBuckets() const blockedGuard = buckets - .map((bucket) => rateLimitGuard(bucket)) + .map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions)) .find((guard) => guard.blocked) if (blockedGuard?.blocked) { const retryAt = blockedGuard.resetAt * 1000 @@ -755,7 +777,7 @@ async function drainQueue(): Promise { noteActiveStart(next) } for (const bucket of buckets) { - noteRateLimitSpend(bucket) + noteRepositoryRateLimitSpend(repository, bucket, 1, executionOptions) } } @@ -922,8 +944,16 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise } // Why: enforce the rate-limit gate so a stale renderer can't bypass it; refuse without spending quota until the later of the two cooldowns. + const manualExecutionOptions = ghRepoExecOptions( + githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) + ) + const manualRepository = await getOriginGitHubApiRepository( + candidate.repoPath, + candidate.connectionId, + manualExecutionOptions + ) const manualBlockedGuard = backgroundRefreshBuckets() - .map((bucket) => rateLimitGuard(bucket)) + .map((bucket) => repositoryRateLimitGuard(manualRepository, bucket, manualExecutionOptions)) .find((guard) => guard.blocked) const secondaryGateUntil = manualRetryGates.get(key) const gateUntil = Math.max( diff --git a/src/main/github/project-view-host-auth.test.ts b/src/main/github/project-view-host-auth.test.ts new file mode 100644 index 00000000000..f243c06ed45 --- /dev/null +++ b/src/main/github/project-view-host-auth.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + acquireMock, + releaseMock, + ghExecFileAsyncMock, + hostAuthenticatedMock, + noteRepositoryRateLimitSpendMock +} = vi.hoisted(() => ({ + acquireMock: vi.fn(), + releaseMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + hostAuthenticatedMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + acquire: acquireMock, + release: releaseMock +})) + +vi.mock('../git/runner', () => ({ + extractExecError: vi.fn(() => ({ stdout: '', stderr: '' })), + ghExecFileAsync: ghExecFileAsyncMock +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + isGitHubHostAuthenticatedForGlobalCli: hostAuthenticatedMock +})) + +import { runGraphql, runRest } from './project-view/internals' +import { _resetProjectViewCachesForTests, resolveProjectRef } from './project-view' + +describe('project view host authentication boundary', () => { + beforeEach(() => { + acquireMock.mockReset().mockResolvedValue(undefined) + releaseMock.mockReset() + ghExecFileAsyncMock.mockReset() + hostAuthenticatedMock.mockReset() + noteRepositoryRateLimitSpendMock.mockReset() + _resetProjectViewCachesForTests() + }) + + it('does not send GraphQL or REST requests to an unconfigured host', async () => { + hostAuthenticatedMock.mockResolvedValue(false) + + const [graphql, rest] = await Promise.all([ + runGraphql('query { viewer { login } }', {}, { host: 'evil.example.test' }), + runRest(['user'], undefined, 'core', { host: 'evil.example.test' }) + ]) + + expect(graphql).toMatchObject({ ok: false, error: { type: 'auth_required' } }) + expect(rest).toMatchObject({ ok: false, error: { type: 'auth_required' } }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(acquireMock).not.toHaveBeenCalled() + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + }) + + it('routes a configured Enterprise request to its selected host', async () => { + hostAuthenticatedMock.mockResolvedValue(true) + ghExecFileAsyncMock.mockResolvedValue({ + stdout: '{"data":{"viewer":{"login":"me"}}}', + stderr: '' + }) + + await expect( + runGraphql('query { viewer { login } }', {}, { host: 'github.corp.example' }) + ).resolves.toMatchObject({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(expect.any(Array), { + encoding: 'utf-8', + host: 'github.corp.example' + }) + }) + + it('keeps github.com fast and never probes it as Enterprise', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: '{"data":{"viewer":{"login":"me"}}}', + stderr: '' + }) + + await expect( + runGraphql('query { viewer { login } }', {}, { host: 'github.com' }) + ).resolves.toMatchObject({ ok: true }) + + expect(hostAuthenticatedMock).not.toHaveBeenCalled() + }) + + it('uses a pasted github.com URL instead of the ambient Enterprise host', async () => { + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + return query.includes('projectV2') + ? { + stdout: JSON.stringify({ + data: { organization: { projectV2: { id: 'PVT_7', title: 'Roadmap' } } } + }), + stderr: '' + } + : { + stdout: JSON.stringify({ data: { organization: { login: 'acme' } } }), + stderr: '' + } + }) + + await expect( + resolveProjectRef({ + input: 'https://github.com/orgs/acme/projects/7', + host: 'github.corp.example' + }) + ).resolves.toMatchObject({ ok: true, host: 'github.com' }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.every(([, options]) => options.host === 'github.com') + ).toBe(true) + expect(hostAuthenticatedMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index 00a8844bb34..1339c5c0fdc 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -68,6 +68,19 @@ describe('classifyProjectError', () => { it('classifies auth-required when gh is not signed in', () => { expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required') }) + + it('pins Enterprise auth and scope remediation to the selected host', () => { + expect( + classifyProjectError('gh auth login required', '', 'github.acme.test').message + ).toContain('gh auth login --hostname github.acme.test') + expect( + classifyProjectError( + 'your token has not been granted the required scopes', + '', + 'github.acme.test' + ).message + ).toContain('gh auth refresh --hostname github.acme.test') + }) }) describe('isValidOwnerSlug', () => { @@ -136,6 +149,7 @@ describe('parseProjectPaste', () => { kind: 'org', owner: 'acme', number: 42, + host: 'github.com', viewNumber: 3 }) }) @@ -144,7 +158,8 @@ describe('parseProjectPaste', () => { expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({ kind: 'user', owner: 'octocat', - number: 1 + number: 1, + host: 'github.com' }) }) @@ -152,6 +167,43 @@ describe('parseProjectPaste', () => { expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull() }) + it('accepts enterprise-host URLs only when that host is provided (GHES)', () => { + const url = 'https://github.corp.example/orgs/acme/projects/7/views/2' + expect(parseProjectPaste(url, 'github.corp.example')).toEqual({ + kind: 'org', + owner: 'acme', + number: 7, + host: 'github.corp.example', + viewNumber: 2 + }) + expect(parseProjectPaste(url)).toBeNull() + // github.com URLs still parse when a GHES host is supplied. + expect( + parseProjectPaste('https://github.com/orgs/acme/projects/7', 'github.corp.example') + ).toEqual({ kind: 'org', owner: 'acme', number: 7, host: 'github.com' }) + }) + + it('preserves a GHES custom port while parsing project URLs', () => { + expect( + parseProjectPaste( + 'https://github.corp.example:8443/orgs/acme/projects/7', + 'github.corp.example:8443' + ) + ).toEqual({ + kind: 'org', + owner: 'acme', + number: 7, + host: 'github.corp.example:8443' + }) + }) + + it('rejects credentials and paths that only begin like a Project URL', () => { + expect(parseProjectPaste('https://user:token@github.com/orgs/acme/projects/1')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1evil')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/views/2evil')).toBeNull() + expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/files')).toBeNull() + }) + it('returns null for empty input', () => { expect(parseProjectPaste('')).toBeNull() expect(parseProjectPaste(' ')).toBeNull() @@ -208,6 +260,13 @@ describe('project view owner caches', () => { expect(_getProjectViewOwnerTypeForTests('owner-1')).toBe('user') }) + it('shares owner type probes between implicit and explicit github.com hosts', () => { + _rememberProjectViewOwnerTypeForTests('acme', 'organization') + + expect(_getProjectViewOwnerTypeForTests('acme', 'github.com')).toBe('organization') + expect(_getProjectViewCacheSizesForTests().ownerTypes).toBe(1) + }) + it('LRU-evicts old parent-field retry and warning probes', () => { for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) { const scopeKey = `owner-${i}\u0000organization` diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 8e2be18044e..2e53eff357c 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -4,19 +4,23 @@ import { release, extractExecError, ghExecFileAsync, - rateLimitGuard, - noteRateLimitSpend, - classifyProjectError, - driftError, - errorsIndicateParentField, - rateLimitedError, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, runGraphql, isValidOwnerSlug, assertSlug, assertPositiveInt, - type GhGraphqlErrorShape, + projectHostAuthenticationError, + projectGhExecOptions, type GraphqlVars } from './project-view/internals' +import { + classifyProjectError, + driftError, + errorsIndicateParentField, + rateLimitedError, + type GhGraphqlErrorShape +} from './project-view/project-error-classification' import type { GetProjectViewTableArgs, GetProjectViewTableResult, @@ -36,6 +40,7 @@ import type { GitHubProjectViewError, GitHubProjectViewLayout, GitHubProjectViewSummary, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListProjectViewsArgs, ListProjectViewsResult, @@ -46,14 +51,11 @@ import { GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, isGitHubProjectRefInputTooLarge } from '../../shared/github-project-ref-input' +import { githubProjectHost } from '../../shared/github-project-identity' // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. -export { - isValidOwnerSlug, - isValidRepoSlug, - isValidSlug, - classifyProjectError -} from './project-view/internals' +export { isValidOwnerSlug, isValidRepoSlug, isValidSlug } from './project-view/internals' +export { classifyProjectError } from './project-view/project-error-classification' export { updateProjectItemFieldValue, clearProjectItemFieldValue, @@ -122,16 +124,32 @@ const parentFieldWarningLoggedByOwner = new Map() // Why: in-flight promise per owner so concurrent fetchAllItems callers share one probe instead of each racing a duplicate first-page probe. const parentFieldProbeInFlight = new Map>() -function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType): string { - return `${owner}\u0000${ownerType}` +// Why: GHES owners are a separate namespace and capability surface from +// github.com owners with the same login — scope cache keys by host so one +// host's probe result can't leak into another. Normalize github.com so +// host-less callers share the same probe state as explicitly pinned calls. +function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType, host?: string): string { + const base = `${owner}\u0000${ownerType}` + return `${base}\u0000${githubProjectHost(host)}` } -function rememberOwnerType(owner: string, ownerType: GitHubProjectOwnerType | null): void { - rememberProjectViewCacheEntry(ownerTypeCache, owner, ownerType) +function ownerTypeCacheKey(owner: string, host?: string): string { + return `${owner}\u0000${githubProjectHost(host)}` } -function getCachedOwnerType(owner: string): GitHubProjectOwnerType | null | undefined { - return getProjectViewCacheEntry(ownerTypeCache, owner) +function rememberOwnerType( + owner: string, + ownerType: GitHubProjectOwnerType | null, + host?: string +): void { + rememberProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host), ownerType) +} + +function getCachedOwnerType( + owner: string, + host?: string +): GitHubProjectOwnerType | null | undefined { + return getProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host)) } function markParentFieldRetried(scopeKey: string): void { @@ -174,16 +192,18 @@ export function _getProjectViewCacheSizesForTests(): { /** @internal - exposed for cache-bound tests only. */ export function _rememberProjectViewOwnerTypeForTests( owner: string, - ownerType: GitHubProjectOwnerType | null + ownerType: GitHubProjectOwnerType | null, + host?: string ): void { - rememberOwnerType(owner, ownerType) + rememberOwnerType(owner, ownerType, host) } /** @internal - exposed for cache-bound tests only. */ export function _getProjectViewOwnerTypeForTests( - owner: string + owner: string, + host?: string ): GitHubProjectOwnerType | null | undefined { - return getCachedOwnerType(owner) + return getCachedOwnerType(owner, host) } /** @internal - exposed for cache-bound tests only. */ @@ -618,6 +638,7 @@ async function fetchProjectViewsPage(args: { owner: string ownerType: GitHubProjectOwnerType projectNumber: number + host?: string after: string | null }): Promise< | { @@ -662,7 +683,8 @@ async function fetchProjectViewsPage(args: { } const res = await runGraphql>( query, - vars + vars, + projectGhExecOptions(args.host) ) if (!res.ok) { return res @@ -685,7 +707,8 @@ async function fetchProjectViewsPage(args: { async function fetchViewFieldsContinuation( viewId: string, - after: string + after: string, + host?: string ): Promise< { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } > { @@ -715,7 +738,7 @@ async function fetchViewFieldsContinuation( nodes?: (RawProjectV2Field | null)[] } } | null - }>(query, { viewId, after: cursor }) + }>(query, { viewId, after: cursor }, projectGhExecOptions(host)) if (!res.ok) { return res } @@ -823,6 +846,7 @@ async function fetchItemsPageWithRaw(args: { first: number after: string | null includeParent: boolean + host?: string }): Promise< | { ok: true; page: RawItemsPage } | { @@ -832,6 +856,10 @@ async function fetchItemsPageWithRaw(args: { stderr: string } > { + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError, rawErrors: [], stderr: '' } + } const root = ownerQueryRoot(args.ownerType) const afterArg = args.after ? `, after: $after` : '' const afterVar = args.after ? `$after:String!, ` : '' @@ -864,7 +892,9 @@ async function fetchItemsPageWithRaw(args: { argsArr.push('-f', `after=${args.after}`) } - const guard = rateLimitGuard('graphql') + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard(args, 'graphql') if (guard.blocked) { return { ok: false, @@ -874,13 +904,16 @@ async function fetchItemsPageWithRaw(args: { } } await acquire() - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(args, 'graphql') try { let stdout = '' let stderr = '' let execFailed = false try { - const r = await ghExecFileAsync(argsArr, { encoding: 'utf-8' }) + const r = await ghExecFileAsync(argsArr, { + encoding: 'utf-8', + ...projectGhExecOptions(args.host) + }) stdout = r.stdout stderr = r.stderr } catch (err) { @@ -897,7 +930,7 @@ async function fetchItemsPageWithRaw(args: { if (execFailed) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: [], stderr } @@ -913,7 +946,7 @@ async function fetchItemsPageWithRaw(args: { if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: [], stderr } @@ -921,7 +954,7 @@ async function fetchItemsPageWithRaw(args: { if (parsed.errors && parsed.errors.length > 0) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, args.host), rawErrors: parsed.errors, stderr } @@ -947,13 +980,14 @@ async function fetchAllItems(args: { ownerType: GitHubProjectOwnerType projectNumber: number query: string + host?: string }): Promise< | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } | { ok: false; error: GitHubProjectViewError; totalCount?: number } > { - // Why: keyed by (owner, ownerType) so one owner's missing Issue.parent capability doesn't poison unrelated owners. - const scopeKey = ownerScopeKey(args.owner, args.ownerType) - // Why: await any in-flight same-owner probe so we don't duplicate it, then re-read the retried flag (the probe may have flipped it). + // Why: isolate missing Issue.parent capability by owner, type, and host. + const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host) + // Why: await the same-scope probe, then re-read state because it may have changed. const inFlight = parentFieldProbeInFlight.get(scopeKey) if (inFlight) { await inFlight.catch(() => {}) @@ -978,7 +1012,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent: true + includeParent: true, + host: args.host }) // Why: set the retried flag BEFORE resolving/clearing the probe so siblings awoken on inFlight.catch() see it and don't fire duplicate with-parent probes. if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { @@ -999,7 +1034,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent + includeParent, + host: args.host }) } if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { @@ -1020,7 +1056,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: null, - includeParent: false + includeParent: false, + host: args.host }) } if (!first.ok) { @@ -1087,7 +1124,8 @@ async function fetchAllItems(args: { query: args.query, first: ITEM_PAGE_SIZE, after: cursor as string, - includeParent + includeParent, + host: args.host }) if (!next.ok) { return { ok: false, error: next.error, totalCount } @@ -1126,6 +1164,7 @@ async function fetchItemsCountOnly(args: { ownerType: GitHubProjectOwnerType projectNumber: number query: string + host?: string }): Promise { const root = ownerQueryRoot(args.ownerType) const query = ` @@ -1139,7 +1178,11 @@ async function fetchItemsCountOnly(args: { ` const res = await runGraphql< Record - >(query, { owner: args.owner, num: args.projectNumber, q: args.query }) + >( + query, + { owner: args.owner, num: args.projectNumber, q: args.query }, + projectGhExecOptions(args.host) + ) if (!res.ok) { return null } @@ -1178,6 +1221,7 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, + host: args.host, after: cursor }) if (!page.ok) { @@ -1225,7 +1269,7 @@ export async function getProjectViewTable( let extraFields: RawProjectV2Field[] = [] const fieldsPi = selectedRaw.fields?.pageInfo if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) { - const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor) + const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor, args.host) if (!cont.ok) { return { ok: false, error: cont.error } } @@ -1248,7 +1292,8 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, - query: effectiveQuery + query: effectiveQuery, + host: args.host }) return { ok: false, @@ -1265,7 +1310,8 @@ export async function getProjectViewTable( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, - query: effectiveQuery + query: effectiveQuery, + host: args.host }) if (!items.ok) { return { @@ -1278,6 +1324,7 @@ export async function getProjectViewTable( const table: GitHubProjectTable = { project: { id: project.id, + host: githubProjectHost(args.host), owner: args.owner, ownerType: args.ownerType, number: args.projectNumber, @@ -1320,7 +1367,10 @@ type RawViewerDiscovery = { } } -export async function listAccessibleProjects(): Promise { +export async function listAccessibleProjects( + args?: ListAccessibleProjectsArgs +): Promise { + const host = githubProjectHost(args?.host) const viewerProjects: GitHubProjectSummary[] = [] const orgProjects: GitHubProjectSummary[] = [] // Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504. @@ -1352,7 +1402,7 @@ export async function listAccessibleProjects(): Promise(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(host)) if (!res.ok) { // Why: viewer-level failure is structural (no projects to build on), so propagate hard; org-level errors below are non-fatal. return { ok: false, error: res.error } @@ -1373,6 +1423,7 @@ export async function listAccessibleProjects(): Promise(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(host)) if (!res.ok) { // Why: org-listing failed; record a synthetic '*' partial failure so the banner explains it, but keep collected viewer projects (the reported 504 path). partialFailures.push({ owner: '*', message: res.error.message }) @@ -1434,8 +1485,8 @@ export async function listAccessibleProjects(): Promise= 1 - ? { viewNumber } - : {}) + host: url.host.toLowerCase(), + ...(viewNumber !== undefined ? { viewNumber } : {}) } + } catch { + // Shorthand parsing below remains available for non-URL input. } // owner/number shorthand — owner alphabet matches OWNER_SLUG_RE. const shortRe = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/ @@ -1526,7 +1593,8 @@ export function parseProjectPaste(input: string): ParsedPaste | null { async function resolveOwnerType( owner: string, - preferred: GitHubProjectOwnerType | null + preferred: GitHubProjectOwnerType | null, + host?: string ): Promise< | { ok: true; ownerType: GitHubProjectOwnerType; title: string } | { ok: false; error: GitHubProjectViewError } @@ -1554,7 +1622,7 @@ async function resolveOwnerType( } const res = await runGraphql< Record - >(query, vars) + >(query, vars, projectGhExecOptions(host)) if (!res.ok) { return { ok: false, error: res.error } } @@ -1572,7 +1640,7 @@ async function resolveOwnerType( return { ok: true, title: '' } } - const cached = getCachedOwnerType(owner) + const cached = getCachedOwnerType(owner, host) const candidates: GitHubProjectOwnerType[] = preferred ? [preferred] : cached @@ -1590,7 +1658,7 @@ async function resolveOwnerType( for (const ot of ordered) { const r = await tryOne(ot, null) if (r.ok) { - rememberOwnerType(owner, ot) + rememberOwnerType(owner, ot, host) return { ok: true, ownerType: ot, title: r.title } } lastError = r.error @@ -1599,7 +1667,7 @@ async function resolveOwnerType( return { ok: false, error: r.error } } } - rememberOwnerType(owner, null) + rememberOwnerType(owner, null, host) return { ok: false, error: lastError ?? { type: 'not_found', message: 'Owner not found.' } @@ -1622,7 +1690,7 @@ export async function resolveProjectRef( error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } } } - const parsed = parseProjectPaste(input) + const parsed = parseProjectPaste(input, args.host) if (!parsed) { return { ok: false, @@ -1634,8 +1702,11 @@ export async function resolveProjectRef( } const preferred: GitHubProjectOwnerType | null = parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null + // Why: a pasted URL is authoritative. The ambient host only applies to + // owner/number shorthand; otherwise same-number Projects can cross hosts. + const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host // Verify by fetching project title. - const ownerRes = await resolveOwnerType(parsed.owner, preferred) + const ownerRes = await resolveOwnerType(parsed.owner, preferred, executionHost) if (!ownerRes.ok) { return { ok: false, error: ownerRes.error } } @@ -1648,7 +1719,7 @@ export async function resolveProjectRef( ` const res = await runGraphql< Record - >(query, { owner: parsed.owner, num: parsed.number }) + >(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(executionHost)) if (!res.ok) { return { ok: false, error: res.error } } @@ -1662,7 +1733,8 @@ export async function resolveProjectRef( ownerType, number: parsed.number, title: p.title ?? '', - // Why: forward the view number from /views/{n} URLs so the renderer can skip the view-pick step (bare owner/number shorthand carries none). + host: executionHost, + // Why: forward URL view numbers so the renderer can skip view selection; bare shorthand has none. ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined ? { viewNumber: parsed.viewNumber } : {}) @@ -1692,6 +1764,7 @@ export async function listProjectViews( owner: args.owner, ownerType: args.ownerType, projectNumber: args.projectNumber, + host: args.host, after: cursor }) if (!page.ok) { diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 586bbaa6e95..5952118ee4c 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -4,12 +4,60 @@ // translation, and a single hook point for future quota tracking. import { acquire, release } from '../gh-utils' import { extractExecError, ghExecFileAsync } from '../../git/runner' -import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit' +import { + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, + type RateLimitBucketKind +} from '../rate-limit' import type { GitHubProjectViewError } from '../../../shared/github-project-types' +import { githubProjectHost } from '../../../shared/github-project-identity' +import { isDefaultGitHubHost } from '../../../shared/github-repository-identity-key' +import { isGitHubHostAuthenticatedForGlobalCli } from '../github-enterprise-repository' +import { + classifyProjectError, + driftError, + rateLimitedError, + type GhGraphqlErrorShape +} from './project-error-classification' -export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend } +export { + acquire, + release, + extractExecError, + ghExecFileAsync, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend +} export type { RateLimitBucketKind } +/** gh exec routing shared by the project-view read/write paths. `host` pins + * GHES requests to the enterprise server via the runner's host qualifier. */ +export type ProjectGhExecOptions = { cwd?: string; host?: string } + +// Why: owner/project-addressed calls carry only a host (no repo slug), so they +// can't reuse githubHostExecOptions — mirror its host pinning for that shape. +export function projectGhExecOptions(host: string | undefined): ProjectGhExecOptions { + return { host: githubProjectHost(host) } +} + +export async function projectHostAuthenticationError( + host: string | undefined +): Promise { + const selectedHost = githubProjectHost(host) + if ( + isDefaultGitHubHost(selectedHost) || + (await isGitHubHostAuthenticatedForGlobalCli(selectedHost)) + ) { + return null + } + // Why: never target an unconfigured pasted host with gh; ambient + // GH_ENTERPRISE_TOKEN credentials could otherwise be sent to that server. + return { + type: 'auth_required', + message: `GitHub CLI is not authenticated to ${selectedHost}. Run gh auth login --hostname ${selectedHost}.` + } +} + // ─── Slug validation ────────────────────────────────────────────────── // Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names @@ -88,176 +136,6 @@ export function validateSlugArgs( return { ok: true } } -// ─── Error classification ────────────────────────────────────────────── - -export type GhGraphqlErrorShape = { - type?: string - message?: string - path?: (string | number)[] - extensions?: { code?: string } -} - -export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { - // `gh api graphql` prints the response JSON to stdout even on GraphQL - // errors, and the stderr carries a summary. Try stdout first; if parsing - // fails, fall back to stderr. - const sources = [stdout, stderr] - for (const src of sources) { - if (!src) { - continue - } - try { - const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } - if (parsed.errors && parsed.errors.length > 0) { - return parsed.errors - } - } catch { - // not JSON — continue - } - } - return [] -} - -export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean { - const lower = stderr.toLowerCase() - // Preview-header shape: gh returns a 4xx with "preview" in the message. - if (lower.includes('preview') && lower.includes('parent')) { - return true - } - return errors.some((e) => { - const type = (e.type ?? '').toUpperCase() - if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { - const tail = e.path?.at(-1) - if (tail === 'parent') { - return true - } - // FIELD_ERRORS often omits `path`; match on message for the parent field. - if ((e.message ?? '').toLowerCase().includes('parent')) { - return true - } - } - return false - }) -} - -export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError { - const errors = extractGraphqlErrors(stderr, stdout) - const s = stderr.toLowerCase() - - // Auth - if ( - s.includes('authentication required') || - s.includes('not logged in') || - s.includes('gh auth login') - ) { - return { - type: 'auth_required', - message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.' - } - } - // Scope - if ( - s.includes('missing required scope') || - s.includes('your token has not been granted') || - (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) - ) { - return { - type: 'scope_missing', - message: - 'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.' - } - } - // Rate limit - if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { - return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } - } - // Network — checked BEFORE not_found because DNS failures surface as - // "could not resolve host", which would otherwise be partially matched by - // the not_found branch's "could not resolve" check. Substring matching here - // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" - // error always contains "to a", so we tighten the not_found check below to - // require that token. - if ( - s.includes('timeout') || - s.includes('no such host') || - s.includes('network') || - s.includes('could not resolve host') || - s.includes('dial tcp') - ) { - return { type: 'network_error', message: 'Network error — check your connection.' } - } - // Not found - if ( - s.includes('http 404') || - errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || - // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to - // an Organization") and "to a" otherwise. The previous singular-only - // check missed the "an" variants when gh emits only the stderr summary - // without a structured GraphQL error array. See bug-scan finding 3. - /could not resolve to an? /.test(s) - ) { - const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') - return { - type: 'not_found', - message: 'Project or view not found.', - details: firstNotFound - ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } - : undefined - } - } - // Validation - if (s.includes('http 422') || s.includes('validation failed')) { - return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } - } - // GraphQL error with structured info - if (errors.length > 0) { - const first = errors[0] - return { - type: 'unknown', - message: first.message ?? 'Unknown GraphQL error.', - details: { path: first.path, code: first.extensions?.code } - } - } - // Why: don't leak full stderr to the UI — it can include verbose request - // dumps with header diagnostics. Truncate to the first non-empty line and - // cap length so unexpected diagnostics stay readable but bounded. - const firstLine = - stderr - .split('\n') - .map((l) => l.trim()) - .find((l) => l.length > 0) ?? '' - const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine - return { - type: 'unknown', - message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' - } -} - -export function driftError( - reason: string, - details?: { path?: (string | number)[]; code?: string } -): GitHubProjectViewError { - return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } -} - -// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` -// when the cached snapshot says we're below the safety floor. Synthesize the -// same `rate_limited` error shape as the post-hoc classifier so the UI path -// is unchanged. We DO NOT fail open here when there's no cached snapshot — -// rateLimitGuard already handles that case (returns `blocked:false`). -export function rateLimitedError(blocked: { - remaining: number - limit: number - resetAt: number -}): GitHubProjectViewError { - const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) - const mins = Math.ceil(resetIn / 60) - return { - type: 'rate_limited', - message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` - } -} - // ─── Low-level gh api graphql invocation ─────────────────────────────── export type GraphqlVars = Record @@ -265,12 +143,18 @@ export type GraphqlVars = Record export async function runGraphql( query: string, vars: GraphqlVars, - cwd?: string + exec?: ProjectGhExecOptions ): Promise< | { ok: true; data: T } | { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } } > { - const guard = rateLimitGuard('graphql') + const authError = await projectHostAuthenticationError(exec?.host) + if (authError) { + return { ok: false, error: authError, raw: { stderr: '', stdout: '' } } + } + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard({ host: exec?.host }, 'graphql') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } } } @@ -286,18 +170,19 @@ export async function runGraphql( } } await acquire() - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend({ host: exec?.host }, 'graphql') try { const { stdout, stderr } = await ghExecFileAsync(args, { encoding: 'utf-8', - ...(cwd ? { cwd } : {}) + ...(exec?.cwd ? { cwd: exec.cwd } : {}), + ...(exec?.host ? { host: exec.host } : {}) }) try { const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] } if (parsed.errors && parsed.errors.length > 0) { return { ok: false, - error: classifyProjectError(stderr, stdout), + error: classifyProjectError(stderr, stdout, exec?.host), raw: { stderr, stdout } } } @@ -324,7 +209,7 @@ export async function runGraphql( const { stderr, stdout: maybeStdout } = extractExecError(err) return { ok: false, - error: classifyProjectError(stderr, maybeStdout), + error: classifyProjectError(stderr, maybeStdout, exec?.host), raw: { stderr, stdout: maybeStdout } } } finally { @@ -336,18 +221,25 @@ export async function runRest( args: string[], cwd?: string, bucket: RateLimitBucketKind = 'core', - options?: { expectEmpty?: boolean } + options?: { expectEmpty?: boolean; host?: string } ): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> { - const guard = rateLimitGuard(bucket) + const authError = await projectHostAuthenticationError(options?.host) + if (authError) { + return { ok: false, error: authError } + } + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard({ host: options?.host }, bucket) if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() - noteRateLimitSpend(bucket) + noteRepositoryRateLimitSpend({ host: options?.host }, bucket) try { const { stdout, stderr } = await ghExecFileAsync(['api', ...args], { encoding: 'utf-8', - ...(cwd ? { cwd } : {}) + ...(cwd ? { cwd } : {}), + ...(options?.host ? { host: options.host } : {}) }) // Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no // body. Treat empty stdout as success rather than misclassifying the @@ -367,7 +259,7 @@ export async function runRest( } } catch (err) { const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + return { ok: false, error: classifyProjectError(stderr, maybeStdout, options?.host) } } finally { release() } diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts index 9d8cabca7f8..30a00a0f110 100644 --- a/src/main/github/project-view/mutations.test.ts +++ b/src/main/github/project-view/mutations.test.ts @@ -1,9 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { acquireMock, ghExecFileAsyncMock, releaseMock, runRestMock } = vi.hoisted(() => ({ +const { + acquireMock, + ghExecFileAsyncMock, + noteRepositoryRateLimitSpendMock, + projectHostAuthenticationErrorMock, + repositoryRateLimitGuardMock, + releaseMock, + runGraphqlMock, + runRestMock +} = vi.hoisted(() => ({ acquireMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn(), + projectHostAuthenticationErrorMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn(), releaseMock: vi.fn(), + runGraphqlMock: vi.fn(), runRestMock: vi.fn() })) @@ -17,9 +30,13 @@ vi.mock('./internals', () => ({ ghExecFileAsync: ghExecFileAsyncMock, rateLimitGuard: () => ({ blocked: false }), noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + projectHostAuthenticationError: projectHostAuthenticationErrorMock, + projectGhExecOptions: (host?: string) => (host ? { host } : {}), classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }), rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }), - runGraphql: vi.fn(), + runGraphql: runGraphqlMock, runRest: runRestMock, validateSlugArgs: (owner: string, repo: string) => owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } }, @@ -29,12 +46,15 @@ vi.mock('./internals', () => ({ : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } })) -import { updateIssueBySlug } from './mutations' +import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations' describe('updateIssueBySlug', () => { beforeEach(() => { acquireMock.mockReset().mockResolvedValue(undefined) ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + noteRepositoryRateLimitSpendMock.mockReset() + projectHostAuthenticationErrorMock.mockReset().mockResolvedValue(null) + repositoryRateLimitGuardMock.mockReset().mockReturnValue({ blocked: false }) releaseMock.mockReset() runRestMock.mockReset().mockResolvedValue({ ok: true, data: {} }) }) @@ -60,16 +80,68 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(runRestMock).not.toHaveBeenCalled() }) + it('rejects an unconfigured Enterprise host before spawning gh', async () => { + projectHostAuthenticationErrorMock.mockResolvedValue({ + type: 'auth_required', + message: 'GitHub host is not authenticated' + }) + + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + host: 'evil.example.test', + number: 12, + updates: { state: 'closed' } + }) + ).resolves.toMatchObject({ ok: false, error: { type: 'auth_required' } }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('guards and accounts state mutations in the selected host quota scope', async () => { + const args = { + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example', + number: 12, + updates: { state: 'closed' as const } + } + + await expect(updateIssueBySlug(args)).resolves.toEqual({ ok: true }) + + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(args, 'core') + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith(args, 'core') + }) + + it('does not spawn a state mutation while that host quota is blocked', async () => { + repositoryRateLimitGuardMock.mockReturnValue({ blocked: true }) + + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + host: 'github.corp.example', + number: 12, + updates: { state: 'closed' } + }) + ).resolves.toMatchObject({ ok: false, error: { type: 'rate_limited' } }) + + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('closes slug-addressed duplicate issues with --duplicate-of', async () => { await expect( updateIssueBySlug({ @@ -82,7 +154,7 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } ) expect(runRestMock).not.toHaveBeenCalled() }) @@ -115,7 +187,68 @@ describe('updateIssueBySlug', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['issue', 'reopen', '12', '--repo', 'acme/widgets'], - { encoding: 'utf-8' } + { encoding: 'utf-8', host: 'github.com' } + ) + }) + + it('threads the GHES host into gh exec and REST options', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + host: 'github.corp.example', + updates: { state: 'closed', stateReason: 'completed', title: 'New title' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], + { encoding: 'utf-8', host: 'github.corp.example' } + ) + expect(runRestMock).toHaveBeenCalledWith( + ['-X', 'PATCH', 'repos/acme/widgets/issues/12', '--raw-field', 'title=New title'], + undefined, + 'core', + { host: 'github.corp.example' } + ) + }) +}) + +describe('getWorkItemDetailsBySlug', () => { + beforeEach(() => { + runGraphqlMock.mockReset() + }) + + it('threads the GHES host into the GraphQL exec options', async () => { + runGraphqlMock.mockResolvedValue({ + ok: true, + data: { + repository: { + issue: { + id: 'I_1', + number: 7, + title: 'Enterprise issue', + url: 'https://github.corp.example/acme/widgets/issues/7', + state: 'OPEN' + } + } + } + }) + + const result = await getWorkItemDetailsBySlug({ + owner: 'acme', + repo: 'widgets', + number: 7, + type: 'issue', + host: 'github.corp.example' + }) + + expect(result.ok).toBe(true) + expect(runGraphqlMock).toHaveBeenCalledWith( + expect.any(String), + { owner: 'acme', repo: 'widgets', num: 7 }, + { host: 'github.corp.example' } ) }) }) diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index e2da3dae068..453a695baec 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -6,16 +6,18 @@ import { release, extractExecError, ghExecFileAsync, - rateLimitGuard, - noteRateLimitSpend, - classifyProjectError, - rateLimitedError, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, runGraphql, runRest, validateSlugArgs, assertPositiveInt, + projectHostAuthenticationError, + projectGhExecOptions, type GraphqlVars } from './internals' +import { classifyProjectError, rateLimitedError } from './project-error-classification' +import { githubProjectHost } from '../../../shared/github-project-identity' import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types' import type { AddIssueCommentBySlugArgs, @@ -39,6 +41,10 @@ import type { UpdateProjectItemFieldArgs } from '../../../shared/github-project-types' +function githubHostExecOptions(args: { host?: string }): { host: string } { + return { host: githubProjectHost(args.host) } +} + // ─── Project field mutations ────────────────────────────────────────── class UnknownFieldMutationKindError extends Error { @@ -122,7 +128,7 @@ export async function updateProjectItemFieldValue( fieldId: args.fieldId, value: valVar.val } - const res = await runGraphql(query, vars) + const res = await runGraphql(query, vars, projectGhExecOptions(args.host)) if (!res.ok) { return { ok: false, error: res.error } } @@ -144,11 +150,15 @@ export async function clearProjectItemFieldValue( }) { projectV2Item { id } } } ` - const res = await runGraphql(query, { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId - }) + const res = await runGraphql( + query, + { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId + }, + projectGhExecOptions(args.host) + ) if (!res.ok) { return { ok: false, error: res.error } } @@ -207,6 +217,10 @@ export async function updateIssueBySlug( return { ok: false, error: duplicate.error } } } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } // Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}. // State uses gh issue close/reopen so duplicate closes can record a target. @@ -214,6 +228,10 @@ export async function updateIssueBySlug( const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` if (state !== undefined) { + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } const stateArgs = state === 'closed' ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] @@ -228,11 +246,12 @@ export async function updateIssueBySlug( } } await acquire() + noteRepositoryRateLimitSpend(args, 'core') try { - await ghExecFileAsync(stateArgs, { encoding: 'utf-8' }) + await ghExecFileAsync(stateArgs, { encoding: 'utf-8', ...githubHostExecOptions(args) }) } catch (err) { const { stderr, stdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, stdout) } + return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } } finally { release() } @@ -247,7 +266,7 @@ export async function updateIssueBySlug( if (body !== undefined) { patchArgs.push('--raw-field', `body=${body}`) } - const r = await runRest(patchArgs) + const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -263,7 +282,12 @@ export async function updateIssueBySlug( const addCount = addLabels?.length ?? 0 if (removeCount > 1) { type RawLabelResp = { name?: string }[] - const fetched = await runRest(['-X', 'GET', `${base}/labels`]) + const fetched = await runRest( + ['-X', 'GET', `${base}/labels`], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!fetched.ok) { return { ok: false, error: fetched.error } } @@ -282,7 +306,8 @@ export async function updateIssueBySlug( // dedicated DELETE endpoint is the documented way to remove all // labels in a single call. const r = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { - expectEmpty: true + expectEmpty: true, + ...githubHostExecOptions(args) }) if (!r.ok && r.error.type !== 'not_found') { return { ok: false, error: r.error } @@ -292,7 +317,7 @@ export async function updateIssueBySlug( for (const name of currentNames) { putArgs.push('--raw-field', `labels[]=${name}`) } - const r = await runRest(putArgs) + const r = await runRest(putArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -303,7 +328,7 @@ export async function updateIssueBySlug( for (const l of addLabels ?? []) { restArgs.push('--raw-field', `labels[]=${l}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -313,7 +338,7 @@ export async function updateIssueBySlug( ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`], undefined, 'core', - { expectEmpty: true } + { expectEmpty: true, ...githubHostExecOptions(args) } ) if (!r.ok && r.error.type !== 'not_found') { return { ok: false, error: r.error } @@ -328,7 +353,7 @@ export async function updateIssueBySlug( for (const u of addAssignees) { restArgs.push('--raw-field', `assignees[]=${u}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -338,7 +363,7 @@ export async function updateIssueBySlug( for (const u of removeAssignees) { restArgs.push('--raw-field', `assignees[]=${u}`) } - const r = await runRest(restArgs) + const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -384,7 +409,7 @@ export async function updatePullRequestBySlug( // No fields to update — nothing to do. return { ok: true } } - const r = await runRest(patchArgs) + const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) if (!r.ok) { return { ok: false, error: r.error } } @@ -425,13 +450,18 @@ export async function addIssueCommentBySlug( if (typeof args.body !== 'string' || !args.body.trim()) { return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } } - const r = await runRest([ - '-X', - 'POST', - `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, - '--raw-field', - `body=${args.body}` - ]) + const r = await runRest( + [ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!r.ok) { return { ok: false, error: r.error } } @@ -452,13 +482,18 @@ export async function updateIssueCommentBySlug( if (typeof args.body !== 'string' || !args.body.trim()) { return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } } - const r = await runRest([ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, - '--raw-field', - `body=${args.body}` - ]) + const r = await runRest( + [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + githubHostExecOptions(args) + ) if (!r.ok) { return { ok: false, error: r.error } } @@ -480,7 +515,7 @@ export async function deleteIssueCommentBySlug( ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], undefined, 'core', - { expectEmpty: true } + { expectEmpty: true, ...githubHostExecOptions(args) } ) if (!r.ok) { return { ok: false, error: r.error } @@ -497,18 +532,22 @@ export async function listLabelsBySlug( if (!v.ok) { return v } - const guard = rateLimitGuard('core') + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const guard = repositoryRateLimitGuard(args, 'core') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() // Why: `--paginate` may fan out to multiple pages; we can only reasonably // estimate a 1-call spend up front. The next probe will reconcile. - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args, 'core') try { const { stdout } = await ghExecFileAsync( ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], - { encoding: 'utf-8' } + { encoding: 'utf-8', ...githubHostExecOptions(args) } ) return { ok: true, @@ -519,7 +558,7 @@ export async function listLabelsBySlug( } } catch (err) { const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + return { ok: false, error: classifyProjectError(stderr, maybeStdout, args.host) } } finally { release() } @@ -532,15 +571,19 @@ export async function listAssignableUsersBySlug( if (!v.ok) { return v } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } // Seed logins merge after the fetch so callers can include currently-visible // assignees even if the repo participant search is sparse. const result: GitHubAssignableUser[] = [] - const guard = rateLimitGuard('core') + const guard = repositoryRateLimitGuard(args, 'core') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() - noteRateLimitSpend('core') + noteRepositoryRateLimitSpend(args, 'core') try { const { stdout } = await ghExecFileAsync( [ @@ -550,7 +593,7 @@ export async function listAssignableUsersBySlug( '--jq', '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' ], - { encoding: 'utf-8' } + { encoding: 'utf-8', ...githubHostExecOptions(args) } ) for (const line of stdout .trim() @@ -567,7 +610,7 @@ export async function listAssignableUsersBySlug( } } catch (err) { const { stderr } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, '') } + return { ok: false, error: classifyProjectError(stderr, '', args.host) } } finally { release() } @@ -614,7 +657,7 @@ export async function listIssueTypesBySlug( } | null)[] } | null } | null - }>(query, { owner: args.owner, repo: args.repo }) + }>(query, { owner: args.owner, repo: args.repo }, githubHostExecOptions(args)) if (!res.ok) { // Why: repos without issue types respond with a GraphQL error claiming the // `issueTypes` field is unknown. Map that to an empty list so the UI shows @@ -659,7 +702,8 @@ export async function updateIssueTypeBySlug( `query($owner:String!, $repo:String!, $num:Int!) { repository(owner:$owner, name:$repo) { issue(number:$num) { id } } }`, - { owner: args.owner, repo: args.repo, num: args.number } + { owner: args.owner, repo: args.repo, num: args.number }, + githubHostExecOptions(args) ) if (!lookup.ok) { return { ok: false, error: lookup.error } @@ -689,7 +733,7 @@ export async function updateIssueTypeBySlug( const vars: GraphqlVars = args.issueTypeId ? { issueId, issueTypeId: args.issueTypeId } : { issueId } - const res = await runGraphql(query, vars) + const res = await runGraphql(query, vars, githubHostExecOptions(args)) if (!res.ok) { return { ok: false, error: res.error } } @@ -810,7 +854,7 @@ export async function getWorkItemDetailsBySlug( }) | null } | null - }>(query, { owner: args.owner, repo: args.repo, num: args.number }) + }>(query, { owner: args.owner, repo: args.repo, num: args.number }, githubHostExecOptions(args)) if (!res.ok) { return { ok: false, error: res.error } } diff --git a/src/main/github/project-view/project-error-classification.ts b/src/main/github/project-view/project-error-classification.ts new file mode 100644 index 00000000000..1468552b345 --- /dev/null +++ b/src/main/github/project-view/project-error-classification.ts @@ -0,0 +1,177 @@ +// Why: turns raw gh stderr/stdout into the typed GitHubProjectViewError the +// renderer can act on (auth vs scope vs rate limit vs drift), shared by the +// project-view read and mutation paths. +import type { GitHubProjectViewError } from '../../../shared/github-project-types' +import { githubProjectHost } from '../../../shared/github-project-identity' + +export type GhGraphqlErrorShape = { + type?: string + message?: string + path?: (string | number)[] + extensions?: { code?: string } +} + +export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { + // `gh api graphql` prints the response JSON to stdout even on GraphQL + // errors, and the stderr carries a summary. Try stdout first; if parsing + // fails, fall back to stderr. + const sources = [stdout, stderr] + for (const src of sources) { + if (!src) { + continue + } + try { + const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return parsed.errors + } + } catch { + // not JSON — continue + } + } + return [] +} + +export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean { + const lower = stderr.toLowerCase() + // Preview-header shape: gh returns a 4xx with "preview" in the message. + if (lower.includes('preview') && lower.includes('parent')) { + return true + } + return errors.some((e) => { + const type = (e.type ?? '').toUpperCase() + if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { + const tail = e.path?.at(-1) + if (tail === 'parent') { + return true + } + // FIELD_ERRORS often omits `path`; match on message for the parent field. + if ((e.message ?? '').toLowerCase().includes('parent')) { + return true + } + } + return false + }) +} + +export function classifyProjectError( + stderr: string, + stdout: string, + host?: string +): GitHubProjectViewError { + const errors = extractGraphqlErrors(stderr, stdout) + const s = stderr.toLowerCase() + const selectedHost = githubProjectHost(host) + + // Auth + if ( + s.includes('authentication required') || + s.includes('not logged in') || + s.includes('gh auth login') + ) { + return { + type: 'auth_required', + message: `Sign in to GitHub to load project tasks. Run \`gh auth login --hostname ${selectedHost}\`.` + } + } + // Scope + if ( + s.includes('missing required scope') || + s.includes('your token has not been granted') || + (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) + ) { + return { + type: 'scope_missing', + message: `GitHub project access needs additional scopes. Run \`gh auth refresh --hostname ${selectedHost} -s project -s read:org -s repo\`.` + } + } + // Rate limit + if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { + return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } + } + // Network — checked BEFORE not_found because DNS failures surface as + // "could not resolve host", which would otherwise be partially matched by + // the not_found branch's "could not resolve" check. Substring matching here + // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" + // error always contains "to a", so we tighten the not_found check below to + // require that token. + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') || + s.includes('dial tcp') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + // Not found + if ( + s.includes('http 404') || + errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || + // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to + // an Organization") and "to a" otherwise. The previous singular-only + // check missed the "an" variants when gh emits only the stderr summary + // without a structured GraphQL error array. See bug-scan finding 3. + /could not resolve to an? /.test(s) + ) { + const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') + return { + type: 'not_found', + message: 'Project or view not found.', + details: firstNotFound + ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } + : undefined + } + } + // Validation + if (s.includes('http 422') || s.includes('validation failed')) { + return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } + } + // GraphQL error with structured info + if (errors.length > 0) { + const first = errors[0] + return { + type: 'unknown', + message: first.message ?? 'Unknown GraphQL error.', + details: { path: first.path, code: first.extensions?.code } + } + } + // Why: don't leak full stderr to the UI — it can include verbose request + // dumps with header diagnostics. Truncate to the first non-empty line and + // cap length so unexpected diagnostics stay readable but bounded. + const firstLine = + stderr + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? '' + const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine + return { + type: 'unknown', + message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' + } +} + +export function driftError( + reason: string, + details?: { path?: (string | number)[]; code?: string } +): GitHubProjectViewError { + return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } +} + +// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` +// when the cached snapshot says we're below the safety floor. Synthesize the +// same `rate_limited` error shape as the post-hoc classifier so the UI path +// is unchanged. We DO NOT fail open here when there's no cached snapshot — +// rateLimitGuard already handles that case (returns `blocked:false`). +export function rateLimitedError(blocked: { + remaining: number + limit: number + resetAt: number +}): GitHubProjectViewError { + const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) + const mins = Math.ceil(resetIn / 60) + return { + type: 'rate_limited', + message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` + } +} diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts index ab1373a70f0..323f729ea26 100644 --- a/src/main/github/rate-limit.test.ts +++ b/src/main/github/rate-limit.test.ts @@ -13,7 +13,21 @@ vi.mock('./gh-utils', () => ({ release: vi.fn() })) -import { getRateLimit, rateLimitGuard, _resetRateLimitCache } from './rate-limit' +import { + getRateLimit, + rateLimitGuard, + noteRateLimitSpend, + spendsSharedGitHubComQuota, + repositoryRateLimitGuard, + noteRepositoryRateLimitSpend, + _resetRateLimitCache +} from './rate-limit' +import { + clearGhRateLimitBlock, + getGhRateLimitBlockedUntilMs, + notifyGhPrimaryRateLimit, + type GhRateLimitBucket +} from '../git/gh-rate-limit-breaker' const PAYLOAD = JSON.stringify({ resources: { @@ -49,12 +63,17 @@ describe('getRateLimit', () => { fetchedAt: 1_000 } }) + // The runner injects --hostname at spawn from the `host` option, so the + // probe passes bare argv and pins the host to github.com through options. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com' + }) }) it('caches a failed probe for the TTL instead of re-spawning gh', async () => { - // Why: regression for #7553 — GHES with rate limiting disabled fails every - // probe. Refreshes fail open past the failure, so without a negative cache - // each queued refresh would spawn its own doomed gh subprocess. + // Why: refreshes fail open past a probe failure, so without a negative cache + // an auth or transport failure would spawn the same doomed gh call repeatedly. ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) const first = await getRateLimit() @@ -136,3 +155,212 @@ describe('rateLimitGuard', () => { expect(rateLimitGuard('core')).toEqual({ blocked: false }) }) }) + +// Why: only default github.com traffic on the native runtime shares this +// snapshot's budget. GHES hosts and WSL distros run against separate quotas, +// so the shared guard must bypass them entirely. +describe('shared-quota host scoping', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 3, reset: resetEpochSeconds }, + search: { limit: 30, remaining: 28, reset: resetEpochSeconds }, + graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds } + } + }) + }) + expect((await getRateLimit()).ok).toBe(true) + } + + it('treats github.com / no host on the native runtime as sharing the budget', () => { + expect(spendsSharedGitHubComQuota(undefined)).toBe(true) + expect(spendsSharedGitHubComQuota(null)).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'github.com' })).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'GitHub.com' })).toBe(true) + }) + + it('excludes GHES hosts and WSL runtimes from the shared budget', () => { + expect(spendsSharedGitHubComQuota({ host: 'github.acme-corp.com' })).toBe(false) + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { wslDistro: 'Ubuntu' })).toBe(false) + expect(spendsSharedGitHubComQuota(undefined, { wslDistro: 'Ubuntu' })).toBe(false) + }) + + it('treats a WSL UNC cwd as non-shared even without an explicit wslDistro', () => { + // Why: the runner derives the WSL distro from a \\wsl.localhost\... cwd, so + // gh runs inside WSL and spends that distro's quota — not the native one. + expect( + spendsSharedGitHubComQuota( + { host: 'github.com' }, + { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } + ) + ).toBe(false) + expect( + spendsSharedGitHubComQuota( + { host: 'github.com' }, + { cwd: '\\\\wsl$\\Ubuntu\\home\\me\\repo' } + ) + ).toBe(false) + // Regular Windows/POSIX cwds keep sharing the native github.com budget. + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: 'C:\\repos\\a' })).toBe(true) + expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: '/Users/me/repo' })).toBe(true) + }) + + it('bypasses the guard and spend for a WSL UNC cwd', async () => { + await cacheExhaustedSnapshot(61) + + const cwd = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core', { cwd })).toEqual({ + blocked: false + }) + + noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core', 1, { cwd }) + const guard = rateLimitGuard('core') + expect(guard.blocked && guard.remaining).toBe(3) + }) + + it('applies the shared guard for github.com but bypasses non-shared scopes', async () => { + await cacheExhaustedSnapshot(61) + + expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core')).toEqual({ + blocked: true, + remaining: 3, + limit: 5000, + resetAt: 61 + }) + expect(repositoryRateLimitGuard({ host: 'github.acme-corp.com' }, 'core')).toEqual({ + blocked: false + }) + expect( + repositoryRateLimitGuard({ host: 'github.com' }, 'core', { wslDistro: 'Ubuntu' }) + ).toEqual({ blocked: false }) + }) + + it('debits shared-budget spend but no-ops for non-shared scopes', async () => { + await cacheExhaustedSnapshot(61) + + noteRepositoryRateLimitSpend({ host: 'github.acme-corp.com' }, 'core') + const afterEnterprise = rateLimitGuard('core') + expect(afterEnterprise.blocked && afterEnterprise.remaining).toBe(3) + + noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core') + const afterShared = rateLimitGuard('core') + expect(afterShared.blocked && afterShared.remaining).toBe(2) + }) + + it('matches noteRateLimitSpend when the scope is shared github.com', async () => { + await cacheExhaustedSnapshot(61) + noteRateLimitSpend('core') + const guard = rateLimitGuard('core') + expect(guard.blocked && guard.remaining).toBe(2) + }) +}) + +// Why: the breaker's reset probe must query the tripping scope's runtime/host +// (WSL distro, GHES host), record into that scope only, and never touch the +// shared native-github.com snapshot. +describe('scope-aware breaker reset probe', () => { + const WSL_SCOPE = 'wsl:ubuntu:github.com' + const GHES_SCOPE = 'native:github.acme-corp.com' + const ALL_BUCKETS: GhRateLimitBucket[] = ['core', 'search', 'graphql'] + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + // Clear per-scope breaker state without unregistering the module-load + // probe (which _resetGhRateLimitBreaker would drop for later tests). + for (const scope of [WSL_SCOPE, GHES_SCOPE]) { + for (const bucket of ALL_BUCKETS) { + clearGhRateLimitBlock(bucket, scope) + } + } + vi.useRealTimers() + }) + + async function flushProbe(): Promise { + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + } + + it('refines a WSL-scope trip via a probe matching that scope', async () => { + // resetAt 601s > the 5-minute fallback so the refinement is observable. + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 0, reset: 601 }, + search: { limit: 30, remaining: 20, reset: 601 }, + graphql: { limit: 5000, remaining: 100, reset: 601 } + } + }) + }) + + notifyGhPrimaryRateLimit('core', WSL_SCOPE) + // Single-flight: a concurrent second trip must not spawn a second probe. + notifyGhPrimaryRateLimit('core', WSL_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com', + wslDistro: 'ubuntu' + }) + // The exhausted core bucket is refined to the real reset for THAT scope. + expect(getGhRateLimitBlockedUntilMs('core', 1_000, WSL_SCOPE)).toBe(601_000) + // Non-exhausted buckets in the same scope stay clear. + expect(getGhRateLimitBlockedUntilMs('search', 1_000, WSL_SCOPE)).toBeNull() + // The shared native-github.com snapshot was not written: the guard has no + // cached data and fails open. + expect(rateLimitGuard('core')).toEqual({ blocked: false }) + expect(getGhRateLimitBlockedUntilMs('core', 1_000)).toBeNull() + }) + + it('fails open on a GHES probe failure and negative-caches the scope', async () => { + ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) + + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + // A second trip inside the failure TTL must not spawn another probe. + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + // Fail open: the blunt fallback block stands (5 minutes from trip time). + expect(getGhRateLimitBlockedUntilMs('core', 1_000, GHES_SCOPE)).toBe(301_000) + + // Past the TTL the scope is probed again (and can recover on success). + vi.setSystemTime(1_000 + 30_000) + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + resources: { core: { limit: 5000, remaining: 4999, reset: 700 } } + }) + }) + notifyGhPrimaryRateLimit('core', GHES_SCOPE) + await flushProbe() + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenLastCalledWith(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.acme-corp.com' + }) + // Budget is actually available in that scope — the block is cleared. + expect(getGhRateLimitBlockedUntilMs('core', 31_000, GHES_SCOPE)).toBeNull() + }) +}) diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index 1b77198db68..2ca3f5cad43 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -9,11 +9,15 @@ import type { GitHubRateLimitBucket, GitHubRateLimitSnapshot } from '../../shared/types' +import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' +import { isWslUncPath } from '../../shared/wsl-paths' import { acquire, release } from './gh-utils' import { ghExecFileAsync } from '../git/runner' import { clearGhRateLimitBlock, getGhRateLimitBlockedUntilMs, + ghRateLimitScopeKey, + parseGhRateLimitScopeKey, recordGhPrimaryRateLimit, registerGhRateLimitResetProbe, type GhRateLimitBucket @@ -54,6 +58,8 @@ function parseBucket( export function _resetRateLimitCache(): void { cached = null probeFailure = null + scopeProbeFailureAtMs.clear() + scopeRefinementInFlight.clear() } // Circuit-breaker floors: enough budget for one user flow; search paginates by 1, so 2 leaves a safety click under the 30/min cap. @@ -120,6 +126,41 @@ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void } } +// Why: this snapshot covers native github.com only; GHES and WSL use quotas protected by the runner's scoped breaker. +export function spendsSharedGitHubComQuota( + repository: { host?: string } | null | undefined, + executionOptions?: { wslDistro?: string; cwd?: string } +): boolean { + if (!isDefaultGitHubHost(repository?.host) || executionOptions?.wslDistro) { + return false + } + // Why: a \\wsl.localhost\... (or \\wsl$\...) cwd makes the runner spawn gh + // inside WSL, so it spends that distro's quota. The UNC shape is + // platform-unambiguous, so no win32 gate is needed. + return !(executionOptions?.cwd && isWslUncPath(executionOptions.cwd)) +} + +export function repositoryRateLimitGuard( + repository: { host?: string } | null | undefined, + bucket: RateLimitBucketKind, + executionOptions?: { wslDistro?: string; cwd?: string } +): ReturnType { + return spendsSharedGitHubComQuota(repository, executionOptions) + ? rateLimitGuard(bucket) + : { blocked: false } +} + +export function noteRepositoryRateLimitSpend( + repository: { host?: string } | null | undefined, + bucket: RateLimitBucketKind, + cost = 1, + executionOptions?: { wslDistro?: string; cwd?: string } +): void { + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + noteRateLimitSpend(bucket, cost) + } +} + // Why: the breaker only knows "blocked", not the reset time; one exempt probe refines it to the real reset or clears a stale block (single-flight so a 403 burst probes once). let resetRefinementInFlight: Promise | null = null @@ -147,7 +188,88 @@ function refineBreakerFromSnapshot(): void { })() } -registerGhRateLimitResetProbe(() => refineBreakerFromSnapshot()) +const DEFAULT_BREAKER_SCOPE = ghRateLimitScopeKey('native', 'github.com') +// Why: non-default scopes (GHES hosts, WSL runtimes) have their own quota and +// their own probe lifecycle — single-flight and failure negative-cache are +// keyed per scope so a GHES 403 storm can't starve a WSL refinement (or vice +// versa). +const scopeRefinementInFlight = new Map>() +const scopeProbeFailureAtMs = new Map() +const SCOPE_PROBE_FAILURE_MAX_ENTRIES = 512 + +function rememberScopeProbeFailure(scope: string, failedAt: number): void { + for (const [key, at] of scopeProbeFailureAtMs) { + if (failedAt - at >= RATE_LIMIT_CACHE_TTL_MS) { + scopeProbeFailureAtMs.delete(key) + } + } + scopeProbeFailureAtMs.delete(scope) + scopeProbeFailureAtMs.set(scope, failedAt) + while (scopeProbeFailureAtMs.size > SCOPE_PROBE_FAILURE_MAX_ENTRIES) { + const oldestKey = scopeProbeFailureAtMs.keys().next().value + if (oldestKey === undefined) { + break + } + scopeProbeFailureAtMs.delete(oldestKey) + } +} + +function refineBreakerForScope(scope: string): void { + // Default scope keeps the existing shared-snapshot refinement path. + if (scope === DEFAULT_BREAKER_SCOPE) { + refineBreakerFromSnapshot() + return + } + const parts = parseGhRateLimitScopeKey(scope) + if (!parts || scopeRefinementInFlight.has(scope)) { + return + } + // Why: GHES with rate limiting disabled 404s every probe. Fail open (the + // fallback block stands) and don't re-probe in a tight loop while the + // breaker keeps tripping. + const failedAt = scopeProbeFailureAtMs.get(scope) + if (failedAt !== undefined && Date.now() - failedAt < RATE_LIMIT_CACHE_TTL_MS) { + return + } + const probe = (async () => { + try { + await acquire() + try { + // Run the probe on the tripping scope's runtime/host so it describes + // that account's buckets, not the native github.com ones. + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { + encoding: 'utf-8', + host: parts.host, + ...(parts.runtime === 'wsl' ? { wslDistro: parts.wslDistro } : {}) + }) + const parsed = JSON.parse(stdout) as GhRateLimitPayload + scopeProbeFailureAtMs.delete(scope) + // Why: mirrors the default-scope refinement, but records into the + // per-scope breaker only — the shared snapshot must keep describing + // native github.com exclusively. + for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) { + const b = parseBucket(parsed.resources?.[bucket]) + if (b.limit > 0 && b.remaining <= 0) { + recordGhPrimaryRateLimit(bucket, b.resetAt * 1000, scope) + } else if (b.limit > 0) { + clearGhRateLimitBlock(bucket, scope) + } + } + } finally { + release() + } + } catch { + // Why: GHES hosts can be supplied through pasted project URLs. Bound the + // negative cache so repeated failing hosts cannot accumulate forever. + rememberScopeProbeFailure(scope, Date.now()) + } finally { + scopeRefinementInFlight.delete(scope) + } + })() + scopeRefinementInFlight.set(scope, probe) +} + +registerGhRateLimitResetProbe((_bucket, scope) => refineBreakerForScope(scope)) // Why: single-flight so a concurrent fan-out resolves to one probe — the TTL cache can't dedupe calls that start before the first lands. let probeInFlight: Promise | null = null @@ -176,7 +298,12 @@ export async function getRateLimit(options?: { force?: boolean }): Promise { await acquire() try { - const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' }) + // Why: this singleton snapshot guards native github.com traffic. Pin the + // host so a process-level GH_HOST cannot make it describe a GHES account. + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { + encoding: 'utf-8', + host: 'github.com' + }) const parsed = JSON.parse(stdout) as GhRateLimitPayload const snapshot: GitHubRateLimitSnapshot = { core: parseBucket(parsed.resources?.core), diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts new file mode 100644 index 00000000000..3fdbacb6491 --- /dev/null +++ b/src/main/github/work-item-details-concurrency.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + acquireMock, + releaseMock, + ghExecFileAsyncMock, + getWorkItemMock, + getPRCommentsMock, + getPRChecksMock +} = vi.hoisted(() => ({ + acquireMock: vi.fn<() => Promise>(), + releaseMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getWorkItemMock: vi.fn(), + getPRCommentsMock: vi.fn(), + getPRChecksMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + acquire: acquireMock, + release: releaseMock, + ghExecFileAsync: ghExecFileAsyncMock, + ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }), + githubRepoContext: (repoPath: string, connectionId?: string | null) => ({ + repoPath, + connectionId: connectionId ?? null + }) +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getPRComments: getPRCommentsMock, + getPRChecks: getPRChecksMock +})) + +vi.mock('./github-api-repository', () => ({ + getIssueGitHubApiRepository: vi.fn(), + getOriginGitHubApiRepository: vi.fn(), + githubHostExecOptions: (repository?: { host?: string } | null) => + repository?.host ? { host: repository.host } : {}, + // Why: getWorkItemDetails awaits resolveGitHubRepoExecution and reads .ownerRepo. + resolveGitHubRepoExecution: vi.fn( + async ( + _repoPath: string, + repository?: { owner: string; repo: string; host?: string } | null + ) => ({ + ownerRepo: repository ?? { owner: 'acme', repo: 'widgets', host: 'github.com' }, + ghOptions: { + cwd: '/repo', + host: repository?.host ?? 'github.com' + } + }) + ) +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn() +})) + +import { getWorkItemDetails } from './work-item-details' + +describe('getWorkItemDetails concurrency', () => { + beforeEach(() => { + let running = 0 + const queue: (() => void)[] = [] + acquireMock.mockReset() + acquireMock.mockImplementation(() => { + if (running < 4) { + running += 1 + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running += 1 + resolve() + }) + ) + }) + releaseMock.mockReset() + releaseMock.mockImplementation(() => { + running -= 1 + queue.shift()?.() + }) + + getWorkItemMock.mockReset() + getWorkItemMock.mockImplementation(async (_repoPath: string, number: number) => ({ + id: `pr:${number}`, + type: 'pr', + number, + title: `PR ${number}`, + state: 'open', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: null, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + })) + + const withNestedPermit = async (value: T): Promise => { + await acquireMock() + try { + return value + } finally { + releaseMock() + } + } + getPRCommentsMock.mockReset() + getPRCommentsMock.mockImplementation(() => withNestedPermit([])) + getPRChecksMock.mockReset() + getPRChecksMock.mockImplementation(() => withNestedPermit([])) + + ghExecFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_id', + files: { pageInfo: { hasNextPage: false }, nodes: [] } + } + } + } + }) + } + } + if (query.includes('participants')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint.includes('/files?')) { + return { stdout: '[]' } + } + if (/\/pulls\/\d+$/.test(endpoint)) { + return { + stdout: JSON.stringify({ body: 'body', head: { sha: 'head' }, base: { sha: 'base' } }) + } + } + return { stdout: JSON.stringify({ data: {} }) } + }) + }) + + it('does not deadlock four concurrent PR loads on nested client permits', async () => { + const details = await Promise.all( + [1, 2, 3, 4].map((number) => getWorkItemDetails('/repo', number, 'pr')) + ) + + expect(details.map((detail) => detail?.item.number)).toEqual([1, 2, 3, 4]) + expect(acquireMock.mock.calls.length).toBeGreaterThan(4) + expect(releaseMock).toHaveBeenCalledTimes(acquireMock.mock.calls.length) + }) +}) diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts new file mode 100644 index 00000000000..aca6e4a7c1c --- /dev/null +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RateLimitGuardResult = + | { blocked: false } + | { blocked: true; remaining: number; limit: number; resetAt: number } + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, + getWorkItemMock, + getWorkItemByOwnerRepoMock, + getPRChecksMock, + getPRCommentsMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCommentsMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: 'core' | 'graphql' | 'search', + options?: { cwd?: string; host?: string; wslDistro?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getOwnerRepoForRemote: vi.fn(), + ghRepoExecOptions: vi.fn((context: { connectionId?: string | null; repoPath: string }) => + context.connectionId ? {} : { cwd: context.repoPath } + ), + githubRepoContext: vi.fn((repoPath: string, connectionId?: string | null) => ({ + repoPath, + connectionId: connectionId ?? null + })), + acquire: vi.fn().mockResolvedValue(undefined), + release: vi.fn() +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, + getPRChecks: getPRChecksMock, + getPRComments: getPRCommentsMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +import { getWorkItemDetails } from './work-item-details' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + +describe('getWorkItemDetails Enterprise host routing', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() + getPRChecksMock.mockReset() + getPRCommentsMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() + }) + + it('uses the GitHub Enterprise host for SSH-backed issue work item details', async () => { + const enterpriseRepository = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:7', + type: 'issue', + number: 7, + title: 'Enterprise issue', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/issues/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'issue-author' + }) + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterpriseRepository) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('comments(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Enterprise issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } + } + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/issues/7/timeline?per_page=100&page=1') { + return { stdout: '' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1') + + expect(details?.body).toBe('Enterprise issue body') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(enterpriseRepository, 'graphql', { + host: 'github.acme-corp.com' + }) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalled() + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + }) + + it('does not query the default host when an SSH issue work item is not found', async () => { + getWorkItemMock.mockResolvedValue(null) + + await expect(getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')).resolves.toBeNull() + + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('uses the GitHub Enterprise host for SSH-backed PR work item details', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:7', + type: 'pr', + number: 7, + title: 'Enterprise PR files', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/pulls/7') { + return { + stdout: JSON.stringify({ + body: 'Enterprise PR body', + head: { sha: 'head-sha' }, + base: { sha: 'base-sha' } + }) + } + } + if (endpoint === 'repos/team/orca/pulls/7/files?per_page=100') { + return { + stdout: JSON.stringify([ + { + filename: 'src/enterprise.ts', + status: 'modified', + additions: 2, + deletions: 1, + changes: 3, + patch: '@@ -1 +1 @@' + } + ]) + } + } + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_enterprise', + files: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ path: 'src/enterprise.ts', viewerViewedState: 'VIEWED' }] + } + } + } + } + }) + } + } + if (query.includes('participants(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1') + + expect(details?.body).toBe('Enterprise PR body') + expect(details?.headSha).toBe('head-sha') + expect(details?.baseSha).toBe('base-sha') + expect(details?.filesUnavailable).toBe(false) + expect(details?.files).toEqual([ + { + path: 'src/enterprise.ts', + oldPath: undefined, + status: 'modified', + additions: 2, + deletions: 1, + isBinary: false, + reviewCommentLineNumbers: [], + viewerViewedState: 'VIEWED' + } + ]) + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(getPRCommentsMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + { prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } }, + 'ssh-1' + ) + expect(getPRChecksMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + 'head-sha', + { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + undefined, + 'ssh-1' + ) + const apiCalls = ghExecFileAsyncMock.mock.calls + .map(([args]) => args as string[]) + .filter((args) => args[0] === 'api') + expect(apiCalls.length).toBeGreaterThan(0) + expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true) + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + }) + + it('does not query the default host when an SSH PR work item is not found', async () => { + getWorkItemMock.mockResolvedValue(null) + + await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull() + + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('surfaces a found SSH PR without any default-host gh call when the repository is unresolved', async () => { + // Why: this is the regression under test — a *found* PR whose owner/repo + // cannot be resolved to an Enterprise host must not silently fall back to + // github.com. With no prRepo, origin resolution runs and returns null. + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:7', + type: 'pr', + number: 7, + title: 'Unresolved Enterprise PR', + state: 'open', + url: 'https://github.acme-corp.com/team/orca/pull/7', + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author' + }) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + + const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1') + + // The item is still surfaced, but no host-qualified (or default-host) gh + // request is made because the execution repository never resolved. + expect(details?.item.id).toBe('pr:7') + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts index a1ec6824aaa..6075803493c 100644 --- a/src/main/github/work-item-details-file-viewed.test.ts +++ b/src/main/github/work-item-details-file-viewed.test.ts @@ -13,6 +13,8 @@ const { getPRCommentsMock, rateLimitGuardMock, noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, acquireMock, releaseMock } = vi.hoisted(() => ({ @@ -24,13 +26,29 @@ const { getPRCommentsMock: vi.fn(), rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + options?: { cwd?: string; host?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: + vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + cost?: number, + options?: { cwd?: string; host?: string } + ) => void + >(), acquireMock: vi.fn(), releaseMock: vi.fn() })) vi.mock('./gh-utils', () => ({ ghExecFileAsync: ghExecFileAsyncMock, - getOwnerRepo: getOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, ghRepoExecOptions: vi.fn((context) => (context.connectionId ? {} : { cwd: context.repoPath })), githubRepoContext: vi.fn((repoPath, connectionId) => ({ @@ -43,17 +61,28 @@ vi.mock('./gh-utils', () => ({ vi.mock('./client', () => ({ getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: vi.fn(), getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock })) vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) import { getWorkItemDetails } from './work-item-details' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('getWorkItemDetails PR file viewed state', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() @@ -65,6 +94,9 @@ describe('getWorkItemDetails PR file viewed state', () => { rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -162,10 +194,26 @@ describe('getWorkItemDetails PR file viewed state', () => { '/repo-root', 42, 'head-sha', - null, + { owner: 'stablyai', repo: 'orca', host: 'github.com' }, undefined, undefined ) + expect(repositoryRateLimitGuardMock.mock.calls.length).toBeGreaterThan(0) + expect( + repositoryRateLimitGuardMock.mock.calls.every(([, bucket, options]) => + bucket === 'graphql' + ? options?.cwd === '/repo-root' && options?.host === 'github.com' + : true + ) + ).toBe(true) + expect(noteRepositoryRateLimitSpendMock.mock.calls.length).toBeGreaterThan(0) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.every(([, bucket, , options]) => + bucket === 'graphql' + ? options?.cwd === '/repo-root' && options?.host === 'github.com' + : true + ) + ).toBe(true) }) it('keeps PR details loadable when checks lookup fails', async () => { diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts new file mode 100644 index 00000000000..cac3c33eaf4 --- /dev/null +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RateLimitGuardResult = + | { blocked: false } + | { blocked: true; remaining: number; limit: number; resetAt: number } + +const { + ghExecFileAsyncMock, + getEnterpriseGitHubRepoSlugMock, + getOwnerRepoMock, + getWorkItemMock, + getPRChecksMock, + getPRCommentsMock, + noteRepositoryRateLimitSpendMock, + repositoryRateLimitGuardMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getWorkItemMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCommentsMock: vi.fn(), + noteRepositoryRateLimitSpendMock: + vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + cost?: number, + options?: { cwd?: string; host?: string } + ) => void + >(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: string, + options?: { cwd?: string; host?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })) +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepoForRemote: getOwnerRepoMock, + getIssueOwnerRepo: vi.fn(), + ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })), + githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + })), + acquire: vi.fn(), + release: vi.fn() +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: vi.fn(), + getPRChecks: getPRChecksMock, + getPRComments: getPRCommentsMock +})) + +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRateLimitSpend: vi.fn(), + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock +})) + +import { getPRFileContents, getWorkItemDetails } from './work-item-details' + +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + +function pullRequestItem(number: number, title: string): Record { + return { + id: `pr:${number}`, + type: 'pr', + number, + title, + state: 'open', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: '2026-07-16T00:00:00Z', + author: 'pr-author' + } +} + +function auxiliaryPRResponse(args: string[]): { stdout: string } { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_file_list', + files: { pageInfo: { hasNextPage: false }, nodes: [] } + } + } + } + }) + } + } + if (query.includes('participants(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + return { stdout: JSON.stringify({ data: {} }) } +} + +describe('getWorkItemDetails PR file listing', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getWorkItemMock.mockReset() + getPRChecksMock.mockReset() + getPRChecksMock.mockResolvedValue([]) + getPRCommentsMock.mockReset() + getPRCommentsMock.mockResolvedValue([]) + noteRepositoryRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + }) + + it('loads files beyond the first 100-result REST page', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(108, 'Large PR')) + const restFile = (index: number) => ({ + filename: `src/file-${index}.ts`, + status: 'modified', + additions: 1, + deletions: 0, + changes: 1, + patch: '@@ -1 +1 @@' + }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/acme/widgets/pulls/108') { + return { stdout: JSON.stringify({ head: { sha: 'head' }, base: { sha: 'base' } }) } + } + if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100') { + return { + stdout: JSON.stringify(Array.from({ length: 100 }, (_, index) => restFile(index))) + } + } + if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100&page=2') { + return { stdout: JSON.stringify([restFile(100)]) } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 108, 'pr') + + expect(details?.files).toHaveLength(101) + expect(details?.files?.at(-1)?.path).toBe('src/file-100.ts') + const fileEndpoints = ghExecFileAsyncMock.mock.calls + .map(([args]) => (args as string[]).find((arg) => arg.includes('/files?'))) + .filter(Boolean) + expect(fileEndpoints).toEqual([ + 'repos/acme/widgets/pulls/108/files?per_page=100', + 'repos/acme/widgets/pulls/108/files?per_page=100&page=2' + ]) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(3) + expect( + repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(3) + }) + + // Why: a rate-limited/auth-failed file fetch must not render as an empty PR; + // the Files tab keys its retry state off details.filesUnavailable. + it('flags filesUnavailable when the file fetch fails', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8305, 'Files fetch fails')) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/8305') { + return { + stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) + } + } + if (target === 'repos/acme/widgets/pulls/8305/files?per_page=100') { + throw new Error('gh: API rate limit exceeded (403)') + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 8305, 'pr') + + expect(details?.filesUnavailable).toBe(true) + expect(details?.files).toBeUndefined() + }) + + it('preserves an empty file list as an available result', async () => { + getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8306, 'Empty PR')) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/8306') { + return { + stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) + } + } + if (target === 'repos/acme/widgets/pulls/8306/files?per_page=100') { + return { stdout: '[]' } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/repo-root', 8306, 'pr') + + expect(details?.filesUnavailable).toBe(false) + expect(details?.files).toEqual([]) + }) + + it('backfills the Enterprise origin host before a host-less PR detail fan-out', async () => { + const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } + getWorkItemMock.mockResolvedValueOnce({ + ...pullRequestItem(8, 'Host-less Enterprise PR'), + author: '', + prRepo: { owner: 'team', repo: 'orca' } + }) + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(enterprise) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/pulls/8') { + return { stdout: JSON.stringify({ body: 'Enterprise body' }) } + } + if (endpoint === 'repos/team/orca/pulls/8/files?per_page=100') { + return { stdout: '[]' } + } + return auxiliaryPRResponse(args) + }) + + const details = await getWorkItemDetails('/remote/repo', 8, 'pr', 'ssh-1') + + expect(details?.item.prRepo).toEqual(enterprise) + expect(getPRCommentsMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + { prRepo: enterprise }, + 'ssh-1' + ) + expect(getPRChecksMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + undefined, + enterprise, + undefined, + 'ssh-1' + ) + expect( + ghExecFileAsyncMock.mock.calls.every(([, options]) => options?.host === enterprise.host) + ).toBe(true) + }) + + it('does not run bare PR detail commands when local host resolution fails', async () => { + getWorkItemMock.mockResolvedValueOnce({ + ...pullRequestItem(9, 'Unresolved PR'), + prRepo: { owner: 'team', repo: 'orca' } + }) + getOwnerRepoMock.mockResolvedValue(null) + + const details = await getWorkItemDetails('/repo-root', 9, 'pr') + + expect(details).toMatchObject({ body: '', comments: [], checks: [], filesUnavailable: true }) + expect(getPRCommentsMock).not.toHaveBeenCalled() + expect(getPRChecksMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('uses the selected upstream GitHub Enterprise repo for both PR file sides', async () => { + const prRepo = { + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + } + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=base-sha') { + return { stdout: 'base content' } + } + if (endpoint === 'repos/team/orca/contents/src/path%23with%3Fchars.ts?ref=head-sha') { + return { stdout: 'head content' } + } + throw new Error(`unexpected gh call: ${args.join(' ')}`) + }) + + const contents = await getPRFileContents({ + repoPath: '/repo-root', + prRepo, + prNumber: 7, + path: 'src/path#with?chars.ts', + status: 'modified', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + + expect(contents).toMatchObject({ + original: 'base content', + modified: 'head content', + originalIsBinary: false, + modifiedIsBinary: false + }) + const apiCalls = ghExecFileAsyncMock.mock.calls.map(([args]) => args as string[]) + expect(apiCalls).toHaveLength(2) + expect(apiCalls.every((args) => !args.includes('--hostname'))).toBe(true) + expect( + ghExecFileAsyncMock.mock.calls.every( + ([, options]) => options?.host === 'github.acme-corp.com' + ) + ).toBe(true) + expect( + repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(2) + expect( + noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core') + ).toHaveLength(2) + }) + + it('does not fetch raw PR file contents while the repository core budget is blocked', async () => { + repositoryRateLimitGuardMock.mockReturnValue({ + blocked: true, + remaining: 0, + limit: 5000, + resetAt: 1_800_000_000 + }) + + await expect( + getPRFileContents({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + prNumber: 7, + path: 'src/file.ts', + status: 'modified', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + ).resolves.toMatchObject({ original: '', modified: '' }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 4fd21131c23..bc2bef2a722 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -7,12 +7,15 @@ type RateLimitGuardResult = const { ghExecFileAsyncMock, getOwnerRepoMock, - getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemoteMock, getWorkItemMock, + getWorkItemByOwnerRepoMock, getPRChecksMock, getPRCommentsMock, - rateLimitGuardMock, - noteRateLimitSpendMock, + repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, acquireMock, @@ -20,12 +23,21 @@ const { } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), - getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getEnterpriseGitHubRepoSlugForRemoteMock: vi.fn(), getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), getPRChecksMock: vi.fn(), getPRCommentsMock: vi.fn(), - rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), - noteRateLimitSpendMock: vi.fn(), + repositoryRateLimitGuardMock: vi.fn< + ( + repository: { host?: string } | null | undefined, + bucket: 'core' | 'graphql' | 'search', + options?: { cwd?: string; wslDistro?: string } + ) => RateLimitGuardResult + >(() => ({ blocked: false })), + noteRepositoryRateLimitSpendMock: vi.fn(), ghRepoExecOptionsMock: vi.fn((context) => context.connectionId ? {} @@ -43,7 +55,7 @@ const { vi.mock('./gh-utils', () => ({ ghExecFileAsync: ghExecFileAsyncMock, getOwnerRepo: getOwnerRepoMock, - getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, ghRepoExecOptions: ghRepoExecOptionsMock, githubRepoContext: githubRepoContextMock, acquire: acquireMock, @@ -52,28 +64,48 @@ vi.mock('./gh-utils', () => ({ vi.mock('./client', () => ({ getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock, + getEnterpriseGitHubRepoSlugForRemote: getEnterpriseGitHubRepoSlugForRemoteMock, + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('./rate-limit', () => ({ - rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + repositoryRateLimitGuard: repositoryRateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) import { getWorkItemDetails } from './work-item-details' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('getWorkItemDetails', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() - getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugForRemoteMock.mockReset() + getEnterpriseGitHubRepoSlugForRemoteMock.mockResolvedValue(null) getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() getPRChecksMock.mockReset() getPRCommentsMock.mockReset() - rateLimitGuardMock.mockReset() - rateLimitGuardMock.mockReturnValue({ blocked: false }) - noteRateLimitSpendMock.mockReset() + repositoryRateLimitGuardMock.mockReset() + repositoryRateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRepositoryRateLimitSpendMock.mockReset() ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() acquireMock.mockReset() @@ -93,7 +125,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) const timelineEvents = [ { id: 101, @@ -214,6 +246,79 @@ describe('getWorkItemDetails', () => { } ]) expect(details?.participants?.[0]?.login).toBe('issue-author') + // Why: issue identities resolve hosted, pinned to github.com for dotcom. + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + { cwd: '/repo-root', host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'graphql', + 1, + { cwd: '/repo-root', host: 'github.com' } + ) + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + { cwd: '/repo-root', host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + 1, + { cwd: '/repo-root', host: 'github.com' } + ) + }) + + it('scopes collapsed issue GraphQL accounting to an implicit WSL UNC runtime', async () => { + const repository = { owner: 'acme', repo: 'widgets' } + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\me\widgets` + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:923', + type: 'issue', + number: 923, + title: 'Use upstream issues', + state: 'open', + url: 'https://github.com/acme/widgets/issues/923', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'issue-author' + }) + getOwnerRepoForRemoteMock.mockResolvedValue(repository) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'WSL issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails(repoPath, 923, 'issue') + + expect(details?.body).toBe('WSL issue body') + // Why: issue identities resolve hosted, pinned to github.com for dotcom. + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( + { ...repository, host: 'github.com' }, + 'graphql', + { cwd: repoPath, host: 'github.com' } + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { ...repository, host: 'github.com' }, + 'graphql', + 1, + { cwd: repoPath, host: 'github.com' } + ) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.cwd === repoPath)).toBe(true) }) it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => { @@ -232,7 +337,7 @@ describe('getWorkItemDetails', () => { author: 'issue-author', assignees: [{ login: 'ghe-assignee', name: 'GHE Assignee', avatarUrl: '' }] }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -278,7 +383,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) const makeTimelineEvent = (page: number, index: number, event: string): string => JSON.stringify({ id: `${page}:${index}`, @@ -354,7 +459,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) // Collapsed GraphQL throws → fallback path picks up. ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL error')) @@ -375,12 +480,12 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/acme/widgets/issues/923'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 4, @@ -392,18 +497,22 @@ describe('getWorkItemDetails', () => { '--jq', '.[] | @json' ], - { cwd: '/repo-root' } + { cwd: '/repo-root', host: 'github.com' } ) expect(details?.body).toBe('Issue body') }) it('skips optional GraphQL issue detail calls when the cached GraphQL budget is low', async () => { - rateLimitGuardMock.mockReturnValue({ - blocked: true, - remaining: 3, - limit: 5000, - resetAt: 1_800_000_000 - }) + repositoryRateLimitGuardMock.mockImplementation((_repository, bucket) => + bucket === 'graphql' + ? { + blocked: true, + remaining: 3, + limit: 5000, + resetAt: 1_800_000_000 + } + : { blocked: false } + ) getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', type: 'issue', @@ -415,7 +524,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) }) .mockResolvedValueOnce({ stdout: '[]' }) @@ -425,7 +534,15 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false) - expect(noteRateLimitSpendMock).not.toHaveBeenCalled() + expect( + noteRepositoryRateLimitSpendMock.mock.calls.some(([, bucket]) => bucket === 'graphql') + ).toBe(false) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( + { owner: 'acme', repo: 'widgets', host: 'github.com' }, + 'core', + 2, + { cwd: '/repo-root', host: 'github.com' } + ) expect(details?.body).toBe('Issue body') expect(details?.participants).toEqual([]) }) @@ -442,7 +559,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -463,8 +580,13 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1') expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1') - expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/tester/widgets', 'ssh-test-1') - expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({}) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/home/tester/widgets', + 'upstream', + 'ssh-test-1', + {} + ) + expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ host: 'github.com' }) expect(details?.body).toBe('Remote issue body') }) @@ -481,7 +603,8 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + // Why: PR detail resolution uses getOwnerRepoForRemote via origin repository lookup. + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -527,11 +650,16 @@ describe('getWorkItemDetails', () => { expect(details?.body).toBe('PR body') expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, localGitOptions) - expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/repo-root', + 'origin', + null, + localGitOptions + ) expect(getPRCommentsMock).toHaveBeenCalledWith( '/repo-root', 42, - undefined, + { prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } }, null, localGitOptions ) @@ -539,7 +667,7 @@ describe('getWorkItemDetails', () => { '/repo-root', 42, 'head-sha', - null, + { owner: 'acme', repo: 'widgets', host: 'github.com' }, undefined, null, localGitOptions @@ -563,7 +691,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-07-11T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -597,7 +725,7 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-07-11T00:00:00Z', author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -638,7 +766,7 @@ describe('getWorkItemDetails', () => { // A default `u/0` placeholder must be replaced by the resolved avatar. assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }] }) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) const avatars: Record = { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 87cdbe52eaa..5e389be8581 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -15,14 +15,18 @@ import { ghExecFileAsync, acquire, release, - getOwnerRepo, - getIssueOwnerRepo, ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions } from './gh-utils' import { getWorkItem, getPRChecks, getPRComments } from './client' -import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { + getIssueGitHubApiRepository, + githubHostExecOptions, + resolveGitHubRepoExecution, + type GitHubApiRepository +} from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' @@ -38,6 +42,10 @@ function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitEx return Object.keys(options).length > 0 ? [options] : [] } +function encodeGitHubContentPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { @@ -120,8 +128,6 @@ type GraphQLIssueDetailsResponse = { errors?: { message?: string }[] } -type GitHubOwnerRepoSlug = { owner: string; repo: string } - type RestTimelineUser = { login?: string | null avatar_url?: string | null @@ -270,13 +276,17 @@ function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { } async function getIssueTimelineItems( - ownerRepo: GitHubOwnerRepoSlug, + ownerRepo: GitHubApiRepository, issueNumber: number, ghOptions: ReturnType ): Promise { try { const items: GitHubIssueTimelineItem[] = [] for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return items + } + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -286,7 +296,7 @@ async function getIssueTimelineItems( '--jq', '.[] | @json' ], - ghOptions + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) // Why: --jq emits NDJSON and explicit paging lets us stop once we hit the drawer cap. const pageEvents = parseRestTimelineEventLines(stdout) @@ -318,6 +328,7 @@ async function getIssueTimelineItems( async function getIssueDetailsViaGraphQL( repoPath: string, issueNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ @@ -329,20 +340,18 @@ async function getIssueDetailsViaGraphQL( participants: GitHubAssignableUser[] timelineItems: GitHubIssueTimelineItem[] } | null> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getIssueOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } if (!ownerRepo) { return null } - if (rateLimitGuard('graphql').blocked) { + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return null } try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -476,47 +485,43 @@ function isBinaryHint(file: RESTPRFile): boolean { return file.patch === undefined && file.changes > 0 } -async function getPRHeadBaseSha( +async function getPRMetadata( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): Promise<{ headSha: string; baseSha: string } | null> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) +): Promise<{ body: string; headSha?: string; baseSha?: string }> { + if (!ownerRepo) { + // Why: a bare `gh pr view` can honor ambient GH_HOST/GH_REPO after hosted + // repository resolution fails, returning metadata for the wrong PR. + return { body: '' } + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return { body: '' } + } try { - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { - head?: { sha?: string } - base?: { sha?: string } - } - if (data.head?.sha && data.base?.sha) { - return { headSha: data.head.sha, baseSha: data.base.sha } - } - return null - } + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(prNumber), '--json', 'headRefOid,baseRefOid'], + ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], ghOptions ) const data = JSON.parse(stdout) as { - headRefOid?: string - baseRefOid?: string + body?: string | null + head?: { sha?: string } + base?: { sha?: string } } - if (data.headRefOid && data.baseRefOid) { - return { headSha: data.headRefOid, baseSha: data.baseRefOid } + return { + body: data.body ?? '', + ...(data.head?.sha ? { headSha: data.head.sha } : {}), + ...(data.base?.sha ? { baseSha: data.base.sha } : {}) } - return null } catch { - return null + return { body: '' } } } @@ -524,30 +529,41 @@ async function getPRHeadBaseSha( async function getPRFiles( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) if (!ownerRepo) { return null } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100` - ], - ghOptions - ) - const data = JSON.parse(stdout) as RESTPRFile[] - return data.slice(0, MAX_PR_FILES).map((file) => ({ + const data: RESTPRFile[] = [] + for (let page = 1; data.length < MAX_PR_FILES; page += 1) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return null + } + const pageSuffix = page === 1 ? '' : `&page=${page}` + noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` + ], + ghOptions + ) + const pageData = JSON.parse(stdout) as RESTPRFile[] + data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) + if (pageData.length < 100) { + break + } + } + return data.map((file) => ({ path: file.filename, oldPath: file.previous_filename, status: mapFileStatus(file.status), @@ -569,19 +585,18 @@ type PRFileViewedStatesResult = { async function getPRFileViewedStates( repoPath: string, prNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) if (!ownerRepo) { return null } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return null } const viewedStates = new Map() @@ -605,7 +620,7 @@ async function getPRFileViewedStates( if (after) { args.push('-f', `after=${after}`) } - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync(args, ghOptions) const parsed = JSON.parse(stdout) as { data?: { @@ -665,6 +680,7 @@ function mergePRFileViewedStates( async function getIssueBodyAndComments( repoPath: string, issueNumber: number, + ownerRepo: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ @@ -673,14 +689,18 @@ async function getIssueBodyAndComments( assignees: string[] timelineItems: GitHubIssueTimelineItem[] }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getIssueOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } try { if (ownerRepo) { + if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + // Why: the fallback starts the issue and comments reads together; debit + // both before spawning so a failed response cannot leave quota overstated. + noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) const [issueResult, commentsResult, timelineItems] = await Promise.all([ ghExecFileAsync( [ @@ -727,6 +747,11 @@ async function getIssueBodyAndComments( const assignees = (issue.assignees ?? []).map((a) => a.login) return { body: issue.body ?? '', comments, assignees, timelineItems } } + if (connectionId) { + // Why: connection-backed gh has no cwd. A bare issue lookup could honor + // process GH_REPO/GH_HOST and return an unrelated repository's issue. + return { body: '', comments: [], assignees: [], timelineItems: [] } + } // Fallback: non-GitHub remote const { stdout } = await ghExecFileAsync( ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], @@ -759,57 +784,27 @@ async function getIssueBodyAndComments( } } -async function getPRBody( - repoPath: string, - prNumber: number, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...localGitOptionArgs(localGitOptions) - ) - try { - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { body?: string | null } - return data.body ?? '' - } - const { stdout } = await ghExecFileAsync( - ['pr', 'view', String(prNumber), '--json', 'body'], - ghOptions - ) - const data = JSON.parse(stdout) as { body?: string } - return data.body ?? '' - } catch { - return '' - } -} - async function getWorkItemParticipants( repoPath: string, item: Pick, + resolvedRepository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: fork work items live upstream, so resolve owner/repo with the same upstream-first resolvers as body/comments (#7331). - const ownerRepo = - item.type === 'issue' - ? await getIssueOwnerRepo(repoPath, connectionId, ...localGitOptionArgs(localGitOptions)) - : await getOwnerRepo(repoPath, connectionId, ...localGitOptionArgs(localGitOptions)) + // Why: reuse the fan-out's repository identity so details cannot drift across hosts. + const ownerRepo = resolvedRepository if (!ownerRepo) { return [] } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(ownerRepo) + } + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { return [] } try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -825,7 +820,7 @@ async function getWorkItemParticipants( '-F', `isPr=${item.type === 'pr'}` ], - ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + ghOptions ) const data = JSON.parse(stdout) as { data?: { @@ -858,16 +853,25 @@ async function getWorkItemParticipants( async function getGitHubUsersByLogin( repoPath: string, logins: string[], + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + if (!repository) { + return [] + } const uniqueLogins = Array.from( new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) ).slice(0, 40) if (uniqueLogins.length === 0) { return [] } - if (rateLimitGuard('graphql').blocked) { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + const guard = repositoryRateLimitGuard(repository, 'graphql', ghOptions) + if (guard.blocked) { // Why: log the skip — callers degrade silently to blank GHE avatars, so it's otherwise untraceable. console.warn( `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` @@ -881,10 +885,10 @@ async function getGitHubUsersByLogin( ) .join('\n') try { - noteRateLimitSpend('graphql') + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) const { stdout } = await ghExecFileAsync( ['api', 'graphql', '-f', `query=query { ${fields} }`], - ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + ghOptions ) const data = JSON.parse(stdout) as { data?: Record< @@ -978,6 +982,7 @@ async function getMentionParticipants( >, comments: PRComment[], participants: GitHubAssignableUser[], + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { @@ -993,6 +998,7 @@ async function getMentionParticipants( const graphQlUsers = await getGitHubUsersByLogin( repoPath, visibleLogins, + repository, connectionId, localGitOptions ) @@ -1003,15 +1009,19 @@ async function getPRChecksForDetails( repoPath: string, prNumber: number, headSha: string | undefined, + repository: GitHubApiRepository | null, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + if (!repository) { + return [] + } try { return await getPRChecks( repoPath, prNumber, headSha, - null, + repository, undefined, connectionId, ...localGitOptionArgs(localGitOptions) @@ -1023,6 +1033,15 @@ async function getPRChecksForDetails( } } +async function withWorkItemDetailsPermit(operation: () => Promise): Promise { + await acquire() + try { + return await operation() + } finally { + release() + } +} + export async function getWorkItemDetails( repoPath: string, number: number, @@ -1030,7 +1049,6 @@ export async function getWorkItemDetails( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: getWorkItem self-manages acquire/release; call it outside our semaphore so the cheap lookup doesn't block detail fetches. const item: Omit | null = await getWorkItem( repoPath, number, @@ -1042,13 +1060,19 @@ export async function getWorkItemDetails( return null } - await acquire() - try { - if (item.type === 'issue') { - // Why: one GraphQL round-trip returns everything (its participants include commenters, so getMentionParticipants is skipped); fall back to REST+GraphQL on any failure. + const resolvedRepository = + item.type === 'issue' + ? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions) + : (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions)) + .ownerRepo + + if (item.type === 'issue') { + return withWorkItemDetailsPermit(async () => { + // Why: one GraphQL trip returns all issue details; fall back to the legacy fan-out on failure. const collapsed = await getIssueDetailsViaGraphQL( repoPath, item.number, + resolvedRepository, connectionId, localGitOptions ) @@ -1068,14 +1092,21 @@ export async function getWorkItemDetails( } // Fallback: fetch body/comments and participants in parallel. const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([ - getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions), - getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) + getIssueBodyAndComments( + repoPath, + item.number, + resolvedRepository, + connectionId, + localGitOptions + ), + getWorkItemParticipants(repoPath, item, resolvedRepository, connectionId, localGitOptions) ]) const mentionParticipants = await getMentionParticipants( repoPath, item, comments, participants, + resolvedRepository, connectionId, localGitOptions ) @@ -1087,45 +1118,81 @@ export async function getWorkItemDetails( participants: mentionParticipants, timelineItems } - } + }) + } - // PR: fetch body + comments + checks + files + head/base SHAs in parallel. - const [body, comments, shas, files, viewedStates, participants] = await Promise.all([ - getPRBody(repoPath, item.number, connectionId, localGitOptions), - getPRComments( - repoPath, - item.number, - undefined, - connectionId, - ...localGitOptionArgs(localGitOptions) + // Why: getPRComments and getPRChecks own their semaphore permits. Keeping an + // outer permit while awaiting either can deadlock four concurrent detail loads. + const [[metadata, files, viewedStates, participants], comments] = await Promise.all([ + Promise.all([ + withWorkItemDetailsPermit(() => + getPRMetadata(repoPath, item.number, resolvedRepository, connectionId, localGitOptions) ), - getPRHeadBaseSha(repoPath, item.number, connectionId, localGitOptions), - getPRFiles(repoPath, item.number, connectionId, localGitOptions), - getPRFileViewedStates(repoPath, item.number, connectionId, localGitOptions), - getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) - ]) + withWorkItemDetailsPermit(() => + getPRFiles(repoPath, item.number, resolvedRepository, connectionId, localGitOptions) + ), + withWorkItemDetailsPermit(() => + getPRFileViewedStates( + repoPath, + item.number, + resolvedRepository, + connectionId, + localGitOptions + ) + ), + withWorkItemDetailsPermit(() => + getWorkItemParticipants(repoPath, item, resolvedRepository, connectionId, localGitOptions) + ) + ]), + resolvedRepository + ? getPRComments( + repoPath, + item.number, + { prRepo: resolvedRepository }, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) + : Promise.resolve([]) + ]) - // Why: mention-author lookup and checks are independent, so run them in parallel. - const [mentionParticipants, checks] = await Promise.all([ - getMentionParticipants(repoPath, item, comments, participants, connectionId, localGitOptions), - getPRChecksForDetails(repoPath, item.number, shas?.headSha, connectionId, localGitOptions) - ]) + // Why: mention hydration spawns gh directly while checks owns a permit; bound them without nesting. + const [mentionParticipants, checks] = await Promise.all([ + withWorkItemDetailsPermit(() => + getMentionParticipants( + repoPath, + item, + comments, + participants, + resolvedRepository, + connectionId, + localGitOptions + ) + ), + getPRChecksForDetails( + repoPath, + item.number, + metadata.headSha, + resolvedRepository, + connectionId, + localGitOptions + ) + ]) - return { - item: enrichItemDisplayAvatars(item, mentionParticipants), - body, - comments, - headSha: shas?.headSha, - baseSha: shas?.baseSha, - pullRequestId: viewedStates?.pullRequestId, - checks, - // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." - files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), - filesUnavailable: files === null, - participants: mentionParticipants - } - } finally { - release() + return { + item: enrichItemDisplayAvatars( + resolvedRepository ? { ...item, prRepo: resolvedRepository } : item, + mentionParticipants + ), + body: metadata.body, + comments, + headSha: metadata.headSha, + baseSha: metadata.baseSha, + pullRequestId: viewedStates?.pullRequestId, + checks, + // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." + files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), + filesUnavailable: files === null, + participants: mentionParticipants } } @@ -1134,12 +1201,20 @@ async function fetchContentAtRef(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions - owner: string - repo: string + ownerRepo: GitHubApiRepository path: string ref: string }): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), + ...githubHostExecOptions(args.ownerRepo), + maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES + } + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { + return { content: '', isBinary: false } + } try { + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) const { stdout } = await ghExecFileAsync( [ 'api', @@ -1147,14 +1222,9 @@ async function fetchContentAtRef(args: { '300s', '-H', 'Accept: application/vnd.github.raw', - `repos/${args.owner}/${args.repo}/contents/${encodeURI(args.path)}?ref=${encodeURIComponent(args.ref)}` + `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` ], - { - ...ghRepoExecOptions( - githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) - ), - maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES - } + ghOptions ) // Heuristic: a NUL byte in the first 2KB means binary (execFile decodes as lossy utf-8). const sample = stdout.slice(0, 2048) @@ -1174,6 +1244,7 @@ export async function getPRFileContents(args: { repoPath: string connectionId?: string | null localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null prNumber: number path: string oldPath?: string @@ -1181,10 +1252,11 @@ export async function getPRFileContents(args: { headSha: string baseSha: string }): Promise { - const ownerRepo = await getOwnerRepo( + const { ownerRepo } = await resolveGitHubRepoExecution( args.repoPath, + args.prRepo, args.connectionId, - ...localGitOptionArgs(args.localGitOptions) + args.localGitOptions ) if (!ownerRepo) { return { @@ -1209,8 +1281,7 @@ export async function getPRFileContents(args: { repoPath: args.repoPath, connectionId: args.connectionId, localGitOptions: args.localGitOptions, - owner: ownerRepo.owner, - repo: ownerRepo.repo, + ownerRepo, path: originalPath, ref: originalRef }) @@ -1223,8 +1294,7 @@ export async function getPRFileContents(args: { repoPath: args.repoPath, connectionId: args.connectionId, localGitOptions: args.localGitOptions, - owner: ownerRepo.owner, - repo: ownerRepo.repo, + ownerRepo, path: args.path, ref: args.headSha }) diff --git a/src/main/index.ts b/src/main/index.ts index b991afe1690..6f7d56f2c04 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -53,7 +53,7 @@ import { rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' -import type { UpdateCheckOptions } from '../shared/types' +import type { TuiAgent, UpdateCheckOptions } from '../shared/types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { configureElectronNetworkCompatibility, @@ -113,6 +113,7 @@ import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' +import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' import { attachMainWindowServices, ensureAutoUpdaterConfigured @@ -129,6 +130,7 @@ import { focusExistingMainWindow } from './window/focus-existing-window' import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import { markCodexProjectTrusted } from './agent-trust-presets' import { normalizeCodexRuntimeSelection, type CodexAccountSelectionTarget @@ -142,7 +144,13 @@ import { import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant' import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' +import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' +import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' +import { findTrustedCodexSessionResume } from './codex/codex-session-resume-home' +import { getSystemCodexHomePath } from './codex/codex-home-paths' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -695,8 +703,21 @@ function startTerminalRuntimeStartupServices(): Promise { function prepareCodexRuntimeHomeForLaunch( target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ): string | null { + if ( + target?.runtime !== 'wsl' && + launchContext?.launchAgent === 'codex' && + launchContext.workspacePath + ) { + try { + // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs synchronously before every recognized Codex spawn. + markCodexProjectTrusted(launchContext.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) + } + } const ensureRealHomeHooksIfSelected = (): boolean => { if ( target?.runtime === 'wsl' || @@ -767,6 +788,84 @@ function prepareCodexRuntimeHomeForLaunch( return runtimeHomePath } +async function prepareCodexSessionResumeForLaunch(args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}): Promise<{ codexHomePath: string | null } | null> { + if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { + return null + } + const systemHomePath = getSystemCodexHomePath() + // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. + const trustedHomes = [ + systemHomePath, + ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() + ] + const sessionSource = await findTrustedCodexSessionResume({ + sessionId: args.providerSession.id, + transcriptPath: args.providerSession.transcriptPath, + trustedCodexHomes: trustedHomes + }) + if (!sessionSource) { + if (args.providerSession.transcriptPath) { + throw new Error( + 'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.' + ) + } + return null + } + + let migrated = { useRealCodexHome: false } + try { + migrated = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + executionHostId: 'local', + filePath: sessionSource.transcriptPath, + codexHome: sessionSource.homePath + }, + { + isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(), + systemCodexHomePath: systemHomePath + } + ) + } catch (error) { + // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. + console.warn( + '[codex-session-resume] Legacy rollout migration failed; using origin home:', + error + ) + } + const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + + if (args.workspacePath) { + try { + markCodexProjectTrusted(args.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) + } + } + const isSystemHome = + normalizeRuntimePathForComparison(resumeHome) === + normalizeRuntimePathForComparison(systemHomePath) + const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + try { + if (isSystemHome) { + ensureRealHomeCodexHookState({ hooksEnabled, userDataPath: app.getPath('userData') }) + } else if (hooksEnabled) { + codexHookService.install(resumeHome) + } else { + codexHookService.refreshRuntimeUserHooks(resumeHome) + } + } catch (error) { + // Why: hook repair is best-effort; session provenance must still win over the currently selected home. + console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) + } + return { codexHomePath: resumeHome } +} + // Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down. function showMainWindowFromTray(): void { if (mainWindow && !mainWindow.isDestroyed()) { @@ -1009,6 +1108,12 @@ function openMainWindow(): BrowserWindow { { getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], + prepareAiVaultSessionResume: (args) => + prepareLegacySharedCodexSessionResume(args, { + isHostSystemDefaultRealHome: () => + codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) + }), onBeforeRelaunch: async () => { isQuitting = true desktopRelayService?.fenceAndCloseNow() @@ -1027,6 +1132,7 @@ function openMainWindow(): BrowserWindow { prepareCodexRuntimeHomeForLaunch, (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), { + prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, awaitLocalPtyStartup: () => localPtyStartupReady, awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady, onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { @@ -1376,8 +1482,13 @@ function getServeOptions(argv = process.argv): ServeOptions { } function getBundledWebClientRoot(): string | undefined { - const root = join(app.getAppPath(), 'out', 'web') - return existsSync(join(root, 'web-index.html')) ? root : undefined + const appPath = app.getAppPath() + const roots = [ + join(appPath, 'out', 'web'), + // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. + join(appPath, '..', 'web') + ] + return roots.find((root) => existsSync(join(root, 'web-index.html'))) } async function renderTerminalPairingQr(pairingUrl: string): Promise { @@ -1775,41 +1886,22 @@ app.whenReady().then(async () => { codexRuntimeHome.isHostSystemDefaultRealHome() && isAgentStatusHooksEnabled(store?.getSettings()) ) - codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) + const codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + isQuitting: () => isQuitting, + resolveSystemCodexHomePathOverride: () => + resolveHostCodexSessionSourceHome(store!.getSettings()), + startBackfill: startCodexSessionBackfillInBackground, + startIndexHeal: startCodexSessionIndexHealInBackground + }) + codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { + onHostSystemDefaultSelected: codexSessionMigration.requestRun + }) // Why: one-time per-host backfill makes historical Orca-managed Codex // sessions visible to the user's own resume picker and app history (#4444, // #8612). Deferred so startup and first PTY spawns never compete with the // sessions tree walk. - setTimeout(() => { - // Why: reverse-backfilling into the user's Codex home belongs exclusively - // to the real-home lane; flag-off, managed-account, and custom-CODEX_HOME - // launch lanes must remain byte-identical and leave that history untouched. - if (!codexRuntimeHome?.isHostSystemDefaultRealHome()) { - return - } - const systemCodexHomePathOverride = resolveHostCodexSessionSourceHome(store!.getSettings()) - const shouldStopSessionMigration = (): boolean => - isQuitting || codexRuntimeHome?.isHostSystemDefaultRealHome() !== true - // Why: the heal pass chains after the backfill settles so thread/read only - // runs once the audit ledger covers this startup's newly linked rollouts; - // it also drains sessions left pending by an interrupted earlier pass. - void startCodexSessionBackfillInBackground( - { shouldStop: shouldStopSessionMigration }, - systemCodexHomePathOverride - ).then(() => { - // Why: flag-OFF, managed-account, and custom-home lanes must never spawn - // an app-server against the user's real sqlite index. - if (!codexRuntimeHome?.isHostSystemDefaultRealHome()) { - return - } - return startCodexSessionIndexHealInBackground( - { - shouldStop: shouldStopSessionMigration - }, - systemCodexHomePathOverride - ) - }) - }, 15_000) + codexSessionMigration.scheduleInitialRun() claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) rateLimits.setCodexHomePathResolver((target) => @@ -1817,9 +1909,23 @@ app.whenReady().then(async () => { ) rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( + rateLimits, + store.getSettings() + ) + store.onSettingsChanged((updates, settings) => { + // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. + void syncAccountRuntimeTargets(updates, settings).catch((error) => + console.warn('[rate-limits] Failed to apply account runtime target:', error) + ) + }) rateLimits.setClaudeAuthPreparationResolver((target) => claudeRuntimeAuth!.prepareForRateLimitFetch(target) ) + // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). + agentHookServer.setClaudeStatusLineListener((event) => { + rateLimits?.ingestLiveClaudeRateLimits(event) + }) rateLimits.setOpenCodeGoConfigResolver(() => { const settings = store!.getSettings() return { @@ -1900,6 +2006,11 @@ app.whenReady().then(async () => { // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], + prepareAiVaultSessionResume: (args) => + prepareLegacySharedCodexSessionResume(args, { + isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) + }), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) @@ -2176,7 +2287,8 @@ app.whenReady().then(async () => { prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - store + store, + prepareCodexSessionResumeForLaunch ) // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. if (headlessBrowserDisplayAvailable) { diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts new file mode 100644 index 00000000000..a5618c0041d --- /dev/null +++ b/src/main/ipc/ai-vault-resume.ts @@ -0,0 +1,39 @@ +import { ipcMain } from 'electron' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult, + AiVaultSessionResumePreparation +} from '../../shared/ai-vault-resume-preparation' +import { parseExecutionHostId } from '../../shared/execution-host' + +export type AiVaultResumeHandlerOptions = { + prepareSessionResume?: AiVaultSessionResumePreparation + prepareRuntimeSessionResume?: ( + environmentId: string, + args: AiVaultPrepareSessionResumeArgs + ) => Promise +} + +export function registerAiVaultResumeHandler(options: AiVaultResumeHandlerOptions): void { + ipcMain.handle('aiVault:prepareSessionResume', (_event, args: AiVaultPrepareSessionResumeArgs) => + prepareAiVaultSessionResume(args, options) + ) +} + +export async function prepareAiVaultSessionResume( + args: AiVaultPrepareSessionResumeArgs, + options: AiVaultResumeHandlerOptions +): Promise { + const executionHost = parseExecutionHostId(args.executionHostId) + if (executionHost?.kind === 'runtime') { + if (!options.prepareRuntimeSessionResume) { + throw new Error('The session host is unavailable. Reconnect it and retry resume.') + } + return options.prepareRuntimeSessionResume(executionHost.environmentId, args) + } + // Why: the desktop process must never materialize transcript paths owned by an SSH host. + if (executionHost?.kind === 'ssh') { + return { useRealCodexHome: false } + } + return options.prepareSessionResume?.(args) ?? { useRealCodexHome: false } +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 8d4ba30dbd5..5f4153106fd 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -13,12 +13,13 @@ const mocks = vi.hoisted(() => ({ getAiVaultWslHomeDirs: vi.fn(), getSshFilesystemProvider: vi.fn(), getActiveSshAiVaultHostInfo: vi.fn(), - getActiveSshAiVaultHostInfos: vi.fn() + getActiveSshAiVaultHostInfos: vi.fn(), + ipcHandle: vi.fn() })) vi.mock('electron', () => ({ app: { on: vi.fn() }, - ipcMain: { handle: vi.fn() } + ipcMain: { handle: mocks.ipcHandle } })) vi.mock('../ai-vault/session-scanner', () => ({ @@ -206,6 +207,79 @@ describe('listAiVaultSessions host routing', () => { }) }) +describe('prepareSessionResume IPC', () => { + it('awaits the host-local targeted resume preparation', async () => { + const prepareSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) + registerAiVaultHandlers({ prepareSessionResume }) + const registration = mocks.ipcHandle.mock.calls.find( + ([channel]) => channel === 'aiVault:prepareSessionResume' + ) + const handler = registration?.[1] as + | ((_event: unknown, args: unknown) => Promise) + | undefined + const args = { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'local' + } + + await expect(handler?.({}, args)).resolves.toEqual({ useRealCodexHome: true }) + expect(prepareSessionResume).toHaveBeenCalledWith(args) + }) + + it('prepares saved-runtime sessions on the transcript-owning runtime', async () => { + const prepareSessionResume = vi.fn() + const prepareRuntimeSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) + registerAiVaultHandlers({ prepareSessionResume, prepareRuntimeSessionResume }) + const args = { + agent: 'codex' as const, + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-123' as const + } + + await expect(getPrepareSessionResumeHandler()({}, args)).resolves.toEqual({ + useRealCodexHome: true + }) + expect(prepareRuntimeSessionResume).toHaveBeenCalledWith('env-123', args) + expect(prepareSessionResume).not.toHaveBeenCalled() + }) + + it('preserves SSH session homes without reading their paths locally', async () => { + const prepareSessionResume = vi.fn() + const prepareRuntimeSessionResume = vi.fn() + registerAiVaultHandlers({ prepareSessionResume, prepareRuntimeSessionResume }) + + await expect( + getPrepareSessionResumeHandler()( + {}, + { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'ssh:dev-box' + } + ) + ).resolves.toEqual({ useRealCodexHome: false }) + expect(prepareSessionResume).not.toHaveBeenCalled() + expect(prepareRuntimeSessionResume).not.toHaveBeenCalled() + }) +}) + +function getPrepareSessionResumeHandler(): ( + event: unknown, + args: unknown +) => Promise<{ useRealCodexHome: boolean }> { + const registration = mocks.ipcHandle.mock.calls.find( + ([channel]) => channel === 'aiVault:prepareSessionResume' + ) + if (!registration) { + throw new Error('aiVault:prepareSessionResume was not registered') + } + return registration[1] +} + describe('listAiVaultSubagentSessions gating', () => { const claudeRoot = join(homedir(), '.claude', 'projects') diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 5a5dbe0c6ea..ebfd52e63a7 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -18,6 +18,7 @@ import type { AiVaultSubagentListArgs, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostScope, @@ -35,14 +36,15 @@ import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh const AI_VAULT_CACHE_TTL_MS = 15_000 const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 -type AiVaultHandlerOptions = AiVaultSessionSources & { - getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] - scanRuntimeAiVaultSessions?: ( - environmentId: string, - args: AiVaultListArgs, - options?: RuntimeAiVaultScanOptions - ) => Promise -} +type AiVaultHandlerOptions = AiVaultSessionSources & + AiVaultResumeHandlerOptions & { + getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] + scanRuntimeAiVaultSessions?: ( + environmentId: string, + args: AiVaultListArgs, + options?: RuntimeAiVaultScanOptions + ) => Promise + } type RuntimeAiVaultScanOptions = { timeoutMs?: number @@ -281,6 +283,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => listAiVaultSessions(args) ) + registerAiVaultResumeHandler(options) ipcMain.handle( 'aiVault:listSubagentSessions', (_event, args?: AiVaultSubagentListArgs): Promise => diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 89b88537eb9..7ced0354ee4 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -53,7 +53,8 @@ const REACT_ERROR_BOUNDARY_SURFACES = new Set { + it('accepts a complete dashboard snapshot', () => { + expect(isDashboardSnapshot(SNAPSHOT)).toBe(true) + }) + + it('rejects malformed or unbounded snapshot fields', () => { + expect(isDashboardSnapshot({ ...SNAPSHOT, generatedAt: Number.NaN })).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], bucket: 'unexpected' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], lastAgentMessage: 'x'.repeat(8_001) }] + }) + ).toBe(false) + }) + + it('requires complete bounded reveal routing', () => { + expect( + isDashboardRevealAgentArgs({ + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: null + }) + ).toBe(true) + expect( + isDashboardRevealAgentArgs({ repoId: 'repo-1', worktreeId: 'worktree-1', tabId: '' }) + ).toBe(false) + }) +}) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts new file mode 100644 index 00000000000..54168d1904a --- /dev/null +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -0,0 +1,85 @@ +import type { DashboardRevealAgentArgs, DashboardSnapshot } from '../../shared/dashboard-snapshot' +import { + AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, + AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, + AGENT_STATUS_MAX_FIELD_LENGTH, + AGENT_TYPE_MAX_LENGTH +} from '../../shared/agent-status-types' + +const MAX_DASHBOARD_CARDS = 1_000 +const MAX_ID_LENGTH = 4_096 +const MAX_LABEL_LENGTH = 1_024 +const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'idle']) +const DASHBOARD_DOT_STATES = new Set(['working', 'blocked', 'waiting', 'done', 'idle']) + +function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalBoundedString(value: unknown, maxLength: number): boolean { + return value === undefined || isBoundedString(value, maxLength, true) +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function isDashboardRevealAgentArgs(value: unknown): value is DashboardRevealAgentArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const args = value as Record + return ( + isBoundedString(args.repoId, MAX_ID_LENGTH) && + isBoundedString(args.worktreeId, MAX_ID_LENGTH) && + isBoundedString(args.tabId, MAX_ID_LENGTH) && + (args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH)) + ) +} + +export function isDashboardPaneKey(value: unknown): value is string { + return isBoundedString(value, MAX_ID_LENGTH) +} + +export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const snapshot = value as Record + return ( + isFiniteNumber(snapshot.generatedAt) && + Array.isArray(snapshot.cards) && + snapshot.cards.length <= MAX_DASHBOARD_CARDS && + snapshot.cards.every(isDashboardCard) + ) +} + +function isDashboardCard(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const card = value as Record + return ( + isBoundedString(card.paneKey, MAX_ID_LENGTH) && + (card.ptyId === null || isBoundedString(card.ptyId, MAX_ID_LENGTH)) && + isBoundedString(card.agentType, AGENT_TYPE_MAX_LENGTH) && + typeof card.bucket === 'string' && + DASHBOARD_BUCKETS.has(card.bucket) && + typeof card.dotState === 'string' && + DASHBOARD_DOT_STATES.has(card.dotState) && + isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) && + isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) && + isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) && + isBoundedString(card.repoId, MAX_ID_LENGTH) && + isBoundedString(card.worktreeId, MAX_ID_LENGTH) && + isBoundedString(card.tabId, MAX_ID_LENGTH) && + (card.leafId === null || isBoundedString(card.leafId, MAX_ID_LENGTH)) && + isBoundedString(card.repoName, MAX_LABEL_LENGTH, true) && + isBoundedString(card.worktreeName, MAX_LABEL_LENGTH, true) && + isFiniteNumber(card.startedAt) && + (card.finishedAt === null || isFiniteNumber(card.finishedAt)) && + isFiniteNumber(card.stateChangedAt) && + typeof card.unseen === 'boolean' && + isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) + ) +} diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts new file mode 100644 index 00000000000..c14b8b3c1fb --- /dev/null +++ b/src/main/ipc/dashboard-popout.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + ipcMainMock, + appMock, + createPopoutMock, + closePopoutMock, + getPopoutMock, + isPopoutRendererMock, + isTrustedUIRendererMock, + getTrustedWindowMock, + sendToTrustedMock, + safelyRevealMock +} = vi.hoisted(() => { + const map = new Map unknown>() + return { + handlers: map, + ipcMainMock: { + removeHandler: vi.fn(), + handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) + }, + appMock: { focus: vi.fn() }, + createPopoutMock: vi.fn(), + closePopoutMock: vi.fn(), + getPopoutMock: vi.fn((): unknown => null), + isPopoutRendererMock: vi.fn((_sender: unknown) => false), + isTrustedUIRendererMock: vi.fn((_sender: unknown) => false), + getTrustedWindowMock: vi.fn((): unknown => null), + sendToTrustedMock: vi.fn(), + safelyRevealMock: vi.fn() + } +}) + +vi.mock('electron', () => ({ app: appMock, ipcMain: ipcMainMock })) +vi.mock('../window/dashboard-popout-window', () => ({ + createOrFocusDashboardPopout: createPopoutMock, + closeDashboardPopout: closePopoutMock, + getDashboardPopoutWindow: getPopoutMock, + isDashboardPopoutRenderer: isPopoutRendererMock, + onDashboardPopoutOpenChanged: vi.fn() +})) +vi.mock('../window/focus-existing-window', () => ({ safelyRevealWindow: safelyRevealMock })) +vi.mock('./ui', () => ({ + getTrustedUIRendererWindow: getTrustedWindowMock, + isTrustedUIRenderer: isTrustedUIRendererMock, + sendToTrustedUIRenderer: sendToTrustedMock +})) + +import { registerDashboardPopoutHandlers } from './dashboard-popout' + +const mainSender = { id: 1, send: vi.fn() } +const popoutSender = { id: 2, send: vi.fn() } +const untrustedSender = { id: 3, send: vi.fn() } +const SNAPSHOT = { generatedAt: 1, cards: [] } + +function makeWindow(sender: typeof mainSender) { + return { + isDestroyed: () => false, + webContents: sender + } +} + +function makeStore(enabled = true) { + let settingsListener: + | ((updates: Record, settings: Record) => void) + | null = null + return { + getSettings: vi.fn(() => ({ experimentalAgentDashboardPopout: enabled })), + onSettingsChanged: vi.fn((listener) => { + settingsListener = listener + return vi.fn() + }), + fireSettingsChanged: (nextEnabled: boolean) => + settingsListener?.( + { experimentalAgentDashboardPopout: nextEnabled }, + { experimentalAgentDashboardPopout: nextEnabled } + ) + } +} + +describe('registerDashboardPopoutHandlers', () => { + let store: ReturnType + + beforeEach(() => { + handlers.clear() + store = makeStore() + isTrustedUIRendererMock.mockImplementation((sender) => sender === mainSender) + isPopoutRendererMock.mockImplementation((sender) => sender === popoutSender) + registerDashboardPopoutHandlers(store as never) + }) + + afterEach(() => { + vi.clearAllMocks() + getPopoutMock.mockReturnValue(null) + getTrustedWindowMock.mockReturnValue(null) + }) + + it('opens only for the trusted main renderer while the feature is enabled', () => { + handlers.get('dashboardPopout:open')!({ sender: untrustedSender } as never) + expect(createPopoutMock).not.toHaveBeenCalled() + + store.getSettings.mockReturnValue({ experimentalAgentDashboardPopout: false }) + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never) + expect(createPopoutMock).not.toHaveBeenCalled() + + store.getSettings.mockReturnValue({ experimentalAgentDashboardPopout: true }) + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never) + expect(createPopoutMock).toHaveBeenCalledWith(store) + }) + + it('auto-closes the popout when the feature is disabled', () => { + store.fireSettingsChanged(false) + expect(closePopoutMock).toHaveBeenCalledOnce() + }) + + it('caches and forwards only valid trusted snapshots', () => { + const popout = makeWindow(popoutSender) + getPopoutMock.mockReturnValue(popout) + + handlers.get('dashboard:publishSnapshot')!({ sender: untrustedSender } as never, SNAPSHOT) + handlers.get('dashboard:publishSnapshot')!({ sender: mainSender } as never, { + generatedAt: Number.NaN, + cards: [] + }) + expect(popoutSender.send).not.toHaveBeenCalled() + + handlers.get('dashboard:publishSnapshot')!({ sender: mainSender } as never, SNAPSHOT) + expect(popoutSender.send).toHaveBeenCalledWith('dashboard:snapshot', SNAPSHOT) + }) + + it('replays the cached snapshot only to the popout and nudges only the trusted main renderer', () => { + const popout = makeWindow(popoutSender) + getPopoutMock.mockReturnValue(popout) + handlers.get('dashboard:publishSnapshot')!({ sender: mainSender } as never, SNAPSHOT) + + handlers.get('dashboard:requestSnapshot')!({ sender: untrustedSender } as never) + expect(untrustedSender.send).not.toHaveBeenCalled() + + handlers.get('dashboard:requestSnapshot')!({ sender: popoutSender } as never) + expect(popoutSender.send).toHaveBeenCalledWith('dashboard:snapshot', SNAPSHOT) + expect(sendToTrustedMock).toHaveBeenCalledWith('dashboard:snapshotRequested', null) + }) + + it('reports open state only to the trusted main renderer', () => { + getPopoutMock.mockReturnValue(makeWindow(popoutSender)) + expect(handlers.get('dashboard:getPopoutOpen')!({ sender: untrustedSender } as never)).toBe( + false + ) + expect(handlers.get('dashboard:getPopoutOpen')!({ sender: mainSender } as never)).toBe(true) + }) + + it('relays valid seen acknowledgements from only the popout', () => { + handlers.get('dashboardPopout:ackAgent')!({ sender: untrustedSender } as never, { + paneKey: 'tab1:leaf1' + }) + handlers.get('dashboardPopout:ackAgent')!({ sender: popoutSender } as never, { paneKey: '' }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:ackAgent')!({ sender: popoutSender } as never, { + paneKey: 'tab1:leaf1' + }) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:ackDashboardAgent', 'tab1:leaf1') + }) + + it('reveals an agent in only the trusted main window', () => { + const main = makeWindow(mainSender) + getTrustedWindowMock.mockReturnValue(main) + const args = { repoId: 'r1', worktreeId: 'w1', tabId: 't1', leafId: 'l1' } + + handlers.get('dashboardPopout:revealAgent')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:revealAgent')!({ sender: popoutSender } as never, { + ...args, + tabId: '' + }) + expect(safelyRevealMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:revealAgent')!({ sender: popoutSender } as never, args) + expect(safelyRevealMock).toHaveBeenCalledWith(main) + expect(mainSender.send).toHaveBeenCalledWith('ui:revealDashboardAgent', args) + expect(appMock.focus).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts new file mode 100644 index 00000000000..95e602ad1ca --- /dev/null +++ b/src/main/ipc/dashboard-popout.ts @@ -0,0 +1,125 @@ +import { app, ipcMain } from 'electron' +import type { Store } from '../persistence' +import type { DashboardSnapshot } from '../../shared/dashboard-snapshot' +import { + createOrFocusDashboardPopout, + closeDashboardPopout, + getDashboardPopoutWindow, + isDashboardPopoutRenderer, + onDashboardPopoutOpenChanged +} from '../window/dashboard-popout-window' +import { safelyRevealWindow } from '../window/focus-existing-window' +import { getTrustedUIRendererWindow, isTrustedUIRenderer, sendToTrustedUIRenderer } from './ui' +import { + isDashboardPaneKey, + isDashboardRevealAgentArgs, + isDashboardSnapshot +} from './dashboard-payload-validation' + +// The most recent snapshot the main renderer published, replayed to the popout +// the instant it mounts so the board paints without waiting for the next tick. +// Cleared on close so a reopened popout never flashes a previous session. +let lastSnapshot: DashboardSnapshot | null = null + +function isDashboardEnabled(store: Store): boolean { + return store.getSettings().experimentalAgentDashboardPopout === true +} + +export function registerDashboardPopoutHandlers(store: Store): void { + ipcMain.removeHandler('dashboardPopout:open') + ipcMain.removeHandler('dashboard:publishSnapshot') + ipcMain.removeHandler('dashboard:requestSnapshot') + ipcMain.removeHandler('dashboard:getPopoutOpen') + ipcMain.removeHandler('dashboardPopout:revealAgent') + ipcMain.removeHandler('dashboardPopout:ackAgent') + + onDashboardPopoutOpenChanged((open) => { + if (!open) { + lastSnapshot = null + } + }) + store.onSettingsChanged((updates, settings) => { + if ( + 'experimentalAgentDashboardPopout' in updates && + settings.experimentalAgentDashboardPopout !== true + ) { + lastSnapshot = null + closeDashboardPopout() + } + }) + + ipcMain.handle('dashboardPopout:open', (event): void => { + if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) { + return + } + createOrFocusDashboardPopout(store) + }) + + // Relay: the main renderer publishes derived snapshots; forward to the popout. + ipcMain.handle('dashboard:publishSnapshot', (event, snapshot: unknown): void => { + if ( + !isTrustedUIRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSnapshot(snapshot) + ) { + return + } + lastSnapshot = snapshot + getDashboardPopoutWindow()?.webContents.send('dashboard:snapshot', snapshot) + }) + + // The popout asks for a snapshot on mount: replay the cache immediately, then + // nudge the main renderer to publish a fresh one. + ipcMain.handle('dashboard:requestSnapshot', (event): void => { + if (!isDashboardPopoutRenderer(event.sender) || !isDashboardEnabled(store)) { + return + } + if (lastSnapshot) { + event.sender.send('dashboard:snapshot', lastSnapshot) + } + sendToTrustedUIRenderer('dashboard:snapshotRequested', null) + }) + + ipcMain.handle('dashboard:getPopoutOpen', (event): boolean => + isTrustedUIRenderer(event.sender) && isDashboardEnabled(store) + ? getDashboardPopoutWindow() !== null + : false + ) + + // Seen-sync: opening a card's terminal dialog acknowledges the agent in the + // main renderer's store — the same ack that mutes its sidebar row. + ipcMain.handle('dashboardPopout:ackAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !args || + typeof args !== 'object' || + !isDashboardPaneKey((args as { paneKey?: unknown }).paneKey) + ) { + return + } + sendToTrustedUIRenderer('ui:ackDashboardAgent', (args as { paneKey: string }).paneKey) + }) + + // Click-to-focus: raise the main window and route it to the agent's pane. + ipcMain.handle('dashboardPopout:revealAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardRevealAgentArgs(args) + ) { + return + } + const mainWindow = getTrustedUIRendererWindow() + if (!mainWindow) { + return + } + safelyRevealWindow(mainWindow) + mainWindow.webContents.send('ui:revealDashboardAgent', args) + try { + app.focus({ steal: true }) + } catch { + // Best-effort; the per-window focus above may still bring it forward. + } + }) +} diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 71f5d286624..bb1dd0cc1f6 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -856,7 +856,7 @@ describe('registerGitHubHandlers', () => { updatedAt: 0 } ] - const prRepo = { owner: 'acme', repo: 'orca' } + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme-corp.com' } const localGitOptions = { wslDistro: 'Ubuntu' } getWorkItemMock.mockResolvedValue(null) getWorkItemByOwnerRepoMock.mockResolvedValue(null) @@ -883,6 +883,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', owner: 'acme', repo: 'orca', + host: prRepo.host, number: 42, type: 'pr' }) @@ -894,6 +895,7 @@ describe('registerGitHubHandlers', () => { await handlers['gh:prFileContents'](null, { repoPath: '/workspace/repo', prNumber: 42, + prRepo, path: 'src/app.ts', status: 'modified', headSha: 'head-sha', @@ -923,13 +925,15 @@ describe('registerGitHubHandlers', () => { await handlers['gh:resolveReviewThread'](null, { repoPath: '/workspace/repo', threadId: 'thread-1', - resolve: true + resolve: true, + prRepo }) await handlers['gh:setPRFileViewed']( { sender: { id: 1 } }, { repoPath: '/workspace/repo', prNumber: 42, + prRepo, pullRequestId: 'PR_kw', path: 'src/app.ts', viewed: true @@ -940,12 +944,12 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, + prRepo, commentId: 11, body: ' Reply ', threadId: 'thread-1', path: 'src/app.ts', - line: 10, - prRepo + line: 10 } ) await handlers['gh:addPRReviewComment']( @@ -953,6 +957,7 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, + prRepo, commitId: ' head-sha ', path: 'src/app.ts', line: 10, @@ -992,21 +997,24 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, - updates: { state: 'closed' } + updates: { state: 'closed' }, + prRepo } ) await handlers['gh:rerunPRChecks'](null, { repoPath: '/workspace/repo', prNumber: 42, headSha: 'head-sha', - failedOnly: true + failedOnly: true, + prRepo }) await handlers['gh:requestPRReviewers']( { sender: { id: 1 } }, { repoPath: '/workspace/repo', prNumber: 42, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo } ) await handlers['gh:removePRReviewers']( @@ -1014,7 +1022,8 @@ describe('registerGitHubHandlers', () => { { repoPath: '/workspace/repo', prNumber: 42, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo } ) @@ -1035,7 +1044,7 @@ describe('registerGitHubHandlers', () => { localGitOptions ) expect(getPRFileContentsMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions }) + expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo }) ) expect(getPRChecksMock).toHaveBeenCalledWith( '/workspace/repo', @@ -1070,10 +1079,11 @@ describe('registerGitHubHandlers', () => { 'thread-1', true, null, + prRepo, localGitOptions ) expect(setPRFileViewedMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions }) + expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo }) ) expect(addPRReviewCommentReplyMock).toHaveBeenCalledWith( '/workspace/repo', @@ -1092,6 +1102,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', commitId: 'head-sha', body: 'Inline', + prRepo, localGitOptions }) ) @@ -1125,12 +1136,13 @@ describe('registerGitHubHandlers', () => { 42, { state: 'closed' }, null, + prRepo, localGitOptions ) expect(rerunPRChecksMock).toHaveBeenCalledWith( '/workspace/repo', 42, - { headSha: 'head-sha', failedOnly: true }, + { headSha: 'head-sha', failedOnly: true, prRepo }, null, localGitOptions ) @@ -1139,6 +1151,7 @@ describe('registerGitHubHandlers', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(removePRReviewersMock).toHaveBeenCalledWith( @@ -1146,6 +1159,7 @@ describe('registerGitHubHandlers', () => { 42, ['octo'], null, + prRepo, localGitOptions ) }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 84afec20c3b..a01225bfccf 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -93,6 +93,7 @@ import type { ClearProjectItemFieldArgs, DeleteIssueCommentBySlugArgs, GetProjectViewTableArgs, + ListAccessibleProjectsArgs, ListAssignableUsersBySlugArgs, ListIssueTypesBySlugArgs, ListLabelsBySlugArgs, @@ -474,6 +475,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' } @@ -481,7 +483,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi const repo = assertRegisteredRepo(args, store) return getWorkItemByOwnerRepo( repo.path, - { owner: args.owner, repo: args.repo }, + // Why: Enterprise host identity must survive the IPC boundary or the + // lookup falls back to gh's default host for a same-named repo. + { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, args.number, args.type, repoConnectionId(repo), @@ -539,6 +543,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -551,6 +556,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: repo.path, connectionId: repoConnectionId(repo), localGitOptions: localGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, prNumber: args.prNumber, path: args.path, oldPath: args.oldPath, @@ -671,6 +677,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null } ) => { const repo = assertRegisteredRepo(args, store) @@ -684,6 +691,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.threadId, args.resolve, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) } @@ -697,6 +705,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: string repoId?: string prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -717,6 +726,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoPath: repo.path, connectionId: repoConnectionId(repo), localGitOptions: localGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId.trim(), path: args.path, viewed: Boolean(args.viewed) @@ -795,6 +805,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -833,6 +844,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } const result = await addPRReviewComment({ repoPath: repo.path, + prRepo: args.prRepo ?? null, prNumber: args.prNumber, commitId: args.commitId.trim(), path: args.path, @@ -947,7 +959,11 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:updatePRState', async ( event, - args: RepoScopedArgs & { prNumber: number; updates: GitHubPullRequestStateUpdate } + args: RepoScopedArgs & { + prNumber: number + updates: GitHubPullRequestStateUpdate + prRepo?: GitHubOwnerRepo | null + } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -962,6 +978,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.prNumber, args.updates, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -978,7 +995,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:rerunPRChecks', async ( _event, - args: RepoScopedArgs & { prNumber: number; headSha?: string; failedOnly?: boolean } + args: RepoScopedArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -991,7 +1013,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi return rerunPRChecks( repo.path, args.prNumber, - { headSha: args.headSha, failedOnly: args.failedOnly }, + { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, repoConnectionId(repo), ...localGitOptionArgs(store, repo) ) @@ -1000,13 +1022,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:requestPRReviewers', - async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { + async ( + event, + args: RepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { const repo = assertRegisteredRepo(args, store) const result = await requestPRReviewers( repo.path, args.prNumber, args.reviewers, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -1021,13 +1051,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:removePRReviewers', - async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { + async ( + event, + args: RepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { const repo = assertRegisteredRepo(args, store) const result = await removePRReviewers( repo.path, args.prNumber, args.reviewers, repoConnectionId(repo), + args.prRepo ?? null, ...localGitOptionArgs(store, repo) ) if (result.ok) { @@ -1157,7 +1195,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi getRateLimit(args?.force ? { force: true } : undefined) ) - ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth()) + ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => + diagnoseGhAuth(args?.host) + ) // ── GitHub ProjectV2 view handlers ───────────────────────────────── // Why: registered unconditionally so enabling the experimental flag at @@ -1165,7 +1205,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi // Handlers never throw across IPC — every failure mode resolves through the // GitHubProjectViewError envelope. - ipcMain.handle('gh:listAccessibleProjects', () => listAccessibleProjects()) + ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => + listAccessibleProjects(args) + ) ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => resolveProjectRef(args) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 78d5f70eed2..cc7428dac3b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -743,7 +743,8 @@ describe('registerPtyHandlers', () => { processEnvOverrides?: Record, getSelectedCodexHomePath?: ( target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, - launchEnv?: NodeJS.ProcessEnv + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ) => string | null, getSettings?: () => { enableGitHubAttribution?: boolean @@ -753,7 +754,9 @@ describe('registerPtyHandlers', () => { }, // Why: PR #2662 finding 2 — accept an optional `command` so callers can exercise OMP target resolution (was untested). command?: string, - launchAgent?: TuiAgent + launchAgent?: TuiAgent, + cwd?: string, + worktreeId?: string ): Promise> { const savedEnv: Record = {} if (processEnvOverrides) { @@ -781,7 +784,9 @@ describe('registerPtyHandlers', () => { rows: 24, ...(argsEnv ? { env: argsEnv } : {}), ...(command ? { command } : {}), - ...(launchAgent ? { launchAgent } : {}) + ...(launchAgent ? { launchAgent } : {}), + ...(cwd ? { cwd } : {}), + ...(worktreeId ? { worktreeId } : {}) }) const spawnCall = spawnMock.mock.calls.at(-1)! return spawnCall[2].env as Record @@ -926,6 +931,139 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('resumes an automatic Codex session from its prepared originating home', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + const prepareResume = vi.fn(async () => ({ codexHomePath: '/managed/origin/home' })) + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: prepareResume } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + } + }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(prepareResume).toHaveBeenCalledWith( + expect.objectContaining({ + providerSession: expect.objectContaining({ id: 'session-a' }), + target: { runtime: 'host' } + }) + ) + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe('/managed/origin/home') + expect(env.ORCA_CODEX_HOME).toBe('/managed/origin/home') + }) + + it('overrides an unmarked custom home when the resumed session originated in real home', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + const systemHome = '/Users/example/.codex' + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + } + }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe(systemHome) + expect(env.ORCA_CODEX_HOME).toBe(systemHome) + expect(env.REMOVE_ME).toBeUndefined() + }) + + it('does not fall back to the selected account when automatic resume provenance is rejected', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { + prepareCodexSessionResume: async () => { + throw new Error('origin unavailable') + } + } + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + } + }) + ).rejects.toThrow('origin unavailable') + + expect(selectedHome).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => { + const workspacePath = '/repo/worktrees/new-feature' + const resolveHome = vi.fn( + ( + _target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + _launchEnv?: NodeJS.ProcessEnv, + _launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + ) => null + ) + + await spawnAndGetEnv( + undefined, + undefined, + resolveHome, + undefined, + 'codex', + 'codex', + workspacePath, + `repo-id::${workspacePath}` + ) + + expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) + expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' }) + expect(resolveHome.mock.invocationCallOrder[0]).toBeLessThan( + spawnMock.mock.invocationCallOrder[0]! + ) + }) + it('injects the OpenCode hook env into Orca terminal PTYs', async () => { // Why: clear any ambient OPENCODE_CONFIG_DIR so the mock's value is used const env = await spawnAndGetEnv(undefined, { OPENCODE_CONFIG_DIR: undefined }) @@ -1504,7 +1642,11 @@ describe('registerPtyHandlers', () => { async function daemonSpawnAndGetOptions( argsEnv?: Record, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: ( + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + ) => string | null, getSettings?: () => { enableGitHubAttribution?: boolean httpProxyUrl?: string @@ -1517,6 +1659,7 @@ describe('registerPtyHandlers', () => { worktreeId?: string shellOverride?: string command?: string + launchAgent?: TuiAgent envToDelete?: string[] }, supportsGitCredentialGuardHost = true @@ -1561,14 +1704,23 @@ describe('registerPtyHandlers', () => { async function daemonSpawnAndGetEnv( argsEnv?: Record, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: ( + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + ) => string | null, getSettings?: () => { enableGitHubAttribution?: boolean httpProxyUrl?: string httpProxyBypassRules?: string }, processEnvOverrides?: Record, - spawnArgs?: { cwd?: string; shellOverride?: string; command?: string }, + spawnArgs?: { + cwd?: string + shellOverride?: string + command?: string + launchAgent?: TuiAgent + }, supportsGitCredentialGuardHost = true ): Promise> { return ( @@ -1682,6 +1834,124 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('overrides an unmarked custom home for an authoritative daemon resume', async () => { + const daemonSpawn = setupDaemonAdapter() + const selectedHome = vi.fn(() => '/managed/current/home') + const systemHome = '/Users/example/.codex' + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl` + } + }) + + const env = daemonSpawn.mock.calls.at(-1)![0].env + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe(systemHome) + expect(env.ORCA_CODEX_HOME).toBe(systemHome) + expect(env.REMOVE_ME).toBeUndefined() + }) + + it('keeps the authoritative home for runtime-created daemon resumes', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + command: string + env: Record + envToDelete: string[] + launchAgent: 'codex' + resumeProviderSession: { + key: 'session_id' + id: string + transcriptPath: string + } + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + const systemHome = '/Users/example/.codex' + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl` + } + }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.env.CODEX_HOME).toBe(systemHome) + expect(spawnOptions.env.ORCA_CODEX_HOME).toBe(systemHome) + expect(spawnOptions.env.REMOVE_ME).toBeUndefined() + expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME') + expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME') + expect(spawnOptions.envToDelete).toContain('REMOVE_ME') + }) + + it('prepares Codex project trust before a daemon-backed interactive launch', async () => { + const workspacePath = '/repo/worktrees/new-feature' + const resolveHome = vi.fn( + ( + _target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + _launchEnv?: NodeJS.ProcessEnv, + _launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + ) => null + ) + + await daemonSpawnAndGetOptions({}, resolveHome, undefined, undefined, { + cwd: workspacePath, + worktreeId: `repo-id::${workspacePath}`, + command: 'codex', + launchAgent: 'codex' + }) + + expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) + expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' }) + }) + it('injects explicit proxy settings on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, undefined, () => ({ httpProxyUrl: 'http://proxy.example:8080', @@ -3555,6 +3825,23 @@ describe('registerPtyHandlers', () => { deletePtyOwnership('remote-pty') }) + it('routes runtime exact liveness without enumerating provider sessions', () => { + const provider = getLocalPtyProvider() + const hasPty = vi.spyOn(provider, 'hasPty').mockImplementation((id) => id === 'live-pty') + const listProcesses = vi.spyOn(provider, 'listProcesses') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + hasPty: (ptyId: string) => boolean | null + } + + expect(controller.hasPty('live-pty')).toBe(true) + expect(controller.hasPty('missing-pty')).toBe(false) + expect(hasPty).toHaveBeenCalledTimes(2) + expect(listProcesses).not.toHaveBeenCalled() + }) + it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { registerSshPtyProvider('ssh-1', {} as never) setPtyOwnership('unsupported-pty', 'ssh-1') @@ -4635,6 +4922,18 @@ describe('registerPtyHandlers', () => { expect(reported).toEqual({ cols: 80, rows: 24 }) }) + it('preserves provider-owned null so the renderer re-forwards an unverified size', async () => { + setupProviderWithAppliedSize({ applied: null, getAppliedSize: async () => null }) + handlers.clear() + registerPtyHandlers(mainWindow as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 100, rows: 30, env: {} }) + const id = (spawn as { id: string }).id + resizeListener()(mainWindowIpcEvent, { id, cols: 80, rows: 24 }) + + const reported = await handlers.get('pty:getSize')!(null, { id }) + expect(reported).toBeNull() + }) + it('falls back to the requested size when getAppliedSize throws', async () => { // A dead daemon/relay must never throw across the IPC boundary or block. setupProviderWithAppliedSize({ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 047cd88749c..5738485dfda 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -32,7 +32,11 @@ import { } from '../../shared/pty-delivery-diagnostics' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { isTuiAgent } from '../../shared/tui-agent-config' -import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { isWslShellName, @@ -619,10 +623,17 @@ function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): stri return keysToDelete } -type GetSelectedCodexHomePath = ( +export type GetSelectedCodexHomePath = ( target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv + launchEnv?: NodeJS.ProcessEnv, + launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ) => string | null +export type PrepareCodexSessionResume = (args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}) => Promise<{ codexHomePath: string | null } | null> type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -746,6 +757,12 @@ function mergePtyEnvDeletions( return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys])) } +function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { + // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. + const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') + return filtered?.length ? filtered : undefined +} + function getInheritedAgentHookEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { @@ -1380,6 +1397,7 @@ export function registerPtyHandlers( prepareClaudeAuth?: PrepareClaudeAuth, store?: Store, options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise awaitLocalPtyProviderStartup?: () => Promise // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). @@ -1452,7 +1470,12 @@ export function registerPtyHandlers( : { runtime: 'host' } const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null + ctx?.codexHomePathOverride + ? ctx.codexHomePathOverride.value + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: ctx?.cwd, + launchAgent: ctx?.launchAgent + }) ?? null) ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const env = buildPtyHostEnv(id, baseEnv, { @@ -1601,7 +1624,7 @@ export function registerPtyHandlers( const backgroundedDeliverySyncByPty = new Map() function syncPtyBackgroundedDelivery(id: string, caller: string): void { const background = - rendererPtyIsKnownHidden(id) && !(runtime?.hasRemoteTerminalViewSubscriber(id) ?? false) + rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) { return } @@ -2695,6 +2718,29 @@ export function registerPtyHandlers( } } + const prepareCodexResumeHome = (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }): Promise<{ codexHomePath: string | null } | null> | null => { + if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) { + return null + } + const providerSession = normalizeAgentProviderSession(args.providerSession) + if (!providerSession) { + return null + } + return options.prepareCodexSessionResume({ + providerSession, + target: args.target, + launchEnv: args.launchEnv, + workspacePath: args.workspacePath + }) + } + // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. runtime?.setPtyController({ spawn: async (args) => { @@ -2725,6 +2771,15 @@ export function registerPtyHandlers( cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) + const codexResumePreparation = prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: codexSelectionTarget, + launchEnv: args.env, + workspacePath: cwd + }) + const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2794,7 +2849,12 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env) ?? null + codexResumeHome + ? codexResumeHome.codexHomePath + : (getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) ) : null const skipCodexHomeEnv = @@ -2842,6 +2902,16 @@ export function registerPtyHandlers( env, ...(isMintedSessionId ? { isNewSession: true } : {}) } + if (!isDaemonHostSpawn && codexResumeHome) { + spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath } + } + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + if (startupTerminalColorQueryReplyColors) { + spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } spawnOptions.envToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []), isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [] @@ -2858,6 +2928,9 @@ export function registerPtyHandlers( 'ORCA_CODEX_HOME' ]) } + if (codexResumeHome?.codexHomePath) { + spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete) + } deleteRequestedEnvKeys(env, spawnOptions.envToDelete) promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) if (args.command !== undefined) { @@ -3317,6 +3390,13 @@ export function registerPtyHandlers( /* best effort: renderer clear still handles local PTYs */ } }, + hasPty: (ptyId) => { + try { + return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null + } catch { + return null + } + }, listProcesses: async () => { const providerSessions = await Promise.all([ localProvider.listProcesses(), @@ -3459,6 +3539,7 @@ export function registerPtyHandlers( command?: string commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null @@ -3679,10 +3760,24 @@ export function registerPtyHandlers( cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) + const codexResumePreparation = prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: codexSelectionTarget, + launchEnv: baseEnv, + workspacePath: cwd + }) + const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null + codexResumeHome + ? codexResumeHome.codexHomePath + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) ) : null const skipCodexHomeEnv = @@ -3744,7 +3839,7 @@ export function registerPtyHandlers( const envToDelete = claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined - const combinedEnvToDelete = mergePtyEnvDeletions( + let combinedEnvToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions( mergePtyEnvDeletions( mergePtyEnvDeletions( @@ -3759,6 +3854,9 @@ export function registerPtyHandlers( // main cannot safely decide ownership for a process it may not parent. stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] ) + if (codexResumeHome?.codexHomePath) { + combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete) + } deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) const spawnOptions: PtySpawnOptions = { @@ -3768,6 +3866,9 @@ export function registerPtyHandlers( env: spawnEnv, ...(isMintedSessionId ? { isNewSession: true } : {}) } + if (!isDaemonHostSpawn && codexResumeHome) { + spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath } + } if (combinedEnvToDelete) { spawnOptions.envToDelete = combinedEnvToDelete } @@ -3821,10 +3922,7 @@ export function registerPtyHandlers( if (startupTerminalColorQueryReplyColors) { spawnOptions.startupIngress = { colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000, - ...(nativeWindowsConptySpawn - ? { echoProjection: 'windows-conpty-esc-stripped' as const } - : {}) + deadlineMs: 5_000 } } const existingPaneSpawn = reservationPaneKey @@ -4804,10 +4902,13 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:getSize', async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { + const provider = tryGetProviderForPty(args.id) try { - const applied = await tryGetProviderForPty(args.id)?.getAppliedSize?.(args.id) - if (applied) { - return applied + if (provider?.getAppliedSize) { + // Why: a provider-owned null means it could not verify the applied + // grid; preserve null so the renderer re-forwards instead of trusting + // the requested-size cache that may describe a dropped resize. + return await provider.getAppliedSize(args.id) } } catch { // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. @@ -4875,7 +4976,8 @@ export function registerHeadlessPtyRuntime( getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store + store?: Store, + prepareCodexSessionResume?: PrepareCodexSessionResume ): void { // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. const headlessWindow = { @@ -4892,7 +4994,8 @@ export function registerHeadlessPtyRuntime( getSelectedCodexHomePath, getSettings, prepareClaudeAuth, - store + store, + { prepareCodexSessionResume } ) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index c7ff8080a28..787053b4995 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -54,6 +54,8 @@ const { registerHostedReviewHandlersMock, registerExportHandlersMock, registerOnboardingHandlersMock, + registerDashboardPopoutHandlersMock, + registerTerminalPreviewHandlersMock, registerSpeechHandlersMock, registerSkillsHandlersMock, registerWorkspaceSpaceHandlersMock, @@ -116,6 +118,8 @@ const { registerHostedReviewHandlersMock: vi.fn(), registerExportHandlersMock: vi.fn(), registerOnboardingHandlersMock: vi.fn(), + registerDashboardPopoutHandlersMock: vi.fn(), + registerTerminalPreviewHandlersMock: vi.fn(), registerSpeechHandlersMock: vi.fn(), registerSkillsHandlersMock: vi.fn(), registerWorkspaceSpaceHandlersMock: vi.fn(), @@ -144,6 +148,14 @@ vi.mock('./onboarding', () => ({ registerOnboardingHandlers: registerOnboardingHandlersMock })) +vi.mock('./dashboard-popout', () => ({ + registerDashboardPopoutHandlers: registerDashboardPopoutHandlersMock +})) + +vi.mock('./terminal-preview', () => ({ + registerTerminalPreviewHandlers: registerTerminalPreviewHandlersMock +})) + vi.mock('./speech', () => ({ registerSpeechHandlers: registerSpeechHandlersMock })) @@ -417,6 +429,8 @@ describe('registerCoreHandlers', () => { registerGitLabHandlersMock.mockReset() registerHostedReviewHandlersMock.mockReset() registerExportHandlersMock.mockReset() + registerDashboardPopoutHandlersMock.mockReset() + registerTerminalPreviewHandlersMock.mockReset() registerSpeechHandlersMock.mockReset() registerSkillsHandlersMock.mockReset() registerWorkspaceSpaceHandlersMock.mockReset() @@ -493,6 +507,8 @@ describe('registerCoreHandlers', () => { expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime) expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled() expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled() + expect(registerDashboardPopoutHandlersMock).toHaveBeenCalledWith(store) + expect(registerTerminalPreviewHandlersMock).toHaveBeenCalledWith(runtime) expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService) expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store) expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store) @@ -512,7 +528,8 @@ describe('registerCoreHandlers', () => { expect.objectContaining({ getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths, getActiveRuntimeAiVaultHostInfos: expect.any(Function), - scanRuntimeAiVaultSessions: expect.any(Function) + scanRuntimeAiVaultSessions: expect.any(Function), + prepareRuntimeSessionResume: expect.any(Function) }) ) expect(aiVaultOptions.getActiveRuntimeAiVaultHostInfos()).toEqual([]) @@ -562,6 +579,26 @@ describe('registerCoreHandlers', () => { }, 3000 ) + + callRuntimeEnvironmentMock.mockResolvedValueOnce({ + ok: true, + result: { useRealCodexHome: true } + }) + const prepareArgs = { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-123' + } + await expect( + aiVaultOptions.prepareRuntimeSessionResume('env-123', prepareArgs) + ).resolves.toEqual({ useRealCodexHome: true }) + expect(callRuntimeEnvironmentMock).toHaveBeenLastCalledWith( + '/test/user-data', + 'env-123', + 'aiVault.prepareSessionResume', + prepareArgs + ) }) it('only registers IPC handlers once but always updates web contents id', () => { diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 1542c49c440..ec25e2779ff 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -30,6 +30,8 @@ import { registerNativeChatHandlers } from './native-chat' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' import { registerOnboardingHandlers } from './onboarding' +import { registerDashboardPopoutHandlers } from './dashboard-popout' +import { registerTerminalPreviewHandlers } from './terminal-preview' import { registerDeveloperPermissionHandlers } from './developer-permissions' import { registerComputerUsePermissionHandlers } from './computer-use-permissions' import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' @@ -74,8 +76,13 @@ import type { AutomationService } from '../automations/service' import type { AgentAwakeService } from '../agent-awake-service' import type { CrashReportStore } from '../crash-reporting/crash-report-store' import type { KeybindingService } from '../keybindings/keybinding-service' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' import { getSavedRuntimeAiVaultHostInfos, + prepareRuntimeAiVaultSessionResume, scanRuntimeAiVaultSessions } from '../ai-vault/runtime-session-scanner' @@ -86,6 +93,9 @@ type CoreHandlerLifecycleOptions = { onOrcaProfileAuthMutation?: () => void onBeforeOrcaProfileSignOut?: () => void getAdditionalAiVaultCodexHomePaths?: () => readonly string[] + prepareAiVaultSessionResume?: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise } export function registerCoreHandlers( @@ -147,6 +157,8 @@ export function registerCoreHandlers( registerNotificationHandlers(store, runtime) registerNotebookHandlers(store) registerOnboardingHandlers(store) + registerDashboardPopoutHandlers(store) + registerTerminalPreviewHandlers(runtime) registerDeveloperPermissionHandlers() // Why: diagnostics handlers are wired alongside telemetry but the two // lanes never share a code path — `ipc/diagnostics.ts` imports only from @@ -190,10 +202,13 @@ export function registerCoreHandlers( registerEphemeralVmHandlers(store) registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, + prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => getSavedRuntimeAiVaultHostInfos(app.getPath('userData')), scanRuntimeAiVaultSessions: async (environmentId, args, options) => - scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options) + scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options), + prepareRuntimeSessionResume: async (environmentId, args) => + prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args) }) registerNativeChatHandlers() registerClipboardHandlers(store) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index d7786b7ca4e..61bbbc76ead 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -1881,6 +1881,61 @@ describe('repos:add + repos:clone', () => { expect(mockStore.addRepo).not.toHaveBeenCalled() }) + it('preserves the selected Enterprise host when aligning an existing folder', async () => { + const existing = { + id: 'repo-setup-enterprise', + path: '/tmp/from-setup-enterprise', + displayName: 'from-setup-enterprise', + kind: 'git', + badgeColor: '#22c55e' + } + const existingProject = { id: 'repo:repo-setup-enterprise', displayName: 'Existing' } + const selectedProject = { + id: 'github:github.acme-corp.com/acme/orca', + displayName: 'Enterprise project', + providerIdentity: { + provider: 'github', + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + } + } + const setup = { + id: existing.id, + projectId: existingProject.id, + repoId: existing.id, + hostId: 'local', + path: existing.path, + displayName: existing.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo' + } + let updatedRepo = existing + mockStore.getRepos.mockReturnValue([existing]) + mockStore.getProjects.mockReturnValue([existingProject, selectedProject]) + mockStore.getProjectHostSetups.mockReturnValue([setup]) + mockStore.updateRepo.mockImplementation((_repoId, updates) => { + updatedRepo = { ...updatedRepo, ...updates } + return updatedRepo + }) + + await handlers.get('projectHostSetups:setupExistingFolder')!(null, { + projectId: selectedProject.id, + hostId: 'local', + path: existing.path, + kind: 'git', + setupMethod: 'imported-existing-folder' + }) + + expect(mockStore.updateRepo).toHaveBeenNthCalledWith(1, existing.id, { + upstream: { + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + } + }) + }) + it('prepares and invalidates roots when repos:update changes worktree base path', () => { const updated = { id: 'repo-update-root', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 54e440dba74..fd0ddcf770d 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -143,7 +143,8 @@ function alignRepoWithRequestedProject( const updated = store.updateRepo(repo.id, { upstream: { owner: project.providerIdentity.owner, - repo: project.providerIdentity.repo + repo: project.providerIdentity.repo, + ...(project.providerIdentity.host ? { host: project.providerIdentity.host } : {}) } }) if (!updated) { diff --git a/src/main/ipc/terminal-preview-output-stream.ts b/src/main/ipc/terminal-preview-output-stream.ts new file mode 100644 index 00000000000..1eb9699ecb3 --- /dev/null +++ b/src/main/ipc/terminal-preview-output-stream.ts @@ -0,0 +1,238 @@ +import type { WebContents } from 'electron' +import { iterateTerminalInputChunks } from '../../shared/terminal-input' +import type { TerminalPreviewDataPayload } from '../../shared/terminal-preview' + +const OUTPUT_BATCH_MS = 5 +export const TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 +const OUTPUT_IN_FLIGHT_MAX_BYTES = 512 * 1024 +const OUTPUT_PENDING_MAX_BYTES = 256 * 1024 +const INITIAL_PENDING_MAX_BYTES = 256 * 1024 + +export type TerminalPreviewOutputMeta = { + seq?: number + rawLength?: number + transformed?: boolean +} + +type PendingOutput = { data: string; bytes: number; meta?: TerminalPreviewOutputMeta } + +function outputAfterSnapshotSeq(output: PendingOutput, snapshotSeq?: number): string | null { + if ( + typeof snapshotSeq !== 'number' || + typeof output.meta?.seq !== 'number' || + typeof output.meta.rawLength !== 'number' + ) { + return output.data + } + if (output.meta.seq <= snapshotSeq) { + return null + } + const startSeq = output.meta.seq - output.meta.rawLength + if (startSeq >= snapshotSeq || output.meta.transformed === true) { + return output.data + } + return output.data.slice(snapshotSeq - startSeq) +} + +/** Owns one preview's bounded snapshot/live output queue and acknowledgements. */ +export class TerminalPreviewOutputStream { + private bufferingSnapshot = true + private initialPending: PendingOutput[] = [] + private initialPendingBytes = 0 + private initialPendingOverflowed = false + private batchChunks: string[] = [] + private batchBytes = 0 + private batchTimer: ReturnType | null = null + private inFlightBytes = 0 + private pendingBatches: { data: string; bytes: number }[] = [] + private pendingBatchBytes = 0 + private resyncPending = false + private awaitingReconnect = false + private unsubscribeData: () => void = () => {} + private isDisposed = false + + constructor( + readonly contents: WebContents, + readonly ptyId: string, + private readonly releaseRawView: () => void, + private readonly onDispose: (stream: TerminalPreviewOutputStream) => void + ) {} + + get disposed(): boolean { + return this.isDisposed + } + + setDataSubscription(unsubscribe: () => void): void { + this.unsubscribeData = unsubscribe + } + + dispose(): void { + if (this.isDisposed) { + return + } + this.isDisposed = true + if (this.batchTimer) { + clearTimeout(this.batchTimer) + this.batchTimer = null + } + this.unsubscribeData() + this.releaseRawView() + this.onDispose(this) + } + + append(data: string, meta?: TerminalPreviewOutputMeta): void { + if (this.bufferingSnapshot) { + this.appendInitial(data, meta) + } else { + this.appendLive(data) + } + } + + consumeInitialOverflow(): boolean { + if (!this.initialPendingOverflowed) { + return false + } + this.initialPending = [] + this.initialPendingBytes = 0 + this.initialPendingOverflowed = false + return true + } + + completeSnapshot(snapshotSeq?: number): string[] { + const replay = this.initialPending.flatMap((output) => { + const uncovered = outputAfterSnapshotSeq(output, snapshotSeq) + return uncovered ? [uncovered] : [] + }) + this.initialPending = [] + this.initialPendingBytes = 0 + this.bufferingSnapshot = false + return replay + } + + pauseForReconnect(): void { + if (this.batchTimer) { + clearTimeout(this.batchTimer) + this.batchTimer = null + } + this.batchChunks = [] + this.batchBytes = 0 + this.pendingBatches = [] + this.pendingBatchBytes = 0 + this.awaitingReconnect = true + } + + acknowledge(bytes: number): void { + this.inFlightBytes = Math.max( + 0, + this.inFlightBytes - Math.min(this.inFlightBytes, Math.floor(bytes)) + ) + this.maybeDrain() + } + + private send(payload: TerminalPreviewDataPayload): boolean { + if (this.isDisposed || this.contents.isDestroyed()) { + this.dispose() + return false + } + try { + this.contents.send('terminalPreview:data', payload) + return true + } catch { + this.dispose() + return false + } + } + + private maybeDrain(): void { + if (this.isDisposed || this.awaitingReconnect) { + return + } + if (this.resyncPending) { + if (this.inFlightBytes === 0) { + this.resyncPending = false + this.awaitingReconnect = true + this.send({ type: 'resync', ptyId: this.ptyId }) + } + return + } + while (this.pendingBatches.length > 0) { + const next = this.pendingBatches[0]! + if (this.inFlightBytes > 0 && this.inFlightBytes + next.bytes > OUTPUT_IN_FLIGHT_MAX_BYTES) { + break + } + this.pendingBatches.shift() + this.pendingBatchBytes -= next.bytes + if (this.send({ type: 'data', ptyId: this.ptyId, data: next.data, bytes: next.bytes })) { + this.inFlightBytes += next.bytes + } + } + } + + private queueBatch(data: string, bytes: number): void { + if (this.isDisposed || this.awaitingReconnect || this.resyncPending) { + return + } + if ( + this.pendingBatches.length === 0 && + (this.inFlightBytes === 0 || this.inFlightBytes + bytes <= OUTPUT_IN_FLIGHT_MAX_BYTES) + ) { + if (this.send({ type: 'data', ptyId: this.ptyId, data, bytes })) { + this.inFlightBytes += bytes + } + return + } + this.pendingBatches.push({ data, bytes }) + this.pendingBatchBytes += bytes + if (this.pendingBatchBytes > OUTPUT_PENDING_MAX_BYTES) { + // Why: a stuck renderer heals from a fresh authoritative snapshot instead of retaining output without bound. + this.pendingBatches = [] + this.pendingBatchBytes = 0 + this.resyncPending = true + } + } + + private flushBatch(): void { + if (this.batchTimer) { + clearTimeout(this.batchTimer) + this.batchTimer = null + } + if (this.batchChunks.length === 0) { + return + } + const data = this.batchChunks.length === 1 ? this.batchChunks[0]! : this.batchChunks.join('') + const bytes = this.batchBytes + this.batchChunks = [] + this.batchBytes = 0 + this.queueBatch(data, bytes) + } + + private appendLive(data: string): void { + for (const chunk of iterateTerminalInputChunks(data, TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES)) { + const bytes = Buffer.byteLength(chunk, 'utf8') + if ( + this.batchBytes > 0 && + this.batchBytes + bytes > TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES + ) { + this.flushBatch() + } + this.batchChunks.push(chunk) + this.batchBytes += bytes + if (this.batchBytes >= TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES) { + this.flushBatch() + } else if (!this.batchTimer) { + this.batchTimer = setTimeout(() => this.flushBatch(), OUTPUT_BATCH_MS) + this.batchTimer.unref?.() + } + } + } + + private appendInitial(data: string, meta?: TerminalPreviewOutputMeta): void { + const bytes = Buffer.byteLength(data, 'utf8') + this.initialPending.push({ data, bytes, meta }) + this.initialPendingBytes += bytes + while (this.initialPendingBytes > INITIAL_PENDING_MAX_BYTES && this.initialPending.length > 0) { + this.initialPendingBytes -= this.initialPending.shift()!.bytes + this.initialPendingOverflowed = true + } + } +} diff --git a/src/main/ipc/terminal-preview.test.ts b/src/main/ipc/terminal-preview.test.ts new file mode 100644 index 00000000000..cb5cf50d626 --- /dev/null +++ b/src/main/ipc/terminal-preview.test.ts @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, ipcMainMock, isDashboardPopoutRendererMock } = vi.hoisted(() => { + const map = new Map unknown>() + return { + handlers: map, + ipcMainMock: { + removeHandler: vi.fn(), + handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) + }, + isDashboardPopoutRendererMock: vi.fn(() => true) + } +}) + +vi.mock('electron', () => ({ ipcMain: ipcMainMock })) +vi.mock('../window/dashboard-popout-window', () => ({ + isDashboardPopoutRenderer: isDashboardPopoutRendererMock +})) + +import { registerTerminalPreviewHandlers } from './terminal-preview' + +type OutputMeta = { seq?: number; rawLength?: number; transformed?: boolean } +type Listener = (data: string, meta?: OutputMeta) => void + +function makeRuntime() { + const listeners: Listener[] = [] + const unsubscribe = vi.fn() + const releaseRawView = vi.fn() + return { + listeners, + unsubscribe, + releaseRawView, + serializeTerminalBuffer: vi.fn( + async (): Promise<{ data: string; cols: number; rows: number; seq: number } | null> => ({ + data: 'screen', + cols: 80, + rows: 20, + seq: 5 + }) + ), + subscribeToTerminalData: vi.fn((_ptyId: string, listener: Listener) => { + listeners.push(listener) + return unsubscribe + }), + registerRawTerminalViewSubscriber: vi.fn(() => releaseRawView), + writeTerminalPreviewInput: vi.fn(async () => true) + } +} + +function makeSender(id = 1) { + const destroyedListeners: (() => void)[] = [] + return { + id, + isDestroyed: () => false, + send: vi.fn(), + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedListeners.push(cb) + } + }, + fireDestroyed: () => destroyedListeners.forEach((cb) => cb()) + } +} + +function eventFor(sender: ReturnType) { + return { sender } as never +} + +describe('registerTerminalPreviewHandlers', () => { + beforeEach(() => { + handlers.clear() + isDashboardPopoutRendererMock.mockReturnValue(true) + }) + afterEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('subscribes before snapshot acquisition and replays only bytes after its sequence', async () => { + const runtime = makeRuntime() + let resolveSnapshot!: (snapshot: { + data: string + cols: number + rows: number + seq: number + }) => void + runtime.serializeTerminalBuffer.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + const resultPromise = handlers.get('terminalPreview:connect')!(eventFor(sender), { + ptyId: 'p1', + opts: { scrollbackRows: 24 } + }) as Promise + + expect(runtime.subscribeToTerminalData).toHaveBeenCalledBefore(runtime.serializeTerminalBuffer) + runtime.listeners[0]!('abc', { seq: 7, rawLength: 3 }) + resolveSnapshot({ data: 'screen', cols: 80, rows: 20, seq: 6 }) + + await expect(resultPromise).resolves.toEqual({ + snapshot: { data: 'screen', cols: 80, rows: 20, seq: 6 }, + replay: ['c'] + }) + expect(runtime.registerRawTerminalViewSubscriber).toHaveBeenCalledWith('p1') + }) + + it('coalesces live chunks before crossing IPC and releases them with acknowledgements', async () => { + vi.useFakeTimers() + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + + runtime.listeners[0]!('a') + runtime.listeners[0]!('b') + expect(sender.send).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5) + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('terminalPreview:data', { + type: 'data', + ptyId: 'p1', + data: 'ab', + bytes: 2 + }) + + handlers.get('terminalPreview:ack')!(eventFor(sender), { ptyId: 'p1', bytes: 2 }) + expect(sender.send).toHaveBeenCalledTimes(1) + }) + + it('bounds an unresponsive renderer and requests a snapshot resync after prior writes drain', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + + const chunk = 'x'.repeat(64 * 1024) + for (let index = 0; index < 13; index++) { + runtime.listeners[0]!(chunk) + } + const dataSends = sender.send.mock.calls.filter(([, payload]) => payload.type === 'data') + expect(dataSends).toHaveLength(8) + + for (let index = 0; index < 8; index++) { + handlers.get('terminalPreview:ack')!(eventFor(sender), { + ptyId: 'p1', + bytes: 64 * 1024 + }) + } + expect(sender.send).toHaveBeenLastCalledWith('terminalPreview:data', { + type: 'resync', + ptyId: 'p1' + }) + }) + + it('never overshoots the in-flight byte budget with partial batches', async () => { + vi.useFakeTimers() + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + + const fullChunk = 'x'.repeat(64 * 1024) + for (let index = 0; index < 7; index++) { + runtime.listeners[0]!(fullChunk) + } + runtime.listeners[0]!('a'.repeat(40 * 1024)) + await vi.advanceTimersByTimeAsync(5) + runtime.listeners[0]!('b'.repeat(40 * 1024)) + await vi.advanceTimersByTimeAsync(5) + expect(sender.send).toHaveBeenCalledTimes(8) + + handlers.get('terminalPreview:ack')!(eventFor(sender), { + ptyId: 'p1', + bytes: 64 * 1024 + }) + expect(sender.send).toHaveBeenCalledTimes(9) + }) + + it('fails safe to another resync when output overflows both snapshot captures', async () => { + const runtime = makeRuntime() + const snapshotResolvers: ((snapshot: { + data: string + cols: number + rows: number + seq: number + }) => void)[] = [] + runtime.serializeTerminalBuffer.mockImplementation( + () => + new Promise((resolve) => { + snapshotResolvers.push(resolve) + }) + ) + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + const resultPromise = handlers.get('terminalPreview:connect')!(eventFor(sender), { + ptyId: 'p1' + }) as Promise + const chunk = 'x'.repeat(64 * 1024) + for (let index = 0; index < 5; index++) { + runtime.listeners[0]!(chunk) + } + snapshotResolvers[0]!({ data: 'first', cols: 80, rows: 20, seq: 1 }) + await vi.waitFor(() => expect(snapshotResolvers).toHaveLength(2)) + for (let index = 0; index < 5; index++) { + runtime.listeners[0]!(chunk) + } + snapshotResolvers[1]!({ data: 'second', cols: 80, rows: 20, seq: 2 }) + + await expect(resultPromise).resolves.toEqual({ + snapshot: { data: 'second', cols: 80, rows: 20, seq: 2 }, + replay: [], + resyncRequired: true + }) + runtime.listeners[0]!('held until reconnect') + expect(sender.send).not.toHaveBeenCalled() + }) + + it('releases output and raw-view presence on unsubscribe and sender destruction', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + handlers.get('terminalPreview:unsubscribe')!(eventFor(sender), { ptyId: 'p1' }) + expect(runtime.unsubscribe).toHaveBeenCalledTimes(1) + expect(runtime.releaseRawView).toHaveBeenCalledTimes(1) + + await handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p2' }) + sender.fireDestroyed() + expect(runtime.unsubscribe).toHaveBeenCalledTimes(2) + expect(runtime.releaseRawView).toHaveBeenCalledTimes(2) + }) + + it('releases output and raw-view presence when no snapshot exists', async () => { + const runtime = makeRuntime() + runtime.serializeTerminalBuffer.mockResolvedValueOnce(null) + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + await expect( + handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'missing' }) + ).resolves.toEqual({ snapshot: null, replay: [] }) + expect(runtime.unsubscribe).toHaveBeenCalledTimes(1) + expect(runtime.releaseRawView).toHaveBeenCalledTimes(1) + }) + + it('rejects non-dashboard senders on every preview channel', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + isDashboardPopoutRendererMock.mockReturnValue(false) + + await expect( + handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + ).resolves.toEqual({ snapshot: null, replay: [] }) + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: 'p1', data: 'x' }) + ).resolves.toBe(false) + handlers.get('terminalPreview:ack')!(eventFor(sender), { ptyId: 'p1', bytes: 1 }) + handlers.get('terminalPreview:unsubscribe')!(eventFor(sender), { ptyId: 'p1' }) + + expect(runtime.serializeTerminalBuffer).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(runtime.writeTerminalPreviewInput).not.toHaveBeenCalled() + }) + + it('validates input before routing it to the runtime', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: 'p1', data: 'ls\r' }) + ).resolves.toBe(true) + expect(runtime.writeTerminalPreviewInput).toHaveBeenCalledWith('p1', 'ls\r') + + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: '', data: 'x' }) + ).resolves.toBe(false) + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { + ptyId: 'x'.repeat(4097), + data: 'x' + }) + ).resolves.toBe(false) + expect(runtime.writeTerminalPreviewInput).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/terminal-preview.ts b/src/main/ipc/terminal-preview.ts new file mode 100644 index 00000000000..3df3c472695 --- /dev/null +++ b/src/main/ipc/terminal-preview.ts @@ -0,0 +1,155 @@ +import { ipcMain, type WebContents } from 'electron' +import type { + TerminalPreviewConnectResult, + TerminalPreviewSnapshot +} from '../../shared/terminal-preview' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { isDashboardPopoutRenderer } from '../window/dashboard-popout-window' +import { + TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES, + TerminalPreviewOutputStream +} from './terminal-preview-output-stream' + +const PREVIEW_ID_MAX_LENGTH = 4096 + +function isValidPtyId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= PREVIEW_ID_MAX_LENGTH +} + +/** Pop-out terminal transport with an atomic snapshot/live boundary. */ +export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): void { + ipcMain.removeHandler('terminalPreview:connect') + ipcMain.removeHandler('terminalPreview:unsubscribe') + ipcMain.removeHandler('terminalPreview:input') + ipcMain.removeHandler('terminalPreview:ack') + + const subscriptionsByContents = new Map>() + + const removeSubscription = (subscription: TerminalPreviewOutputStream): void => { + const perPty = subscriptionsByContents.get(subscription.contents.id) + if (perPty?.get(subscription.ptyId) === subscription) { + perPty.delete(subscription.ptyId) + } + } + + const disposeContents = (contentsId: number): void => { + const perPty = subscriptionsByContents.get(contentsId) + if (!perPty) { + return + } + for (const subscription of perPty.values()) { + subscription.dispose() + } + subscriptionsByContents.delete(contentsId) + } + + const subscriptionsFor = (contents: WebContents): Map => { + let perPty = subscriptionsByContents.get(contents.id) + if (!perPty) { + perPty = new Map() + subscriptionsByContents.set(contents.id, perPty) + contents.once('destroyed', () => disposeContents(contents.id)) + } + return perPty + } + + ipcMain.handle( + 'terminalPreview:connect', + async ( + event, + args: { ptyId?: unknown; opts?: { scrollbackRows?: unknown } } + ): Promise => { + if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { + return { snapshot: null, replay: [] } + } + const ptyId = args.ptyId + const perPty = subscriptionsFor(event.sender) + perPty.get(ptyId)?.dispose() + + const subscription = new TerminalPreviewOutputStream( + event.sender, + ptyId, + runtime.registerRawTerminalViewSubscriber(ptyId), + removeSubscription + ) + subscription.setDataSubscription( + runtime.subscribeToTerminalData(ptyId, (data, meta) => subscription.append(data, meta)) + ) + perPty.set(ptyId, subscription) + + const requestedRows = args.opts?.scrollbackRows + const scrollbackRows = + typeof requestedRows === 'number' && Number.isFinite(requestedRows) + ? Math.max(0, Math.min(1000, Math.floor(requestedRows))) + : undefined + let snapshot: TerminalPreviewSnapshot | null + let resyncRequired = false + try { + snapshot = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows }) + if (subscription.consumeInitialOverflow() && !subscription.disposed) { + snapshot = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows }) + if (subscription.consumeInitialOverflow()) { + // Why: never replay a tail with a silently missing middle; the renderer keeps its old frame while reconnecting. + resyncRequired = true + } + } + } catch { + subscription.dispose() + return { snapshot: null, replay: [] } + } + if (subscription.disposed) { + return { snapshot: null, replay: [] } + } + if (!snapshot) { + // Why: a failed lookup has no future live boundary; release raw presence even if the renderer never invokes unsubscribe. + subscription.dispose() + return { snapshot: null, replay: [] } + } + + const replay = subscription.completeSnapshot(snapshot.seq) + if (resyncRequired) { + // Why: no live writes may outlive this stream and acknowledge bytes against its replacement. + subscription.pauseForReconnect() + } + return { snapshot, replay, ...(resyncRequired ? { resyncRequired: true } : {}) } + } + ) + + ipcMain.handle( + 'terminalPreview:input', + (event, args: { ptyId?: unknown; data?: unknown }): Promise => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isValidPtyId(args?.ptyId) || + typeof args.data !== 'string' + ) { + return Promise.resolve(false) + } + return runtime.writeTerminalPreviewInput(args.ptyId, args.data) + } + ) + + ipcMain.handle( + 'terminalPreview:ack', + (event, args: { ptyId?: unknown; bytes?: unknown }): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isValidPtyId(args?.ptyId) || + typeof args.bytes !== 'number' || + !Number.isFinite(args.bytes) || + args.bytes <= 0 || + args.bytes > TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES + ) { + return + } + subscriptionsByContents.get(event.sender.id)?.get(args.ptyId)?.acknowledge(args.bytes) + } + ) + + ipcMain.handle('terminalPreview:unsubscribe', (event, args: { ptyId?: unknown }): void => { + if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { + return + } + subscriptionsByContents.get(event.sender.id)?.get(args.ptyId)?.dispose() + }) +} diff --git a/src/main/ipc/ui.test.ts b/src/main/ipc/ui.test.ts index 806d0fa5ab3..398efa8065a 100644 --- a/src/main/ipc/ui.test.ts +++ b/src/main/ipc/ui.test.ts @@ -36,6 +36,7 @@ vi.mock('electron', () => ({ import { clearTrustedUIRendererWebContentsId, + getTrustedUIRendererWindow, registerUIHandlers, sendToTrustedUIRenderer, setTrustedUIRendererWebContentsId @@ -112,6 +113,20 @@ describe('UI IPC', () => { expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0) }) + it('resolves only the BrowserWindow that owns the trusted renderer', () => { + const renderer = { id: 17, isDestroyed: () => false } + const mainWindow = { id: 'main' } + fromIdMock.mockReturnValue(renderer) + fromWebContentsMock.mockReturnValue(mainWindow) + setTrustedUIRendererWebContentsId(17) + + expect(getTrustedUIRendererWindow()).toBe(mainWindow) + expect(fromWebContentsMock).toHaveBeenCalledWith(renderer) + + fromIdMock.mockReturnValue({ id: 17, isDestroyed: () => true }) + expect(getTrustedUIRendererWindow()).toBeNull() + }) + it('skips missing, destroyed, and originating renderers', () => { const rendererSend = vi.fn() setTrustedUIRendererWebContentsId(17) diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index de6f26136d5..83744b0404b 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -20,17 +20,28 @@ export function sendToTrustedUIRenderer( payload: unknown, excludedWebContentsId?: number ): void { + const renderer = getTrustedUIRendererWebContents(excludedWebContentsId) + renderer?.send(channel, payload) +} + +export function getTrustedUIRendererWebContents( + excludedWebContentsId?: number +): WebContents | null { + // Why: exact targeting avoids waking retained browser/utility windows that cannot consume app UI events. const rendererId = trustedUIRendererWebContentsId if (rendererId == null || rendererId === excludedWebContentsId) { - return + return null } const renderer = webContents.fromId(rendererId) if (!renderer || renderer.isDestroyed()) { - return + return null } - // Why: app events belong to the registered UI renderer; enumerating every - // WebContents wakes retained browser guests that cannot consume the channel. - renderer.send(channel, payload) + return renderer +} + +export function getTrustedUIRendererWindow(): BrowserWindow | null { + const renderer = getTrustedUIRendererWebContents() + return renderer ? BrowserWindow.fromWebContents(renderer) : null } export function registerUIHandlers(store: Store): void { diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index 0f66860022e..7f518afe6e8 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -102,9 +102,13 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { return { ...actual, homedir: () => tempHome } }) class AuthenticationLinearError extends Error {} - vi.doMock('@linear/sdk', () => ({ - AuthenticationLinearError, - LinearClient: linearClientMock + // Why: client.ts loads @linear/sdk lazily via ./linear-sdk (createRequire) to + // keep it off the startup parse path; mock the loader, not the raw module. + vi.doMock('./linear-sdk', () => ({ + loadLinearSdk: () => ({ + AuthenticationLinearError, + LinearClient: linearClientMock + }) })) return import('./client') diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index bbd57fbd666..7058d130597 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -2,10 +2,11 @@ selection share one module so keychain-safe status reads and token mutation stay in one consistency boundary. */ import { safeStorage } from 'electron' -import { LinearClient, AuthenticationLinearError } from '@linear/sdk' +import type { LinearClient } from '@linear/sdk' import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { loadLinearSdk } from './linear-sdk' import { CredentialDecryptionError, credentialFileHasContent, @@ -515,7 +516,7 @@ export function getClient(workspaceId?: string | null): LinearClient | null { if (!token) { return null } - return new LinearClient({ apiKey: token }) + return new (loadLinearSdk().LinearClient)({ apiKey: token }) } export function getClients( @@ -546,13 +547,17 @@ export function getClients( if (!token) { continue } - clients.push({ workspace, client: new LinearClient({ apiKey: token }), apiKey: token }) + clients.push({ + workspace, + client: new (loadLinearSdk().LinearClient)({ apiKey: token }), + apiKey: token + }) } return clients } export function getPublicFileUrlClient(entry: LinearClientForWorkspace): LinearClient { - return new LinearClient({ + return new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, headers: { 'public-file-urls-expire-in': String(LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS) @@ -565,7 +570,7 @@ export function getPublicFileUrlClient(entry: LinearClientForWorkspace): LinearC // renderer. All other errors are swallowed with console.warn to match GitHub // client's graceful degradation. export function isAuthError(error: unknown): boolean { - return error instanceof AuthenticationLinearError + return error instanceof loadLinearSdk().AuthenticationLinearError } // ── Connect / disconnect / status ──────────────────────────────────── @@ -575,7 +580,7 @@ export async function connect( { ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string } > { try { - const client = new LinearClient({ apiKey }) + const client = new (loadLinearSdk().LinearClient)({ apiKey }) const me = await client.viewer const org = await me.organization const workspace = workspaceFromLinearData(me, org) @@ -670,7 +675,7 @@ export async function testConnection( } try { - const client = new LinearClient({ apiKey: token }) + const client = new (loadLinearSdk().LinearClient)({ apiKey: token }) const me = await client.viewer const org = await me.organization const workspace = workspaceFromLinearData(me, org) diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index e4509c9e6a6..ac0f854c739 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -9,7 +9,8 @@ import type { LinearWorkspaceError, LinearWorkspaceSelection } from '../../shared/types' -import { LinearClient } from '@linear/sdk' +import type { LinearClient } from '@linear/sdk' +import { loadLinearSdk } from './linear-sdk' import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit @@ -602,7 +603,9 @@ async function runLinearWrite( ): Promise { await acquire() try { - const client = signal ? new LinearClient({ apiKey: entry.apiKey, signal }) : entry.client + const client = signal + ? new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, signal }) + : entry.client return await write(client) } catch (error) { if (error instanceof LinearWriteFailure) { diff --git a/src/main/linear/linear-sdk.ts b/src/main/linear/linear-sdk.ts new file mode 100644 index 00000000000..7a41b6bbfeb --- /dev/null +++ b/src/main/linear/linear-sdk.ts @@ -0,0 +1,32 @@ +import type { LinearClient } from '@linear/sdk' +import { createRequire } from 'node:module' + +// The subset of @linear/sdk that client.ts constructs / type-checks. Declared +// structurally (not `typeof import('@linear/sdk')`) so this loader never forces +// an eager module import and satisfies the no-`import()`-type lint rule. +export type LinearSdkModule = { + LinearClient: new (options: { + apiKey: string + headers?: Record + signal?: AbortSignal + }) => LinearClient + AuthenticationLinearError: new (...args: never[]) => Error +} + +// Why: @linear/sdk is a ~2.6MB CJS bundle (~33ms to parse). The Linear client +// module is imported on the main-process startup path (orca-runtime + ipc), but +// the SDK itself is only needed once the user actually acts on Linear. Load it +// lazily via createRequire so launch never parses it for the majority who never +// connect Linear, and cache it so repeat calls are free. The accessor stays +// synchronous so the client factories don't have to become async. +// +// Kept in its own module (rather than a bare createRequire in client.ts) so +// tests can mock the loader — a raw createRequire bypasses vitest's module +// registry, but `vi.doMock('./linear-sdk', …)` on this module works. +const requireFromMain = createRequire(__filename) +let cached: LinearSdkModule | null = null + +export function loadLinearSdk(): LinearSdkModule { + cached ??= requireFromMain('@linear/sdk') as LinearSdkModule + return cached +} diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index 0b459cb5ed1..63f8026b870 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import os from 'node:os' import type { MemorySnapshotStore } from './collector' type AppMetricFixture = { @@ -8,8 +9,9 @@ type AppMetricFixture = { memory: { workingSetSize: number } } -const { appMetricsMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({ +const { appMetricsMock, execFileMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({ appMetricsMock: vi.fn<() => AppMetricFixture[]>(() => []), + execFileMock: vi.fn(), execMock: vi.fn(), listRegisteredPtysMock: vi.fn() })) @@ -22,7 +24,13 @@ vi.mock('electron', () => ({ vi.mock('child_process', () => ({ exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) => - execMock(cmd, opts, cb) + execMock(cmd, opts, cb), + execFile: ( + file: string, + args: string[], + opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => execFileMock(file, args, opts, cb) })) vi.mock('./pty-registry', () => ({ @@ -34,6 +42,11 @@ async function loadCollector() { return await import('./collector') } +async function loadWindowsProcessResourceCollector() { + vi.resetModules() + return await import('./windows-process-resource-collector') +} + const emptyStore = { getWorktreeMeta: () => undefined, getRepo: () => undefined @@ -102,48 +115,65 @@ describe('parsePsOutput', () => { }) }) -describe('parseWmicOutput', () => { - it('emits one row per blank-line-delimited stanza', async () => { - const { parseWmicOutput } = await loadCollector() - const stdout = [ - 'ParentProcessId=1', - 'ProcessId=100', - 'WorkingSetSize=2048', - '', - 'ParentProcessId=100', - 'ProcessId=200', - 'WorkingSetSize=1024', - '' - ].join('\r\n') +describe('parseWindowsProcessOutput', () => { + it('parses tab-delimited CIM process rows', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() - const rows = parseWmicOutput(stdout) - - expect(rows).toEqual([ + expect(parseWindowsProcessOutput('100\t1\t2048\r\n200\t100\t1024')).toEqual([ { pid: 100, ppid: 1, cpu: 0, memory: 2048 }, { pid: 200, ppid: 100, cpu: 0, memory: 1024 } ]) }) - it('flushes the final stanza even without a trailing blank line', async () => { - const { parseWmicOutput } = await loadCollector() - const rows = parseWmicOutput('ProcessId=100\nParentProcessId=1\nWorkingSetSize=512') - expect(rows).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 512 }]) + it('skips malformed rows and clamps invalid memory to zero', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() + + expect( + parseWindowsProcessOutput( + [ + 'garbage', + 'abc\t1\t100', + '10\txyz\t100', + '0\t0\t100', + '-5\t0\t100', + '30\t-1\t100', + '20\t1\t-50' + ].join('\n') + ) + ).toEqual([{ pid: 20, ppid: 1, cpu: 0, memory: 0 }]) }) - it('skips stanzas missing pid or ppid', async () => { - const { parseWmicOutput } = await loadCollector() - // Why: wmic occasionally emits a stanza with only WorkingSetSize set - // (e.g. a process that exited mid-query). Dropping such rows avoids - // injecting ghost zero-pid entries into the index. - const stdout = ['WorkingSetSize=999', '', 'ProcessId=100', 'ParentProcessId=1'].join('\n') - const rows = parseWmicOutput(stdout) - expect(rows).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 0 }]) + it('preserves empty CIM field positions instead of shifting CPU ticks into memory', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() + + expect(parseWindowsProcessOutput('100\t1\t\t200\t300\t638830000000000000')).toEqual([ + { pid: 100, ppid: 1, cpu: 0, memory: 0 } + ]) + }) +}) + +describe('parseTypeperfProcessOutput', () => { + it('joins PID, parent PID, and working-set counters by process instance', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessResourceCollector() + const stdout = [ + '"(PDH-CSV 4.0)","\\\\HOST\\Process(node)\\ID Process","\\\\HOST\\Process(node#1)\\ID Process","\\\\HOST\\Process(node)\\Creating Process ID","\\\\HOST\\Process(node#1)\\Creating Process ID","\\\\HOST\\Process(node)\\Working Set","\\\\HOST\\Process(node#1)\\Working Set"', + '"07/15/2026 01:44:54.514","100.000000","200.000000","1.000000","100.000000","2048.000000","4096.000000"' + ].join('\r\n') + + expect(parseTypeperfProcessOutput(stdout)).toEqual([ + { pid: 100, ppid: 1, cpu: 0, memory: 2048 }, + { pid: 200, ppid: 100, cpu: 0, memory: 4096 } + ]) }) - it('ignores lines without an equals separator', async () => { - const { parseWmicOutput } = await loadCollector() - const rows = parseWmicOutput(['garbage line', 'ProcessId=5', 'ParentProcessId=1'].join('\n')) - expect(rows).toEqual([{ pid: 5, ppid: 1, cpu: 0, memory: 0 }]) + it('ignores aggregate and incomplete rows and clamps invalid memory', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessResourceCollector() + const stdout = [ + '"(PDH-CSV 4.0)","\\\\HOST\\Process(_Total)\\ID Process","\\\\HOST\\Process(cmd)\\ID Process","\\\\HOST\\Process(orphan)\\ID Process","\\\\HOST\\Process(_Total)\\Creating Process ID","\\\\HOST\\Process(cmd)\\Creating Process ID","\\\\HOST\\Process(_Total)\\Working Set","\\\\HOST\\Process(cmd)\\Working Set"', + '"time","0.000000","100.000000","200.000000","0.000000","1.000000","999999.000000","-1.000000"' + ].join('\r\n') + + expect(parseTypeperfProcessOutput(stdout)).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 0 }]) }) }) @@ -209,21 +239,27 @@ describe('collectSubtree', () => { describe('collectMemorySnapshot', () => { beforeEach(() => { + vi.restoreAllMocks() appMetricsMock.mockReset() appMetricsMock.mockReturnValue([]) + execFileMock.mockReset() execMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) }) function mockPsResponse(stdout: string) { - const processStdout = process.platform === 'win32' ? psFixtureToWmic(stdout) : stdout - execMock.mockImplementation((_cmd, _opts, cb) => - cb(null, { stdout: processStdout, stderr: '' }) - ) + execMock.mockImplementation((_cmd, _opts, cb) => cb(null, { stdout, stderr: '' })) + execFileMock.mockImplementation((file, _args, _opts, cb) => { + const output = + file === 'typeperf.exe' + ? psFixtureToTypeperfOutput(stdout) + : psFixtureToWindowsProcessOutput(stdout) + cb(null, output, '') + }) } - function psFixtureToWmic(stdout: string): string { + function psFixtureToWindowsProcessOutput(stdout: string): string { return stdout .split('\n') .map((line) => line.trim()) @@ -232,14 +268,361 @@ describe('collectMemorySnapshot', () => { const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4) const memory = Number.parseInt(rssKb ?? '', 10) return [ - `ParentProcessId=${ppid ?? ''}`, - `ProcessId=${pid ?? ''}`, - `WorkingSetSize=${Number.isFinite(memory) && memory > 0 ? memory * 1024 : 0}` - ].join('\r\n') + pid ?? '', + ppid ?? '', + Number.isFinite(memory) && memory > 0 ? memory * 1024 : 0, + '0', + '0', + '1' + ].join('\t') }) - .join('\r\n\r\n') + .join('\r\n') } + function psFixtureToTypeperfOutput(stdout: string): string { + const rows = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4) + const memoryKb = Number.parseInt(rssKb ?? '', 10) + return { + instance: `fixture${index}`, + pid: pid ?? '', + ppid: ppid ?? '', + memory: Number.isFinite(memoryKb) && memoryKb > 0 ? memoryKb * 1024 : 0 + } + }) + const counterColumns = (counter: string): string[] => + rows.map((row) => `"\\\\HOST\\Process(${row.instance})\\${counter}"`) + const valueColumns = (field: 'pid' | 'ppid' | 'memory'): string[] => + rows.map((row) => `"${row[field]}"`) + + return [ + [ + '"(PDH-CSV 4.0)"', + ...counterColumns('ID Process'), + ...counterColumns('Creating Process ID'), + ...counterColumns('Working Set') + ].join(','), + ['"time"', ...valueColumns('pid'), ...valueColumns('ppid'), ...valueColumns('memory')].join( + ',' + ) + ].join('\r\n') + } + + function expectProcessSweepCount(count: number): void { + if (os.platform() === 'win32') { + expect(execFileMock).toHaveBeenCalledTimes(count) + return + } + expect(execMock).toHaveBeenCalledTimes(count) + } + + it('uses one CIM process for Windows memory and CPU sampling', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + mockPsResponse('10 1 0 1024') + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + + expect(execMock).not.toHaveBeenCalled() + expect(execFileMock).toHaveBeenCalledTimes(1) + const [file, args] = execFileMock.mock.calls[0] + expect(file).toBe('powershell.exe') + expect(args.join(' ')).toContain('Get-CimInstance Win32_Process') + expect(args.join(' ')).toContain('KernelModeTime') + expect(args.join(' ')).toContain('UserModeTime') + expect(args.join(' ')).toContain('CreationDate') + expect(execFileMock.mock.calls[0][2]).toMatchObject({ + maxBuffer: 10 * 1024 * 1024, + timeout: 5_000, + windowsHide: true + }) + }) + + it('attributes Windows process CPU from cumulative time deltas between sweeps', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'windows-cpu-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const first = await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(first.worktrees[0].sessions[0].cpu).toBe(0) + expect(second.worktrees[0].sessions[0].cpu).toBe(100) + expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([ + 'powershell.exe', + 'powershell.exe' + ]) + }) + + it('does not attribute prior CPU time after Windows reuses a process id', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000001' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'reused-pid-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(second.worktrees[0].sessions[0].cpu).toBe(0) + }) + + it('supports cumulative CPU counters above JavaScript safe integers', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t90071992547409920\t0\t638830000000000000', + '10\t1\t1048576\t90071992567409920\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'large-counter-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(second.worktrees[0].sessions[0].cpu).toBe(100) + }) + + it('keeps the older CPU baseline when forced snapshots are too close together', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_100) + .mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t1000000\t0\t638830000000000000', + '10\t1\t1048576\t20000000\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'short-sample-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const tooSoon = await collectMemorySnapshot(emptyStore) + const normalPoll = await collectMemorySnapshot(emptyStore) + + expect(tooSoon.worktrees[0].sessions[0].cpu).toBe(0) + expect(normalPoll.worktrees[0].sessions[0].cpu).toBe(100) + }) + + it('caps impossible Windows CPU deltas at the host core capacity', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(os, 'cpus').mockReturnValue([{}, {}] as ReturnType) + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t1000000000\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'impossible-cpu-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const capped = await collectMemorySnapshot(emptyStore) + + expect(capped.worktrees[0].sessions[0].cpu).toBe(200) + }) + + it('warms CPU sampling again after Resource Manager was closed', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(12_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t100000000\t0\t638830000000000000' + ] + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, cpuOutputs.shift() ?? '', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'stale-counter-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const reopened = await collectMemorySnapshot(emptyStore) + + expect(reopened.worktrees[0].sessions[0].cpu).toBe(0) + }) + + it('preserves Windows process memory when CPU counters are unavailable', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(null, '10\t1\t1048576\t\t\t638830000000000000', '') + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'cpu-failure-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const snapshot = await collectMemorySnapshot(emptyStore) + + expect(snapshot.worktrees[0].sessions[0]).toMatchObject({ cpu: 0, memory: 1024 * 1024 }) + }) + + it('uses Typeperf during the CIM retry cooldown', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + execFileMock.mockImplementation((file, _args, _opts, cb) => { + if (file === 'powershell.exe') { + cb(new Error('CIM unavailable'), '', '') + return + } + cb(null, psFixtureToTypeperfOutput('10 1 0 1024'), '') + }) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'cim-pty', + worktreeId: null, + sessionId: null, + paneKey: null, + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const first = await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([ + 'powershell.exe', + 'typeperf.exe', + 'typeperf.exe' + ]) + expect(execFileMock.mock.calls[1][2]).toMatchObject({ windowsHide: true, timeout: 5_000 }) + expect(first.worktrees[0].memory).toBe(1048576) + expect(second.worktrees[0].memory).toBe(1048576) + }) + + it('retries CIM after fallback and warms CPU sampling before restoring deltas', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(2_000) + .mockReturnValueOnce(31_001) + .mockReturnValueOnce(32_000) + .mockReturnValueOnce(34_000) + const cimOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000000' + ] + let cimCalls = 0 + execFileMock.mockImplementation((file, _args, _opts, cb) => { + if (file === 'typeperf.exe') { + cb(null, psFixtureToTypeperfOutput('10 1 0 1024'), '') + return + } + cimCalls += 1 + if (cimCalls === 1) { + cb(new Error('transient CIM failure'), '', '') + return + } + cb(null, cimOutputs.shift() ?? '', '') + }) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'recovering-cim-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + await collectMemorySnapshot(emptyStore) + const warming = await collectMemorySnapshot(emptyStore) + const recovered = await collectMemorySnapshot(emptyStore) + + expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([ + 'powershell.exe', + 'typeperf.exe', + 'typeperf.exe', + 'powershell.exe', + 'powershell.exe' + ]) + expect(warming.worktrees[0].sessions[0].cpu).toBe(0) + expect(recovered.worktrees[0].sessions[0].cpu).toBe(100) + }) + it('coalesces concurrent callers onto a single in-flight sweep', async () => { // Why: the collector exists in part to prevent a burst of renderer // polls from spawning overlapping `ps` children. If a regression ever @@ -254,7 +637,7 @@ describe('collectMemorySnapshot', () => { collectMemorySnapshot(emptyStore) ]) - expect(execMock).toHaveBeenCalledTimes(1) + expectProcessSweepCount(1) // All three callers see the same snapshot object (same promise). expect(a).toBe(b) expect(b).toBe(c) @@ -267,7 +650,7 @@ describe('collectMemorySnapshot', () => { await collectMemorySnapshot(emptyStore) await collectMemorySnapshot(emptyStore) - expect(execMock).toHaveBeenCalledTimes(2) + expectProcessSweepCount(2) }) it('uses host process RSS for Electron app metrics when available', async () => { @@ -388,14 +771,19 @@ describe('collectMemorySnapshot', () => { expect(snap.worktrees[0].memory).toBe(2048 * 1024) }) - it('returns an empty snapshot when ps fails', async () => { - execMock.mockImplementation((_cmd, _opts, cb) => cb(new Error('ps blew up'), { stdout: '' })) + it('returns an empty snapshot when process enumeration fails', async () => { + execMock.mockImplementation((_cmd, _opts, cb) => + cb(new Error('process enumeration failed'), { stdout: '' }) + ) + execFileMock.mockImplementation((_file, _args, _opts, cb) => + cb(new Error('process enumeration failed'), '', '') + ) listRegisteredPtysMock.mockReturnValue([]) const { collectMemorySnapshot } = await loadCollector() const snap = await collectMemorySnapshot(emptyStore) - // ps failure should not surface as a rejected promise or crash the + // Enumeration failure should not surface as a rejected promise or crash the // renderer; the collector swallows and returns zeros so the UI can // render an empty state. expect(snap.worktrees).toEqual([]) diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index a22fceac481..171c07f175b 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -1,8 +1,6 @@ -/* eslint-disable max-lines -- Why: the collector's platform-specific - enumeration paths (`ps` on Unix, `wmic` on Windows) plus the history - ring buffer plus the Electron bucketing live together to keep one - snapshot's worth of code in one place. Splitting them produces extra - modules whose only consumer is this file. */ +/* eslint-disable max-lines -- Why: the collector's host-process index, + history ring buffer, Electron bucketing, and worktree attribution live + together to keep one snapshot's orchestration in one place. */ /** * Memory dashboard collector. * @@ -10,7 +8,7 @@ * - Orca's own Electron processes, via `app.getAppMetrics()`, bucketed * into main / renderer / other. * - Each registered PTY's process subtree, enumerated once from a host- - * wide `ps` sweep (`wmic` on Windows). + * wide process sweep (PowerShell CIM with a Typeperf fallback on Windows). * * Memory samples are held in a per-key ring (one key per worktree, plus * a reserved app-total key) so the UI can draw a trend sparkline. @@ -39,6 +37,7 @@ import type { import type { Store } from '../persistence' import { ORPHAN_WORKTREE_ID } from '../../shared/constants' import { listRegisteredPtys } from './pty-registry' +import { enumerateWindowsProcessResources } from './windows-process-resource-collector' export type MemorySnapshotStore = Pick @@ -77,13 +76,13 @@ const PS_MAX_BUFFER = 10 * 1024 * 1024 type ProcRow = { pid: number ppid: number - /** Percent of one core (may exceed 100 on multi-core). 0 on Windows. */ + /** Percent of one core (may exceed 100 on multi-core). */ cpu: number /** Resident memory in bytes. */ memory: number } -/** Indexed view of a single `ps`/`wmic` sweep. */ +/** Indexed view of a single host process sweep. */ type ProcIndex = { byPid: Map childrenOf: Map @@ -226,69 +225,8 @@ export function parsePsOutput(stdout: string): ProcRow[] { } async function enumerateWindows(): Promise { - // Why: `wmic` ships with stock Windows and emits a plain, tab-separated - // table without the CSV-quoting edge cases PowerShell's ConvertTo-Csv - // introduces. CPU% would require delta sampling between two queries; for - // v1 we report 0% on Windows — memory attribution is the primary signal. - try { - const { stdout } = await execAsync( - 'wmic process get ProcessId,ParentProcessId,WorkingSetSize /format:value', - { maxBuffer: PS_MAX_BUFFER, timeout: PS_EXEC_TIMEOUT_MS } - ) - return parseWmicOutput(stdout) - } catch (err) { - console.warn('[memory] wmic enumeration failed', err) - return [] - } + return enumerateWindowsProcessResources() } - -/** Exported for tests: parses `wmic /format:value` stanza output. */ -export function parseWmicOutput(stdout: string): ProcRow[] { - // wmic /format:value emits stanzas of `Key=Value` lines separated by blank - // lines. We accumulate a record until a blank line, then flush. - const rows: ProcRow[] = [] - let pid = Number.NaN - let ppid = Number.NaN - let ws = Number.NaN - - const flush = (): void => { - if (!Number.isNaN(pid) && !Number.isNaN(ppid)) { - rows.push({ - pid, - ppid, - cpu: 0, - memory: Number.isFinite(ws) && ws > 0 ? ws : 0 - }) - } - pid = Number.NaN - ppid = Number.NaN - ws = Number.NaN - } - - for (const raw of stdout.split(/\r?\n/)) { - const line = raw.trim() - if (line.length === 0) { - flush() - continue - } - const eq = line.indexOf('=') - if (eq < 0) { - continue - } - const key = line.slice(0, eq) - const value = line.slice(eq + 1) - if (key === 'ProcessId') { - pid = Number.parseInt(value, 10) - } else if (key === 'ParentProcessId') { - ppid = Number.parseInt(value, 10) - } else if (key === 'WorkingSetSize') { - ws = Number.parseInt(value, 10) - } - } - flush() - return rows -} - /** Walk every descendant PID of `root`, inclusive. Exported for tests. */ export function collectSubtree(index: ProcIndex, root: number): number[] { const result: number[] = [] diff --git a/src/main/memory/windows-process-resource-collector.ts b/src/main/memory/windows-process-resource-collector.ts new file mode 100644 index 00000000000..d73ae95b8c9 --- /dev/null +++ b/src/main/memory/windows-process-resource-collector.ts @@ -0,0 +1,341 @@ +import { execFile } from 'node:child_process' +import os from 'node:os' +import { performance } from 'node:perf_hooks' +import { + iterateProcessOutputLines, + PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS +} from '../../shared/process-output-field-scanner' + +const PROCESS_QUERY_TIMEOUT_MS = 5_000 +const PROCESS_QUERY_MAX_BUFFER = 10 * 1024 * 1024 +const TYPEPERF_COUNTERS = [ + '\\Process(*)\\ID Process', + '\\Process(*)\\Creating Process ID', + '\\Process(*)\\Working Set' +] as const +const TYPEPERF_MAX_FIELDS = 8_192 +const TYPEPERF_MAX_LINE_CHARS = 1024 * 1024 +const CPU_MIN_SAMPLE_MS = 250 +const CPU_STALE_AFTER_MS = 10_000 +const HUNDRED_NS_TICKS_PER_MS = 10_000 +const CIM_RETRY_AFTER_MS = 30_000 + +export type WindowsProcessResourceRow = { + pid: number + ppid: number + /** Percent of one core (may exceed 100 on multi-core). */ + cpu: number + /** Resident memory in bytes. */ + memory: number +} + +type WindowsCpuTimes = { + cpuTicks: bigint + startTimeId: string +} + +type WindowsProcessSample = { + sampledAtMs: number + rows: WindowsProcessResourceRow[] + cpuByPid: Map +} + +type ParsedWindowsProcessSample = Omit + +type TypeperfProcessFields = { + pid?: number + ppid?: number + memory?: number +} + +let processBackend: 'cim' | 'typeperf' = 'cim' +let previousCpuSample: WindowsProcessSample | null = null +let retryCimAtMs = 0 + +export async function enumerateWindowsProcessResources(): Promise { + // Why: one CIM sweep supplies both resource values and process identity, + // avoiding a second host-wide PowerShell process on every open-popover poll. + if (processBackend === 'typeperf') { + if (performance.now() < retryCimAtMs) { + return enumerateWindowsWithTypeperf() + } + processBackend = 'cim' + } + + const sample = await enumerateWindowsWithCim() + if (sample) { + return applyWindowsCpuSample(sample) + } + // Why: avoid repeating a blocked CIM timeout every two-second poll while + // still recovering CPU attribution after a transient PowerShell failure. + processBackend = 'typeperf' + retryCimAtMs = performance.now() + CIM_RETRY_AFTER_MS + previousCpuSample = null + return enumerateWindowsWithTypeperf() +} + +function applyWindowsCpuSample(sample: WindowsProcessSample): WindowsProcessResourceRow[] { + const previous = previousCpuSample + if (!previous) { + previousCpuSample = sample + return sample.rows + } + const elapsedMs = sample.sampledAtMs - previous.sampledAtMs + if (elapsedMs < CPU_MIN_SAMPLE_MS) { + // Why: forced snapshots can land too close together for a stable rate. + // Keep the older baseline so the next normal poll spans a useful interval. + return sample.rows + } + previousCpuSample = sample + if (elapsedMs > CPU_STALE_AFTER_MS) { + // Why: closing Resource Manager or sleeping the machine leaves a stale + // baseline whose long-term average is not the current CPU usage. + return sample.rows + } + + const maxProcessCpu = Math.max(1, os.cpus().length) * 100 + for (const row of sample.rows) { + const currentTimes = sample.cpuByPid.get(row.pid) + const previousTimes = previous.cpuByPid.get(row.pid) + // Why: process start time prevents a recycled PID from inheriting the old + // process's cumulative CPU time; counter resets likewise warm up again. + if ( + !currentTimes || + !previousTimes || + currentTimes.startTimeId !== previousTimes.startTimeId || + currentTimes.cpuTicks < previousTimes.cpuTicks + ) { + continue + } + const cpuMs = Number(currentTimes.cpuTicks - previousTimes.cpuTicks) / HUNDRED_NS_TICKS_PER_MS + row.cpu = Math.min(maxProcessCpu, nonNegativeNumber((cpuMs / elapsedMs) * 100)) + } + return sample.rows +} + +async function enumerateWindowsWithCim(): Promise { + const args = [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; " + + 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime,CreationDate | ' + + 'ForEach-Object { try { [string]::Join([char]9, @($_.ProcessId, $_.ParentProcessId, $_.WorkingSetSize, [string]$_.KernelModeTime, [string]$_.UserModeTime, $_.CreationDate.ToUniversalTime().Ticks)) } catch {} }' + ] + try { + const stdout = await execFileText('powershell.exe', args) + const parsed = parseWindowsProcessSample(stdout) + return parsed.rows.length > 0 ? { ...parsed, sampledAtMs: performance.now() } : null + } catch (err) { + console.warn('[memory] PowerShell process enumeration failed; falling back to typeperf', err) + return null + } +} + +async function enumerateWindowsWithTypeperf(): Promise { + try { + // Why: an immediate sample keeps the memory-only fallback inside the + // Resource Manager's two-second poll interval without inventing CPU rates. + const stdout = await execFileText('typeperf.exe', [ + ...TYPEPERF_COUNTERS, + '-sc', + '1', + '-si', + '0' + ]) + return parseTypeperfProcessOutput(stdout) + } catch (err) { + console.warn('[memory] typeperf process enumeration failed', err) + return [] + } +} + +function execFileText(file: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + file, + args, + { + encoding: 'utf8', + maxBuffer: PROCESS_QUERY_MAX_BUFFER, + timeout: PROCESS_QUERY_TIMEOUT_MS, + windowsHide: true + }, + (err, output) => { + if (err) { + reject(err) + return + } + resolve(String(output)) + } + ) + }) +} + +function parseWindowsProcessSample(stdout: string): ParsedWindowsProcessSample { + const rows: WindowsProcessResourceRow[] = [] + const cpuByPid = new Map() + for (const line of iterateProcessOutputLines(stdout)) { + const fields = parseCimTabFields(line) + if (fields.length < 3) { + continue + } + const pid = Number.parseInt(fields[0], 10) + const ppid = Number.parseInt(fields[1], 10) + const memory = Number.parseInt(fields[2], 10) + if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(ppid) || ppid < 0) { + continue + } + rows.push({ + pid, + ppid, + cpu: 0, + memory: Number.isFinite(memory) && memory > 0 ? memory : 0 + }) + + const kernelTicks = parseUnsignedBigInt(fields[3]) + const userTicks = parseUnsignedBigInt(fields[4]) + const startTimeId = fields[5] ?? '' + if ( + kernelTicks !== null && + userTicks !== null && + /^\d+$/.test(startTimeId) && + !/^0+$/.test(startTimeId) + ) { + cpuByPid.set(pid, { cpuTicks: kernelTicks + userTicks, startTimeId }) + } + } + return { rows, cpuByPid } +} + +function parseCimTabFields(line: string): string[] { + // Why: CIM serializes null properties as empty tab fields; collapsing + // whitespace would shift CPU counters into the working-set column. + if (line.length > PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS) { + return [] + } + return line.split('\t', 6).map((field) => field.trim()) +} + +/** Parse tab-delimited PowerShell CIM process rows without deriving CPU deltas. */ +export function parseWindowsProcessOutput(stdout: string): WindowsProcessResourceRow[] { + return parseWindowsProcessSample(stdout).rows +} + +/** Parse one CSV sample from Windows Typeperf. */ +export function parseTypeperfProcessOutput(stdout: string): WindowsProcessResourceRow[] { + let headers: string[] | null = null + let values: string[] | null = null + + for (const line of iterateProcessOutputLines(stdout)) { + if (!line || line.length > TYPEPERF_MAX_LINE_CHARS) { + continue + } + const fields = parseTypeperfCsvLine(line) + if (!headers && fields[0]?.startsWith('(PDH-CSV')) { + headers = fields + continue + } + if (headers && fields.length === headers.length) { + values = fields + break + } + } + + if (!headers || !values) { + return [] + } + + const byInstance = new Map() + for (let index = 1; index < headers.length; index += 1) { + const path = parseTypeperfCounterPath(headers[index]) + if (!path || path.instance === '_Total') { + continue + } + const value = Number.parseFloat(values[index]) + if (!Number.isFinite(value)) { + continue + } + const row = byInstance.get(path.instance) ?? {} + if (path.counter === 'ID Process') { + row.pid = Math.trunc(value) + } else if (path.counter === 'Creating Process ID') { + row.ppid = Math.trunc(value) + } else if (path.counter === 'Working Set') { + row.memory = value + } + byInstance.set(path.instance, row) + } + + const rows: WindowsProcessResourceRow[] = [] + for (const row of byInstance.values()) { + if (row.pid === undefined || row.pid <= 0 || row.ppid === undefined || row.ppid < 0) { + continue + } + rows.push({ + pid: row.pid, + ppid: row.ppid, + cpu: 0, + memory: row.memory !== undefined && row.memory > 0 ? row.memory : 0 + }) + } + return rows +} + +function parseTypeperfCounterPath(path: string): { instance: string; counter: string } | null { + const processStart = path.lastIndexOf('\\Process(') + const counterStart = path.lastIndexOf(')\\') + if (processStart < 0 || counterStart <= processStart + 9) { + return null + } + return { + instance: path.slice(processStart + 9, counterStart), + counter: path.slice(counterStart + 2) + } +} + +function parseTypeperfCsvLine(line: string): string[] { + const fields: string[] = [] + let value = '' + let quoted = false + + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + if (char === '"') { + if (quoted && line[index + 1] === '"') { + value += '"' + index += 1 + } else { + quoted = !quoted + } + continue + } + if (char === ',' && !quoted) { + fields.push(value) + value = '' + if (fields.length >= TYPEPERF_MAX_FIELDS) { + return [] + } + continue + } + value += char + } + fields.push(value) + return fields +} + +function parseUnsignedBigInt(value: string | undefined): bigint | null { + if (!value || !/^\d+$/.test(value)) { + return null + } + try { + return BigInt(value) + } catch { + return null + } +} + +function nonNegativeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} diff --git a/src/main/native-chat/transcript-file-version.ts b/src/main/native-chat/transcript-file-version.ts new file mode 100644 index 00000000000..69634c0a957 --- /dev/null +++ b/src/main/native-chat/transcript-file-version.ts @@ -0,0 +1,30 @@ +import { stat } from 'node:fs/promises' + +export type TranscriptFileVersion = { + identity: string + size: number + mtimeMs: number + ctimeMs: number +} + +export async function readTranscriptFileVersion(filePath: string): Promise { + const value = await stat(filePath) + return { + identity: `${value.dev}:${value.ino}`, + size: value.size, + mtimeMs: value.mtimeMs, + ctimeMs: value.ctimeMs + } +} + +export function transcriptFileVersionChanged( + current: TranscriptFileVersion, + previous: TranscriptFileVersion +): boolean { + return ( + current.identity !== previous.identity || + current.size !== previous.size || + current.mtimeMs !== previous.mtimeMs || + current.ctimeMs !== previous.ctimeMs + ) +} diff --git a/src/main/native-chat/transcript-native-watcher.ts b/src/main/native-chat/transcript-native-watcher.ts new file mode 100644 index 00000000000..8cccd022c57 --- /dev/null +++ b/src/main/native-chat/transcript-native-watcher.ts @@ -0,0 +1,87 @@ +import { watch, type FSWatcher } from 'node:fs' +import { basename, dirname } from 'node:path' + +export type TranscriptNativeWatcher = { + /** Best-effort bind; false keeps the caller in reconciliation-only mode. */ + bind: () => boolean + /** Detach from an identity that may no longer represent the watched path. */ + invalidate: () => void + needsRebind: () => boolean + dispose: () => void +} + +/** + * Optional fs.watch acceleration for transcript reconciliation. Native watches + * can fail on otherwise-readable remote filesystems, so binding is retryable + * and never owns transcript liveness; the caller's polling loop does. + */ +export function createTranscriptNativeWatcher( + filePath: string, + onEvent: () => void, + onError: () => void +): TranscriptNativeWatcher { + const watchedName = basename(filePath) + let disposed = false + let watcher: FSWatcher | null = null + let rebindNeeded = true + + function invalidateCandidate(candidate: FSWatcher): void { + if (watcher !== candidate) { + return + } + watcher = null + rebindNeeded = true + candidate.close() + } + + return { + bind(): boolean { + if (disposed || watcher) { + return watcher !== null + } + let nextWatcher: FSWatcher + try { + // Why: watching the parent survives target-file replacement on macOS. + nextWatcher = watch(dirname(filePath), (event, changedName) => { + if (changedName !== null && changedName.toString() !== watchedName) { + return + } + // Why: a parent replacement may emit rename without a watcher error. + if (event === 'rename') { + invalidateCandidate(nextWatcher) + } + onEvent() + }) + } catch { + rebindNeeded = true + return false + } + // Why: an active tail should not keep a headless runtime alive during shutdown. + nextWatcher.unref?.() + nextWatcher.on('error', () => { + if (disposed || watcher !== nextWatcher) { + return + } + invalidateCandidate(nextWatcher) + onError() + }) + watcher = nextWatcher + rebindNeeded = false + return true + }, + invalidate(): void { + if (watcher) { + invalidateCandidate(watcher) + } else { + rebindNeeded = true + } + }, + needsRebind: () => rebindNeeded, + dispose(): void { + disposed = true + watcher?.close() + watcher = null + rebindNeeded = false + } + } +} diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts index 2942594b370..f2e46f3581b 100644 --- a/src/main/native-chat/transcript-watch-contract.ts +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -28,6 +28,8 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { debounceMs?: number /** Test-only override for the production resolve-poll backoff. */ resolvePollIntervalMs?: number + /** Test-only override for the host-side watcher reconciliation interval. */ + reconciliationIntervalMs?: number } export type NativeChatTranscriptSubscription = { diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index 6ce52de4787..ff1df71ee0f 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -1,20 +1,24 @@ -import { watch, type FSWatcher } from 'node:fs' import { open, stat } from 'node:fs/promises' -import { basename, dirname } from 'node:path' import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' +import { + readTranscriptFileVersion, + transcriptFileVersionChanged, + type TranscriptFileVersion +} from './transcript-file-version' import { readIncrementalTranscriptMessages, resetIncrementalTranscriptState, type IncrementalTranscriptState } from './transcript-incremental-reader' +import { createTranscriptNativeWatcher } from './transcript-native-watcher' import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle' import type { NativeChatTranscriptSubscription, SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' +import { createTranscriptWatchScheduler } from './transcript-watch-scheduler' -const DEFAULT_DEBOUNCE_MS = 40 const ROTATION_RETRY_MS = 25 const MAX_ROTATION_RETRY_MS = 2_000 let activeWatcherCount = 0 @@ -23,11 +27,6 @@ export function getActiveNativeChatWatcherCount(): number { return activeWatcherCount } -async function fileVersion(filePath: string): Promise<{ identity: string; size: number }> { - const value = await stat(filePath) - return { identity: `${value.dev}:${value.ino}`, size: value.size } -} - async function boundaryFingerprint(filePath: string, offset: number): Promise { if (offset <= 0) { return '' @@ -45,12 +44,9 @@ async function boundaryFingerprint(filePath: string, offset: number): Promise | null = null - let watcher: FSWatcher | null = null - let watcherNeedsRebind = false - - function scheduleDrain(): void { - if (closed) { - return - } - if (debounceTimer) { - clearTimeout(debounceTimer) - } - debounceTimer = setTimeout(() => { - debounceTimer = null - void drain() - }, debounceMs ?? DEFAULT_DEBOUNCE_MS) - } function scheduleRotationRetry(): void { - if (closed || debounceTimer) { + if (closed) { return } const retryDelay = Math.min( ROTATION_RETRY_MS * 2 ** Math.min(rotationRetryCount, 7), MAX_ROTATION_RETRY_MS ) - rotationRetryCount += 1 - debounceTimer = setTimeout(() => { - debounceTimer = null - void drain() - }, retryDelay) + if (scheduler.scheduleRetry(retryDelay)) { + rotationRetryCount += 1 + } } async function readAndEmitAppends(): Promise { @@ -135,24 +113,50 @@ export async function installTranscriptWatcher( } } + async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise { + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + const completedVersion = await readTranscriptFileVersion(filePath) + if (transcriptFileVersionChanged(completedVersion, startVersion)) { + // Why: a write racing this drain needs another pass even when the reader + // happened to reach its new EOF; timestamp-only rewrites may need replace. + watchedVersion = startVersion + pendingReadRequested = true + } else { + watchedVersion = completedVersion + } + if (closed) { + return + } + if (!nativeWatcher.needsRebind() || nativeWatcher.bind()) { + rotationRetryCount = 0 + return + } + scheduleRotationRetry() + } + async function drainOnce(): Promise { - const current = await fileVersion(filePath) + const current = await readTranscriptFileVersion(filePath) const currentBoundary = await boundaryFingerprint(filePath, state.offset) if (closed) { return } - if (watcherNeedsRebind) { - bindWatcher() - } - const identityChanged = watchedIdentity !== null && current.identity !== watchedIdentity + const identityChanged = watchedVersion !== null && current.identity !== watchedVersion.identity + const sameSizeVersionChanged = + watchedVersion !== null && + current.identity === watchedVersion.identity && + current.size === watchedVersion.size && + transcriptFileVersionChanged(current, watchedVersion) const contentReplaced = identityChanged || + sameSizeVersionChanged || current.size < state.offset || (state.offset > 0 && watchedBoundary !== currentBoundary) + if (identityChanged) { + nativeWatcher.invalidate() + } if (contentReplaced) { resetIncrementalTranscriptState(state) } - watchedIdentity = current.identity const replacementSnapshot = // Why: 0 is a valid window — an explicit undefined check keeps an empty @@ -180,8 +184,7 @@ export async function installTranscriptWatcher( replacementSnapshot.lifecycle ) await readAndEmitAppends() - watchedBoundary = await boundaryFingerprint(filePath, state.offset) - rotationRetryCount = 0 + await finishSuccessfulDrain(current) return } @@ -224,14 +227,16 @@ export async function installTranscriptWatcher( lifecycle = nextLifecycle } ) + if (closed) { + return + } onInitialSnapshot(messages, false, 0, undefined, lifecycle) } } else { initialDrain = false await readAndEmitAppends() } - watchedBoundary = await boundaryFingerprint(filePath, state.offset) - rotationRetryCount = 0 + await finishSuccessfulDrain(current) } async function drain(): Promise { @@ -254,7 +259,7 @@ export async function installTranscriptWatcher( // A still-pending initial drain also surfaces one error snapshot so a // watching client isn't stranded at 'loading' when the read keeps // throwing; initialDrain stays true so a recovered read can still win. - if (initialDrain && onInitialSnapshot && !initialErrorEmitted) { + if (!closed && initialDrain && onInitialSnapshot && !initialErrorEmitted) { initialErrorEmitted = true onInitialSnapshot([], false, 0, 'Transcript unavailable') } @@ -267,38 +272,43 @@ export async function installTranscriptWatcher( } } - function bindWatcher(): void { - const watchedName = basename(filePath) - // Why: file watchers stay bound to an unlinked inode on macOS. Watching - // the parent keeps observing a successor even after a long recreate gap. - const nextWatcher = watch(dirname(filePath), (_event, changedName) => { - if (changedName === null || changedName.toString() === watchedName) { - scheduleDrain() - } - }) - nextWatcher.on('error', () => { - // Why: Windows can emit EPERM when a watched directory disappears. An - // error listener prevents a process crash and retries against its successor. - if (closed || watcher !== nextWatcher) { + async function reconcile(): Promise { + if (closed) { + return + } + try { + const current = await readTranscriptFileVersion(filePath) + if (closed) { return } - watcherNeedsRebind = true - nextWatcher.close() - watcher = null - scheduleRotationRetry() - }) - watcher = nextWatcher - watcherNeedsRebind = false + const versionChanged = + watchedVersion === null || transcriptFileVersionChanged(current, watchedVersion) + if (versionChanged || current.size !== state.offset || nativeWatcher.needsRebind()) { + await drain() + } + } catch { + // Why: a missing/replaced path needs the existing capped rotation retry, + // even when fs.watch stayed silent about the transition. + await drain() + } } - try { - bindWatcher() - } catch { - // File vanished between resolve and watch. - return null - } + const scheduler = createTranscriptWatchScheduler({ + debounceMs: args.debounceMs, + reconciliationIntervalMs: args.reconciliationIntervalMs, + drain: () => void drain(), + reconcile + }) + const nativeWatcher = createTranscriptNativeWatcher( + filePath, + () => scheduler.scheduleEventDrain(), + scheduleRotationRetry + ) + + nativeWatcher.bind() activeWatcherCount++ - scheduleDrain() + scheduler.startReconciliation() + scheduler.scheduleEventDrain() return { watching: true, @@ -307,12 +317,8 @@ export async function installTranscriptWatcher( return } closed = true - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } - watcher?.close() - watcher = null + scheduler.dispose() + nativeWatcher.dispose() activeWatcherCount-- } } diff --git a/src/main/native-chat/transcript-watch-liveness.test.ts b/src/main/native-chat/transcript-watch-liveness.test.ts new file mode 100644 index 00000000000..e5d67a3e121 --- /dev/null +++ b/src/main/native-chat/transcript-watch-liveness.test.ts @@ -0,0 +1,273 @@ +import { EventEmitter } from 'node:events' +import type { FSWatcher } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { appendFile, mkdir, mkdtemp, rename, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { watchers, watchCallbacks, watchMock, watchState } = vi.hoisted(() => ({ + watchers: [] as (EventEmitter & { + close: ReturnType + unref: ReturnType + })[], + watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[], + watchMock: vi.fn(), + watchState: { error: null as Error | null } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + watchMock.mockImplementation((_path, callback) => { + if (watchState.error) { + throw watchState.error + } + const watcher = Object.assign(new EventEmitter(), { close: vi.fn(), unref: vi.fn() }) + watchers.push(watcher) + watchCallbacks.push(callback) + return watcher as unknown as FSWatcher + }) + return { ...actual, watch: watchMock } +}) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +const roots: string[] = [] + +afterEach(async () => { + vi.useRealTimers() + watchers.length = 0 + watchCallbacks.length = 0 + watchMock.mockClear() + watchState.error = null + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function tempFile(initial: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-liveness-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, initial) + return filePath +} + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role, content: role === 'user' ? text : [{ type: 'text', text }] } + })}\n` +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +describe('native chat transcript watcher liveness', () => { + it('reconciles without a native watcher when fs.watch cannot bind', async () => { + watchState.error = new Error('ENOSYS') + const filePath = await tempFile(claudeLine('seed', 'user', 'hello')) + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + await appendFile(filePath, claudeLine('poll-only', 'assistant', 'recovered')) + await waitFor(() => appends.mock.calls.flat(2).some((message) => message.id === 'poll-only')) + + subscription.unsubscribe() + expect(subscription.watching).toBe(true) + expect(watchers).toHaveLength(0) + expect(watchMock.mock.calls.length).toBeGreaterThan(1) + }) + + it('keeps polling readable transcripts while native watcher rebind fails', async () => { + const filePath = await tempFile(claudeLine('seed', 'user', 'hello')) + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + watchState.error = new Error('EPERM') + watchers[0]!.emit('error', watchState.error) + await appendFile(filePath, claudeLine('failed-rebind', 'assistant', 'still tailed')) + await waitFor( + () => + appends.mock.calls.flat(2).some((message) => message.id === 'failed-rebind') && + watchMock.mock.calls.length > 1 + ) + + subscription.unsubscribe() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(watchMock.mock.calls.length).toBeGreaterThan(1) + }) + + it('reconciles an append when fs.watch omits the callback', async () => { + const filePath = await tempFile(claudeLine('seed', 'user', 'hello')) + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + await appendFile(filePath, claudeLine('missed-event', 'assistant', 'recovered')) + // The mocked watcher never invokes watchCallbacks[0], reproducing a silent + // missed/coalesced host event while the file itself remains valid. + await waitFor(() => appends.mock.calls.flat(2).some((message) => message.id === 'missed-event')) + + subscription.unsubscribe() + expect(watchCallbacks).toHaveLength(1) + }) + + it('replaces a same-size prefix rewrite with an unchanged trailing boundary', async () => { + const prefixBefore = claudeLine('prefix-old', 'user', 'before') + const prefixAfter = claudeLine('prefix-new', 'user', 'after!') + const stableTail = claudeLine('stable-tail', 'assistant', 'x'.repeat(200)) + expect(Buffer.byteLength(prefixAfter)).toBe(Buffer.byteLength(prefixBefore)) + const filePath = await tempFile(prefixBefore + stableTail) + const snapshots = vi.fn() + const replacements = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + initialLimit: 40, + onInitialSnapshot: snapshots, + onReplace: replacements, + onAppend: () => {}, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + await writeFile(filePath, prefixAfter + stableTail) + const future = new Date(Date.now() + 10_000) + await utimes(filePath, future, future) + await waitFor(() => + replacements.mock.calls.flat(2).some((message) => message.id === 'prefix-new') + ) + + subscription.unsubscribe() + expect(replacements).toHaveBeenCalledOnce() + }) + + it('rebinds the native watcher after silent parent-directory replacement', async () => { + const filePath = await tempFile(claudeLine('old-file', 'user', 'before')) + const root = dirname(filePath) + const oldRoot = `${root}.old` + roots.push(oldRoot) + const snapshots = vi.fn() + const replacements = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + initialLimit: 40, + onInitialSnapshot: snapshots, + onReplace: replacements, + onAppend: appends, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + + await rename(root, oldRoot) + await mkdir(root) + await writeFile(filePath, claudeLine('new-file', 'user', 'after')) + await waitFor(() => replacements.mock.calls.length === 1 && watchers.length === 2) + await appendFile(filePath, claudeLine('new-followup', 'assistant', 'event-driven')) + watchCallbacks[1]!('change', 'transcript.jsonl') + await waitFor(() => appends.mock.calls.flat(2).some((message) => message.id === 'new-followup')) + + subscription.unsubscribe() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('drains within the max wait while matching events remain sustained', async () => { + const filePath = await tempFile(claudeLine('seed', 'user', 'hello')) + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + debounceMs: 40, + reconciliationIntervalMs: 10_000 + }) + await waitFor(() => snapshots.mock.calls.length === 1) + await appendFile(filePath, claudeLine('sustained-events', 'assistant', 'bounded')) + + for (let index = 0; index < 20; index += 1) { + watchCallbacks[0]!('change', 'transcript.jsonl') + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect(appends.mock.calls.flat(2).some((message) => message.id === 'sustained-events')).toBe( + true + ) + subscription.unsubscribe() + }) + + it('clears every timer and cannot rebind after unsubscribe', async () => { + vi.useFakeTimers() + const filePath = await tempFile('') + const snapshots = vi.fn() + const appends = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot: snapshots, + onAppend: appends, + reconciliationIntervalMs: 20 + }) + + subscription.unsubscribe() + expect(watchers[0]!.unref).toHaveBeenCalledOnce() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + + watchCallbacks[0]!('change', 'transcript.jsonl') + watchers[0]!.emit('error', new Error('late watcher error')) + await vi.advanceTimersByTimeAsync(10_000) + + expect(watchMock).toHaveBeenCalledOnce() + expect(snapshots).not.toHaveBeenCalled() + expect(appends).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/native-chat/transcript-watch-scheduler.ts b/src/main/native-chat/transcript-watch-scheduler.ts new file mode 100644 index 00000000000..983b1802166 --- /dev/null +++ b/src/main/native-chat/transcript-watch-scheduler.ts @@ -0,0 +1,101 @@ +const DEFAULT_DEBOUNCE_MS = 40 +const EVENT_DRAIN_MAX_WAIT_MS = 250 +const DEFAULT_RECONCILIATION_MS = 1_000 + +type TranscriptWatchSchedulerOptions = { + debounceMs?: number + reconciliationIntervalMs?: number + drain: () => void + reconcile: () => Promise +} + +export type TranscriptWatchScheduler = { + scheduleEventDrain: () => void + scheduleRetry: (delayMs: number) => boolean + startReconciliation: () => void + dispose: () => void +} + +function unrefTimer(timer: ReturnType): void { + timer.unref?.() +} + +export function createTranscriptWatchScheduler( + options: TranscriptWatchSchedulerOptions +): TranscriptWatchScheduler { + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS + const maxEventWaitMs = Math.max(debounceMs, EVENT_DRAIN_MAX_WAIT_MS) + const reconciliationIntervalMs = options.reconciliationIntervalMs ?? DEFAULT_RECONCILIATION_MS + let disposed = false + let drainTimer: ReturnType | null = null + let firstEventAt: number | null = null + let reconciliationTimer: ReturnType | null = null + + function clearDrainTimer(): void { + if (!drainTimer) { + return + } + clearTimeout(drainTimer) + drainTimer = null + } + + function fireDrain(): void { + clearDrainTimer() + firstEventAt = null + options.drain() + } + + function armDrain(delayMs: number): void { + drainTimer = setTimeout(fireDrain, delayMs) + unrefTimer(drainTimer) + } + + function armReconciliation(): void { + if (disposed || reconciliationTimer) { + return + } + reconciliationTimer = setTimeout(() => { + reconciliationTimer = null + // Why: wait for the host-side stat/drain check before rearming so a slow + // remote filesystem cannot accumulate overlapping reconciliation work. + void options.reconcile().then(armReconciliation, armReconciliation) + }, reconciliationIntervalMs) + unrefTimer(reconciliationTimer) + } + + return { + scheduleEventDrain(): void { + if (disposed) { + return + } + const now = Date.now() + firstEventAt ??= now + const remainingMaxWait = Math.max(0, maxEventWaitMs - (now - firstEventAt)) + clearDrainTimer() + armDrain(Math.min(debounceMs, remainingMaxWait)) + }, + scheduleRetry(delayMs: number): boolean { + if (disposed || drainTimer) { + return false + } + firstEventAt = null + armDrain(delayMs) + return true + }, + startReconciliation(): void { + armReconciliation() + }, + dispose(): void { + if (disposed) { + return + } + disposed = true + clearDrainTimer() + if (reconciliationTimer) { + clearTimeout(reconciliationTimer) + reconciliationTimer = null + } + firstEventAt = null + } + } +} diff --git a/src/main/native-chat/transcript-watch-unsubscribe-race.test.ts b/src/main/native-chat/transcript-watch-unsubscribe-race.test.ts new file mode 100644 index 00000000000..1e6ef3c1bf9 --- /dev/null +++ b/src/main/native-chat/transcript-watch-unsubscribe-race.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as TranscriptTailReader from './transcript-tail-reader' + +const { tailRead, tailReadStarted, rejectTailRead } = vi.hoisted(() => { + let markStarted = (): void => {} + let reject = (_error: Error): void => {} + const started = new Promise((resolve) => { + markStarted = resolve + }) + const read = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise + }) + return { + tailRead: vi.fn(() => { + markStarted() + return read + }), + tailReadStarted: started, + rejectTailRead: reject + } +}) + +vi.mock('./transcript-tail-reader', async () => { + const actual = await vi.importActual('./transcript-tail-reader') + return { ...actual, readNativeChatTranscriptTailFile: tailRead } +}) + +import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' + +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +describe('native chat transcript watcher unsubscribe race', () => { + it('does not emit an initial read error after unsubscribe', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unsubscribe-race-')) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '{}\n') + const snapshots = vi.fn() + const activeBefore = getActiveNativeChatWatcherCount() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + initialLimit: 40, + onInitialSnapshot: snapshots, + onAppend: () => {}, + debounceMs: 0, + reconciliationIntervalMs: 10_000 + }) + await tailReadStarted + + subscription.unsubscribe() + rejectTailRead(new Error('late read failure')) + await new Promise((resolve) => setImmediate(resolve)) + + expect(snapshots).not.toHaveBeenCalled() + expect(getActiveNativeChatWatcherCount()).toBe(activeBefore) + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 1d92afe2974..72c609b02a2 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -15,9 +15,8 @@ export type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' -/** One resolve+install attempt. Returns null when the file isn't resolvable - * yet, or vanished between resolve and `watch()` — either case is retried by - * the resolve-poll rather than treated as a hard failure. */ +/** One resolve+install attempt. Returns null while the transcript file itself + * is unresolved; native-watch failure degrades to reconciliation-only mode. */ async function attemptInstall( args: SubscribeNativeChatTranscriptArgs, decode: (line: string, fallbackId: string) => NativeChatMessage | null diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts new file mode 100644 index 00000000000..66c3b4ab0f6 --- /dev/null +++ b/src/main/persistence-single-serialize.test.ts @@ -0,0 +1,291 @@ +// Why this file exists: persistence.test.ts mocks safeStorage.encryptString +// deterministically, which cannot catch the real-world hazard the single- +// stringify save guard must survive — encrypt() uses a random IV, so identical +// state produces different on-disk bytes each save. These tests mock a +// nondeterministic cipher and pin the guard + payload invariants. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' + +const testState = { dir: '' } + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +// Nondeterministic cipher: same plaintext → different ciphertext every call, +// like safeStorage's random IV. encryptionAvailable is toggleable per test. +// deterministic=true mimics macOS/legacy-Linux OSCrypt (constant IV → same +// plaintext always yields the same ciphertext), which is what makes the +// cross-field blob-collision reachable by a user. +const cipherState = { encryptionAvailable: true, deterministic: false } +const DETERMINISTIC_IV = 'd'.repeat(36) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => cipherState.encryptionAvailable, + encryptString: (plaintext: string) => + Buffer.from( + `enc:${cipherState.deterministic ? DETERMINISTIC_IV : randomUUID()}:${plaintext}`, + 'utf-8' + ), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + } + } +})) + +vi.mock('./telemetry/client', () => ({ + track: vi.fn() +})) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function dataFile(): string { + return join(testState.dir, 'orca-data.json') +} + +const SECRETS = { + opencodeSessionCookie: 'cookie-$&-value', + httpProxyUrl: 'http://user:p@ss@proxy.local:8080' +} as const +const KAGI_LINK = 'https://kagi.com/session?token=abc123' + +describe('persistence single-serialize save guard', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + cipherState.encryptionAvailable = true + cipherState.deterministic = false + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + async function seedStoreWithSecrets() { + const store = await createStore() + store.updateSettings({ ...SECRETS }) + store.updateUI({ browserKagiSessionLink: KAGI_LINK }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + return store + } + + it('skips the disk write when state is identical, even with secrets set (random-IV cipher)', async () => { + const store = await seedStoreWithSecrets() + const inoBefore = statSync(dataFile()).ino + + // Net no-op mutation burst: the encrypted payload bytes would differ + // (random IV), but the normalized guard hash must not. + const originalWidth = store.getUI().sidebarWidth + store.updateUI({ sidebarWidth: 512 }) + store.updateUI({ sidebarWidth: originalWidth }) + store.updateSettings({ ...SECRETS }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + }) + + it('still writes when state actually changes (including a secret change)', async () => { + const store = await seedStoreWithSecrets() + const inoBefore = statSync(dataFile()).ino + + store.updateSettings({ opencodeSessionCookie: 'rotated-cookie' }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + const inoAfter = statSync(dataFile()).ino + expect(inoAfter).not.toBe(inoBefore) + + store.updateUI({ sidebarWidth: 777 }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + expect(statSync(dataFile()).ino).not.toBe(inoAfter) + }) + + it('writes encrypted secrets to disk and round-trips them through a reload', async () => { + await seedStoreWithSecrets() + + const raw = readFileSync(dataFile(), 'utf-8') + const persisted = JSON.parse(raw) as { + settings: { opencodeSessionCookie: string; httpProxyUrl: string } + ui: { browserKagiSessionLink: string } + } + // Secrets are ciphertext on disk, plaintext nowhere in the payload. + expect(persisted.settings.opencodeSessionCookie).not.toBe(SECRETS.opencodeSessionCookie) + expect(persisted.settings.httpProxyUrl).not.toBe(SECRETS.httpProxyUrl) + expect(persisted.ui.browserKagiSessionLink).not.toBe(KAGI_LINK) + expect(raw).not.toContain(SECRETS.opencodeSessionCookie) + expect(raw).not.toContain(KAGI_LINK) + + const reloaded = await createStore() + expect(reloaded.getSettings().opencodeSessionCookie).toBe(SECRETS.opencodeSessionCookie) + expect(reloaded.getSettings().httpProxyUrl).toBe(SECRETS.httpProxyUrl) + expect(reloaded.getUI().browserKagiSessionLink).toBe(KAGI_LINK) + }) + + it('handles empty secrets and unavailable encryption (payload stays plaintext, guard still skips)', async () => { + cipherState.encryptionAvailable = false + const store = await createStore() + store.updateSettings({ ...SECRETS, opencodeSessionCookie: '' }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { opencodeSessionCookie: string; httpProxyUrl: string } + } + expect(persisted.settings.opencodeSessionCookie).toBe('') + expect(persisted.settings.httpProxyUrl).toBe(SECRETS.httpProxyUrl) + + const inoBefore = statSync(dataFile()).ino + store.updateSettings({ httpProxyUrl: SECRETS.httpProxyUrl }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + expect(statSync(dataFile()).ino).toBe(inoBefore) + }) + + it('sync flush also skips on identical state with secrets set', async () => { + const store = await seedStoreWithSecrets() + const inoBefore = statSync(dataFile()).ino + + store.flushOrThrow() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + }) + + it('performs exactly one full-state JSON.stringify per save (was two)', async () => { + const store = await seedStoreWithSecrets() + + // Count full-state serializations: only the durable-state payload is + // anywhere near this size in the save path (tiny stringifies elsewhere + // stay far below the threshold). + const original = JSON.stringify.bind(JSON) + let fullStateSerializations = 0 + const spy = vi.spyOn(JSON, 'stringify').mockImplementation((( + value: unknown, + ...rest: unknown[] + ) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const out = original(value as any, ...(rest as [any?, any?])) + if (typeof out === 'string' && out.length > 1_000) { + fullStateSerializations++ + } + return out + }) as typeof JSON.stringify) + try { + store.updateUI({ sidebarWidth: 640 }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + } finally { + spy.mockRestore() + } + + expect(fullStateSerializations).toBe(1) + }) + + // Regression (adversarial review, gpt-5.6-sol round 1): the guard hash + // normalizes encrypted secrets back to plaintext. A user-controlled plaintext + // VALUE (httpProxyBypassRules) can equal a secret's ciphertext; on a + // deterministic cipher (macOS/legacy-Linux OSCrypt constant IV) a string + // search for the ciphertext would substitute the wrong site, letting two + // distinct states hash equal → a silently dropped write (data loss). The + // position-exact sentinel substitution must keep the hashes distinct. + it('persists a swap between a plaintext field and a secret when the plaintext equals the secret ciphertext (deterministic cipher)', async () => { + cipherState.deterministic = true + const store = await createStore() + + const P = 'cookie-plaintext-value' + // Persist cookie=P, then read its on-disk ciphertext C (what a user could + // copy out of orca-data.json). + store.updateSettings({ opencodeSessionCookie: P }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const C = ( + JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { opencodeSessionCookie: string } + } + ).settings.opencodeSessionCookie + expect(C).not.toBe(P) // C is ciphertext + + // State 1: the plaintext bypass-rules field literally holds ciphertext C; + // cookie is still P (which also encrypts to C under the deterministic IV). + store.updateSettings({ httpProxyBypassRules: C, opencodeSessionCookie: P }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + const inoState1 = statSync(dataFile()).ino + + // State 2 (distinct): swap the two values. Must be written, not skipped. + store.updateSettings({ httpProxyBypassRules: P, opencodeSessionCookie: C }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + expect(statSync(dataFile()).ino).not.toBe(inoState1) + + // The swap round-trips through a reload — nothing was lost. + const reloaded = await createStore() + expect(reloaded.getSettings().httpProxyBypassRules).toBe(P) + expect(reloaded.getSettings().opencodeSessionCookie).toBe(C) + }) + + // Regression (adversarial review, gpt-5.6-sol round 2): the deeper variant of + // the same class — a user-controlled JSON KEY. agentDefaultEnv lets the user + // name an env var exactly after a secret field, so `"browserKagiSessionLink": + // ""` can appear (inside settings, before ui) as a non-secret + // entry. A key-anchored string replace would substitute that site instead of + // the real ui field. The per-slot sentinel is unguessable, so it only ever + // matches the real secret slot and the two distinct states stay distinct. + it('persists a swap when an agentDefaultEnv var is named after a secret field and holds its ciphertext (deterministic cipher)', async () => { + cipherState.deterministic = true + const store = await createStore() + + const K = 'https://kagi.com/session?token=SECRET' + store.updateUI({ browserKagiSessionLink: K }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const C = ( + JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + ui: { browserKagiSessionLink: string } + } + ).ui.browserKagiSessionLink + expect(C).not.toBe(K) // C is ciphertext + + // State 1: env var literally named after the secret field, value = C; the + // real ui secret is still K. + store.updateSettings({ agentDefaultEnv: { claude: { browserKagiSessionLink: C } } }) + store.updateUI({ browserKagiSessionLink: K }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + const inoState1 = statSync(dataFile()).ino + + // State 2 (distinct): swap — env var value = K, ui secret = C. Must write. + store.updateSettings({ agentDefaultEnv: { claude: { browserKagiSessionLink: K } } }) + store.updateUI({ browserKagiSessionLink: C }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + expect(statSync(dataFile()).ino).not.toBe(inoState1) + + const reloaded = await createStore() + expect(reloaded.getSettings().agentDefaultEnv?.claude?.browserKagiSessionLink).toBe(K) + expect(reloaded.getUI().browserKagiSessionLink).toBe(C) + }) +}) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ed2508be9f0..43f94be0942 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -549,6 +549,59 @@ describe('Store', () => { }) }) + it('migrates the legacy host account-runtime default to auto once', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + localAccountRuntime: 'host' + } + }) + + const store = await createStore() + + expect(store.getSettings().localAccountRuntime).toBe('auto') + expect(store.getSettings().localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) + store.flush() + const persisted = (readDataFile() as PersistedState).settings + expect(persisted.localAccountRuntime).toBe('auto') + expect(persisted.localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) + }) + + it('preserves an explicit WSL account-runtime pin through the migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + } + }) + + const store = await createStore() + + expect(store.getSettings().localAccountRuntime).toBe('wsl') + expect(store.getSettings().localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) + }) + + it('does not re-flip an explicit host pin chosen after migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + localAccountRuntime: 'host', + localAccountRuntimeDefaultedToAutoForAllUsers: true + } + }) + + const store = await createStore() + + expect(store.getSettings().localAccountRuntime).toBe('host') + }) + it('returns default settings when no data file exists', async () => { const store = await createStore() const settings = store.getSettings() @@ -565,6 +618,8 @@ describe('Store', () => { expect(settings.terminalScrollSensitivity).toBe(1.15) expect(settings.terminalFastScrollSensitivity).toBe(5) expect(settings.terminalTuiScrollSensitivity).toBe(1) + expect(settings.localAccountRuntime).toBe('auto') + expect(settings.localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) expect(settings.terminalTuiScrollSensitivityDefaultedToOne).toBe(true) expect(settings.terminalUseSeparateLightTheme).toBe(true) expect(settings.rightSidebarOpenByDefault).toBe(true) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2e5b8255367..aba79a4561c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -103,6 +103,7 @@ import { } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' +import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides' @@ -265,10 +266,6 @@ function decrypt(ciphertext: string): string { } } -function encryptOptionalSecret(value: string | null | undefined): string | null { - return value ? encrypt(value) : null -} - function decryptOptionalSecret(value: string | null | undefined): string | null { return value ? decrypt(value) : null } @@ -2535,7 +2532,7 @@ export class Store { private writeGeneration = 0 // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. private writesFrozen = false - // Plaintext hash at last write, to skip no-op writes; not the payload, since encrypt() uses a random IV per call. + // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. private lastWrittenStateHash: string | null = null private firstPendingSaveAt: number | null = null private githubCacheDirty = false @@ -2959,6 +2956,18 @@ export class Store { ) { this.loadNeedsSave = true } + // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. + const localAccountRuntimeAlreadyMigrated = + parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true + const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = + localAccountRuntimeAlreadyMigrated + ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) + : parsed.settings?.localAccountRuntime === 'wsl' + ? 'wsl' + : 'auto' + if (!localAccountRuntimeAlreadyMigrated) { + this.loadNeedsSave = true + } if (!autoRenameBranchFromWorkDefaultedOn) { this.loadNeedsSave = true } @@ -3038,6 +3047,8 @@ export class Store { terminalMacOptionAsAlt: migratedOptionAsAlt, terminalMacOptionAsAltMigrated: true, localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, + localAccountRuntime: migratedLocalAccountRuntime, + localAccountRuntimeDefaultedToAutoForAllUsers: true, floatingTerminalEnabled: migratedFloatingTerminalEnabled, floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: migratedFloatingTerminalCwd, @@ -3491,27 +3502,61 @@ export class Store { return durable } - private computeStateHash(): string { - return createHash('sha1').update(JSON.stringify(this.getDurableState())).digest('hex') - } - - // Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). - private buildStateToSave(): string { + // Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). One full-state stringify serves both the on-disk payload and the no-op-write guard hash: each secret slot is serialized as a fresh unguessable sentinel, then sentinels are substituted to ciphertext for the payload and to plaintext for the hash. The hash is thus a pure function of plaintext state (skips a byte-identical rewrite) without a second stringify. + private buildStateToSave(): { payload: string; stateHash: string } { + // Why sentinels (not a blob/key string match): the substitution must be + // position-exact. A plain search for the ciphertext — or even for a + // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an + // agentDefaultEnv var named after a secret field, or a value equal to a + // ciphertext), which would substitute the wrong site and let two DISTINCT + // states normalize equal → a silently dropped write (data loss), reachable + // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot + // random UUID can't occur anywhere else in the serialized state (the user + // sets their data before it is minted), so it appears exactly once. + const secretSubs: { sentinel: string; blob: string; plaintext: string }[] = [] + const encryptToSentinel = (plaintext: string): string => { + const blob = encrypt(plaintext) + // Deterministic already (empty secret / safeStorage unavailable / encrypt + // failure): blob === plaintext, so no normalization — and no sentinel, + // which also avoids substituting an empty or plaintext-shaped slot. + if (blob === plaintext) { + return blob + } + const sentinel = `orca-secret-slot-${randomUUID()}` + secretSubs.push({ sentinel, blob, plaintext }) + return sentinel + } // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), settings: { ...this.state.settings, - opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie), - httpProxyUrl: encrypt(this.state.settings.httpProxyUrl ?? '') + opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie), + httpProxyUrl: encryptToSentinel(this.state.settings.httpProxyUrl ?? '') }, ui: { ...this.state.ui, - browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink) + browserKagiSessionLink: this.state.ui.browserKagiSessionLink + ? encryptToSentinel(this.state.ui.browserKagiSessionLink) + : null } } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. - return JSON.stringify(stateToSave) + // One full-state stringify; secret slots currently hold sentinels. + const serialized = JSON.stringify(stateToSave) + // Substitute each unique sentinel exactly once: ciphertext for the on-disk + // payload, plaintext for the guard hash. Function-form replacement keeps + // `$` in blob/plaintext inert; both sides read the sentinel as JSON-escaped + // in `serialized`, so each replace is byte-for-byte position-exact. + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, plaintext } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => blob) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(plaintext).slice(1, -1)) + } + const stateHash = createHash('sha1').update(hashInput).digest('hex') + return { payload, stateHash } } // Why: async writes avoid blocking the main Electron thread on every debounced save. @@ -3520,12 +3565,11 @@ export class Store { return } const gen = this.writeGeneration - const stateHash = this.computeStateHash() + const { payload, stateHash } = this.buildStateToSave() // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. if (stateHash === this.lastWrittenStateHash) { return } - const payload = this.buildStateToSave() const dataFile = this.dataFile const dir = dirname(dataFile) await mkdir(dir, { recursive: true }).catch(() => {}) @@ -3565,7 +3609,7 @@ export class Store { if (this.writesFrozen) { return } - const stateHash = this.computeStateHash() + const { payload, stateHash } = this.buildStateToSave() // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. if (!opts.force && stateHash === this.lastWrittenStateHash) { return @@ -3577,8 +3621,6 @@ export class Store { } const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - const payload = this.buildStateToSave() - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. let renamed = false try { @@ -5181,6 +5223,7 @@ export class Store { usagePercentageDisplay: normalizeUsagePercentageDisplay( this.state.ui?.usagePercentageDisplay ), + statusBarUsageMode: normalizeStatusBarUsageMode(this.state.ui?.statusBarUsageMode), // Why: strict boolean coercion so a missing/legacy value reads as false (first-run notice still fires). trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), @@ -5278,6 +5321,9 @@ export class Store { usagePercentageDisplay: normalizeUsagePercentageDisplay( sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay ), + statusBarUsageMode: normalizeStatusBarUsageMode( + sanitizedUpdates.statusBarUsageMode ?? this.state.ui?.statusBarUsageMode + ), markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index a93735a0287..aff948c6138 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -109,6 +109,97 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') }) + // Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364). + it('reports the outer omp wrapper, not the wrapped pi child', async () => { + mockPs(['101 100 S+ omp', '102 101 S+ pi'].join('\n')) + + await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp') + }) + + it('reports omp even when the wrapped pi child holds the foreground alone', async () => { + // Why: across command boundaries only the deeper `pi` carries `+`; the + // wrapper identity must stay omp regardless of which frame we sampled. + mockPs(['101 100 S omp', '102 101 S+ pi'].join('\n')) + + await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp') + }) + + it('reports bare pi when no omp wrapper is present', async () => { + mockPs(['101 100 S+ pi'].join('\n')) + + await expect(resolveAgentForegroundProcess(100, 'pi')).resolves.toBe('pi') + }) + + it('reports the outer omp wrapper on Windows', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { + stdout: windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'omp.exe', + Name: 'omp.exe', + ParentProcessId: 100, + ProcessId: 101 + }, + { + CommandLine: 'pi.exe', + Name: 'pi.exe', + ParentProcessId: 101, + ProcessId: 102 + } + ]), + stderr: '' + }) + } + ) + + await expect(resolveAgentForegroundProcess(100, 'pi.exe')).resolves.toBe('omp') + }) + + it('keeps the Windows omp ancestor when context selects one of multiple pi descendants', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'omp.exe', + Name: 'omp.exe', + ParentProcessId: 100, + ProcessId: 101 + }, + { + CommandLine: 'pi.exe --cwd C:\\repo\\orca', + Name: 'pi.exe', + ParentProcessId: 101, + ProcessId: 102 + }, + { + CommandLine: 'pi.exe --cwd C:\\repo\\other', + Name: 'pi.exe', + ParentProcessId: 100, + ProcessId: 103 + } + ]) + ) + + await expect( + resolveAgentForegroundProcess(100, 'pi.exe', { contextPaths: ['C:\\repo\\orca'] }) + ).resolves.toBe('omp') + }) + it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => { mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex') @@ -117,7 +208,7 @@ describe('resolveAgentForegroundProcess', () => { ).resolves.toEqual({ available: false, processName: 'zsh' }) }) - it('treats a failed fresh POSIX scan as unavailable', async () => { + it('treats failed POSIX scans as unavailable', async () => { execFileMock.mockImplementation( (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void @@ -128,6 +219,10 @@ describe('resolveAgentForegroundProcess', () => { await expect( resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) ).resolves.toEqual({ available: false, processName: 'zsh' }) + await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({ + available: false, + processName: 'zsh' + }) await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') }) diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 6224b804953..e428c945c32 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,4 +1,5 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import { getFreshProcessTableSnapshot, getProcessTableSnapshot, @@ -101,7 +102,8 @@ export async function resolveAgentForegroundProcessWithAvailability( processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess } } catch { - return { available: !options.fresh, processName: fallbackProcess } + // Why: a failed scan cannot prove fallback ownership; callers retain the last recognized agent. + return { available: false, processName: fallbackProcess } } } @@ -125,7 +127,9 @@ function resolveAgentForegroundProcessFromPs( } const recognized = recognizeAgentProcessFromCommandLine(candidate.command) if (recognized) { - return recognized.processName + // Why: return the outer wrapper (omp) rather than the deeper wrapped child + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) } } return null diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 8609d97d18f..a32e7ed2b6a 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -441,6 +441,44 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') }) + it('does not inherit NODE_ENV from the Orca process env', async () => { + // Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets + // `development` in dev runs); leaking it breaks `next build` and Vitest. + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + try { + await provider.spawn({ cols: 80, rows: 24 }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.NODE_ENV).toBeUndefined() + expect(spawnCall[2].env.PATH).toBe(process.env.PATH) + }) + + it('keeps an explicitly requested NODE_ENV for spawned terminals', async () => { + // Why: only the ambient value is stripped; a caller-supplied NODE_ENV still wins. + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + try { + await provider.spawn({ cols: 80, rows: 24, env: { NODE_ENV: 'production' } }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.NODE_ENV).toBe('production') + }) + it('suppresses the first-run Powerlevel10k wizard for spawned terminals', async () => { await provider.spawn({ cols: 80, rows: 24 }) @@ -1612,6 +1650,7 @@ describe('LocalPtyProvider', () => { }) it('classifies startup queries before runtime and public data listeners', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const runtimeData = vi.fn() const dataHandler = vi.fn() provider.configure({ onData: runtimeData }) @@ -1621,8 +1660,7 @@ describe('LocalPtyProvider', () => { rows: 24, startupIngress: { colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000, - echoProjection: 'windows-conpty-esc-stripped' + deadlineMs: 5_000 } }) const onDataCb = mockProc.onData.mock.calls[0][0] @@ -1652,6 +1690,47 @@ describe('LocalPtyProvider', () => { ]) }) + it('consumes a native Windows OSC color query before renderer delivery', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const dataHandler = vi.fn() + provider.onData(dataHandler) + const { id } = await provider.spawn({ + cols: 80, + rows: 24, + shellOverride: 'powershell.exe' + }) + const onDataCb = mockProc.onData.mock.calls[0][0] + const query = '\x1b]10;?\x07' + + onDataCb(query) + + expect(dataHandler).toHaveBeenCalledWith({ + id, + data: '', + sequenceChars: query.length, + seq: query.length, + transformed: true + }) + expect(mockProc.write).not.toHaveBeenCalled() + }) + + it('keeps forwarded OSC color replies for a Windows-owned WSL PTY', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const { id } = await provider.spawn({ + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + const onDataCb = mockProc.onData.mock.calls[0][0] + const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' + + onDataCb('\x1b]11;?\x07') + provider.write(id, reply) + + expect(mockProc.write).toHaveBeenCalledWith(reply) + }) + it('notifies exit listeners when PTY exits', async () => { const exitHandler = vi.fn() provider.onExit(exitHandler) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index e83b5103d7a..41d8708e36a 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -37,6 +37,7 @@ import { import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' +import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' import { @@ -66,6 +67,7 @@ import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' +import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -471,6 +473,8 @@ export type LocalPtyProviderOptions = { ctx?: { command?: string launchAgent?: PtySpawnOptions['launchAgent'] + codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride'] + cwd?: string shellPath?: string isWsl?: boolean wslDistro?: string | null @@ -634,7 +638,7 @@ export class LocalPtyProvider implements IPtyProvider { validateWorkingDirectory(validationCwd) const spawnEnv: Record = { - ...mergeGitConfigEnvProtocol(process.env, args.env), + ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), args.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', @@ -672,6 +676,8 @@ export class LocalPtyProvider implements IPtyProvider { ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, launchAgent: args.launchAgent, + codexHomePathOverride: args.codexHomePathOverride, + cwd, shellPath, isWsl: isWslShell, wslDistro: launchWslDistro @@ -876,6 +882,11 @@ export class LocalPtyProvider implements IPtyProvider { } const startupIngress = new PtyStartupIngress({ ...(args.startupIngress ? { intent: args.startupIngress } : {}), + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath, + wslDistro: spawnedWslDistro + }), write: (data) => proc.write(data), onEmission: emitIngressData }) diff --git a/src/main/providers/ssh-pty-applied-size.ts b/src/main/providers/ssh-pty-applied-size.ts new file mode 100644 index 00000000000..f725f7c2a09 --- /dev/null +++ b/src/main/providers/ssh-pty-applied-size.ts @@ -0,0 +1,49 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' +import { toRelaySshPtyId } from './ssh-pty-id' + +type AppliedPtySize = { cols: number; rows: number } +// Why: wake repair can safely re-forward on failure, so never inherit the generic 30-second RPC timeout. +const SSH_PTY_APPLIED_SIZE_TIMEOUT_MS = 1_000 + +export function createSshPtyAppliedSizeReader( + mux: SshChannelMultiplexer, + connectionId: string +): (id: string) => Promise { + let supported: boolean | null = null + return async (id) => { + if (supported === false) { + return null + } + try { + const result = (await mux.request( + 'pty.getSize', + { + id: toRelaySshPtyId(connectionId, id) + }, + { timeoutMs: SSH_PTY_APPLIED_SIZE_TIMEOUT_MS } + )) as { + cols?: unknown + rows?: unknown + } | null + supported = true + if ( + !result || + !Number.isInteger(result.cols) || + !Number.isInteger(result.rows) || + Number(result.cols) <= 0 || + Number(result.rows) <= 0 + ) { + return null + } + return { cols: Number(result.cols), rows: Number(result.rows) } + } catch (error) { + if ((error as { code?: unknown })?.code === JsonRpcErrorCode.MethodNotFound) { + // Why: old relays lack pty.getSize; remember that per SSH provider so + // each wake re-forwards once without repeatedly probing the same host. + supported = false + } + return null + } + } +} diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 209218fa1f0..cac21f2fe97 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env' +import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' type MockMultiplexer = { request: ReturnType @@ -60,7 +61,10 @@ describe('SshPtyProvider', () => { expect(mux.request).toHaveBeenCalledWith( 'pty.spawn', - expect.objectContaining({ startupIngressVersion: 1, startupIngress }) + expect.objectContaining({ + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + startupIngress + }) ) }) @@ -470,6 +474,33 @@ describe('SshPtyProvider', () => { expect(mux.notify).toHaveBeenCalledWith('pty.resize', { id: 'pty-1', cols: 120, rows: 40 }) }) + it('reads the applied PTY size from the relay', async () => { + mux.request.mockResolvedValue({ cols: 120, rows: 40 }) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 120, rows: 40 }) + expect(mux.request).toHaveBeenCalledWith('pty.getSize', { id: 'pty-1' }, { timeoutMs: 1_000 }) + }) + + it('caches only an old relay method-not-found response', async () => { + mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + expect(mux.request).toHaveBeenCalledTimes(1) + }) + + it('retries an applied-size read after a transient relay failure', async () => { + mux.request + .mockRejectedValueOnce( + Object.assign(new Error('connection lost'), { code: 'CONNECTION_LOST' }) + ) + .mockResolvedValueOnce({ cols: 100, rows: 30 }) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 100, rows: 30 }) + expect(mux.request).toHaveBeenCalledTimes(2) + }) + it('shutdown sends pty.shutdown request', async () => { await provider.shutdown(scopedPty1, { immediate: true }) expect(mux.request).toHaveBeenCalledWith( diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index df6d0540d47..48240e27e25 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -3,6 +3,7 @@ import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } fr import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' +import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' type DataCallback = (payload: { id: string @@ -56,6 +57,7 @@ export class SshPtyProvider implements IPtyProvider { // multiplexer. Without this, notification callbacks keep firing after // the provider is torn down on disconnect, routing events to stale state. private unsubscribeNotifications: (() => void) | null = null + readonly getAppliedSize: NonNullable constructor( connectionId: string, @@ -64,6 +66,7 @@ export class SshPtyProvider implements IPtyProvider { ) { this.connectionId = connectionId this.mux = mux + this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) // Subscribe to relay notifications for PTY events this.unsubscribeNotifications = mux.onNotification((method, params) => { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 6523736fd2b..fca74501c10 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -56,6 +56,8 @@ export type PtySpawnOptions = { cwd?: string env?: Record envToDelete?: string[] + /** Main-validated home provenance for an automatic Codex session resume. */ + codexHomePathOverride?: { value: string | null } command?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 556d6763e62..8da37b5f611 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -1,8 +1,14 @@ import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, - recognizeAgentProcessFromCommandLine + recognizeAgentProcess, + recognizeAgentProcessFromCommandLine, + type RecognizedAgentProcess } from '../../shared/agent-process-recognition' +import { + resolveOuterWrapperForegroundProcess, + shouldInspectOuterWrapperForegroundProcess +} from '../../shared/foreground-wrapper-agent' import { isShellProcess } from '../../shared/shell-process-detection' import { queryWindowsProcessDescendants, @@ -26,7 +32,12 @@ export type WindowsAgentForegroundResolution = { } export function shouldInspectWindowsAgentForeground(fallbackProcess: string): boolean { - return isAgentForegroundWrapperProcess(fallbackProcess) || isShellProcess(fallbackProcess) + const recognized = recognizeAgentProcess(fallbackProcess) + return ( + isAgentForegroundWrapperProcess(fallbackProcess) || + isShellProcess(fallbackProcess) || + (recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized)) + ) } export async function resolveWindowsAgentForegroundProcess( @@ -106,14 +117,18 @@ function resolveWindowsProcessName( windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess) ) if (wrapperCandidates.length !== 1) { - return resolveWrapperForegroundProcessFromWindowsCandidates(wrapperCandidates, contextPaths) + return resolveWrapperForegroundProcessFromWindowsCandidates( + wrapperCandidates, + candidates, + contextPaths + ) } const [candidate] = wrapperCandidates const recognized = recognizeAgentProcessFromCommandLine(candidate.command) ?? recognizeAgentProcessFromCommandLine(candidate.name) if (recognized) { - return recognized.processName + return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) } return null } @@ -132,6 +147,7 @@ function resolveShellForegroundProcessFromWindowsCandidates( function resolveWrapperForegroundProcessFromWindowsCandidates( candidates: readonly WindowsProcessCandidate[], + allCandidates: readonly WindowsProcessCandidate[], contextPaths: readonly string[] | undefined ): string | null { const contextCandidates = createRecognizedWindowsProcessCandidates( @@ -139,7 +155,7 @@ function resolveWrapperForegroundProcessFromWindowsCandidates( contextPaths ).filter((candidate) => candidate.contextMatch) return contextCandidates.length > 0 - ? resolveRecognizedWindowsProcessCandidates(contextCandidates, candidates) + ? resolveRecognizedWindowsProcessCandidates(contextCandidates, allCandidates) : null } @@ -147,6 +163,7 @@ type RecognizedWindowsProcessCandidate = WindowsProcessRow & { contextMatch: boolean depth: number processName: string + recognized: RecognizedAgentProcess } function createRecognizedWindowsProcessCandidates( @@ -163,7 +180,8 @@ function createRecognizedWindowsProcessCandidates( { ...candidate, contextMatch: candidateMatchesContextPath(candidate, normalizedContextPaths), - processName: recognized + processName: recognized.processName, + recognized } ] }) @@ -176,13 +194,6 @@ function resolveRecognizedWindowsProcessCandidates( if (recognizedCandidates.length === 0) { return null } - const recognizedProcessNames = new Set( - recognizedCandidates.map((candidate) => candidate.processName) - ) - if (recognizedProcessNames.size === 1) { - return [...recognizedProcessNames][0] - } - const candidatesByPid = new Map(allCandidates.map((candidate) => [candidate.pid, candidate])) const leafCandidates = recognizedCandidates.filter( (candidate) => @@ -192,7 +203,11 @@ function resolveRecognizedWindowsProcessCandidates( windowsCandidateIsAncestor(candidate, other, candidatesByPid) ) ) - const leafProcessNames = new Set(leafCandidates.map((candidate) => candidate.processName)) + const leafProcessNames = new Set( + leafCandidates.map((candidate) => + resolveOuterWrapperForegroundProcess(candidate.recognized, candidate, allCandidates) + ) + ) // Why: Windows lacks a cheap PTY foreground marker like POSIX '+'. A single // recognized lineage leaf is strong enough; sibling agent leaves are not. return leafProcessNames.size === 1 ? [...leafProcessNames][0] : null @@ -267,11 +282,13 @@ function commandLineContainsPath(haystack: string, contextPath: string): boolean return false } -function recognizeWindowsProcessCandidate(candidate: WindowsProcessRow): string | null { - const recognized = +function recognizeWindowsProcessCandidate( + candidate: WindowsProcessRow +): RecognizedAgentProcess | null { + return ( recognizeAgentProcessFromCommandLine(candidate.command) ?? recognizeAgentProcessFromCommandLine(candidate.name) - return recognized?.processName ?? null + ) } function windowsCandidateMatchesFallbackWrapper( diff --git a/src/main/pty/build-mode-env.test.ts b/src/main/pty/build-mode-env.test.ts new file mode 100644 index 00000000000..22df13bba7d --- /dev/null +++ b/src/main/pty/build-mode-env.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { stripInheritedBuildModeEnv } from './build-mode-env' + +describe('stripInheritedBuildModeEnv', () => { + it('drops NODE_ENV while keeping every other inherited variable', () => { + const env = { NODE_ENV: 'development', PATH: '/usr/bin', HOME: '/home/tester' } + + expect(stripInheritedBuildModeEnv(env)).toEqual({ PATH: '/usr/bin', HOME: '/home/tester' }) + }) + + it('does not mutate the source env', () => { + const env = { NODE_ENV: 'development' } + + stripInheritedBuildModeEnv(env) + + expect(env.NODE_ENV).toBe('development') + }) + + it('is a no-op when NODE_ENV is unset', () => { + expect(stripInheritedBuildModeEnv({ PATH: '/usr/bin' })).toEqual({ PATH: '/usr/bin' }) + }) +}) diff --git a/src/main/pty/build-mode-env.ts b/src/main/pty/build-mode-env.ts new file mode 100644 index 00000000000..de82874abea --- /dev/null +++ b/src/main/pty/build-mode-env.ts @@ -0,0 +1,9 @@ +/** Return a copy of an inherited environment without Orca's own build-mode flag. */ +export function stripInheritedBuildModeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + // Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets + // `development` in dev runs), not the user's; leaking it makes `next build` + // and Vitest take the wrong branch. Caller/renderer env and shell rc still win. + const next = { ...env } + delete next.NODE_ENV + return next +} diff --git a/src/main/rate-limits/account-runtime-target-sync.test.ts b/src/main/rate-limits/account-runtime-target-sync.test.ts new file mode 100644 index 00000000000..1277c89114e --- /dev/null +++ b/src/main/rate-limits/account-runtime-target-sync.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { RateLimitState } from '../../shared/rate-limit-types' +import { createAccountRuntimeTargetSettingsSync } from './account-runtime-target-sync' + +function createServiceTargets( + claudeTarget: RateLimitState['claudeTarget'], + codexTarget: RateLimitState['codexTarget'] +) { + const state = { claudeTarget, codexTarget } as RateLimitState + return { + getState: vi.fn(() => state), + refreshClaudeForTarget: vi.fn(async () => state), + refreshCodexForTarget: vi.fn(async () => state) + } +} + +describe('createAccountRuntimeTargetSettingsSync', () => { + it('retargets only Claude and Codex when auto changes to WSL', async () => { + const service = createServiceTargets( + { runtime: 'host', wslDistro: null }, + { runtime: 'host', wslDistro: null } + ) + const settings = { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto' as const, + localWindowsRuntimeDefault: { kind: 'wsl' as const, distro: 'Ubuntu' } + } + const syncSettings = createAccountRuntimeTargetSettingsSync( + service, + getDefaultSettings('/tmp'), + 'win32' + ) + + await syncSettings( + { localWindowsRuntimeDefault: settings.localWindowsRuntimeDefault }, + settings + ) + + const expectedTarget = { runtime: 'wsl', wslDistro: 'Ubuntu' } + expect(service.refreshClaudeForTarget).toHaveBeenCalledOnce() + expect(service.refreshClaudeForTarget).toHaveBeenCalledWith(expectedTarget) + expect(service.refreshCodexForTarget).toHaveBeenCalledOnce() + expect(service.refreshCodexForTarget).toHaveBeenCalledWith(expectedTarget) + }) + + it('does no work for unrelated settings updates', async () => { + const service = createServiceTargets( + { runtime: 'host', wslDistro: null }, + { runtime: 'host', wslDistro: null } + ) + const settings = getDefaultSettings('/tmp') + const syncSettings = createAccountRuntimeTargetSettingsSync(service, settings, 'win32') + + await syncSettings({ theme: 'dark' }, settings) + + expect(service.getState).not.toHaveBeenCalled() + expect(service.refreshClaudeForTarget).not.toHaveBeenCalled() + expect(service.refreshCodexForTarget).not.toHaveBeenCalled() + }) + + it('preserves a manual runtime when the settings-derived policy does not change', async () => { + const service = createServiceTargets( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + const initialSettings = { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'host' as const + } + const settings = { + ...initialSettings, + localWindowsRuntimeDefault: { kind: 'wsl' as const, distro: 'Ubuntu' } + } + const syncSettings = createAccountRuntimeTargetSettingsSync(service, initialSettings, 'win32') + + await syncSettings( + { localWindowsRuntimeDefault: settings.localWindowsRuntimeDefault }, + settings + ) + + expect(service.getState).not.toHaveBeenCalled() + expect(service.refreshClaudeForTarget).not.toHaveBeenCalled() + expect(service.refreshCodexForTarget).not.toHaveBeenCalled() + }) + + it('refreshes only the provider whose current target differs', async () => { + const service = createServiceTargets( + { runtime: 'host', wslDistro: null }, + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + const initialSettings = { + ...getDefaultSettings('/tmp'), + localWindowsRuntimeDefault: { kind: 'wsl' as const, distro: 'Ubuntu' } + } + const settings = getDefaultSettings('/tmp') + const syncSettings = createAccountRuntimeTargetSettingsSync(service, initialSettings, 'win32') + + await syncSettings( + { localWindowsRuntimeDefault: settings.localWindowsRuntimeDefault }, + settings + ) + + expect(service.refreshClaudeForTarget).not.toHaveBeenCalled() + expect(service.refreshCodexForTarget).toHaveBeenCalledOnce() + expect(service.refreshCodexForTarget).toHaveBeenCalledWith({ runtime: 'host' }) + }) +}) diff --git a/src/main/rate-limits/account-runtime-target-sync.ts b/src/main/rate-limits/account-runtime-target-sync.ts new file mode 100644 index 00000000000..d78a4b68910 --- /dev/null +++ b/src/main/rate-limits/account-runtime-target-sync.ts @@ -0,0 +1,70 @@ +import type { GlobalSettings } from '../../shared/types' +import type { RateLimitState } from '../../shared/rate-limit-types' +import type { RateLimitService } from './service' +import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' + +type AccountRuntimeRateLimitService = Pick< + RateLimitService, + 'getState' | 'refreshClaudeForTarget' | 'refreshCodexForTarget' +> + +type RuntimeTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export function createAccountRuntimeTargetSettingsSync( + rateLimits: AccountRuntimeRateLimitService, + initialSettings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): (updates: Partial, settings: GlobalSettings) => Promise { + let settingsTargets = getSettingsTargets(initialSettings, platform) + + return async (updates, settings): Promise => { + if (!containsAccountRuntimeTargetUpdate(updates)) { + return + } + + const nextSettingsTargets = getSettingsTargets(settings, platform) + const claudePolicyChanged = !isSameTarget(settingsTargets.claude, nextSettingsTargets.claude) + const codexPolicyChanged = !isSameTarget(settingsTargets.codex, nextSettingsTargets.codex) + settingsTargets = nextSettingsTargets + if (!claudePolicyChanged && !codexPolicyChanged) { + return + } + + const current = rateLimits.getState() + const refreshes: Promise[] = [] + if (claudePolicyChanged && !isSameTarget(current.claudeTarget, nextSettingsTargets.claude)) { + refreshes.push(rateLimits.refreshClaudeForTarget(nextSettingsTargets.claude)) + } + if (codexPolicyChanged && !isSameTarget(current.codexTarget, nextSettingsTargets.codex)) { + refreshes.push(rateLimits.refreshCodexForTarget(nextSettingsTargets.codex)) + } + + await Promise.all(refreshes) + } +} + +function getSettingsTargets(settings: GlobalSettings, platform: NodeJS.Platform) { + return { + claude: getInitialClaudeRateLimitTarget(settings, platform), + codex: getInitialCodexRateLimitTarget(settings, platform) + } +} + +function containsAccountRuntimeTargetUpdate(updates: Partial): boolean { + return ( + 'localAccountRuntime' in updates || + 'localAccountWslDistro' in updates || + 'localWindowsRuntimeDefault' in updates + ) +} + +function isSameTarget(current: RuntimeTarget, next: RuntimeTarget): boolean { + return ( + (current.runtime ?? 'host') === (next.runtime ?? 'host') && + (current.wslDistro ?? null) === (next.wslDistro ?? null) + ) +} diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 95f7d607ac0..7eb218f04de 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -856,15 +856,20 @@ describe('fetchClaudeRateLimits', () => { message: 'Rate limited. Please try again later.' } }), - { status: 429 } + { status: 429, headers: { 'retry-after': '3000' } } ) ) - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + const before = Date.now() + const result = await fetchClaudeRateLimits({ authPreparation }) + expect(result).toMatchObject({ provider: 'claude', status: 'error', - error: 'Claude usage is rate limited right now.' + error: 'Claude usage is rate limited right now.', + usageMetadata: expect.objectContaining({ failureKind: 'rate-limited' }) }) + expect(result.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3000 * 1000) + expect(result.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3000 * 1000) expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', @@ -877,6 +882,41 @@ describe('fetchClaudeRateLimits', () => { expect(fetchViaPty).not.toHaveBeenCalled() }) + it('omits retryAtMs when a 429 has no Retry-After header', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'expired-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + type: 'rate_limit_error', + message: 'Rate limited. Please try again later.' + } + }), + { status: 429 } + ) + ) + + const result = await fetchClaudeRateLimits({ authPreparation }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.retryAtMs).toBeUndefined() + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('uses CLI fallback for OAuth auth failures when automatic repair is safe', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index e37e831d54d..206748e1bc5 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -33,6 +33,7 @@ import { refreshClaudeOauthCredentials } from '../claude-accounts/oauth-refresh' import { createOAuthUsageError, OAuthUsageError } from './claude-oauth-usage-error' +import { mapClaudeUsageWindow, type ClaudeUsageWindowInput } from './claude-usage-window' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' @@ -278,11 +279,7 @@ function warnClaudeUsageFetchFailure( // OAuth API fetch // --------------------------------------------------------------------------- -type OAuthUsageWindow = { - utilization?: number - used_percentage?: number - resets_at?: string | number -} +type OAuthUsageWindow = ClaudeUsageWindowInput type OAuthUsageLimit = { kind?: string @@ -316,73 +313,6 @@ function abortedClaudeRateLimitResult(): ProviderRateLimits { } } -function parseResetTimestamp(value: string | number | undefined): number | null { - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - return null - } - return value > 10_000_000_000 ? value : value * 1000 - } - - if (!value) { - return null - } - - const numericValue = Number(value) - if (Number.isFinite(numericValue) && value.trim() !== '') { - return numericValue > 10_000_000_000 ? numericValue : numericValue * 1000 - } - - const parsed = new Date(value).getTime() - return Number.isNaN(parsed) ? null : parsed -} - -function parseResetDescription(resetValue: string | number | undefined): string | null { - const resetTimestamp = parseResetTimestamp(resetValue) - if (resetTimestamp === null) { - return null - } - try { - const date = new Date(resetTimestamp) - const now = new Date() - const isToday = date.toDateString() === now.toDateString() - if (isToday) { - return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) - } - return date.toLocaleDateString(undefined, { - weekday: 'short', - hour: 'numeric', - minute: '2-digit' - }) - } catch { - return null - } -} - -function mapWindow( - raw: OAuthUsageWindow | undefined, - windowMinutes: number -): RateLimitWindow | null { - if (!raw) { - return null - } - const usedPercent = - typeof raw.utilization === 'number' - ? raw.utilization - : typeof raw.used_percentage === 'number' - ? raw.used_percentage - : null - if (usedPercent === null) { - return null - } - return { - usedPercent: Math.min(100, Math.max(0, usedPercent)), - windowMinutes, - resetsAt: parseResetTimestamp(raw.resets_at), - resetDescription: parseResetDescription(raw.resets_at) - } -} - function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { // Why: model quotas moved to structured scoped limits; prefer them but keep legacy weekly fields for older responses. const scoped = Array.isArray(data.limits) @@ -396,13 +326,13 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null ) : undefined return ( - mapWindow( + mapClaudeUsageWindow( scoped ? { used_percentage: scoped.percent, resets_at: scoped.resets_at } : undefined, 10080 ) ?? - mapWindow(data.fable_weekly, 10080) ?? - mapWindow(data.fable_seven_day, 10080) ?? - mapWindow(data.seven_day_fable, 10080) + mapClaudeUsageWindow(data.fable_weekly, 10080) ?? + mapClaudeUsageWindow(data.fable_seven_day, 10080) ?? + mapClaudeUsageWindow(data.seven_day_fable, 10080) ) } @@ -443,8 +373,8 @@ async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise): Response { + return new Response('{"error":{"type":"rate_limit_error"}}', { status: 429, headers }) +} + +describe('createOAuthUsageError', () => { + it('captures a numeric Retry-After on 429', async () => { + const error = await createOAuthUsageError(rateLimitedResponse({ 'retry-after': '3037' })) + expect(error.status).toBe(429) + expect(error.skipPtyFallback).toBe(true) + expect(error.retryAfterMs).toBe(3037 * 1000) + expect(error.message).toBe('Claude usage is rate limited right now.') + }) + + it('captures an HTTP-date Retry-After on 429', async () => { + const error = await createOAuthUsageError( + rateLimitedResponse({ 'retry-after': new Date(Date.now() + 90_000).toUTCString() }) + ) + expect(error.retryAfterMs).toBeGreaterThan(0) + expect(error.retryAfterMs).toBeLessThanOrEqual(90_000) + }) + + it('caps Retry-After at 24 hours', async () => { + const error = await createOAuthUsageError( + rateLimitedResponse({ 'retry-after': String(48 * 60 * 60) }) + ) + expect(error.retryAfterMs).toBe(24 * 60 * 60 * 1000) + }) + + it('ignores missing, invalid, and non-positive Retry-After values', async () => { + for (const headers of [ + undefined, + { 'retry-after': 'soon' }, + { 'retry-after': '0' }, + { 'retry-after': '-5' } + ]) { + const error = await createOAuthUsageError(rateLimitedResponse(headers)) + expect(error.retryAfterMs).toBeNull() + } + }) + + it('does not capture Retry-After for non-429 responses', async () => { + const error = await createOAuthUsageError( + new Response('{"error":{"message":"Invalid token"}}', { + status: 401, + headers: { 'retry-after': '60' } + }) + ) + expect(error.status).toBe(401) + expect(error.retryAfterMs).toBeNull() + }) +}) diff --git a/src/main/rate-limits/claude-oauth-usage-error.ts b/src/main/rate-limits/claude-oauth-usage-error.ts index 8742ff08f2f..1ccb1471de6 100644 --- a/src/main/rate-limits/claude-oauth-usage-error.ts +++ b/src/main/rate-limits/claude-oauth-usage-error.ts @@ -1,8 +1,12 @@ +// Why: a corrupt/hostile Retry-After must not gate usage refreshes for days. +const MAX_RETRY_AFTER_MS = 24 * 60 * 60 * 1000 + export class OAuthUsageError extends Error { constructor( message: string, readonly status: number, - readonly skipPtyFallback: boolean + readonly skipPtyFallback: boolean, + readonly retryAfterMs: number | null = null ) { super(message) } @@ -14,10 +18,28 @@ export async function createOAuthUsageError(res: Response): Promise 0 ? Math.min(seconds * 1000, MAX_RETRY_AFTER_MS) : null + } + // Why: Retry-After may also be an HTTP-date (RFC 9110). + const dateMs = Date.parse(header) + if (!Number.isFinite(dateMs)) { + return null + } + const delta = dateMs - Date.now() + return delta > 0 ? Math.min(delta, MAX_RETRY_AFTER_MS) : null +} + async function describeOAuthUsageError(res: Response): Promise { if (res.status === 429) { return 'Claude usage is rate limited right now.' diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts index e6789814f41..969124655ad 100644 --- a/src/main/rate-limits/claude-rate-limit-target.test.ts +++ b/src/main/rate-limits/claude-rate-limit-target.test.ts @@ -103,6 +103,49 @@ describe('getInitialClaudeRateLimitTarget', () => { ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + it('auto follows the global WSL project runtime default', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('auto resolves to host when the global project runtime is windows-host', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) + + it('does not let a stale WSL-only selection override an auto host target', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' }, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) + it('keeps explicit host runtime on host', () => { expect( getInitialClaudeRateLimitTarget( @@ -120,4 +163,17 @@ describe('getInitialClaudeRateLimitTarget', () => { ) ).toEqual({ runtime: 'host' }) }) + + it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + }, + 'linux' + ) + ).toEqual({ runtime: 'host' }) + }) }) diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts index f98a3e2645a..199250cd3e5 100644 --- a/src/main/rate-limits/claude-rate-limit-target.ts +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../shared/types' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getClaudeWslSelectionKey, normalizeClaudeRuntimeSelection, @@ -29,6 +30,9 @@ export function getInitialClaudeRateLimitTarget( return { runtime: 'host' } } if (settings.localAccountRuntime === 'wsl') { + if (platform !== 'win32') { + return { runtime: 'host' } + } return { runtime: 'wsl', wslDistro: @@ -36,7 +40,14 @@ export function getInitialClaudeRateLimitTarget( getSingleSelectedWslDistro(settings) } } + if (settings.localAccountRuntime === 'auto') { + const target = resolveLocalAccountRuntimeTarget(settings, platform) + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } + } + // Why: pre-setting profiles used account selection as their startup fallback. const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) if (projectRuntimeTarget) { return projectRuntimeTarget diff --git a/src/main/rate-limits/claude-usage-window.ts b/src/main/rate-limits/claude-usage-window.ts new file mode 100644 index 00000000000..dc4b7f49e23 --- /dev/null +++ b/src/main/rate-limits/claude-usage-window.ts @@ -0,0 +1,76 @@ +import type { RateLimitWindow } from '../../shared/rate-limit-types' + +// Why: shared by the OAuth usage fetcher and the statusline live feed, whose window payloads share this shape. +export type ClaudeUsageWindowInput = { + utilization?: number + used_percentage?: number + resets_at?: string | number +} + +// Why: 1e10 sits between any plausible seconds epoch (<2286) and any millisecond epoch (>2001), so it distinguishes the two units without extra metadata. +export function parseClaudeUsageResetTimestamp(value: string | number | undefined): number | null { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return null + } + return value > 10_000_000_000 ? value : value * 1000 + } + + if (!value) { + return null + } + + const numericValue = Number(value) + if (Number.isFinite(numericValue) && value.trim() !== '') { + return numericValue > 10_000_000_000 ? numericValue : numericValue * 1000 + } + + const parsed = new Date(value).getTime() + return Number.isNaN(parsed) ? null : parsed +} + +function parseClaudeUsageResetDescription(resetValue: string | number | undefined): string | null { + const resetTimestamp = parseClaudeUsageResetTimestamp(resetValue) + if (resetTimestamp === null) { + return null + } + try { + const date = new Date(resetTimestamp) + const now = new Date() + const isToday = date.toDateString() === now.toDateString() + if (isToday) { + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + } + return date.toLocaleDateString(undefined, { + weekday: 'short', + hour: 'numeric', + minute: '2-digit' + }) + } catch { + return null + } +} + +export function mapClaudeUsageWindow( + raw: ClaudeUsageWindowInput | undefined, + windowMinutes: number +): RateLimitWindow | null { + if (!raw) { + return null + } + const usedPercent = + typeof raw.utilization === 'number' + ? raw.utilization + : typeof raw.used_percentage === 'number' + ? raw.used_percentage + : null + if (usedPercent === null) { + return null + } + return { + usedPercent: Math.min(100, Math.max(0, usedPercent)), + windowMinutes, + resetsAt: parseClaudeUsageResetTimestamp(raw.resets_at), + resetDescription: parseClaudeUsageResetDescription(raw.resets_at) + } +} diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index d345d94a5bf..9102e54ab09 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -81,6 +81,7 @@ describe('Codex backend rate-limit requests', () => { availableCount: 1, nextExpiresAt: Date.parse('2027-01-15T12:00:00Z') }, + planType: 'plus', status: 'ok' }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 77eef555a68..dd328e007d5 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -532,6 +532,8 @@ async function fetchViaBackend( provider: 'codex', session: mapBackendUsageWindow(payload.rate_limit?.primary_window, 300), weekly: mapBackendUsageWindow(payload.rate_limit?.secondary_window, 10080), + // Surfaced for the status-bar Usage row (e.g. "Codex · Plus"). + planType: payload.plan_type, ...(payload.rate_limit_reset_credits !== undefined ? { rateLimitResetCredits: diff --git a/src/main/rate-limits/codex-rate-limit-target.test.ts b/src/main/rate-limits/codex-rate-limit-target.test.ts index fbe87616a5a..b3b39f027f9 100644 --- a/src/main/rate-limits/codex-rate-limit-target.test.ts +++ b/src/main/rate-limits/codex-rate-limit-target.test.ts @@ -103,6 +103,49 @@ describe('getInitialCodexRateLimitTarget', () => { ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + it('auto follows the global WSL project runtime default', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('auto resolves to host when the global project runtime is windows-host', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) + + it('does not let a stale WSL-only selection override an auto host target', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' }, + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) + it('keeps explicit host runtime on host', () => { expect( getInitialCodexRateLimitTarget( @@ -120,4 +163,17 @@ describe('getInitialCodexRateLimitTarget', () => { ) ).toEqual({ runtime: 'host' }) }) + + it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + }, + 'darwin' + ) + ).toEqual({ runtime: 'host' }) + }) }) diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts index 67bf04fe9db..65c459ad7d3 100644 --- a/src/main/rate-limits/codex-rate-limit-target.ts +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../shared/types' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getWslSelectionKey, normalizeCodexRuntimeSelection, @@ -29,6 +30,9 @@ export function getInitialCodexRateLimitTarget( return { runtime: 'host' } } if (settings.localAccountRuntime === 'wsl') { + if (platform !== 'win32') { + return { runtime: 'host' } + } return { runtime: 'wsl', wslDistro: @@ -36,7 +40,14 @@ export function getInitialCodexRateLimitTarget( getSingleSelectedWslDistro(settings) } } + if (settings.localAccountRuntime === 'auto') { + const target = resolveLocalAccountRuntimeTarget(settings, platform) + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } + } + // Why: pre-setting profiles used account selection as their startup fallback. const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) if (projectRuntimeTarget) { return projectRuntimeTarget diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 78a374269f5..c4c15f2c3f8 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -561,6 +561,390 @@ describe('RateLimitService', () => { } }) + it('waits out Retry-After before automated Claude refetches, then recovers', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => ({ + ...errorProvider('claude', 'Claude usage is rate limited right now.'), + usageMetadata: { failureKind: 'rate-limited', retryAtMs: Date.now() + 40 * 60 * 1000 } + })) + .mockImplementation(async () => okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // Activations that would normally retry immediately must respect the server's Retry-After. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // The 15- and 30-minute poll cycles land inside the 40-minute window: other providers refresh, Claude is skipped. + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(vi.mocked(fetchCodexRateLimits).mock.calls.length).toBeGreaterThan(1) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // The 45-minute poll cycle is past the window and refetches Claude. + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(service.getState().claude?.status).toBe('ok') + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('lets a user-directed refresh bypass the Claude Retry-After gate', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => ({ + ...errorProvider('claude', 'Claude usage is rate limited right now.'), + usageMetadata: { failureKind: 'rate-limited', retryAtMs: Date.now() + 40 * 60 * 1000 } + })) + .mockImplementation(async () => okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + + await service.refresh() + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('error') + + await service.refresh() + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(service.getState().claude?.status).toBe('ok') + } finally { + vi.useRealTimers() + } + }) + + it('keeps last-known usage through a rate-limited window past the generic stale threshold', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => okProvider('claude', 18)) + .mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage is rate limited right now.'), + usageMetadata: { failureKind: 'rate-limited' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + + await service.refresh() + expect(service.getState().claude?.status).toBe('ok') + + // 31 minutes later the generic 30-minute stale policy would drop the snapshot; rate-limited failures keep it. + await vi.advanceTimersByTimeAsync(31 * 60 * 1000) + await service.refresh() + + const claude = service.getState().claude + expect(claude?.status).toBe('error') + expect(claude?.error).toBe('Claude usage is rate limited right now.') + expect(claude?.session?.usedPercent).toBe(18) + } finally { + vi.useRealTimers() + } + }) + + it('ingests statusline usage, clears errors, and skips OAuth polls while the live feed is fresh', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage is rate limited right now.'), + usageMetadata: { failureKind: 'rate-limited' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('error') + + // A live session reports usage 12 minutes in; the error state clears without any OAuth call. + await vi.advanceTimersByTimeAsync(12 * 60 * 1000 - 1000) + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 23.5, resets_at: Math.floor(Date.now() / 1000) + 3600 }, + sevenDay: { used_percentage: 41.2 } + }) + + const claude = service.getState().claude + expect(claude?.status).toBe('ok') + expect(claude?.error).toBeNull() + expect(claude?.session?.usedPercent).toBe(23.5) + expect(claude?.weekly?.usedPercent).toBe(41.2) + expect(claude?.usageMetadata?.source).toBe('live-session') + + // The 15-minute poll cycle lands inside the live-feed freshness window: other providers refresh, Claude's OAuth fetch is skipped. + const codexCallsBeforePoll = vi.mocked(fetchCodexRateLimits).mock.calls.length + await vi.advanceTimersByTimeAsync(3 * 60 * 1000) + expect(vi.mocked(fetchCodexRateLimits).mock.calls.length).toBeGreaterThan( + codexCallsBeforePoll + ) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // Once the live feed goes stale, automated refetches resume. + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(vi.mocked(fetchClaudeRateLimits).mock.calls.length).toBeGreaterThan(1) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('drops statusline posts before attribution is known or from a mismatched config dir', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + + // No fetch cycle has captured the selected account's config dir yet. + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 50 }, + sevenDay: null + }) + expect(service.getState().claude).toBeNull() + + await service.refresh() + expect(service.getState().claude?.session?.usedPercent).toBe(18) + + // A managed-account session must not overwrite the system-default bar. + service.ingestLiveClaudeRateLimits({ + configDir: '/some/managed/dir', + fiveHour: { used_percentage: 99 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(18) + + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 33 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(33) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the other window when a statusline post carries only one', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 20 }, + sevenDay: { used_percentage: 41 } + }) + expect(service.getState().claude?.weekly?.usedPercent).toBe(41) + + // A later post reporting only the 5h window must not wipe the weekly bar. + await vi.advanceTimersByTimeAsync(1000) + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 25 }, + sevenDay: null + }) + const claude = service.getState().claude + expect(claude?.session?.usedPercent).toBe(25) + expect(claude?.weekly?.usedPercent).toBe(41) + } finally { + vi.useRealTimers() + } + }) + + it('dedupes identical statusline posts within the throttle window', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + + const event = { + configDir: null, + fiveHour: { used_percentage: 20, resets_at: 1738425600 }, + sevenDay: null + } + service.ingestLiveClaudeRateLimits(event) + const firstUpdatedAt = service.getState().claude?.updatedAt + + await vi.advanceTimersByTimeAsync(1000) + service.ingestLiveClaudeRateLimits(event) + expect(service.getState().claude?.updatedAt).toBe(firstUpdatedAt) + + // A changed window updates immediately despite the throttle. + service.ingestLiveClaudeRateLimits({ + ...event, + fiveHour: { used_percentage: 21, resets_at: 1738425600 } + }) + expect(service.getState().claude?.session?.usedPercent).toBe(21) + } finally { + vi.useRealTimers() + } + }) + + it('does not roll back a live-session snapshot that arrived while a claude fetch was in flight', async () => { + vi.useFakeTimers() + try { + const parkedClaudeFetch = deferred() + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => okProvider('claude', 18)) + .mockImplementationOnce(() => parkedClaudeFetch.promise) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + + const secondRefresh = service.refresh() + await flushMicrotasks() + + // A live statusline post lands while the second fetch is parked in flight. + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 41 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(41) + + parkedClaudeFetch.resolve({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'boom', + status: 'error' + }) + await secondRefresh + + // The failed fetch must not roll the bar back to the pre-cycle snapshot or flip it to error. + const claude = service.getState().claude + expect(claude?.status).toBe('ok') + expect(claude?.session?.usedPercent).toBe(41) + expect(claude?.usageMetadata?.source).toBe('live-session') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a populated weekly bar when a live post carries only the five-hour window', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue({ + ...okProvider('claude', 18), + weekly: { usedPercent: 62, windowMinutes: 10080, resetsAt: null, resetDescription: null } + }) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + expect(service.getState().claude?.weekly?.usedPercent).toBe(62) + + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 23.5 }, + sevenDay: null + }) + + const claude = service.getState().claude + expect(claude?.session?.usedPercent).toBe(23.5) + expect(claude?.weekly?.usedPercent).toBe(62) + expect(claude?.usageMetadata?.source).toBe('live-session') + } finally { + vi.useRealTimers() + } + }) + + it('does not restore the outgoing account auth snapshot when the account switches mid-resolve', async () => { + vi.useFakeTimers() + try { + const staleClaudeFetch = deferred() + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(() => staleClaudeFetch.promise) + .mockImplementation(async () => okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + + const authGate = deferred() + let resolverCalls = 0 + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => { + resolverCalls += 1 + const outgoing = resolverCalls === 1 + if (outgoing) { + await authGate.promise + } + return { + configDir: outgoing ? '/outgoing/.claude' : '/incoming/.claude', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { + CLAUDE_CONFIG_DIR: outgoing ? '/outgoing/.claude' : '/incoming/.claude' + }, + stripAuthEnv: false, + provenance: outgoing ? 'managed:outgoing' : 'managed:incoming' + } + }) + + // The first cycle is parked inside the outgoing account's resolver await when the switch lands. + const firstRefresh = service.refresh() + await flushMicrotasks() + const switchPromise = service.refreshForClaudeAccountChange('outgoing-account') + await flushMicrotasks() + authGate.resolve() + await flushMicrotasks(8) + + // The stale cycle resumed after the switch; while its Claude fetch is still in flight, + // a live post from the outgoing session must not land on the incoming account's bar. + const beforeOutgoingPost = service.getState().claude?.session?.usedPercent + service.ingestLiveClaudeRateLimits({ + configDir: '/outgoing/.claude', + fiveHour: { used_percentage: 99 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(beforeOutgoingPost) + + staleClaudeFetch.resolve(okProvider('claude', 18)) + await Promise.all([firstRefresh, switchPromise]) + + // The incoming account's own sessions still attribute correctly. + service.ingestLiveClaudeRateLimits({ + configDir: '/incoming/.claude', + fiveHour: { used_percentage: 55 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(55) + } finally { + vi.useRealTimers() + } + }) + it('keeps a settled error chip settled during background refetches instead of flashing fetching', async () => { vi.useFakeTimers() try { diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 13dfd7a62c4..7af75e3ccfb 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -9,6 +9,8 @@ import type { } from '../../shared/rate-limit-types' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import type { InactiveClaudeAccountInfo } from './claude-fetcher' +import { mapClaudeUsageWindow } from './claude-usage-window' +import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { NetworkProxySettings } from '../../shared/network-proxy' @@ -83,6 +85,10 @@ const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = 'grok' ]) const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped +// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". +const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 +// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. +const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 @@ -120,6 +126,22 @@ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { + // Why: the same dir can arrive with mixed separators (Windows env vs statusline JSON); unify them so attribution compares paths, not spellings. Case is left alone — Linux paths are case-sensitive. + const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return trimmed || null +} + +function isSameUsageWindow( + a: ProviderRateLimits['session'], + b: ProviderRateLimits['session'] +): boolean { + if (!a || !b) { + return a === b + } + return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -168,6 +190,8 @@ export class RateLimitService { private fetchIdleResolvers: (() => void)[] = [] private codexFetchGeneration = 0 private claudeFetchGeneration = 0 + // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. + private lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null private opencodeFetchGeneration = 0 private minimaxFetchGeneration = 0 private lastOpencodeConfigHash = '' @@ -433,6 +457,8 @@ export class RateLimitService { this.claudeFetchGeneration += 1 // Why: a new account/target starts with a clean retry schedule. this.activeFailureStreakByProvider.claude = 0 + // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. + this.lastClaudeAuthSnapshot = null this.lastInactiveClaudeFetchAt = 0 this.updateState({ ...this.state, @@ -448,6 +474,10 @@ export class RateLimitService { this.claudeFetchTarget = nextTarget this.claudeFetchGeneration += 1 this.activeFailureStreakByProvider.claude = 0 + if (targetChanged) { + // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. + this.lastClaudeAuthSnapshot = null + } this.updateState({ ...this.state, claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') @@ -758,6 +788,10 @@ export class RateLimitService { } // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. if (limits.status === 'error') { + // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. + if (this.isRetryAfterActive(limits)) { + continue + } const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) ? Math.min( @@ -847,11 +881,14 @@ export class RateLimitService { try { let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false while (shouldContinue) { const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal) + this.runFetchAllCycle(fetchSignal, { force: cycleForce }) ) shouldContinue = false + cycleForce = true if (signal.aborted) { break } @@ -872,7 +909,7 @@ export class RateLimitService { if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal) + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) ) if (claudeSignal.aborted) { break @@ -917,7 +954,7 @@ export class RateLimitService { if (this.fullFetchQueued) { this.fullFetchQueued = false const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal) + this.runFetchAllCycle(fetchSignal, { force: true }) ) if (fullSignal.aborted) { break @@ -931,7 +968,7 @@ export class RateLimitService { if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal) + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) ) if (claudeSignal.aborted) { break @@ -965,18 +1002,21 @@ export class RateLimitService { try { let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false while (shouldContinue) { const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal) + this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) ) shouldContinue = false + cycleForce = true if (signal.aborted) { break } if (this.fullFetchQueued) { this.fullFetchQueued = false const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal) + this.runFetchAllCycle(fetchSignal, { force: true }) ) if (fullSignal.aborted) { break @@ -1035,7 +1075,7 @@ export class RateLimitService { if (this.fullFetchQueued) { this.fullFetchQueued = false const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal) + this.runFetchAllCycle(fetchSignal, { force: true }) ) if (fullSignal.aborted) { break @@ -1058,7 +1098,7 @@ export class RateLimitService { if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal) + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) ) if (claudeSignal.aborted) { break @@ -1242,6 +1282,119 @@ export class RateLimitService { } } + // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. + private isRetryAfterActive(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'error' && + limits.usageMetadata?.retryAtMs && + limits.usageMetadata.retryAtMs > Date.now() + ) + } + + // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. + private isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'ok' && + limits.usageMetadata?.source === 'live-session' && + Date.now() - limits.updatedAt < MIN_REFETCH_MS + ) + } + + private shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { + return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) + } + + private resolveClaudeFetchApply( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not + // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. + const current = this.state.claude + if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { + return current + } + return this.applyStalePolicy(fresh, previous) + } + + private rememberClaudeAuthSnapshot( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + claudeGeneration: number, + claudeTarget: NormalizedClaudeAccountSelectionTarget + ): void { + // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. + if ( + claudeGeneration !== this.claudeFetchGeneration || + !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + ) { + return + } + this.lastClaudeAuthSnapshot = { + configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + provenance: authPreparation?.provenance ?? 'system' + } + } + + /** Live usage windows forwarded from a Claude session's statusLine command. */ + ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { + // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. + const snapshot = this.lastClaudeAuthSnapshot + if (!snapshot) { + // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. + console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { + eventConfigDir: event.configDir + }) + return + } + // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. + if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { + console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { + eventConfigDir: event.configDir, + snapshotConfigDir: snapshot.configDir + }) + return + } + const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) + const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) + if (!freshSession && !freshWeekly) { + return + } + const previous = this.state.claude + // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. + const session = freshSession ?? previous?.session ?? null + const weekly = freshWeekly ?? previous?.weekly ?? null + if ( + previous?.status === 'ok' && + previous.usageMetadata?.source === 'live-session' && + Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && + isSameUsageWindow(previous.session, session) && + isSameUsageWindow(previous.weekly, weekly) + ) { + return + } + this.activeFailureStreakByProvider.claude = 0 + this.updateState({ + ...this.state, + claude: { + provider: 'claude', + session, + weekly, + // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. + // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. + fableWeekly: previous?.fableWeekly ?? null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'live-session', + lastSuccessfulSource: 'live-session', + credentialSource: previous?.usageMetadata?.credentialSource, + authProvenance: snapshot.provenance + } + } + }) + } + private trackActiveFailureStreak( provider: ActiveRateLimitProvider, fresh: ProviderRateLimits @@ -1287,17 +1440,22 @@ export class RateLimitService { return { ...current, status: 'fetching' } } - private async runFetchAllCycle(signal: AbortSignal): Promise { + private async runFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { if (signal.aborted) { return } const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { return } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const claudeGeneration = this.claudeFetchGeneration const codexTarget = this.codexFetchTarget const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) @@ -1360,15 +1518,21 @@ export class RateLimitService { (reason) => ({ status: 'rejected', reason }) as const ) + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + const claudeFetchGated = + !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = await Promise.allSettled([ - fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), missingWslCodexHome ?? fetchCodexRateLimits({ codexHomePath, @@ -1488,7 +1652,9 @@ export class RateLimitService { const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance + // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. const shouldApplyClaude = + !claudeFetchGated && claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance && this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) @@ -1515,7 +1681,7 @@ export class RateLimitService { this.updateState({ ...this.state, claude: shouldApplyClaude - ? this.applyStalePolicy(claude, previousState.claude) + ? this.resolveClaudeFetchApply(claude, previousState.claude) : this.state.claude, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) @@ -1612,17 +1778,26 @@ export class RateLimitService { }) } - private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise { + private async runFetchClaudeOnlyCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { if (signal.aborted) { return } + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { + return + } const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { return } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const claudeGeneration = this.claudeFetchGeneration const previousState = this.state this.updateState({ @@ -1667,7 +1842,7 @@ export class RateLimitService { this.updateState({ ...this.state, claude: shouldApplyClaude - ? this.applyStalePolicy(claude, previousState.claude) + ? this.resolveClaudeFetchApply(claude, previousState.claude) : this.state.claude }) } @@ -1745,7 +1920,11 @@ export class RateLimitService { } // Previous data is too old — don't show stale data - if (Date.now() - previous.updatedAt > STALE_THRESHOLD_MS) { + const staleThresholdMs = + fresh.usageMetadata?.failureKind === 'rate-limited' + ? RATE_LIMITED_STALE_THRESHOLD_MS + : STALE_THRESHOLD_MS + if (Date.now() - previous.updatedAt > staleThresholdMs) { return fresh } diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index e6abbe278f8..c491f344422 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -179,7 +179,8 @@ describe('detectRepoIcon', () => { source: 'github', label: 'stablyai/orca' }, - upstream: { owner: 'stablyai', repo: 'orca' } + // Why: fork parents resolve host-qualified so avatars/links stay on the fork's server. + upstream: { owner: 'stablyai', repo: 'orca', host: 'github.com' } }) }) diff --git a/src/main/runtime/client-session-tab-selection.test.ts b/src/main/runtime/client-session-tab-selection.test.ts new file mode 100644 index 00000000000..c75ffd2ac78 --- /dev/null +++ b/src/main/runtime/client-session-tab-selection.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { + activateClientSessionTabSelection, + ClientSessionTabSelectionStore, + deriveClientSessionTabSelection, + projectClientSessionTabSelection +} from './client-session-tab-selection' + +function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult { + const tabs = [ + { + type: 'terminal' as const, + id: 'terminal-a::leaf-a', + parentTabId: 'terminal-a', + leafId: 'leaf-a', + title: 'A', + isActive: activeTabId === 'terminal-a::leaf-a', + status: 'ready' as const, + terminal: 'term-a' + }, + { + type: 'terminal' as const, + id: 'terminal-a::leaf-b', + parentTabId: 'terminal-a', + leafId: 'leaf-b', + title: 'A split', + isActive: activeTabId === 'terminal-a::leaf-b', + status: 'ready' as const, + terminal: 'term-b' + }, + { + type: 'browser' as const, + id: 'browser-unified', + browserWorkspaceId: 'browser-workspace', + browserPageId: 'page-1', + title: 'Browser', + url: 'about:blank', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: activeTabId === 'browser-unified' + } + ] + return { + worktree: 'wt-1', + publicationEpoch: 'renderer:1', + snapshotVersion: 1, + activeGroupId: 'group-left', + activeTabId, + activeTabType: activeTabId === 'browser-unified' ? 'browser' : 'terminal', + tabGroups: [ + { id: 'group-left', activeTabId: 'terminal-a', tabOrder: ['terminal-a'] }, + { id: 'group-right', activeTabId: 'browser-unified', tabOrder: ['browser-unified'] } + ], + tabs + } +} + +describe('client session-tab selection', () => { + it('keeps a client selection when a later host snapshot activates another tab', () => { + const initial = snapshot() + const selected = activateClientSessionTabSelection( + initial, + deriveClientSessionTabSelection(initial), + 'browser-unified' + ) + const hostChanged = snapshot('terminal-a::leaf-b') + + const projected = projectClientSessionTabSelection(hostChanged, selected) + + expect(projected.snapshot.activeTabId).toBe('browser-unified') + expect(projected.snapshot.activeGroupId).toBe('group-right') + expect(projected.snapshot.tabGroups?.[1]?.activeTabId).toBe('browser-unified') + }) + + it('tracks split-leaf focus while group selection uses the parent tab id', () => { + const initial = snapshot() + const selected = activateClientSessionTabSelection( + initial, + deriveClientSessionTabSelection(initial), + 'terminal-a::leaf-b' + ) + + const projected = projectClientSessionTabSelection(initial, selected) + + expect(projected.snapshot.activeTabId).toBe('terminal-a::leaf-b') + expect(projected.snapshot.tabGroups?.[0]?.activeTabId).toBe('terminal-a') + }) + + it('falls back within the selected group when the selected tab disappears', () => { + const initial = snapshot() + const selected = activateClientSessionTabSelection( + initial, + deriveClientSessionTabSelection(initial), + 'terminal-a::leaf-b' + ) + const removed = { + ...initial, + activeGroupId: 'group-left', + activeTabId: 'terminal-a::leaf-a', + activeTabType: 'terminal' as const, + tabs: initial.tabs.filter((tab) => tab.id !== 'terminal-a::leaf-b') + } + + const projected = projectClientSessionTabSelection(removed, selected) + + expect(projected.snapshot.activeGroupId).toBe('group-left') + expect(projected.snapshot.activeTabId).toBe('terminal-a::leaf-a') + }) + + it('namespaces projections by device and discards revoked state', () => { + const store = new ClientSessionTabSelectionStore() + const initial = snapshot() + + store.project(initial, 'device-a') + store.project(initial, 'device-b') + const selectedA = store.activate(initial, 'device-a', 'browser-unified') + + expect(selectedA.activeTabId).toBe('browser-unified') + expect(selectedA.publicationEpoch).toBe('renderer:1:client-navigation') + expect(selectedA.snapshotVersion).toBe(2) + expect(store.project(initial, 'device-b').activeTabId).toBe('terminal-a::leaf-a') + const selectedAgain = store.activate(initial, 'device-a', 'terminal-a::leaf-b') + expect(selectedAgain.snapshotVersion).toBe(3) + expect(selectedAgain.activeTabId).toBe('terminal-a::leaf-b') + store.forgetClient('device-a') + expect(store.project(initial, 'device-a').activeTabId).toBe('terminal-a::leaf-a') + }) + + it('does not expose host focus when initializing a new paired device', () => { + const store = new ClientSessionTabSelectionStore() + const hostFocusedBrowser = snapshot('browser-unified') + + const projected = store.project(hostFocusedBrowser, 'new-device') + + expect(projected.activeTabId).toBe('terminal-a::leaf-a') + expect(projected.activeGroupId).toBe('group-left') + expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('terminal-a::leaf-a') + }) +}) diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts new file mode 100644 index 00000000000..d3cb60e67b7 --- /dev/null +++ b/src/main/runtime/client-session-tab-selection.ts @@ -0,0 +1,195 @@ +import type { + RuntimeMobileSessionClientTab, + RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' + +export type ClientSessionTabSelection = { + activeTabId: string | null + activeGroupId: string | null + activeTabIdByGroupId: Readonly> +} + +type StoredClientSessionTabSelection = { + selection: ClientSessionTabSelection + revision: number +} + +function emptyClientSessionTabSelection(): ClientSessionTabSelection { + return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } +} + +function topLevelTabId(tab: RuntimeMobileSessionClientTab): string { + if (tab.type === 'terminal') { + return tab.parentTabId + } + return tab.id +} + +function findTabByTopLevelId( + snapshot: RuntimeMobileSessionTabsResult, + topLevelId: string | null | undefined +): RuntimeMobileSessionClientTab | null { + if (!topLevelId) { + return null + } + return snapshot.tabs.find((tab) => topLevelTabId(tab) === topLevelId) ?? null +} + +export function deriveClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult +): ClientSessionTabSelection { + return { + activeTabId: snapshot.activeTabId, + activeGroupId: snapshot.activeGroupId, + activeTabIdByGroupId: Object.fromEntries( + snapshot.tabGroups?.flatMap((group) => + group.activeTabId ? [[group.id, group.activeTabId] as const] : [] + ) ?? [] + ) + } +} + +export function activateClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult, + selection: ClientSessionTabSelection, + activeTabId: string +): ClientSessionTabSelection { + const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId) + if (!activeTab) { + return selection + } + const activeTopLevelTabId = topLevelTabId(activeTab) + const activeGroup = snapshot.tabGroups?.find((group) => + group.tabOrder.includes(activeTopLevelTabId) + ) + return { + activeTabId, + activeGroupId: activeGroup?.id ?? selection.activeGroupId, + activeTabIdByGroupId: activeGroup + ? { ...selection.activeTabIdByGroupId, [activeGroup.id]: activeTopLevelTabId } + : selection.activeTabIdByGroupId + } +} + +export function projectClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult, + selection: ClientSessionTabSelection +): { snapshot: RuntimeMobileSessionTabsResult; selection: ClientSessionTabSelection } { + const selectedGroup = snapshot.tabGroups?.find((group) => group.id === selection.activeGroupId) + // Why: preserve the client's leaf and group choices before falling back to shared snapshot order. + const activeTab = + snapshot.tabs.find((tab) => tab.id === selection.activeTabId) ?? + findTabByTopLevelId( + snapshot, + selectedGroup ? selection.activeTabIdByGroupId[selectedGroup.id] : null + ) ?? + findTabByTopLevelId(snapshot, selectedGroup?.tabOrder[0]) ?? + snapshot.tabs[0] ?? + null + const activeTopLevelTabId = activeTab ? topLevelTabId(activeTab) : null + const activeTabIdByGroupId: Record = {} + const tabGroups = snapshot.tabGroups?.map((group) => { + const selected = selection.activeTabIdByGroupId[group.id] + const activeTabId = + (selected && group.tabOrder.includes(selected) ? selected : null) ?? group.tabOrder[0] ?? null + if (activeTabId) { + activeTabIdByGroupId[group.id] = activeTabId + } + return { ...group, activeTabId } + }) + const activeGroupId = + tabGroups?.find((group) => + activeTopLevelTabId ? group.tabOrder.includes(activeTopLevelTabId) : false + )?.id ?? + (selection.activeGroupId && tabGroups?.some((group) => group.id === selection.activeGroupId) + ? selection.activeGroupId + : null) ?? + tabGroups?.[0]?.id ?? + null + const nextSelection: ClientSessionTabSelection = { + activeTabId: activeTab?.id ?? null, + activeGroupId, + activeTabIdByGroupId + } + return { + selection: nextSelection, + snapshot: { + ...snapshot, + activeGroupId, + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + ...(tabGroups ? { tabGroups } : {}), + tabs: snapshot.tabs.map((tab) => ({ ...tab, isActive: tab.id === activeTab?.id })) + } + } +} + +export class ClientSessionTabSelectionStore { + private statesByClient = new Map>() + + private getStatesByWorktree( + clientNavigationId: string + ): Map { + let statesByWorktree = this.statesByClient.get(clientNavigationId) + if (!statesByWorktree) { + statesByWorktree = new Map() + this.statesByClient.set(clientNavigationId, statesByWorktree) + } + return statesByWorktree + } + + project( + snapshot: RuntimeMobileSessionTabsResult, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { + if (!clientNavigationId) { + return snapshot + } + const statesByWorktree = this.getStatesByWorktree(clientNavigationId) + const state = statesByWorktree.get(snapshot.worktree) ?? { + // Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it. + selection: emptyClientSessionTabSelection(), + revision: 0 + } + const projected = projectClientSessionTabSelection(snapshot, state.selection) + statesByWorktree.set(snapshot.worktree, { + selection: projected.selection, + revision: state.revision + }) + return { + ...projected.snapshot, + publicationEpoch: `${snapshot.publicationEpoch}:client-navigation`, + snapshotVersion: snapshot.snapshotVersion + state.revision + } + } + + activate( + snapshot: RuntimeMobileSessionTabsResult, + clientNavigationId: string, + activeTabId: string + ): RuntimeMobileSessionTabsResult { + const statesByWorktree = this.getStatesByWorktree(clientNavigationId) + const state = statesByWorktree.get(snapshot.worktree) ?? { + selection: emptyClientSessionTabSelection(), + revision: 0 + } + statesByWorktree.set(snapshot.worktree, { + selection: activateClientSessionTabSelection(snapshot, state.selection, activeTabId), + revision: state.revision + 1 + }) + return this.project(snapshot, clientNavigationId) + } + + forgetClient(clientNavigationId: string): void { + this.statesByClient.delete(clientNavigationId) + } + + forgetWorktree(worktreeId: string): void { + for (const [clientNavigationId, statesByWorktree] of this.statesByClient) { + statesByWorktree.delete(worktreeId) + if (statesByWorktree.size === 0) { + this.statesByClient.delete(clientNavigationId) + } + } + } +} diff --git a/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts new file mode 100644 index 00000000000..3efcbcc8010 --- /dev/null +++ b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts @@ -0,0 +1,861 @@ +/** + * Graph-sync mobile snapshot gating: the serve-only hydrate fast-path must + * never hide a serve-owned terminal or a headless browser tab, and the + * changed-worktree coalesced fanout must emit every real change while + * suppressing no-op syncs entirely. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +// Freshness predicate of shouldApplyWebSessionTabsSnapshot in +// src/renderer/src/runtime/web-session-tabs-sync.ts, copied as a literal +// because a main-process test must not import a renderer module: a same-epoch +// frame whose version is not strictly newer is dropped by web clients. +function makeWebClientFreshnessGate(): (frame: RuntimeMobileSessionTabsResult) => boolean { + const latestByWorktree = new Map() + return (frame) => { + const current = latestByWorktree.get(frame.worktree) + if ( + current && + current.publicationEpoch === frame.publicationEpoch && + frame.snapshotVersion <= current.snapshotVersion + ) { + return false + } + latestByWorktree.set(frame.worktree, { + publicationEpoch: frame.publicationEpoch, + snapshotVersion: frame.snapshotVersion + }) + return true + } +} + +const WT = 'repo-1::/tmp/worktree-a' +const WT_B = 'repo-1::/tmp/worktree-b' + +const storeBase = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [storeBase.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function makeSession(overrides: Partial = {}): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides + } +} + +function makeTerminalTab(id: string, ptyId: string | null) { + return { + id, + ptyId, + worktreeId: WT, + title: `Terminal ${id}`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function createRuntime(initialSession: WorkspaceSessionState) { + let session = initialSession + const runtime = new OrcaRuntimeService({ + ...storeBase, + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + } + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const setSession = (next: WorkspaceSessionState): void => { + session = next + } + const sync = ( + mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[], + graph: { tabs?: unknown[]; leaves?: unknown[] } = {} + ): void => { + runtime.syncWindowGraph(1, { + tabs: (graph.tabs ?? []) as never, + leaves: (graph.leaves ?? []) as never, + mobileSessionTabs + }) + } + return { runtime, events, sync, setSession, unsubscribe } +} + +function makeRendererSnapshot(args: { + worktree?: string + version: number + epoch?: string + title?: string + ptyId?: string +}): RuntimeMobileSessionTabsSnapshot { + const worktree = args.worktree ?? WT + return { + worktree, + publicationEpoch: args.epoch ?? 'renderer:test-epoch', + snapshotVersion: args.version, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: args.title ?? 'Terminal 1', + ...(args.ptyId ? { ptyId: args.ptyId } : {}), + isActive: true + } + ] + } +} + +type RuntimeInternals = { + buildHeadlessMobileSessionTerminalTabs: (...args: unknown[]) => unknown[] + offscreenBrowserBackend: unknown + agentBrowserBridge: unknown + mobileSessionTabsByWorktree: Map +} + +describe('graph-sync mobile snapshot gating', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('hydrates a serve-owned terminal bound via tab.ptyId and emits it', () => { + const { events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + + sync([]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.worktree).toBe(WT) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' }) + ]) + }) + + it('hydrates a serve-owned terminal bound ONLY via the layout leaf map (superset proof)', () => { + const { events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('split-tab', null)] }, + terminalLayoutsByTabId: { + 'split-tab': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'serve-pty-2' } + } + } + }) + ) + + sync([]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + parentTabId: 'split-tab', + parentLayout: expect.objectContaining({ + ptyIdsByLeafId: { 'leaf-a': 'serve-pty-2' } + }) + }) + ]) + }) + + it('skips the serve-only hydrate rebuild and emits nothing when no serve ptys and no browser backend', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] } + }) + ) + const buildSpy = vi.spyOn( + runtime as unknown as RuntimeInternals, + 'buildHeadlessMobileSessionTerminalTabs' + ) + + const snapshot = makeRendererSnapshot({ version: 1 }) + sync([structuredClone(snapshot)]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(buildSpy).not.toHaveBeenCalled() + + // Re-sync with fresh IPC-style structured clones of the same publication + // (same epoch + version = the renderer's unchanged-content resend): zero + // emits, even though object identity never survives IPC. + events.length = 0 + for (let i = 0; i < 5; i++) { + sync([structuredClone(snapshot)]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(buildSpy).not.toHaveBeenCalled() + }) + + it('still emits when the renderer publishes a new version with identical content', () => { + const { events, sync } = createRuntime(makeSession()) + + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + events.length = 0 + + // Byte-identical content, but a fresh version: a higher version is the + // renderer saying "this worktree changed", so it must emit. + sync([makeRendererSnapshot({ version: 2 })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + }) + + it('accepts a renderer-reload epoch with a reset snapshotVersion and emits', () => { + const { events, sync } = createRuntime(makeSession()) + + sync([makeRendererSnapshot({ version: 5, epoch: 'renderer:epoch-1' })]) + vi.advanceTimersByTime(300) + events.length = 0 + + sync([makeRendererSnapshot({ version: 1, epoch: 'renderer:epoch-2', title: 'Renamed' })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.publicationEpoch).toBe('renderer:epoch-2') + }) + + it('hydrates headless browser tabs when an offscreen backend exists despite zero serve terminals', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] } + }) + ) + const internals = runtime as unknown as RuntimeInternals + internals.offscreenBrowserBackend = { closeTab: vi.fn() } + internals.agentBrowserBridge = { + tabList: vi.fn(() => ({ + tabs: [ + { browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: true } + ] + })), + getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])) + } + + sync([]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'browser', browserPageId: 'page-1' }) + ]) + ) + }) + + it('fans out only the changed worktree, never the unchanged sibling (across IPC clones)', () => { + const { events, sync } = createRuntime(makeSession()) + const snapshotB = makeRendererSnapshot({ worktree: WT_B, version: 1 }) + + sync([makeRendererSnapshot({ version: 1 }), structuredClone(snapshotB)]) + vi.advanceTimersByTime(300) + events.length = 0 + + // Worktree A republishes with a new version; unchanged B arrives as a fresh + // structured clone with the same (epoch, version) pair, exactly as the + // renderer's per-worktree snapshot cache produces over IPC. + sync([makeRendererSnapshot({ version: 3, title: 'Renamed' }), structuredClone(snapshotB)]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.worktree).toBe(WT) + }) + + it('emits a removed frame immediately and cancels the pending coalesced notify', () => { + const { events, sync } = createRuntime(makeSession()) + + sync([makeRendererSnapshot({ version: 1 })]) + // Pending coalesced notify exists (no timer advance yet). Removing the + // worktree must emit the removed frame NOW and cancel the pending notify. + sync([]) + + const removed = events.filter((event) => 'removed' in event && event.removed === true) + expect(removed).toHaveLength(1) + expect(removed[0]?.worktree).toBe(WT) + + events.length = 0 + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + }) + + it('forces an emit by the starvation cap under sustained sync churn', () => { + const { events, sync } = createRuntime(makeSession()) + + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + events.length = 0 + + // Re-schedule every 20ms: the 50ms trailing edge never settles, so the + // 250ms cap must force the emit (the schedule at t>=250 fires inline). + let version = 2 + for (let elapsed = 0; elapsed <= 300; elapsed += 20) { + sync([makeRendererSnapshot({ version: version++, title: `spin-${version}` })]) + vi.advanceTimersByTime(20) + } + expect(events.length).toBeGreaterThanOrEqual(1) + }) + + it('emits ready when a graph-only sync binds a restored leaf under an unchanged snapshot pair', () => { + const { events, sync } = createRuntime(makeSession()) + const webClientAccepts = makeWebClientFreshnessGate() + // Renderer restore: the saved layout ptyId is in the snapshot, but the + // graph leaf has not re-bound yet (null ptyId) — client sees pending-handle. + const snapshot = makeRendererSnapshot({ version: 1, ptyId: 'pty-restored' }) + const nullLeaf = { + tabId: 'tab-1', + worktreeId: WT, + leafId: 'leaf-1', + paneRuntimeId: 1, + ptyId: null + } + sync([structuredClone(snapshot)], { leaves: [nullLeaf] }) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + // The web client applies the pending frame and records its (epoch, version). + expect(webClientAccepts(events[0]!)).toBe(true) + + // A later graph-only sync binds the leaf while the renderer resends the + // exact same (epoch, version) pair. The payload is a function of leaf state + // too, so the subscriber must receive ready + a terminal handle. + events.length = 0 + sync([structuredClone(snapshot)], { leaves: [{ ...nullLeaf, ptyId: 'pty-restored' }] }) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toMatchObject({ + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + // Why: the raw listener receiving the frame is not enough — the web + // freshness gate drops same-epoch versions that aren't strictly newer, so + // the ready frame must carry a bumped version to actually reach the client. + expect(webClientAccepts(events[0]!)).toBe(true) + + // Once bound, further byte-identical no-op syncs stay fully suppressed. + events.length = 0 + sync([structuredClone(snapshot)], { leaves: [{ ...nullLeaf, ptyId: 'pty-restored' }] }) + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + }) + + it('accepts a newer renderer revision after main-local touches raised the stored version', () => { + const { runtime, events, sync } = createRuntime(makeSession()) + sync([makeRendererSnapshot({ version: 1, ptyId: 'pty-live' })]) + vi.advanceTimersByTime(300) + events.length = 0 + + // Several OSC/status chunks touch the stored snapshot: version 1 → 6, + // while the renderer's counter is still at 1. + const internals = runtime as unknown as RuntimeInternals & { + touchMobileSessionSnapshotsForPty: (ptyId: string) => void + } + for (let i = 0; i < 5; i++) { + internals.touchMobileSessionSnapshotsForPty('pty-live') + } + vi.advanceTimersByTime(300) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion).toBe(6) + events.length = 0 + + // The renderer's next real change publishes version 2 — lower than main's 6. + // It must merge (renderer ordering, not stored-version ordering) and the + // emitted version must stay strictly monotonic so clients don't drop it. + sync([makeRendererSnapshot({ version: 2, ptyId: 'pty-live', title: 'Renamed tab' })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toMatchObject({ title: 'Renamed tab' }) + expect(events[0]?.snapshotVersion).toBeGreaterThan(6) + + // Byte-identical graph-sync resends of that revision are no-op suppressed + // and never resurrect the pre-rename content. + events.length = 0 + const emittedVersion = internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion + for (let i = 0; i < 3; i++) { + sync([makeRendererSnapshot({ version: 2, ptyId: 'pty-live', title: 'Renamed tab' })]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion).toBe(emittedVersion) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs[0]?.title).toBe('Renamed tab') + }) + + it('suppresses repeated unchanged syncs with a serve-owned terminal present', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + const internals = runtime as unknown as RuntimeInternals + const stored = internals.mobileSessionTabsByWorktree.get(WT) + + // Byte-identical re-syncs: the serve-only hydrate rebuilds the projection + // but must retain the existing snapshot object/epoch/version, so the + // identity-based no-op gating emits nothing. + events.length = 0 + for (let i = 0; i < 5; i++) { + sync([]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(internals.mobileSessionTabsByWorktree.get(WT)).toBe(stored) + }) + + it('suppresses repeated unchanged syncs with an offscreen browser backend enabled', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] } + }) + ) + const internals = runtime as unknown as RuntimeInternals + internals.offscreenBrowserBackend = { closeTab: vi.fn() } + internals.agentBrowserBridge = { + tabList: vi.fn(() => ({ + tabs: [ + { browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: true } + ] + })), + getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])) + } + sync([]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + const stored = internals.mobileSessionTabsByWorktree.get(WT) + + events.length = 0 + for (let i = 0; i < 5; i++) { + sync([]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(internals.mobileSessionTabsByWorktree.get(WT)).toBe(stored) + + // A real browser change (a newly opened page) must still emit. Same-page + // navigation is delivered via the browser-bridge notify path, not graph + // sync, so a new tab is the graph-sync-visible browser change. + internals.agentBrowserBridge = { + tabList: vi.fn(() => ({ + tabs: [ + { browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: false }, + { browserPageId: 'page-2', index: 1, url: 'https://y.test', title: 'Y', active: true } + ] + })), + getRegisteredTabs: vi.fn( + () => + new Map([ + ['page-1', 100], + ['page-2', 101] + ]) + ) + } + sync([]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'browser', browserPageId: 'page-2' }) + ]) + ) + }) + + it('drops a closed renderer tab from a mixed renderer+serve snapshot while keeping the serve tab', () => { + const { runtime, events, sync, setSession } = createRuntime(makeSession()) + const internals = runtime as unknown as RuntimeInternals + + // Renderer publishes desktop tab A with no serve terminals anywhere. + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + + // A serve-owned terminal appears in the persisted session; the serve-only + // hydrate merges it into the stored RENDERER publication (mixed snapshot). + setSession( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + events.length = 0 + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + const mixed = internals.mobileSessionTabsByWorktree.get(WT) + expect(mixed?.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id))).toEqual([ + 'tab-1', + 'serve-tab' + ]) + + // Desktop closes tab A: the next renderer publication omits it. The merge + // must NOT resurrect A from the mixed snapshot, but the serve tab (not + // renderer-owned) must survive. + events.length = 0 + sync([ + { + worktree: WT, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: 2, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' }) + ]) + + // The serve binding itself disappears from persistence (and no live PTY + // backs it): the next renderer publication must stop preserving it too. + setSession(makeSession()) + events.length = 0 + sync([ + { + worktree: WT, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: 3, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ]) + vi.advanceTimersByTime(60) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs).toEqual([]) + }) + + it('delivers the prune frame when the renderer omits a worktree whose serve tab is preserved', () => { + const { events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + const webClientAccepts = makeWebClientFreshnessGate() + + // Phone accepts the renderer+serve merged frame. + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(webClientAccepts(events[0]!)).toBe(true) + expect(events[0]?.tabs.map((tab) => ('parentTabId' in tab ? tab.parentTabId : tab.id))).toEqual( + ['tab-1', 'serve-tab'] + ) + + // Desktop closes the renderer tab, so the next graph omits the worktree + // while the serve binding persists. Preservation prunes the renderer tab, + // but the preserved epoch hashes only the unchanged serve tab — without a + // fresh snapshotVersion the phone's same-epoch freshness gate drops the + // prune frame and keeps the closed tab forever. + events.length = 0 + sync([]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' }) + ]) + expect(webClientAccepts(events[0]!)).toBe(true) + + // Recomputing the preservation on further omitted syncs is a genuine + // no-op: the preservedIsNoOp identity gate keeps the entry, zero fanout. + events.length = 0 + for (let i = 0; i < 3; i++) { + sync([]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + }) + + it('drops a de-persisted serve tab when the renderer resends the unchanged accepted revision', () => { + const { runtime, events, sync, setSession } = createRuntime(makeSession()) + const internals = runtime as unknown as RuntimeInternals + const webClientAccepts = makeWebClientFreshnessGate() + + // Renderer publishes (epoch, version 1) while a serve binding exists; the + // serve-only hydrate merges the serve tab and version 1 becomes accepted. + setSession( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(webClientAccepts(events[0]!)).toBe(true) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(true) + + // The serve binding disappears from persistence (no live PTY backs it) + // while desktop tabs are unchanged, so the renderer correctly resends the + // SAME (epoch, version 1). The accepted-revision no-op gate must not keep + // the stale preserved serve tab published: the resend must re-merge, drop + // the tab, and reach clients past their same-epoch freshness gate. + setSession(makeSession()) + events.length = 0 + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(60) + expect(events).toHaveLength(1) + expect( + events[0]?.tabs.some((tab) => 'parentTabId' in tab && tab.parentTabId === 'serve-tab') + ).toBe(false) + expect(webClientAccepts(events[0]!)).toBe(true) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(false) + + // Further byte-identical resends of the accepted revision stay suppressed. + events.length = 0 + for (let i = 0; i < 3; i++) { + sync([makeRendererSnapshot({ version: 1 })]) + } + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + }) + + it('drops a de-persisted SSH tab when the renderer resends the unchanged accepted revision', () => { + const sshPtyId = 'ssh:conn-1@@pty-7' + const { runtime, sync, setSession } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('ssh-tab', sshPtyId)] } + }) + ) + const internals = runtime as unknown as RuntimeInternals & { + hydrateHeadlessMobileSessionTabsFromWorkspaceSession: (worktreeId?: string) => Set + } + internals.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WT) + + // Renderer attaches: the SSH tab merges into the accepted renderer revision. + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(true) + + // The persisted SSH binding is removed with no renderer-visible change, so + // the renderer resends the unchanged version 1 — the tab must still drop. + setSession(makeSession()) + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(60) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(false) + }) + + it('still preserves every tab across a renderer publication when the base snapshot is headless-built', () => { + const { runtime, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + const internals = runtime as unknown as RuntimeInternals + + // Headless-built snapshot (serve hydrate, no renderer publication yet). + sync([]) + vi.advanceTimersByTime(300) + expect( + internals.mobileSessionTabsByWorktree.get(WT)?.publicationEpoch.startsWith('headless') + ).toBe(true) + + // A renderer attaches and publishes an unrelated tab: the headless-built + // tab must be preserved into the merged renderer snapshot (broad rule). + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(60) + const merged = internals.mobileSessionTabsByWorktree.get(WT) + expect( + merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(true) + expect(merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'tab-1')).toBe( + true + ) + }) + + it('preserves a runtime-owned SSH terminal across successive renderer revisions', () => { + const sshPtyId = 'ssh:conn-1@@pty-7' + const { runtime, sync, setSession } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('ssh-tab', sshPtyId)] } + }) + ) + const internals = runtime as unknown as RuntimeInternals & { + hydrateHeadlessMobileSessionTabsFromWorkspaceSession: (worktreeId?: string) => Set + } + // Full headless hydrate (SSH tabs never come from the serve-only path) + // builds the SSH tab into a headless-built snapshot. + internals.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WT) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'ssh-tab' }) + ]) + + // A renderer attaches and publishes an unrelated tab: the SSH tab is merged + // into the renderer epoch (broad headless-built preservation). + sync([makeRendererSnapshot({ version: 1 })]) + vi.advanceTimersByTime(300) + const merged = internals.mobileSessionTabsByWorktree.get(WT) + expect( + merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(true) + + // A newer renderer revision still omits the still-runtime-owned SSH tab. + // Its binding remains persisted, so it must remain published — this is the + // regression: serve-only preservation dropped app-scoped ssh:@@ bindings. + sync([makeRendererSnapshot({ version: 2, title: 'Renamed' })]) + vi.advanceTimersByTime(60) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(true) + + // Once the SSH binding disappears from persistence (and no live PTY backs + // it), the next renderer revision must stop preserving it. + setSession(makeSession()) + sync([makeRendererSnapshot({ version: 3, title: 'Renamed again' })]) + vi.advanceTimersByTime(60) + expect( + internals.mobileSessionTabsByWorktree + .get(WT) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') + ).toBe(false) + }) + + it('retains the split-group layout across no-op syncs with a serve-owned terminal present', () => { + const { runtime, events, sync } = createRuntime( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + const internals = runtime as unknown as RuntimeInternals + const splitLayout = { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, groupId: 'group-1' }, + second: { type: 'leaf' as const, groupId: 'group-2' }, + ratio: 0.4 + } + const makeSplitRendererSnapshot = (): RuntimeMobileSessionTabsSnapshot => ({ + worktree: WT, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabGroups: [ + { id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }, + { id: 'group-2', activeTabId: 'tab-2', tabOrder: ['tab-2'] } + ], + tabGroupLayout: structuredClone(splitLayout), + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal 1', + isActive: true + }, + { + type: 'terminal', + id: 'tab-2::leaf-1', + parentTabId: 'tab-2', + leafId: 'leaf-1', + title: 'Terminal 2', + isActive: false + } + ] + }) + + // Renderer publishes a two-group split while a serve binding exists; the + // merged snapshot must carry the renderer's split layout. + sync([makeSplitRendererSnapshot()]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(1) + expect(events[0]?.tabGroupLayout).toEqual(splitLayout) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabGroupLayout).toEqual(splitLayout) + + // A second identical renderer pair: the serve-only hydrate rebuild runs + // (serve PTY present) and must carry the stored split layout forward, so + // the sync is a full no-op — stored layout unchanged, zero fanout. + events.length = 0 + sync([makeSplitRendererSnapshot()]) + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabGroupLayout).toEqual(splitLayout) + }) + + it('hydrates a serve terminal that appears in the session after suppressed syncs', () => { + const { events, sync, setSession } = createRuntime(makeSession()) + + sync([]) + vi.advanceTimersByTime(300) + expect(events).toHaveLength(0) + + setSession( + makeSession({ + tabsByWorktree: { [WT]: [makeTerminalTab('late-serve-tab', 'serve-pty-9')] } + }) + ) + sync([]) + vi.advanceTimersByTime(60) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'terminal', parentTabId: 'late-serve-tab' }) + ]) + }) +}) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 87568e3e9d7..517c038bd70 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -71,12 +71,16 @@ function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { ['pty-1', { cols: 150, rows: 40 }] ]) const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const writes: string[] = [] const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = [] let resizeSucceeds = true runtime.setPtyController({ - write: () => true, + write: (_ptyId, data) => { + writes.push(data) + return true + }, kill: () => true, getForegroundProcess: async () => null, resize: (ptyId, cols, rows) => { @@ -111,6 +115,7 @@ function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { runtime, ptySizes, resizes, + writes, driverEvents, fitOverrideEvents, setResizeSucceeds: (next: boolean) => { @@ -128,6 +133,22 @@ describe('mobile presence lock — driver state machine', () => { expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) }) + it('stops a preview paste when mobile claims the floor between chunks', async () => { + const { runtime, writes } = createRuntime() + let driverChecks = 0 + vi.spyOn(runtime, 'getDriver').mockImplementation(() => { + driverChecks++ + return driverChecks >= 3 ? { kind: 'mobile', clientId: 'phone-A' } : { kind: 'idle' } + }) + + const result = runtime.writeTerminalPreviewInput('pty-1', 'x'.repeat(32 * 1024)) + await vi.advanceTimersByTimeAsync(0) + + await expect(result).resolves.toBe(false) + expect(writes).toHaveLength(1) + expect(Buffer.byteLength(writes[0]!, 'utf8')).toBe(16 * 1024) + }) + it('handleMobileSubscribe in auto mode transitions idle → mobile{clientId}', async () => { const { runtime, driverEvents } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts new file mode 100644 index 00000000000..3c918f7b4b2 --- /dev/null +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -0,0 +1,581 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { parsePairingCode } from '../../shared/pairing' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' +import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' +import { OrcaRuntimeRpcServer } from './runtime-rpc' + +const REPO_ID = 'repo-1' +const worktreeId = (name: string): string => `${REPO_ID}::/tmp/${name}` +const HOST_WORKTREE_ID = worktreeId('host') +const CLIENT_A_WORKTREE_ID = worktreeId('client-a') +const CLIENT_A2_WORKTREE_ID = worktreeId('client-a2') +const CLIENT_B_WORKTREE_ID = worktreeId('client-b') +const SESSION_WORKTREE_ID = worktreeId('session') + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue( + ['host', 'client-a', 'client-a2', 'client-b', 'session'].map((name) => ({ + path: `/tmp/${name}`, + head: name, + branch: name, + isBare: false, + isMainWorktree: false + })) + ), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +type PairedSession = { + ws: WebSocket + sharedKey: Uint8Array +} + +type ResponseReader = { + next: ( + id: string, + predicate?: (response: Record) => boolean + ) => Promise> + dispose: () => void +} + +function makeStore() { + const worktreeMeta = Object.fromEntries( + [ + HOST_WORKTREE_ID, + CLIENT_A_WORKTREE_ID, + CLIENT_A2_WORKTREE_ID, + CLIENT_B_WORKTREE_ID, + SESSION_WORKTREE_ID + ].map((id) => [ + id, + { + displayName: id.split('/').at(-1) ?? id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + instanceId: id + } + ]) + ) + return { + getRepo: (id: string) => (id === REPO_ID ? makeStore().getRepos()[0] : undefined), + getRepos: () => [ + { + id: REPO_ID, + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => worktreeMeta, + getWorktreeMeta: (id: string) => worktreeMeta[id], + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } +} + +function connect(endpoint: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(endpoint) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) +} + +function nextMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => resolve(typeof data === 'string' ? data : data.toString('utf-8'))) + }) +} + +async function authenticate(pairingUrl: string): Promise { + const pairing = parsePairingCode(pairingUrl) + if (!pairing) { + throw new Error('invalid_pairing_url') + } + const ws = await connect(pairing.endpoint) + const keys = generateKeyPair() + const serverPublicKey = Uint8Array.from(Buffer.from(pairing.publicKeyB64, 'base64')) + const sharedKey = deriveSharedKey(keys.secretKey, serverPublicKey) + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(keys.publicKey).toString('base64') + }) + ) + expect(JSON.parse(await nextMessage(ws))).toEqual({ type: 'e2ee_ready' }) + ws.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) + expect(JSON.parse(decrypt(await nextMessage(ws), sharedKey)!)).toEqual({ + type: 'e2ee_authenticated' + }) + return { ws, sharedKey } +} + +function send(session: PairedSession, request: Record): void { + session.ws.send(encrypt(JSON.stringify(request), session.sharedKey)) +} + +function createReader(session: PairedSession): ResponseReader { + type Waiter = { + id: string + predicate: (response: Record) => boolean + resolve: (response: Record) => void + } + const queued: Record[] = [] + const waiters: Waiter[] = [] + const onMessage = (data: WebSocket.RawData): void => { + const plaintext = decrypt( + typeof data === 'string' ? data : data.toString('utf-8'), + session.sharedKey + ) + if (!plaintext) { + return + } + const response = JSON.parse(plaintext) as Record + const waiterIndex = waiters.findIndex( + (waiter) => response.id === waiter.id && waiter.predicate(response) + ) + if (waiterIndex === -1) { + queued.push(response) + return + } + waiters.splice(waiterIndex, 1)[0]?.resolve(response) + } + session.ws.on('message', onMessage) + return { + next: (id, predicate = () => true) => { + const queuedIndex = queued.findIndex((response) => response.id === id && predicate(response)) + if (queuedIndex !== -1) { + return Promise.resolve(queued.splice(queuedIndex, 1)[0]!) + } + return new Promise((resolve) => waiters.push({ id, predicate, resolve })) + }, + dispose: () => { + session.ws.off('message', onMessage) + queued.length = 0 + waiters.length = 0 + } + } +} + +function resultType(response: Record): string | undefined { + return (response.result as { type?: string } | undefined)?.type +} + +function activeTabId(response: Record): string | null { + return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null +} + +function seedSessionTabs(runtime: OrcaRuntimeService): void { + const tabs = ['host-tab', 'client-a-tab', 'client-a2-tab', 'client-b-tab'].map((id, index) => ({ + type: 'terminal' as const, + id, + parentTabId: id, + leafId: `pane:${index + 1}`, + ptyId: `pty-${index + 1}`, + title: id, + isActive: id === 'host-tab' + })) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: SESSION_WORKTREE_ID, + publicationEpoch: 'renderer:host', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'host-tab', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'host-tab', + tabOrder: tabs.map((tab) => tab.parentTabId) + } + ], + tabs + } + ] + }) + for (let index = 0; index < tabs.length; index += 1) { + runtime.registerPty(`pty-${index + 1}`, SESSION_WORKTREE_ID) + } +} + +describe('paired runtime navigation isolation', () => { + const servers: OrcaRuntimeRpcServer[] = [] + const sessions: PairedSession[] = [] + const readers: ResponseReader[] = [] + + afterEach(async () => { + for (const reader of readers.splice(0)) { + reader.dispose() + } + for (const session of sessions.splice(0)) { + session.ws.close() + } + await Promise.all(servers.splice(0).map((server) => server.stop())) + }) + + async function startHarness() { + const hostSelections = { worktreeId: HOST_WORKTREE_ID, tabId: 'host-tab' } + const activateWorktree = vi.fn((_repoId: string, nextWorktreeId: string) => { + hostSelections.worktreeId = nextWorktreeId + }) + const focusTerminal = vi.fn((nextTabId: string) => { + hostSelections.tabId = nextTabId + }) + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal, + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + seedSessionTabs(runtime) + + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: mkdtempSync(join(tmpdir(), 'orca-navigation-isolation-')), + enableWebSocket: true, + wsPort: 0 + }) + servers.push(server) + await server.start() + + const offerA = server.createPairingOffer({ + address: '127.0.0.1', + name: 'client-a', + scope: 'runtime' + }) + if (!offerA.available) { + throw new Error('pairing_unavailable') + } + const clientA = await authenticate(offerA.pairingUrl) + sessions.push(clientA) + const offerB = server.createPairingOffer({ + address: '127.0.0.1', + name: 'client-b', + scope: 'runtime' + }) + if (!offerB.available) { + throw new Error('pairing_unavailable') + } + const clientB = await authenticate(offerB.pairingUrl) + sessions.push(clientB) + expect(offerA.deviceId).not.toBe(offerB.deviceId) + expect(parsePairingCode(offerA.pairingUrl)?.deviceToken).not.toBe( + parsePairingCode(offerB.pairingUrl)?.deviceToken + ) + + const readerA = createReader(clientA) + const readerB = createReader(clientB) + readers.push(readerA, readerB) + return { + runtime, + hostSelections, + activateWorktree, + focusTerminal, + clientA, + clientB, + pairingUrlA: offerA.pairingUrl, + readerA, + readerB + } + } + + it('keeps worktree navigation local to each paired runtime client by default', async () => { + const harness = await startHarness() + const clientSelections = { + a: CLIENT_A_WORKTREE_ID, + b: CLIENT_B_WORKTREE_ID + } + for (const [session, id] of [ + [harness.clientA, 'events-a'], + [harness.clientB, 'events-b'] + ] as const) { + send(session, { id, method: 'runtime.clientEvents.subscribe' }) + } + await Promise.all([ + harness.readerA.next('events-a', (response) => resultType(response) === 'ready'), + harness.readerB.next('events-b', (response) => resultType(response) === 'ready') + ]) + + clientSelections.a = CLIENT_A2_WORKTREE_ID + send(harness.clientA, { + id: 'activate-a2', + method: 'worktree.activate', + params: { + worktree: `id:${CLIENT_A2_WORKTREE_ID}`, + notifyClients: true + } + }) + await expect(harness.readerA.next('activate-a2')).resolves.toMatchObject({ ok: true }) + + harness.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID) + const [nextA, nextB] = await Promise.all([ + harness.readerA.next('events-a'), + harness.readerB.next('events-b') + ]) + expect([resultType(nextA), resultType(nextB)]).toEqual(['worktreesChanged', 'worktreesChanged']) + expect(clientSelections).toEqual({ + a: CLIENT_A2_WORKTREE_ID, + b: CLIENT_B_WORKTREE_ID + }) + expect(harness.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID) + expect(harness.activateWorktree).not.toHaveBeenCalled() + }) + + it('projects session-tab activation only to the paired caller across fanout and reconnect', async () => { + const harness = await startHarness() + + send(harness.clientA, { + id: 'select-a', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-a-tab', + navigation: 'caller', + notifyClients: false + } + }) + send(harness.clientB, { + id: 'select-b', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-b-tab', + navigation: 'caller', + notifyClients: false + } + }) + expect(activeTabId(await harness.readerA.next('select-a'))).toBe('client-a-tab') + expect(activeTabId(await harness.readerB.next('select-b'))).toBe('client-b-tab') + + for (const [session, id] of [ + [harness.clientA, 'tabs-a'], + [harness.clientB, 'tabs-b'] + ] as const) { + send(session, { + id, + method: 'session.tabs.subscribe', + params: { worktree: `id:${SESSION_WORKTREE_ID}` } + }) + } + expect(activeTabId(await harness.readerA.next('tabs-a'))).toBe('client-a-tab') + expect(activeTabId(await harness.readerB.next('tabs-b'))).toBe('client-b-tab') + + send(harness.clientA, { + id: 'select-a2', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-a2-tab', + navigation: 'caller', + notifyClients: false + } + }) + expect(activeTabId(await harness.readerA.next('select-a2'))).toBe('client-a2-tab') + + harness.runtime.notifyMobileSessionTabsChanged(SESSION_WORKTREE_ID) + const [updateA, updateB] = await Promise.all([ + harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'), + harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated') + ]) + expect(activeTabId(updateA)).toBe('client-a2-tab') + expect(activeTabId(updateB)).toBe('client-b-tab') + expect(harness.hostSelections.tabId).toBe('host-tab') + expect(harness.focusTerminal).not.toHaveBeenCalled() + expect( + (await harness.runtime.listMobileSessionTabs(`id:${SESSION_WORKTREE_ID}`)).activeTabId + ).toBe('host-tab') + + harness.readerA.dispose() + harness.clientA.ws.close() + const reconnectedA = await authenticate(harness.pairingUrlA) + sessions.push(reconnectedA) + const reconnectedReaderA = createReader(reconnectedA) + readers.push(reconnectedReaderA) + send(reconnectedA, { + id: 'tabs-a-reconnected', + method: 'session.tabs.list', + params: { worktree: `id:${SESSION_WORKTREE_ID}` } + }) + expect(activeTabId(await reconnectedReaderA.next('tabs-a-reconnected'))).toBe('client-a2-tab') + }) + + it('routes explicit host and paired-client follow intent without changing the default', async () => { + const harness = await startHarness() + for (const [session, id] of [ + [harness.clientA, 'events-a'], + [harness.clientB, 'events-b'] + ] as const) { + send(session, { id, method: 'runtime.clientEvents.subscribe' }) + } + await Promise.all([ + harness.readerA.next('events-a', (response) => resultType(response) === 'ready'), + harness.readerB.next('events-b', (response) => resultType(response) === 'ready') + ]) + + send(harness.clientA, { + id: 'host-follow', + method: 'worktree.activate', + params: { worktree: `id:${CLIENT_A_WORKTREE_ID}`, navigation: 'host' } + }) + await harness.readerA.next('host-follow') + harness.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID) + expect(resultType(await harness.readerA.next('events-a'))).toBe('worktreesChanged') + expect(resultType(await harness.readerB.next('events-b'))).toBe('worktreesChanged') + expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID) + + send(harness.clientB, { + id: 'clients-follow', + method: 'worktree.activate', + params: { worktree: `id:${CLIENT_B_WORKTREE_ID}`, navigation: 'clients' } + }) + await harness.readerB.next('clients-follow') + const [eventA, eventB] = await Promise.all([ + harness.readerA.next('events-a'), + harness.readerB.next('events-b') + ]) + expect([resultType(eventA), resultType(eventB)]).toEqual([ + 'activateWorktree', + 'activateWorktree' + ]) + expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID) + + for (const [session, id] of [ + [harness.clientA, 'tabs-a'], + [harness.clientB, 'tabs-b'] + ] as const) { + send(session, { + id, + method: 'session.tabs.subscribe', + params: { worktree: `id:${SESSION_WORKTREE_ID}` } + }) + } + await Promise.all([harness.readerA.next('tabs-a'), harness.readerB.next('tabs-b')]) + send(harness.clientA, { + id: 'tabs-host-follow', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-a-tab', + navigation: 'host' + } + }) + expect(activeTabId(await harness.readerA.next('tabs-host-follow'))).toBe('client-a-tab') + expect( + activeTabId( + await harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated') + ) + ).toBe('client-a-tab') + expect(harness.hostSelections.tabId).toBe('client-a-tab') + + send(harness.clientB, { + id: 'tabs-clients-follow', + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId: 'client-b-tab', + navigation: 'clients' + } + }) + await harness.readerB.next('tabs-clients-follow') + const [tabsA, tabsB] = await Promise.all([ + harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'), + harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated') + ]) + expect([activeTabId(tabsA), activeTabId(tabsB)]).toEqual(['client-b-tab', 'client-b-tab']) + expect( + [tabsA, tabsB].map( + (response) => + (response.result as RuntimeMobileSessionTabsResult | undefined)?.navigationIntent + ) + ).toEqual(['follow', 'follow']) + expect(harness.hostSelections.tabId).toBe('client-a-tab') + }) + + it('isolates crossed clients across two runtime servers', async () => { + const serverOne = await startHarness() + const serverTwo = await startHarness() + for (const [session, id] of [ + [serverTwo.clientA, 'server-two-events-a'], + [serverTwo.clientB, 'server-two-events-b'] + ] as const) { + send(session, { id, method: 'runtime.clientEvents.subscribe' }) + } + await Promise.all([ + serverTwo.readerA.next('server-two-events-a', (response) => resultType(response) === 'ready'), + serverTwo.readerB.next('server-two-events-b', (response) => resultType(response) === 'ready') + ]) + + send(serverOne.clientA, { + id: 'server-one-worktree', + method: 'worktree.activate', + params: { worktree: `id:${CLIENT_A2_WORKTREE_ID}`, notifyClients: true } + }) + await serverOne.readerA.next('server-one-worktree') + serverTwo.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID) + expect(resultType(await serverTwo.readerA.next('server-two-events-a'))).toBe('worktreesChanged') + expect(resultType(await serverTwo.readerB.next('server-two-events-b'))).toBe('worktreesChanged') + expect(serverOne.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID) + expect(serverTwo.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID) + + for (const [session, reader, requestId, tabId] of [ + [serverOne.clientA, serverOne.readerA, 's1-a', 'client-a2-tab'], + [serverOne.clientB, serverOne.readerB, 's1-b', 'client-b-tab'], + [serverTwo.clientA, serverTwo.readerA, 's2-a', 'client-a-tab'], + [serverTwo.clientB, serverTwo.readerB, 's2-b', 'host-tab'] + ] as const) { + send(session, { + id: requestId, + method: 'session.tabs.activate', + params: { + worktree: `id:${SESSION_WORKTREE_ID}`, + tabId, + navigation: 'caller', + notifyClients: false + } + }) + expect(activeTabId(await reader.next(requestId))).toBe(tabId) + } + + expect(serverOne.hostSelections.tabId).toBe('host-tab') + expect(serverTwo.hostSelections.tabId).toBe('host-tab') + }) +}) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 9070f90bef8..a2d72969226 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -299,7 +299,7 @@ export class RuntimeBrowserCommands { await waitForTabRegistration(browserPageId) } - // Why: CDP navigation bypasses Electron's webview events, so the renderer's did-navigate listeners never fire; push updates to keep the UI in sync. + // Why: helper-driven clicks can bypass Electron navigation events; push authoritative URL/title updates after automation. private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { try { const win = this.host.getAuthoritativeWindow() @@ -1345,7 +1345,7 @@ export class RuntimeBrowserCommands { bridge.setActiveTab(wcId, worktreeId) } - // Why: the webview loads about:blank first, so navigate via the bridge to make agent-browser's CDP session track the real URL. + // Why: the webview loads about:blank first; route navigation through the bridge so its registered owner remains authoritative. if (url && url !== 'about:blank') { try { const result = await bridge.goto(url, worktreeId, browserPageId) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index de64a3fff8c..d6cdb4d08d0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -98,6 +98,10 @@ import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' import { TERMINAL_METHODS } from './rpc/methods/terminal' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' +import { + _resetTerminalViewAttributesForTest, + setTerminalViewAttributes +} from './terminal-view-attribute-store' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') @@ -587,6 +591,7 @@ vi.mock('../git/git-username', async () => { function resetRuntimeTestMocks(): void { resetPlatform() + _resetTerminalViewAttributesForTest() advertisedUrlWatcher.clear() electronMocks.BrowserWindow.fromId.mockReset() electronMocks.BrowserWindow.fromId.mockReturnValue(null) @@ -1712,6 +1717,86 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().terminalWindowsShell).toBe('wsl.exe') }) + it('reports floating workspace availability from settings on status', () => { + expect(createRuntime().getStatus().floatingWorkspaceEnabled).toBe(true) + + const disabledRuntime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + floatingTerminalEnabled: false + }) + } as never) + expect(disabledRuntime.getStatus().floatingWorkspaceEnabled).toBe(false) + }) + + it('polls floating tabs with targeted PTY liveness and no repo/provider inventory', async () => { + const getRepos = vi.fn(store.getRepos) + const listProcesses = vi.fn().mockResolvedValue([]) + const floatingPtyId = `${FLOATING_TERMINAL_WORKTREE_ID}@@pty-1` + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeRepoId: null, + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'floating-tab' }, + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'floating-tab', + ptyId: floatingPtyId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + title: 'Floating Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'floating-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: floatingPtyId }) + } + }) + ) + const runtime = new OrcaRuntimeService({ ...runtimeStore, getRepos } as never) + const ptyController = { + livePtyIds: new Set([floatingPtyId]), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasPty(this: { livePtyIds: Set }, ptyId: string) { + return this.livePtyIds.has(ptyId) + }, + listProcesses + } + const hasPty = vi.spyOn(ptyController, 'hasPty') + runtime.setPtyController(ptyController) + + const tabs = await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + const terminals = await runtime.listTerminals(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + await runtime.listTerminals(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + + expect(tabs.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + parentTabId: 'floating-tab', + status: 'ready', + terminal: expect.any(String) + }) + ]) + expect(terminals.terminals).toEqual([ + expect.objectContaining({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + connected: true + }) + ]) + expect(hasPty).toHaveBeenCalledTimes(4) + expect(hasPty).toHaveBeenCalledWith(floatingPtyId) + expect(listProcesses).not.toHaveBeenCalled() + expect(getRepos).not.toHaveBeenCalled() + }) + it('advertises browser screencast only when a renderer window is available', () => { const runtime = createRuntime() electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) @@ -5377,6 +5462,7 @@ describe('OrcaRuntimeService', () => { }) it('allows host integration slug helpers for SSH repos through provider-aware GitHub clients', async () => { + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme.test' } getIssueMock.mockResolvedValueOnce({ number: 12, title: 'Remote issue' }) listGitHubIssuesMock.mockResolvedValueOnce({ items: [{ number: 7, title: 'Remote issue list item' }] @@ -5404,16 +5490,30 @@ describe('OrcaRuntimeService', () => { await expect(runtime.listRepoIssues('id:repo-1', 10)).resolves.toEqual([ { number: 7, title: 'Remote issue list item' } ]) - await expect(runtime.requestRepoPRReviewers('id:repo-1', 7, ['alex'])).resolves.toEqual({ - ok: true - }) - await expect(runtime.removeRepoPRReviewers('id:repo-1', 7, ['alex'])).resolves.toEqual({ + await expect(runtime.requestRepoPRReviewers('id:repo-1', 7, ['alex'], prRepo)).resolves.toEqual( + { + ok: true + } + ) + await expect(runtime.removeRepoPRReviewers('id:repo-1', 7, ['alex'], prRepo)).resolves.toEqual({ ok: true }) expect(getIssueMock).toHaveBeenCalledWith('/remote/repo', 12, 'ssh-1') expect(listGitHubIssuesMock).toHaveBeenCalledWith('/remote/repo', 10, undefined, 'ssh-1') - expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith('/remote/repo', 7, ['alex'], 'ssh-1') - expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith('/remote/repo', 7, ['alex'], 'ssh-1') + expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + ['alex'], + 'ssh-1', + prRepo + ) + expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + ['alex'], + 'ssh-1', + prRepo + ) }) it('routes runtime GitHub repo identity helpers through the selected WSL project runtime', async () => { @@ -5587,14 +5687,18 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) const localGitOptions = { wslDistro: 'Ubuntu' } - const prRepo = { owner: 'acme', repo: 'orca' } + const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme.test' } await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43) await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr') await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr') await runtime.getRepoPRChecks('id:repo-1', 42, 'head-sha', prRepo, { noCache: true }) - await runtime.rerunRepoPRChecks('id:repo-1', 42, { headSha: 'head-sha', failedOnly: true }) + await runtime.rerunRepoPRChecks('id:repo-1', 42, { + headSha: 'head-sha', + failedOnly: true, + prRepo + }) await runtime.getRepoPRCheckDetails('id:repo-1', { checkRunId: 9, workflowRunId: 8, @@ -5605,13 +5709,15 @@ describe('OrcaRuntimeService', () => { await runtime.getRepoPRComments('id:repo-1', 42, prRepo, { noCache: true }) await runtime.getRepoPRFileContents('id:repo-1', { prNumber: 42, + prRepo, path: 'src/app.ts', status: 'modified', headSha: 'head-sha', baseSha: 'base-sha' }) - await runtime.resolveRepoReviewThread('id:repo-1', 'thread-1', true) + await runtime.resolveRepoReviewThread('id:repo-1', 'thread-1', true, prRepo) await runtime.setRepoPRFileViewed('id:repo-1', { + prRepo, pullRequestId: 'PR_kw', path: 'src/app.ts', viewed: true @@ -5620,11 +5726,12 @@ describe('OrcaRuntimeService', () => { await runtime.updateRepoPRDetails('id:repo-1', 42, { body: 'New body' }, prRepo) await runtime.mergeRepoPR('id:repo-1', 42, 'squash', prRepo) await runtime.setRepoPRAutoMerge('id:repo-1', 42, true, 'squash', prRepo) - await runtime.updateRepoPRState('id:repo-1', 42, { state: 'closed' }) - await runtime.requestRepoPRReviewers('id:repo-1', 42, ['octo']) - await runtime.removeRepoPRReviewers('id:repo-1', 42, ['octo']) + await runtime.updateRepoPRState('id:repo-1', 42, { state: 'closed' }, prRepo) + await runtime.requestRepoPRReviewers('id:repo-1', 42, ['octo'], prRepo) + await runtime.removeRepoPRReviewers('id:repo-1', 42, ['octo'], prRepo) await runtime.addRepoPRReviewComment('id:repo-1', { prNumber: 42, + prRepo, body: 'Inline', commitId: 'head-sha', path: 'src/app.ts', @@ -5684,7 +5791,7 @@ describe('OrcaRuntimeService', () => { expect(rerunGitHubPRChecksMock).toHaveBeenCalledWith( TEST_REPO_PATH, 42, - { headSha: 'head-sha', failedOnly: true }, + { headSha: 'head-sha', failedOnly: true, prRepo }, null, localGitOptions ) @@ -5708,17 +5815,18 @@ describe('OrcaRuntimeService', () => { localGitOptions ) expect(getGitHubPRFileContentsMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions }) + expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, prRepo }) ) expect(resolveGitHubReviewThreadMock).toHaveBeenCalledWith( TEST_REPO_PATH, 'thread-1', true, null, + prRepo, localGitOptions ) expect(setGitHubPRFileViewedMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions }) + expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, prRepo }) ) expect(updateGitHubPRTitleMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -5758,6 +5866,7 @@ describe('OrcaRuntimeService', () => { 42, { state: 'closed' }, null, + prRepo, localGitOptions ) expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith( @@ -5765,6 +5874,7 @@ describe('OrcaRuntimeService', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith( @@ -5772,12 +5882,14 @@ describe('OrcaRuntimeService', () => { 42, ['octo'], null, + prRepo, localGitOptions ) expect(addGitHubPRReviewCommentMock).toHaveBeenCalledWith( expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions, + prRepo, body: 'Inline' }) ) @@ -7656,7 +7768,7 @@ describe('OrcaRuntimeService', () => { kind: 'pr-link', link: { url: 'https://github.com/acme/orca/pull/42', - slug: { owner: 'acme', repo: 'orca' }, + slug: { owner: 'acme', repo: 'orca', host: 'github.com' }, number: 42 } }, @@ -7664,7 +7776,7 @@ describe('OrcaRuntimeService', () => { kind: 'pr-link', link: { url: 'https://github.com/acme/orca/pull/43', - slug: { owner: 'acme', repo: 'orca' }, + slug: { owner: 'acme', repo: 'orca', host: 'github.com' }, number: 43 } } @@ -8468,6 +8580,181 @@ describe('OrcaRuntimeService', () => { ) }) + it('resolves paths without candidate activation via the lazy safety net', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/lazy-activation-artifact.json' + + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + + // No mobile connect ever happened; the query itself must activate+backfill. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('backfills candidates on activation so scrolled-off paths still resolve', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/backfilled-artifact.json' + + // Path arrives while tracking is inactive (desktop-only phase). + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + // First mobile connect: backfill from the retained raw window. + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw 64KB window past the path with pathless output. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Only the backfilled candidate tier can answer now. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('backfills per retained chunk so chunk boundaries match the eager extractor', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/a.json' + + // Two chunks whose join would parse as one different candidate + // (/tmp/a.jsonsuffix.txt). The eager per-chunk extractor kept /tmp/a.json. + runtime.onPtyData('pty-1', `wrote ${artifactPath}`, 100) + runtime.onPtyData('pty-1', 'suffix.txt', 150) + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw 64KB window so only the backfilled candidates can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('extracts candidates per chunk after activation for scrolled-off paths', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/post-activation-artifact.json' + + runtime.activateRecentPtyPathCandidateTracking() + // Idempotent: a second activation must not disturb live tracking. + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('does not retain pre-activation paths that scrolled past the raw window', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/pre-activation-scrolled-artifact.json' + + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Documented accepted loss: output that scrolled past the raw window + // before the first-ever mobile connect yields no candidates. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + false + ) + }) + + it('backfill does not mint candidates from an over-limit line shortened by the window trim', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/result.json' + + // One chunk with a >4KiB line whose tail is the path: the eager + // extractor skipped it under the line-length guard. + runtime.onPtyData('pty-1', `${'a'.repeat(5000)} ${artifactPath}\n`, 100) + // Newline-free filler trims the window to ~1KiB before the path, so a + // trimmed-head replay would see an under-limit line ending in the path. + runtime.onPtyData('pty-1', 'y'.repeat(64 * 1024 - 1000), 150) + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw window so only backfilled candidates can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Parity with eager extraction: the over-limit line never yielded a + // candidate, so the grant must stay denied after the raw window scrolls. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + false + ) + }) + + it('backfill replays the full head chunk including its window-trimmed prefix', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/trimmed-prefix-artifact.json' + + // Path sits in the head chunk's prefix, which the window trim drops from + // read() but the eager extractor saw at append time. + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n${'b'.repeat(3000)}\n`, 100) + runtime.onPtyData('pty-1', 'y'.repeat(64 * 1024 - 1000), 150) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Parity with eager extraction: the append-time candidate outlived the + // raw window, so backfill must recover it from the intact head chunk. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('matches eager extraction exactly for a pre-sliced oversized chunk', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const cutLinePath = '/tmp/cut-line.json' + const keptPath = '/tmp/kept-after-cut.json' + + // Single >64KiB append is stored pre-sliced, so its original text is + // unrecoverable at activation time. Extraction runs eagerly at append + // instead: cutLinePath sat on an over-4KiB line the extractor's line + // guard rejects (and the slice leaves an under-4KiB tail of it that must + // NOT mint a candidate later), while keptPath sat on a short line and + // must survive the raw window scrolling. + const keptLine = `wrote ${keptPath}\n` + const afterFirstLine = `${keptLine}${'z'.repeat(62 * 1024 - keptLine.length)}` + const oversized = `${'a'.repeat(5 * 1024)} ${cutLinePath}\n${afterFirstLine}` + runtime.onPtyData('pty-1', oversized, 100) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, cutLinePath, cutLinePath)).toBe( + false + ) + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, keptPath, keptPath)).toBe(true) + }) + + it('keeps a candidate from the short first line of an oversized chunk after the window scrolls', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/result.json' + + // A short first line of a >64KiB chunk loses only its `wrote ` prefix to + // the pre-slice; the path itself stays in the retained window. The old + // eager extractor recorded it from the intact original chunk, so it must + // stay authorized after the raw window scrolls — parity requires the + // append-time extraction for oversized chunks, not backfill replay. + const firstLine = `wrote ${artifactPath}\n` + runtime.onPtyData('pty-1', `${firstLine}${'f'.repeat(64 * 1024 + 6 - firstLine.length)}`, 100) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + it('replaces suffix-only headless state with the recovered renderer snapshot', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') @@ -10219,6 +10506,37 @@ describe('OrcaRuntimeService', () => { }) }) + it('passes cached view colors to background agent spawns for source-owned startup replies', async () => { + setTerminalViewAttributes({ + foreground: [0xff, 0xff, 0xff], + background: [0x28, 0x2c, 0x34], + cursor: [0xff, 0xff, 0xff], + ansi: Array.from({ length: 256 }, () => [0, 0, 0] as [number, number, number]), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: false + }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'codex' }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + terminalColorQueryReplies: { + foreground: '#ffffff', + background: '#282c34' + } + }) + ) + }) + it('applies Settings agent defaults to bare agent command terminal creates', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtimeStore = { @@ -14615,6 +14933,57 @@ describe('OrcaRuntimeService', () => { unsubscribe() }) + // Why: restored OMP panes can retain the hook while the wrapped Pi owns foreground (#6364). + it('keeps an OMP hook labeled OMP when the wrapped pi child owns the foreground', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'omp-flicker-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + // Why: the remote relay reads the deeper `pi` child of the omp process tree. + getForegroundProcess: async () => 'pi' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'omp-tab', + leafId: HEADLESS_LEAF_ID + }) + // Restored/mirrored pane: no launchAgent, only the pi foreground read remains. + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('omp-flicker-pty')! + pty.launchAgent = null + pty.foregroundAgent = 'pi' + events.length = 0 + + runtime.onPtyData( + 'omp-flicker-pty', + '\x1b]0;⠋ Pi\x07' + + '\x1b]9999;{"state":"working","prompt":"fix the bug","agentType":"omp"}\x07', + 100 + ) + + await waitForMobileSessionTabsEvents(events, 1) + expect(events[0]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: '⠋ OMP', + agentStatus: expect.objectContaining({ + state: 'working', + agentType: 'omp', + terminalTitle: '⠋ OMP' + }) + }) + ) + + unsubscribe() + }) + it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' }) const runtime = new OrcaRuntimeService(store) @@ -18805,6 +19174,62 @@ describe('OrcaRuntimeService', () => { ]) }) + it('selects a created terminal only for the paired caller', async () => { + let spawnIndex = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `pty-headless-${++spawnIndex}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const hostTerminal = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + + const callerTerminal = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + clientNavigationId: 'device-a', + navigation: 'caller' + }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).activeTabId).toBe( + hostTerminal.tab.id + ) + expect( + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`, 'device-a')).activeTabId + ).toBe(callerTerminal.tab.id) + expect( + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`, 'device-b')).activeTabId + ).toBe(hostTerminal.tab.id) + }) + + it('scopes terminal-create idempotency to the paired caller', async () => { + let spawnIndex = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `pty-headless-${++spawnIndex}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const [createdA, createdB] = await Promise.all([ + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + clientNavigationId: 'device-a', + navigation: 'caller', + clientMutationId: 'same-mutation' + }), + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + clientNavigationId: 'device-b', + navigation: 'caller', + clientMutationId: 'same-mutation' + }) + ]) + + expect(createdA.tab.id).not.toBe(createdB.tab.id) + expect(spawnIndex).toBe(2) + }) + it('creates mobile session terminals for folder workspaces in a headless runtime server', async () => { const folderPath = await mkdtemp(join(tmpdir(), 'orca-mobile-folder-workspace-')) const spawn = vi.fn().mockResolvedValue({ id: 'pty-mobile-folder' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 10e728c0d61..1784cede32d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,6 +14,7 @@ import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extra import { parseFileUriPathParts } from '../daemon/osc7-file-uri' import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { createTerminalTitleTracker, stripBrailleSpinnerGlyphs, @@ -39,6 +40,7 @@ import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../shared/pane-agent-owner' import { createAgentStatusOscProcessor, type ProcessedAgentStatusChunk @@ -150,9 +152,17 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' +import { + navigationTargetsClients, + navigationTargetsHost, + type RuntimeNavigationTarget +} from '../../shared/runtime-navigation' import type { SshConnectionState } from '../../shared/ssh-types' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { @@ -286,6 +296,10 @@ import { listAiVaultSessions } from '../ai-vault/cached-session-list' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' import type { WorkspacePortKillRequest, WorkspacePortKillResult, @@ -356,7 +370,7 @@ import type { import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout' -import { RecentPtyOutputBuffer } from './recent-pty-output-buffer' +import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { buildHeadlessTabGroupMove, buildHeadlessTabGroupSplit @@ -365,6 +379,12 @@ import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emula import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' +import { + activateClientSessionTabSelection, + ClientSessionTabSelectionStore, + deriveClientSessionTabSelection, + projectClientSessionTabSelection +} from './client-session-tab-selection' import type { PtyProviderBufferSnapshot } from '../providers/types' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import type { @@ -614,6 +634,7 @@ import { import type { ClearProjectItemFieldArgs, GetProjectViewTableArgs, + ListAccessibleProjectsArgs, ListAssignableUsersBySlugArgs, ListIssueTypesBySlugArgs, ListLabelsBySlugArgs, @@ -753,6 +774,7 @@ import { } from './terminal-model-query-authority' import { getTerminalViewAttributes, + getTerminalViewColorQueryReplyColors, registerTerminalViewAttributesApplier } from './terminal-view-attribute-store' import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown' @@ -894,6 +916,7 @@ type RuntimeStore = { agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] + floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] @@ -1069,8 +1092,10 @@ type TerminalCreateOptions = { env?: Record envToDelete?: string[] launchConfig?: WorktreeStartupLaunch['launchConfig'] + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent + terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] @@ -1262,6 +1287,7 @@ type RuntimePtyController = { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] + resumeProviderSession?: AgentProviderSessionMetadata telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string @@ -1270,6 +1296,7 @@ type RuntimePtyController = { leafId?: string sessionId?: string persistHostSessionBinding?: boolean + terminalColorQueryReplies?: { foreground?: string; background?: string } }): Promise<{ id: string; wslDistro?: string }> write(ptyId: string, data: string): boolean kill(ptyId: string): boolean @@ -1283,6 +1310,8 @@ type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean + // Why: exact-id mobile polls should not enumerate every local and SSH PTY. + hasPty?(ptyId: string): boolean | null listProcesses?(): Promise serializeBuffer?( ptyId: string, @@ -2241,6 +2270,17 @@ export class OrcaRuntimeService { private authoritativeWindowId: number | null = null private tabs = new Map() private mobileSessionTabsByWorktree = new Map() + // Why: renderer publication ordering must be judged against the renderer's + // own last-accepted (epoch, version) — never against the stored snapshot's + // version, which main-local touches bump independently and can push + // permanently ahead of the renderer's counter. The renderer reuses one pair + // for byte-identical content, so a same-epoch version <= this one is a no-op + // resend (or stale) and is skipped without touching the stored entry. + private acceptedRendererMobileSnapshotByWorktree = new Map< + string, + { publicationEpoch: string; rendererVersion: number } + >() + private clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the // same clientMutationId returns the in-flight operation instead of duplicating. private mobileTerminalCreateByMutationId = new Map< @@ -2264,7 +2304,10 @@ export class OrcaRuntimeService { viewMode?: 'terminal' | 'chat' } >() - private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() + private mobileSessionTabListeners = new Set<{ + listener: (snapshot: RuntimeMobileSessionTabsResult) => void + clientNavigationId?: string + }>() // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a // second. Emit reads the latest snapshot, so only the freshest version ships. @@ -2374,6 +2417,10 @@ export class OrcaRuntimeService { private ptyLifecycleGenerationById = new Map() private nextPtyLifecycleGeneration = 1 private recentPtyPathCandidatesById = new Map() + // Why: candidates only feed mobile file-tap provenance; desktop-only + // sessions skip the 3-regex extraction on every PTY chunk until a + // mobile/remote client authenticates (sticky, backfilled on activation). + private recentPtyPathCandidateTrackingActive = false // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a // mounted xterm view. @@ -2483,6 +2530,9 @@ export class OrcaRuntimeService { // streams can attach concurrently; mobileSubscribers is consulted too so // grace-window mobile records keep suppressing. private remoteTerminalViewSubscriberCounts = new Map() + // Preview windows consume the raw stream but deliberately leave terminal + // query replies to main's headless emulator. + private rawTerminalViewSubscriberCounts = new Map() // Why: per-PTY driver state. The "driver" is whoever currently owns the // input/resize floor. While `kind === 'mobile'` the desktop renderer drops @@ -2660,6 +2710,9 @@ export class OrcaRuntimeService { private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus + private readonly prepareAiVaultSessionResumeFn: + | ((args: AiVaultPrepareSessionResumeArgs) => Promise) + | null private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -2693,6 +2746,9 @@ export class OrcaRuntimeService { // runs under `orca serve`, so remote/SSH hosts would silently drop // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. getAdditionalAiVaultCodexHomePaths?: () => readonly string[] + prepareAiVaultSessionResume?: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise buildAgentHookPtyEnv?: () => Record getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus } @@ -2723,6 +2779,7 @@ export class OrcaRuntimeService { this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') + this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here @@ -3193,6 +3250,7 @@ export class OrcaRuntimeService { capabilities, hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, + floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } @@ -3205,6 +3263,14 @@ export class OrcaRuntimeService { return listAiVaultSessions(args) } + prepareAiVaultSessionResume( + args: AiVaultPrepareSessionResumeArgs + ): Promise { + return ( + this.prepareAiVaultSessionResumeFn?.(args) ?? Promise.resolve({ useRealCodexHome: false }) + ) + } + setPtyController(controller: RuntimePtyController | null): void { // Why: CLI terminal writes must go through the main-owned PTY registry // instead of tunneling back through renderer IPC, or live handles could @@ -3270,8 +3336,28 @@ export class OrcaRuntimeService { setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'] + ): void { + this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + this.notifyClientsActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } + + private notifyHostActivateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] ): void { this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } + + private notifyClientsActivateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] + ): void { this.emitClientEvent( toRuntimeActivateWorktreeEvent(repoId, worktreeId, setup, startup, defaultTabs) ) @@ -3383,8 +3469,10 @@ export class OrcaRuntimeService { throw new Error('Runtime graph publisher does not match the authoritative window') } + const previousTabs = this.tabs + const previousLeaves = this.leaves this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) - this.syncMobileSessionTabs(graph.mobileSessionTabs) + const changedMobileWorktrees = this.syncMobileSessionTabs(graph.mobileSessionTabs) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() @@ -3505,7 +3593,40 @@ export class OrcaRuntimeService { this.leaves = nextLeaves this.rebuildLeafPtyIndex() - this.notifyMobileSessionTabSnapshots() + // Why: the emitted client payload is a function of the stored snapshot AND + // the tab/leaf graph (handles/titles/connected resolve from leaf state), so + // a graph-only change — e.g. a restored leaf binding its ptyId while the + // snapshot pair is unchanged — must also fan out, or a paired client stays + // on pending-handle forever. Schedule the union on the same 50ms trailing + // edge as the OSC-title path; the coalescer emit reads the latest state at + // fire time so no final version is ever lost. + for (const worktreeId of this.collectMobileVisibleGraphChangedWorktrees( + previousTabs, + previousLeaves + )) { + if (changedMobileWorktrees.has(worktreeId)) { + continue + } + const stored = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!stored) { + continue + } + // Why: web clients drop same-epoch frames whose version isn't strictly + // newer, so a graph-only change must mint a fresh stored version (like + // the PTY touch path does) or the re-emitted payload — e.g. the + // pending-handle → ready flip — is discarded and the client stays stale. + // The accepted-renderer tracking is untouched: this is a main-local bump. + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...stored, + snapshotVersion: stored.snapshotVersion + 1 + }) + changedMobileWorktrees.add(worktreeId) + } + for (const worktreeId of changedMobileWorktrees) { + if (this.mobileSessionTabsByWorktree.has(worktreeId)) { + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + } + } this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() @@ -3526,24 +3647,72 @@ export class OrcaRuntimeService { } } - async listMobileSessionTabs(worktreeSelector: string): Promise { + // Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and + // this.leaves, so any tab/leaf delta a graph sync installs can flip the + // client payload (pending-handle → ready, tab title) with zero change to the + // stored snapshot. Compare exactly the projection-relevant fields and report + // the affected worktrees; false positives only cost a coalesced no-op emit. + private collectMobileVisibleGraphChangedWorktrees( + previousTabs: Map, + previousLeaves: Map + ): Set { + const changed = new Set() + for (const [tabId, tab] of this.tabs) { + const prev = previousTabs.get(tabId) + if (!prev || prev.title !== tab.title) { + changed.add(tab.worktreeId) + } + } + for (const [tabId, tab] of previousTabs) { + if (!this.tabs.has(tabId)) { + changed.add(tab.worktreeId) + } + } + for (const [leafKey, leaf] of this.leaves) { + const prev = previousLeaves.get(leafKey) + if ( + !prev || + prev.ptyId !== leaf.ptyId || + prev.connected !== leaf.connected || + prev.paneTitle !== leaf.paneTitle + ) { + changed.add(leaf.worktreeId) + } + } + for (const [leafKey, leaf] of previousLeaves) { + if (!this.leaves.has(leafKey)) { + changed.add(leaf.worktreeId) + } + } + return changed + } + + async listMobileSessionTabs( + worktreeSelector: string, + clientNavigationId?: string + ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) if (explicitWorktreeId) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) - await this.refreshMobileSessionPtyRecords() - return this.getMobileSessionTabsForWorktree(explicitWorktreeId) + await this.refreshMobileSessionPtyRecords(explicitWorktreeId) + return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId) } const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id) await this.refreshMobileSessionPtyRecords() - return this.getMobileSessionTabsForWorktree(worktree.id) + return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId) } - async listAllMobileSessionTabs(): Promise { + async listAllMobileSessionTabs( + clientNavigationId?: string + ): Promise { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() await this.refreshMobileSessionPtyRecords() return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => - this.toMobileSessionTabsResult(snapshot) + this.clientSessionTabSelections.project( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) ) } @@ -3565,6 +3734,18 @@ export class OrcaRuntimeService { if (!session) { return reconciledWorktreeIds } + // Why: with no serve-owned ptyId anywhere in the session and no offscreen + // browser backend, the serve-only hydrate provably builds zero tabs for + // every worktree — skip the per-worktree rebuild entirely (hot on every + // graph sync). Scoped to onlyServeOwnedTerminals so full hydrates are + // untouched. + if ( + options.onlyServeOwnedTerminals === true && + !this.offscreenBrowserBackend && + !this.workspaceSessionHasServeOwnedPty(session) + ) { + return reconciledWorktreeIds + } const entries = worktreeId !== undefined ? ([[worktreeId, session.tabsByWorktree[worktreeId] ?? []]] as const) @@ -3634,57 +3815,146 @@ export class OrcaRuntimeService { ? mergedActiveTab.parentTabId : mergedActiveTab.id : null - this.mobileSessionTabsByWorktree.set(entryWorktreeId, { + const nextTabGroups: RuntimeMobileSessionTabGroup[] = hasPersistedSplit + ? this.appendBrowserTabOrder( + this.distributeHeadlessTabsAcrossGroups( + persistedGroups.map((group) => ({ + id: group.id, + activeTabId: group.activeTabId, + tabOrder: [...group.tabOrder], + ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) + })), + this.collectHeadlessParentTabOrder(mergedTerminalTabs), + activeTopLevelId + ), + mergedBrowserOrder, + undefined, + // Why: distribute drops browser ids (terminal-only), so carry each + // browser's persisted group forward instead of coalescing left. + this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) + ) + : options.onlyServeOwnedTerminals === true && existing?.tabGroups + ? this.appendBrowserTabOrder( + this.mergeMobileSessionTabGroups( + entryWorktreeId, + existing.tabGroups, + mergedTerminalTabs, + mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null + ), + mergedBrowserOrder + ) + : [ + { + id: groupId, + activeTabId: mergedActiveTab?.id + ? (activeTab?.parentTabId ?? mergedActiveTab.id) + : (tabOrder[0] ?? null), + tabOrder + } + ] + // Why: merging runtime tabs INTO a renderer publication must not reclass + // the snapshot as headless-built — the preservation predicate would then + // treat the renderer's own tabs as runtime-owned and resurrect tabs the + // renderer later closes. Keep the renderer base epoch with a merge suffix + // (idempotent) so ownership stays derivable from the epoch. + const mergedIntoRendererPublication = + options.onlyServeOwnedTerminals === true && + existing !== undefined && + !this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch) + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { worktree: existing?.worktree ?? entryWorktreeId, - publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, + publicationEpoch: mergedIntoRendererPublication + ? this.getMergedMobileSessionPublicationEpoch(existing, tabs) + : `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, activeGroupId: existing?.activeGroupId ?? groupId, activeTabId: mergedActiveTab?.id ?? null, activeTabType: mergedActiveTab?.type ?? null, - tabGroups: hasPersistedSplit - ? this.appendBrowserTabOrder( - this.distributeHeadlessTabsAcrossGroups( - persistedGroups.map((group) => ({ - id: group.id, - activeTabId: group.activeTabId, - tabOrder: [...group.tabOrder], - ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) - })), - this.collectHeadlessParentTabOrder(mergedTerminalTabs), - activeTopLevelId - ), - mergedBrowserOrder, - undefined, - // Why: distribute drops browser ids (terminal-only), so carry each - // browser's persisted group forward instead of coalescing left. - this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) - ) - : options.onlyServeOwnedTerminals === true && existing?.tabGroups - ? this.appendBrowserTabOrder( - this.mergeMobileSessionTabGroups( - entryWorktreeId, - existing.tabGroups, - mergedTerminalTabs, - mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null - ), - mergedBrowserOrder - ) - : [ - { - id: groupId, - activeTabId: mergedActiveTab?.id - ? (activeTab?.parentTabId ?? mergedActiveTab.id) - : (tabOrder[0] ?? null), - tabOrder - } - ], - ...(hasPersistedSplit && persistedLayout ? { tabGroupLayout: persistedLayout } : {}), + tabGroups: nextTabGroups, + // Why: the serve-only rebuild runs on every graph sync — carry the + // existing split layout forward or each sync drops it and fans out. + ...(hasPersistedSplit && persistedLayout + ? { tabGroupLayout: persistedLayout } + : options.onlyServeOwnedTerminals === true && existing?.tabGroupLayout + ? { tabGroupLayout: existing.tabGroupLayout } + : {}), tabs: mergedTabs - }) + } + // Why: the serve-only hydrate runs on EVERY graph sync; when the rebuilt + // projection matches the existing snapshot, keep the existing object and + // (epoch, version) untouched so identity-based change detection stays a + // pure no-op and unchanged serve/browser worktrees never fan out. + if (existing && this.headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) { + continue + } + this.mobileSessionTabsByWorktree.set(entryWorktreeId, nextSnapshot) } return reconciledWorktreeIds } + // Why: content equality for the hydrate's idempotence check — compares every + // client-visible field EXCEPT publicationEpoch/snapshotVersion (both are + // freshly minted on each rebuild and would defeat the comparison). Tab and + // group objects are rebuilt each hydrate, so compare by value, not identity. + private headlessMobileSnapshotContentUnchanged( + existing: RuntimeMobileSessionTabsSnapshot, + next: RuntimeMobileSessionTabsSnapshot + ): boolean { + if ( + existing.worktree !== next.worktree || + existing.activeGroupId !== next.activeGroupId || + existing.activeTabId !== next.activeTabId || + existing.activeTabType !== next.activeTabType + ) { + return false + } + // Why: this runs per persisted worktree on EVERY graph sync whenever a + // serve PTY exists, so compare structurally instead of stable-stringifying + // both sides (which allocated six full serialized trees per worktree). + return ( + this.mobileSnapshotValueEqual(existing.tabs, next.tabs) && + this.mobileSnapshotValueEqual(existing.tabGroups ?? null, next.tabGroups ?? null) && + this.mobileSnapshotValueEqual(existing.tabGroupLayout ?? null, next.tabGroupLayout ?? null) + ) + } + + // Deep structural equality over plain snapshot JSON (objects/arrays/scalars). + // Key order is irrelevant; a mismatch only costs a coalesced no-op emit. + private mobileSnapshotValueEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false + } + for (let index = 0; index < a.length; index++) { + if (!this.mobileSnapshotValueEqual(a[index], b[index])) { + return false + } + } + return true + } + if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + if (aKeys.length !== Object.keys(bRecord).length) { + return false + } + for (const key of aKeys) { + if ( + !Object.hasOwn(bRecord, key) || + !this.mobileSnapshotValueEqual(aRecord[key], bRecord[key]) + ) { + return false + } + } + return true + } + return false + } + // Why: keep an existing snapshot's browser tabs in sync with the live bridge // without rebuilding stable terminal state. Replaces browser entries with the // current live set and rewrites the browser portion of the primary group order. @@ -3802,6 +4072,27 @@ export class OrcaRuntimeService { return typeof ptyId === 'string' && ptyId.startsWith('serve-') } + // Why: strict superset of hasServeOwnedPtyBinding's inputs (tab.ptyId + + // layout leaf ptyIds are what the built tabs' bindings are derived from), so + // the serve-only hydrate fast-path can never hide a serve terminal. + private workspaceSessionHasServeOwnedPty(session: WorkspaceSessionState): boolean { + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (this.isServeOwnedPtyId(tab.ptyId)) { + return true + } + const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId + if ( + leafPtyIds && + Object.values(leafPtyIds).some((ptyId) => this.isServeOwnedPtyId(ptyId)) + ) { + return true + } + } + } + return false + } + private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean { if (this.isServeOwnedPtyId(tab.ptyId)) { return true @@ -3833,6 +4124,47 @@ export class OrcaRuntimeService { ) } + // Why: a snapshot tab can keep a serve/SSH-owned ptyId after the runtime + // terminal died and was de-persisted, so id shape alone must not preserve it + // against a renderer publication. Require the binding to be backed by a live + // PTY or by the persisted workspace session (a dormant persisted serve/SSH + // binding is still re-hydratable, so it stays preserved). + private hasLiveOrPersistedServeOrSshOwnedPtyBinding( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const boundPtyIds = [ + tab.ptyId, + ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}) + ].filter((ptyId): ptyId is string => this.isServeOrSshOwnedPtyId(ptyId)) + if (boundPtyIds.length === 0) { + return false + } + // Why: exited PTY records are archived in ptysById, so require a connected + // record — a dead serve shell whose persisted binding is also gone must + // stop being preserved. + if (boundPtyIds.some((ptyId) => this.ptysById.get(ptyId)?.connected === true)) { + return true + } + const session = this.store?.getWorkspaceSession?.() + if (!session) { + return false + } + const persistedTab = (session.tabsByWorktree?.[worktreeId] ?? []).find( + (candidate) => candidate.id === tab.parentTabId + ) + if (!persistedTab) { + return false + } + const persistedPtyIds = new Set( + [ + persistedTab.ptyId, + ...Object.values(session.terminalLayoutsByTabId?.[persistedTab.id]?.ptyIdsByLeafId ?? {}) + ].filter((ptyId): ptyId is string => typeof ptyId === 'string') + ) + return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId)) + } + // Why: a tab needs authoritative runtime teardown (kill + de-persist + prune) // only when the renderer can't durably tear it down: either it's serve/SSH // (preserved + re-hydrated, would resurrect) or the renderer graph never @@ -4566,30 +4898,45 @@ export class OrcaRuntimeService { return } const result = this.toMobileSessionTabsResult(snapshot) - for (const listener of this.mobileSessionTabListeners) { - listener(result) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(result, subscription.clientNavigationId) + ) } } - private async refreshMobileSessionPtyRecords(): Promise { - if (!this.ptyController?.listProcesses) { + private async refreshMobileSessionPtyRecords( + targetWorktreeId: string | null = null + ): Promise { + if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { return } - const resolvedWorktrees = await this.listResolvedWorktrees() - await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. + const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID + const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() + await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + isFloatingWorkspace ? targetWorktreeId : null + ) } async activateMobileSessionTab( worktreeSelector: string, tabId: string, leafId?: string, - opts: { notifyClients?: boolean } = {} + opts: { + notifyClients?: boolean + clientNavigationId?: string + navigation?: RuntimeNavigationTarget + } = {} ): Promise { + const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') + const targetsHost = navigationTargetsHost(navigation) const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - await this.refreshMobileSessionPtyRecords() + await this.refreshMobileSessionPtyRecords(worktreeId) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId) const tab = leafId @@ -4622,7 +4969,7 @@ export class OrcaRuntimeService { const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && - (opts.notifyClients === false || + (!targetsHost || !this.notifier?.focusTerminal || this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) if (shouldMaterializePendingTerminal) { @@ -4650,7 +4997,7 @@ export class OrcaRuntimeService { } } try { - await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, { + await this.createHeadlessMobileSessionTerminal(worktreeId, targetsHost, undefined, { identity: { tabId: tab.parentTabId, leafId: tab.leafId, @@ -4672,82 +5019,107 @@ export class OrcaRuntimeService { } throw err } - return this.getMobileSessionTabsForWorktree(worktreeId) + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + navigation, + opts.clientNavigationId + ) } + const callerSnapshot = this.getMobileSessionTabsForWorktree( + worktreeId, + opts.clientNavigationId + ) const activeSibling = tab.id === tabId || leafId ? null - : snapshot?.tabs.find( - (candidate): candidate is RuntimeMobileSessionTerminalTab => + : (callerSnapshot.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId && candidate.isActive - ) + ) as RuntimeMobileSessionTerminalTab | undefined) const targetTab = activeSibling ?? tab - if (opts.notifyClients === false) { - this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, targetTab) - return this.getMobileSessionTabsForWorktree(worktreeId) - } - if (!this.notifier?.focusTerminal) { + if (targetsHost && !this.notifier?.focusTerminal) { if ( !targetTab.isActive && this.shouldPersistHeadlessMobileSessionActivation(snapshot!, targetTab) ) { this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot!, targetTab) } - return this.getMobileSessionTabsForWorktree(worktreeId) + } else if (targetsHost) { + this.notifier?.focusTerminal?.(targetTab.parentTabId, worktreeId, targetTab.leafId) } - this.notifier?.focusTerminal(targetTab.parentTabId, worktreeId, targetTab.leafId) + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + targetTab.id, + navigation, + opts.clientNavigationId + ) } else if (tab.type === 'browser') { - if (opts.notifyClients === false) { - this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, tab) - return this.getMobileSessionTabsForWorktree(worktreeId) - } // Why: browser mobile tabs are renderer-owned unified tabs; focusing the // session tab keeps desktop tab order/group state authoritative. - this.notifier?.focusEditorTab?.(tab.id, worktreeId) + if (targetsHost) { + this.notifier?.focusEditorTab?.(tab.id, worktreeId) + } } else { - if (opts.notifyClients === false) { - this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, tab) - return this.getMobileSessionTabsForWorktree(worktreeId) + if (targetsHost) { + this.notifier?.focusEditorTab?.(tab.id, worktreeId) } - this.notifier?.focusEditorTab?.(tab.id, worktreeId) } - return this.getMobileSessionTabsForWorktree(worktreeId) + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + navigation, + opts.clientNavigationId + ) } - private activateMobileSessionTabForRemoteClient( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - activeTab: RuntimeMobileSessionSnapshotTab - ): void { - // Why: phone tab selection should update the mobile snapshot without - // asking desktop renderers to focus the phone's background worktree. - const activeTopLevelId = activeTab.type === 'terminal' ? activeTab.parentTabId : activeTab.id - const tabs = snapshot.tabs.map((tab) => ({ - ...tab, - isActive: tab.id === activeTab.id - })) - const tabGroups = snapshot.tabGroups?.map((group) => - group.tabOrder.includes(activeTopLevelId) - ? { ...group, activeTabId: activeTopLevelId } - : group - ) - const activeGroupId = - tabGroups?.find((group) => group.tabOrder.includes(activeTopLevelId))?.id ?? - snapshot.activeGroupId - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `mobile-local:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - activeGroupId, - activeTabId: activeTab.id, - activeTabType: activeTab.type, - ...(tabGroups ? { tabGroups } : {}), - tabs + private applyMobileSessionTabNavigation( + snapshot: RuntimeMobileSessionTabsResult, + activeTabId: string, + navigation: RuntimeNavigationTarget, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { + let callerSnapshot: RuntimeMobileSessionTabsResult | null = null + if (navigationTargetsClients(navigation)) { + // Why: follow is live intent; disconnected devices must not inherit stale navigation on reconnect. + const ids = new Set( + [...this.mobileSessionTabListeners] + .map((subscription) => subscription.clientNavigationId) + .filter((id): id is string => Boolean(id)) + ) + if (clientNavigationId) { + ids.add(clientNavigationId) + } + for (const id of ids) { + const projected = this.clientSessionTabSelections.activate(snapshot, id, activeTabId) + this.emitMobileSessionTabsSnapshotToClient(projected, id, true) + if (id === clientNavigationId) { + callerSnapshot = projected + } + } + } else if (clientNavigationId) { + // Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner. + callerSnapshot = this.clientSessionTabSelections.activate( + snapshot, + clientNavigationId, + activeTabId + ) + this.emitMobileSessionTabsSnapshotToClient(callerSnapshot, clientNavigationId) } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) + if (clientNavigationId) { + return callerSnapshot ?? this.clientSessionTabSelections.project(snapshot, clientNavigationId) + } + if (navigation === 'caller') { + const selection = activateClientSessionTabSelection( + snapshot, + deriveClientSessionTabSelection(snapshot), + activeTabId + ) + return projectClientSessionTabSelection(snapshot, selection).snapshot + } + return snapshot } private shouldMaterializeHeadlessMobileSessionTab( @@ -5951,17 +6323,23 @@ export class OrcaRuntimeService { } onMobileSessionTabsChanged( - listener: (snapshot: RuntimeMobileSessionTabsResult) => void + listener: (snapshot: RuntimeMobileSessionTabsResult) => void, + clientNavigationId?: string ): () => void { - this.mobileSessionTabListeners.add(listener) + const subscription = { listener, clientNavigationId } + this.mobileSessionTabListeners.add(subscription) return () => { // Why: flush pending coalesced notifies before dropping this listener so a // subscriber closing mid-window still receives the latest settled state. this.mobileSessionTabsNotifyCoalescer.flushAll() - this.mobileSessionTabListeners.delete(listener) + this.mobileSessionTabListeners.delete(subscription) } } + forgetClientNavigationState(clientNavigationId: string): void { + this.clientSessionTabSelections.forgetClient(clientNavigationId) + } + // Why: terminal handles are normally created lazily when first referenced via // RPC, but agents need their own handle at spawn time (via ORCA_TERMINAL_HANDLE // env var) so they can self-identify in orchestration messages without an @@ -7243,6 +7621,37 @@ export class OrcaRuntimeService { } } + /** Mark a raw-output viewer without transferring terminal query authority. */ + registerRawTerminalViewSubscriber(ptyId: string): () => void { + this.rawTerminalViewSubscriberCounts.set( + ptyId, + (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 + ) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + let released = false + return () => { + if (released) { + return + } + released = true + const next = (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 + if (next <= 0) { + this.rawTerminalViewSubscriberCounts.delete(ptyId) + } else { + this.rawTerminalViewSubscriberCounts.set(ptyId, next) + } + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + /** Raw stream presence prevents provider thinning without changing reply ownership. */ + hasRawTerminalViewSubscriber(ptyId: string): boolean { + return ( + (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0 || + this.hasRemoteTerminalViewSubscriber(ptyId) + ) + } + hasRemoteTerminalViewSubscriber(ptyId: string): boolean { if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { return true @@ -7324,6 +7733,28 @@ export class OrcaRuntimeService { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + /** Raw keystroke pass-through for the pop-out dashboard's terminal preview. + * Honors the mobile-presence lock like the main window's pty:write path. */ + async writeTerminalPreviewInput(ptyId: string, data: string): Promise { + if (data.length === 0 || this.getDriver(ptyId).kind === 'mobile') { + return false + } + try { + await assertTerminalInputWithinLimitWithYield(data) + await this.writeTerminalInputChunks(ptyId, data, { + // Why: a phone can claim the floor while a paste yields between chunks. + beforeWrite: () => { + if (this.getDriver(ptyId).kind === 'mobile') { + throw new Error('terminal_mobile_driver_active') + } + } + }) + return true + } catch { + return false + } + } + hasHeadlessTerminalState(ptyId: string): boolean { return this.headlessTerminals.has(ptyId) } @@ -8280,14 +8711,60 @@ export class OrcaRuntimeService { private recordRecentPtyOutputForPathProvenance(ptyId: string, data: string): void { let recentOutputBuffer = this.recentPtyOutputById.get(ptyId) if (!recentOutputBuffer) { - recentOutputBuffer = new RecentPtyOutputBuffer() + // Boundaries are only owed to the one-time activation backfill; once + // tracking is live, new buffers keep the read-collapsing hot path. + recentOutputBuffer = new RecentPtyOutputBuffer({ + preserveChunkBoundaries: !this.recentPtyPathCandidateTrackingActive + }) this.recentPtyOutputById.set(ptyId, recentOutputBuffer) } recentOutputBuffer.append(data) - this.recentPtyPathCandidatesById.set( - ptyId, - appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) - ) + if ( + this.recentPtyPathCandidateTrackingActive || + // Why: an over-window chunk is stored pre-sliced, so activation backfill + // could never replay its original text. Extract while intact; oversized + // chunks are rare, so the desktop-only gate still skips the hot path. + data.length > RECENT_PTY_OUTPUT_LIMIT + ) { + this.recentPtyPathCandidatesById.set( + ptyId, + appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) + ) + } + } + + activateRecentPtyPathCandidateTracking(): void { + if (this.recentPtyPathCandidateTrackingActive) { + return + } + this.recentPtyPathCandidateTrackingActive = true + // Why: synchronous backfill from the retained raw windows so a file tap + // right after first mobile connect resolves exactly as before the gate. + // Replay each retained chunk in its original full form: joining or + // trimming chunks would change the candidate set (e.g. a window cut can + // shorten an over-4KiB line under the extractor's line guard, minting + // candidates the eager extractor rejected). + // Accepted best-effort loss: output that scrolled past the raw window + // before the first-ever connect no longer yields candidates. + for (const [ptyId, buffer] of this.recentPtyOutputById) { + let candidates = this.recentPtyPathCandidatesById.get(ptyId) + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + for (let index = 0; index < chunks.length; index += 1) { + if (index === 0 && headChunkIsPartial) { + // A pre-sliced over-window chunk was already extracted eagerly at + // append time (while its original text was intact); replaying its + // truncated remainder would mint or drop candidates spuriously. + continue + } + candidates = appendRecentPtyPathCandidates(candidates, chunks[index]!) + } + if (candidates) { + this.recentPtyPathCandidatesById.set(ptyId, candidates) + } + // Chunk boundaries were owed only to this one-time backfill; return + // the buffer to the compact read-collapsing steady state. + buffer.compact() + } } resolveTerminalContext( @@ -8330,6 +8807,11 @@ export class OrcaRuntimeService { } hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { + // Why: safety net for any query path that never saw a mobile onReady — + // lazily backfill so the answer matches pre-gate behavior. + if (!this.recentPtyPathCandidateTrackingActive) { + this.activateRecentPtyPathCandidateTracking() + } const ptyId = this.resolveLeafForHandle(handle)?.ptyId const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId)?.read() : null if (recentOutput && recentTerminalOutputIncludesPath(recentOutput, pathText, absolutePath)) { @@ -9264,6 +9746,7 @@ export class OrcaRuntimeService { // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) + this.rawTerminalViewSubscriberCounts.delete(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) @@ -12774,7 +13257,10 @@ export class OrcaRuntimeService { const updated = this.store.updateRepo(repo.id, { upstream: { owner: existingProject.providerIdentity.owner, - repo: existingProject.providerIdentity.repo + repo: existingProject.providerIdentity.repo, + ...(existingProject.providerIdentity.host + ? { host: existingProject.providerIdentity.host } + : {}) } }) if (!updated) { @@ -13875,7 +14361,7 @@ export class OrcaRuntimeService { return isWslUncPath(scope.path) ? 'linux' : process.platform } - async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + async getRepoSlug(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) return options @@ -13883,7 +14369,7 @@ export class OrcaRuntimeService { : getRepoSlug(repo.path, repo.connectionId ?? null) } - async getRepoUpstream(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + async getRepoUpstream(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) return options @@ -13904,7 +14390,7 @@ export class OrcaRuntimeService { if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) { continue } - let upstream: { owner: string; repo: string } | null + let upstream: GitHubOwnerRepo | null try { upstream = await getRepoUpstream(repo.path, null) } catch { @@ -13978,7 +14464,7 @@ export class OrcaRuntimeService { async getRepoWorkItemByOwnerRepo( repoSelector: string, - ownerRepo: { owner: string; repo: string }, + ownerRepo: { owner: string; repo: string; host?: string }, number: number, type: 'issue' | 'pr' ): Promise>> { @@ -14592,7 +15078,11 @@ export class OrcaRuntimeService { async rerunRepoPRChecks( repoSelector: string, prNumber: number, - options?: { headSha?: string; failedOnly?: boolean } + options?: { + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return rerunPRChecks( @@ -14643,6 +15133,7 @@ export class OrcaRuntimeService { repoSelector: string, args: { prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -14662,7 +15153,8 @@ export class OrcaRuntimeService { async resolveRepoReviewThread( repoSelector: string, threadId: string, - resolve: boolean + resolve: boolean, + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return resolveReviewThread( @@ -14670,6 +15162,7 @@ export class OrcaRuntimeService { threadId, resolve, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14677,6 +15170,7 @@ export class OrcaRuntimeService { async setRepoPRFileViewed( repoSelector: string, args: { + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -14764,7 +15258,8 @@ export class OrcaRuntimeService { async updateRepoPRState( repoSelector: string, prNumber: number, - updates: GitHubPullRequestStateUpdate + updates: GitHubPullRequestStateUpdate, + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updatePRState( @@ -14772,6 +15267,7 @@ export class OrcaRuntimeService { prNumber, updates, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14779,7 +15275,8 @@ export class OrcaRuntimeService { async requestRepoPRReviewers( repoSelector: string, prNumber: number, - reviewers: string[] + reviewers: string[], + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return requestPRReviewers( @@ -14787,6 +15284,7 @@ export class OrcaRuntimeService { prNumber, reviewers, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14794,7 +15292,8 @@ export class OrcaRuntimeService { async removeRepoPRReviewers( repoSelector: string, prNumber: number, - reviewers: string[] + reviewers: string[], + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return removePRReviewers( @@ -14802,6 +15301,7 @@ export class OrcaRuntimeService { prNumber, reviewers, repo.connectionId ?? null, + prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -14896,8 +15396,10 @@ export class OrcaRuntimeService { ) } - async listGitHubProjects(): Promise>> { - return listAccessibleProjects() + async listGitHubProjects( + args?: ListAccessibleProjectsArgs + ): Promise>> { + return listAccessibleProjects(args) } async listGitHubLabelsBySlug( @@ -15443,7 +15945,11 @@ export class OrcaRuntimeService { async activateManagedWorktree( worktreeSelector: string, - opts: { notifyClients?: boolean; clientKind?: 'mobile' | 'runtime' } = {} + opts: { + notifyClients?: boolean + clientKind?: 'mobile' | 'runtime' + navigation?: RuntimeNavigationTarget + } = {} ): Promise<{ repoId: string worktreeId: string @@ -15459,8 +15965,11 @@ export class OrcaRuntimeService { if (!repo) { throw new Error('repo_not_found') } + const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') + const targetsHost = navigationTargetsHost(navigation) + const targetsClients = navigationTargetsClients(navigation) - if (opts.notifyClients === false && this.store?.getWorktreeMeta(worktree.id)?.isUnread) { + if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) { // Why: mobile/web session activation intentionally bypasses renderer // selection, so the runtime must acknowledge the unread state itself. this.store.setWorktreeMeta(worktree.id, { isUnread: false }) @@ -15469,11 +15978,17 @@ export class OrcaRuntimeService { let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' = 'not-applicable' - if (opts.notifyClients !== false) { + if (targetsHost || targetsClients) { // Why: inactive worktree terminal panes are renderer-owned and may not have // live PTYs until the desktop activates the worktree and mounts them. - this.notifyActivateWorktree(repo.id, worktree.id) - } else { + if (targetsHost) { + this.notifyHostActivateWorktree(repo.id, worktree.id) + } + if (targetsClients) { + this.notifyClientsActivateWorktree(repo.id, worktree.id) + } + } + if (!targetsHost) { // Why: mobile/web selection needs fresh session surfaces without forcing // every attached desktop renderer to navigate to the phone's workspace. this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { @@ -19093,6 +19608,8 @@ export class OrcaRuntimeService { tabId, agentTeamsPlan?.env ) + const terminalColorQueryReplies = + launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors() const result = await this.ptyController.spawn({ cols: 120, rows: 40, @@ -19108,12 +19625,14 @@ export class OrcaRuntimeService { launchOpts.envToDelete, agentTeamsPlan?.envToDelete ), + resumeProviderSession: launchOpts.resumeProviderSession, telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, worktreeId: workspace.id, preAllocatedHandle, tabId, leafId, + ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), // Why: a headless-created pane has no renderer session writer. Persist // its tab/leaf binding at spawn so a later promoted window reattaches @@ -19251,6 +19770,9 @@ export class OrcaRuntimeService { cwd, ...(launchOpts.env ? { env: launchOpts.env } : {}), ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), + ...(launchOpts.resumeProviderSession + ? { resumeProviderSession: launchOpts.resumeProviderSession } + : {}), ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), @@ -19348,35 +19870,58 @@ export class OrcaRuntimeService { launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' activate?: boolean + select?: boolean + clientNavigationId?: string + navigation?: RuntimeNavigationTarget clientMutationId?: string signal?: AbortSignal } = {} ): Promise { + const navigation = opts.navigation ?? 'all' + const select = opts.select ?? opts.activate !== false + const runOpts = { + ...opts, + activate: select && navigationTargetsHost(navigation) + } const mutationId = opts.clientMutationId + let result: RuntimeMobileSessionCreateTerminalResult if (!mutationId) { - return this.runCreateMobileSessionTerminal(worktreeSelector, opts) - } - const mutationKey = `${worktreeSelector}\0${mutationId}` - // Why: a retried create (double-tap, reconnect replay) with the same - // idempotency key must return the in-flight operation instead of spawning a - // duplicate terminal. Successes are kept briefly so a retry whose response - // was lost in transit reuses the created terminal; failures are dropped - // immediately so a retry can start a fresh create. - const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) - if (inflight) { - return inflight - } - const run = this.runCreateMobileSessionTerminal(worktreeSelector, opts) - this.mobileTerminalCreateByMutationId.set(mutationKey, run) - const drop = (): void => { - if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { - this.mobileTerminalCreateByMutationId.delete(mutationKey) + result = await this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) + } else { + // Why: idempotency is caller-owned; two paired devices may reuse the same mutation id without sharing a result. + const mutationKey = `${opts.clientNavigationId ?? 'local'}\0${worktreeSelector}\0${mutationId}` + // Why: a retried create (double-tap, reconnect replay) with the same + // idempotency key must return the in-flight operation instead of spawning a + // duplicate terminal. Successes are kept briefly so a retry whose response + // was lost in transit reuses the created terminal; failures are dropped + // immediately so a retry can start a fresh create. + const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) + const run = inflight ?? this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) + if (!inflight) { + this.mobileTerminalCreateByMutationId.set(mutationKey, run) + const drop = (): void => { + if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { + this.mobileTerminalCreateByMutationId.delete(mutationKey) + } + } + void run.then(() => { + setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() + }, drop) } + result = await run } - void run.then(() => { - setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() - }, drop) - return run + if (select) { + const worktreeId = + this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? + (await this.resolveWorktreeSelector(worktreeSelector)).id + this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + result.tab.id, + navigation, + opts.clientNavigationId + ) + } + return result } private async runCreateMobileSessionTerminal( @@ -19773,8 +20318,10 @@ export class OrcaRuntimeService { } this.mobileSessionTabsByWorktree.set(worktreeId, next) const result = this.toMobileSessionTabsResult(next) - for (const listener of this.mobileSessionTabListeners) { - listener(result) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(result, subscription.clientNavigationId) + ) } const created = result.tabs.find((candidate) => candidate.id === tab.id) if (!created || created.type !== 'terminal') { @@ -20126,24 +20673,30 @@ export class OrcaRuntimeService { return null } - async focusTerminal(handle: string): Promise { + async focusTerminal( + handle: string, + options: { navigateHost?: boolean } = {} + ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { throw new Error('terminal_exited') } const parsedPaneKey = parsePaneKey(pty.pty.paneKey ?? '') - const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { - ptyId: pty.pty.ptyId, - title: getLatestPtyTitle(pty.pty), - ...(pty.pty.launchConfig - ? { launchConfig: copySleepingAgentLaunchConfig(pty.pty.launchConfig) } - : {}), - ...(pty.pty.launchToken ? { launchToken: pty.pty.launchToken } : {}), - ...(pty.pty.launchAgent ? { launchAgent: pty.pty.launchAgent } : {}), - ...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}), - ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) - }) + const revealed = + options.navigateHost === false + ? undefined + : await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { + ptyId: pty.pty.ptyId, + title: getLatestPtyTitle(pty.pty), + ...(pty.pty.launchConfig + ? { launchConfig: copySleepingAgentLaunchConfig(pty.pty.launchConfig) } + : {}), + ...(pty.pty.launchToken ? { launchToken: pty.pty.launchToken } : {}), + ...(pty.pty.launchAgent ? { launchAgent: pty.pty.launchAgent } : {}), + ...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}), + ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) + }) return { handle, tabId: revealed?.tabId ?? pty.pty.tabId ?? pty.record.tabId, @@ -20152,7 +20705,9 @@ export class OrcaRuntimeService { } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) - this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId, leaf.leafId) + if (options.navigateHost !== false) { + this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId, leaf.leafId) + } return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId } } @@ -21879,6 +22434,12 @@ export class OrcaRuntimeService { resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null ): Promise | null> { + if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() + if (targetedLiveness !== null) { + return targetedLiveness + } + } if (!this.ptyController?.listProcesses) { return null } @@ -21923,6 +22484,87 @@ export class OrcaRuntimeService { return livePtyIds } + private refreshFloatingWorkspacePtyLiveness(): Set | null { + const controller = this.ptyController + if (!controller?.hasPty) { + return null + } + const knownPtyIds = new Set() + const persistedBindingByPtyId = new Map() + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + knownPtyIds.add(pty.ptyId) + } + } + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === FLOATING_TERMINAL_WORKTREE_ID && leaf.ptyId) { + knownPtyIds.add(leaf.ptyId) + } + } + const snapshot = this.mobileSessionTabsByWorktree.get(FLOATING_TERMINAL_WORKTREE_ID) + for (const tab of snapshot?.tabs ?? []) { + if (tab.type !== 'terminal') { + continue + } + if (tab.ptyId) { + knownPtyIds.add(tab.ptyId) + persistedBindingByPtyId.set(tab.ptyId, { + tabId: tab.parentTabId, + paneKey: this.getMobileTerminalPaneKey(tab) + }) + } + for (const [leafId, ptyId] of Object.entries(tab.parentLayout?.ptyIdsByLeafId ?? {})) { + knownPtyIds.add(ptyId) + persistedBindingByPtyId.set(ptyId, { + tabId: tab.parentTabId, + paneKey: isTerminalLeafId(leafId) + ? makePaneKey(tab.parentTabId, leafId) + : `${tab.parentTabId}:${/^pane:(\d+)$/.exec(leafId)?.[1] ?? leafId}` + }) + } + } + + const liveness = new Map() + try { + for (const ptyId of knownPtyIds) { + const live = controller.hasPty(ptyId) + if (live === null) { + return null + } + liveness.set(ptyId, live) + } + } catch { + return null + } + + const livePtyIds = new Set() + for (const [ptyId, live] of liveness) { + let pty = this.ptysById.get(ptyId) + if (live) { + livePtyIds.add(ptyId) + const binding = persistedBindingByPtyId.get(ptyId) + if (!pty && binding) { + // Why: a live daemon PTY restored from disk needs its pane identity before mobile can issue a safe handle. + pty = this.recordPtyWorktree(ptyId, FLOATING_TERMINAL_WORKTREE_ID, { + connected: true, + tabId: binding.tabId, + paneKey: binding.paneKey + }) + } + if (pty) { + pty.connected = true + pty.disconnectedAt = null + this.refreshPtyForegroundAgent(ptyId) + } + } else if (pty && !this.leafExistsForPty(ptyId)) { + pty.connected = false + pty.disconnectedAt ??= Date.now() + } + } + this.pruneDisconnectedPtyRecords() + return livePtyIds + } + private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void { if (pty.connected) { return @@ -22068,10 +22710,20 @@ export class OrcaRuntimeService { } } - private syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined): void { + // Returns the worktrees whose stored snapshot object changed during this + // sync, so the caller can fan out only actually-changed worktrees. + private syncMobileSessionTabs( + snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined + ): Set { + const changedWorktreeIds = new Set() if (snapshots === undefined) { - return + return changedWorktreeIds } + // Why: snapshots are immutable — every writer replaces the map entry with a + // new object, and the accept gate below drops semantically-unchanged + // renderer resends before they replace an entry — so reference identity + // before/after detects exactly the entries that actually changed. + const before = new Map(this.mobileSessionTabsByWorktree) // Why: renderer graphs own renderer tabs, but headless serve terminals never enter that graph unless we preserve their bindings. this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, { allowAttachedWindow: true, @@ -22081,29 +22733,85 @@ export class OrcaRuntimeService { for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) - const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing) + // Why: judge renderer publication ordering against the renderer's own + // last-accepted (epoch, version) — the renderer reuses one pair for + // byte-identical content, so a same-epoch version <= the accepted one is + // a no-op resend (or a stale frame) and must be skipped. Never compare + // against the stored snapshot's version: main-local touches bump it + // independently and would reject genuinely newer renderer revisions. + const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree) if ( - !existing || - nextSnapshot.publicationEpoch !== existing.publicationEpoch || - nextSnapshot.snapshotVersion >= existing.snapshotVersion + accepted && + accepted.publicationEpoch === snapshot.publicationEpoch && + snapshot.snapshotVersion <= accepted.rendererVersion && + // Why: preservation is main-only state — a serve/SSH binding (or live + // browser page) can disappear without the renderer bumping its version, + // so a resend of the EXACT accepted revision (content-identical to the + // accepted publication, safe to re-merge) must still fall through to + // the merge, which prunes stale preserved tabs. Strictly-older frames + // stay skipped: their content is outdated, and the next accepted-pair + // resend performs the prune. + !( + existing && + snapshot.snapshotVersion === accepted.rendererVersion && + this.storedMobileSnapshotHasStalePreservedTab(existing, snapshot) + ) ) { - this.mobileSessionTabsByWorktree.set(snapshot.worktree, nextSnapshot) + continue } + const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing) + // Why: clients drop same-epoch frames whose version isn't strictly newer, + // and main-local touches may already have emitted a higher version than + // the renderer's counter — keep the stored version strictly monotonic so + // the accepted content is never discarded as stale downstream. + const storedVersion = existing + ? Math.max(nextSnapshot.snapshotVersion, existing.snapshotVersion + 1) + : nextSnapshot.snapshotVersion + this.mobileSessionTabsByWorktree.set( + snapshot.worktree, + storedVersion === nextSnapshot.snapshotVersion + ? nextSnapshot + : { ...nextSnapshot, snapshotVersion: storedVersion } + ) + this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, { + publicationEpoch: snapshot.publicationEpoch, + rendererVersion: snapshot.snapshotVersion + }) } for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { if (!nextWorktrees.has(worktreeId)) { const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing) if (preserved) { - this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + // Why: preservation filters existing.tabs in place (same objects) and + // the merge epoch hashes the preserved identities idempotently, so an + // equal epoch with every tab object retained means the recomputation + // was a no-op — keep the entry so no-op syncs don't fan out. + const preservedIsNoOp = + preserved.publicationEpoch === existing.publicationEpoch && + preserved.tabs.length === existing.tabs.length && + preserved.tabs.every((tab, index) => tab === existing.tabs[index]) + if (!preservedIsNoOp) { + this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + } + // Why: the stored entry is no longer the renderer's publication, so a + // future renderer frame must be re-merged even if it reuses the pair. + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) } } } + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + if (before.get(worktreeId) !== snapshot) { + changedWorktreeIds.add(worktreeId) + } + } + return changedWorktreeIds } private mergePreservedHeadlessMobileSessionTabs( @@ -22172,6 +22880,8 @@ export class OrcaRuntimeService { return { ...existing, publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs), + // Why: mint a fresh version or clients' same-epoch gate drops the prune frame. + snapshotVersion: existing.snapshotVersion + 1, activeGroupId: existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree), activeTabId: activeTab?.id ?? null, @@ -22186,6 +22896,26 @@ export class OrcaRuntimeService { } } + // Why: the accepted-revision no-op gate must not fossilize preserved runtime + // tabs. A stored merged snapshot's tabs that are absent from the incoming + // renderer publication exist only via preservation; if any such tab no longer + // passes the preservation predicate (binding removed from the live PTY table + // and persisted session, or browser page closed), the stored snapshot is + // stale even though the renderer revision is unchanged. + private storedMobileSnapshotHasStalePreservedTab( + existing: RuntimeMobileSessionTabsSnapshot, + incoming: RuntimeMobileSessionTabsSnapshot + ): boolean { + const incomingIds = new Set( + incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) + ) + return existing.tabs.some( + (tab) => + !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id)) && + !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + ) + } + private collectPreservedHeadlessMobileSessionTabs( existing: RuntimeMobileSessionTabsSnapshot, incoming?: RuntimeMobileSessionTabsSnapshot @@ -22207,17 +22937,31 @@ export class OrcaRuntimeService { ): boolean { // Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph". if (tab.type === 'browser') { + if (!this.offscreenBrowserBackend) { + return false + } + // Why: in a renderer-based merged snapshot the browser entries can also + // be renderer-owned, so only pages the offscreen bridge still lists are + // runtime-owned and preservable; a pure renderer epoch preserves none. return ( - Boolean(this.offscreenBrowserBackend) && - this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) + this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || + (snapshot.publicationEpoch.includes(':headless-merge:') && + typeof tab.browserPageId === 'string' && + this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)) ) } if (tab.type !== 'terminal') { return false } + // Why: a merged renderer snapshot carries BOTH renderer-owned and + // runtime-owned tabs, so the epoch alone must not preserve every terminal — + // that resurrects renderer tabs the renderer already closed. Broad + // preservation applies only to genuinely headless-built snapshots; in a + // renderer-based one, only tabs with a live-or-persisted serve/SSH binding + // are runtime-owned and preservable. return ( - this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || - this.hasServeOwnedPtyBinding(tab) + this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || + this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab) ) } @@ -22229,6 +22973,15 @@ export class OrcaRuntimeService { ) } + // Why: `:headless-merge:` only marks that runtime tabs were merged in — the + // BASE epoch still says who published the snapshot. A renderer-based merged + // snapshot must not be classified as headless-built, or its renderer tabs + // read as runtime-owned. + private isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean { + const base = publicationEpoch.split(':headless-merge:')[0] + return base.startsWith('headless:') || base.startsWith('headless-hydrated:') + } + private getMergedMobileSessionPublicationEpoch( snapshot: RuntimeMobileSessionTabsSnapshot, preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] @@ -22261,9 +23014,12 @@ export class OrcaRuntimeService { activeTabType: null, tabs: [] } - for (const listener of this.mobileSessionTabListeners) { - listener(removed) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(removed, subscription.clientNavigationId) + ) } + this.clientSessionTabSelections.forgetWorktree(worktreeId) } notifyMobileSessionTabsChanged(worktreeId?: string): void { @@ -22296,8 +23052,10 @@ export class OrcaRuntimeService { } // Why: browser bridge events are already worktree-scoped; don't fan out every workspace snapshot during navigation/tab churn. const result = this.toMobileSessionTabsResult(snapshot) - for (const listener of this.mobileSessionTabListeners) { - listener(result) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(result, subscription.clientNavigationId) + ) } } @@ -22307,26 +23065,49 @@ export class OrcaRuntimeService { } for (const snapshot of this.mobileSessionTabsByWorktree.values()) { const result = this.toMobileSessionTabsResult(snapshot) - for (const listener of this.mobileSessionTabListeners) { - listener(result) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(result, subscription.clientNavigationId) + ) } } } - private getMobileSessionTabsForWorktree(worktreeId: string): RuntimeMobileSessionTabsResult { + private getMobileSessionTabsForWorktree( + worktreeId: string, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { - return { - worktree: worktreeId, - publicationEpoch: 'none', - snapshotVersion: 0, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] + return this.clientSessionTabSelections.project( + { + worktree: worktreeId, + publicationEpoch: 'none', + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }, + clientNavigationId + ) + } + return this.clientSessionTabSelections.project( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) + } + + private emitMobileSessionTabsSnapshotToClient( + projected: RuntimeMobileSessionTabsResult, + clientNavigationId: string, + follow = false + ): void { + for (const subscription of this.mobileSessionTabListeners) { + if (subscription.clientNavigationId === clientNavigationId) { + subscription.listener(follow ? { ...projected, navigationIntent: 'follow' } : projected) } } - return this.toMobileSessionTabsResult(snapshot) } private async resolveMobileMarkdownWorktreeId( @@ -22471,6 +23252,12 @@ export class OrcaRuntimeService { const paneKey = isTerminalLeafId(tab.leafId) ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` + const mobileStatusPty = livePty ?? pty + // Why: headless hooks live only in main's retained rows; reuse this lookup + // for both title ownership and status publication so the two cannot diverge. + const retainedAgentStatus = tab.agentStatus + ? null + : this.getFreshRetainedAgentStatusForMobileTab(paneKey, liveLeafPty ?? mobileStatusPty, tab) const leafTitle = leaf ? getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, @@ -22484,7 +23271,15 @@ export class OrcaRuntimeService { ) : null const launchAgent = tab.launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null - const ownerAgent = launchAgent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null + // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. + const ownerAgent = + resolvePaneAgentOwner({ + launchAgent, + hookAgent: tab.agentStatus?.agentType ?? retainedAgentStatus?.payload.agentType ?? null + }) ?? + liveLeafPty?.foregroundAgent ?? + pty?.foregroundAgent ?? + null const title = normalizeCompatibleAgentTitleForOwner( leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, ownerAgent @@ -22543,7 +23338,13 @@ export class OrcaRuntimeService { ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), - ...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)), + ...(agentStatus ?? + this.buildPtyMobileAgentStatus( + mobileStatusPty, + tab, + terminalHandle, + retainedAgentStatus + )), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.color != null ? { color: tab.color } : {}), @@ -22598,10 +23399,10 @@ export class OrcaRuntimeService { private buildPtyMobileAgentStatus( pty: RuntimePtyWorktreeRecord | null, tab: RuntimeMobileSessionTerminalTab, - terminalHandle: string | null + terminalHandle: string | null, + retained: RuntimeAgentRowSnapshot | null ): { agentStatus: AgentStatusEntry } | Record { const paneKey = this.getMobileTerminalPaneKey(tab) - const retained = this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab) if (!pty?.lastAgentStatus && !retained) { return {} } @@ -22626,7 +23427,14 @@ export class OrcaRuntimeService { return {} } } - const ownerAgent = tab.launchAgent ?? pty?.launchAgent ?? pty?.foregroundAgent ?? null + // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. + const ownerAgent = + resolvePaneAgentOwner({ + launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, + hookAgent: retained?.payload.agentType ?? null + }) ?? + pty?.foregroundAgent ?? + null const terminalTitle = normalizeCompatibleAgentTitleForOwner( (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, ownerAgent @@ -22802,6 +23610,14 @@ export class OrcaRuntimeService { if (!db) { return undefined } + // Why: this runs on every 16ms graph publish (title/status churn). With no + // dispatch rows — the overwhelming majority who never orchestrate — the + // per-terminal query fan-out below can only ever yield an empty result, so + // skip it wholesale via the DB's cached emptiness probe. Optional call so + // partial test-injected DBs without the probe fall through to the scan. + if (db.hasAnyDispatchContexts?.() === false) { + return undefined + } const contexts: Record = {} for (const leaf of this.leaves.values()) { if (!leaf.ptyId) { diff --git a/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts b/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts new file mode 100644 index 00000000000..991d9df4358 --- /dev/null +++ b/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +// Why: buildAgentOrchestrationByPaneKey issues 2 dispatch lookups per terminal +// on EVERY 16ms graph publish. For users who never orchestrate, every one of +// those queries returns nothing — pure main-thread SQLite churn. This bench +// proves the hasAnyDispatchContexts() short-circuit turns an N-terminal scan +// into a single cached probe, by counting the per-terminal +// getActiveDispatchForTerminal + getLatestDispatchForTerminal executions. + +// Simulate the buildAgentOrchestrationByPaneKey per-terminal fan-out. +function simulateGraphPublish(db: OrchestrationDb, terminalHandles: string[]): number { + if (db.hasAnyDispatchContexts() === false) { + return 0 + } + let contexts = 0 + for (const handle of terminalHandles) { + const active = db.getActiveDispatchForTerminal(handle) + const recent = active ?? db.getLatestDispatchForTerminal(handle) + if (recent) { + contexts++ + } + } + return contexts +} + +describe('orchestration empty-dispatch short-circuit (benchmark)', () => { + it('short-circuits the per-terminal query fan-out when no dispatch rows exist', () => { + const db = new OrchestrationDb(':memory:') + const handles = Array.from({ length: 100 }, (_, i) => `term_${i}`) + + // Instrument the real query methods to count executions. + let queries = 0 + const wrap = unknown>(fn: T): T => + ((...a: Parameters) => { + queries++ + return fn(...a) + }) as T + const original = { + active: db.getActiveDispatchForTerminal.bind(db), + latest: db.getLatestDispatchForTerminal.bind(db) + } + db.getActiveDispatchForTerminal = wrap(original.active) + db.getLatestDispatchForTerminal = wrap(original.latest) + + // 60 publishes/s * 5s = 300 ticks, 100 terminals each. + const TICKS = 300 + for (let t = 0; t < TICKS; t++) { + simulateGraphPublish(db, handles) + } + + // With the short-circuit, zero per-terminal dispatch queries execute. + expect(queries).toBe(0) + + // Sanity: without the short-circuit it would have been 2 * 100 * 300. + const wouldHaveBeen = 2 * handles.length * TICKS + // eslint-disable-next-line no-console + console.log( + `[bench] empty-dispatch short-circuit: ${queries} dispatch queries over ${TICKS} publishes x ${handles.length} terminals (naive path would run ${wouldHaveBeen})` + ) + expect(wouldHaveBeen).toBe(60000) + }) + + it('still runs the fan-out once a dispatch exists (correctness preserved)', () => { + const db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + db.createDispatchContext(task.id, 'term_5') + const handles = Array.from({ length: 10 }, (_, i) => `term_${i}`) + + const contexts = simulateGraphPublish(db, handles) + // term_5 has an active dispatch → its context is produced. + expect(contexts).toBe(1) + }) + + it('predicate lifecycle: false when empty, true after dispatch (even completed), false after reset', () => { + const db = new OrchestrationDb(':memory:') + expect(db.hasAnyDispatchContexts()).toBe(false) + const ctx = db.createDispatchContext(db.createTask({ spec: 'work' }).id, 'term_worker') + expect(db.hasAnyDispatchContexts()).toBe(true) + // Completed rows still count — recent-completed lookups must stay valid. + db.completeDispatch(ctx.id) + expect(db.hasAnyDispatchContexts()).toBe(true) + db.resetTasks() + expect(db.hasAnyDispatchContexts()).toBe(false) + }) +}) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 1c4b68f9d10..b287cb0182c 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -89,6 +89,14 @@ const SCHEMA_VERSION = 6 export class OrchestrationDb { private db: Database.Database + // Why: the orchestration DB is created lazily for ALL users, but only the + // small minority who dispatch work ever have dispatch_contexts rows. The + // renderer graph publish rebuilds orchestration context on every 16ms tick + // (buildAgentOrchestrationByPaneKey), issuing 2 queries per terminal. Cache + // emptiness so the non-orchestration majority short-circuits the whole + // per-terminal fan-out. Only createDispatchContext flips this false→true. + private hasAnyDispatchContextsCache: boolean | undefined + constructor(dbPath: string | ':memory:') { this.db = new Database(dbPath) this.db.pragma('journal_mode = WAL') @@ -670,6 +678,7 @@ export class OrchestrationDb { VALUES (?, ?, ?, ?, 'dispatched', ?, datetime('now'))` ) .run(id, taskId, assigneeHandle, assigneePaneKey ?? null, priorFailures) + this.hasAnyDispatchContextsCache = true this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) @@ -694,6 +703,20 @@ export class OrchestrationDb { return this.findActiveDispatchForAssignee(handle) } + /** + * Cheap "are there any dispatch rows at all" probe. When false, no terminal + * can have an active or recent-completed dispatch, so orchestration-context + * builders can skip their per-terminal query fan-out entirely. Cached after + * the first probe; createDispatchContext marks it true, resets clear it. + */ + hasAnyDispatchContexts(): boolean { + if (this.hasAnyDispatchContextsCache === undefined) { + const row = this.db.prepare('SELECT 1 FROM dispatch_contexts LIMIT 1').get() + this.hasAnyDispatchContextsCache = row !== undefined + } + return this.hasAnyDispatchContextsCache + } + private findActiveDispatchForAssignee( assigneeHandle: string, assigneePaneKey?: string @@ -956,6 +979,7 @@ export class OrchestrationDb { this.db.exec('DELETE FROM dispatch_contexts') this.db.exec('DELETE FROM tasks') this.db.exec('DELETE FROM messages') + this.hasAnyDispatchContextsCache = undefined } resetTasks(): void { @@ -963,6 +987,7 @@ export class OrchestrationDb { this.db.exec('DELETE FROM decision_gates') this.db.exec('DELETE FROM dispatch_contexts') this.db.exec('DELETE FROM tasks') + this.hasAnyDispatchContextsCache = undefined } resetMessages(): void { diff --git a/src/main/runtime/recent-pty-output-buffer.test.ts b/src/main/runtime/recent-pty-output-buffer.test.ts index 1778c7aee42..6c21284e163 100644 --- a/src/main/runtime/recent-pty-output-buffer.test.ts +++ b/src/main/runtime/recent-pty-output-buffer.test.ts @@ -165,6 +165,83 @@ describe('RecentPtyOutputBuffer', () => { expect(buffer.read()).toBe(reference ?? '') }) + it('retainedChunks preserves original chunk boundaries within the window', () => { + const buffer = new RecentPtyOutputBuffer() + buffer.append('wrote /tmp/a.json') + buffer.append('suffix.txt') + expect(buffer.retainedChunks().chunks).toEqual(['wrote /tmp/a.json', 'suffix.txt']) + expect(buffer.read()).toBe('wrote /tmp/a.jsonsuffix.txt') + // read() must not collapse the chunk array before compact(): boundaries + // survive a read so the backfill can still replay original chunks. + expect(buffer.retainedChunks().chunks).toEqual(['wrote /tmp/a.json', 'suffix.txt']) + }) + + it('retainedChunks keeps the full original head chunk when the window trims mid-chunk', () => { + const buffer = new RecentPtyOutputBuffer() + const head = 'a'.repeat(RECENT_PTY_OUTPUT_LIMIT - 5) + buffer.append(head) + buffer.append('bbbbbbbbbb') + buffer.append('cc') + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + // The head chunk is intact (its original text, trimmed prefix included) + // so candidate backfill sees the same input the eager extractor saw. + expect(chunks).toEqual([head, 'bbbbbbbbbb', 'cc']) + expect(headChunkIsPartial).toBe(false) + expect(buffer.read()).toBe(`${'a'.repeat(RECENT_PTY_OUTPUT_LIMIT - 12)}bbbbbbbbbbcc`) + }) + + it('retainedChunks flags a pre-sliced oversized head chunk as partial', () => { + const buffer = new RecentPtyOutputBuffer() + buffer.append('z'.repeat(RECENT_PTY_OUTPUT_LIMIT + 100)) + expect(buffer.retainedChunks().headChunkIsPartial).toBe(true) + buffer.append('tail') + expect(buffer.retainedChunks().headChunkIsPartial).toBe(true) + // Once the partial head chunk is fully evicted the flag clears. + buffer.append('w'.repeat(RECENT_PTY_OUTPUT_LIMIT - 1)) + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + expect(headChunkIsPartial).toBe(false) + expect(chunks).toEqual(['tail', 'w'.repeat(RECENT_PTY_OUTPUT_LIMIT - 1)]) + // An exactly cap-sized append is stored whole, not partial. + const exact = new RecentPtyOutputBuffer() + exact.append('q'.repeat(RECENT_PTY_OUTPUT_LIMIT)) + expect(exact.retainedChunks().headChunkIsPartial).toBe(false) + }) + + it('compact collapses to a single chunk and restores read-time defragmentation', () => { + const buffer = new RecentPtyOutputBuffer() + let reference: string | undefined + for (let i = 0; i < 200; i++) { + const chunk = `${i}:${'p'.repeat(700)}\n` + buffer.append(chunk) + reference = referenceAppend(reference, chunk) + } + buffer.compact() + expect(buffer.retainedChunks().chunks).toEqual([reference]) + expect(buffer.read()).toBe(reference) + // After compact, a read re-collapses fragmentation from later appends. + buffer.append('after') + reference = referenceAppend(reference, 'after') + expect(buffer.read()).toBe(reference) + expect(buffer.retainedChunks().chunks).toEqual([reference]) + }) + + it('retainedChunks window-trimmed join always equals read()', () => { + const rng = mulberry32(0xfeed) + const buffer = new RecentPtyOutputBuffer() + const joinRetained = (): string => { + const { chunks } = buffer.retainedChunks() + const joined = chunks.join('') + return joined.slice(Math.max(0, joined.length - RECENT_PTY_OUTPUT_LIMIT)) + } + for (let i = 0; i < 300; i++) { + buffer.append(String.fromCharCode(33 + (i % 90)).repeat(Math.floor(rng() * 1500))) + if (i % 11 === 0) { + expect(joinRetained()).toBe(buffer.read()) + } + } + expect(joinRetained()).toBe(buffer.read()) + }) + it('stays equivalent under randomized chunk sizes straddling the cap', () => { const rng = mulberry32(0xc0ffee) for (let round = 0; round < 5; round++) { diff --git a/src/main/runtime/recent-pty-output-buffer.ts b/src/main/runtime/recent-pty-output-buffer.ts index 29af04f1daf..a10559b75c9 100644 --- a/src/main/runtime/recent-pty-output-buffer.ts +++ b/src/main/runtime/recent-pty-output-buffer.ts @@ -14,20 +14,33 @@ const DROPPED_HEAD_COMPACT_THRESHOLD = 1024 export class RecentPtyOutputBuffer { private chunks: string[] = [] private headIndex = 0 - // Code units already trimmed off the front of the head chunk. Deferred to - // read() so repeated small trims never allocate a substring per append. + // Code units already trimmed off the front of the head chunk. Deferred so + // repeated small trims never allocate a substring per append, and so the + // head chunk's original text stays available for candidate backfill. private headOffset = 0 private totalLen = 0 + // True when the stored head chunk is not the full original PTY chunk (a + // single over-limit append is stored pre-sliced), so backfill replay knows + // the original line context of its leading text is gone. + private headChunkIsPartial = false + // Original chunk boundaries are owed only to the one-time path-candidate + // backfill; compact() ends that obligation and lets read() collapse. + private preserveChunkBoundaries: boolean + + constructor(options?: { preserveChunkBoundaries?: boolean }) { + this.preserveChunkBoundaries = options?.preserveChunkBoundaries ?? true + } append(data: string): void { + if (data.length === 0) { + return + } if (data.length >= RECENT_PTY_OUTPUT_LIMIT) { this.chunks = [data.slice(-RECENT_PTY_OUTPUT_LIMIT)] this.headIndex = 0 this.headOffset = 0 this.totalLen = RECENT_PTY_OUTPUT_LIMIT - return - } - if (data.length === 0) { + this.headChunkIsPartial = data.length > RECENT_PTY_OUTPUT_LIMIT return } this.chunks.push(data) @@ -40,6 +53,7 @@ export class RecentPtyOutputBuffer { this.chunks[this.headIndex] = '' this.headIndex += 1 this.headOffset = 0 + this.headChunkIsPartial = false this.totalLen -= headRemaining } else { this.headOffset += excess @@ -53,6 +67,19 @@ export class RecentPtyOutputBuffer { } read(): string { + if (this.preserveChunkBoundaries) { + // Join without mutating: boundaries and the original head chunk are + // still owed to retainedChunks(); reads are rare before compact(). + if (this.chunks.length - this.headIndex > 1) { + const retained = this.chunks.slice(this.headIndex) + if (this.headOffset > 0) { + retained[0] = retained[0].slice(this.headOffset) + } + return retained.join('') + } + const head = this.chunks[this.headIndex] ?? '' + return this.headOffset > 0 ? head.slice(this.headOffset) : head + } if (this.chunks.length - this.headIndex > 1) { // Collapse to the joined tail so repeated reads stay O(1). const retained = this.chunks.slice(this.headIndex) @@ -69,4 +96,28 @@ export class RecentPtyOutputBuffer { } return this.chunks[this.headIndex] ?? '' } + + /** + * Retained chunks with original PTY boundaries. The head chunk is its full + * original text (any window-trimmed prefix included) unless + * headChunkIsPartial. Why: path-candidate backfill must replay the eager + * per-chunk extraction exactly — trimming or joining chunks changes the + * candidate set. Only meaningful before compact(). + */ + retainedChunks(): { chunks: string[]; headChunkIsPartial: boolean } { + return { + chunks: this.chunks.slice(this.headIndex), + headChunkIsPartial: this.headChunkIsPartial + } + } + + /** + * Ends the chunk-boundary obligation after the one-time backfill and + * collapses immediately, so the append/read hot path returns to the + * compact single-chunk steady state. + */ + compact(): void { + this.preserveChunkBoundaries = false + this.read() + } } diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 3a01789f157..061ff895a4a 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -56,6 +56,8 @@ export type RpcContext = { requestId?: string // Why: paired mobile device token; state-owning handlers use it to clean up when that device disconnects. clientId?: string + // Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id. + pairedDeviceId?: string // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' pairing?: PairingRpcContext diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index bf7e26fb461..5b677088074 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -102,6 +102,7 @@ export class RpcDispatcher { connectionId?: string signal?: AbortSignal clientId?: string + pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' pairing?: PairingRpcContext sendBinary?: (bytes: Uint8Array) => boolean | void @@ -136,6 +137,7 @@ export class RpcDispatcher { requestId: request.id, connectionId: options?.connectionId, clientId: options?.clientId, + pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, pairing: options?.pairing, sendBinary: options?.sendBinary, @@ -171,7 +173,9 @@ export class RpcDispatcher { requestId: request.id, connectionId: options?.connectionId, clientId: options?.clientId, + pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, + pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }, diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index d676f35e579..a622ea4f835 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -13,7 +13,11 @@ vi.mock('../../../ai-vault/session-scanner', () => ({ scanAiVaultSessions })) -import { AI_VAULT_METHODS, AiVaultListSessionsParams } from './ai-vault' +import { + AI_VAULT_METHODS, + AiVaultListSessionsParams, + AiVaultPrepareSessionResumeParams +} from './ai-vault' import { configureAiVaultSessionSources, listAiVaultSessions, @@ -106,6 +110,41 @@ describe('aiVault.listSessions params schema', () => { }) }) +describe('aiVault.prepareSessionResume', () => { + it('validates bounded paths and executes against the receiving host identity', async () => { + expect( + AiVaultPrepareSessionResumeParams.safeParse({ + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed' + }).success + ).toBe(true) + const prepareAiVaultSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + prepareAiVaultSessionResume + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('aiVault.prepareSessionResume', { + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'ssh:spoofed' + }) + ) + + expect(response).toMatchObject({ ok: true, result: { useRealCodexHome: true } }) + expect(prepareAiVaultSessionResume).toHaveBeenCalledWith({ + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'local' + }) + }) +}) + describe('aiVault.listSessions handler + shared cache', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 50ace9d222a..cd6c9f00cca 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -2,7 +2,8 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean } from '../schemas' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' -import { AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' +import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { parseExecutionHostId } from '../../../../shared/execution-host' // Why: bound limit + scopePaths so a client cannot force an unbounded scan. @@ -45,6 +46,13 @@ export const AiVaultListSessionsParams = z.object({ executionHostId: executionHostIdSchema.optional() }) +export const AiVaultPrepareSessionResumeParams = z.object({ + agent: z.enum(AI_VAULT_AGENTS), + filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), + codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), + executionHostId: z.string().optional() +}) + export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ name: 'aiVault.listSessions', @@ -61,5 +69,18 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ ? restampAiVaultListResult(result, params.executionHostId) : result } + }), + defineMethod({ + name: 'aiVault.prepareSessionResume', + params: AiVaultPrepareSessionResumeParams, + handler: (params, { runtime }) => + runtime.prepareAiVaultSessionResume({ + agent: params.agent, + filePath: params.filePath, + codexHome: params.codexHome, + // Why: the RPC executes on the transcript-owning host; never let a + // client-provided runtime/SSH stamp escape that host boundary. + executionHostId: LOCAL_EXECUTION_HOST_ID + }) }) ] diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index b5adcf617b4..74efa071a5f 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -111,7 +111,8 @@ const GitHubProjectRef = z .object({ owner: z.string(), ownerType: z.enum(['organization', 'user']), - number: z.number().int() + number: z.number().int(), + host: z.string().optional() }) .strict() const GitHubProjectSettings = z @@ -219,6 +220,7 @@ export const UiUpdate = z _grokStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), + statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 44efc0d1ae2..1cf3be1337b 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -32,7 +32,14 @@ describe('client UI RPC methods', () => { minimaxGroupId: 'group-42', minimaxUsageModels: 'general,abab6.5', githubProjects: { - pinned: [], + pinned: [ + { + owner: 'stablyai', + ownerType: 'organization' as const, + number: 1, + host: 'ghe.example:8443' + } + ], recent: [], lastViewByProject: {}, activeProject: null @@ -68,7 +75,12 @@ describe('client UI RPC methods', () => { lastViewByProject: { 'organization:stablyai:1': { viewId: 'view-1' } }, - activeProject: { owner: 'stablyai', ownerType: 'organization', number: 1 } + activeProject: { + owner: 'stablyai', + ownerType: 'organization' as const, + number: 1, + host: 'ghe.example:8443' + } } } const runtime = { diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 2135507c3e9..06cc04d917e 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -175,7 +175,7 @@ describe('github RPC methods', () => { repo: 'repo-1', prNumber: 7, headSha: 'abc123', - prRepo: { owner: 'acme', repo: 'widgets' }, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, noCache: true }) ) @@ -184,7 +184,7 @@ describe('github RPC methods', () => { 'repo-1', 7, 'abc123', - { owner: 'acme', repo: 'widgets' }, + { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, { noCache: true } @@ -235,6 +235,7 @@ describe('github RPC methods', () => { makeRequest('github.prFileContents', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, path: 'src/app.ts', status: 'modified', headSha: 'head', @@ -244,6 +245,7 @@ describe('github RPC methods', () => { expect(runtime.getRepoPRFileContents).toHaveBeenCalledWith('repo-1', { prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, path: 'src/app.ts', oldPath: undefined, status: 'modified', @@ -264,11 +266,16 @@ describe('github RPC methods', () => { makeRequest('github.resolveReviewThread', { repo: 'repo-1', threadId: 'PRRT_1', - resolve: true + resolve: true, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) - expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true) + expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true, { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) expect(response).toMatchObject({ ok: true, result: true }) }) @@ -282,6 +289,7 @@ describe('github RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('github.setPRFileViewed', { repo: 'repo-1', + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, pullRequestId: 'PR_kwDO123', path: 'src/app.ts', viewed: true @@ -289,6 +297,7 @@ describe('github RPC methods', () => { ) expect(runtime.setRepoPRFileViewed).toHaveBeenCalledWith('repo-1', { + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, pullRequestId: 'PR_kwDO123', path: 'src/app.ts', viewed: true @@ -406,19 +415,29 @@ describe('github RPC methods', () => { makeRequest('github.requestPRReviewers', { repo: 'repo-1', prNumber: 7, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) const removeResponse = await dispatcher.dispatch( makeRequest('github.removePRReviewers', { repo: 'repo-1', prNumber: 7, - reviewers: ['octo'] + reviewers: ['octo'], + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } }) ) - expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo']) - expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo']) + expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'], { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) + expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'], { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) expect(requestResponse).toMatchObject({ ok: true, result: { ok: true } }) expect(removeResponse).toMatchObject({ ok: true, result: { ok: true } }) }) @@ -434,11 +453,21 @@ describe('github RPC methods', () => { makeRequest('github.updatePRState', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, updates: { state: 'closed' } }) ) - expect(runtime.updateRepoPRState).toHaveBeenCalledWith('repo-1', 7, { state: 'closed' }) + expect(runtime.updateRepoPRState).toHaveBeenCalledWith( + 'repo-1', + 7, + { state: 'closed' }, + { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + } + ) expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) @@ -542,6 +571,7 @@ describe('github RPC methods', () => { makeRequest('github.addPRReviewComment', { repo: 'repo-1', prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, commitId: 'head', path: 'src/app.ts', line: 12, @@ -552,6 +582,7 @@ describe('github RPC methods', () => { expect(runtime.addRepoPRReviewComment).toHaveBeenCalledWith('repo-1', { prNumber: 7, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }, commitId: 'head', path: 'src/app.ts', line: 12, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 44375181da0..858d22b72bb 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -26,6 +26,9 @@ const WorkItem = RepoSelector.extend({ const WorkItemByOwnerRepo = RepoSelector.extend({ owner: requiredString('Missing owner'), ownerRepo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), type: z.enum(['issue', 'pr']) }) @@ -42,7 +45,10 @@ const RateLimit = z.object({ const SlugRepo = z.object({ owner: requiredString('Missing owner'), - repo: requiredString('Missing repo') + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString }) const SlugAssignableUsers = SlugRepo.extend({ @@ -86,6 +92,7 @@ const RerunPullRequestChecks = PullRequest.extend({ const PullRequestFileContents = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), path: requiredString('Missing file path'), oldPath: OptionalString, status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), @@ -94,12 +101,14 @@ const PullRequestFileContents = RepoSelector.extend({ }) const PullRequestFileViewed = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), pullRequestId: requiredString('Missing pull request ID'), path: requiredString('Missing file path'), viewed: z.boolean() }) const ReviewThread = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), threadId: requiredString('Missing thread ID'), resolve: z.boolean() }) @@ -134,6 +143,7 @@ const SetPrAutoMerge = RepoSelector.extend({ const UpdatePrState = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), updates: z.object({ state: z.enum(['open', 'closed']) }) @@ -141,11 +151,13 @@ const UpdatePrState = RepoSelector.extend({ const RequestPrReviewers = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), reviewers: z.array(z.string()).min(1) }) const RemovePrReviewers = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), reviewers: z.array(z.string()).min(1) }) @@ -180,6 +192,7 @@ const IssueComment = RepoSelector.extend({ const PRReviewComment = RepoSelector.extend({ prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), commitId: requiredString('Missing PR head SHA'), path: requiredString('File path required'), line: z.number().int().positive(), @@ -201,6 +214,9 @@ const ProjectOwnerType = z.enum(['organization', 'user']) const ProjectViewTable = z.object({ owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, ownerType: ProjectOwnerType, projectNumber: z.number().int().positive(), viewId: OptionalString, @@ -215,17 +231,26 @@ const ProjectWorkItemDetailsBySlug = SlugRepo.extend({ }) const ProjectRef = z.object({ - input: requiredString('Missing project reference') + input: requiredString('Missing project reference'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString }) const ProjectViews = z.object({ owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, ownerType: ProjectOwnerType, projectNumber: z.number().int().positive() }) const ProjectItemField = z.object({ projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, itemId: requiredString('Missing item ID'), fieldId: requiredString('Missing field ID'), value: z.any() @@ -233,6 +258,9 @@ const ProjectItemField = z.object({ const ClearProjectItemField = z.object({ projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, itemId: requiredString('Missing item ID'), fieldId: requiredString('Missing field ID') }) @@ -240,6 +268,9 @@ const ClearProjectItemField = z.object({ const SlugIssueUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), updates: IssueUpdate }) @@ -247,6 +278,9 @@ const SlugIssueUpdate = z.object({ const SlugPullRequestUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), updates: z.object({ state: z.enum(['open', 'closed']).optional(), @@ -258,6 +292,9 @@ const SlugPullRequestUpdate = z.object({ const SlugIssueTypeUpdate = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), issueTypeId: z.string().nullable() }) @@ -265,6 +302,9 @@ const SlugIssueTypeUpdate = z.object({ const SlugIssueComment = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, number: z.number().int().positive(), body: requiredString('Comment body required') }) @@ -272,6 +312,9 @@ const SlugIssueComment = z.object({ const SlugIssueCommentEdit = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, commentId: z.number().int().positive(), body: requiredString('Comment body required') }) @@ -279,6 +322,9 @@ const SlugIssueCommentEdit = z.object({ const SlugIssueCommentDelete = z.object({ owner: requiredString('Missing owner'), repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, commentId: z.number().int().positive() }) @@ -342,7 +388,11 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getRepoWorkItemByOwnerRepo( params.repo, - { owner: params.owner, repo: params.ownerRepo }, + { + owner: params.owner, + repo: params.ownerRepo, + ...(params.host ? { host: params.host } : {}) + }, params.number, params.type ) @@ -397,7 +447,8 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.rerunRepoPRChecks(params.repo, params.prNumber, { headSha: params.headSha, - failedOnly: params.failedOnly + failedOnly: params.failedOnly, + prRepo: params.prRepo ?? null }) }), defineMethod({ @@ -414,6 +465,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getRepoPRFileContents(params.repo, { prNumber: params.prNumber, + prRepo: params.prRepo ?? null, path: params.path, oldPath: params.oldPath, status: params.status, @@ -425,13 +477,19 @@ export const GITHUB_METHODS: RpcMethod[] = [ name: 'github.resolveReviewThread', params: ReviewThread, handler: async (params, { runtime }) => - runtime.resolveRepoReviewThread(params.repo, params.threadId, params.resolve) + runtime.resolveRepoReviewThread( + params.repo, + params.threadId, + params.resolve, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.setPRFileViewed', params: PullRequestFileViewed, handler: async (params, { runtime }) => runtime.setRepoPRFileViewed(params.repo, { + prRepo: params.prRepo ?? null, pullRequestId: params.pullRequestId, path: params.path, viewed: params.viewed @@ -476,19 +534,29 @@ export const GITHUB_METHODS: RpcMethod[] = [ name: 'github.updatePRState', params: UpdatePrState, handler: async (params, { runtime }) => - runtime.updateRepoPRState(params.repo, params.prNumber, params.updates) + runtime.updateRepoPRState(params.repo, params.prNumber, params.updates, params.prRepo ?? null) }), defineMethod({ name: 'github.requestPRReviewers', params: RequestPrReviewers, handler: async (params, { runtime }) => - runtime.requestRepoPRReviewers(params.repo, params.prNumber, params.reviewers) + runtime.requestRepoPRReviewers( + params.repo, + params.prNumber, + params.reviewers, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.removePRReviewers', params: RemovePrReviewers, handler: async (params, { runtime }) => - runtime.removeRepoPRReviewers(params.repo, params.prNumber, params.reviewers) + runtime.removeRepoPRReviewers( + params.repo, + params.prNumber, + params.reviewers, + params.prRepo ?? null + ) }), defineMethod({ name: 'github.createIssue', @@ -521,6 +589,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.addRepoPRReviewComment(params.repo, { prNumber: params.prNumber, + prRepo: params.prRepo ?? null, commitId: params.commitId, path: params.path, line: params.line, @@ -544,8 +613,8 @@ export const GITHUB_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'github.project.listAccessible', - params: z.object({}), - handler: async (_params, { runtime }) => runtime.listGitHubProjects() + params: z.object({ host: OptionalString }), + handler: async (params, { runtime }) => runtime.listGitHubProjects(params) }), defineMethod({ name: 'github.project.listLabelsBySlug', diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts index 2af6206f0e1..0df074cbce3 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts @@ -1,8 +1,19 @@ import { describe, expect, it } from 'vitest' -import { UpdatePaneLayout } from './session-tabs-schemas' +import { ActivateTab, UpdatePaneLayout } from './session-tabs-schemas' const WT = 'id:wt' +describe('ActivateTab.navigation', () => { + it('accepts declared targets and rejects unknown fanout', () => { + expect(ActivateTab.parse({ worktree: WT, tabId: 'tab', navigation: 'all' }).navigation).toBe( + 'all' + ) + expect( + ActivateTab.safeParse({ worktree: WT, tabId: 'tab', navigation: 'others' }).success + ).toBe(false) + }) +}) + describe('UpdatePaneLayout.root (untrusted remote pane-layout tree)', () => { it('accepts a valid split tree', () => { const parsed = UpdatePaneLayout.parse({ diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 7f984f29b74..3d9f678695b 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -3,6 +3,7 @@ import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/termin import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' +import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' import { OptionalBoolean } from '../schemas' export const WorktreeTabSelector = z.object({ @@ -22,7 +23,8 @@ export const ActivateTab = WorktreeTabSelector.extend({ .transform((v) => (typeof v === 'string' ? v : '')) .pipe(z.string().min(1, 'Missing tab id')), leafId: z.string().max(128).optional(), - notifyClients: OptionalBoolean + notifyClients: OptionalBoolean, + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() }) export type TerminalPaneLayoutNodeInput = @@ -141,6 +143,8 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ .optional(), viewMode: z.enum(['terminal', 'chat']).optional(), activate: z.boolean().optional(), + select: z.boolean().optional(), + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), // Why: idempotency key so a retried create (double-tap, reconnect replay) // returns the in-flight operation instead of spawning a duplicate terminal. clientMutationId: z.string().min(1).max(128).optional() diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 452680931ad..2fe42fcdc36 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -35,7 +35,35 @@ describe('session tab RPC methods', () => { expect(response.ok).toBe(true) expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', 'leaf-1', { - notifyClients: false + notifyClients: false, + clientNavigationId: undefined, + navigation: 'caller' + }) + }) + + it('defaults legacy paired activation to the authenticated caller identity', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.activate', { + worktree: 'id:wt-1', + tabId: 'tab-1', + notifyClients: true + }), + (response) => replies.push(response), + { clientKind: 'runtime', pairedDeviceId: 'device-a' } + ) + + expect(replies).toHaveLength(1) + expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', undefined, { + notifyClients: true, + clientNavigationId: 'device-a', + navigation: 'caller' }) }) @@ -171,10 +199,45 @@ describe('session tab RPC methods', () => { }, launchAgent: 'codex', viewMode: 'chat', - activate: true + activate: true, + select: undefined, + clientNavigationId: undefined, + navigation: 'all' }) }) + it('defaults legacy paired terminal creation to caller-owned selection', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createMobileSessionTerminal: vi.fn().mockResolvedValue({ + tab: { type: 'terminal', id: 'tab-1::leaf-1' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.createTerminal', { + worktree: 'id:wt-1', + activate: true, + clientMutationId: 'create-1' + }), + () => {}, + { clientKind: 'runtime', pairedDeviceId: 'device-a' } + ) + + expect(runtime.createMobileSessionTerminal).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ + activate: true, + clientMutationId: 'create-1', + clientNavigationId: 'device-a', + navigation: 'caller' + }) + ) + }) + it('dispatches terminal creation with a requested agent preset', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -211,7 +274,10 @@ describe('session tab RPC methods', () => { startupCommandDelivery: undefined, agent: 'codex', agentPrompt: 'Review this diff', - activate: undefined + activate: undefined, + select: undefined, + clientNavigationId: undefined, + navigation: 'all' }) }) @@ -271,7 +337,10 @@ describe('session tab RPC methods', () => { command: "codex 'linked issue context'", startupCommandDelivery: 'shell-ready', agent: undefined, - activate: undefined + activate: undefined, + select: undefined, + clientNavigationId: undefined, + navigation: 'all' }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 2f986f4d3ef..364f1b39301 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { ActivateTab, @@ -15,21 +16,28 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime }) => runtime.listMobileSessionTabs(params.worktree) + handler: async (params, { runtime, pairedDeviceId }) => + runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, { runtime }) => ({ - snapshots: await runtime.listAllMobileSessionTabs() + handler: async (_params, { runtime, pairedDeviceId }) => ({ + snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) }) }), defineMethod({ name: 'session.tabs.activate', params: ActivateTab, - handler: async (params, { runtime }) => + handler: async (params, { runtime, clientKind, pairedDeviceId }) => runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId, { - notifyClients: params.notifyClients !== false + notifyClients: params.notifyClients !== false, + clientNavigationId: pairedDeviceId, + navigation: resolveRuntimeNavigationTarget({ + navigation: params.navigation, + notifyClients: params.notifyClients, + clientKind + }) }) }), defineMethod({ @@ -41,7 +49,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, - handler: async (params, { runtime, signal }) => + handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => runtime.createMobileSessionTerminal(params.worktree, { afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, @@ -57,6 +65,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, + select: params.select, + clientNavigationId: pairedDeviceId, + navigation: resolveRuntimeNavigationTarget({ + navigation: params.navigation, + clientKind + }), clientMutationId: params.clientMutationId, // Why: a dead client connection must cancel the surface wait instead // of running down the timeout and rolling back a live tab (#7718). @@ -117,12 +131,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribe', params: WorktreeTabSelector, - handler: async (params, { runtime, connectionId, requestId }, emit) => { + handler: async (params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { let subscribedWorktree: string | null = null let unsubscribe = (): void => {} let closed = false let initialized = false - const initial = await runtime.listMobileSessionTabs(params.worktree) + const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) if (closed) { return } @@ -155,7 +169,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (snapshot.worktree === subscribedWorktree) { emit({ type: 'updated', ...snapshot }) } - }) + }, pairedDeviceId) if (closed) { unsubscribe() } @@ -164,8 +178,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.unsubscribe', params: SessionTabsUnsubscribe, - handler: async (params, { runtime, connectionId }) => { - const snapshot = await runtime.listMobileSessionTabs(params.worktree) + handler: async (params, { runtime, connectionId, pairedDeviceId }) => { + const snapshot = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) const connection = connectionId ?? 'local' if (params.subscriptionId) { runtime.cleanupSubscription( @@ -182,7 +196,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, { runtime, connectionId, requestId }, emit) => { + handler: async (_params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { let unsubscribe = (): void => {} let closed = false // Why: initial listAll errors should return one RPC error, not a leaked @@ -207,7 +221,9 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (closed) { return } - const snapshots = await Promise.resolve(runtime.listAllMobileSessionTabs()).catch((error) => { + const snapshots = await Promise.resolve( + runtime.listAllMobileSessionTabs(pairedDeviceId) + ).catch((error) => { runtime.cleanupSubscription(subscriptionId) throw error }) @@ -222,7 +238,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ } unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { emit({ type: 'updated', ...snapshot }) - }) + }, pairedDeviceId) } }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal-navigation.test.ts b/src/main/runtime/rpc/methods/terminal-navigation.test.ts new file mode 100644 index 00000000000..b0aa29828c6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-navigation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { TERMINAL_METHODS } from './terminal' + +function request(params: unknown): RpcRequest { + return { + id: 'focus-1', + authToken: 'token', + method: 'terminal.focus', + params + } +} + +describe('terminal focus navigation authority', () => { + it('denies implicit paired focus while preserving local and explicit host focus', async () => { + const runtime = { + getRuntimeId: () => 'runtime-1', + focusTerminal: vi.fn().mockResolvedValue({ + handle: 'term-1', + tabId: 'tab-1', + worktreeId: 'wt-1' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming(request({ terminal: 'term-1' }), () => {}, { + clientKind: 'mobile', + pairedDeviceId: 'device-a' + }) + await dispatcher.dispatchStreaming( + request({ terminal: 'term-1', navigation: 'host' }), + () => {}, + { clientKind: 'runtime', pairedDeviceId: 'device-b' } + ) + await dispatcher.dispatchStreaming(request({ terminal: 'term-1' }), () => {}, { + clientKind: 'runtime', + pairedDeviceId: 'device-b' + }) + await dispatcher.dispatch(request({ terminal: 'term-1' })) + + expect(runtime.focusTerminal).toHaveBeenNthCalledWith(1, 'term-1', { + navigateHost: false + }) + expect(runtime.focusTerminal).toHaveBeenNthCalledWith(2, 'term-1', { + navigateHost: true + }) + expect(runtime.focusTerminal).toHaveBeenNthCalledWith(3, 'term-1', { + navigateHost: false + }) + expect(runtime.focusTerminal).toHaveBeenNthCalledWith(4, 'term-1', { + navigateHost: true + }) + }) + + it('rejects client fanout targets that terminal focus cannot honor', async () => { + const runtime = { + getRuntimeId: () => 'runtime-1', + focusTerminal: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + request({ terminal: 'term-1', navigation: 'clients' }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.focusTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4be8d98c94d..48458ea0d07 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -38,6 +38,10 @@ import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from '../../scrollback-limits' import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' +import { + navigationTargetsHost, + resolveRuntimeNavigationTarget +} from '../../../../shared/runtime-navigation' const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 @@ -820,6 +824,10 @@ const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) +const TerminalFocus = TerminalHandle.extend({ + navigation: z.enum(['caller', 'host']).optional() +}) + const TerminalListParams = z.object({ worktree: OptionalString, limit: OptionalFiniteNumber, @@ -913,8 +921,21 @@ const TerminalCreateParams = z.object({ agentEnv: z.record(z.string(), z.string()) }) .optional(), + resumeProviderSession: z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + .optional(), launchToken: OptionalString, launchAgent: z.string().refine(isTuiAgent).optional(), + terminalColorQueryReplies: z + .object({ + foreground: z.string().max(128).optional(), + background: z.string().max(128).optional() + }) + .optional(), title: OptionalString, focus: z.unknown().optional(), rendererBacked: z.unknown().optional(), @@ -1348,8 +1369,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ env: params.env, envToDelete: params.envToDelete, ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), title: params.title, focus: params.focus === true, rendererBacked: params.rendererBacked === true, @@ -1412,9 +1439,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }), defineMethod({ name: 'terminal.focus', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - focus: await runtime.focusTerminal(params.terminal) + params: TerminalFocus, + handler: async (params, { runtime, clientKind }) => ({ + focus: await runtime.focusTerminal(params.terminal, { + navigateHost: navigationTargetsHost( + resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) + ) + }) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/worktree-schemas.test.ts b/src/main/runtime/rpc/methods/worktree-schemas.test.ts index 4f42e5fc4ce..975693bb7f7 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.test.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from 'vitest' -import { WorktreeCreate } from './worktree-schemas' +import { WorktreeActivate, WorktreeCreate } from './worktree-schemas' describe('worktree RPC schemas', () => { + it('validates additive navigation intent', () => { + expect(WorktreeActivate.parse({ worktree: 'id:wt-1', navigation: 'clients' }).navigation).toBe( + 'clients' + ) + expect( + WorktreeActivate.safeParse({ worktree: 'id:wt-1', navigation: 'everyone' }).success + ).toBe(false) + }) + it('rejects invalid startup agent values', () => { const parsed = WorktreeCreate.safeParse({ repo: 'repo-1', diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index e271432e5f3..45848c92db9 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -3,6 +3,7 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { workspaceSourceSchema } from '../../../../shared/telemetry-events' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' +import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' import { OptionalBoolean, OptionalFiniteNumber, @@ -56,7 +57,8 @@ export const WorktreeSelector = z.object({ }) export const WorktreeActivate = WorktreeSelector.extend({ - notifyClients: OptionalBoolean + notifyClients: OptionalBoolean, + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() }) export const WorktreeCreate = z diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index ef96f0e1688..10271681dc4 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -45,7 +45,8 @@ describe('worktree RPC methods', () => { expect(response).toMatchObject({ ok: true }) expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { notifyClients: false, - clientKind: undefined + clientKind: undefined, + navigation: 'caller' }) }) @@ -70,7 +71,8 @@ describe('worktree RPC methods', () => { expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { notifyClients: false, - clientKind: 'mobile' + clientKind: 'mobile', + navigation: 'caller' }) }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 4d3382f635d..05e7b0a08a3 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -4,6 +4,7 @@ import { resolveAutomationWorkspaceProvenance } from '../../../automations/workspace-provenance' import { defineMethod, type RpcMethod } from '../core' +import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { WorktreeCreate, WorktreeDetectedListParams, @@ -64,7 +65,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ // wake to phones so web/desktop activation behavior is unchanged. runtime.activateManagedWorktree(params.worktree, { notifyClients: params.notifyClients !== false, - clientKind + clientKind, + navigation: resolveRuntimeNavigationTarget({ + navigation: params.navigation, + notifyClients: params.notifyClients, + clientKind + }) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 77deacc420c..382353de803 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -21,6 +21,30 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('RpcDispatcher streaming', () => { + it('passes pairing authority to streaming handlers', async () => { + const pairing = { + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + } + let receivedPairing: unknown + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.pairing-stream', + params: null, + handler: async (_params, ctx) => { + receivedPairing = ctx.pairing + } + }) + ] + }) + + await dispatcher.dispatchStreaming(makeRequest('test.pairing-stream'), () => {}, { pairing }) + + expect(receivedPairing).toBe(pairing) + }) + it('sends initial scrollback via emit', async () => { const messages: string[] = [] const dispatcher = new RpcDispatcher({ diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 7744bbe67eb..54b775b3178 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: this integration-style RPC test keeps the request/response contract together so regressions in the external CLI surface are easier to spot. */ import { existsSync, mkdtempSync } from 'node:fs' +import { rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createConnection, type Socket } from 'node:net' @@ -2361,7 +2362,8 @@ describe('OrcaRuntimeRpcServer', () => { path: 'src/app.ts', line: 10, startLine: undefined, - body: 'please fix' + body: 'please fix', + prRepo: null }) expect(addRepoPRReviewCommentReply).toHaveBeenCalledWith('id:repo-1', { prNumber: 456, @@ -2378,19 +2380,22 @@ describe('OrcaRuntimeRpcServer', () => { oldPath: undefined, status: 'modified', headSha: 'abc123', - baseSha: 'def456' + baseSha: 'def456', + prRepo: null }) expect(rerunRepoPRChecks).toHaveBeenCalledWith('id:repo-1', 456, { headSha: 'abc123', - failedOnly: true + failedOnly: true, + prRepo: null }) - expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true) + expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true, null) expect(setRepoPRFileViewed).toHaveBeenCalledWith('id:repo-1', { pullRequestId: 'PR_kw', path: 'src/app.ts', - viewed: true + viewed: true, + prRepo: null }) - expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex']) + expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex'], null) expect(mergeRepoPR).toHaveBeenCalledWith('id:repo-1', 456, 'squash', null) expect(addGitLabRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', undefined) expect(addGitLabRepoMRComment).toHaveBeenCalledWith('id:repo-1', 456, 'ship it', undefined) @@ -3041,6 +3046,7 @@ describe('OrcaRuntimeRpcServer', () => { params: { worktree: 'id:repo-1::/tmp/worktree-a', command: "claude 'work on the issue'", + terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' }, tabId: 'laptop-tab', leafId, presentation: 'background' @@ -3060,6 +3066,11 @@ describe('OrcaRuntimeRpcServer', () => { expect( (createResponse.result as { terminal?: { warning?: string } } | undefined)?.terminal?.warning ).toBeUndefined() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' } + }) + ) runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 456) runtime.onPtyData('laptop-created-pty', 'Claude is working...\r\n', 456) @@ -3263,6 +3274,162 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('authorizes a mobile artifact tap after first-connect backfill even once the raw window scrolls', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getCwd: async () => '/tmp/worktree-a', + getForegroundProcess: async () => null + }) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + // Real artifact under the temp root so the grant path stats it. + const artifactPath = join(tmpdir(), `orca-artifact-${process.pid}-${Date.now()}.json`) + await writeFile(artifactPath, '{"ok":true}') + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Agent', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + // Path printed before any mobile client exists: tracking is inactive, so + // only the retained raw window knows it at connect time. + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + + await server.start() + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'phone', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + // Full direct E2EE authentication drives MobileSocketWiring.onReady (the + // relay transport attaches through the same wiring), which must backfill + // candidates from the raw window without any direct activation call. + const phone = await authenticateMobileWsSession(offer.pairingUrl) + const phoneResponses = createEncryptedWsResponseReader(phone) + try { + // Post-connect pathless output scrolls the artifact out of the raw + // 64KiB window; only the connect-time backfilled candidate can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + sendEncryptedWsRequest(phone, { + id: 'phone_terminals', + method: 'terminal.list', + params: { worktree: 'id:repo-1::/tmp/worktree-a' } + }) + const listResponse = await phoneResponses.next('phone_terminals') + const handle = (listResponse.result as { terminals: { handle: string }[] }).terminals[0]! + .handle + expect(handle).toBeTruthy() + + sendEncryptedWsRequest(phone, { + id: 'phone_tap', + method: 'files.resolveTerminalPath', + params: { + worktree: 'id:repo-1::/tmp/worktree-a', + pathText: artifactPath, + terminal: handle + } + }) + await expect(phoneResponses.next('phone_tap')).resolves.toMatchObject({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + grantId: expect.any(String) + } + } + }) + } finally { + phoneResponses.dispose() + phone.ws.close() + await server.stop() + await rm(artifactPath, { force: true }) + } + }) + + it('completes remote E2EE authentication against a runtime proxy without activateRecentPtyPathCandidateTracking', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: a remote-host runtime proxy only implements RPC-forwarded methods; + // activation is a local-host concern, so the proxy legitimately lacks + // activateRecentPtyPathCandidateTracking and onReady must not throw. + const runtimeProxy = { + getRuntimeId: () => 'proxy-runtime-test', + getStartedAt: () => 1, + getStatus: () => ({ graphStatus: 'unavailable' }), + cleanupSubscriptionsForConnection: () => {}, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {} + } as unknown as OrcaRuntimeService + expect( + (runtimeProxy as { activateRecentPtyPathCandidateTracking?: unknown }) + .activateRecentPtyPathCandidateTracking + ).toBeUndefined() + const server = new OrcaRuntimeRpcServer({ + runtime: runtimeProxy, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'remote', + scope: 'runtime' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + // Real E2EE pairing + authentication drives MobileSocketWiring.onReady + // before e2ee_authenticated is sent; a throwing onReady never authenticates. + const session = await authenticateMobileWsSession(offer.pairingUrl) + const responses = createEncryptedWsResponseReader(session) + try { + sendEncryptedWsRequest(session, { id: 'proxy_status', method: 'status.get' }) + await expect(responses.next('proxy_status')).resolves.toMatchObject({ + id: 'proxy_status', + ok: true, + result: { graphStatus: 'unavailable' } + }) + } finally { + responses.dispose() + session.ws.close() + await server.stop() + } + }) + it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const writes: { terminal: string; text: string }[] = [] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 21dde39f93d..020eec5183c 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -146,6 +146,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.subscribe', 'accounts.unsubscribe', 'aiVault.listSessions', + 'aiVault.prepareSessionResume', 'browser.back', 'browser.dialogAccept', 'browser.dialogDismiss', @@ -539,6 +540,7 @@ export class OrcaRuntimeRpcServer { return false } this.mobileRelayPairingProvider?.onDemandStateChanged?.() + this.runtime.forgetClientNavigationState(deviceId) this.mobileSocketWiring?.terminateDeviceConnections(device.token) return true } @@ -548,6 +550,7 @@ export class OrcaRuntimeRpcServer { if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { return false } + this.runtime.forgetClientNavigationState(deviceId) this.mobileSocketWiring?.terminateDeviceConnections(device.token) return true } @@ -848,7 +851,15 @@ export class OrcaRuntimeRpcServer { ) }, onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), - onReady: () => this.mobileRelayPairingProvider?.onDemandStateChanged?.(), + onReady: () => { + // Why: first authenticated mobile/remote client (direct WS and + // cloud relay both attach here) starts path-candidate tracking. + // Activation is a local-host concern: candidate buffers live on the + // buffer-owning host's runtime, so a remote runtime proxy may + // legitimately lack this method (its own server activates it). + this.runtime.activateRecentPtyPathCandidateTracking?.() + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + }, onClose: (socket, hasOtherConnections) => { if (!socket) { return @@ -1093,6 +1104,7 @@ export class OrcaRuntimeRpcServer { await this.dispatcher.dispatchStreaming(request, replyForRequest, { connectionId, clientId: token, + pairedDeviceId: device.deviceId, // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, pairing: pairingContext, diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index 896fa59b060..cdcd10dfbc3 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -247,6 +247,21 @@ describe('reply ownership matrix', () => { expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false) }) + it('keeps raw preview presence separate from terminal query authority', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-preview') + const release = runtime.registerRawTerminalViewSubscriber('pty-preview') + + expect(runtime.hasRawTerminalViewSubscriber('pty-preview')).toBe(true) + expect(runtime.hasRemoteTerminalViewSubscriber('pty-preview')).toBe(false) + runtime.onPtyData('pty-preview', DA1, Date.now()) + await settle(runtime, 'pty-preview') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + + release() + expect(runtime.hasRawTerminalViewSubscriber('pty-preview')).toBe(false) + }) + it('treats mobile subscriber records as remote view subscribers', async () => { const { runtime } = createResponderRuntime() await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 }) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts index 2743dd70e3b..be33324d81e 100644 --- a/src/main/runtime/terminal-view-attribute-store.ts +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -13,6 +13,7 @@ import { terminalViewAttributesEqual, type TerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' // Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives // the push, the runtime emulators consult it at reply time via the getter. @@ -50,6 +51,20 @@ export function getTerminalViewAttributes(): TerminalViewAttributes | null { return currentAttributes } +function rgbToCssHex(rgb: readonly [number, number, number]): string { + return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}` +} + +export function getTerminalViewColorQueryReplyColors(): TerminalOscColorQueryReplyColors | null { + if (!currentAttributes) { + return null + } + return { + foreground: rgbToCssHex(currentAttributes.foreground), + background: rgbToCssHex(currentAttributes.background) + } +} + /** Test seam: reset module state between tests. */ export function _resetTerminalViewAttributesForTest(): void { currentAttributes = null diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 827ec95d92d..1025c6be020 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -567,6 +567,25 @@ describe('killAllProcessesForWorktree', () => { expect(result.runtimeStopped).toBe(0) }) + it('tolerates an unresolved runtime selector during destructive removal', async () => { + // Why: a just-created/removed worktree can be absent from the runtime + // graph; that means zero runtime-owned PTYs, not a failed teardown. + const stopTerminalsForWorktree = vi.fn().mockRejectedValue(new Error('selector_not_found')) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + const localProvider = createProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([]) + + const result = await killAllProcessesForWorktree('w1', { + runtime, + localProvider, + requirePhysicalStop: true + }) + + expect(result.runtimeStopped).toBe(0) + }) + it('fails destructive teardown closed when the runtime sweep rejects', async () => { const stopTerminalsForWorktree = vi.fn().mockRejectedValue(new Error('runtime sweep failed')) const runtime = { diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 77ebaa8737a..8dfe63a9f7e 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -93,15 +93,21 @@ export async function killAllProcessesForWorktree( return current } - // Why: headless CLI has no ready renderer graph; only that known sentinel - // may fall through to the provider and registry physical-owner sweeps. + // Why: headless CLI has no ready renderer graph, and a just-created/removed + // worktree may not resolve in the graph yet; either case means zero + // runtime-owned PTYs, so both sentinels fall through instead of failing + // destructive removal closed. const runtimeSweep = deps.runtime ? settleBeforeDeadline( () => deps.runtime!.stopTerminalsForWorktree(worktreeId, { deadline, stopPty }), { stopped: 0 }, deadline, deps.requirePhysicalStop ? deadlineError : undefined, - (error) => !(error instanceof Error && error.message === 'runtime_unavailable') + (error) => + !( + error instanceof Error && + (error.message === 'runtime_unavailable' || error.message === 'selector_not_found') + ) ) : Promise.resolve({ stopped: 0 }) const providerSweep = settleBeforeDeadline( diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index 95c5c9d2676..884b72f90de 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -81,6 +81,14 @@ import { getForgeProviderForRepository } from './forge-provider' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + describe('forge provider interface', () => { beforeEach(() => { createGitHubPullRequestMock.mockReset() @@ -113,8 +121,9 @@ describe('forge provider interface', () => { // slug parsing returns null. Detection must claim it via the enterprise // resolver instead of falling through to Gitea's demand for ORCA_GITEA_TOKEN. getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) - getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ owner: 'team', repo: 'orca', host: 'github.acme-corp.com' diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index 1a6ea4a4368..d4967486ca0 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -22,7 +22,6 @@ import { } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' -import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { getMergeRequest, getMergeRequestForBranchOrThrow, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { @@ -126,25 +125,11 @@ function unwrapGitHubPRForBranchOutcome( const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, - resolveRepository: async (context) => { - const slug = await getRepoSlug( - context.repoPath, - context.connectionId, - ...hostedReviewExecutionArgs(context) - ) - if (slug) { - return slug - } - // Why: GHES remotes live on a custom host, so github.com-only slug parsing - // misses them and detection would otherwise fall through to Gitea (#8312). - // Claim the repo when gh is authenticated to its host — the same signal - // GitLab uses for self-hosted instances. - return getEnterpriseGitHubRepoSlug( - context.repoPath, - context.connectionId, - ...hostedReviewExecutionArgs(context) - ) - }, + // Why: getRepoSlug resolves hosted identities — GHES remotes are claimed when + // gh is authenticated to their host (the same signal GitLab uses for + // self-hosted instances), so detection never falls through to Gitea (#8312). + resolveRepository: async (context) => + getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index 484ede6cbff..f2fa711ee54 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -117,6 +117,14 @@ vi.mock('./hosted-review', () => ({ import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function resetMocks(): void { for (const mock of [ createGitHubPullRequestMock, @@ -155,10 +163,17 @@ function mockGitHubProvider(): void { // resolver claims the repo and reports the host for the gh auth probe (#8312). function mockGitHubEnterpriseProvider(): void { getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + // The auth gate still keys off the enterprise resolver (authed-GHES signal). getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca', diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 04c751d7c3b..cb593ae6a00 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -117,6 +117,14 @@ vi.mock('./hosted-review', () => ({ import { createHostedReview } from './hosted-review-creation' +import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository' + +// The origin-repository cache is module-level state; reset it so slugs +// resolved by one test cannot leak into the next. +beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() +}) + function resetMocks(): void { for (const mock of [ createGitHubPullRequestMock, @@ -155,10 +163,17 @@ function mockGitHubProvider(): void { // resolver claims the repo and reports the host for the gh auth probe (#8312). function mockGitHubEnterpriseProvider(): void { getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue(null) + // Why: getRepoSlug resolves hosted identities itself now — a GHES remote + // comes back host-qualified instead of null + separate enterprise fallback. + getRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + // The auth gate still keys off the enterprise resolver (authed-GHES signal). getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca', @@ -393,7 +408,7 @@ describe('createHostedReview', () => { ) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['auth', 'status', '--hostname', 'github.com'], - { cwd: '/repo', wslDistro: 'Ubuntu' } + { cwd: '/repo', wslDistro: 'Ubuntu', host: 'github.com' } ) expect(createGitHubPullRequestMock).toHaveBeenCalledWith( '/repo', @@ -578,7 +593,7 @@ describe('createHostedReview', () => { expect(getUpstreamStatusMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['auth', 'status', '--hostname', 'github.com'], - {} + { host: 'github.com' } ) expect(createGitHubPullRequestMock).toHaveBeenCalledWith( '/remote/repo', diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 9d95f6ddf02..618ef9bb684 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -68,9 +68,13 @@ async function isGitHubAuthenticated( } await acquire() try { + // Why: `host` scopes any rate-limit breaker trip to github.com — the host + // this probe actually targets — instead of a GH_HOST-derived scope. await ghExecFileAsync( ['auth', 'status', '--hostname', 'github.com'], - connectionId ? {} : { cwd: repoPath, ...getHostedReviewLocalGitOptions(options) } + connectionId + ? { host: 'github.com' } + : { cwd: repoPath, ...getHostedReviewLocalGitOptions(options), host: 'github.com' } ) return true } catch { diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 2d1a1a446fc..f2154d1eb13 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -503,8 +503,10 @@ async function writeRemoteFile( } } +const NODE_PTY_VERSION = '1.1.0' +const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' const RELAY_NATIVE_DEPS = { - 'node-pty': '1.1.0', + 'node-pty': NODE_PTY_VERSION, '@parcel/watcher': '2.5.6' } as const @@ -520,7 +522,8 @@ const RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST = Object.fromEntries( function nativeDepsProbeJs(successToken: string): string { // Why: node-pty's Windows wrapper defers conpty.node until first spawn, so require("node-pty") alone can't prove the binding is healthy. const loadNodePty = - 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty")' + 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty");' + + `if(process.platform==="win32"){require("./${NODE_PTY_CONSOLE_LIST_PATCH_FILENAME}").assertPatchedNodePtyConsoleListAgent(process.cwd())}` return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` } @@ -723,7 +726,9 @@ async function installNativeDeps( RELAY_NATIVE_DEPS ) .map(([dep, version]) => powerShellLiteral(`${dep}@${version}`)) - .join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` + .join( + ' ' + )}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ${windowsNodePtyPatchCommand(nodePath)}` ) : commandWithNodePath( hostPlatform, @@ -840,7 +845,7 @@ async function rebuildNativeDeps( hostPlatform, nodePath, remoteDir, - `npm rebuild --ignore-scripts=false ${depNames.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` + `npm rebuild --ignore-scripts=false ${depNames.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ${windowsNodePtyPatchCommand(nodePath)}` ) : commandWithNodePath( hostPlatform, @@ -854,6 +859,11 @@ async function rebuildNativeDeps( }) } +function windowsNodePtyPatchCommand(nodePath: string): string { + // Why: pnpm patches do not cross the SSH boundary; apply the version-checked fallback to the remote npm package. + return `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` +} + async function makeNodePtySpawnHelperExecutable( conn: SshConnection, remoteDir: string, diff --git a/src/main/stats/collector-async-save.test.ts b/src/main/stats/collector-async-save.test.ts new file mode 100644 index 00000000000..0fc34072b47 --- /dev/null +++ b/src/main/stats/collector-async-save.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: the debounced stats save must be async (off the main thread), while the +// shutdown flush() must stay synchronous. These tests pin both behaviors, prove +// the async path leaves no stray temp files, and — critically — prove an +// in-flight async write can never clobber the more-complete shutdown flush. + +let userDataDir: string +const statsPath = (): string => join(userDataDir, 'orca-stats.json') + +vi.mock('electron', () => ({ + app: { getPath: () => userDataDir } +})) + +// A controllable gate over node:fs/promises writeFile so a test can hold a +// debounced async write "in flight" while it drives a synchronous shutdown flush. +const gate = vi.hoisted(() => ({ + blocked: false, + waiters: [] as (() => void)[], + writeFileCalls: 0 +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + const writeFile = (async (...args: Parameters) => { + gate.writeFileCalls += 1 + if (gate.blocked) { + await new Promise((resolve) => gate.waiters.push(resolve)) + } + return actual.writeFile(...args) + }) as typeof actual.writeFile + return { ...actual, writeFile } +}) + +async function importCollector() { + return import('./collector') +} + +describe('StatsCollector async debounced save', () => { + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-stats-test-')) + gate.blocked = false + gate.waiters = [] + gate.writeFileCalls = 0 + vi.resetModules() + }) + + afterEach(() => { + rmSync(userDataDir, { recursive: true, force: true }) + vi.useRealTimers() + }) + + it('writes valid JSON via the async fs/promises path on the debounced timer, no stray temp files', async () => { + vi.useFakeTimers() + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + + collector.onAgentStart('pty-1', Date.now(), 'repo-1', 'wt-1') + + await vi.advanceTimersByTimeAsync(5_000) + await vi.waitFor(() => { + expect(readdirSync(userDataDir)).toContain('orca-stats.json') + }) + + // Proves the debounced path went through async fs/promises writeFile (a + // revert to writeFileSync would leave this at 0). + expect(gate.writeFileCalls).toBeGreaterThan(0) + // No stray *.tmp files — the rename completed. + expect(readdirSync(userDataDir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) + + const parsed = JSON.parse(readFileSync(statsPath(), 'utf-8')) + expect(parsed.aggregates.totalAgentsSpawned).toBe(1) + expect(Array.isArray(parsed.events)).toBe(true) + }) + + it('flush() writes synchronously (no timer, immediately on disk)', async () => { + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + + collector.onAgentStart('pty-2', Date.now()) + collector.flush() + + // Synchronous: the file exists immediately with no awaiting. + const parsed = JSON.parse(readFileSync(statsPath(), 'utf-8')) + expect(parsed.aggregates.totalAgentsSpawned).toBe(1) + }) + + it('an in-flight async write is vetoed by a shutdown flush (no data-loss clobber)', async () => { + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + // White-box: drive the two writers directly so the race is deterministic + // (the debounce path calls writeToDiskAsync; flush() calls writeToDiskSync). + const internals = collector as unknown as { writeToDiskAsync: () => Promise } + + collector.onAgentStart('pty-a', 1_000) + collector.onAgentStart('pty-b', 1_000) + + // Start the async write; it parks inside the blocked writeFile, before rename. + gate.blocked = true + const inflight = internals.writeToDiskAsync() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(gate.writeFileCalls).toBeGreaterThan(0) + + // App quits: close out both agents and flush the COMPLETE state synchronously + // (newer generation) while the async write is still parked. + collector.onAgentStop('pty-a', 5_000) + collector.onAgentStop('pty-b', 5_000) + collector.flush() + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentTimeMs).toBe(8_000) + + // Release the parked write; its older generation must be vetoed — the + // flushed data must survive and no temp file may leak. + gate.blocked = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await inflight + + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentTimeMs).toBe(8_000) + expect(readdirSync(userDataDir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) + }) +}) diff --git a/src/main/stats/collector.ts b/src/main/stats/collector.ts index 562e4de9681..508aa0a172a 100644 --- a/src/main/stats/collector.ts +++ b/src/main/stats/collector.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs' +import { writeFile, mkdir, rm } from 'node:fs/promises' import { join, dirname } from 'node:path' import type { StatsSummary } from '../../shared/types' import type { StatsEvent, StatsAggregates, StatsFile } from './types' @@ -55,6 +56,10 @@ export class StatsCollector { private aggregates: StatsAggregates private liveAgents = new Map() // ptyId → startTimestamp private writeTimer: ReturnType | null = null + // Monotonic id stamped on each prepared payload; the highest committed one + // wins so a slow in-flight async write can't clobber a newer sync flush. + private writeGeneration = 0 + private lastCommittedGeneration = 0 // Why: star-nag lives in its own service but needs to observe the running // agent-spawned counter. A lightweight listener avoids cyclic imports and // keeps StatsCollector unaware of how the counter is consumed. @@ -226,11 +231,15 @@ export class StatsCollector { } this.writeTimer = setTimeout(() => { this.writeTimer = null - try { - this.writeToDiskSync() - } catch (err) { + // Why: the debounced save is fun-stats telemetry, not crash-critical + // state, so it uses the async writer to move the ~900KB tmp-file write + // off the main thread (the stringify stays sync — see prepareWritePayload). + // A chatty multi-agent session re-arms this every 5s; a fully-sync write + // is a recurring main-thread stall. Shutdown flush() stays synchronous; + // the generation guard keeps the two paths race-safe. + void this.writeToDiskAsync().catch((err) => { console.error('[stats] Failed to write stats:', err) - } + }) }, DEBOUNCE_MS) } @@ -241,12 +250,18 @@ export class StatsCollector { } } - private writeToDiskSync(): void { + // Serialize the current state and pick a unique temp path. Trimming mutates + // in-memory state and JSON.stringify must see a consistent snapshot, so both + // writers call this synchronously before any await to avoid a torn snapshot. + // The monotonic generation lets a later write veto an earlier, still-in-flight + // one so a stale rename can never win (see writeToDiskAsync). + private prepareWritePayload(): { + statsFile: string + tmpFile: string + json: string + generation: number + } { const statsFile = getStatsFile() - const dir = dirname(statsFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } // Trim events to bounded size before writing if (this.events.length > MAX_EVENTS) { @@ -259,10 +274,44 @@ export class StatsCollector { aggregates: this.aggregates } - // Why unique temp file: same race-safe pattern as persistence.ts:120 — - // synchronous flushes can race the debounced writer during shutdown. + const generation = ++this.writeGeneration + // Unique temp file so the async debounced writer and the sync shutdown + // flush never write the same temp path (same pattern as persistence.ts). const tmpFile = `${statsFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - writeFileSync(tmpFile, JSON.stringify(data), 'utf-8') + return { statsFile, tmpFile, json: JSON.stringify(data), generation } + } + + private writeToDiskSync(): void { + const dir = dirname(getStatsFile()) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const { statsFile, tmpFile, json, generation } = this.prepareWritePayload() + writeFileSync(tmpFile, json, 'utf-8') renameSync(tmpFile, statsFile) + this.lastCommittedGeneration = Math.max(this.lastCommittedGeneration, generation) + } + + private async writeToDiskAsync(): Promise { + const dir = dirname(getStatsFile()) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + const { statsFile, tmpFile, json, generation } = this.prepareWritePayload() + // Only the ~900KB tmp write moves off the main thread; stringify stayed sync + // above (torn-snapshot constraint). The rename is a trivial metadata op done + // SYNCHRONOUSLY so it stays ordered with the shutdown flush's renameSync — + // an async rename could land after flush and clobber the more-complete + // shutdown data. The generation guard vetoes this write if a newer one (a + // later debounce OR the shutdown flush) already committed while we were + // writing; the check + rename run with no await between them, so the sync + // flush cannot interleave. + await writeFile(tmpFile, json, 'utf-8') + if (this.lastCommittedGeneration >= generation) { + await rm(tmpFile, { force: true }).catch(() => {}) + return + } + renameSync(tmpFile, statsFile) + this.lastCommittedGeneration = generation } } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 82c93f809cc..39ea7ff2a6a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -12,7 +12,12 @@ import type { import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' -import { getLocalPtyProvider, registerPtyHandlers } from '../ipc/pty' +import { + getLocalPtyProvider, + registerPtyHandlers, + type GetSelectedCodexHomePath, + type PrepareCodexSessionResume +} from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' @@ -39,7 +44,6 @@ import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay' -import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' import { @@ -66,14 +70,12 @@ export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: ( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv - ) => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, prepareClaudeAuth?: ( target?: ClaudeAccountSelectionTarget ) => Promise, options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise awaitLocalPtyProviderStartup?: () => Promise onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void @@ -97,6 +99,7 @@ export function attachMainWindowServices( prepareClaudeAuth, store, { + prepareCodexSessionResume: options?.prepareCodexSessionResume, awaitLocalPtyStartup: options?.awaitLocalPtyStartup, awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index e679e4ae034..1e0c32b3d62 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -16,8 +16,10 @@ const { fsReaddirMock, fsRmMock, fsWriteFileMock, + fsOpenMock, fsStatMock, clipboardReadTextMock, + clipboardReadBufferMock, clipboardWriteTextMock, clipboardReadImageMock, clipboardWriteImageMock, @@ -47,8 +49,10 @@ const { fsReaddirMock: vi.fn(), fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), + fsOpenMock: vi.fn(), fsStatMock: vi.fn(), clipboardReadTextMock: vi.fn(), + clipboardReadBufferMock: vi.fn(), clipboardWriteTextMock: vi.fn(), clipboardReadImageMock: vi.fn(), clipboardWriteImageMock: vi.fn(), @@ -67,6 +71,7 @@ vi.mock('node:fs/promises', () => ({ mkdir: fsMkdirMock, readdir: fsReaddirMock, rm: fsRmMock, + open: fsOpenMock, stat: fsStatMock, default: { writeFile: fsWriteFileMock @@ -91,6 +96,7 @@ vi.mock('electron', () => ({ }, clipboard: { readText: clipboardReadTextMock, + readBuffer: clipboardReadBufferMock, writeText: clipboardWriteTextMock, readImage: clipboardReadImageMock, writeImage: clipboardWriteImageMock, @@ -170,6 +176,12 @@ function dirent(name: string, directory = true): { name: string; isDirectory: () return { name, isDirectory: () => directory } } +function shellIdListArray(childCount: number): Buffer { + const value = Buffer.alloc(4 + 4 * (childCount + 1)) + value.writeUInt32LE(childCount) + return value +} + describe('registerClipboardHandlers', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) @@ -186,9 +198,12 @@ describe('registerClipboardHandlers', () => { fsRmMock.mockReset() fsRmMock.mockResolvedValue(undefined) fsWriteFileMock.mockReset() + fsOpenMock.mockReset() fsStatMock.mockReset() fsStatMock.mockResolvedValue({}) clipboardReadTextMock.mockReset() + clipboardReadBufferMock.mockReset() + clipboardReadBufferMock.mockReturnValue(Buffer.alloc(0)) clipboardWriteTextMock.mockReset() clipboardReadImageMock.mockReset() clipboardWriteImageMock.mockReset() @@ -542,9 +557,85 @@ describe('registerClipboardHandlers', () => { handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), undefined) ).resolves.toBe(expectedPath) expect(fsWriteFileMock).toHaveBeenCalledWith(expectedPath, png) + expect(clipboardReadBufferMock).not.toHaveBeenCalled() + expect(fsOpenMock).not.toHaveBeenCalled() expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) + it('does not inspect FileNameW when an empty image clipboard is read outside Windows', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + clipboardReadImageMock.mockReturnValue({ isEmpty: () => true }) + + try { + registerClipboardHandlers({} as never) + + const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile') + await expect(handler?.(makeClipboardEvent(), undefined)).resolves.toBeNull() + expect(clipboardReadBufferMock).not.toHaveBeenCalled() + expect(fsOpenMock).not.toHaveBeenCalled() + expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled() + } finally { + platformSpy.mockRestore() + } + }) + + it('routes a Windows Explorer FileNameW image through the target-aware attachment flow', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png' + const png = Buffer.from([4, 3, 2, 1]) + clipboardReadImageMock.mockReturnValue({ isEmpty: () => true }) + clipboardReadBufferMock.mockImplementation((format: string) => + format === 'FileNameW' ? Buffer.from(`${sourcePath}\0`, 'utf16le') : shellIdListArray(1) + ) + const source = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source) + source.writeUInt32BE(13, 8) + source.write('IHDR', 12, 'ascii') + source.writeUInt32BE(1, 16) + source.writeUInt32BE(1, 20) + const close = vi.fn().mockResolvedValue(undefined) + fsOpenMock.mockResolvedValue({ + close, + stat: vi.fn().mockResolvedValue({ isFile: () => true, size: source.byteLength }), + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min(Math.max(source.byteLength - position, 0), length) + source.copy(buffer, offset, position, position + bytesRead) + return { buffer, bytesRead } + }) + }) + nativeImageCreateFromBufferMock.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), + isEmpty: () => false, + toPNG: () => png + }) + const writeFileBase64 = vi.fn().mockResolvedValue(undefined) + getSshFilesystemProviderMock.mockReturnValue({ + getTempDir: vi.fn().mockResolvedValue('/var/tmp'), + writeFileBase64 + }) + + try { + registerClipboardHandlers({} as never) + + const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile') + await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe( + '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' + ) + expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW') + expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array') + expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r') + expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source) + expect(close).toHaveBeenCalled() + expect(writeFileBase64).toHaveBeenCalledWith( + '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', + png.toString('base64') + ) + expect(fsWriteFileMock).not.toHaveBeenCalled() + } finally { + platformSpy.mockRestore() + } + }) + it('saves clipboard images through the selected remote runtime host', async () => { const png = Buffer.alloc(512 * 1024) const contentBase64 = png.toString('base64') diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f6c98d6bb2d..47b4d5d47ff 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -7,7 +7,7 @@ import { type WebContents } from 'electron' import { spawn } from 'node:child_process' -import { stat } from 'node:fs/promises' +import { open, stat } from 'node:fs/promises' import type { Store } from '../persistence' import { isENOENT, PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from '../ipc/filesystem-auth' import { @@ -34,6 +34,7 @@ import { writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' +import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' let trustedClipboardRendererWebContentsId: number | null = null @@ -102,7 +103,20 @@ export function registerClipboardHandlers(store: Store): void { assertTrustedClipboardSender(event) const image = clipboard.readImage() if (image.isEmpty()) { - return null + if (process.platform !== 'win32') { + return null + } + const copiedFilePng = await readWindowsClipboardImageFileAsPng( + { + fileNameW: clipboard.readBuffer('FileNameW'), + shellIdListArray: clipboard.readBuffer('Shell IDList Array') + }, + { + createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer), + openFile: (filePath) => open(filePath, 'r') + } + ) + return copiedFilePng ? saveClipboardImageBufferForTarget(copiedFilePng, args) : null } assertClipboardImageDimensionsWithinLimit(image.getSize()) return saveClipboardImageBufferForTarget(image.toPNG(), args) diff --git a/src/main/window/clipboard-windows-image-file.test.ts b/src/main/window/clipboard-windows-image-file.test.ts new file mode 100644 index 00000000000..d69762066d6 --- /dev/null +++ b/src/main/window/clipboard-windows-image-file.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES +} from '../../shared/clipboard-image' +import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' + +function fileNameW(filePath: string): Buffer { + return Buffer.from(`${filePath}\0`, 'utf16le') +} + +function clipboardFormats(filePath: string, shellItemCount = 1) { + const shellIdListArray = Buffer.alloc(4 + 4 * (shellItemCount + 1)) + shellIdListArray.writeUInt32LE(shellItemCount) + return { fileNameW: fileNameW(filePath), shellIdListArray } +} + +function pngHeader(width = 10, height = 10): Buffer { + const source = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source) + source.writeUInt32BE(13, 8) + source.write('IHDR', 12, 'ascii') + source.writeUInt32BE(width, 16) + source.writeUInt32BE(height, 20) + return source +} + +function jpegHeader(width = 10, height = 10): Buffer { + return Buffer.from([ + 0xff, + 0xd8, + 0xff, + 0xe0, + 0x00, + 0x02, + 0xff, + 0xc2, + 0x00, + 0x07, + 0x08, + height >> 8, + height & 0xff, + width >> 8, + width & 0xff + ]) +} + +function image(png = Buffer.from([4, 3, 2, 1])) { + return { + getSize: () => ({ height: 10, width: 10 }), + isEmpty: () => false, + toPNG: () => png + } +} + +function fileHandle( + source: Buffer, + options: { chunkSize?: number; isFile?: boolean; size?: number } = {} +) { + const close = vi.fn().mockResolvedValue(undefined) + const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min( + Math.max(source.byteLength - position, 0), + length, + options.chunkSize ?? Number.POSITIVE_INFINITY + ) + source.copy(buffer, offset, position, position + bytesRead) + return { buffer, bytesRead } + }) + return { + close, + read, + stat: vi.fn().mockResolvedValue({ + isFile: () => options.isFile ?? true, + size: options.size ?? source.byteLength + }) + } +} + +describe('readWindowsClipboardImageFileAsPng', () => { + it.each([ + 'C:\\Users\\alice\\图片\\shot.PNG', + '\\\\server\\share\\shot.jpeg', + '\\\\?\\C:\\Users\\alice\\shot.jpg', + '\\\\?\\UNC\\server\\share\\shot.png' + ])('decodes and converts a bounded FileNameW path: %s', async (filePath) => { + const source = filePath.toLowerCase().endsWith('.png') ? pngHeader() : jpegHeader() + const png = Buffer.from([9, 8, 7]) + const handle = fileHandle(source, { chunkSize: 3 }) + const openFile = vi.fn().mockResolvedValue(handle) + const createImageFromBuffer = vi.fn(() => image(png) as never) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats(filePath), { + createImageFromBuffer, + openFile + }) + ).resolves.toEqual(png) + + expect(openFile).toHaveBeenCalledWith(filePath) + expect(handle.read.mock.calls.length).toBeGreaterThan(1) + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).toHaveBeenCalledWith(source) + }) + + it.each([ + ['empty', Buffer.alloc(0)], + ['odd byte count', Buffer.from([65, 0, 0])], + ['missing terminator', Buffer.from('C:\\shot.png', 'utf16le')], + ['relative path', fileNameW('shot.png')], + ['drive-relative path', fileNameW('C:shot.png')], + ['rooted drive-relative path', fileNameW('\\shot.png')], + ['device namespace', fileNameW('\\\\.\\pipe\\shot.png')], + ['UNC named-pipe namespace', fileNameW('\\\\server\\pipe\\shot.png')], + ['extended UNC named-pipe namespace', fileNameW('\\\\?\\UNC\\server\\pipe\\shot.png')], + ['multiple paths', Buffer.from('C:\\one.png\0C:\\two.png\0', 'utf16le')], + ['unsupported image type', fileNameW('C:\\shot.webp')], + ['oversized payload', Buffer.alloc(64 * 1024 + 2)] + ])('ignores malformed or unsupported %s payloads', async (_name, payload) => { + const openFile = vi.fn() + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng( + { fileNameW: payload, shellIdListArray: Buffer.alloc(0) }, + { createImageFromBuffer, openFile } + ) + ).resolves.toBeNull() + + expect(openFile).not.toHaveBeenCalled() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('rejects an Explorer multi-selection when FileNameW exposes only its first path', async () => { + const openFile = vi.fn() + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\one.png', 2), { + createImageFromBuffer, + openFile + }) + ).resolves.toBeNull() + + expect(openFile).not.toHaveBeenCalled() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores missing, inaccessible, and looping paths', async () => { + for (const code of ['ENOENT', 'EACCES', 'ELOOP']) { + const error = Object.assign(new Error(code), { code }) + const openFile = vi.fn().mockRejectedValue(error) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\shot.png'), { + createImageFromBuffer: vi.fn(), + openFile + }) + ).resolves.toBeNull() + } + }) + + it('ignores directories and closes their handles', async () => { + const handle = fileHandle(Buffer.alloc(0), { isFile: false }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\images.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('rejects oversized sources before reading or decoding', async () => { + const handle = fileHandle(Buffer.alloc(0), { + size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 + }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\huge.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).rejects.toThrow('Clipboard image is too large') + + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores a source that changes size after handle validation', async () => { + const handle = fileHandle(Buffer.from('grew'), { size: 3 }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\changed.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores image bytes the native decoder cannot decode', async () => { + const handle = fileHandle(Buffer.from('not-an-image')) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\invalid.png'), { + createImageFromBuffer: vi.fn(() => ({ isEmpty: () => true }) as never), + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + }) + + it('rejects oversized encoded dimensions before native decoding', async () => { + const handle = fileHandle(pngHeader(CLIPBOARD_IMAGE_MAX_PIXELS + 1, 1)) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\pixel-bomb.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).rejects.toThrow('Clipboard image is too large') + + expect(createImageFromBuffer).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + }) + + it('bounds malformed JPEG marker scanning before native decoding', async () => { + const source = Buffer.alloc(2 + 2 * 4097) + source.set([0xff, 0xd8]) + for (let offset = 2; offset < source.byteLength; offset += 2) { + source.set([0xff, 0x01], offset) + } + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\marker-flood.jpg'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(fileHandle(source)) + }) + ).resolves.toBeNull() + + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('bounds decoded dimensions and converted PNG bytes', async () => { + const oversizedDimensions = fileHandle(pngHeader()) + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\wide.png'), { + createImageFromBuffer: vi.fn( + () => + ({ + getSize: () => ({ height: 1, width: CLIPBOARD_IMAGE_MAX_PIXELS + 1 }), + isEmpty: () => false, + toPNG: vi.fn() + }) as never + ), + openFile: vi.fn().mockResolvedValue(oversizedDimensions) + }) + ).rejects.toThrow('Clipboard image is too large') + + const oversizedPng = fileHandle(pngHeader()) + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\expanded.png'), { + createImageFromBuffer: vi.fn( + () => image(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) as never + ), + openFile: vi.fn().mockResolvedValue(oversizedPng) + }) + ).rejects.toThrow('Clipboard image is too large') + }) +}) diff --git a/src/main/window/clipboard-windows-image-file.ts b/src/main/window/clipboard-windows-image-file.ts new file mode 100644 index 00000000000..3c26bb70c4f --- /dev/null +++ b/src/main/window/clipboard-windows-image-file.ts @@ -0,0 +1,218 @@ +import { win32 } from 'node:path' +import type { FileHandle } from 'node:fs/promises' +import type { NativeImage } from 'electron' +import { + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../shared/clipboard-image' + +type ClipboardImageFileHandle = Pick + +type WindowsClipboardImageFileDeps = { + createImageFromBuffer: (buffer: Buffer) => NativeImage + openFile: (filePath: string) => Promise +} + +type WindowsClipboardImageFileFormats = { + fileNameW: Buffer + shellIdListArray: Buffer +} + +const FILE_NAME_W_MAX_BYTES = 64 * 1024 +const FILE_READ_MAX_CALLS = 1024 +const IMAGE_FILE_EXTENSION_SET = new Set(['.jpeg', '.jpg', '.png']) +const JPEG_DIMENSION_SCAN_MAX_BYTES = 1024 * 1024 +const JPEG_DIMENSION_SCAN_MAX_MARKERS = 4096 +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) +const JPEG_START_OF_FRAME_MARKERS = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf +]) + +function isOrdinaryUncShare(share: string | undefined): boolean { + return typeof share === 'string' && share.toLowerCase() !== 'pipe' +} + +function isFullyQualifiedWindowsPath(filePath: string): boolean { + if (/^[A-Za-z]:[\\/]/.test(filePath)) { + return true + } + if (/^\\\\\?\\[A-Za-z]:\\/.test(filePath)) { + return true + } + const extendedUnc = /^\\\\\?\\UNC\\[^\\/]+\\([^\\/]+)(?:\\|$)/i.exec(filePath) + if (extendedUnc) { + return isOrdinaryUncShare(extendedUnc[1]) + } + const unc = /^[/\\]{2}(?![?.][/\\])[^/\\]+[/\\]([^/\\]+)(?:[/\\]|$)/.exec(filePath) + return isOrdinaryUncShare(unc?.[1]) +} + +function decodeFileNameW(value: Buffer): string | null { + if ( + value.byteLength < 2 || + value.byteLength > FILE_NAME_W_MAX_BYTES || + value.byteLength % 2 !== 0 || + value.readUInt16LE(value.byteLength - 2) !== 0 + ) { + return null + } + + let end = value.byteLength - 2 + while (end >= 2 && value.readUInt16LE(end - 2) === 0) { + end -= 2 + } + const filePath = value.subarray(0, end).toString('utf16le') + if (!filePath || filePath.includes('\0') || !isFullyQualifiedWindowsPath(filePath)) { + return null + } + return IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase()) ? filePath : null +} + +function hasAtMostOneShellItem(value: Buffer): boolean { + if (value.byteLength === 0) { + return true + } + // Why: Explorer's FileNameW exposes only the first path even when its CIDA has multiple items. + return value.byteLength >= 12 && value.readUInt32LE(0) === 1 +} + +function readPngDimensions(source: Buffer): { height: number; width: number } | null { + if ( + source.byteLength < 24 || + !source.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE) || + source.readUInt32BE(8) !== 13 || + source.toString('ascii', 12, 16) !== 'IHDR' + ) { + return null + } + return { height: source.readUInt32BE(20), width: source.readUInt32BE(16) } +} + +function readJpegDimensions(source: Buffer): { height: number; width: number } | null { + if (source.byteLength < 4 || source[0] !== 0xff || source[1] !== 0xd8) { + return null + } + let offset = 2 + let markersRead = 0 + const scanEnd = Math.min(source.byteLength, JPEG_DIMENSION_SCAN_MAX_BYTES) + while (offset < scanEnd && markersRead < JPEG_DIMENSION_SCAN_MAX_MARKERS) { + while (offset < scanEnd && source[offset] === 0xff) { + offset += 1 + } + const marker = source[offset] + offset += 1 + markersRead += 1 + if (marker === undefined || marker === 0x00 || marker === 0xd9 || marker === 0xda) { + return null + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { + continue + } + if (offset + 2 > source.byteLength) { + return null + } + const segmentLength = source.readUInt16BE(offset) + if ( + segmentLength < 2 || + offset + segmentLength > source.byteLength || + offset + segmentLength > scanEnd + ) { + return null + } + if (JPEG_START_OF_FRAME_MARKERS.has(marker)) { + if (segmentLength < 7) { + return null + } + return { height: source.readUInt16BE(offset + 3), width: source.readUInt16BE(offset + 5) } + } + offset += segmentLength + } + return null +} + +function readImageDimensions(source: Buffer): { height: number; width: number } | null { + return readPngDimensions(source) ?? readJpegDimensions(source) +} + +async function readStableFile( + handle: ClipboardImageFileHandle, + expectedSize: number +): Promise { + const buffer = Buffer.alloc(expectedSize + 1) + let bytesRead = 0 + let readCalls = 0 + while (bytesRead < buffer.byteLength && readCalls < FILE_READ_MAX_CALLS) { + const result = await handle.read(buffer, bytesRead, buffer.byteLength - bytesRead, bytesRead) + readCalls += 1 + if (result.bytesRead === 0) { + break + } + bytesRead += result.bytesRead + } + return bytesRead === expectedSize ? buffer.subarray(0, bytesRead) : null +} + +export async function readWindowsClipboardImageFileAsPng( + { fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats, + { createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps +): Promise { + if (!hasAtMostOneShellItem(shellIdListArray)) { + return null + } + const filePath = decodeFileNameW(fileNameW) + if (!filePath) { + return null + } + + let handle: ClipboardImageFileHandle + try { + // Why: one handle keeps validation and the bounded read on the same file if its path changes. + handle = await openFile(filePath) + } catch { + return null + } + + let source: Buffer | null = null + try { + let file: Awaited> + try { + file = await handle.stat() + } catch { + return null + } + if (!file.isFile() || !Number.isSafeInteger(file.size) || file.size < 0) { + return null + } + assertClipboardImageByteLengthWithinLimit(file.size) + try { + source = await readStableFile(handle, file.size) + } catch { + return null + } + } finally { + await handle.close().catch(() => {}) + } + if (!source) { + return null + } + const encodedDimensions = readImageDimensions(source) + if (!encodedDimensions) { + return null + } + // Why: reject pixel bombs from metadata before NativeImage allocates decoded pixels. + assertClipboardImageDimensionsWithinLimit(encodedDimensions) + + let image: NativeImage + try { + image = createImageFromBuffer(source) + } catch { + return null + } + if (image.isEmpty()) { + return null + } + assertClipboardImageDimensionsWithinLimit(image.getSize()) + const png = image.toPNG() + assertClipboardImageByteLengthWithinLimit(png.byteLength) + return png +} diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 3be600cb56b..e35cd24d71a 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -7,8 +7,7 @@ import { nativeTheme, Notification, powerMonitor, - screen, - shell + screen } from 'electron' import { join } from 'node:path' import { is } from '@electron-toolkit/utils' @@ -17,10 +16,7 @@ import { getAppIconPath } from '../app-icon' import { browserManager } from '../browser/browser-manager' import { browserSessionRegistry } from '../browser/browser-session-registry' import { translateMain } from '../i18n/main-i18n' -import { - normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl -} from '../../shared/browser-url' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' import { isCrashReportReason } from '../../shared/crash-reporting' import { @@ -49,6 +45,9 @@ import { getMainE2EConfig } from '../e2e-config' import { buildEditableContextMenuTemplate } from './editable-context-menu' import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' import { resolveWindowCloseAction } from './window-close-decision' +import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' +import { closeDashboardPopout } from './dashboard-popout-window' +import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() @@ -173,34 +172,12 @@ export function createMainWindow( opts?: CreateMainWindowOptions ): BrowserWindow { const rawSavedBounds = store?.getUI().windowBounds - // Why: discard persisted bounds that are shrink-to-min (corrupt, see freezeBoundsOnQuit) or off-screen, falling back to defaultBounds. - // Require ~half MIN_WIDTH/HEIGHT of workArea overlap so a 1px sliver or dock-hidden rect isn't treated as visible. - const rectHasVisibleAreaOnAnyDisplay = (b: { - x: number - y: number - width: number - height: number - }): boolean => { - try { - return screen.getAllDisplays().some((d) => { - const wa = d.workArea - const overlapX = Math.max(0, Math.min(b.x + b.width, wa.x + wa.width) - Math.max(b.x, wa.x)) - const overlapY = Math.max( - 0, - Math.min(b.y + b.height, wa.y + wa.height) - Math.max(b.y, wa.y) - ) - return overlapX >= MIN_WIDTH / 2 && overlapY >= MIN_HEIGHT / 2 - }) - } catch (err) { - console.warn('[window] screen.getAllDisplays() threw; treating bounds as off-screen', err) - return false - } - } + // Why: reject min-size or substantially off-screen bounds so the titlebar stays reachable after display changes. const savedBounds = rawSavedBounds && rawSavedBounds.width > MIN_WIDTH && rawSavedBounds.height > MIN_HEIGHT && - rectHasVisibleAreaOnAnyDisplay(rawSavedBounds) + rectHasVisibleAreaOnAnyDisplay(rawSavedBounds, MIN_WIDTH / 2, MIN_HEIGHT / 2) ? rawSavedBounds : undefined if (rawSavedBounds && !savedBounds) { @@ -417,13 +394,7 @@ export function createMainWindow( mainWindow.webContents.send('window:fullscreen-changed', false) }) - mainWindow.webContents.setWindowOpenHandler((details) => { - const externalUrl = normalizeExternalBrowserUrl(details.url) - if (externalUrl) { - shell.openExternal(externalUrl) - } - return { action: 'deny' } - }) + installPrivilegedWindowNavigationPolicy(mainWindow.webContents) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' @@ -458,29 +429,6 @@ export function createMainWindow( browserManager.attachGuestPolicies(guest) }) - // Why: block in-window navigations so remote pages can't inherit the privileged preload bridge (dev server allowed for HMR). - mainWindow.webContents.on('will-navigate', (event, url) => { - const externalUrl = normalizeExternalBrowserUrl(url) - - if (externalUrl) { - const target = new URL(externalUrl) - if (is.dev && process.env.ELECTRON_RENDERER_URL) { - try { - const allowed = new URL(process.env.ELECTRON_RENDERER_URL) - if (target.origin === allowed.origin) { - return // allow dev server navigations (HMR, etc.) - } - } catch { - // fall through to prevent - } - } - - shell.openExternal(externalUrl) - } - - event.preventDefault() - }) - // Why: mirror markdown-editor focus so before-input-event skips Cmd/Ctrl+B while TipTap owns focus (docs/markdown-cmd-b-bold-design.md). let markdownEditorFocused = false let terminalInputFocused = false @@ -1033,6 +981,10 @@ export function createMainWindow( ipcMain.on(confirmCloseChannel, onConfirmClose) mainWindow.on('closed', () => { + // Why: the dashboard pop-out is a companion of the main window — close it + // alongside so it never orphans as a lone window after the app window is + // gone (e.g. on macOS where the app stays alive after the window closes). + closeDashboardPopout() clearInitialRevealFallbackTimer() // Why: default-deny the Cmd+B carve-out after the window is gone so a stale-true flag can't leak into later state. markdownEditorFocused = false diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts new file mode 100644 index 00000000000..79396e1c728 --- /dev/null +++ b/src/main/window/dashboard-popout-window.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type Display = { workArea: { x: number; y: number; width: number; height: number } } + +const { + instances, + BrowserWindowMock, + nativeThemeMock, + appOnMock, + appRemoveListenerMock, + getAllDisplaysMock, + installNavigationPolicyMock, + sendToTrustedUIRendererMock, + isMock +} = vi.hoisted(() => { + const created: FakeWindow[] = [] + + class FakeWindow { + options: Electron.BrowserWindowConstructorOptions + private handlers: Record void)[]> = {} + private onceHandlers: Record void)[]> = {} + destroyed = false + minimized = false + fullscreen = false + webContents = { id: created.length + 1, send: vi.fn(), isDestroyed: () => this.destroyed } + bounds = { x: 100, y: 100, width: 960, height: 720 } + focus = vi.fn() + show = vi.fn() + restore = vi.fn(() => { + this.minimized = false + }) + loadURL = vi.fn() + loadFile = vi.fn() + close = vi.fn(() => { + this.destroyed = true + this.emit('close') + this.emit('closed') + }) + + constructor(options: Electron.BrowserWindowConstructorOptions) { + this.options = options + created.push(this) + } + + on(event: string, cb: (...args: unknown[]) => void): this { + ;(this.handlers[event] ||= []).push(cb) + return this + } + + once(event: string, cb: (...args: unknown[]) => void): this { + ;(this.onceHandlers[event] ||= []).push(cb) + return this + } + + emit(event: string, ...args: unknown[]): void { + for (const cb of this.handlers[event] ?? []) { + cb(...args) + } + for (const cb of this.onceHandlers[event] ?? []) { + cb(...args) + } + } + + isDestroyed(): boolean { + return this.destroyed + } + isMinimized(): boolean { + return this.minimized + } + isFullScreen(): boolean { + return this.fullscreen + } + getBounds(): { x: number; y: number; width: number; height: number } { + return this.bounds + } + } + + return { + instances: created, + BrowserWindowMock: FakeWindow, + nativeThemeMock: { shouldUseDarkColors: true }, + appOnMock: vi.fn(), + appRemoveListenerMock: vi.fn(), + getAllDisplaysMock: vi.fn((): Display[] => [ + { workArea: { x: 0, y: 0, width: 1920, height: 1080 } } + ]), + installNavigationPolicyMock: vi.fn(), + sendToTrustedUIRendererMock: vi.fn(), + isMock: { dev: false } as { dev: boolean } + } +}) + +// Static helpers live on the constructor; broadcastPopoutOpenChanged uses +// BrowserWindow.getAllWindows(). Return the instances created in this test. +;(BrowserWindowMock as unknown as { getAllWindows: () => unknown[] }).getAllWindows = () => + instances + +vi.mock('electron', () => ({ + app: { on: appOnMock, removeListener: appRemoveListenerMock }, + BrowserWindow: BrowserWindowMock, + nativeTheme: nativeThemeMock, + screen: { getAllDisplays: getAllDisplaysMock } +})) + +vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) +vi.mock('../ipc/ui', () => ({ sendToTrustedUIRenderer: sendToTrustedUIRendererMock })) +vi.mock('./privileged-window-navigation', () => ({ + installPrivilegedWindowNavigationPolicy: installNavigationPolicyMock +})) + +import { + createOrFocusDashboardPopout, + closeDashboardPopout, + isDashboardPopoutRenderer +} from './dashboard-popout-window' + +type FakeWindow = InstanceType + +function makeStore(ui: Record = {}): { + getUI: () => Record + updateUI: ReturnType +} { + return { getUI: () => ui, updateUI: vi.fn() } +} + +const RENDERER_URL = 'http://localhost:5173' + +describe('createOrFocusDashboardPopout', () => { + beforeEach(() => { + instances.length = 0 + isMock.dev = false + vi.stubEnv('ELECTRON_RENDERER_URL', '') + getAllDisplaysMock.mockReturnValue([{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]) + }) + + afterEach(() => { + // Reset the module-level singleton between tests. + closeDashboardPopout() + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('creates a native-frame window with the shared preload and no webview surface', () => { + createOrFocusDashboardPopout(makeStore() as never) + + expect(instances).toHaveLength(1) + const opts = instances[0].options + expect(opts.title).toBe('Orca Agent Dashboard') + expect(opts.minWidth).toBe(480) + expect(opts.minHeight).toBe(360) + // Native frame: neither a custom titleBarStyle nor frame:false is set. + expect(opts.titleBarStyle).toBeUndefined() + expect(opts.frame).toBeUndefined() + expect(opts.backgroundColor).toBe('#0a0a0a') // dark theme mock + expect(opts.webPreferences?.sandbox).toBe(true) + expect(opts.webPreferences?.webviewTag).toBe(false) + expect(opts.webPreferences?.preload).toMatch(/preload[\\/]index\.js$/) + expect(installNavigationPolicyMock).toHaveBeenCalledWith(instances[0].webContents) + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith('dashboard:popoutOpenChanged', true) + }) + + it('shows the window on ready-to-show', () => { + createOrFocusDashboardPopout(makeStore() as never) + const win = instances[0] + expect(win.show).not.toHaveBeenCalled() + win.emit('ready-to-show') + expect(win.show).toHaveBeenCalledTimes(1) + }) + + it('loads the prod file entry with the requested view', () => { + createOrFocusDashboardPopout(makeStore() as never, 'kanban') + const win = instances[0] + expect(win.loadURL).not.toHaveBeenCalled() + expect(win.loadFile).toHaveBeenCalledTimes(1) + const [file, options] = win.loadFile.mock.calls[0] + expect(String(file)).toMatch(/renderer[\\/]popout\.html$/) + expect(options).toEqual({ search: 'view=kanban' }) + }) + + it('loads the dev server URL with the requested view when in dev', () => { + isMock.dev = true + vi.stubEnv('ELECTRON_RENDERER_URL', RENDERER_URL) + createOrFocusDashboardPopout(makeStore() as never, 'kanban') + const win = instances[0] + expect(win.loadFile).not.toHaveBeenCalled() + expect(win.loadURL).toHaveBeenCalledWith(`${RENDERER_URL}/popout.html?view=kanban`) + }) + + it('focuses the existing window instead of creating a second one', () => { + const store = makeStore() + const first = createOrFocusDashboardPopout(store as never) + const second = createOrFocusDashboardPopout(store as never) + expect(instances).toHaveLength(1) + expect(second).toBe(first) + expect(instances[0].focus).toHaveBeenCalledTimes(1) + }) + + it('trusts only the live popout webContents', () => { + const win = createOrFocusDashboardPopout(makeStore() as never) as unknown as FakeWindow + expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(true) + expect(isDashboardPopoutRenderer({ id: win.webContents.id } as never)).toBe(false) + win.destroyed = true + expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(false) + }) + + it('restores a minimized window when re-requested', () => { + const store = makeStore() + const win = createOrFocusDashboardPopout(store as never) as unknown as FakeWindow + win.minimized = true + createOrFocusDashboardPopout(store as never) + expect(win.restore).toHaveBeenCalledTimes(1) + expect(win.focus).toHaveBeenCalledTimes(1) + }) + + it('restores valid persisted bounds', () => { + const store = makeStore({ + dashboardPopoutBounds: { x: 200, y: 150, width: 1000, height: 800 } + }) + createOrFocusDashboardPopout(store as never) + const opts = instances[0].options + expect(opts.x).toBe(200) + expect(opts.y).toBe(150) + expect(opts.width).toBe(1000) + expect(opts.height).toBe(800) + }) + + it('discards off-screen persisted bounds and falls back to defaults', () => { + // Display does not overlap the saved rect at all. + getAllDisplaysMock.mockReturnValue([{ workArea: { x: 0, y: 0, width: 800, height: 600 } }]) + const store = makeStore({ + dashboardPopoutBounds: { x: 5000, y: 5000, width: 1000, height: 800 } + }) + createOrFocusDashboardPopout(store as never) + const opts = instances[0].options + expect(opts.x).toBeUndefined() + expect(opts.y).toBeUndefined() + expect(opts.width).toBe(960) + expect(opts.height).toBe(720) + }) + + it('persists bounds on resize after the debounce, guarding near-minimum sizes', () => { + vi.useFakeTimers() + try { + const store = makeStore() + const win = createOrFocusDashboardPopout(store as never) as unknown as FakeWindow + + win.bounds = { x: 10, y: 10, width: 1200, height: 900 } + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).toHaveBeenCalledWith({ + dashboardPopoutBounds: { x: 10, y: 10, width: 1200, height: 900 } + }) + + store.updateUI.mockClear() + win.bounds = { x: 10, y: 10, width: 100, height: 100 } // below minimum + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('closeDashboardPopout closes an open window', () => { + createOrFocusDashboardPopout(makeStore() as never) + const win = instances[0] + closeDashboardPopout() + expect(win.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts new file mode 100644 index 00000000000..bcfd5985721 --- /dev/null +++ b/src/main/window/dashboard-popout-window.ts @@ -0,0 +1,189 @@ +import { app, BrowserWindow, nativeTheme, type WebContents } from 'electron' +import { join } from 'node:path' +import { is } from '@electron-toolkit/utils' +import type { Store } from '../persistence' +import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' +import { sendToTrustedUIRenderer } from '../ipc/ui' +import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' + +const MIN_WIDTH = 480 +const MIN_HEIGHT = 360 +const DEFAULT_WIDTH = 960 +const DEFAULT_HEIGHT = 720 +const DEFAULT_VIEW = 'kanban' + +// Why: singleton — the dashboard is a companion surface, so a second "Pop Out" +// request focuses the existing window rather than spawning duplicates. +let dashboardPopoutWindow: BrowserWindow | null = null + +/** The live pop-out window, or null when closed. Used by the dashboard relay to + * forward snapshots to the popout's webContents. */ +export function getDashboardPopoutWindow(): BrowserWindow | null { + return dashboardPopoutWindow && + !dashboardPopoutWindow.isDestroyed() && + !dashboardPopoutWindow.webContents.isDestroyed() + ? dashboardPopoutWindow + : null +} + +export function isDashboardPopoutRenderer(sender: WebContents): boolean { + return getDashboardPopoutWindow()?.webContents === sender +} + +// In-process listeners (the dashboard relay clears its cached snapshot on close). +const popoutOpenListeners = new Set<(open: boolean) => void>() + +/** Subscribe to pop-out open/close transitions in the main process. */ +export function onDashboardPopoutOpenChanged(listener: (open: boolean) => void): () => void { + popoutOpenListeners.add(listener) + return () => popoutOpenListeners.delete(listener) +} + +// Why: the main renderer's snapshot publisher only runs while the pop-out is +// open. Tell that exact window when the state flips, then notify listeners. +function broadcastPopoutOpenChanged(open: boolean): void { + sendToTrustedUIRenderer('dashboard:popoutOpenChanged', open) + for (const listener of popoutOpenListeners) { + listener(open) + } +} + +function loadDashboardPopout(window: BrowserWindow, view: string): void { + const search = `view=${encodeURIComponent(view)}` + // Why: mirror loadMainWindow's dev/prod branch — the dev server serves the + // second HTML entry, prod loads the emitted file. + if (is.dev && process.env.ELECTRON_RENDERER_URL) { + void window.loadURL(`${process.env.ELECTRON_RENDERER_URL}/popout.html?${search}`) + } else { + void window.loadFile(join(__dirname, '../renderer/popout.html'), { search }) + } +} + +function resolveRestoredBounds(store: Store | null): { + x: number + y: number + width: number + height: number +} | null { + const raw = store?.getUI().dashboardPopoutBounds ?? null + if ( + raw && + raw.width >= MIN_WIDTH && + raw.height >= MIN_HEIGHT && + rectHasVisibleAreaOnAnyDisplay(raw, MIN_WIDTH / 2, MIN_HEIGHT / 2) + ) { + return raw + } + if (raw) { + console.warn('[dashboard-popout] Discarding off-screen/near-min popout bounds:', raw) + } + return null +} + +/** + * Open the pop-out dashboard window, or focus it if already open. The window is + * a standalone top-level BrowserWindow with a native frame that reuses the same + * preload/window.api as the main window but renders its own React root + * (popout.html?view=…). + */ +export function createOrFocusDashboardPopout( + store: Store | null, + view: string = DEFAULT_VIEW +): BrowserWindow { + if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) { + if (dashboardPopoutWindow.isMinimized()) { + dashboardPopoutWindow.restore() + } + dashboardPopoutWindow.focus() + return dashboardPopoutWindow + } + + const savedBounds = resolveRestoredBounds(store) + + const window = new BrowserWindow({ + width: savedBounds?.width ?? DEFAULT_WIDTH, + height: savedBounds?.height ?? DEFAULT_HEIGHT, + ...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}), + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, + title: 'Orca Agent Dashboard', + show: false, + autoHideMenuBar: true, + backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', + // Why: the pop-out uses a standard native frame so it is movable, closable, + // and minimizable on every platform without reimplementing the main + // window's custom titlebar/drag-region/window-control chrome. The main + // window is frameless because it draws its own titlebar; the dashboard has + // no such chrome yet, so a native frame is the correct default here. + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: true, + // Why: the dashboard is plain DOM — no guests — so keep the + // guest-embedding surface off for this window. + webviewTag: false + } + }) + installPrivilegedWindowNavigationPolicy(window.webContents) + dashboardPopoutWindow = window + broadcastPopoutOpenChanged(true) + + window.once('ready-to-show', () => { + if (!window.isDestroyed()) { + window.show() + } + }) + + // Bounds persistence — mirrors the main window's debounced/frozen approach so + // teardown-time resize/move events can't clobber the remembered size with + // near-minimum bounds. + let boundsTimer: ReturnType | null = null + let windowClosing = false + const saveBounds = (): void => { + if (boundsTimer) { + clearTimeout(boundsTimer) + } + boundsTimer = setTimeout(() => { + boundsTimer = null + if (windowClosing || window.isDestroyed() || window.isMinimized() || window.isFullScreen()) { + return + } + const bounds = window.getBounds() + if (bounds.width < MIN_WIDTH || bounds.height < MIN_HEIGHT) { + return + } + store?.updateUI({ dashboardPopoutBounds: bounds }) + }, 500) + } + window.on('resize', saveBounds) + window.on('move', saveBounds) + + const freezeBounds = (): void => { + windowClosing = true + if (boundsTimer) { + clearTimeout(boundsTimer) + boundsTimer = null + } + } + window.on('close', freezeBounds) + app.on('before-quit', freezeBounds) + + window.on('closed', () => { + app.removeListener('before-quit', freezeBounds) + if (dashboardPopoutWindow === window) { + dashboardPopoutWindow = null + } + broadcastPopoutOpenChanged(false) + }) + + loadDashboardPopout(window, view) + return window +} + +/** Close the pop-out dashboard if it is open. Called when the main window + * closes so the dashboard never orphans without its owning app window. */ +export function closeDashboardPopout(): void { + if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) { + dashboardPopoutWindow.close() + } + dashboardPopoutWindow = null +} diff --git a/src/main/window/focus-existing-window.test.ts b/src/main/window/focus-existing-window.test.ts index e452489a6eb..e12e71ae109 100644 --- a/src/main/window/focus-existing-window.test.ts +++ b/src/main/window/focus-existing-window.test.ts @@ -63,7 +63,14 @@ function makeTimer(): { return callbacks.length }, run: (ms) => { - for (const entry of callbacks.filter((entry) => entry.ms === ms)) { + // Why: splice out matching entries before invoking — real timers fire + // once, and a retry callback here can schedule a fresh same-ms entry + // that a later run() call must not confuse with one already fired. + const due = callbacks.filter((entry) => entry.ms === ms) + for (const entry of due) { + callbacks.splice(callbacks.indexOf(entry), 1) + } + for (const entry of due) { entry.callback() } }, @@ -154,4 +161,137 @@ describe('focusExistingMainWindow', () => { expect(openedWindow.calls.show).toHaveBeenCalledTimes(1) expect(openedWindow.calls.focus).toHaveBeenCalledTimes(1) }) + + it('retries a transient reopen failure instead of stranding the app windowless', () => { + const app = makeFakeApp() + const timer = makeTimer() + const openedWindow = makeFakeWindow() + const warn = vi.fn() + let attempts = 0 + const openWindow = vi.fn(() => { + attempts += 1 + if (attempts < 2) { + throw new Error('transient failure') + } + return openedWindow + }) + + const result = focusExistingMainWindow({ + app, + getWindow: () => null, + openWindow, + warn, + setTimeout: timer.setTimeout + }) + + // Why: the first attempt's own return value is synchronous, so a retry + // scheduled after the first throw still reports 'pending' immediately. + expect(result).toBe('pending') + expect(warn).toHaveBeenCalledTimes(1) + expect(openWindow).toHaveBeenCalledTimes(1) + + timer.run(300) + expect(openWindow).toHaveBeenCalledTimes(2) + expect(openedWindow.calls.show).toHaveBeenCalledTimes(1) + expect(openedWindow.calls.focus).toHaveBeenCalledTimes(1) + }) + + it('adopts a window that appeared during the retry gap instead of opening a duplicate', () => { + // Why: openMainWindow is not idempotent — a duplicate open would orphan the + // window already on screen. If getWindow() resolves before the retry fires, + // the retry must reveal that window and never call openWindow again. + const app = makeFakeApp() + const timer = makeTimer() + const raceWindow = makeFakeWindow() + const warn = vi.fn() + let liveWindow: BrowserWindow | null = null + const openWindow = vi.fn(() => { + // First attempt throws, but a concurrent path lands a window before retry. + liveWindow = raceWindow + throw new Error('transient failure') + }) + + const result = focusExistingMainWindow({ + app, + getWindow: () => liveWindow, + openWindow, + warn, + setTimeout: timer.setTimeout + }) + + expect(result).toBe('pending') + expect(openWindow).toHaveBeenCalledTimes(1) + + timer.run(300) + // Retry adopts the raced-in window; it must not construct a second one. + expect(openWindow).toHaveBeenCalledTimes(1) + expect(raceWindow.calls.show).toHaveBeenCalledTimes(1) + expect(raceWindow.calls.focus).toHaveBeenCalledTimes(1) + }) + + it('reinforces win32 activation when a window is recovered on the retry path', () => { + const app = makeFakeApp() + const timer = makeTimer() + const openedWindow = makeFakeWindow() + const warn = vi.fn() + let attempts = 0 + const openWindow = vi.fn(() => { + attempts += 1 + if (attempts < 2) { + throw new Error('transient failure') + } + return openedWindow + }) + + focusExistingMainWindow({ + app, + getWindow: () => null, + openWindow, + warn, + platform: 'win32', + setTimeout: timer.setTimeout + }) + + timer.run(300) + // Why: the retry callback must run the same win32 reinforcement + focus retry + // as the sync success path, not just show/focus. + expect(openedWindow.calls.moveTop).toHaveBeenCalledTimes(1) + expect(openedWindow.calls.setAlwaysOnTop).toHaveBeenCalledWith(true) + + timer.run(100) + expect(app.focus).toHaveBeenCalledTimes(2) + expect(openedWindow.calls.focus).toHaveBeenCalledTimes(2) + + timer.run(250) + expect(openedWindow.calls.setAlwaysOnTop).toHaveBeenLastCalledWith(false) + }) + + it('gives up after exhausting reopen retries', () => { + const timer = makeTimer() + const warn = vi.fn() + const openWindow = vi.fn(() => { + throw new Error('persistent failure') + }) + + const result = focusExistingMainWindow({ + app: makeFakeApp(), + getWindow: () => null, + openWindow, + warn, + setTimeout: timer.setTimeout + }) + + expect(result).toBe('pending') + expect(openWindow).toHaveBeenCalledTimes(1) + + timer.run(300) + timer.run(300) + // Why: REOPEN_MAX_ATTEMPTS caps total tries at 3 (1 initial + 2 retries); + // a further run() call must not schedule or fire a 4th attempt. + expect(openWindow).toHaveBeenCalledTimes(3) + expect(warn).toHaveBeenCalledTimes(3) + timer.run(300) + expect(openWindow).toHaveBeenCalledTimes(3) + expect(warn).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/main/window/focus-existing-window.ts b/src/main/window/focus-existing-window.ts index 1ff9d86478f..3ba740cd2df 100644 --- a/src/main/window/focus-existing-window.ts +++ b/src/main/window/focus-existing-window.ts @@ -25,7 +25,7 @@ function safelyFocusApp(app: Pick): void { } } -function safelyRevealWindow(window: BrowserWindow): void { +export function safelyRevealWindow(window: BrowserWindow): void { if (window.isDestroyed()) { return } @@ -64,6 +64,66 @@ function retryFocus(window: BrowserWindow, app: Pick, setTimer: Fo }, 100) } +// Why: shared so the sync success path and the async retry/adopt callback can't +// drift on win32 reinforcement (moveTop/pulseAlwaysOnTop) or the 100ms focus retry. +function activateWindow( + window: BrowserWindow, + app: Pick, + platform: NodeJS.Platform, + setTimer: FocusTimer +): void { + safelyFocusApp(app) + safelyRevealWindow(window) + if (platform === 'win32') { + try { + window.moveTop() + } catch { + // Older Electron versions or destroyed windows may reject this; focus retry remains. + } + pulseAlwaysOnTop(window, setTimer) + } + retryFocus(window, app, setTimer) +} + +// Why: a second-instance/activate reopen can race transient startup pressure +// (e.g. GPU/process churn right after another launch attempt exits); one +// swallowed throw would otherwise strand the app with no window until some +// later external trigger happens to retry. +const REOPEN_MAX_ATTEMPTS = 3 +const REOPEN_RETRY_DELAY_MS = 300 + +function openWindowWithRetry( + opts: Pick, + platform: NodeJS.Platform, + setTimer: FocusTimer, + attempt: number +): BrowserWindow | null { + try { + return opts.openWindow() + } catch (error) { + opts.warn?.('[window] Failed to reopen main window for second-instance launch', error) + if (attempt >= REOPEN_MAX_ATTEMPTS) { + return null + } + setTimer(() => { + // Why: openWindow() (openMainWindow) is not idempotent — it constructs and + // registers a fresh BrowserWindow on every call. Between attempts another + // path (or a first attempt that threw after creating its window) may have + // produced a live window, so adopt it instead of opening a duplicate that + // would orphan the one already on screen. + const existing = opts.getWindow() + const window = + existing && !existing.isDestroyed() + ? existing + : openWindowWithRetry(opts, platform, setTimer, attempt + 1) + if (window) { + activateWindow(window, opts.app, platform, setTimer) + } + }, REOPEN_RETRY_DELAY_MS) + return null + } +} + export function focusExistingMainWindow( opts: FocusExistingMainWindowOptions ): FocusExistingMainWindowResult { @@ -76,25 +136,13 @@ export function focusExistingMainWindow( if (!opts.app.isReady()) { return 'pending' } - try { - window = opts.openWindow() - openedWindow = true - } catch (error) { - opts.warn?.('[window] Failed to reopen main window for second-instance launch', error) + window = openWindowWithRetry(opts, platform, setTimer, 1) + if (!window) { return 'pending' } + openedWindow = true } - safelyFocusApp(opts.app) - safelyRevealWindow(window) - if (platform === 'win32') { - try { - window.moveTop() - } catch { - // Older Electron versions or destroyed windows may reject this; focus retry remains. - } - pulseAlwaysOnTop(window, setTimer) - } - retryFocus(window, opts.app, setTimer) + activateWindow(window, opts.app, platform, setTimer) return openedWindow ? 'opened' : 'focused' } diff --git a/src/main/window/privileged-window-navigation.ts b/src/main/window/privileged-window-navigation.ts new file mode 100644 index 00000000000..a5afbbe0a98 --- /dev/null +++ b/src/main/window/privileged-window-navigation.ts @@ -0,0 +1,33 @@ +import { shell, type WebContents } from 'electron' +import { is } from '@electron-toolkit/utils' +import { normalizeExternalBrowserUrl } from '../../shared/browser-url' + +/** Keep remote documents from inheriting an Orca window's privileged preload. */ +export function installPrivilegedWindowNavigationPolicy(contents: WebContents): void { + contents.setWindowOpenHandler(({ url }) => { + const externalUrl = normalizeExternalBrowserUrl(url) + if (externalUrl) { + void shell.openExternal(externalUrl) + } + return { action: 'deny' } + }) + + contents.on('will-navigate', (event, url) => { + const externalUrl = normalizeExternalBrowserUrl(url) + if (externalUrl) { + if (is.dev && process.env.ELECTRON_RENDERER_URL) { + try { + const target = new URL(externalUrl) + const allowed = new URL(process.env.ELECTRON_RENDERER_URL) + if (target.origin === allowed.origin) { + return + } + } catch { + // Fall through and block malformed navigation targets. + } + } + void shell.openExternal(externalUrl) + } + event.preventDefault() + }) +} diff --git a/src/main/window/window-bounds-validation.ts b/src/main/window/window-bounds-validation.ts new file mode 100644 index 00000000000..f358ef391c6 --- /dev/null +++ b/src/main/window/window-bounds-validation.ts @@ -0,0 +1,35 @@ +import { screen } from 'electron' + +export type WindowRect = { x: number; y: number; width: number; height: number } + +/** + * True when `rect` overlaps some currently-attached display's workArea by at + * least the given visible width/height. Used to reject persisted bounds that + * would restore a window off-screen — e.g. saved while an external monitor was + * connected. workArea excludes the menu bar / dock, so a rect hidden entirely + * under the dock is also correctly rejected. Requiring a *meaningful* overlap + * (not just >0) avoids a one-pixel sliver leaving the titlebar unreachable. + */ +export function rectHasVisibleAreaOnAnyDisplay( + rect: WindowRect, + minVisibleWidth: number, + minVisibleHeight: number +): boolean { + try { + return screen.getAllDisplays().some((display) => { + const wa = display.workArea + const overlapX = Math.max( + 0, + Math.min(rect.x + rect.width, wa.x + wa.width) - Math.max(rect.x, wa.x) + ) + const overlapY = Math.max( + 0, + Math.min(rect.y + rect.height, wa.y + wa.height) - Math.max(rect.y, wa.y) + ) + return overlapX >= minVisibleWidth && overlapY >= minVisibleHeight + }) + } catch (err) { + console.warn('[window] screen.getAllDisplays() threw; treating bounds as off-screen', err) + return false + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5fb45b992ed..b031ff3aff4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,11 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload +} from '../shared/terminal-preview' import type { TerminalTabCloseRequest, TerminalTabCloseResponse @@ -58,7 +63,10 @@ import type { TaskSourceContext } from '../shared/task-source-context' import type { LinearIssueAttributeFilter } from '../shared/linear-issue-attribute-filter' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../shared/agent-session-resume' import type { LocalhostWorktreeLabelResult, LocalhostWorktreeLabelRoute @@ -258,6 +266,7 @@ import type { GetProjectViewTableResult, GitHubProjectCommentMutationResult, GitHubProjectMutationResult, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListAssignableUsersBySlugArgs, ListAssignableUsersBySlugResult, @@ -433,6 +442,10 @@ import type { AiVaultSubagentListArgs, AiVaultSubagentListResult } from '../shared/ai-vault-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../shared/ai-vault-resume-preparation' import type { AgentType, NativeChatMessage, @@ -822,6 +835,9 @@ export type OpenCodeUsageApi = { export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise + prepareSessionResume: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise /** Lists the Task subagent transcripts of one session, on demand. */ listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ @@ -1277,6 +1293,7 @@ export type PreloadApi = { envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -1477,11 +1494,11 @@ export type PreloadApi = { repoSlug: (args: { repoPath: string repoId?: string - }) => Promise<{ owner: string; repo: string } | null> + }) => Promise<{ owner: string; repo: string; host?: string } | null> repoUpstream: (args: { repoPath: string repoId?: string - }) => Promise<{ owner: string; repo: string } | null> + }) => Promise<{ owner: string; repo: string; host?: string } | null> prForBranch: (args: { repoPath: string repoId?: string @@ -1520,6 +1537,7 @@ export type PreloadApi = { repoId?: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' }) => Promise | null> @@ -1538,6 +1556,7 @@ export type PreloadApi = { prFileContents: ( args: GitHubRepoSelectorArgs & { prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] @@ -1591,6 +1610,7 @@ export type PreloadApi = { prNumber: number headSha?: string failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true; count: number } | { ok: false; error: string }> prComments: (args: { @@ -1607,10 +1627,12 @@ export type PreloadApi = { sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null }) => Promise setPRFileViewed: ( args: GitHubRepoSelectorArgs & { prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1642,18 +1664,21 @@ export type PreloadApi = { args: GitHubRepoSelectorArgs & { prNumber: number updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> requestPRReviewers: ( args: GitHubRepoSelectorArgs & { prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> removePRReviewers: ( args: GitHubRepoSelectorArgs & { prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null } ) => Promise<{ ok: true } | { ok: false; error: string }> updateIssue: ( @@ -1716,9 +1741,11 @@ export type PreloadApi = { */ rateLimit: (args?: { force?: boolean }) => Promise /** Explains scope_missing ProjectV2 failures — notably a shell `GITHUB_TOKEN` shadowing the keyring credential, where `gh auth refresh` is a no-op. */ - diagnoseAuth: () => Promise + diagnoseAuth: (args?: { host?: string }) => Promise // ── ProjectV2 (GitHub Projects) ───────────────────────────────── - listAccessibleProjects: () => Promise + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ) => Promise resolveProjectRef: (args: ResolveProjectRefArgs) => Promise listProjectViews: (args: ListProjectViewsArgs) => Promise getProjectViewTable: (args: GetProjectViewTableArgs) => Promise @@ -2205,6 +2232,29 @@ export type PreloadApi = { } ) => Promise } + dashboard: { + openPopout: () => Promise + publishSnapshot: (snapshot: DashboardSnapshot) => Promise + getPopoutOpen: () => Promise + onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void + onSnapshotRequested: (callback: () => void) => () => void + onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void + onAckAgent: (callback: (paneKey: string) => void) => () => void + requestSnapshot: () => Promise + onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void + revealAgent: (args: DashboardRevealAgentArgs) => Promise + ackAgent: (paneKey: string) => Promise + } + terminalPreview: { + connect: ( + ptyId: string, + opts?: { scrollbackRows?: number } + ) => Promise + input: (ptyId: string, data: string) => Promise + ack: (ptyId: string, bytes: number) => Promise + unsubscribe: (ptyId: string) => Promise + onData: (callback: (payload: TerminalPreviewDataPayload) => void) => () => void + } developerPermissions: { getStatus: () => Promise request: (args: { id: DeveloperPermissionId }) => Promise @@ -2832,6 +2882,7 @@ export type PreloadApi = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' diff --git a/src/preload/index.ts b/src/preload/index.ts index be5db015851..eed0de1c6d5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,12 +4,20 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' +import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload +} from '../shared/terminal-preview' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { @@ -25,6 +33,7 @@ import type { GitHubAssignableUser, GitHubCommentResult, GitHubCreateIssueResult, + GitHubOwnerRepo, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -108,6 +117,7 @@ import type { GetProjectViewTableResult, GitHubProjectCommentMutationResult, GitHubProjectMutationResult, + ListAccessibleProjectsArgs, ListAccessibleProjectsResult, ListAssignableUsersBySlugArgs, ListAssignableUsersBySlugResult, @@ -176,6 +186,7 @@ import type { } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' +import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' import type { NativeChatAppendedPayload, @@ -789,6 +800,7 @@ const api = { envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -1193,6 +1205,7 @@ const api = { repoId?: string owner: string repo: string + host?: string number: number type: 'issue' | 'pr' }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), @@ -1217,6 +1230,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: string @@ -1259,7 +1273,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number headSha?: string - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prChecks', args), @@ -1271,7 +1285,7 @@ const api = { workflowRunId?: number checkName?: string url?: string | null - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { @@ -1281,6 +1295,7 @@ const api = { prNumber: number headSha?: string failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => ipcRenderer.invoke('gh:rerunPRChecks', args), @@ -1289,7 +1304,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prComments', args), @@ -1299,6 +1314,7 @@ const api = { sourceContext?: TaskSourceContext | null threadId: string resolve: boolean + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), setPRFileViewed: (args: { @@ -1306,6 +1322,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1316,7 +1333,7 @@ const api = { repoId?: string prNumber: number title: string - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), mergePR: (args: { @@ -1325,7 +1342,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args), @@ -1336,7 +1353,7 @@ const api = { prNumber: number enabled: boolean method?: 'merge' | 'squash' | 'rebase' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:setPRAutoMerge', args), @@ -1346,6 +1363,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:updatePRState', args), @@ -1355,6 +1373,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:requestPRReviewers', args), @@ -1364,6 +1383,7 @@ const api = { sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] + prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:removePRReviewers', args), @@ -1383,7 +1403,7 @@ const api = { number: number body: string type?: 'issue' | 'pr' - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { @@ -1396,7 +1416,7 @@ const api = { threadId?: string path?: string line?: number - prRepo?: { owner: string; repo: string } | null + prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), addPRReviewComment: (args: { @@ -1404,6 +1424,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -1448,11 +1469,14 @@ const api = { rateLimit: (args?: { force?: boolean }): Promise => ipcRenderer.invoke('gh:rateLimit', args), - diagnoseAuth: (): Promise => ipcRenderer.invoke('gh:diagnoseAuth'), + diagnoseAuth: (args?: { host?: string }): Promise => + ipcRenderer.invoke('gh:diagnoseAuth', args), // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── - listAccessibleProjects: (): Promise => - ipcRenderer.invoke('gh:listAccessibleProjects'), + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ): Promise => + ipcRenderer.invoke('gh:listAccessibleProjects', args), resolveProjectRef: (args: ResolveProjectRefArgs): Promise => ipcRenderer.invoke('gh:resolveProjectRef', args), listProjectViews: (args: ListProjectViewsArgs): Promise => @@ -2068,6 +2092,73 @@ const api = { ): Promise => ipcRenderer.invoke('onboarding:update', updates) }, + dashboard: { + // Open the pop-out dashboard window, or focus it if already open. + openPopout: (): Promise => ipcRenderer.invoke('dashboardPopout:open'), + + // ── Producer side (main window) ────────────────────────────────────── + publishSnapshot: (snapshot: DashboardSnapshot): Promise => + ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), + getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), + onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) + ipcRenderer.on('dashboard:popoutOpenChanged', listener) + return () => ipcRenderer.removeListener('dashboard:popoutOpenChanged', listener) + }, + onSnapshotRequested: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('dashboard:snapshotRequested', listener) + return () => ipcRenderer.removeListener('dashboard:snapshotRequested', listener) + }, + onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardRevealAgentArgs): void => + callback(args) + ipcRenderer.on('ui:revealDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) + }, + onAckAgent: (callback: (paneKey: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => + callback(paneKey) + ipcRenderer.on('ui:ackDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) + }, + + // ── Consumer side (pop-out window) ─────────────────────────────────── + requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), + onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => + callback(snapshot) + ipcRenderer.on('dashboard:snapshot', listener) + return () => ipcRenderer.removeListener('dashboard:snapshot', listener) + }, + revealAgent: (args: DashboardRevealAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:revealAgent', args), + ackAgent: (paneKey: string): Promise => + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }) + }, + + terminalPreview: { + connect: ( + ptyId: string, + opts?: { scrollbackRows?: number } + ): Promise => + ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), + input: (ptyId: string, data: string): Promise => + ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), + ack: (ptyId: string, bytes: number): Promise => + ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), + unsubscribe: (ptyId: string): Promise => + ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), + onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: TerminalPreviewDataPayload + ): void => callback(payload) + ipcRenderer.on('terminalPreview:data', listener) + return () => ipcRenderer.removeListener('terminalPreview:data', listener) + } + }, + developerPermissions: { getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), request: (args: { id: string }): Promise => @@ -3400,6 +3491,7 @@ const api = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' @@ -3423,6 +3515,7 @@ const api = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' @@ -3824,6 +3917,8 @@ const api = { aiVault: { listSessions: (args?: AiVaultListArgs): Promise => ipcRenderer.invoke('aiVault:listSessions', args), + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => + ipcRenderer.invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => ipcRenderer.invoke('aiVault:listSubagentSessions', args), onWindowFocused: (callback: () => void): (() => void) => { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 0c0b33d9b94..3e01840c7b5 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -182,6 +182,48 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it("does not forward Orca's own NODE_ENV into the spawned shell", async () => { + // Why: NODE_ENV in the relay host process is a build-mode flag, not the + // user's; leaking it breaks `next build` and Vitest in the terminal. + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnOptions.env.NODE_ENV).toBeUndefined() + expect(spawnOptions.env.PATH).toBe(process.env.PATH) + }) + + it('keeps a renderer-supplied NODE_ENV for the spawned shell', async () => { + // Why: only the ambient value is stripped; an explicit request still wins. + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { NODE_ENV: 'production' } + }) + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } + + const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnOptions.env.NODE_ENV).toBe('production') + }) + it('normalizes a missing native binding as degraded node-pty availability', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error( @@ -1010,29 +1052,131 @@ describe('PtyHandler', () => { }) it('leaves startup queries untouched for an unsupported relay capability version', async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - await dispatcher.callRequest('pty.spawn', { - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1, - startupIngress: { - colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000 + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + try { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() } - }) + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 + } + }) - const query = '\x1b]10;?\x07' - dataCallback!(query) - vi.advanceTimersByTime(8) + const query = '\x1b]10;?\x07' + dataCallback!(query) + vi.advanceTimersByTime(8) - expect(term.write).not.toHaveBeenCalled() - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query }) + expect(term.write).not.toHaveBeenCalled() + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('consumes a color query at a native Windows SSH relay owner', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { shellOverride: 'powershell.exe' }) + + dataCallback!('\x1b]10;?\x07') + vi.advanceTimersByTime(8) + + expect(term.write).not.toHaveBeenCalled() + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '', + rawLength: '\x1b]10;?\x07'.length, + seq: '\x1b]10;?\x07'.length, + transformed: true + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('forwards color queries from a POSIX SSH relay owner', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + try { + let dataCallback: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', { shellOverride: '/bin/bash' }) + const query = '\x1b]10;?\x07' + + dataCallback!(query) + vi.advanceTimersByTime(8) + + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('keeps renderer color replies for a Windows SSH relay that owns WSL', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' + + dataCallback!('\x1b]11;?\x07') + vi.advanceTimersByTime(8) + dispatcher.callNotification('pty.data', { id: 'pty-1', data: reply }) + + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b]11;?\x07' + }) + expect(term.write).toHaveBeenCalledWith(reply) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } }) it('coalesces background PTY output before notifying the client', async () => { @@ -1274,6 +1418,24 @@ describe('PtyHandler', () => { expect(mockResize).toHaveBeenCalledWith(120, 40) }) + it('reports the PTY grid actually applied by node-pty', async () => { + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + cols: 132, + rows: 43, + onData: vi.fn(), + onExit: vi.fn() + }) + + const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } + + await expect(dispatcher.callRequest('pty.getSize', { id: spawned.id })).resolves.toEqual({ + cols: 132, + rows: 43 + }) + await expect(dispatcher.callRequest('pty.getSize', { id: 'missing' })).resolves.toBeNull() + }) + it('kills PTY on shutdown with SIGTERM by default', async () => { const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 28df63b7960..b9c4fae001a 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -39,12 +39,14 @@ import { } from '../shared/git-credential-prompt-env' import { isTuiAgent } from '../shared/tui-agent-config' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' +import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' import { PTY_STARTUP_INGRESS_VERSION, PtyStartupIngress, parsePtyStartupIngressIntent, type PtyIngressEmission } from '../shared/pty-startup-ingress' +import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' function isMissingNodePtyNativeBinding(error: unknown): boolean { return ( @@ -80,6 +82,7 @@ type ManagedPty = { gracefulKillSent?: boolean startupIngress?: PtyStartupIngress startupIngressIntent?: ReturnType + ownerBackend: PtyOwnerBackend } type PendingPtyOutput = { @@ -387,7 +390,7 @@ export class PtyHandler { ): Record { const baseEnv = mergeGitConfigEnvProtocol( { - ...process.env, + ...stripInheritedBuildModeEnv(process.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', @@ -500,6 +503,7 @@ export class PtyHandler { } managed.startupIngress ??= new PtyStartupIngress({ ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), + ownerBackend: managed.ownerBackend, write: (data) => managed.pty.write(data), onEmission: emitIngressData }) @@ -575,6 +579,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p)) this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) + this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) @@ -900,9 +905,7 @@ export class PtyHandler { const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined const startupIngressIntent = params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION - ? parsePtyStartupIngressIntent(params.startupIngress, { - allowWindowsEchoProjection: false - }) + ? parsePtyStartupIngressIntent(params.startupIngress) : undefined const managed: ManagedPty = { id, @@ -916,6 +919,11 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: shell, + wslDistro: terminalWindowsWslDistro + }), ...(startupIngressIntent ? { startupIngressIntent } : {}), ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand @@ -1021,6 +1029,16 @@ export class PtyHandler { } } + private async getSize( + params: Record + ): Promise<{ cols: number; rows: number } | null> { + const managed = this.ptys.get(params.id as string) + if (!managed || managed.disposed) { + return null + } + return { cols: managed.pty.cols, rows: managed.pty.rows } + } + private async shutdown(params: Record): Promise { const id = params.id as string const immediate = params.immediate as boolean @@ -1313,6 +1331,10 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: shell + }), ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index 544e1509c12..b880c5a64e7 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -8,6 +8,7 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) +import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows' import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot' import { getForegroundProcessName, @@ -50,6 +51,7 @@ async function withProcessPlatform( beforeEach(() => { execFileMock.mockReset() resetProcessTableSnapshotForTests() + resetWindowsProcessRowsSnapshotForTests() }) describe('isProcessAlive', () => { @@ -269,6 +271,94 @@ describe('getForegroundProcessName', () => { }) }) + // Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364). + it('reports the outer omp wrapper over the wrapped pi child from a shell fallback', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'bash')).resolves.toBe('omp') + }) + }) + + it('rescans for the omp wrapper when node-pty reports the wrapped pi as foreground', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') + }) + }) + + it('returns an outer omp fallback without a process-table scan', async () => { + mockExecFile(() => new Error('unexpected process-table scan')) + + await expect(getForegroundProcessName(100, 'omp')).resolves.toBe('omp') + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('rescans a Windows pi fallback for its outer omp wrapper', async () => { + await withProcessPlatform('win32', async () => { + mockExecFile((command) => { + if (command === 'powershell.exe') { + return { + stdout: JSON.stringify([ + { + CommandLine: 'powershell.exe', + ExecutablePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'omp.exe', + ExecutablePath: 'C:\\Tools\\omp.exe', + Name: 'omp.exe', + ParentProcessId: 100, + ProcessId: 101 + }, + { + CommandLine: 'pi.exe', + ExecutablePath: 'C:\\Tools\\pi.exe', + Name: 'pi.exe', + ParentProcessId: 101, + ProcessId: 102 + } + ]) + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') + }) + }) + + it('keeps a pi fallback as pi when no omp wrapper is in the tree', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { stdout: ['100 99 Ss bash -l', '101 100 S+ pi'].join('\n') } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('pi') + }) + }) + it('falls back to the root process command when descendant inspection fails', async () => { mockExecFile((_command, args) => { if (args[0] === '-axo') { diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 87035c9510d..6d589b8c6a0 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -11,6 +11,10 @@ import { } from '../shared/agent-process-recognition' import { getFirstCommandToken } from '../shared/command-token-scanner' import { getProcessTableSnapshot, type ProcessTableRow } from '../shared/process-table-snapshot' +import { + resolveOuterWrapperForegroundProcess, + shouldInspectOuterWrapperForegroundProcess +} from '../shared/foreground-wrapper-agent' import { isShellProcess } from '../shared/shell-process-detection' import { resolveWindowsAgentForegroundProcess, @@ -232,7 +236,9 @@ async function getRecognizedForegroundDescendant( for (const candidate of inspectionCandidates) { const recognized = recognizeAgentProcessFromCommandLine(candidate.command) if (recognized) { - return recognized.processName + // Why: return the outer wrapper (omp) rather than the deeper wrapped child + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) } } } catch { @@ -251,6 +257,20 @@ export async function getForegroundProcessName( if (fallbackProcess) { const fallbackRecognition = recognizeAgentProcess(fallbackProcess) if (fallbackRecognition) { + // Why: node-pty can report OMP's wrapped Pi; enrich only that ambiguous + // fallback so authoritative OMP reads keep the zero-subprocess fast path. + if (shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) { + if (process.platform === 'win32') { + return ( + (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? + fallbackRecognition.processName + ) + } + return ( + (await getRecognizedForegroundDescendant(pid, fallbackProcess)) ?? + fallbackRecognition.processName + ) + } return fallbackRecognition.processName } if (process.platform === 'win32') { diff --git a/src/renderer/popout.html b/src/renderer/popout.html new file mode 100644 index 00000000000..944bcd23b30 --- /dev/null +++ b/src/renderer/popout.html @@ -0,0 +1,13 @@ + + + + + Orca Agent Dashboard + + + + +
+ + + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index faf1b7bb9ba..b228a47158f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -91,6 +91,7 @@ import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' +import { useDashboardPopoutBridge } from './components/dashboard/useDashboardPopoutBridge' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { resolvePrimarySelectionMiddleClickPaste, @@ -715,6 +716,7 @@ function App(): React.JSX.Element { useEditorExternalWatch() useGlobalFileDrop() useAutoAckViewedAgent() + useDashboardPopoutBridge(settings?.experimentalAgentDashboardPopout === true) useEffect(() => { return onOnboardingReopened(setOnboarding) diff --git a/src/renderer/src/assets/mobile-page-qr-layout.test.ts b/src/renderer/src/assets/mobile-page-qr-layout.test.ts new file mode 100644 index 00000000000..b08d4201208 --- /dev/null +++ b/src/renderer/src/assets/mobile-page-qr-layout.test.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const mobilePageCss = fs.readFileSync(new URL('./mobile-page.css', import.meta.url), 'utf8') + +describe('mobile page QR grid layout (#9700)', () => { + it('defines a shared large-QR size token used by the box and grid tracks', () => { + expect(mobilePageCss).toMatch(/--mp-qr-large-size:\s*184px/) + expect(mobilePageCss).toMatch( + /\.mobile-page-root \.mp-qr-large\s*{[^}]*width:\s*var\(--mp-qr-large-size\)/s + ) + }) + + // Why: `auto` sized the QR track to the unwrapped relay-degraded notice and + // starved the copy column so CJK wrapped one glyph per line (#9700). + it('pins step and pairing QR columns to the QR size instead of auto', () => { + expect(mobilePageCss).toMatch( + /\.mobile-page-root \.mp-step2-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+var\(--mp-qr-large-size\)/s + ) + expect(mobilePageCss).toMatch( + /\.mobile-page-root \.mp-pairing-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+var\(--mp-qr-large-size\)/s + ) + expect(mobilePageCss).not.toMatch( + /\.mobile-page-root \.mp-(?:step2|pairing)-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto/s + ) + }) + + it('lets under-QR stack children shrink so long notices wrap inside the track', () => { + expect(mobilePageCss).toMatch( + /\.mobile-page-root \.mp-qr-stack\s*>\s*\*\s*{[^}]*max-width:\s*100%[^}]*min-width:\s*0/s + ) + }) +}) diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index cc009527d33..6c0d16ee55e 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -37,6 +37,7 @@ --mp-flow-card-border-width: 1px; --mp-flow-card-inset: calc(var(--mp-flow-card-padding) + var(--mp-flow-card-border-width)); --mp-flow-shell-height: 400px; + --mp-qr-large-size: 184px; --m-bg-base: #111111; --m-bg-panel: #1a1a1a; --m-bg-raised: #242424; @@ -583,14 +584,15 @@ .mobile-page-root .mp-qr-large { box-sizing: border-box; - width: 184px; - height: 184px; + width: var(--mp-qr-large-size); + height: var(--mp-qr-large-size); + /* Why: keep img size in lockstep with the box (padding 10px each side). */ padding: 10px; } .mobile-page-root .mp-qr-large img { - width: 164px; - height: 164px; + width: calc(var(--mp-qr-large-size) - 20px); + height: calc(var(--mp-qr-large-size) - 20px); } .mobile-page-root .mp-qr-refreshing { @@ -615,6 +617,19 @@ flex-direction: column; align-items: center; gap: 10px; + /* Why: stack sits in a fixed QR grid track; fill the track so under-QR copy + has a definite width to wrap against. */ + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +/* Why: align-items:center sizes children to max-content. Cap width AND set + min-width:0 so max-width can actually win over flex/grid min-size:auto and + long under-QR copy (relay-degraded notice, regenerate label) wraps. */ +.mobile-page-root .mp-qr-stack > * { + max-width: 100%; + min-width: 0; } .mobile-page-root .mp-link-under { @@ -639,17 +654,24 @@ cursor: not-allowed; } -/* Step 1 (Get the app) — borderless layout, page-1 typography */ +/* Step 1 (Get the app) + Step 2 (Pair) — QR column width */ +/* Why: pin the QR column to the QR's own width rather than `auto`. When the flow + card is narrow (mp-hero pins it to minmax(360px,560px)) an `auto` track grows + to its content's max-content — the relay-degraded notice under the pairing QR + is an unwrapped single line — starving the minmax(0,1fr) copy column to ~1 char + wide, so CJK copy wrapped one glyph per line (#9700). A fixed track stops that + starvation; .mp-qr-stack min-width:0 + max-width:100% (and the notice's + min-w-0 text span) stop the notice overflowing the track. */ .mobile-page-root .mp-step2-layout { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) var(--mp-qr-large-size); gap: 28px; align-items: start; } .mobile-page-root .mp-pairing-layout { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) var(--mp-qr-large-size); grid-template-areas: 'copy qr' 'relay qr' @@ -661,10 +683,12 @@ .mobile-page-root .mp-pairing-copy { grid-area: copy; + min-width: 0; } .mobile-page-root .mp-pairing-qr { grid-area: qr; + min-width: 0; /* Why: the QR belongs to the pairing heading, not the paragraph below it. */ margin-top: 48px; } diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index a4f11bdc0bb..522a47fc03e 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -6,11 +6,7 @@ import { useAppStore } from '../store' import { selectCodexRestartInputs } from './codex-restart-chip-inputs' import { translate } from '@/i18n/i18n' import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus' -import { - buildCodexRestartNoticeKey, - createCodexRestartOverlayCollapseState, - getCodexRestartOverlayCollapseState -} from './codex-restart-overlay-collapse' +import { buildCodexRestartNoticeKey } from './codex-restart-notice-key' const EMPTY_TABS: { id: string }[] = [] @@ -99,16 +95,6 @@ export default function CodexRestartChip({ const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice) const noticeKey = restartNotice ? buildCodexRestartNoticeKey(restartNotice) : null - const [collapseState, setCollapseState] = useState(() => - createCodexRestartOverlayCollapseState(noticeKey) - ) - - const currentCollapseState = getCodexRestartOverlayCollapseState(collapseState, noticeKey) - useEffect(() => { - // Why: a new account switch must reopen loud mode without updating state - // during render, which can trip React's external-store snapshot guard. - setCollapseState((state) => getCodexRestartOverlayCollapseState(state, noticeKey)) - }, [noticeKey]) if (staleWorktreePtyIds.length === 0 || !restartNotice) { return null @@ -122,22 +108,11 @@ export default function CodexRestartChip({ dismissStaleWorktreePtyIds(staleWorktreePtyIds, clearCodexRestartNotice) } - if (currentCollapseState.collapsed) { - return ( - setCollapseState(createCodexRestartOverlayCollapseState(noticeKey))} - onRestart={handleRestart} - /> - ) - } - return ( setCollapseState({ noticeKey, collapsed: true })} onDismiss={handleDismiss} onRestart={handleRestart} /> @@ -148,14 +123,12 @@ function LoudRestartOverlay({ isVisible, noticeKey, restartNotice, - onCollapse, onDismiss, onRestart }: { isVisible: boolean noticeKey: string | null restartNotice: RestartNotice - onCollapse: () => void onDismiss: () => void onRestart: () => void }): React.JSX.Element { @@ -218,9 +191,6 @@ function LoudRestartOverlay({ - - - - ) -} diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 30c4a491bd4..815c8dac7e4 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -187,6 +187,8 @@ import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods } from '../../../shared/github-pr-merge-methods' +import { githubProjectHost } from '../../../shared/github-project-identity' +import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' import { findGithubIssueWorkspaceAttachment, getGithubWorkItemWorkspaceAttachmentLabel @@ -227,18 +229,18 @@ import { type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' -// Why: the dialog lacks owner/repo context, so recover it from the canonical URL present on every item; non-GitHub hosts return null (matches the indicator's suppression rule). +// Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { try { const parsed = new URL(url) - if (parsed.hostname !== 'github.com') { + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.host) { return null } const segments = parsed.pathname.split('/').filter(Boolean) if (segments.length < 2) { return null } - return { owner: segments[0], repo: segments[1] } + return { owner: segments[0], repo: segments[1], host: parsed.host } } catch { return null } @@ -297,6 +299,8 @@ function normalizeItemDialogTab( export type GitHubItemDialogProjectOrigin = { owner: string repo: string + /** GitHub host (e.g. GHES); absent means github.com. */ + host?: string number: number type: 'issue' | 'pr' projectId: string @@ -304,6 +308,25 @@ export type GitHubItemDialogProjectOrigin = { cacheKey: string } +// Why: every PR mutation needs the same host-pinned identity so process GH_HOST +// cannot redirect a github.com item or a Project row to the wrong server. +function resolvePullRequestRepo( + item: Pick, + projectOrigin?: Pick +): GitHubOwnerRepo | null { + const repo = + item.prRepo ?? + (projectOrigin + ? { + owner: projectOrigin.owner, + repo: projectOrigin.repo, + host: projectOrigin.host + } + : null) ?? + parseOwnerRepoFromItemUrl(item.url) + return repo ? { ...repo, host: githubProjectHost(repo.host) } : null +} + type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null @@ -512,7 +535,8 @@ function PRAssigneesPanel({ slugOwner, slugRepo, assigneeLogins, - sourceSettings + sourceSettings, + projectOrigin?.host ?? assigneeSlug?.host ) const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath @@ -683,12 +707,14 @@ function PRReviewersPanel({ loading, repoPath, sourceContext, + projectOrigin, onReviewersRequested }: { item: GitHubWorkItem loading: boolean repoPath: string | null sourceContext?: TaskSourceContext | null + projectOrigin?: GitHubItemDialogProjectOrigin onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { const [open, setOpen] = useState(false) @@ -788,19 +814,23 @@ function PRReviewersPanel({ return Array.from(byLogin.values()) }, [item.author, item.latestReviews, localReviewRequests]) - const reviewSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const reviewRepo = useMemo( + () => resolvePullRequestRepo(item, projectOrigin), + [item, projectOrigin] + ) const reviewerMetadataBySlug = useRepoAssigneesBySlug( - open && reviewSlug ? reviewSlug.owner : null, - open && reviewSlug ? reviewSlug.repo : null, + open && reviewRepo ? reviewRepo.owner : null, + open && reviewRepo ? reviewRepo.repo : null, reviewerSeedUsers.map((user) => user.login), - sourceSettings + sourceSettings, + reviewRepo?.host ) const reviewerMetadataByPath = useRepoAssignees( - open && !reviewSlug ? repoPath : null, - open && !reviewSlug ? item.repoId : null, + open && !reviewRepo ? repoPath : null, + open && !reviewRepo ? item.repoId : null, sourceSettings ) - const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath + const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } const reviewers = getGitHubPRReviewerRows(displayItem) const authorLogin = item.author?.toLowerCase() ?? null @@ -930,7 +960,12 @@ function PRReviewersPanel({ ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.requestPRReviewers', - { repo: runtimeRepo, prNumber: item.number, reviewers: logins }, + { + repo: runtimeRepo, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }, { timeoutMs: 30_000 } ) : await window.api.gh.requestPRReviewers({ @@ -938,7 +973,8 @@ function PRReviewersPanel({ repoId: item.repoId, sourceContext, prNumber: item.number, - reviewers: logins + reviewers: logins, + prRepo: reviewRepo }) if (!reviewerPanelMountedRef.current) { return @@ -1021,7 +1057,12 @@ function PRReviewersPanel({ ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.removePRReviewers', - { repo: runtimeRepo, prNumber: item.number, reviewers: logins }, + { + repo: runtimeRepo, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }, { timeoutMs: 30_000 } ) : await window.api.gh.removePRReviewers({ @@ -1029,7 +1070,8 @@ function PRReviewersPanel({ repoId: item.repoId, sourceContext, prNumber: item.number, - reviewers: logins + reviewers: logins, + prRepo: reviewRepo }) if (!reviewerPanelMountedRef.current) { return @@ -1646,6 +1688,7 @@ function getPRFileContentCacheKey(args: { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null file: GitHubPRFile headSha: string baseSha: string @@ -1659,6 +1702,7 @@ function getPRFileContentCacheKey(args: { repositoryKey, sourceKey, args.prNumber, + args.prRepo ? githubRepoIdentityKey(args.prRepo) : '', args.file.path, args.file.oldPath ?? '', args.file.status, @@ -1672,6 +1716,7 @@ function loadPRFileContents(args: { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null file: GitHubPRFile headSha: string baseSha: string @@ -1692,6 +1737,7 @@ function loadPRFileContents(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, path: args.file.path, oldPath: args.file.oldPath, status: args.file.status, @@ -1705,6 +1751,7 @@ function loadPRFileContents(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, path: args.file.path, oldPath: args.file.oldPath, status: args.file.status, @@ -1737,6 +1784,7 @@ function addIssueCommentForRepo(args: { number: number body: string type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null }): Promise>> { const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) if (runtimeHost) { @@ -1746,7 +1794,8 @@ function addIssueCommentForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), number: args.number, - body: args.body + body: args.body, + prRepo: args.prRepo ?? null }, { timeoutMs: 30_000 } ).then((result) => { @@ -1771,7 +1820,8 @@ function addIssueCommentForRepo(args: { sourceContext: args.sourceContext, number: args.number, body: args.body, - type: args.type + type: args.type, + prRepo: args.prRepo ?? null }) } @@ -1780,6 +1830,7 @@ function addPRReviewCommentForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commitId: string path: string line: number @@ -1794,6 +1845,7 @@ function addPRReviewCommentForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commitId: args.commitId, path: args.path, line: args.line, @@ -1822,6 +1874,7 @@ function addPRReviewCommentForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commitId: args.commitId, path: args.path, line: args.line, @@ -1835,6 +1888,7 @@ function addPRReviewCommentReplyForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null commentId: number body: string threadId?: string @@ -1849,6 +1903,7 @@ function addPRReviewCommentReplyForRepo(args: { { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commentId: args.commentId, body: args.body, threadId: args.threadId, @@ -1877,6 +1932,7 @@ function addPRReviewCommentReplyForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, commentId: args.commentId, body: args.body, threadId: args.threadId, @@ -1913,6 +1969,7 @@ function setPRFileViewedForRepo(args: { repoPath: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean @@ -1924,6 +1981,7 @@ function setPRFileViewedForRepo(args: { 'github.setPRFileViewed', { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId, path: args.path, viewed: args.viewed @@ -1950,6 +2008,7 @@ function setPRFileViewedForRepo(args: { repoId: args.repoId, sourceContext: args.sourceContext, prNumber: args.prNumber, + prRepo: args.prRepo ?? null, pullRequestId: args.pullRequestId, path: args.path, viewed: args.viewed @@ -2109,6 +2168,7 @@ type PRFilesCombinedDiffViewerProps = { repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null prUrl: string headSha: string | undefined baseSha: string | undefined @@ -2124,6 +2184,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, prUrl, headSha, baseSha, @@ -2200,11 +2261,12 @@ function PRFilesCombinedDiffViewer({ JSON.stringify({ repoId, prNumber, + prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null, headSha: headSha ?? null, baseSha: baseSha ?? null, files: diffEntrySignature }), - [baseSha, diffEntrySignature, headSha, prNumber, repoId] + [baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId] ) const [sections, setSections] = useState([]) const [sideBySide, setSideBySide] = useState(false) @@ -2298,6 +2360,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, file, headSha, baseSha @@ -2346,7 +2409,7 @@ function PRFilesCombinedDiffViewer({ ) }) }, - [baseSha, fileByPath, headSha, prNumber, repoId, repoPath, sourceContext] + [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] ) const retrySection = useCallback( @@ -2491,6 +2554,7 @@ function PRFilesCombinedDiffViewer({ repoId, sourceContext, prNumber, + prRepo, commitId: headSha, path: section.path, line: lineNumber, @@ -2513,7 +2577,7 @@ function PRFilesCombinedDiffViewer({ ) return true }, - [headSha, onCommentAdded, prNumber, repoId, repoPath, sourceContext] + [headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext] ) const renderViewedCheckbox = useCallback( @@ -2661,6 +2725,7 @@ function CommentCodeContext({ repoId, sourceContext, prNumber, + prRepo, files, headSha, baseSha @@ -2670,6 +2735,7 @@ function CommentCodeContext({ repoId: string sourceContext?: TaskSourceContext | null prNumber: number + prRepo?: GitHubOwnerRepo | null files: GitHubPRFile[] headSha: string | undefined baseSha: string | undefined @@ -2693,7 +2759,16 @@ function CommentCodeContext({ return } let cancelled = false - loadPRFileContents({ repoPath, repoId, sourceContext, prNumber, file, headSha, baseSha }) + loadPRFileContents({ + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + file, + headSha, + baseSha + }) .then((result) => { if (!cancelled) { setContents(result) @@ -2707,7 +2782,7 @@ function CommentCodeContext({ return () => { cancelled = true } - }, [baseSha, file, headSha, line, prNumber, repoId, repoPath, sourceContext]) + }, [baseSha, file, headSha, line, prNumber, prRepo, repoId, repoPath, sourceContext]) const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( contextExpansionState, @@ -3098,6 +3173,7 @@ function ConversationTab({ } const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) const markdownGitHubRepo = useMemo( () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), [bodySlug, projectOrigin] @@ -3171,6 +3247,7 @@ function ConversationTab({ repoId: item.repoId, sourceContext, prNumber: item.number, + prRepo, commentId: comment.id, body: replyBody, threadId: comment.threadId, @@ -3183,7 +3260,8 @@ function ConversationTab({ sourceContext, number: item.number, body: `@${comment.author} ${replyBody}`, - type: item.type + type: item.type, + prRepo }) if (!result.ok) { @@ -3204,6 +3282,7 @@ function ConversationTab({ item.repoId, item.type, onCommentAdded, + prRepo, repoPath, sourceContext ] @@ -3234,6 +3313,7 @@ function ConversationTab({ loading={loading} repoPath={repoPath} sourceContext={sourceContext} + projectOrigin={projectOrigin} onReviewersRequested={onReviewersRequested} />