diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 24d51006f6b..01724348ba6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -116,6 +116,15 @@ jobs: - name: Enforce max-lines ratchet run: pnpm run check:max-lines-ratchet + - name: Enforce runtime Electron-import ratchet + run: pnpm run check:runtime-electron-ratchet + + # Why both: the ratchet proves nothing reachable from the runtime imports electron, + # which is a property of the import graph. This proves the Node artifact it enables + # actually boots, pairs, creates a worktree and round-trips a real PTY. + - name: Boot orcad and round-trip a terminal + run: pnpm run smoke:orcad-terminal + - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides @@ -406,6 +415,8 @@ jobs: src/main/daemon/node-pty-fd-leak.test.ts \ src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + src/main/pty/codex-shell-launch-preflight.test.ts \ + src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/shell-startup-feature-channel.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ diff --git a/config/runtime-electron-baseline.txt b/config/runtime-electron-baseline.txt new file mode 100644 index 00000000000..f392f70ecc9 --- /dev/null +++ b/config/runtime-electron-baseline.txt @@ -0,0 +1,6 @@ +# Modules reachable from the Orca runtime that import `electron`. +# Generated by config/scripts/check-runtime-electron-ratchet.mjs. +# This list is EMPTY and must stay that way: the runtime boots on plain Node +# (see `pnpm run build:orcad`). Any entry means the runtime got less portable; +# migrate the module behind a host port instead (src/main/host/). + diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs new file mode 100644 index 00000000000..989efd2d8e7 --- /dev/null +++ b/config/scripts/build-orcad.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * Bundle `orcad` — the Orca runtime served from plain Node, no Electron. + * + * Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and + * speech clusters are excluded. That is not a size optimisation — those modules are + * the only ones that statically import `node:sqlite`, so dropping them is what keeps + * the host Node floor at 18 instead of 22.5+. + */ +import { build } from 'esbuild' +import { mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' + +const ROOT = join(import.meta.dirname, '..', '..') +const OUT_DIR = join(ROOT, 'out', 'orcad') +const ENTRY = join(ROOT, 'src/main/orcad/main.ts') + +// Native addons must exist on the host; they cannot be bundled. +// `electron` is external so a residual import fails loudly at require() time rather +// than silently bundling the npm package's installer shim, which is what happened the +// first time and made the bundle look clean while it was not. +// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad +// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the +// server boots without it but every watch install fails. `fsevents` is macOS-only and +// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here +// defensively and appear nowhere in the graph; listing them implied a shipping burden +// that does not exist. +const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] + +/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */ +const jsoncParserEsm = { + name: 'jsonc-parser-esm', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({ + path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js') + })) + } +} + +/** Why: optional native deps reference prebuilt .node files that may not exist here. */ +const externalNativeAddons = { + name: 'external-native-addons', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true })) + } +} + +rmSync(OUT_DIR, { recursive: true, force: true }) +mkdirSync(OUT_DIR, { recursive: true }) + +const result = await build({ + entryPoints: [ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(OUT_DIR, 'orcad.js'), + external: EXTERNAL, + plugins: [jsoncParserEsm, externalNativeAddons], + metafile: true, + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' +}) + +const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint) +// Why check `original` and not just `path`: when electron is bundleable, esbuild +// rewrites `path` to the resolved file under node_modules and the naive check passes +// while the package is very much in the bundle. +const electronImporters = new Set() +for (const [file, info] of Object.entries(result.metafile.inputs)) { + for (const imported of info.imports ?? []) { + const specifier = imported.original ?? imported.path + if (specifier === 'electron' || specifier.startsWith('electron/')) { + electronImporters.add(file) + } + } +} + +if (electronImporters.size > 0) { + console.error( + `[build-orcad] ${electronImporters.size} module(s) in the bundle import electron: +${[...electronImporters].map((f) => ` - ${f}`).join('\n')}` + ) + // Why this can exceed the ratchet baseline: the ratchet measures the graph reachable + // from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to + // install the PTY controller. Once orcad ships, it should become a ratchet entry + // point so the two numbers cannot drift. + process.exitCode = 1 +} else { + console.log( + `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.` + ) +} diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs new file mode 100644 index 00000000000..c50d5df8f46 --- /dev/null +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * Ratchet gate for Electron imports reachable from the Orca runtime. + * + * The runtime is meant to become host-agnostic so it can also run on plain Node + * (see docs/design/node-only-runtime-backend.html). Nothing enforces that today: + * `orca-runtime.ts` reaches ~50 modules that import `electron`, and the number + * silently grows whenever someone adds an import several hops away, because no + * single reviewer sees the transitive edge. + * + * This bundles the runtime with esbuild, reads the metafile for every module that + * imports `electron`, and compares that set to a checked-in baseline. A NEW module + * fails the build; a removed one must be dropped from the baseline. The baseline + * may only shrink, so the migration is measurable and cannot regress. + * + * This is a reachability check, not a lint rule: the point is precisely the edges + * that no per-file rule can see. + * + * Usage: node config/scripts/check-runtime-electron-ratchet.mjs [--write] + */ +import { build } from 'esbuild' +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' + +// Why absolute, not cwd-relative: `pnpm lint` runs from the repo root but CI steps and +// editors do not always, and a cwd-relative miss surfaced as an unhandled ENOENT stack +// instead of a usable message. +const ROOT = path.join(import.meta.dirname, '..', '..') +const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt') + +// The two module graphs a Node backend would have to boot: the runtime service +// itself and the RPC server that fronts it. +const ENTRY_POINTS = [ + path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'), + path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts') +] + +// Native addons and electron cannot be bundled; externalising them is what the +// relay build already does (config/scripts/build-relay.mjs). +const EXTERNAL = [ + 'electron', + 'node-pty', + '@parcel/watcher', + 'better-sqlite3', + 'keytar', + 'fsevents', + 'cpu-features' +] + +/** + * Why: some optional native deps (ssh2's cpu-features) reference a prebuilt `.node` + * that only exists where a build toolchain has run. Resolving them made this gate + * pass on a developer machine and hard-fail on CI. Nothing here needs the addon — + * only the import graph — so mark every `.node` external instead. + */ +const externalNativeAddons = { + name: 'external-native-addons', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true })) + } +} + +export async function collectElectronImporters(entryPoints = ENTRY_POINTS) { + const result = await build({ + entryPoints, + bundle: true, + write: false, + // Why outdir with write:false: esbuild refuses multiple entry points without one, + // even though nothing is emitted — the metafile is all this reads. + outdir: path.join(ROOT, 'runtime-electron-ratchet-metafile-only'), + platform: 'node', + target: 'node20', + format: 'cjs', + external: EXTERNAL, + metafile: true, + absWorkingDir: ROOT, + logLevel: 'silent', + plugins: [externalNativeAddons] + }) + const importers = new Set() + for (const [file, info] of Object.entries(result.metafile.inputs)) { + for (const imported of info.imports ?? []) { + // Subpaths (electron/main) are as unavailable under plain Node as the bare module. + if (imported.path === 'electron' || imported.path.startsWith('electron/')) { + importers.add(path.relative(ROOT, path.resolve(ROOT, file)).split(path.sep).join('/')) + } + } + } + return [...importers].sort() +} + +export function readBaseline(text) { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .sort() +} + +export function diffAgainstBaseline(current, baseline) { + const baselineSet = new Set(baseline) + const currentSet = new Set(current) + return { + added: current.filter((file) => !baselineSet.has(file)), + removed: baseline.filter((file) => !currentSet.has(file)) + } +} + +function renderBaseline(files) { + return [ + '# Modules reachable from the Orca runtime that import `electron`.', + '# Generated by config/scripts/check-runtime-electron-ratchet.mjs.', + '# This list is EMPTY and must stay that way: the runtime boots on plain Node', + '# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;', + '# migrate the module behind a host port instead (src/main/host/).', + '', + ...files + ].join('\n') +} + +async function main() { + const write = process.argv.includes('--write') + const current = await collectElectronImporters() + + if (write) { + writeFileSync(BASELINE_PATH, `${renderBaseline(current)}\n`) + console.log(`[runtime-electron-ratchet] wrote ${current.length} entries to ${BASELINE_PATH}`) + return + } + + const baseline = readBaseline(readFileSync(BASELINE_PATH, 'utf8')) + const { added, removed } = diffAgainstBaseline(current, baseline) + + if (added.length > 0) { + console.error( + `[runtime-electron-ratchet] ${added.length} new module(s) reachable from the Orca runtime now import electron: +${added.map((file) => ` + ${file}`).join('\n')} + +The runtime must stay bootable on plain Node. Put the Electron facility behind a port in +src/main/host/ and depend on the port, or move the code out of the runtime's import graph. +See docs/design/node-only-runtime-backend.html.` + ) + process.exitCode = 1 + return + } + + if (removed.length > 0) { + console.error( + `[runtime-electron-ratchet] ${removed.length} module(s) no longer import electron — nice. +Refresh the baseline so the gate keeps its new, tighter floor: +${removed.map((file) => ` - ${file}`).join('\n')} + + node config/scripts/check-runtime-electron-ratchet.mjs --write` + ) + process.exitCode = 1 + return + } + + console.log(`[runtime-electron-ratchet] ok — ${current.length} entries, unchanged.`) +} + +// Why pathToFileURL and not a `file://` template: on Windows process.argv[1] is a +// native path (C:\repo\...) while import.meta.url is file:///C:/repo/..., so the +// template never matches and the gate would exit 0 without checking anything — a +// lint gate that fails open. Same idiom as check-max-lines-ratchet.mjs:225. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs new file mode 100644 index 00000000000..e993ca32fc5 --- /dev/null +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { + collectElectronImporters, + diffAgainstBaseline, + readBaseline +} from './check-runtime-electron-ratchet.mjs' + +describe('readBaseline', () => { + it('drops comments and blank lines and sorts, so baseline formatting cannot cause a false diff', () => { + expect(readBaseline('# header\n\n b/second.ts \na/first.ts\n')).toEqual([ + 'a/first.ts', + 'b/second.ts' + ]) + }) +}) + +describe('diffAgainstBaseline', () => { + it('reports a module that started importing electron', () => { + expect(diffAgainstBaseline(['a.ts', 'b.ts'], ['a.ts'])).toEqual({ + added: ['b.ts'], + removed: [] + }) + }) + + it('reports a module that stopped, so the baseline is forced to tighten rather than drift', () => { + expect(diffAgainstBaseline(['a.ts'], ['a.ts', 'b.ts'])).toEqual({ + added: [], + removed: ['b.ts'] + }) + }) + + it('is quiet when the set is unchanged', () => { + expect(diffAgainstBaseline(['a.ts'], ['a.ts'])).toEqual({ added: [], removed: [] }) + }) +}) + +describe('the checked-in baseline', () => { + // Why real: the value of this gate is the transitive edges, which a fixture cannot model. + // If this is slow enough to hurt, it is still cheaper than shipping a runtime that + // cannot boot on Node. + it('matches what the runtime actually reaches today', async () => { + const current = await collectElectronImporters() + const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8')) + expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] }) + }, 120_000) + + // Why an exact-empty assertion now: the reachable set reached zero, so "may only + // shrink" has no room left and any entry at all is a regression. This is strictly + // stronger than the old under-src/ check, which only stopped a node_modules path from + // padding a non-empty count. + it('stays empty, so nothing reachable from the runtime imports electron', () => { + const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8')) + expect(baseline).toEqual([]) + }) +}) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 1f51cabc1d3..5f7f8c37530 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -848,6 +848,9 @@ "auto.components.editor.CheckRunDetailsPanel.fd46a70f1a": { "ko": "시작됨" }, + "auto.components.editor.CheckRunJobs.1c0a4d7e02": { + "ko": "성공" + }, "auto.components.editor.CombinedDiffFileTree.d5ac717d65": { "ko": "commit 안 됨" }, diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs new file mode 100644 index 00000000000..21b6cea6b0b --- /dev/null +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -0,0 +1,362 @@ +/** + * Boots the BUILT headless runtime server (`out/main/index.js --serve`), pairs a real + * client to it over the advertised endpoint, creates a terminal, runs a command in it, + * and asserts the output comes back — then shuts down. + * + * Why this exists: "the server started" proves almost nothing. The runtime dispatches + * terminal creation into OrcaRuntimeService, and without an installed headless PTY + * controller that path falls through to a renderer reply that never arrives and times + * out after ten seconds. A boot probe, a port bind, and a `host.platform` call all pass + * against a server whose terminals are dead. Only a PTY round trip catches it. + * + * This is also the acceptance gate for a future Node-only backend + * (docs/design/node-only-runtime-backend.html): the same script should pass against + * `orcad` unchanged, because it drives nothing but the public pairing + RPC surface. + * + * Hard assertions (fail the job): + * - the server emits its ready payload with a pairing offer, + * - a paired client can list worktrees and create a terminal, + * - a command run in that terminal produces its output, + * - the server exits when asked. + */ +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { randomBytes } from 'node:crypto' +import process from 'node:process' + +const projectDir = resolve(import.meta.dirname, '../..') +const serveEntry = join(projectDir, 'out', 'main', 'index.js') +const ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js') +const READY_TIMEOUT_MS = 120_000 +const OUTPUT_TIMEOUT_MS = 30_000 +const SHUTDOWN_TIMEOUT_MS = 15_000 +// Why a random high port: a fixed one collides with a developer's own `orca serve`. +const PORT = 6800 + Math.floor(Number(process.env.ORCA_SMOKE_PORT_OFFSET ?? '0')) + +function log(message) { + process.stdout.write(`[serve-terminal-smoke] ${message}\n`) +} + +function fail(message) { + process.stderr.write(`[serve-terminal-smoke] FAIL: ${message}\n`) + process.exitCode = 1 +} + +/** + * Prefer the CLI built from this checkout over whatever `orca` is on PATH: it is the + * version under test, and a CI runner has no installed Orca app to fall back on. + */ +function resolveCli() { + const built = join(projectDir, 'out', 'cli', 'index.js') + return existsSync(built) + ? { command: process.execPath, prefix: [built] } + : { command: 'orca', prefix: [] } +} + +/** The `orca` CLI, driven with an explicit pairing code so it targets this server only. */ +function orca(pairingCode, args) { + const cli = resolveCli() + const result = spawnSync( + cli.command, + [...cli.prefix, ...args, '--pairing-code', pairingCode, '--json'], + { + encoding: 'utf8', + // Why not shell:true — argument encoding is handled by spawnSync; a shell would + // re-split the pairing code, which is base64url and can contain '='. + shell: false + } + ) + if (result.error) { + throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`) + } + const line = (result.stdout ?? '').trim() + if (!line.startsWith('{')) { + throw new Error(`orca ${args.join(' ')} produced no JSON:\n${result.stdout}\n${result.stderr}`) + } + const parsed = JSON.parse(line) + if (parsed.ok === false) { + throw new Error( + `orca ${args.join(' ')} returned ${parsed.error?.code}: ${parsed.error?.message}` + ) + } + return parsed.result +} + +function waitForReady(child) { + return new Promise((resolvePromise, rejectPromise) => { + let buffered = '' + let serverErr = '' + const timer = setTimeout( + () => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)), + READY_TIMEOUT_MS + ) + // Why read stderr at all: an unread pipe can fill and block the child, and without it + // a boot failure surfaces only as "exited with 1", which says nothing actionable. + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + serverErr += chunk + }) + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + buffered += chunk + for (const line of buffered.split('\n')) { + if (!line.startsWith('{')) { + continue + } + try { + const payload = JSON.parse(line) + if (payload.type === 'orca_server_ready') { + clearTimeout(timer) + resolvePromise(payload) + return + } + } catch { + // Partial line; wait for the rest. + } + } + }) + child.on('exit', (code) => { + clearTimeout(timer) + rejectPromise( + new Error( + `server exited with ${code} before signalling ready${ + serverErr.trim() ? `:\n${serverErr.trim()}` : ' (no stderr)' + }` + ) + ) + }) + }) +} + +function pairingCodeFrom(payload) { + const url = payload?.pairing?.url + if (!url) { + throw new Error('ready payload carried no pairing offer') + } + const code = new URL(url).searchParams.get('code') + if (!code) { + throw new Error(`pairing url had no code: ${url}`) + } + return code +} + +async function waitForNonce(pairingCode, terminalHandle, nonce) { + const deadline = Date.now() + OUTPUT_TIMEOUT_MS + while (Date.now() < deadline) { + const read = orca(pairingCode, ['terminal', 'read', '--terminal', terminalHandle]) + const tail = (read?.terminal?.tail ?? []).map((entry) => String(entry)).join('\n') + if (tail.includes(nonce)) { + return true + } + await new Promise((r) => setTimeout(r, 1_000)) + } + return false +} + +/** + * The two hosts this acceptance drives. + * + * Everything after boot — pairing, worktree list, terminal create, the nonce round trip — + * is identical for both. That is the point: the Node artifact has to satisfy the same + * contract as the Electron server, proven by the same code rather than a parallel test + * that could drift into asserting less. + */ +function resolveLaunch(userDataDir) { + // Why a flag and not just an env var: package scripts have to set this on Windows too, + // and `FOO=bar cmd` is not portable there. + const flagIndex = process.argv.indexOf('--target') + const target = + flagIndex !== -1 ? process.argv[flagIndex + 1] : (process.env.ORCA_SMOKE_TARGET ?? 'electron') + if (target === 'orcad') { + return { + label: `orcad (${ORCAD_ENTRY})`, + command: process.execPath, + args: [ORCAD_ENTRY, '--port', String(PORT), '--json'], + env: { ORCA_USER_DATA: userDataDir } + } + } + if (target !== 'electron') { + throw new Error( + `--target (or ORCA_SMOKE_TARGET) must be 'electron' or 'orcad', got '${target}'` + ) + } + const serveArgs = [ + serveEntry, + '--serve', + '--serve-port', + String(PORT), + '--serve-json', + `--user-data-dir=${userDataDir}` + ] + const override = process.env.ORCA_SMOKE_ELECTRON + return { + label: `electron (${serveEntry})`, + command: override ?? 'npx', + args: override ? serveArgs : ['electron', ...serveArgs], + env: {} + } +} + +/** A throwaway git repo with one commit, so `repo add` has something real to register. */ +function seedGitRepo() { + const dir = mkdtempSync(join(tmpdir(), 'orca-smoke-repo-')) + writeFileSync(join(dir, 'README.md'), '# orca smoke\n') + const git = (...args) => { + const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`) + } + } + git('init', '-b', 'main') + git('config', 'user.email', 'smoke@orca.test') + git('config', 'user.name', 'Orca Smoke') + git('add', '-A') + git('commit', '-m', 'seed') + return dir +} + +async function main() { + const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-')) + const launch = resolveLaunch(userDataDir) + log(`booting ${launch.label} on port ${PORT} with userData ${userDataDir}`) + + // Why tracked out here: the worktree lands in the real workspaces root, not the temp + // profile, so the finally block has to remove it explicitly or every run leaks one. + let seeded = null + let pairing = null + + const child = spawn(launch.command, launch.args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...launch.env } + }) + + try { + const ready = await waitForReady(child) + log(`ready: ${ready.advertisedEndpoint}`) + const pairingCode = pairingCodeFrom(ready) + pairing = pairingCode + + // Why seed instead of using whatever the profile already holds: a hermetic repo makes + // this runnable on a clean CI box, keeps the assertion deterministic, and exercises + // repo.add + worktree.create rather than assuming someone else registered a worktree. + const repoPath = seedGitRepo() + seeded = { repoPath } + log(`seeded repo at ${repoPath}`) + const repo = orca(pairingCode, ['repo', 'add', '--path', repoPath])?.repo + if (!repo?.id) { + throw new Error('repo.add returned no repo id') + } + + const worktreeName = `smoke-${randomBytes(4).toString('hex')}` + const created = orca(pairingCode, [ + 'worktree', + 'create', + '--repo', + `id:${repo.id}`, + '--name', + worktreeName, + '--setup', + 'skip' + ])?.worktree + if (!created?.id) { + throw new Error('worktree.create returned no worktree id') + } + seeded.worktreeId = created.id + log(`created worktree ${created.id}`) + + // Why `show` and not membership in `list`: list is capped, and the Electron target + // reads a shared dev profile that can already hold more worktrees than the cap. The + // point is that the server persisted and can resolve THIS worktree. + const shown = orca(pairingCode, ['worktree', 'show', '--worktree', created.id])?.worktree + if (shown?.id !== created.id) { + throw new Error('worktree.create succeeded but worktree.show cannot resolve it') + } + log(`server resolves ${shown.id}`) + + const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal + if (!terminal?.handle) { + throw new Error('terminal.create returned no handle') + } + log(`created ${terminal.handle}`) + + // Why invoke node rather than `echo`: the shell differs per platform, node does not. + const nonce = `ORCA_SMOKE_${randomBytes(8).toString('hex')}` + orca(pairingCode, [ + 'terminal', + 'send', + '--terminal', + terminal.handle, + '--text', + `"${process.execPath}" -e "console.log('${nonce}')"`, + '--enter' + ]) + + if (!(await waitForNonce(pairingCode, terminal.handle, nonce))) { + throw new Error( + `terminal produced no output containing ${nonce} within ${OUTPUT_TIMEOUT_MS}ms — ` + + `the server started and answered RPC, but its PTY path is dead` + ) + } + log('terminal round trip OK') + } catch (error) { + fail(error instanceof Error ? error.message : String(error)) + } finally { + // Why before SIGTERM: worktree removal is a server operation, so it needs the server. + if (seeded?.worktreeId && pairing) { + const cleanupCli = resolveCli() + const removed = spawnSync( + cleanupCli.command, + [ + ...cleanupCli.prefix, + 'worktree', + 'rm', + '--worktree', + seeded.worktreeId, + '--pairing-code', + pairing, + '--force', + '--json' + ], + { encoding: 'utf8' } + ) + // Why the parent too: `worktree rm` removes the worktree directory, leaving the + // empty `//` container behind. Every run would leak one. + const worktreePath = seeded.worktreeId.split('::')[1] + if (removed.status === 0 && worktreePath) { + rmSync(dirname(worktreePath), { recursive: true, force: true }) + } + if (removed.status !== 0) { + log( + `WARN: could not remove seeded worktree ${seeded.worktreeId}: ` + + `${(removed.stderr?.trim() || removed.stdout?.trim() || '').replace(/\s+/g, ' ')} (exit ${removed.status})` + ) + } + } + // Why the exitCode guard: a server that died during boot has already exited, and + // waiting for a second 'exit' that will never fire reported a bogus shutdown failure + // stacked on top of the real error. + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM') + const exited = await Promise.race([ + new Promise((r) => child.on('exit', () => r(true))), + new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS)) + ]) + if (!exited) { + child.kill('SIGKILL') + fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`) + } + } + rmSync(userDataDir, { recursive: true, force: true }) + if (seeded?.repoPath) { + rmSync(seeded.repoPath, { recursive: true, force: true }) + } + } + + if (!process.exitCode) { + log('PASS') + } +} + +await main() diff --git a/config/scripts/vitest-host-ports-setup.ts b/config/scripts/vitest-host-ports-setup.ts new file mode 100644 index 00000000000..34df265b7e2 --- /dev/null +++ b/config/scripts/vitest-host-ports-setup.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeEach } from 'vitest' +import { setAppEnvironment, type AppEnvironment } from '../../src/shared/app-environment' +import { setSecretStore } from '../../src/shared/secret-store' + +/** + * Why: both host ports throw until an entrypoint installs them, which is the right + * production behaviour but would fail ~70 suites that only ever needed *some* host. + * Install benign defaults before every test so those suites stay unchanged; a suite + * that asserts on host behaviour installs its own and wins, because this runs first. + */ + +// One directory per test environment, not per test: suites that write a file in one +// step and read it back in the next would otherwise lose it between them. Removed on +// teardown — vitest builds one environment per test file, so without this a full run +// leaves thousands of directories behind. +const userData = mkdtempSync(join(tmpdir(), 'orca-vitest-userdata-')) + +afterAll(() => { + rmSync(userData, { recursive: true, force: true }) +}) + +// Deliberately not a plaintext passthrough: encryptString must return something a +// test can tell apart from its input, or a test that forgot to seal would pass. +const SEAL_PREFIX = 'vitest-sealed:' + +/** + * Build a fake environment from just the members a suite cares about. Exported so + * suites that need a specific path or metrics fixture state only that, instead of + * restating all seven members — which is boilerplate, and pushed one suite past + * the max-lines budget. + */ +export function fakeAppEnvironment(overrides: Partial = {}): AppEnvironment { + return { + getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [], + ...overrides + } +} + +/** Install a fake in one call — the common shape in suites that need one specific member. */ +export function installFakeAppEnvironment(overrides: Partial = {}): void { + setAppEnvironment(fakeAppEnvironment(overrides)) +} + +beforeEach(() => { + setAppEnvironment(fakeAppEnvironment()) + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(`${SEAL_PREFIX}${plainText}`), + decryptString: (cipher) => { + const text = cipher.toString() + if (!text.startsWith(SEAL_PREFIX)) { + throw new Error('vitest secret store: ciphertext was not produced by this store') + } + return text.slice(SEAL_PREFIX.length) + }, + describeProtectionGap: () => null + }) +}) diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 948a9e88a1f..c42b937a467 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -3,6 +3,7 @@ "include": [ "../electron.vite.config.*", "./build-plugins/**/*", + "./scripts/vitest-host-ports-setup.ts", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/preload/**/*", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index d23bee128c9..92e0cf146a5 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -19,7 +19,10 @@ export default defineConfig({ // Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds. execArgv: ['--no-experimental-webstorage', '--expose-gc'], // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. - setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], + setupFiles: [ + resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), + resolve('config/scripts/vitest-host-ports-setup.ts') + ], include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index a76d981dd2f..06be8eda659 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -102,7 +102,7 @@ Orca uses shadows sparingly. Three levels in practice: 1. **Inset hairline** — `border` + `border` token. The default. Almost everything sits at this level. 2. **Subtle lift** — `shadow-xs` + a single-token border. Outline buttons, embedded cards. -3. **Floating** — `0 10px 24px rgba(0, 0, 0, 0.18)`. Popovers, popups that escape the editor surface. Reserved. +3. **Floating** — `shadow-floating` (`0 10px 24px rgba(0, 0, 0, 0.18)`). Popovers, popups that escape the editor surface. Reserved. Don't add a fourth level. If something needs more emphasis than "floating," you're probably reaching for the focus `ring` instead. diff --git a/package.json b/package.json index ee488485b7f..a026bdb7824 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -29,6 +29,10 @@ "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", + "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", + "build:orcad": "node config/scripts/build-orcad.mjs", + "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", + "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 03453cb33d3..079d23ec734 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -59,15 +59,23 @@ __orca_omp() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" +# Why unalias inside the substitution: an alias named codex makes command -v +# report the alias text, and the subshell leaves the user's own alias intact. +# Why || : twice — zsh alone aborts inside the substitution, but every shell's +# assignment adopts its exit status, so an absent codex trips set -e in bash too. +__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 1b89c740f2b..f0ec07cfcfd 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -97,15 +97,23 @@ __orca_deferred_init() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. - __orca_codex_binary="$(command -v codex 2>/dev/null || :)" + # Why unalias inside the substitution: an alias named codex makes command -v + # report the alias text, and the subshell leaves the user's own alias intact. + # Why || : twice — zsh alone aborts inside the substitution, but every shell's + # assignment adopts its exit status, so an absent codex trips set -e in bash too. + __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index 3041e85391d..895434cc688 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -62,15 +62,23 @@ __orca_omp() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" +# Why unalias inside the substitution: an alias named codex makes command -v +# report the alias text, and the subshell leaves the user's own alias intact. +# Why || : twice — zsh alone aborts inside the substitution, but every shell's +# assignment adopts its exit status, so an absent codex trips set -e in bash too. +__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index d73f2d4c4c9..92802c53dca 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -97,15 +97,23 @@ __orca_deferred_init() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. - __orca_codex_binary="$(command -v codex 2>/dev/null || :)" + # Why unalias inside the substitution: an alias named codex makes command -v + # report the alias text, and the subshell leaves the user's own alias intact. + # Why || : twice — zsh alone aborts inside the substitution, but every shell's + # assignment adopts its exit status, so an absent codex trips set -e in bash too. + __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index 4c76e53acf6..6ac463f5770 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -51,7 +51,9 @@ __orca_omp() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi if [[ -n "${ORCA_HISTFILE:-}" ]]; then diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 40e10d17210..6e791812e07 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -71,7 +71,9 @@ __orca_deferred_init() { fi } if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an `alias omp` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi fi if [[ -n "${_orca_histfile:-}" ]]; then diff --git a/src/main/agent-hooks/server-copilot-normalization.test.ts b/src/main/agent-hooks/server-copilot-normalization.test.ts index ccf7de07056..af8107debd6 100644 --- a/src/main/agent-hooks/server-copilot-normalization.test.ts +++ b/src/main/agent-hooks/server-copilot-normalization.test.ts @@ -51,6 +51,33 @@ describe('Copilot hook normalization', () => { expect(result?.payload.prompt).toBe('camel event') }) + it('captures the Copilot provider session from SessionStart and Stop', () => { + const sessionId = '940237d9-c712-48e8-bca1-fd75fc4a8d4b' + const started = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'SessionStart', session_id: sessionId }), + 'production' + ) + expect(started?.providerSession).toEqual({ key: 'session_id', id: sessionId }) + + const stopped = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'Stop', session_id: sessionId }), + 'production' + ) + expect(stopped?.payload.state).toBe('done') + expect(stopped?.providerSession).toEqual({ key: 'session_id', id: sessionId }) + }) + + it('captures the Copilot provider session from a camelCase sessionId', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hookEventName: 'agentStop', sessionId: 'copilot-camel' }), + 'production' + ) + expect(result?.providerSession).toEqual({ key: 'session_id', id: 'copilot-camel' }) + }) + it('infers Copilot user prompt payloads that omit hook_event_name', () => { const result = _internals.normalizeHookPayload( 'copilot', diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index e4f5bc24978..770258b972a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -6,7 +6,7 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { @@ -43,7 +43,7 @@ export function resolveWslHookRelayBundle(): WslHookRelayBundle | null { candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl')) } try { - const appPath = app.getAppPath() + const appPath = getAppEnvironment().getAppPath() candidates.push(join(appPath, 'resources', 'relay', 'wsl')) candidates.push(join(appPath, 'out', 'relay', 'wsl')) } catch { @@ -181,18 +181,20 @@ export async function runWslInstallProcess( ): Promise<{ code: number | null; stderr: string }> { const result = await runWslProcess({ distro, - lane: 'probe', + loginPath: 'none', script, - timeoutMs: INSTALL_TIMEOUT_MS, - maxOutputBytes: MAX_STARTUP_BUFFER_BYTES, - // The install script only shells out to base64/mkdir/mv/chmod, all on any - // default PATH -- it must still run when the login-PATH probe itself is - // what's wedged, which is exactly the state this call recovers from. - allowDegradedEnvironment: true + // Declared because the payload is opaque here: it is POSIX plus a heredoc. + shell: 'sh', + timeoutMs: INSTALL_TIMEOUT_MS + // No maxOutputBytes: the default cap holds the whole stream so the slice + // below can take the end of it. }) + // Tail, not head: the operative error ("mv: Read-only file system") lands + // after whatever apt and base64 already printed. + const stderr = result.stderr.slice(-MAX_STARTUP_BUFFER_BYTES) return result.timedOut - ? { code: null, stderr: `${result.stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms` } - : { code: result.code, stderr: result.stderr } + ? { code: null, stderr: `${stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms` } + : { code: result.code, stderr } } const TRANSIENT_RETRY_LIMIT = 2 diff --git a/src/main/ai-vault/session-scanner-service-entry-path.ts b/src/main/ai-vault/session-scanner-service-entry-path.ts index 5f334aa7c44..a64676d66b3 100644 --- a/src/main/ai-vault/session-scanner-service-entry-path.ts +++ b/src/main/ai-vault/session-scanner-service-entry-path.ts @@ -1,14 +1,14 @@ import { existsSync } from 'node:fs' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' -type ElectronAppPath = { getAppPath(): string; isPackaged: boolean } +type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } +// Why the port and not require('electron'): this module is reachable from plain-Node +// fork entries, where the literal text require("electron") fails the build guard even +// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. function loadElectronApp(): ElectronAppPath | null { - try { - return require('electron').app ?? null - } catch { - return null - } + return hasAppEnvironment() ? getAppEnvironment() : null } export function resolveAiVaultServiceEntryPath( @@ -47,6 +47,6 @@ export function resolveAiVaultServiceEntryPathWithoutApp( export function getAiVaultServiceEntryPath(): string { const app = loadElectronApp() return app - ? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged) + ? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged()) : resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath) } diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index dc96b9b964a..994d5cab560 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -7,7 +7,7 @@ import { updateExternalAutomation } from './external-manager' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' -import { getActiveMultiplexer } from '../ipc/ssh' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' import type { Store } from '../persistence' import type * as Fs from 'node:fs' @@ -41,7 +41,7 @@ vi.mock('fs', async () => { } }) -vi.mock('../ipc/ssh', () => ({ +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: vi.fn() })) diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index 8c4d0f0d53e..eb2f340322b 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -17,7 +17,7 @@ import type { } from '../../shared/automations-types' import type { SshTarget } from '../../shared/ssh-types' import type { Store } from '../persistence' -import { getActiveMultiplexer } from '../ipc/ssh' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' import { diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 27f96400258..0b546d5a46e 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import type { Repo } from '../../shared/repo-types' import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { AutomationService } from './service' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -21,6 +22,8 @@ vi.mock('electron', () => ({ async function createStore() { vi.resetModules() + // Why: userData resolves through AppEnvironment; point it at this file's temp dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') initDataPath() return new Store() diff --git a/src/main/bitbucket/credential-connection.test.ts b/src/main/bitbucket/credential-connection.test.ts index 0026e6901cb..ea07cf6a96c 100644 --- a/src/main/bitbucket/credential-connection.test.ts +++ b/src/main/bitbucket/credential-connection.test.ts @@ -10,13 +10,13 @@ let tempHome = '' async function loadModule() { vi.resetModules() - vi.doMock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (value: string) => Buffer.from(value), - decryptString: (value: Buffer) => value.toString('utf-8') - } - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value), + decryptString: (value) => value.toString('utf-8'), + describeProtectionGap: () => null + }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/bitbucket/credential-store.test.ts b/src/main/bitbucket/credential-store.test.ts index 4edd11b0444..34797bb516b 100644 --- a/src/main/bitbucket/credential-store.test.ts +++ b/src/main/bitbucket/credential-store.test.ts @@ -19,13 +19,13 @@ async function loadStore( // Why: doMock registrations outlive resetModules, so an injected failure from // one case would leak into every later one in this file. vi.doUnmock('node:fs') - vi.doMock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (value: string) => Buffer.from(value), - decryptString: decryptStringMock - } - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value), + decryptString: decryptStringMock, + describeProtectionGap: () => null + }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/bitbucket/status-no-decrypt.test.ts b/src/main/bitbucket/status-no-decrypt.test.ts index 946f1aea4aa..11dc39bfcd8 100644 --- a/src/main/bitbucket/status-no-decrypt.test.ts +++ b/src/main/bitbucket/status-no-decrypt.test.ts @@ -19,13 +19,13 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) async function loadModules() { vi.resetModules() - vi.doMock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (value: string) => Buffer.from(value), - decryptString: decryptSpy - } - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value), + decryptString: decryptSpy, + describeProtectionGap: () => null + }) vi.doMock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/browser/browser-error.ts b/src/main/browser/browser-error.ts new file mode 100644 index 00000000000..21f7e7300a6 --- /dev/null +++ b/src/main/browser/browser-error.ts @@ -0,0 +1,16 @@ +/** + * The error every browser command rejects with. + * + * Why its own module: this is seven lines with no dependencies, but it lived in + * `cdp-bridge.ts`, which imports `webContents` and drags the whole Chromium cluster + * along. The runtime catches this type on paths that have nothing to do with CDP, so + * that one import kept a Node host from loading the runtime at all. + */ +export class BrowserError extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } +} diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index b3b836d07b0..5a2cd3a8d23 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -1,4 +1,6 @@ import { mkdtempSync } from 'node:fs' +import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createConnection } from 'node:net' @@ -280,6 +282,10 @@ describe('Browser automation pipeline (integration)', () => { const RENDERER_WC_ID = 1 beforeEach(async () => { + // Why: constructing the browser commands is what pulls the Chromium cluster in, so + // production installs this at the Electron entry. Suites that exercise browser + // automation install it too; a Node host installs none and the RPCs reject. + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain') const { guest } = activeGuestHarness activeGuest = guest diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index 47d263ac371..fc5991924a9 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -48,17 +48,13 @@ import { import { insertTextThroughCdp } from './browser-text-insertion' import type { BrowserManager } from './browser-manager' import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { BrowserError } from './browser-error' const CAPTURE_LOG_LIMIT = 1000 -export class BrowserError extends Error { - constructor( - readonly code: string, - message: string - ) { - super(message) - } -} +// Why re-exported: moved to ./browser-error so the runtime can catch it without +// pulling Chromium in. Existing importers of this path keep working. +export { BrowserError } from './browser-error' type TabState = { navigationId: string | null diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 29568d38fe4..e720378a8b8 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1088,7 +1088,7 @@ export class ClaudeRuntimeAuthService { try { const owned = await runWslProcess({ distro: wslInfo.distro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: [ 'set -euo pipefail', @@ -1100,12 +1100,7 @@ export class ClaudeRuntimeAuthService { `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' ].join('\n'), - timeoutMs: 5000, - // Why degraded is allowed: a failed answer here disowns the account and - // clears the user's selection, so a slow distro probe must not decide - // ownership. The script reads only $HOME and coreutils, both of which - // wsl.exe supplies without the login PATH. - allowDegradedEnvironment: true + timeoutMs: 5000 }) if (owned.timedOut) { throw new Error(OWNERSHIP_PROBE_TIMEOUT) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index e9f18a8a5d9..2e3206eab69 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -664,13 +664,10 @@ export class ClaudeAccountService { } const created = await runWslProcess({ distro: location.wslDistro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"', - timeoutMs: 5000, - // Why degraded is allowed: mktemp is a coreutil on the default PATH, so an - // unprobed distro must not turn a working sign-in into a failed one. - allowDegradedEnvironment: true + timeoutMs: 5000 }) const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim() if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) { @@ -692,13 +689,10 @@ export class ClaudeAccountService { try { await runWslProcess({ distro: tempConfig.wslDistro, - lane: 'probe', + loginPath: 'none', program: 'rm', args: ['-rf', '--', tempConfig.linuxPath], - timeoutMs: 5000, - // Why degraded is allowed: leaving a login temp dir behind is worse than - // running rm on the default PATH, and this is already best-effort. - allowDegradedEnvironment: true + timeoutMs: 5000 }) } catch { // Best-effort cleanup. @@ -910,13 +904,10 @@ export class ClaudeAccountService { const requestedDistro = target.wslDistro?.trim() || undefined const info = await runWslProcess({ distro: requestedDistro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', - timeoutMs: 5000, - // Why degraded is allowed: both variables come from wsl.exe itself, not from - // the login PATH, so an unprobed distro still answers correctly here. - allowDegradedEnvironment: true + timeoutMs: 5000 }) const [rawDistro, rawHome] = info.code === 0 && !info.timedOut @@ -934,14 +925,11 @@ export class ClaudeAccountService { const wslLinuxAuthPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` const created = await runWslProcess({ distro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: 'mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"', args: [wslLinuxAuthPath, accountId], - timeoutMs: 5000, - // Why degraded is allowed: mkdir is a coreutil on the default PATH, and the - // target path was already resolved from the guest's own $HOME above. - allowDegradedEnvironment: true + timeoutMs: 5000 }) if (created.code !== 0 || created.timedOut) { throw new Error('Could not create the managed WSL Claude auth directory.') @@ -978,7 +966,7 @@ export class ClaudeAccountService { try { const owned = await runWslProcess({ distro: wslInfo.distro, - lane: 'probe', + loginPath: 'none', shell: 'bash', script: [ 'set -euo pipefail', @@ -992,11 +980,7 @@ export class ClaudeAccountService { : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"', 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' ].join('\n'), - timeoutMs: 5000, - // Why degraded is allowed: the ownership proof is the script's own - // marker and containment checks, not the login PATH, and refusing here - // would fail every managed read/write on a distro Orca could not probe. - allowDegradedEnvironment: true + timeoutMs: 5000 }) if (owned.code !== 0 || owned.timedOut) { throw new Error('Managed Claude auth directory does not exist on disk.') diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index a57fdf95924..4bf23fc4190 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: this file centralizes cross-platform CLI install state, launcher resolution, and PATH registration so the public shell command stays consistent across packaged and development builds. */ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { execFile } from 'node:child_process' import { constants, existsSync } from 'node:fs' import { @@ -93,11 +93,11 @@ export class CliInstaller { constructor(options: CliInstallerOptions = {}) { this.platform = options.platform ?? process.platform - this.isPackaged = options.isPackaged ?? app.isPackaged - this.userDataPath = options.userDataPath ?? app.getPath('userData') + this.isPackaged = options.isPackaged ?? getAppEnvironment().isPackaged() + this.userDataPath = options.userDataPath ?? getAppEnvironment().getPath('userData') this.resourcesPath = options.resourcesPath ?? process.resourcesPath this.execPathValue = options.execPath ?? process.execPath - this.appPathValue = options.appPath ?? app.getAppPath() + this.appPathValue = options.appPath ?? getAppEnvironment().getAppPath() this.homePath = options.homePath ?? homedir() this.localAppDataPath = options.localAppDataPath ?? diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 78d91581fae..717232509ba 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -785,6 +785,27 @@ describe('WslCliInstaller', () => { await expect(installer.getStatus()).rejects.toThrow('WSL command timed out') }) + it('reports the distro unreachable rather than "not on PATH" without the login PATH', async () => { + // The `case ":$PATH:"` probe answers from the distro default PATH when the + // login PATH never resolved, and ~/.local/bin is never on that. Settings + // would then state as fact that the CLI is not on PATH while the user's + // own terminal finds it -- #14288 turning into a confident wrong verdict. + runWslProcessMock.mockResolvedValue({ + environmentResolved: false, + code: 0, + stdout: 'no', + stderr: '', + timedOut: false + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() } + }) + + await expect(installer.getStatus()).rejects.toThrow('Could not reach the WSL distro') + }) + it('refuses to remove an old managed launcher when the bridge path is user-owned', async () => { const oldLauncher = _internals.buildWslLauncher( 'C:\\Old\\orca.cmd', diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index ca38f660972..7c95be6431c 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -459,22 +459,27 @@ export class WslCliInstaller { async function runWslCommand(distro: string, command: string): Promise { // Why the probe lane fixes #14288: the prior login shell (`bash -lc`) sourced // ~/.profile, so one blocking line there ate the whole 10s timeout. - let result: Awaited> - try { - result = await runWslProcess({ - distro, - lane: 'probe', - script: command, - timeoutMs: WSL_COMMAND_TIMEOUT_MS - }) - } catch { - // Why map: the runner's raw "guest environment is unavailable" otherwise - // reaches Settings verbatim through ipc/cli.ts -> toast.error. - throw new Error('Could not reach the WSL distro. Try again.') - } + const result = await runWslProcess({ + distro, + loginPath: 'preferred', + script: command, + // Declared, not assumed: the payload is opaque here, so the guard cannot + // check it for bashisms. These are POSIX (`-eu`, `case`), hence sh. + shell: 'sh', + timeoutMs: WSL_COMMAND_TIMEOUT_MS + }) + // Timeout first: it is the more specific diagnosis, and a timed-out run also + // leaves the environment unresolved, so the order decides which one shows. if (result.timedOut) { throw new Error(`WSL command timed out after ${WSL_COMMAND_TIMEOUT_MS}ms.`) } + // Every command here reads the login PATH -- the `case ":$PATH:"` probe most + // of all. Without it that probe answers from the distro default PATH, which + // never has ~/.local/bin, and Settings states as fact that the CLI is not on + // PATH while the user's own terminal finds it. Unverifiable, not negative. + if (!result.environmentResolved) { + throw new Error('Could not reach the WSL distro. Try again.') + } if (result.code !== 0) { throw new Error(result.stderr.trim() || `WSL command failed with exit code ${result.code}.`) } diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 0bfec4abce4..c9be97fc641 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1235,7 +1235,9 @@ export class CodexRuntimeHomeService { 'fi' ].join('\n') ], - { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 } + // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes + // a conhost and steals foreground for up to the timeout (#10488). + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } ) } @@ -1244,7 +1246,6 @@ export class CodexRuntimeHomeService { return index > 0 ? value.slice(0, index) : '/' } - private joinWslPath(basePath: string, ...segments: string[]): string { return parseWslUncPath(basePath) ? pathWin32.join(basePath, ...segments) diff --git a/src/main/codex-accounts/service-wsl-accounts.test.ts b/src/main/codex-accounts/service-wsl-accounts.test.ts index 3e62db7e59e..2dbf44026a2 100644 --- a/src/main/codex-accounts/service-wsl-accounts.test.ts +++ b/src/main/codex-accounts/service-wsl-accounts.test.ts @@ -71,14 +71,20 @@ describe('CodexAccountService config sync', () => { const runWslProcessMock = vi.fn(async (spec: WslSpec) => { const script = String(spec.script) expect(spec.distro).toBe('Debian') - expect(spec.lane).toBe('probe') if (script.includes('WSL_DISTRO_NAME')) { + // 'none': reads $HOME and $WSL_DISTRO_NAME, which wsl.exe supplies from + // /etc/passwd without a login shell. + expect(spec.loginPath).toBe('none') return wslOk('Debian\n/home/alice\n') } if (script.includes('_orca_lookup_command=')) { + // 'preferred': a PATH lookup. Under 'none' an nvm-installed codex is + // invisible and a working install is reported absent (#9725). + expect(spec.loginPath).toBe('preferred') expect(script).toBe(buildWslCodexAvailabilityScript()) return wslOk() } + expect(spec.loginPath).toBe('none') expect(script).toContain('mkdir -p ') mkdirSync(wslManagedHomePath, { recursive: true }) writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') @@ -245,6 +251,84 @@ describe('CodexAccountService config sync', () => { } }) + it('says it could not check, not "not installed", when the login PATH is unavailable', async () => { + // #9725: an nvm-installed codex lives only on the login PATH. When the + // probe cannot supply it, a non-zero lookup is "we could not check" -- + // reporting a working install as absent is the bug this pins. + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home' + + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + expect(args.slice(0, 2)).toEqual(['-d', 'Debian']) + expect(script).toContain('readlink -f') + return `${wslLinuxHomePath}\n` + }) + const runWslProcessMock = vi.fn(async (spec: WslSpec) => { + const script = String(spec.script) + expect(spec.distro).toBe('Debian') + if (script.includes('WSL_DISTRO_NAME')) { + return wslOk('Debian\n/home/alice\n') + } + if (script.includes('_orca_lookup_command=')) { + expect(script).toBe(buildWslCodexAvailabilityScript()) + return { ...wslFailed(1, 'codex missing'), environmentResolved: false } + } + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') + return wslOk() + }) + const spawnMock = vi.fn() + + vi.doMock('node:crypto', () => ({ + randomUUID: () => 'account-id-for-test' + })) + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + await expect(service.addAccount({ runtime: 'wsl', wslDistro: 'Debian' })).rejects.toThrow( + 'Could not check the Codex CLI in WSL. Try again.' + ) + expect(spawnMock).not.toHaveBeenCalled() + expect(existsSync(wslManagedHomePath)).toBe(false) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('reauthenticates a WSL managed Codex account inside its distro', async () => { vi.resetModules() const originalPlatform = process.platform diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 8874cc0b849..cd790207823 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1215,9 +1215,7 @@ export class CodexAccountService { const requestedDistro = target.wslDistro?.trim() || undefined const info = await runWslProcess({ distro: requestedDistro, - lane: 'probe', - // Degrade rather than refuse: $HOME and $WSL_DISTRO_NAME come from wsl.exe itself, not the login PATH -- the same rationale the Claude sites use. - allowDegradedEnvironment: true, + loginPath: 'none', script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', shell: 'bash', timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS @@ -1239,9 +1237,7 @@ export class CodexAccountService { const markerPath = `${wslLinuxHomePath}/.orca-managed-home` const created = await runWslProcess({ distro, - lane: 'probe', - // Degrade rather than refuse: $HOME and $WSL_DISTRO_NAME come from wsl.exe itself, not the login PATH -- the same rationale the Claude sites use. - allowDegradedEnvironment: true, + loginPath: 'none', script: `mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`, shell: 'bash', timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS @@ -1478,9 +1474,7 @@ export class CodexAccountService { const result = await runWslProcess({ distro: wslInfo.distro, - lane: 'probe', - // Degrade rather than refuse: $HOME and $WSL_DISTRO_NAME come from wsl.exe itself, not the login PATH -- the same rationale the Claude sites use. - allowDegradedEnvironment: true, + loginPath: 'none', script: [ 'set -euo pipefail', `candidate=${shellQuote(wslInfo.linuxPath)}`, @@ -1566,8 +1560,7 @@ export class CodexAccountService { ].join('\n') ) ], - { windowsHide: true, - encoding: 'utf-8', timeout: 5000 } + { windowsHide: true, encoding: 'utf-8', timeout: 5000 } ).trim() if (!canonicalLinuxPath) { throw new Error('Managed Codex home directory does not exist on disk.') @@ -1616,9 +1609,7 @@ export class CodexAccountService { try { const result = await runWslProcess({ distro, - lane: 'probe', - // Degrade rather than refuse: $HOME and $WSL_DISTRO_NAME come from wsl.exe itself, not the login PATH -- the same rationale the Claude sites use. - allowDegradedEnvironment: true, + loginPath: 'none', script: [ 'set -euo pipefail', `candidate=${shellQuote(linuxHomePath)}`, @@ -1865,20 +1856,19 @@ export class CodexAccountService { distro: string linuxPath: string }): Promise { - // Why the runner's default (throw) and not allowDegradedEnvironment: without - // the login PATH an nvm-installed codex is invisible, and this message would - // turn "we could not check" into "not installed" (#9725). - let result: Awaited> - try { - result = await runWslProcess({ - distro: wslInfo.distro, - lane: 'probe', - script: buildWslCodexAvailabilityScript(), - timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS - }) - } catch { - // Why map: the runner's raw "guest environment is unavailable" would - // otherwise reach the account dialog verbatim. + // This is a PATH lookup, so it needs the login PATH: an nvm-installed codex + // lives nowhere else. Marking it 'none' reports a working install as absent. + const result = await runWslProcess({ + distro: wslInfo.distro, + loginPath: 'preferred', + script: buildWslCodexAvailabilityScript(), + // POSIX command lookup; declared because the payload is opaque here. + shell: 'sh', + timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS + }) + if (result.code !== 0 && !result.environmentResolved) { + // A miss without the login PATH is "we could not check", not "not + // installed" -- claiming absence here is #9725. throw new Error('Could not check the Codex CLI in WSL. Try again.') } if (result.code !== 0 || result.timedOut) { diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts index f71474528be..ef880a378aa 100644 --- a/src/main/codex/wsl-codex-session-bridge.test.ts +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -44,13 +44,13 @@ describe('syncWslCodexSessionsIntoManagedHome', () => { expect(runWslProcessMock).toHaveBeenCalledTimes(1) const spec = runWslProcessMock.mock.calls[0]?.[0] as { distro: string - lane: string + loginPath: string shell?: string script: string timeoutMs: number } expect(spec.distro).toBe('Ubuntu') - expect(spec.lane).toBe('probe') + expect(spec.loginPath).toBe('none') expect(spec.shell).toBe('bash') expect(spec.timeoutMs).toBe(30_000) @@ -79,7 +79,9 @@ describe('syncWslCodexSessionsIntoManagedHome', () => { }) it('parses the summary after leading stdout noise', async () => { - mockRunWslProcessSuccess('Welcome to Ubuntu\nprofile output\n{"scannedFiles":4,"linkedFiles":3}\n') + mockRunWslProcessSuccess( + 'Welcome to Ubuntu\nprofile output\n{"scannedFiles":4,"linkedFiles":3}\n' + ) const summary = await syncWslCodexSessionsIntoManagedHome({ distro: 'Ubuntu', diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts index 9b5cbf0895d..f65dfdbaa03 100644 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -56,7 +56,7 @@ export async function syncWslCodexSessionsIntoManagedHome( const result = await runWslProcess({ distro: target.distro, - lane: 'probe', + loginPath: 'none', script: buildWslCodexSessionBridgeShellCommand(paths), // Process substitution and `read -d` are bash-only; dash rejects both. shell: 'bash', diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts index d0cfa9dfb2c..489c463dd93 100644 --- a/src/main/computer/sidecar-client.ts +++ b/src/main/computer/sidecar-client.ts @@ -1,4 +1,5 @@ import { fork, type ChildProcess } from 'node:child_process' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import type { ComputerActionResult, @@ -107,12 +108,15 @@ function getComputerSidecarEntryPath(): string { return join(basePath, 'out', 'main', 'computer-sidecar.js') } +// Why the port and not require('electron'): the literal text fails the plain-Node +// entry guard even inside a try/catch, and hasAppEnvironment() gives the same +// "no app root here" answer without it. function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { - try { - return require('electron').app - } catch { + if (!hasAppEnvironment()) { return null } + const environment = getAppEnvironment() + return { getAppPath: () => environment.getAppPath(), isPackaged: environment.isPackaged() } } class ComputerSidecarProcess { diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index a4f3fde2b64..149ea478fb4 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -191,7 +191,13 @@ async function runInspectionCommand( args: string[], timeoutMs: number ): Promise { - const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs }) + // powershell.exe is console-subsystem: without this it flashes a conhost and + // steals foreground on every inspection (#10488). + const { stdout } = await execFileAsync(file, args, { + encoding: 'utf8', + timeout: timeoutMs, + windowsHide: true + }) return stdout } diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 8e2c43f62b8..53846b5071b 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -312,6 +312,41 @@ describe('createPtySubprocess', () => { expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined() }) + it('does not forward a half-activated conda env from the daemon process env', async () => { + // Why here as well as the main process: the daemon fork composes its own + // inherited env, which main never sees, so it is the default terminal's + // only chance to drop a CONDA_SHLVL sentinel left without a prefix (#14195). + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const saved = { + CONDA_SHLVL: process.env.CONDA_SHLVL, + CONDA_PREFIX: process.env.CONDA_PREFIX, + CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV, + CONDA_EXE: process.env.CONDA_EXE + } + delete process.env.CONDA_PREFIX + process.env.CONDA_SHLVL = '1' + process.env.CONDA_DEFAULT_ENV = 'base' + process.env.CONDA_EXE = '/opt/miniconda3/bin/conda' + + try { + await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + }) + it('does not inherit legacy attribution state from a pre-upgrade daemon', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 47161208721..2f983e4f7c5 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -43,6 +43,7 @@ import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' +import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' @@ -902,6 +903,8 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { }) describe('addSparseWorktree', () => { + // Why: argv now depends on the host OS, so pin a non-Windows default or the exact-argv + // assertions below would fail for a maintainer running vitest on Windows. + let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { resetWorktreeGitMocks({ gitExecFileAsyncMock, @@ -75,6 +79,43 @@ describe('addSparseWorktree', () => { statMock, resolveGitDirMock }) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + platformSpy.mockRestore() + }) + + it('enables long paths on the Windows commands that actually write the deep checkout', async () => { + // Why: `worktree add --no-checkout` writes nothing, so the long-path flag on it alone + // left sparse creation failing with "Filename too long" (issue #15785). + platformSpy.mockReturnValue('win32') + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await addSparseWorktree('C:\\repo', 'C:\\repo-feature', 'feature/test', ['packages/web']) + + const calls = getGitCalls() + expect(calls).toEqual( + expect.arrayContaining([ + 'git -c core.longpaths=true sparse-checkout set -- packages/web', + 'git -c core.longpaths=true checkout feature/test' + ]) + ) + }) + + it('omits the long-path option on non-Windows hosts', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web']) + + const calls = getGitCalls() + expect(calls).toEqual( + expect.arrayContaining([ + 'git sparse-checkout set -- packages/web', + 'git checkout feature/test' + ]) + ) + expect(calls.some((call) => call.includes('core.longpaths'))).toBe(false) }) it('separates sparse checkout directory operands from options', async () => { diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts index 92832fc0f89..30851fe2882 100644 --- a/src/main/git/runner.test.ts +++ b/src/main/git/runner.test.ts @@ -338,3 +338,18 @@ describe('git env forces untranslated diagnostics (issue #7808)', () => { expect(env.LANGUAGE).toBe('en') }) }) + +describe('redirectPortedHostnameToEnv WSLENV forwarding', () => { + it('names GITLAB_HOST in WSLENV so it can cross into a distro', async () => { + // A WSL-routed glab only sees Windows variables listed in WSLENV; without + // the entry the ported host is silently dropped and glab talks to + // gitlab.com (#12557). + const { redirectPortedHostnameToEnv } = await import('./runner') + const { options } = redirectPortedHostnameToEnv( + ['api', '--hostname', 'gitlab.example.com:8443'], + { env: { PATH: '/usr/bin' } } + ) + expect(options.env?.GITLAB_HOST).toBe('gitlab.example.com:8443') + expect((options.env?.WSLENV ?? '').split(':')).toContain('GITLAB_HOST') + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 9f5e3346dcf..1ae53551f29 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1908,9 +1908,17 @@ export function redirectPortedHostnameToEnv( if (!/^[^/\s]+:\d+$/.test(host)) { return { args, options } } + // Why WSLENV: a glab routed into a distro only sees Windows-side variables + // named in WSLENV, so without this the ported host silently never crosses and + // glab talks to gitlab.com instead (#12557). Credit: #12558. + const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), GITLAB_HOST: host } + // Unguarded by platform on purpose: WSLENV is meaningless outside Windows, so + // the extra key is inert there, and gating it would need the test to know the + // platform for no behavioural gain. + addWslEnvKeys(env, ['GITLAB_HOST']) return { args: [...args.slice(0, i), ...args.slice(i + 2)], - options: { ...options, env: { ...(options.env ?? process.env), GITLAB_HOST: host } } + options: { ...options, env } } } diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts index f6b3d0d51d6..44394b7728d 100644 --- a/src/main/git/worktree-add-creation-config.test.ts +++ b/src/main/git/worktree-add-creation-config.test.ts @@ -1,5 +1,5 @@ // addWorktree: checkout creation, branch-base/push.autoSetupRemote config writes, ref qualification. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, @@ -40,10 +40,19 @@ describe('addWorktree', () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base } + // Why: argv now depends on the host OS, so pin a non-Windows default or every + // exact-argv assertion below would fail for a maintainer running vitest on Windows. + let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + platformSpy.mockRestore() }) it('creates the worktree without touching the local base ref by default', async () => { @@ -99,6 +108,86 @@ describe('addWorktree', () => { ]) }) + it('enables long paths for native Windows worktree creation', async () => { + platformSpy.mockReturnValue('win32') + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + + await addWorktree( + 'C:\\repo', + 'C:\\repo-feature', + 'feature/test', + 'feature/test', + false, + false, + { checkoutExistingBranch: true } + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'], + { cwd: 'C:\\repo', timeout: WORKTREE_ADD_TIMEOUT_MS } + ) + }) + + it('still enables long paths for a Windows-path repo that has a WSL distro configured', async () => { + // Why: a C:\ cwd can be served by host git.exe even with wslDistro set, and that + // is exactly the MAX_PATH-prone case; Linux git parses and ignores the key. + platformSpy.mockReturnValue('win32') + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + + await addWorktree( + 'C:\\repo', + 'C:\\repo-feature', + 'feature/test', + 'feature/test', + false, + false, + { checkoutExistingBranch: true, wslDistro: 'Ubuntu' } + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'], + { cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS } + ) + }) + + it('does not pass the Windows-only long-path option for a WSL UNC repo path', async () => { + platformSpy.mockReturnValue('win32') + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + + const repoPath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo' + await addWorktree( + repoPath, + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', + 'feature/test', + 'feature/test', + false, + false, + { checkoutExistingBranch: true, wslDistro: 'Ubuntu' } + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['worktree', 'add', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', 'feature/test'], + { cwd: repoPath, wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS } + ) + }) + + it.each(['darwin', 'linux'] as const)( + 'does not pass the long-path option on %s', + async (platform) => { + platformSpy.mockReturnValue(platform) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + + await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, { + checkoutExistingBranch: true + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['worktree', 'add', '/repo-feature', 'feature/test'], + { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS } + ) + } + ) + it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => { // Why: without a timeout, a OneDrive cloud-placeholder checkout can stall // `git worktree add` for minutes. Assert the runner receives a non-zero diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index bfe76b397a7..cec1151e0eb 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -347,13 +347,19 @@ describe('addWorktree', () => { it('skips updating the local branch when it has diverged', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward')) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // for-each-ref refs/heads/main (exists) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote - await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) expect(gitExecFileAsyncMock.mock.calls).toEqual([ [ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], @@ -363,6 +369,10 @@ describe('addWorktree', () => { ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], expect.objectContaining({ cwd: '/repo' }) ], + [ + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'], + expect.objectContaining({ cwd: '/repo' }) + ], [ [ 'worktree', @@ -396,12 +406,112 @@ describe('addWorktree', () => { ]) }) + // #15331: evaluation runs before `-b ` exists, so rev-list fails on the missing local ref. + it('does not warn when worktree add itself creates the local base branch', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/feature-x^{commit} + .mockRejectedValueOnce( + new Error( + "fatal: ambiguous argument 'refs/heads/feature-x...refs/remotes/origin/feature-x': unknown revision or path not in the working tree." + ) + ) // rev-list: refs/heads/feature-x does not exist yet + .mockResolvedValueOnce({ stdout: '' }) // for-each-ref refs/heads/feature-x (missing) + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree( + '/repo', + '/repo-feature-x', + 'feature-x', + 'origin/feature-x', + true + ) + + expect(result.localBaseRefRefresh).toBeUndefined() + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'worktree', + 'add', + '--no-track', + '-b', + 'feature-x', + '/repo-feature-x', + 'refs/remotes/origin/feature-x' + ]) + // Nothing was refreshed, so no ref mutation. + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('update-ref') + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('reset') + }) + + // #15331: same missing-local-branch class, but the new branch name differs from the base's. + it('does not warn when the local base branch does not exist in a fetch-only clone', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} + .mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) // rev-list: no local main + .mockResolvedValueOnce({ stdout: '' }) // for-each-ref refs/heads/main (missing) + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) + + expect(result.localBaseRefRefresh).toBeUndefined() + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'for-each-ref', + '--count=1', + '--format=%(refname)', + 'refs/heads/main' + ]) + }) + + // A failed probe is not proof of absence, so the warning must survive it. + it('keeps the warning when the local base ref probe itself fails', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} + .mockRejectedValueOnce(new Error('rev-list failed')) // drift probe + .mockRejectedValueOnce(new Error('fatal: not a git repository')) // for-each-ref probe could not run + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + }) + + it('still suggests nothing but keeps the warning when the local base ref exists and diverged', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '2\t3\n' }) // rev-list: 2 local-only commits + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'main', 'origin/main', true) + + // Same branch name as the base, but rev-list succeeded: real divergence must still warn. + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + }) + it('skips local base refresh when captured OIDs are no longer ancestor-safe', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '0\t2\n' }) // stale rev-list result gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local\n' }) // rev-parse refs/heads/main^{commit} gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not an ancestor')) // merge-base captured OIDs + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // for-each-ref refs/heads/main (exists) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) @@ -435,6 +545,10 @@ describe('addWorktree', () => { ['merge-base', '--is-ancestor', 'new-local', 'remote-main'], expect.objectContaining({ cwd: '/repo' }) ], + [ + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'], + expect.objectContaining({ cwd: '/repo' }) + ], [ [ 'worktree', diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 44b8d4d85e9..85c8c8baa8f 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -37,3 +37,32 @@ export async function hasWorktreeBaseCommitRef( ): Promise { return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null } + +export type WorktreeBaseRefPresence = 'present' | 'absent' | 'unknown' + +/** + * Distinguish "the ref does not exist" from "the probe itself failed". + * + * Why for-each-ref: it exits 0 whether or not the pattern matches, so an empty result + * proves absence while a rejection still means the probe never ran (broken repo, dead + * SSH transport). `rev-parse --verify --quiet` exits 1 for both, and reading that as + * "absent" would silently drop warnings the caller must still surface. + * + * Executor-injected so the SSH path can route the same argv through the relay. + */ +export async function probeWorktreeBaseRefPresence( + runGit: (args: string[]) => Promise<{ stdout: string }>, + qualifiedRef: string +): Promise { + try { + const { stdout } = await runGit([ + 'for-each-ref', + '--count=1', + '--format=%(refname)', + qualifiedRef + ]) + return stdout.trim() === qualifiedRef ? 'present' : 'absent' + } catch { + return 'unknown' + } +} diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 1f96523899e..55c5d33cf79 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -14,6 +14,7 @@ import { scheduleWorktreeTrashDeletion } from '../worktree-trash' import { parseWslPath } from '../wsl' +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' import type { LocalBaseRefRefreshResult, LocalBaseRefUpdateSuggestion @@ -33,7 +34,7 @@ import { import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' -import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { hasWorktreeBaseCommitRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe' export type AddWorktreeResult = { localBaseRefRefresh?: LocalBaseRefRefreshResult @@ -265,6 +266,16 @@ async function evaluateLocalBaseRefRefreshability( ) drift = parsedDrift } catch { + // Why (#15331): the probes above also fail when refs/heads/ is simply absent; a branch that + // does not exist yet cannot be stale, so report nothing instead of a bogus divergence warning. + // Only a proven absence suppresses: an unusable repo leaves the warning alone. + const presence = await probeWorktreeBaseRefPresence( + (args) => gitExecFileAsync(args, gitExecOptions(repoPath, options)), + parsed.fullRef + ) + if (presence === 'absent') { + return undefined + } return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } @@ -975,7 +986,8 @@ async function performAddWorktree( ): Promise { let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined - const args = ['worktree', 'add'] + // Why: enable long paths for this Windows checkout without changing user Git config. + const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add'] let effectiveBase: string | undefined if (noCheckout) { args.push('--no-checkout') @@ -1081,15 +1093,21 @@ export async function addSparseWorktree( options ) created = true + // Why: `worktree add --no-checkout` writes no files, so these are the calls that + // actually materialize the deep path and need the long-path escape hatch. + const longPathArgs = windowsLongPathGitArgs(worktreePath) await gitExecFileAsync( ['sparse-checkout', 'init', '--cone'], gitExecOptions(worktreePath, options) ) await gitExecFileAsync( - ['sparse-checkout', 'set', '--', ...directories], + [...longPathArgs, 'sparse-checkout', 'set', '--', ...directories], + gitExecOptions(worktreePath, options) + ) + await gitExecFileAsync( + [...longPathArgs, 'checkout', branch], gitExecOptions(worktreePath, options) ) - await gitExecFileAsync(['checkout', branch], gitExecOptions(worktreePath, options)) return addResult } catch (error) { const wrapped: SparseWorktreeCreateError = diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 094c1b3a50e..cd6d3c67ca9 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -27,6 +27,11 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/runtime/relay/relay-region-preference.ts', 3], ['main/source-control/hosted-review-api-request.ts', 1], ['main/speech/openai-transcription-client.ts', 1], + // Main HTTP port: one type declaration plus the Node fallback call. The fallback + // returns the Response to its caller without inspecting it, so the consume/cancel + // obligation stays with the caller — unchanged from when those callers used + // Electron's net directly. + ['main/network/http-client.ts', 2], // fetch appears only inside injected-page script source strings, not as a // call this process makes ['main/amp/agent-status-plugin-source.ts', 1], diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 23293ce8b48..12744712543 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -87,6 +87,49 @@ describe('runHook', () => { } }) + it('does not run setup scripts with a half-activated conda env', async () => { + // Why: setup scripts source conda exactly like a shell rc does, so the + // orphaned CONDA_SHLVL sentinel surfaces as an opaque hook failure (#14195). + let capturedEnv: Record | undefined + execMock.mockImplementation((_script, options, callback) => { + capturedEnv = (options as { env: Record }).env + callback?.(null, '', '') + return {} as never + }) + + const fs = await import('node:fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n') + + const saved = { + CONDA_SHLVL: process.env.CONDA_SHLVL, + CONDA_PREFIX: process.env.CONDA_PREFIX, + CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV, + CONDA_EXE: process.env.CONDA_EXE + } + delete process.env.CONDA_PREFIX + process.env.CONDA_SHLVL = '1' + process.env.CONDA_DEFAULT_ENV = 'base' + process.env.CONDA_EXE = '/opt/miniconda3/bin/conda' + + try { + const { runHook } = await import('./hooks') + await runHook('setup', '/repo/worktree', makeRepo()) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + expect(capturedEnv?.CONDA_SHLVL).toBeUndefined() + expect(capturedEnv?.CONDA_DEFAULT_ENV).toBeUndefined() + expect(capturedEnv?.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + }) + it('keeps bash as the hook shell on non-Windows platforms', async () => { execMock.mockImplementation((_script, _options, callback) => { callback?.(null, '', '') @@ -141,7 +184,13 @@ describe('runHook', () => { it('runs WSL hooks through runWslProcess and translates env paths to Linux', async () => { execMock.mockReset() runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -164,7 +213,7 @@ describe('runHook', () => { expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', - lane: 'probe', + loginPath: 'preferred', script: 'echo hello', cwd: '/home/jin/feature', // #7652 regression: the unattended WSL hook branch must carry the @@ -191,7 +240,13 @@ describe('runHook', () => { it('runs Windows-path hooks through WSL when the project runtime targets WSL', async () => { execMock.mockReset() runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -220,7 +275,7 @@ describe('runHook', () => { expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', - lane: 'probe', + loginPath: 'preferred', script: 'echo hello', cwd: '/mnt/c/Users/jinwo/git/orca-feature', env: expect.objectContaining({ @@ -296,7 +351,13 @@ describe('runHook', () => { // mocked runWslProcess -- there is nothing left in hooks.ts to advance. execMock.mockReset() runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: null, stdout: '', stderr: '', timedOut: true }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: null, + stdout: '', + stderr: '', + timedOut: true + }) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index d55647e6737..7778132e910 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -8,6 +8,7 @@ import { getHookRuntimeTarget, getHookWslContext } from './hook-runtime-target' import { getSetupEnvVars } from './setup-hook-env-vars' import { iterateLfScriptLines } from './setup-runner-script-text' import { promptGuardShellEnv } from './git/runner' +import { dropIncoherentCondaActivationEnv } from './pty/conda-activation-env' import { toLinuxPath } from './wsl' import { runWslProcess } from './wsl/wsl-runner' import type { HookRuntimeTarget } from './hook-runtime-target' @@ -145,7 +146,9 @@ export function runHook( for (const [key, value] of Object.entries(guardedEnv)) { if ( value !== undefined && - (key === 'GIT_TERMINAL_PROMPT' || key === 'GCM_INTERACTIVE' || key.startsWith('GIT_CONFIG_')) + (key === 'GIT_TERMINAL_PROMPT' || + key === 'GCM_INTERACTIVE' || + key.startsWith('GIT_CONFIG_')) ) { guestEnv[key] = value } @@ -153,17 +156,12 @@ export function runHook( return runWslProcess({ distro: wslInfo.distro ?? undefined, - lane: 'probe', + loginPath: 'preferred', script, // Why pinned: these are user-authored orca.yaml scripts and the native // path runs /bin/bash. Defaulting to sh would fail bash-only hooks on WSL // only -- a downgrade the user never asked for. shell: 'bash', - // Why degrade rather than fail: a hook is an action, not an "is this - // installed?" question. Before the runner it ran on the default PATH when - // no login shell was available; refusing would turn worktree setup into a - // hard failure whenever WSL is briefly slow. - allowDegradedEnvironment: true, cwd: wslInfo.linuxPath, env: guestEnv, timeoutMs: HOOK_TIMEOUT @@ -189,6 +187,9 @@ export function runHook( }) } + const shellHookEnv: NodeJS.ProcessEnv = { ...process.env, ...getSetupEnvVars(repo, cwd) } + dropIncoherentCondaActivationEnv(shellHookEnv) + return new Promise((resolve) => { exec( script, @@ -197,10 +198,7 @@ export function runHook( timeout: HOOK_TIMEOUT, shell: getHookShell(), // Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652). - env: promptGuardShellEnv({ - ...process.env, - ...getSetupEnvVars(repo, cwd) - }) + env: promptGuardShellEnv(shellHookEnv) }, (error, stdout, stderr) => { if (error) { diff --git a/src/main/host/electron-app-environment.ts b/src/main/host/electron-app-environment.ts new file mode 100644 index 00000000000..1e8b8c6965c --- /dev/null +++ b/src/main/host/electron-app-environment.ts @@ -0,0 +1,38 @@ +import { app } from 'electron' +import type { AppEnvironment, AppPathName, AppProcessMetric } from '../../shared/app-environment' + +/** + * Electron-backed AppEnvironment for the desktop app: a pass-through to + * `electron.app`, so desktop path, version and lifecycle behaviour is unchanged. + */ +export class ElectronAppEnvironment implements AppEnvironment { + getPath(name: AppPathName): string { + return app.getPath(name) + } + + getAppPath(): string { + return app.getAppPath() + } + + getVersion(): string { + return app.getVersion() + } + + isPackaged(): boolean { + return app.isPackaged + } + + onWillQuit(handler: () => void): void { + app.on('will-quit', handler) + } + + exit(code = 0): void { + app.exit(code) + } + + getAppMetrics(): AppProcessMetric[] { + // Electron's ProcessMetric is structurally compatible with the loose mirror + // (pid plus optional cpu/memory); the nominal types differ, hence the cast. + return app.getAppMetrics() as unknown as AppProcessMetric[] + } +} diff --git a/src/main/host/electron-browser-commands.ts b/src/main/host/electron-browser-commands.ts new file mode 100644 index 00000000000..24d53f00eb3 --- /dev/null +++ b/src/main/host/electron-browser-commands.ts @@ -0,0 +1,6 @@ +import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' +import type { RuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' + +/** The desktop factory. Importing this file is what pulls in the Chromium browser cluster. */ +export const electronRuntimeBrowserCommandsFactory: RuntimeBrowserCommandsFactory = (host) => + new RuntimeBrowserCommands(host) diff --git a/src/main/host/electron-http-client.ts b/src/main/host/electron-http-client.ts new file mode 100644 index 00000000000..daa538757bc --- /dev/null +++ b/src/main/host/electron-http-client.ts @@ -0,0 +1,14 @@ +import { net, session } from 'electron' +import type { MainHttpClient } from '../network/http-client' + +/** + * The desktop HTTP client: Chromium's network stack, which follows session and proxy + * state and sends a Chrome user agent. + * + * `session.defaultSession` throws before the app is ready, so it is read per call + * rather than captured at install time. + */ +export const electronHttpClient: MainHttpClient = { + fetch: (url, init) => net.fetch(url, init), + proxySession: () => session.defaultSession +} diff --git a/src/main/host/electron-runtime-desktop-surface.ts b/src/main/host/electron-runtime-desktop-surface.ts new file mode 100644 index 00000000000..f709804955c --- /dev/null +++ b/src/main/host/electron-runtime-desktop-surface.ts @@ -0,0 +1,20 @@ +import { BrowserWindow, ipcMain, Notification } from 'electron' +import type { RuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' + +/** The desktop implementation of the runtime's optional desktop facilities. */ +export const electronRuntimeDesktopSurface: RuntimeDesktopSurface = { + showNotification: ({ title, body }) => { + if (!Notification.isSupported()) { + return false + } + new Notification({ title, body }).show() + return true + }, + findWindowById: (id) => BrowserWindow.fromId(id), + onIpc: (channel, listener) => { + ipcMain.on(channel, listener as Parameters[1]) + }, + removeIpcListener: (channel, listener) => { + ipcMain.removeListener(channel, listener as Parameters[1]) + } +} diff --git a/src/main/host/electron-secret-store.test.ts b/src/main/host/electron-secret-store.test.ts new file mode 100644 index 00000000000..0a1f3127cb6 --- /dev/null +++ b/src/main/host/electron-secret-store.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const safeStorageMock = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((plainText: string) => Buffer.from(`os-sealed:${plainText}`)), + decryptString: vi.fn((cipher: Buffer) => cipher.toString().slice('os-sealed:'.length)), + getSelectedStorageBackend: vi.fn(() => 'gnome_libsecret') +})) + +vi.mock('electron', () => ({ safeStorage: safeStorageMock })) + +const { ElectronSecretStore } = await import('./electron-secret-store') + +describe('ElectronSecretStore', () => { + beforeEach(() => { + vi.clearAllMocks() + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + safeStorageMock.getSelectedStorageBackend.mockReturnValue('gnome_libsecret') + }) + + function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } + } + + describe('at-rest protection reporting', () => { + it('reports no gap when a real keyring backend is selected', () => { + withPlatform('linux', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toBeNull() + }) + }) + + it('reports a gap for the Linux basic_text backend, which protects nothing', () => { + safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') + withPlatform('linux', () => { + const gap = new ElectronSecretStore().describeProtectionGap() + expect(gap).toMatch(/built-in key/) + expect(gap).toMatch(/gnome-keyring|kwallet/) + }) + }) + + it('keeps sealing AVAILABLE on basic_text so stored credentials still decrypt', () => { + // Why this matters more than the warning: flipping isEncryptionAvailable() would + // make decryptWithStatus() stop attempting and return every stored secret as + // empty. The gap is a trust signal, never a capability switch. + safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') + withPlatform('linux', () => { + const store = new ElectronSecretStore() + expect(store.isEncryptionAvailable()).toBe(true) + expect(store.decryptString(store.encryptString('linear-token'))).toBe('linear-token') + }) + }) + + it('does not consult the backend on macOS, where basic_text does not exist', () => { + withPlatform('darwin', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toBeNull() + }) + expect(safeStorageMock.getSelectedStorageBackend).not.toHaveBeenCalled() + }) + + it('reports the missing-keyring gap before considering the backend', () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + withPlatform('linux', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toMatch(/keyring is unavailable/) + }) + }) + }) + + // Why this shape: the whole safety argument for the SecretStore refactor is that the + // desktop byte path did not change. That is only true if this adapter forwards + // verbatim — same argument, same return value, no re-encoding. + it('forwards encryptString to safeStorage and returns its buffer unchanged', () => { + const sealed = new ElectronSecretStore().encryptString('token') + expect(safeStorageMock.encryptString).toHaveBeenCalledExactlyOnceWith('token') + expect(sealed).toBe(safeStorageMock.encryptString.mock.results[0]!.value) + }) + + it('forwards decryptString to safeStorage and returns its string unchanged', () => { + const cipher = Buffer.from('os-sealed:token') + expect(new ElectronSecretStore().decryptString(cipher)).toBe('token') + expect(safeStorageMock.decryptString).toHaveBeenCalledExactlyOnceWith(cipher) + }) + + // Why the narrower claim: safeStorage is mocked here, so this proves the adapter + // pairs encrypt/decrypt without mangling the buffer — NOT that credentials sealed by + // a previous build still open. Real-ciphertext compatibility needs a captured fixture. + it('pairs encryptString and decryptString without altering the payload', () => { + const store = new ElectronSecretStore() + expect(store.decryptString(store.encryptString('linear-token'))).toBe('linear-token') + }) + + it('reports availability from safeStorage rather than caching it', () => { + const store = new ElectronSecretStore() + expect(store.isEncryptionAvailable()).toBe(true) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + expect(store.isEncryptionAvailable()).toBe(false) + }) + + it('has no reason to give while sealing works', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toBeNull() + }) + + it('names the missing facility when sealing is unavailable, so the plaintext fallback is explainable', () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const reason = new ElectronSecretStore().describeProtectionGap() + expect(reason).toContain('unencrypted') + if (process.platform === 'linux') { + expect(reason).toContain('keyring') + } + }) +}) diff --git a/src/main/host/electron-secret-store.ts b/src/main/host/electron-secret-store.ts new file mode 100644 index 00000000000..ae9f8643a74 --- /dev/null +++ b/src/main/host/electron-secret-store.ts @@ -0,0 +1,39 @@ +import { safeStorage } from 'electron' +import type { SecretStore } from '../../shared/secret-store' + +/** + * Electron-backed SecretStore for the desktop app: a pass-through to + * `electron.safeStorage`, which seals against the OS keychain. + */ +export class ElectronSecretStore implements SecretStore { + isEncryptionAvailable(): boolean { + return safeStorage.isEncryptionAvailable() + } + + encryptString(plainText: string): Buffer { + return safeStorage.encryptString(plainText) + } + + decryptString(cipher: Buffer): string { + return safeStorage.decryptString(cipher) + } + + describeProtectionGap(): string | null { + if (!safeStorage.isEncryptionAvailable()) { + // Why platform-specific: the fix differs, and "encryption unavailable" alone + // sends users looking in the wrong place. + return process.platform === 'linux' + ? 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.' + : 'The OS keychain is unavailable, so secrets are stored unencrypted.' + } + // Why this is not folded into isEncryptionAvailable(): on Linux with no keyring, + // Electron falls back to `basic_text`, which "encrypts" with a hardcoded password. + // It round-trips, so sealing and unsealing genuinely work and must keep working — + // reporting it unavailable would strand every credential already stored this way. + // But it protects nothing, and reporting it as sealed is the actual lie. + if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend() === 'basic_text') { + return 'Secrets are obfuscated with a built-in key, not protected by the OS keyring. Install and unlock gnome-keyring or kwallet, then restart Orca, to seal them properly.' + } + return null + } +} diff --git a/src/main/host/electron-speech-services.ts b/src/main/host/electron-speech-services.ts new file mode 100644 index 00000000000..52ccf9099c1 --- /dev/null +++ b/src/main/host/electron-speech-services.ts @@ -0,0 +1,9 @@ +import { ModelManager } from '../speech/model-manager' +import { SttService } from '../speech/stt-service' +import type { SpeechServiceFactories } from '../speech/speech-runtime-service' + +/** The desktop speech factories. Importing this file is what pulls Electron's net in. */ +export const electronSpeechServiceFactories: SpeechServiceFactories = { + createModelManager: (customModelsDir) => new ModelManager(customModelsDir), + createSttService: (models) => new SttService(models) +} diff --git a/src/main/host/secret-protection-report.test.ts b/src/main/host/secret-protection-report.test.ts new file mode 100644 index 00000000000..2288bf11806 --- /dev/null +++ b/src/main/host/secret-protection-report.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { setSecretStore, _resetSecretStoreForTests } from '../../shared/secret-store' +import { reportSecretProtectionGap } from './secret-protection-report' + +function installStore(gap: string | null): void { + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(plainText), + decryptString: (cipher) => cipher.toString(), + describeProtectionGap: () => gap + }) +} + +describe('reportSecretProtectionGap', () => { + it('logs and returns the gap when protection is weaker than a user would assume', () => { + installStore('Secrets are obfuscated with a built-in key.') + const logged: string[] = [] + expect(reportSecretProtectionGap((m) => logged.push(m))).toMatch(/built-in key/) + expect(logged).toHaveLength(1) + expect(logged[0]).toContain('[secrets]') + }) + + it('stays silent when secrets are properly sealed', () => { + installStore(null) + const logged: string[] = [] + expect(reportSecretProtectionGap((m) => logged.push(m))).toBeNull() + expect(logged).toEqual([]) + }) + + it('does not throw startup when no store is installed', () => { + // Why: this is diagnostics running early in bootstrap. The useful error is the one + // raised by the first real read, not a crash from the thing reporting on it. + _resetSecretStoreForTests() + const logged: string[] = [] + expect(() => reportSecretProtectionGap((m) => logged.push(m))).not.toThrow() + expect(logged[0]).toContain('could not determine at-rest protection') + }) +}) diff --git a/src/main/host/secret-protection-report.ts b/src/main/host/secret-protection-report.ts new file mode 100644 index 00000000000..511b2a4425c --- /dev/null +++ b/src/main/host/secret-protection-report.ts @@ -0,0 +1,30 @@ +import { getSecretStore } from '../../shared/secret-store' + +/** + * Report at-rest secret protection once at startup. + * + * Why this exists: the port has always been able to describe a protection gap, and + * nothing read it — so a Linux user whose secrets are obfuscated with a built-in key + * was told nothing at all. Reading it here is the difference between the port + * documenting a promise and keeping one. + * + * Deliberately not fatal and not a dialog: sealing still works, so blocking startup + * would be worse than the gap it reports. + */ +export function reportSecretProtectionGap( + log: (message: string) => void = (message) => console.warn(message) +): string | null { + let gap: string | null + try { + gap = getSecretStore().describeProtectionGap() + } catch (error) { + // Why swallow: this is diagnostics. An uninstalled store is already a hard failure + // at the first real read, and that error is the useful one. + log(`[secrets] could not determine at-rest protection: ${String(error)}`) + return null + } + if (gap) { + log(`[secrets] ${gap}`) + } + return gap +} diff --git a/src/main/index.ts b/src/main/index.ts index db9cc9506b6..5686dd41e33 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,16 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' -import { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerMonitor, type Tray } from 'electron' +import { + app, + BrowserWindow, + dialog, + ipcMain, + nativeTheme, + powerMonitor, + type Tray, + session +} from 'electron' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { @@ -11,6 +20,21 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' +import { setAppEnvironment } from '../shared/app-environment' +import { ElectronAppEnvironment } from './host/electron-app-environment' +import { setPtyHostBindings } from './ipc/pty-host-bindings' +import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface' +import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface' +import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands' +import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' +import { electronHttpClient } from './host/electron-http-client' +import { setMainHttpClient } from './network/http-client' +import { electronSpeechServiceFactories } from './host/electron-speech-services' +import { setSpeechServiceFactories } from './speech/speech-runtime-service' +import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' +import { setSecretStore } from '../shared/secret-store' +import { ElectronSecretStore } from './host/electron-secret-store' +import { reportSecretProtectionGap } from './host/secret-protection-report' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' @@ -47,7 +71,7 @@ import { isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys } from './codex/codex-pane-account-registry' -import { closeAllWatchers } from './ipc/filesystem-watcher' +import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade' import { registerCoreHandlers } from './ipc/register-core-handlers' @@ -350,7 +374,10 @@ import { } from '../shared/runtime-types' import { LocalPtyProvider } from './providers/local-pty-provider' import { KeybindingService } from './keybindings/keybinding-service' -import { applyElectronProxySettings } from './network/proxy-settings' +import { + applyElectronProxySettings, + setDefaultProxySessionResolver +} from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' import { CliInstaller } from './cli/cli-installer' import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' @@ -850,6 +877,42 @@ if (!hasSingleInstanceLock) { // Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. if (hasSingleInstanceLock) { + // Why first: both accessors throw until installed, and everything below this line + // may resolve a path or read a credential. Neither constructor touches `app` or + // `safeStorage` — they resolve lazily per call — so installing here changes no + // timing, in particular not the pre-ready Keychain service-name resolution and + // the app.setName ordering the userData captures below depend on. + setAppEnvironment(new ElectronAppEnvironment()) + setSecretStore(new ElectronSecretStore()) + // Why right after install: this is the first moment the answer is knowable, and a + // silent weak backend is the gap users most need told about. + reportSecretProtectionGap() + // Why at process level, not per-window: pty.ts registers against injected surfaces so + // it can load without electron, and an Electron main process always has ipcMain — + // whether a window exists is irrelevant. Installing this in attachMainWindowServices + // meant `orca serve` registered its PTY handlers against no-ops before any window + // attached, so a paired desktop owner never received them. + setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) + // Why also at process level: the runtime's notification, window-lookup and + // tab-create-reply channel are desktop-only. A Node host installs none and the + // runtime routes notifications to paired clients instead. + setRuntimeDesktopSurface(electronRuntimeDesktopSurface) + // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser + // cluster into the graph. The desktop installs it; a Node host installs none and every + // browser RPC rejects, which capability filtering already tells clients about. + setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) + // Why here: proxy-settings only needed electron for `session.defaultSession`. The + // desktop supplies it; a Node host has no Chromium proxy config to consult, so the + // environment variables are the whole answer there. + setDefaultProxySessionResolver(() => session.defaultSession) + // Why here: integrations use Chromium's network stack on the desktop. A Node host + // falls back to the platform default, which is a real behavioural difference (proxy + // read from the environment, Node's user agent) rather than a transparent swap. + setMainHttpClient(electronHttpClient) + // Why here: constructing the speech services is what pulls Electron's streaming net + // request in. A host without them rejects speech calls rather than pretending. + setSpeechServiceFactories(electronSpeechServiceFactories) + setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. const shouldCoupleToDevParent = is.dev && !isServeMode installDevParentDisconnectQuit(shouldCoupleToDevParent) diff --git a/src/main/integration-credential-file.ts b/src/main/integration-credential-file.ts index 96823a77a65..01a85c28b51 100644 --- a/src/main/integration-credential-file.ts +++ b/src/main/integration-credential-file.ts @@ -8,7 +8,7 @@ import { unlinkSync, writeSync } from 'node:fs' -import { safeStorage } from 'electron' +import { getSecretStore } from '../shared/secret-store' import { credentialDecryptionMessage, type IntegrationCredentialService @@ -31,12 +31,12 @@ export function writeEncryptedCredential( path: string, value: string ): void { - if (safeStorage.isEncryptionAvailable()) { - writeCredentialFileAtomic(path, safeStorage.encryptString(value)) + if (getSecretStore().isEncryptionAvailable()) { + writeCredentialFileAtomic(path, getSecretStore().encryptString(value)) return } console.warn( - `[${service.toLowerCase()}] safeStorage encryption unavailable — storing credential in plaintext` + `[${service.toLowerCase()}] secret encryption unavailable — storing credential in plaintext` ) writeCredentialFileAtomic(path, Buffer.from(value, 'utf-8')) } @@ -111,9 +111,9 @@ export function readStoredCredentialToken( return null } - if (safeStorage.isEncryptionAvailable()) { + if (getSecretStore().isEncryptionAvailable()) { try { - return usableToken(safeStorage.decryptString(raw)) + return usableToken(getSecretStore().decryptString(raw)) } catch { return readPlaintextLegacyCredential(service, raw) } @@ -127,7 +127,7 @@ function readPlaintextLegacyCredential( raw: Buffer ): string | null { const plaintext = decodeUtf8(raw) - // Why: legacy plaintext tokens are printable UTF-8; safeStorage ciphertext + // Why: legacy plaintext tokens are printable UTF-8; sealed ciphertext // such as macOS v10 blobs must not be decoded into auth-header junk. if (plaintext === null || hasControlCharacter(plaintext)) { throw new CredentialDecryptionError(service) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 0a337967386..71830efc7c2 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -39,6 +39,7 @@ import { } from './watcher-removal-drain' // Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows). import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' +import type { WorktreeWatcherRemoval } from './worktree-watcher-removal' // ── Per-root watcher state ─────────────────────────────────────────── // WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape. @@ -1959,3 +1960,16 @@ export async function closeAllWatchers(): Promise { } remoteWatchers.clear() } + +/** The desktop binding for {@link WorktreeWatcherRemoval}. Installed during startup. */ +export const desktopWorktreeWatcherRemoval: WorktreeWatcherRemoval = { + closeLocal: (worktreePath, deadline) => + deadline + ? closeLocalWatcherForWorktreePath(worktreePath, deadline) + : closeLocalWatcherForWorktreePath(worktreePath), + restoreLocal: restoreLocalWatcherAfterFailedRemoval, + forgetLocal: forgetLocalWatcherRemovalSnapshot, + closeRemote: closeRemoteWatcherForWorktreePath, + restoreRemote: restoreRemoteWatcherAfterFailedRemoval, + forgetRemote: forgetRemoteWatcherRemovalSnapshot +} diff --git a/src/main/ipc/orca-profile-auth-handlers.test.ts b/src/main/ipc/orca-profile-auth-handlers.test.ts index 73726dd2b52..e16d949dc1a 100644 --- a/src/main/ipc/orca-profile-auth-handlers.test.ts +++ b/src/main/ipc/orca-profile-auth-handlers.test.ts @@ -21,7 +21,6 @@ const { vi.mock('electron', () => ({ app: { exit: vi.fn(), - getPath: () => '/tmp/orca-user-data', relaunch: vi.fn() }, ipcMain: { @@ -56,9 +55,13 @@ vi.mock('../orca-profiles/profile-cloud-service', () => ({ })) import { registerOrcaProfileHandlers } from './orca-profiles' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' describe('registerOrcaProfileHandlers auth channels', () => { beforeEach(() => { + // Why the port and per-test: userData resolves through AppEnvironment now, and + // the global setup's beforeEach reinstates its own fake before this runs. + installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) handlers.clear() createCloudLinkedOrcaProfileMock.mockReset() connectCurrentOrcaProfileMock.mockReset() diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index fe69ce2e138..215b559327d 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -30,8 +30,7 @@ vi.mock('electron', () => ({ app: { exit: appExitMock, quit: appQuitMock, - relaunch: appRelaunchMock, - getPath: () => '/tmp/orca-user-data' + relaunch: appRelaunchMock }, ipcMain: { handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { @@ -68,9 +67,13 @@ vi.mock('../orca-profiles/profile-project-transfer', () => ({ })) import { registerOrcaProfileHandlers } from './orca-profiles' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' describe('registerOrcaProfileHandlers', () => { beforeEach(() => { + // Why the port and per-test: userData resolves through AppEnvironment now, and + // the global setup's beforeEach reinstates its own fake before this runs. + installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) vi.useFakeTimers() handlers.clear() appExitMock.mockReset() diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index 7eba4c205da..a61d240f2bb 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -1,14 +1,14 @@ import { existsSync } from 'node:fs' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' -type ElectronAppPath = { getAppPath(): string; isPackaged: boolean } +type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } +// Why the port and not require('electron'): this module is reachable from plain-Node +// fork entries, where the literal text require("electron") fails the build guard even +// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. function loadElectronApp(): ElectronAppPath | null { - try { - return require('electron').app ?? null - } catch { - return null - } + return hasAppEnvironment() ? getAppEnvironment() : null } export function resolveWatcherProcessEntryPath( @@ -53,7 +53,7 @@ export function resolveWatcherProcessEntryPathWithoutApp( export function getWatcherProcessEntryPath(): string { const app = loadElectronApp() if (app) { - return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged) + return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged()) } return resolveWatcherProcessEntryPathWithoutApp(process.cwd(), process.resourcesPath) } diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 29a78812353..a6bc7b6f767 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -14,7 +14,6 @@ import { removeInstalledPlugin } from '../plugins/plugin-install' import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' -import { buildPluginList, type PluginListEntry } from '../plugins/plugin-list-projection' import type { PluginService } from '../plugins/plugin-service' import { bindPluginPanelOwnerLifecycle } from '../plugins/plugin-panel-owner-lifecycle' import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' @@ -68,13 +67,10 @@ const removeArgsSchema = z.object({ }) const logsArgsSchema = z.object({ pluginKey: z.string().min(1) }) -export async function listPluginsForClients( - pluginService: PluginService -): Promise { - await pluginService.whenReady() - const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath)) - return buildPluginList(pluginService, lock) -} +// Why re-exported: moved to ../plugins/plugin-client-list so the runtime RPC can reach +// it without ipcMain. Existing importers of this path keep working. +export { listPluginsForClients } from '../plugins/plugin-client-list' +import { listPluginsForClients } from '../plugins/plugin-client-list' export function canRemoveInstalledPlugin( pluginService: PluginService, diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 02a47f7570f..37a061e0541 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -135,13 +135,31 @@ describe('preflight', () => { const target = String(args[0]) if (target === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } if (target === 'continue') { - return { environmentResolved: true, code: 0, stdout: 'continue: shell built-in command\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: 'continue: shell built-in command\n', + stderr: '', + timedOut: false + } } if (target === 'cursor-agent') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/cursor-agent\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/cursor-agent\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -155,7 +173,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -169,10 +193,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -190,10 +226,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/mock/windows/npm/claude.cmd\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/mock/windows/npm/claude.cmd\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/mock/windows/programs/orca.cmd\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/mock/windows/programs/orca.cmd\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -239,7 +287,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -273,10 +327,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'openclaude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/openclaude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/openclaude\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'cursor-agent') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/cursor-agent\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/cursor-agent\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -303,7 +369,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) { - return { environmentResolved: true, code: 0, stdout: '/home/test/.local/bin/codex\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/home/test/.local/bin/codex\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -328,7 +400,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -349,7 +427,13 @@ describe('preflight', () => { expect(script).not.toContain("'orca-dev'") expect(script).not.toContain("'orca-ide'") if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -363,7 +447,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'vibe') { - return { environmentResolved: true, code: 0, stdout: '/home/test/.local/bin/vibe\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/home/test/.local/bin/vibe\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -392,7 +482,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -405,7 +501,7 @@ describe('preflight', () => { // pinned by its own tests. What this suite owns is that detection asks the // right distro on the lane that carries the user's PATH. expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ distro: 'Ubuntu', lane: 'probe' }) + expect.objectContaining({ distro: 'Ubuntu', loginPath: 'preferred' }) ) }) @@ -433,7 +529,7 @@ describe('preflight', () => { expect(resolveCliCommandsMock).not.toHaveBeenCalled() // No distro named: the runner resolves the default. expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ distro: undefined, lane: 'probe' }) + expect.objectContaining({ distro: undefined, loginPath: 'preferred' }) ) }) }) diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index 63cb6657b92..ea4da2d240d 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -276,7 +276,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes('gh') && script.includes('--version')) { - return { environmentResolved: true, code: 0, stdout: 'gh version 2.0.0\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: 'gh version 2.0.0\n', + stderr: '', + timedOut: false + } } if (script.includes('gh') && script.includes('auth status')) { return { @@ -296,14 +302,14 @@ describe('preflight', () => { expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', - lane: 'probe', + loginPath: 'preferred', script: expect.stringMatching(/gh[\s\S]*--version/) }) ) expect(runWslProcessMock).toHaveBeenCalledWith( expect.objectContaining({ distro: 'Ubuntu', - lane: 'probe', + loginPath: 'preferred', script: expect.stringMatching(/gh[\s\S]*auth status/) }) ) diff --git a/src/main/ipc/preflight-remote-ssh.test.ts b/src/main/ipc/preflight-remote-ssh.test.ts index b94f9c297a9..14d79e953e8 100644 --- a/src/main/ipc/preflight-remote-ssh.test.ts +++ b/src/main/ipc/preflight-remote-ssh.test.ts @@ -70,6 +70,9 @@ vi.mock('../pty/windows-environment-path', () => ({ vi.mock('./ssh', () => ({ getActiveMultiplexer: getActiveMultiplexerMock })) +vi.mock('../ssh/ssh-target-registry', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock +})) vi.mock('../bitbucket/client', () => ({ getBitbucketAuthStatus: getBitbucketAuthStatusMock diff --git a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts index f28886cdaec..6ac63d39c5a 100644 --- a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts +++ b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts @@ -1,4 +1,4 @@ -import { getActiveMultiplexer } from './ssh' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' export type RemoteWindowsTerminalCapabilities = { wslAvailable: boolean diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 2b15efe37fc..a646a9625b5 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -1,3 +1,7 @@ +import { execFileSync, type ExecFileSyncOptions } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runWslProcessMock = vi.hoisted(() => vi.fn()) @@ -6,7 +10,7 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' -type RunWslProcessSpec = { distro?: string; lane: string; script: string } +type RunWslProcessSpec = { distro?: string; loginPath: string; script: string } function lastSpec(): RunWslProcessSpec { const call = runWslProcessMock.mock.calls.at(-1) @@ -17,18 +21,24 @@ function lastSpec(): RunWslProcessSpec { describe('detectWslCommandsOnPath', () => { beforeEach(() => { runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) }) afterEach(() => { vi.restoreAllMocks() }) - it('runs on the probe lane -- no shell, so no rc/motd banner can appear', async () => { + it('asks for the login PATH without running a shell, so no banner can appear', async () => { await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude']) const spec = lastSpec() - expect(spec.lane).toBe('probe') + expect(spec.loginPath).toBe('preferred') expect(spec.distro).toBe('Ubuntu') }) @@ -121,3 +131,92 @@ describe('detectWslCommandsOnPath', () => { expect(runWslProcessMock).not.toHaveBeenCalled() }) }) + +describe('the detection script itself, run by a real POSIX shell', () => { + // Not a mock: the fallback is shell globbing and `[ -x ]`, which only the + // shell can be trusted about. Skipped on Windows, where /bin/sh is absent. + const itPosix = process.platform === 'win32' ? it.skip : it + let home: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-wsl-detect-')) + }) + afterEach(() => { + rmSync(home, { recursive: true, force: true }) + }) + + const plant = (dir: string, name: string): void => { + mkdirSync(join(home, dir), { recursive: true }) + const file = join(home, dir, name) + writeFileSync(file, '#!/bin/sh\necho hi\n') + chmodSync(file, 0o755) + } + + const runScript = async (): Promise => { + runWslProcessMock.mockResolvedValue({ + environmentResolved: false, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['orca-fake-cli', 'nosuchtool']) + const script = String(runWslProcessMock.mock.calls.at(-1)?.[0].script) + const options: ExecFileSyncOptions = { + encoding: 'utf8', + env: { HOME: home, PATH: '/usr/bin:/bin' } + } + return String(execFileSync('/bin/sh', ['-c', script], options)) + } + + itPosix('finds an nvm-installed binary the login PATH would have shown', async () => { + // #9725: without the login PATH this resolves to nothing and preflight + // reports a working install as "not installed". + plant('.nvm/versions/node/v20.1.0/bin', 'orca-fake-cli') + const out = await runScript() + expect(out).toContain('__ORCA_AGENT_PATH__orca-fake-cli') + expect(out).toContain('.nvm/versions/node/v20.1.0/bin/orca-fake-cli') + }) + + itPosix('finds a ~/.local/bin install', async () => { + plant('.local/bin', 'orca-fake-cli') + expect(await runScript()).toContain('__ORCA_AGENT_PATH__orca-fake-cli') + }) + + itPosix('still reports nothing for a command that is genuinely absent', async () => { + plant('.local/bin', 'orca-fake-cli') + expect(await runScript()).not.toContain('nosuchtool') + }) + + itPosix('finds a CLI when $HOME contains a space', async () => { + // The dir list is globbed, so an unquoted $HOME word-split into a relative + // path and every CLI read as absent -- the #9725 symptom, from the fix. + const spaced = mkdtempSync(join(tmpdir(), 'orca has space-')) + const previous = home + home = spaced + try { + plant('.nvm/versions/node/v20.1.0/bin', 'orca-fake-cli') + expect(await runScript()).toContain(`${spaced}/.nvm/versions/node/v20.1.0/bin/orca-fake-cli`) + } finally { + home = previous + rmSync(spaced, { recursive: true, force: true }) + } + }) + + itPosix('does not report a directory as an installed CLI', async () => { + // Directories are mode 755 and pass -x. Preflight would say installed and + // the launch would fail later with EISDIR. + mkdirSync(join(home, '.local/bin/orca-fake-cli'), { recursive: true }) + expect(await runScript()).not.toContain('__ORCA_AGENT_PATH__orca-fake-cli') + }) + + itPosix.each([ + '.volta/bin', + '.asdf/shims', + '.fnm/aliases/default/bin', + '.local/share/mise/shims' + ])('covers %s, which the native fallback also probes', async (dir) => { + plant(dir, 'orca-fake-cli') + expect(await runScript()).toContain('__ORCA_AGENT_PATH__orca-fake-cli') + }) +}) diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index b2c2c02309b..701da37f8c4 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import { buildPosixFallbackPathPrelude } from '../../shared/posix-version-manager-bin-dirs' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { runWslProcess } from '../wsl/wsl-runner' @@ -25,6 +26,11 @@ export async function detectWslCommandsOnPath( }) // Newlines keep the loop valid in every POSIX shell used here. const script = [ + // The same fallback the preflight command runner uses: append the + // version-manager dirs to PATH and let the ordinary lookup find them. A + // second bespoke `[ -x ]` walk here duplicated the lookup script's own + // `! -d` guard, which is how a directory once read as an installed CLI. + buildPosixFallbackPathPrelude(), `for cmd in ${commandList}; do`, lookupScript, 'if [ -n "$resolved" ]; then', @@ -38,15 +44,10 @@ export async function detectWslCommandsOnPath( // with no shell in the loop, so there is no rc/motd banner to land in stdout. const result = await runWslProcess({ distro: wslTarget.distro, - lane: 'probe', + loginPath: 'preferred', script, - // Why degrade rather than refuse: the Set has no room for "unverifiable", - // and refusing would turn a slow distro into "no agents" anyway -- via a - // throw instead of an empty result. Degrading at least finds anything on - // the default PATH. The residual gap (an nvm-only agent missed during the - // probe's retry window, #9725) is the pre-migration behaviour, not new, - // and Refresh now re-probes. - allowDegradedEnvironment: true, + // POSIX `command -v` loop; declared because the payload is opaque here. + shell: 'sh', timeoutMs: WSL_AGENT_DETECTION_TIMEOUT_MS }) // runProcess resolves on a timeout and on a non-zero exit, so partial diff --git a/src/main/ipc/preflight-wsl-command.test.ts b/src/main/ipc/preflight-wsl-command.test.ts index 931b1b1275b..f5842f6afd8 100644 --- a/src/main/ipc/preflight-wsl-command.test.ts +++ b/src/main/ipc/preflight-wsl-command.test.ts @@ -1,3 +1,7 @@ +import { execFileSync, type ExecFileSyncOptions } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const runWslProcessMock = vi.hoisted(() => vi.fn()) @@ -17,15 +21,14 @@ beforeEach(() => { }) describe('runPreflightCommandInWsl', () => { - it('degrades rather than letting a slow distro read as "not installed"', async () => { + it('prefers the login PATH but never lets its absence decide the answer', async () => { // Every caller collapses a throw into a verdict: isCommandAvailable returns // false ("not installed"), isGhAuthenticated reads an empty payload as - // "not authenticated". Refusing here turns a slow distro into a confident - // wrong answer -- #9725 through the other door. + // "not authenticated". A missing login PATH must therefore never be fatal. await runPreflightCommandInWsl({ distro: 'Ubuntu' }, 'gh --version', 5_000) expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ allowDegradedEnvironment: true, distro: 'Ubuntu' }) + expect.objectContaining({ loginPath: 'preferred', distro: 'Ubuntu' }) ) }) @@ -45,3 +48,74 @@ describe('runPreflightCommandInWsl', () => { ).rejects.toMatchObject({ stdout: 'partial', stderr: 'boom', code: 1 }) }) }) + +describe('the PATH fallback, run by a real POSIX shell', () => { + const itPosix = process.platform === 'win32' ? it.skip : it + + itPosix('finds an nvm-installed CLI a degraded probe would have missed', async () => { + // #9725: the caller turns a throw into "not installed", so a degraded probe + // told the user to install a CLI their own terminal already runs. + // + // A fabricated name, not `gh`: CI has a real /usr/bin/gh, and the fallback + // APPENDS, so the PATH lookup correctly found the real one and the planted + // stub was never reached. The test was asserting the host, not the code. + const home = mkdtempSync(join(tmpdir(), 'orca-wsl-cmd-')) + try { + const bin = join(home, '.nvm/versions/node/v20.1.0/bin') + mkdirSync(bin, { recursive: true }) + writeFileSync(join(bin, 'orca-fake-cli'), '#!/bin/sh\necho cli-ok\n') + chmodSync(join(bin, 'orca-fake-cli'), 0o755) + + runWslProcessMock.mockResolvedValue({ + environmentResolved: false, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + await runPreflightCommandInWsl({ distro: 'Ubuntu' }, 'command -v orca-fake-cli', 5000) + const script = String(runWslProcessMock.mock.calls.at(-1)?.[0].script) + const options: ExecFileSyncOptions = { + encoding: 'utf8', + env: { HOME: home, PATH: '/usr/bin:/bin' } + } + expect(String(execFileSync('/bin/sh', ['-c', script], options))).toContain( + '.nvm/versions/node/v20.1.0/bin/orca-fake-cli' + ) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itPosix('appends rather than prepends, so a resolved PATH still wins', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-wsl-cmd-')) + const real = mkdtempSync(join(tmpdir(), 'orca-wsl-real-')) + try { + for (const [dir, body] of [ + [join(home, '.local/bin'), 'stale'], + [real, 'current'] + ] as const) { + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'orca-fake-cli'), `#!/bin/sh\necho ${body}\n`) + chmodSync(join(dir, 'orca-fake-cli'), 0o755) + } + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + await runPreflightCommandInWsl({ distro: 'Ubuntu' }, 'orca-fake-cli', 5000) + const script = String(runWslProcessMock.mock.calls.at(-1)?.[0].script) + const options: ExecFileSyncOptions = { + encoding: 'utf8', + env: { HOME: home, PATH: `${real}:/usr/bin:/bin` } + } + expect(String(execFileSync('/bin/sh', ['-c', script], options)).trim()).toBe('current') + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(real, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ipc/preflight-wsl-command.ts b/src/main/ipc/preflight-wsl-command.ts index 4729ea38e1d..0e8f86f2d0c 100644 --- a/src/main/ipc/preflight-wsl-command.ts +++ b/src/main/ipc/preflight-wsl-command.ts @@ -1,3 +1,4 @@ +import { buildPosixFallbackPathPrelude } from '../../shared/posix-version-manager-bin-dirs' import { runWslProcess } from '../wsl/wsl-runner' import type { WslPreflightTarget } from './preflight-wsl-agent-detection' @@ -12,13 +13,11 @@ export async function runPreflightCommandInWsl( // with no shell in the loop, so there is no rc/motd banner to strip. const result = await runWslProcess({ distro: target.distro, - lane: 'probe', - script: command, - // Why degrade: every caller collapses a throw into "not installed" or - // "not authenticated" (isCommandAvailable, isGhAuthenticated). Refusing - // here turns a slow distro into a confident wrong answer -- #9725 through - // the other door, on the branch built to close it. - allowDegradedEnvironment: true, + loginPath: 'preferred', + // Appending the version-manager dirs keeps a resolved login PATH + // authoritative while stopping a degraded probe from turning an installed + // CLI into "not installed" (#9725), the same fallback the native branch has. + script: `${buildPosixFallbackPathPrelude()}\n${command}`, timeoutMs }) // runWslProcess resolves on a timeout and on a non-zero exit; the caller's diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 1f0ab007971..e8a678b40cd 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,293 +1,21 @@ -import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { ipcMain } from 'electron' -import type { - PathSource, - ShellHydrationFailureReason -} from '../../shared/shell-path-hydration-types' -import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' -import { getAzureDevOpsAuthStatus } from '../azure-devops/client' -import { getBitbucketAuthStatus } from '../bitbucket/client' -import { getGiteaAuthStatus } from '../gitea/client' -import { _resetKnownHostsCache } from '../gitlab/gl-utils' -import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' -import { getActiveMultiplexer } from './ssh' -import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection' -import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' -import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' -import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' -import { - execCommandInWsl, - execLocalPreflightCommand, - isCommandAvailable, - isCommandOnPath, - shellQuote -} from './preflight-command-exec' import { + detectInstalledAgentsWithShellPathHydration, + detectRemoteAgents, detectRemoteWindowsTerminalCapabilities, - type RemoteWindowsTerminalCapabilities -} from './preflight-remote-windows-terminal-capabilities' -import { - getTuiAgentDetectionProbeCommands, - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - resolveDetectedTuiAgentIds -} from './tui-agent-detection-commands' + refreshShellPathAndDetectAgents, + runPreflightCheck +} from '../preflight/agent-detection' +import type { + PreflightRuntimeContext, + PreflightStatus, + RemoteWindowsTerminalCapabilities +} from '../preflight/agent-detection' -export type PreflightStatus = { - git: { installed: boolean } - gh: { installed: boolean; authenticated: boolean } - // Why: optional so existing renderer call sites that only render git/gh - // status keep typechecking. Consumers that surface GitLab-specific - // affordances (the GitLab tab in the source picker, MR list, etc.) - // gate on `glab?.authenticated`. - glab?: { installed: boolean; authenticated: boolean } - bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } - azureDevOps?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - gitea?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } -} - -export { detectRemoteWindowsTerminalCapabilities } -export type { RemoteWindowsTerminalCapabilities } - -// Why: cache the result so repeated Landing mounts don't re-spawn processes. -// The check only runs once per app session — relaunch to re-check. -let cached: PreflightStatus | null = null - -/** @internal - tests need a clean preflight cache between cases. */ -export function _resetPreflightCache(): void { - cached = null -} - -function uniqueAgentIds(ids: Iterable): string[] { - return [...new Set(ids)] -} - -async function detectCommandRuntime( - command: string, - context?: PreflightRuntimeContext -): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - return (await isCommandAvailable(command, wslTarget)) - ? { installed: true, wslTarget } - : { installed: false } - } - if (await isCommandAvailable(command)) { - return { installed: true } - } - return { installed: false } -} - -export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - const foundCommands = await detectWslCommandsOnPath( - wslTarget, - getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') - ) - return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') - } - - const probeCommands = getTuiAgentDetectionProbeCommands( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - process.platform - ) - const pathChecks = await Promise.all( - probeCommands.map(async (cmd) => ({ - cmd, - installedOnPath: await isCommandOnPath(cmd) - })) - ) - const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) - // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution - // computes user install dirs once instead of blocking once per missed CLI. - const installDirCommands = detectCommandsInInstallDirs(missedCommands) - const foundCommands = new Set( - pathChecks - .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) - .map(({ cmd }) => cmd) - ) - return resolveDetectedTuiAgentIds( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - foundCommands, - process.platform - ) -} - -export async function detectInstalledAgentsWithShellPathHydration( - context?: PreflightRuntimeContext -): Promise { - await hydrateShellPathForAgentDetection(context) - return detectInstalledAgents(context) -} - -export type RefreshAgentsResult = { - /** Agents detected after hydrating PATH from the user's login shell. */ - agents: string[] - /** PATH segments that were added this refresh (empty if nothing new). */ - addedPathSegments: string[] - /** True when the shell spawn succeeded. False = relied on existing PATH. */ - shellHydrationOk: boolean - /** Whether `detectInstalledAgents` ran against shell-hydrated PATH or only - * the seed list from `patchPackagedProcessPath`. Drives the on_path:false - * triage in tile A on dashboard 1562016. */ - pathSource: PathSource - /** Why hydration failed (or `'none'` on success). Typed against the shared - * alias so the IPC boundary stays in lockstep with the renderer-visible - * enum on `onboardingAgentPickedSchema`. */ - pathFailureReason: ShellHydrationFailureReason -} - -/** - * Re-spawn the user's login shell to refresh process.env.PATH, then re-run - * agent detection. Called by the Agents settings pane when the user clicks - * Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case - * without requiring an app restart. - */ -export async function refreshShellPathAndDetectAgents( - context?: PreflightRuntimeContext -): Promise { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - // Why invalidate first: the guest PATH is cached per distro for the process - // lifetime, so Refresh would otherwise re-read the pre-install PATH and - // keep reporting a just-installed CLI as absent -- the exact case this - // function exists to handle. - invalidateWslGuestEnvironment(wslTarget.distro) - const agents = await detectInstalledAgents(context) - return { - agents, - addedPathSegments: [], - shellHydrationOk: true, - pathSource: 'sync_seed_only', - pathFailureReason: 'none' - } - } - - const hydration = await hydrateShellPath({ force: true }) - const added = hydration.ok ? mergePathSegments(hydration.segments) : [] - const agents = await detectInstalledAgents(context) - return { - agents, - addedPathSegments: added, - shellHydrationOk: hydration.ok, - pathSource: hydration.ok ? 'shell_hydrate' : 'sync_seed_only', - pathFailureReason: hydration.failureReason - } -} - -export async function detectRemoteAgents(args: { connectionId: string }): Promise { - const mux = getActiveMultiplexer(args.connectionId) - if (!mux || mux.isDisposed()) { - // Why: remote agent detection is passive UI polling. A disconnected host has - // no detectable agents until reconnect, but should not spam IPC errors. - return [] - } - const result = (await mux.request('preflight.detectAgents', { - commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS - })) as { agents: string[] } - return uniqueAgentIds(result.agents) -} - -async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { - try { - await (wslTarget - ? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`) - : execLocalPreflightCommand('gh', ['auth', 'status'])) - // Why: for plain-text `gh auth status`, exit 0 means gh did not detect any - // authentication issues for the checked hosts/accounts. - return true - } catch (error) { - // Why: some environments may surface partial command output on the thrown - // error object. Keep a compatibility fallback so we avoid a false auth - // warning if success markers are present despite a non-zero result. - const stdout = (error as { stdout?: string }).stdout ?? '' - const stderr = (error as { stderr?: string }).stderr ?? '' - const output = `${stdout}\n${stderr}` - return output.includes('Logged in') || output.includes('Active account: true') - } -} - -// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth -// status to stderr in some versions and stdout in others; check both. -async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise { - try { - await (wslTarget - ? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`) - : execLocalPreflightCommand('glab', ['auth', 'status'])) - return true - } catch (error) { - const stdout = (error as { stdout?: string }).stdout ?? '' - const stderr = (error as { stderr?: string }).stderr ?? '' - const output = `${stdout}\n${stderr}` - return output.includes('Logged in') - } -} - -export async function runPreflightCheck( - force = false, - context?: PreflightRuntimeContext -): Promise { - const wslTarget = getPreflightWslTarget(context) - const cacheable = !wslTarget - if (cacheable && cached && !force) { - return cached - } - - if (process.platform === 'win32' && !wslTarget) { - await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force }) - } - - if (force) { - // Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the - // first GitLab request and never invalidated within a session. A user who - // runs `glab auth login` for a self-hosted host after Orca starts would - // otherwise see "No GitLab project found" until app relaunch. The Re-check - // path in IntegrationsPane forces preflight, so piggyback on that signal - // to refresh the host list too. - _resetKnownHostsCache() - } - - const [gitProbe, ghProbe, glabProbe] = await Promise.all([ - detectCommandRuntime('git', context), - detectCommandRuntime('gh', context), - detectCommandRuntime('glab', context) - ]) - - const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ - ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false), - glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false), - getBitbucketAuthStatus(), - getAzureDevOpsAuthStatus(), - getGiteaAuthStatus() - ]) - - const result = { - git: { installed: gitProbe.installed }, - gh: { installed: ghProbe.installed, authenticated: ghAuthenticated }, - glab: { installed: glabProbe.installed, authenticated: glabAuthenticated }, - bitbucket, - azureDevOps, - gitea - } - - if (cacheable) { - cached = result - } - - return result -} +// Why this file is thin: everything above the handler layer moved to +// ../preflight/agent-detection so the runtime can call it without ipcMain. +// Re-exported here so existing importers of `ipc/preflight` keep working. +export * from '../preflight/agent-detection' export function registerPreflightHandlers(): void { ipcMain.handle( diff --git a/src/main/ipc/pty-host-bindings.ts b/src/main/ipc/pty-host-bindings.ts new file mode 100644 index 00000000000..e1eb0dde552 --- /dev/null +++ b/src/main/ipc/pty-host-bindings.ts @@ -0,0 +1,68 @@ +import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' + +/** + * The host facilities the PTY handlers register against. + * + * Why injected rather than imported: `registerPtyHandlers` owns the PTY controller + * that `terminal.create` actually spawns through, and a Node-only backend needs that + * controller. Everything else in the module is already host-agnostic — the only thing + * pinning it to Electron was a static `ipcMain` / `powerMonitor` import used purely to + * register renderer handlers that no headless host will ever receive. + * + * The desktop passes the real Electron objects. A headless host passes nothing and + * gets no-ops, which is honest: there is no renderer to answer, so registering is a + * no-op rather than a lie about having registered. + */ + +/** + * Deliberately `any[]` on the rest args, matching Electron's own `IpcMain` signature: + * a narrower type here would not accept the real object, and widening at the call site + * would need a cast that hides genuine mismatches. + */ +export type PtyIpcSurface = { + handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: any[]) => unknown): void + on(channel: string, listener: (event: IpcMainEvent, ...args: any[]) => void): void + removeHandler(channel: string): void + removeAllListeners(channel: string): void +} + +export type PtyPowerSurface = { + on(event: 'suspend' | 'resume', listener: () => void): void +} + +/** Why not optional-chaining at 75 call sites: one object keeps the call sites unchanged. */ +export const noopPtyIpcSurface: PtyIpcSurface = { + handle: () => {}, + on: () => {}, + removeHandler: () => {}, + removeAllListeners: () => {} +} + +export const noopPtyPowerSurface: PtyPowerSurface = { + on: () => {} +} + +let currentIpc: PtyIpcSurface = noopPtyIpcSurface +let currentPower: PtyPowerSurface = noopPtyPowerSurface + +/** + * Install the host surfaces once at startup. Defaults are no-ops rather than a throw, + * unlike AppEnvironment/SecretStore: a host with no renderer legitimately has nothing to + * register against, and silently not registering handlers nobody can call is correct + * rather than a hidden downgrade. + */ +export function setPtyHostBindings(bindings: { + ipc?: PtyIpcSurface + power?: PtyPowerSurface +}): void { + currentIpc = bindings.ipc ?? noopPtyIpcSurface + currentPower = bindings.power ?? noopPtyPowerSurface +} + +export function getPtyIpc(): PtyIpcSurface { + return currentIpc +} + +export function getPtyPower(): PtyPowerSurface { + return currentPower +} diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index f20e0c3885f..5e5a9c819f7 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, vi } from 'vitest' +import * as electron from 'electron' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { setPtyHostBindings } from './pty-host-bindings' +import { testPtyIpcSurface } from './pty-ipc-test-surface' import type { Mock } from 'vitest' import { handleMock, @@ -89,6 +93,25 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { const envScope = createPtyIpcProcessEnvScope() beforeEach(() => { + // Why here: pty.ts registers against injected surfaces now, so the mocked ipcMain + // must be installed for the shared `handlers` map to keep capturing registrations. + setPtyHostBindings({ ipc: testPtyIpcSurface() }) + // Why here: pty.ts reads app paths and the packaged flag through the AppEnvironment + // port now, so the shared vi.mock('electron') app object alone is inert. Back the + // port with the same mocks so every suite's existing expectations still hold. + // Why read through the electron mock instead of hardcoding: suites toggle + // `app.isPackaged` mid-test to exercise dev-mode spawn paths, so the port must + // observe the same mutable field rather than freeze a value at install time. + const electronAppMock = ( + vi.mocked(electron) as unknown as { + app: { isPackaged: boolean; getPath: (name: string) => string; getVersion: () => string } + } + ).app + installFakeAppEnvironment({ + getPath: (name) => electronAppMock.getPath(name), + isPackaged: () => electronAppMock.isPackaged, + getVersion: () => electronAppMock.getVersion() + }) envScope.applyTestEnvDefaults() handlers.clear() handleMock.mockReset() diff --git a/src/main/ipc/pty-ipc-test-surface.ts b/src/main/ipc/pty-ipc-test-surface.ts new file mode 100644 index 00000000000..12f21e8fb37 --- /dev/null +++ b/src/main/ipc/pty-ipc-test-surface.ts @@ -0,0 +1,21 @@ +import type { PtyIpcSurface } from './pty-host-bindings' +import { + handleMock, + onMock, + removeAllListenersMock, + removeHandlerMock +} from './pty-ipc-mock-registry' + +/** + * The registration surface pty suites drive. Production injects Electron's `ipcMain`; + * suites inject this so the existing `handlers` map keeps capturing registrations + * exactly as it did when the module imported `ipcMain` directly. + */ +export function testPtyIpcSurface(): PtyIpcSurface { + return { + handle: handleMock as unknown as PtyIpcSurface['handle'], + on: onMock as unknown as PtyIpcSurface['on'], + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } +} diff --git a/src/main/ipc/pty-renderer-surface.ts b/src/main/ipc/pty-renderer-surface.ts new file mode 100644 index 00000000000..09fff75e681 --- /dev/null +++ b/src/main/ipc/pty-renderer-surface.ts @@ -0,0 +1,37 @@ +import type { BrowserWindow, WebContents } from 'electron' + +/** + * The renderer surface the PTY handlers talk to, which may not exist. + * + * Why: `orca serve` — and a future Node-only backend — run the same PTY handlers + * with no window. That used to be faked: `registerHeadlessPtyRuntime` built a + * `BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send` + * was a no-op, purely to satisfy the type — the "looks fine, silently lies" shape + * this codebase rejects elsewhere, and what forced an `electron` value import into a + * path that needs none. It now passes `null`. + * + * An absent renderer is semantically identical to a destroyed one — every call site + * already guards on `isDestroyed()` and skips — so model it as `null` and say so. + */ + +/** True when there is no renderer, or it is gone. Callers already treat these the same. */ +export function isRendererGone(window: BrowserWindow | null): boolean { + return window === null || window.isDestroyed() +} + +/** Send to the renderer if one is listening. Absent renderer drops the message, as a destroyed one does. */ +export function sendToRenderer( + window: BrowserWindow | null, + channel: string, + payload?: unknown +): void { + if (isRendererGone(window)) { + return + } + window!.webContents.send(channel, payload) +} + +/** The renderer's WebContents, or null. Used for identity checks and listener registration. */ +export function rendererWebContents(window: BrowserWindow | null): WebContents | null { + return isRendererGone(window) ? null : window!.webContents +} diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 78f7477f468..95dafaaab8a 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -11,8 +11,10 @@ describe('PTY startup barrier ordering', () => { const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) - const rendererSpawnStart = source.indexOf("ipcMain.handle(\n 'pty:spawn'") - const rendererSpawnEnd = source.indexOf("ipcMain.handle(\n 'pty:kill'", rendererSpawnStart) + // Why `ipc.` and not `ipcMain.`: the module registers against an injected surface now + // (pty-host-bindings) so it can run without electron. The ordering this asserts is unchanged. + const rendererSpawnStart = source.indexOf("ipc.handle(\n 'pty:spawn'") + const rendererSpawnEnd = source.indexOf("ipc.handle(\n 'pty:kill'", rendererSpawnStart) const rendererSpawn = source.slice(rendererSpawnStart, rendererSpawnEnd) for (const spawnBlock of [runtimeSpawn, rendererSpawn]) { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 6b436f186b7..cac4538be37 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1,16 +1,11 @@ /* eslint-disable max-lines -- Why: PTY IPC is centralized in one main-process module so spawn env scoping, lifecycle cleanup, process inspection, and renderer IPC stay behind one audited boundary. */ import { join, delimiter } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { getPtyIpc, getPtyPower, type PtyPowerSurface } from './pty-host-bindings' +import { isRendererGone, rendererWebContents, sendToRenderer } from './pty-renderer-surface' import { randomUUID } from 'node:crypto' import { statSync } from 'node:fs' -import { - type BrowserWindow, - type IpcMainEvent, - type IpcMainInvokeEvent, - type WebContents, - ipcMain, - app, - powerMonitor -} from 'electron' +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { PtyBindingSourceExpectation, Store } from '../persistence' @@ -2280,16 +2275,16 @@ let lastPowerResumeAtMs: number | null = null let powerSignalBreadcrumbsInstalled = false // Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. -function installPowerSignalBreadcrumbs(): void { +function installPowerSignalBreadcrumbs(power: PtyPowerSurface): void { if (powerSignalBreadcrumbsInstalled) { return } powerSignalBreadcrumbsInstalled = true - powerMonitor.on('suspend', () => { + power.on('suspend', () => { lastPowerSuspendAtMs = Date.now() mainDeliveryBreadcrumbs.record('power-suspend') }) - powerMonitor.on('resume', () => { + power.on('resume', () => { lastPowerResumeAtMs = Date.now() mainDeliveryBreadcrumbs.record('power-resume') }) @@ -2432,7 +2427,7 @@ export function unbindLocalProviderListeners(): void { // ─── IPC Registration ─────────────────────────────────────────────── export function registerPtyHandlers( - mainWindow: BrowserWindow, + mainWindow: BrowserWindow | null, runtime?: OrcaRuntimeService, getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, @@ -2448,12 +2443,16 @@ export function registerPtyHandlers( onPtyExit?: (id: string, exitSequence: number) => void } ): void { + const ipc = getPtyIpc() // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. clearRendererDispatcherReadyWatchdog() resetRendererDeliveryAccountingForLifecycleReset = () => {} invalidatePendingPtyDrainPriority = () => {} invalidatePendingPtyDrainPolicy = () => {} - registerRendererLifecycleResetHandlers(mainWindow.webContents) + const rendererContents = rendererWebContents(mainWindow) + if (rendererContents) { + registerRendererLifecycleResetHandlers(rendererContents) + } const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { if (connectionId) { @@ -2473,32 +2472,32 @@ export function registerPtyHandlers( } // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. - ipcMain.removeHandler('pty:spawn') - ipcMain.removeHandler('pty:kill') - ipcMain.removeHandler('pty:listSessions') - ipcMain.removeHandler('pty:hasPty') - ipcMain.removeHandler('pty:hasChildProcesses') - ipcMain.removeHandler('pty:getForegroundProcess') - ipcMain.removeHandler('pty:inspectProcess') - ipcMain.removeHandler('pty:confirmForegroundProcess') - ipcMain.removeHandler('pty:getCwd') - ipcMain.removeHandler('pty:getSize') - ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') - ipcMain.removeHandler('pty:declarePendingPaneSerializer') - ipcMain.removeHandler('pty:settlePaneSerializer') - ipcMain.removeHandler('pty:clearPendingPaneSerializer') - ipcMain.removeHandler('pty:reportRendererSerializerReady') - ipcMain.removeHandler('pty:getMainBufferSnapshot') - ipcMain.removeHandler('pty:sideEffectSnapshot') - ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') - ipcMain.removeHandler('pty:resetRendererDeliveryDebug') - ipcMain.removeHandler('pty:reportRendererDeliveryState') - ipcMain.removeHandler('pty:writeAccepted') - ipcMain.removeAllListeners('pty:write') - ipcMain.removeAllListeners('pty:ackColdRestore') - ipcMain.removeAllListeners('pty:ackData') - ipcMain.removeAllListeners('pty:deliveryResyncResponse') - ipcMain.removeAllListeners('pty:serializeBuffer:response') + ipc.removeHandler('pty:spawn') + ipc.removeHandler('pty:kill') + ipc.removeHandler('pty:listSessions') + ipc.removeHandler('pty:hasPty') + ipc.removeHandler('pty:hasChildProcesses') + ipc.removeHandler('pty:getForegroundProcess') + ipc.removeHandler('pty:inspectProcess') + ipc.removeHandler('pty:confirmForegroundProcess') + ipc.removeHandler('pty:getCwd') + ipc.removeHandler('pty:getSize') + ipc.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') + ipc.removeHandler('pty:declarePendingPaneSerializer') + ipc.removeHandler('pty:settlePaneSerializer') + ipc.removeHandler('pty:clearPendingPaneSerializer') + ipc.removeHandler('pty:reportRendererSerializerReady') + ipc.removeHandler('pty:getMainBufferSnapshot') + ipc.removeHandler('pty:sideEffectSnapshot') + ipc.removeHandler('pty:getRendererDeliveryDebugSnapshot') + ipc.removeHandler('pty:resetRendererDeliveryDebug') + ipc.removeHandler('pty:reportRendererDeliveryState') + ipc.removeHandler('pty:writeAccepted') + ipc.removeAllListeners('pty:write') + ipc.removeAllListeners('pty:ackColdRestore') + ipc.removeAllListeners('pty:ackData') + ipc.removeAllListeners('pty:deliveryResyncResponse') + ipc.removeAllListeners('pty:serializeBuffer:response') // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. if (localProvider instanceof LocalPtyProvider) { @@ -2527,9 +2526,9 @@ export function registerPtyHandlers( const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const ptySettings = getSettings?.() const env = buildPtyHostEnv(id, baseEnv, { - isPackaged: app.isPackaged, + isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ @@ -2879,13 +2878,13 @@ export function registerPtyHandlers( }) } perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) - const windowAlive = !mainWindow.isDestroyed() + const windowAlive = !isRendererGone(mainWindow) return { - appVersion: app.getVersion(), + appVersion: getAppEnvironment().getVersion(), mainUptimeMs: Math.round(process.uptime() * 1000), - windowFocused: windowAlive ? mainWindow.isFocused() : null, - windowVisible: windowAlive ? mainWindow.isVisible() : null, - windowMinimized: windowAlive ? mainWindow.isMinimized() : null, + windowFocused: windowAlive ? mainWindow!.isFocused() : null, + windowVisible: windowAlive ? mainWindow!.isVisible() : null, + windowMinimized: windowAlive ? mainWindow!.isMinimized() : null, msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), @@ -3082,7 +3081,7 @@ export function registerPtyHandlers( // Why: data for a fully gated PTY signals delivery may be stuck on lost ACKs (e.g. dropped across suspend); ask the renderer for authoritative totals instead of a wall-clock guess. function requestDeliveryResyncForGatedPty(): void { - if (deliveryResyncOutstandingRequestId !== null || mainWindow.isDestroyed()) { + if (deliveryResyncOutstandingRequestId !== null || isRendererGone(mainWindow)) { return } deliveryResyncRequestSerial += 1 @@ -3104,7 +3103,7 @@ export function registerPtyHandlers( }) }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) deliveryResyncTimer.unref?.() - mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) + sendToRenderer(mainWindow, 'pty:requestDeliveryResync', { requestId }) } // Why write off: bytes sent but never received after a confirmed wedge are gone (no ACK can repay them); hand back restore markers so panes repaint from the snapshot. @@ -3188,7 +3187,7 @@ export function registerPtyHandlers( rendererInFlightTotalChars += charCount recordPtyRendererDeliveryPressure(id) try { - mainWindow.webContents.send('pty:data', payload) + sendToRenderer(mainWindow, 'pty:data', payload) } catch (error) { const current = rendererDeliveryAccountingByPty.get(id) if (current) { @@ -3275,10 +3274,10 @@ export function registerPtyHandlers( reason: PtyModelRestoreReason, markerSeq: number | undefined ): void { - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { return } - mainWindow.webContents.send('pty:modelRestoreNeeded', { + sendToRenderer(mainWindow, 'pty:modelRestoreNeeded', { id, reason, ...(typeof markerSeq === 'number' ? { markerSeq } : {}) @@ -3475,13 +3474,13 @@ export function registerPtyHandlers( function armDispatcherReadyWatchdog(): void { clearDispatcherReadyWatchdog() - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { return } // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. dispatcherReadyWatchdogTimer = setTimeout(() => { dispatcherReadyWatchdogTimer = null - if (rendererPtyDispatcherReady || mainWindow.isDestroyed()) { + if (rendererPtyDispatcherReady || isRendererGone(mainWindow)) { return } rendererPtyDispatcherReady = true @@ -3494,7 +3493,7 @@ export function registerPtyHandlers( function flushPendingData(): void { flushTimer = null - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. producerFlowControl.releaseAll() clearDeliveryResyncProbe() @@ -3688,7 +3687,7 @@ export function registerPtyHandlers( } function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null { - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') return () => {} } @@ -3749,7 +3748,7 @@ export function registerPtyHandlers( } function finalizePtyExitForRenderer(payload: { id: string; code: number }): void { - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { rendererCreditBeforeExitByPty.delete(payload.id) return } @@ -3777,7 +3776,7 @@ export function registerPtyHandlers( schedulePendingDataAfterCreditReport(true) } } - mainWindow.webContents.send('pty:exit', { + sendToRenderer(mainWindow, 'pty:exit', { ...payload, ...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {}) }) @@ -3798,8 +3797,8 @@ export function registerPtyHandlers( } function sendPtySpawnedToRenderer(id: string): void { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:spawned', { id }) + if (!isRendererGone(mainWindow)) { + sendToRenderer(mainWindow, 'pty:spawned', { id }) } } @@ -3817,7 +3816,7 @@ export function registerPtyHandlers( const preservesSeq = !payload.transformed && rawLength === payload.data.length const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined const projectionId = projection?.identity.projectionSemanticsId - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { if (projectionId) { sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') } @@ -4065,14 +4064,10 @@ export function registerPtyHandlers( // just the pane whose write happened to detect the dead endpoint (STA-2373). localWriteUnavailableUnsub = localProvider.onWriteUnavailable?.((payload) => { - if ( - mainWindow.isDestroyed() || - (typeof mainWindow.webContents.isDestroyed === 'function' && - mainWindow.webContents.isDestroyed()) - ) { + if (isRendererGone(mainWindow)) { return } - mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id }) + sendToRenderer(mainWindow, 'pty:writeUnavailable', { id: payload.id }) }) ?? null // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. @@ -4165,7 +4160,7 @@ export function registerPtyHandlers( pending.resolve(result) } - ipcMain.on( + ipc.on( 'pty:serializeBuffer:response', ( _event, @@ -4226,7 +4221,7 @@ export function registerPtyHandlers( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } ): Promise { - if (mainWindow.isDestroyed()) { + if (isRendererGone(mainWindow)) { return Promise.resolve(null) } @@ -4244,7 +4239,7 @@ export function registerPtyHandlers( if (opts) { payload.opts = opts } - mainWindow.webContents.send('pty:serializeBuffer:request', payload) + sendToRenderer(mainWindow, 'pty:serializeBuffer:request', payload) }) } @@ -4261,9 +4256,13 @@ export function registerPtyHandlers( } rendererGateResetLoadHandler = resetRendererPtyDeliveryGateState rendererGateResetGoneHandler = resetRendererPtyDeliveryGateState - rendererGateResetWebContents = mainWindow.webContents - mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler) - mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler) + // Why guarded: with no renderer there is nothing to reset a gate for, and nothing + // that will ever emit these. Registering against a fake window was the old workaround. + if (rendererContents) { + rendererGateResetWebContents = rendererContents + rendererContents.on('did-finish-load', rendererGateResetLoadHandler) + rendererContents.on('render-process-gone', rendererGateResetGoneHandler) + } // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. clearDidFinishLoadHandler() @@ -4272,14 +4271,16 @@ export function registerPtyHandlers( didFinishLoadHandler = () => { // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). const generation = lp.advanceGeneration() - if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) { + if (rendererContents && options?.isRecoveryReloadInFlight?.(rendererContents.id)) { return } // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. lp.killOrphanedPtys(generation - 1) } - didFinishLoadWebContents = mainWindow.webContents - mainWindow.webContents.on('did-finish-load', didFinishLoadHandler) + if (rendererContents) { + didFinishLoadWebContents = rendererContents + rendererContents.on('did-finish-load', didFinishLoadHandler) + } } const assertFolderWorkspacePtyPathUsable = async ( @@ -4801,13 +4802,13 @@ export function registerPtyHandlers( settings: ptySettings }) if (isDaemonHostSpawn && sessionId && !preAdoptedStablePane) { - if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { + if (!isSafePtySessionId(sessionId, getAppEnvironment().getPath('userData'))) { throw new Error('Invalid PTY session id') } env = buildPtyHostEnv(sessionId, env ?? {}, { - isPackaged: app.isPackaged, + isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, stripInheritedOrcaCodexHome, @@ -5868,7 +5869,7 @@ export function registerPtyHandlers( }, clearBuffer: async (ptyId) => { // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. - mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + sendToRenderer(mainWindow, 'pty:clearBuffer:request', { ptyId }) try { await getProviderForPty(ptyId).clearBuffer(ptyId) } catch { @@ -5942,7 +5943,7 @@ export function registerPtyHandlers( return Math.max(0, Math.min(50_000, Math.floor(value))) } - ipcMain.handle( + ipc.handle( 'pty:getMainBufferSnapshot', async ( _event, @@ -6006,22 +6007,22 @@ export function registerPtyHandlers( ) // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). - ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + ipc.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null } return runtime.getTerminalSideEffectSnapshot(args.id) }) - installPowerSignalBreadcrumbs() - ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { + installPowerSignalBreadcrumbs(getPtyPower()) + ipc.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { return getPtyRendererDeliveryDebugSnapshot() }) - ipcMain.handle('pty:resetRendererDeliveryDebug', (): void => { + ipc.handle('pty:resetRendererDeliveryDebug', (): void => { resetPtyRendererDeliveryDebug() }) - ipcMain.handle( + ipc.handle( 'pty:spawn', async ( _event, @@ -6544,16 +6545,16 @@ export function registerPtyHandlers( } const sessionIdForEnv = effectiveSessionId // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. - if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) { + if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) { throw new Error('Invalid PTY session id') } // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). env = { ...baseEnv } try { buildPtyHostEnv(sessionIdForEnv, env, { - isPackaged: app.isPackaged, + isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, stripInheritedOrcaCodexHome, @@ -7232,15 +7233,10 @@ export function registerPtyHandlers( ) const reportUnavailablePtyWrite = (id: string, error: unknown): void => { - if ( - !isPtyWriteUnavailableError(error) || - mainWindow.isDestroyed() || - (typeof mainWindow.webContents.isDestroyed === 'function' && - mainWindow.webContents.isDestroyed()) - ) { + if (!isPtyWriteUnavailableError(error) || isRendererGone(mainWindow)) { return } - mainWindow.webContents.send('pty:writeUnavailable', { id }) + sendToRenderer(mainWindow, 'pty:writeUnavailable', { id }) } const writePtyProviderInputWithinLimit = ( @@ -7331,12 +7327,16 @@ export function registerPtyHandlers( (value as { cols: number }).cols > 0 && (value as { rows: number }).rows > 0 + // Why null-tolerant: with no renderer there is no sender that can legitimately match, + // so every write is rejected. These handlers cannot fire headless anyway — ipcMain has + // nothing to deliver from — but failing closed is the right answer if that ever changes. const isPtyWriteEventFromMainWindow = ( event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents + mainWebContents: WebContents | null ): boolean => + mainWebContents !== null && event.sender === mainWebContents && - !mainWindow.isDestroyed() && + !isRendererGone(mainWindow) && !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) const writePtyInput = (args: PtyWritePayload): boolean | Promise => { @@ -7388,8 +7388,11 @@ export function registerPtyHandlers( const hostViewportClaimTails = new Map>() - ipcMain.on('pty:write', (event, args: unknown) => { - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + ipc.on('pty:write', (event, args: unknown) => { + if ( + !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || + !isPtyWritePayload(args) + ) { return } const claimTail = hostViewportClaimTails.get(args.id) @@ -7399,8 +7402,11 @@ export function registerPtyHandlers( } writePtyInput(args) }) - ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + ipc.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { + if ( + !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || + !isPtyWritePayload(args) + ) { return false } const claimTail = hostViewportClaimTails.get(args.id) @@ -7409,10 +7415,10 @@ export function registerPtyHandlers( : writePtyInputAccepted(args) }) - ipcMain.removeAllListeners('pty:claimViewport') - ipcMain.on('pty:claimViewport', (event, args: unknown) => { + ipc.removeAllListeners('pty:claimViewport') + ipc.on('pty:claimViewport', (event, args: unknown) => { if ( - !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || + !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || !runtime || !isPtyViewportClaimPayload(args) ) { @@ -7436,9 +7442,9 @@ export function registerPtyHandlers( }) }) - // Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. - ipcMain.removeAllListeners('pty:resize') - ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + // Why: resize is fire-and-forget — ipc.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. + ipc.removeAllListeners('pty:resize') + ipc.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. if (runtime?.isResizeSuppressed()) { return @@ -7478,13 +7484,13 @@ export function registerPtyHandlers( }) // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. - ipcMain.removeAllListeners('pty:reportGeometry') - ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { + ipc.removeAllListeners('pty:reportGeometry') + ipc.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { runtime?.recordRendererGeometry(args.id, args.cols, args.rows) }) // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. - ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => { + ipc.on('pty:ackColdRestore', (_event, args: { id: string }) => { const provider = tryGetProviderForPty(args.id) if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { provider.ackColdRestore(args.id) @@ -7492,7 +7498,7 @@ export function registerPtyHandlers( }) // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. - ipcMain.on( + ipc.on( 'pty:ackData', (_event, args: { id: string; charCount?: number; processedChars?: number }) => { lastAckReceivedAtMs = Date.now() @@ -7512,7 +7518,7 @@ export function registerPtyHandlers( } ) - ipcMain.on( + ipc.on( 'pty:deliveryResyncResponse', (_event, args: { requestId: number; processedCharsByPty: Record }) => { if ( @@ -7540,7 +7546,7 @@ export function registerPtyHandlers( ) // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. - ipcMain.handle( + ipc.handle( 'pty:reportRendererDeliveryState', (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. @@ -7583,10 +7589,10 @@ export function registerPtyHandlers( ) // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. - ipcMain.removeAllListeners('pty:rendererDispatcherReady') - ipcMain.on('pty:rendererDispatcherReady', (event) => { + ipc.removeAllListeners('pty:rendererDispatcherReady') + ipc.on('pty:rendererDispatcherReady', (event) => { // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents)) { + if (!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow))) { return } // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. @@ -7600,8 +7606,8 @@ export function registerPtyHandlers( schedulePendingDataFlush(0) }) - ipcMain.removeAllListeners('pty:setActiveRendererPty') - ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { + ipc.removeAllListeners('pty:setActiveRendererPty') + ipc.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { if (typeof args.id !== 'string' || !args.id) { return } @@ -7617,8 +7623,8 @@ export function registerPtyHandlers( invalidatePendingPtyDrainPriority(args.id) }) - ipcMain.removeAllListeners('pty:setRendererPtyVisible') - ipcMain.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { + ipc.removeAllListeners('pty:setRendererPtyVisible') + ipc.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { if (typeof args.id !== 'string' || !args.id) { return } @@ -7633,8 +7639,8 @@ export function registerPtyHandlers( syncPtyBackgroundedDelivery(args.id, 'visibility-report') }) - ipcMain.removeAllListeners('pty:setHiddenRendererPty') - ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + ipc.removeAllListeners('pty:setHiddenRendererPty') + ipc.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { if (typeof args.id !== 'string' || !args.id) { return } @@ -7678,8 +7684,8 @@ export function registerPtyHandlers( } }) - ipcMain.removeAllListeners('pty:terminalViewAttributes') - ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + ipc.removeAllListeners('pty:terminalViewAttributes') + ipc.on('pty:terminalViewAttributes', (_event, args: unknown) => { // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. const attributes = validateTerminalViewAttributes(args) if (attributes) { @@ -7687,8 +7693,8 @@ export function registerPtyHandlers( } }) - ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') - ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + ipc.removeAllListeners('pty:setPtyDeliveryInterest') + ipc.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { if (typeof args.id !== 'string' || !args.id) { return } @@ -7701,15 +7707,15 @@ export function registerPtyHandlers( } }) - ipcMain.removeAllListeners('pty:signal') - ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { + ipc.removeAllListeners('pty:signal') + ipc.on('pty:signal', (_event, args: { id: string; signal: string }) => { tryGetProviderForPty(args.id) ?.sendSignal(args.id, args.signal) .catch(() => {}) }) - ipcMain.removeAllListeners('pty:clearBuffer') - ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { + ipc.removeAllListeners('pty:clearBuffer') + ipc.on('pty:clearBuffer', (_event, args: { id: string }) => { // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. tryGetProviderForPty(args.id) ?.clearBuffer(args.id) @@ -7717,7 +7723,7 @@ export function registerPtyHandlers( runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) }) - ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { + ipc.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. throw new Error('Invalid PTY provider id') @@ -7767,7 +7773,7 @@ export function registerPtyHandlers( } }) - ipcMain.handle('pty:listSessions', async (): Promise => { + ipc.handle('pty:listSessions', async (): Promise => { const deduped = new Map() const admission = new PtyProcessListAdmission() await visitPtyProcessListingsInBatches( @@ -7799,7 +7805,7 @@ export function registerPtyHandlers( return Array.from(deduped.values()) }) - ipcMain.handle( + ipc.handle( 'pty:getAuthoritativeBufferSnapshotCapabilities', async (_event, args: { ids?: unknown }) => { const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] @@ -7847,7 +7853,7 @@ export function registerPtyHandlers( } ) - ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { + ipc.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { // Why: same routing hazard pty:kill guards against — ptyOwnership never holds // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup @@ -7871,17 +7877,14 @@ export function registerPtyHandlers( } }) - ipcMain.handle( - 'pty:hasChildProcesses', - async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return false - } - return getProviderForPty(args.id).hasChildProcesses(args.id) + ipc.handle('pty:hasChildProcesses', async (_event, args: { id: string }): Promise => { + if (!hasPtyProviderForInspection(args.id)) { + return false } - ) + return getProviderForPty(args.id).hasChildProcesses(args.id) + }) - ipcMain.handle( + ipc.handle( 'pty:getForegroundProcess', async (_event, args: { id: string }): Promise => { if (!hasPtyProviderForInspection(args.id)) { @@ -7891,11 +7894,11 @@ export function registerPtyHandlers( } ) - ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => + ipc.handle('pty:inspectProcess', async (_event, args: { id: string }) => inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) ) - ipcMain.handle( + ipc.handle( 'pty:confirmForegroundProcess', async (_event, args: { id: string }): Promise => { if (!hasPtyProviderForInspection(args.id)) { @@ -7908,7 +7911,7 @@ export function registerPtyHandlers( ) // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. - ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { + ipc.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { try { return await getProviderForPty(args.id).getCwd(args.id) } catch { @@ -7917,7 +7920,7 @@ export function registerPtyHandlers( }) // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. - ipcMain.handle( + ipc.handle( 'pty:getSize', async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { const provider = tryGetProviderForPty(args.id) @@ -7936,7 +7939,7 @@ export function registerPtyHandlers( ) // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. - ipcMain.handle( + ipc.handle( 'pty:declarePendingPaneSerializer', async (event, args: { paneKey?: unknown }): Promise => { if (!isValidPaneKey(args.paneKey)) { @@ -7946,7 +7949,7 @@ export function registerPtyHandlers( } ) - ipcMain.handle( + ipc.handle( 'pty:settlePaneSerializer', async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { @@ -7962,7 +7965,7 @@ export function registerPtyHandlers( } ) - ipcMain.handle( + ipc.handle( 'pty:clearPendingPaneSerializer', async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { @@ -7973,7 +7976,7 @@ export function registerPtyHandlers( } ) - ipcMain.handle( + ipc.handle( 'pty:reportRendererSerializerReady', async (_event, args: { ptyId?: unknown }): Promise => { if ( @@ -8001,17 +8004,13 @@ export function registerHeadlessPtyRuntime( onPtyExit?: (id: string, exitSequence: number) => void } ): void { - // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. - const headlessWindow = { - isDestroyed: () => true, - webContents: { - send: () => {}, - on: () => {}, - removeListener: () => {} - } - } as unknown as BrowserWindow + // Why null and not a stub window: headless `orca serve` has no renderer but needs the + // same PTY handlers so remote clients can drive terminals. This used to pass a fake + // BrowserWindow whose isDestroyed() returned true — a window-shaped object that lied + // about being a window. `null` says the same thing honestly, and keeps `electron` out + // of this path entirely. registerPtyHandlers( - headlessWindow, + null, runtime, getSelectedCodexHomePath, getSettings, diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 0caa144ba88..6d200522535 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index bd581042881..aed03724200 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index d612c6bde1b..b6f851590ba 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -30,6 +30,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-nested-import.test.ts b/src/main/ipc/repos-nested-import.test.ts index f5731059b1b..010be624a91 100644 --- a/src/main/ipc/repos-nested-import.test.ts +++ b/src/main/ipc/repos-nested-import.test.ts @@ -29,6 +29,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-nested-scan.test.ts b/src/main/ipc/repos-nested-scan.test.ts index 22d4c24be29..26650246a06 100644 --- a/src/main/ipc/repos-nested-scan.test.ts +++ b/src/main/ipc/repos-nested-scan.test.ts @@ -29,6 +29,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index 296dcb3c7a6..f96ff9c4da6 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-remote-git-username.test.ts b/src/main/ipc/repos-remote-git-username.test.ts index d1c0cc27403..d3f22254fd7 100644 --- a/src/main/ipc/repos-remote-git-username.test.ts +++ b/src/main/ipc/repos-remote-git-username.test.ts @@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 7145db75db2..e5660eb8d27 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -27,6 +27,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) ) vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) +vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/ssh-worktree-create-root-registration.ts b/src/main/ipc/ssh-worktree-create-root-registration.ts index e364239cdbe..705489992b7 100644 --- a/src/main/ipc/ssh-worktree-create-root-registration.ts +++ b/src/main/ipc/ssh-worktree-create-root-registration.ts @@ -1,4 +1,4 @@ -import { getActiveMultiplexer } from './ssh' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' const SSH_CONNECTION_UNAVAILABLE_MESSAGE = 'SSH connection is not available. Please reconnect and try again.' diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 0c504c5f0a0..e31b852a751 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -9,7 +9,6 @@ import { } from '../ssh/ssh-config-host-picker' import type { SshConnection, SshConnectionCallbacks } from '../ssh/ssh-connection' import { SshConnectionManager } from '../ssh/ssh-connection-manager' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' import type { SshAiVaultRelayListParams, @@ -30,6 +29,29 @@ import type { import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' +import { + getSshTargetRegistryStore, + setSshActiveMultiplexerResolver, + setSshTargetRegistryHandlers, + setSshTargetRegistryStore +} from '../ssh/ssh-target-registry' + +// Why at module scope: this resolver is pure state lookup with no handler lifecycle, so +// installing it on import keeps it correct even before registerSshHandlers runs. +setSshActiveMultiplexerResolver( + (connectionId) => activeSessions.get(connectionId)?.getMux() ?? undefined +) + +// Why re-exported: the registry moved to ../ssh/ssh-target-registry so the runtime can +// read it without pulling ipcMain in, but many existing importers reference these from +// here. Re-exporting keeps them working without a repo-wide rename. +export { + connectRegisteredSshTarget, + getActiveMultiplexer, + getRegisteredSshState, + listRegisteredRemovedSshTargetLabels, + listRegisteredSshTargets +} from '../ssh/ssh-target-registry' import { isAuthError } from '../ssh/ssh-connection-utils' import { createCancelledConnectAttemptError } from '../ssh/ssh-connect-attempt-cancellation' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' @@ -62,11 +84,8 @@ import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null let portForwardManager: SshPortForwardManager | null = null -let registeredConnectSshTarget: ((targetId: string) => Promise) | null = null -let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null let persistedStore: Store | null = null let advertisedUrlWatcherUnsubscribe: (() => void) | null = null let powerMonitorUnsubscribe: (() => void) | null = null @@ -103,27 +122,6 @@ function getCurrentMainWindow(): BrowserWindow | null { return currentGetMainWindow() } -export async function connectRegisteredSshTarget(targetId: string): Promise { - if (!registeredConnectSshTarget) { - throw new Error('ssh_handlers_not_registered') - } - return registeredConnectSshTarget(targetId) -} - -export function getRegisteredSshState(targetId: string): SshConnectionState | undefined { - return registeredGetSshState?.(targetId) -} - -/** Public targets for runtime RPC clients — same list the desktop renderer gets. */ -export function listRegisteredSshTargets(): SshTarget[] { - return sshStore?.listTargets() ?? [] -} - -/** Removed-target id → last known label, for ghost-host display on paired clients. */ -export function listRegisteredRemovedSshTargetLabels(): Record { - return sshStore?.listRemovedTargetLabels() ?? {} -} - export async function disconnectRegisteredSshTarget(targetId: string): Promise { invalidateConnectAttempt(targetId) await runTargetLifecycle(targetId, () => @@ -132,10 +130,10 @@ export async function disconnectRegisteredSshTarget(targetId: string): Promise { - if (!sshStore) { + const store = getSshTargetRegistryStore() + if (!store) { return } - const store = sshStore invalidateConnectAttempt(targetId) await runTargetLifecycle(targetId, async () => { try { @@ -528,7 +526,9 @@ function persistPortForwards(targetId: string): void { remotePort: f.remotePort, label: f.label })) - sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined }) + getSshTargetRegistryStore()!.updateTarget(targetId, { + portForwards: saved.length > 0 ? saved : undefined + }) } // Why: keep forwards that failed to restore in the persisted list so they retry on next reconnect instead of being silently dropped. @@ -536,7 +536,7 @@ function persistPortForwardsWithUnrestored(targetId: string): void { const active = portForwardManager!.listForwards(targetId) const activeKeys = new Set(active.map((f) => `${f.localPort}:${f.remoteHost}:${f.remotePort}`)) - const existing = sshStore!.getTarget(targetId)?.portForwards ?? [] + const existing = getSshTargetRegistryStore()!.getTarget(targetId)?.portForwards ?? [] const unrestored = existing.filter( (pf) => !activeKeys.has(`${pf.localPort}:${pf.remoteHost}:${pf.remotePort}`) ) @@ -550,14 +550,16 @@ function persistPortForwardsWithUnrestored(targetId: string): void { })), ...unrestored ] - sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined }) + getSshTargetRegistryStore()!.updateTarget(targetId, { + portForwards: saved.length > 0 ? saved : undefined + }) } async function restorePortForwards( targetId: string, getMainWindow: () => BrowserWindow | null ): Promise { - const target = sshStore!.getTarget(targetId) + const target = getSshTargetRegistryStore()!.getTarget(targetId) if (!target?.portForwards?.length) { return } @@ -765,7 +767,7 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { } // Why: allow reconnect from both 'ready' and 'reconnecting'; without the latter, a failed relay deploy would permanently brick the session. if (shouldReconnectRelay) { - const target = sshStore?.getTarget(targetId) + const target = getSshTargetRegistryStore()?.getTarget(targetId) const conn = connectionManager?.getConnection(targetId) if (conn) { void session.reconnect(conn, relayGracePeriodForTarget(target)) @@ -804,7 +806,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { if (!c) { return } - const t = sshStore?.getTarget(tid) + const t = getSshTargetRegistryStore()?.getTarget(tid) // Why: bounded exponential backoff — without it, a remote bug that closes every fresh --connect channel becomes an infinite relay-deploy loop. const state = relayLostBackoff.get(tid) ?? { @@ -963,7 +965,7 @@ export function registerSshHandlers( currentGetMainWindow = getMainWindow currentRuntime = runtime - sshStore = new SshConnectionStore(store) + setSshTargetRegistryStore(new SshConnectionStore(store)) persistedStore = store registerAdvertisedUrlRefresh(getCurrentMainWindow) @@ -997,11 +999,12 @@ export function registerSshHandlers( // Why: add/import can re-adopt workspaces orphaned on a removed target id (see ssh-target-readoption); the renderer must refresh its repo list to surface them. function takeRepoReadoptions(): SshRepoReadoption[] { - if (!sshStore || sshStore.lastRepoReadoptions.length === 0) { + const store = getSshTargetRegistryStore() + if (!store || store.lastRepoReadoptions.length === 0) { return [] } - const repoReadoptions = sshStore.lastRepoReadoptions - sshStore.lastRepoReadoptions = [] + const repoReadoptions = store.lastRepoReadoptions + store.lastRepoReadoptions = [] for (const targetId of new Set( repoReadoptions.flatMap(({ oldTargetId, newTargetId }) => [oldTargetId, newTargetId]) )) { @@ -1015,15 +1018,15 @@ export function registerSshHandlers( } ipcMain.handle('ssh:listTargets', () => { - return sshStore!.listTargets() + return getSshTargetRegistryStore()!.listTargets() }) ipcMain.handle('ssh:listRemovedTargetLabels', () => { - return sshStore!.listRemovedTargetLabels() + return getSshTargetRegistryStore()!.listRemovedTargetLabels() }) ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit }) => { - const target = sshStore!.addTarget(args.target) + const target = getSshTargetRegistryStore()!.addTarget(args.target) // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. const repoReadoptions = takeRepoReadoptions() return { target, repoReadoptions } @@ -1032,7 +1035,7 @@ export function registerSshHandlers( ipcMain.handle( 'ssh:updateTarget', (_event, args: { id: string; updates: Partial> }) => { - return sshStore!.updateTarget(args.id, args.updates) + return getSshTargetRegistryStore()!.updateTarget(args.id, args.updates) } ) @@ -1041,7 +1044,7 @@ export function registerSshHandlers( }) ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { - const targets = sshStore!.importFromSshConfig(args) + const targets = getSshTargetRegistryStore()!.importFromSshConfig(args) const repoReadoptions = takeRepoReadoptions() return { targets, repoReadoptions } }) @@ -1050,9 +1053,9 @@ export function registerSshHandlers( // mutate the target store (bulk sync stays on Settings → Import). ipcMain.handle('ssh:listConfigHosts', (_event, args?: SshConfigHostListArgs) => { return listUserSshConfigHostSummaries( - sshStore!.listTargets(), + getSshTargetRegistryStore()!.listTargets(), args?.query, - sshStore!.listSuppressedSshConfigAliases(), + getSshTargetRegistryStore()!.listSuppressedSshConfigAliases(), { refresh: args?.refresh === true } ) }) @@ -1115,8 +1118,10 @@ export function registerSshHandlers( } } - registeredConnectSshTarget = connectTarget - registeredGetSshState = (targetId: string) => getPublicSshState(targetId) + setSshTargetRegistryHandlers({ + connect: connectTarget, + getState: (targetId: string) => getPublicSshState(targetId) + }) ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => { return connectTarget(args.targetId) @@ -1126,7 +1131,7 @@ export function registerSshHandlers( targetId: string, replacePendingTransport = false ): Promise { - const target = sshStore!.getTarget(targetId) + const target = getSshTargetRegistryStore()!.getTarget(targetId) if (!target) { throw new Error(`SSH target "${targetId}" not found`) } @@ -1284,7 +1289,9 @@ export function registerSshHandlers( // Why: persist whether this connect needed a credential so startup can partition targets into eager vs deferred without re-probing keys. const requiredPassphrase = credentialRequestedForTarget.has(targetId) credentialRequestedForTarget.delete(targetId) - sshStore!.updateTarget(targetId, { lastRequiredPassphrase: requiredPassphrase }) + getSshTargetRegistryStore()!.updateTarget(targetId, { + lastRequiredPassphrase: requiredPassphrase + }) return getPublicSshState(targetId)! } @@ -1416,7 +1423,7 @@ export function registerSshHandlers( return existingReset } - const target = sshStore!.getTarget(args.targetId) + const target = getSshTargetRegistryStore()!.getTarget(args.targetId) if (!target) { throw new Error(`SSH target "${args.targetId}" not found`) } @@ -1441,7 +1448,7 @@ export function registerSshHandlers( // Why: auto-connect callers need to know whether connecting will prompt; true when the last connect required a credential and no live conn has it cached. ipcMain.handle('ssh:needsPassphrasePrompt', (_event, args: { targetId: string }) => { - const target = sshStore!.getTarget(args.targetId) + const target = getSshTargetRegistryStore()!.getTarget(args.targetId) if (!target?.lastRequiredPassphrase) { return false } @@ -1450,7 +1457,7 @@ export function registerSshHandlers( }) ipcMain.handle('ssh:testConnection', async (_event, args: { targetId: string }) => { - const target = sshStore!.getTarget(args.targetId) + const target = getSshTargetRegistryStore()!.getTarget(args.targetId) if (!target) { throw new Error(`SSH target "${args.targetId}" not found`) } @@ -1601,7 +1608,7 @@ export function registerSshHandlers( return enrichDetected(args.targetId, ports) }) - return { connectionManager, sshStore } + return { connectionManager, sshStore: getSshTargetRegistryStore() as SshConnectionStore } } export function getSshConnectionManager(): SshConnectionManager | null { @@ -1788,18 +1795,13 @@ export async function resetSshHandlerStateForTests(): Promise { portForwardManager?.dispose() connectionManager = null portForwardManager = null - sshStore = null + setSshTargetRegistryStore(null) persistedStore = null - registeredConnectSshTarget = null - registeredGetSshState = null + setSshTargetRegistryHandlers({ connect: null, getState: null }) currentGetMainWindow = () => null currentRuntime = undefined } export function getSshConnectionStore(): SshConnectionStore | null { - return sshStore -} - -export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined { - return activeSessions.get(connectionId)?.getMux() ?? undefined + return getSshTargetRegistryStore() } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 72ef373758e..1d63cf97deb 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -35,6 +35,7 @@ import { } from '../git/repo' import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' +import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { getHostedReviewForBranch } from '../source-control/hosted-review' @@ -181,7 +182,8 @@ type RemoteLocalBaseRefRefreshability = } | { refreshable: false - result: LocalBaseRefRefreshResult + // undefined = nothing to refresh (no local branch yet), so the caller reports no status at all. + result: LocalBaseRefRefreshResult | undefined } function appendWorktreeCreateWarning(current: string | undefined, next: string): string { @@ -698,8 +700,7 @@ async function hasRemoteWorktreeBaseRef( repoPath: string, baseRef: string ): Promise { - const refExists = (qualifiedRef: string) => - hasRemoteTrackingRefSsh(provider, repoPath, qualifiedRef) + const refExists = (qualifiedRef: string) => hasCommitRefSsh(provider, repoPath, qualifiedRef) const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) if (resolvedBaseRef !== baseRef) { return true @@ -710,8 +711,8 @@ async function hasRemoteWorktreeBaseRef( return hasRemoteCommitObject(provider, repoPath, baseRef) } -// Why: hasRemoteCommitObject resolves only SHAs, not symbolic remote-tracking refs; detect those directly for the fetch-failed local fallback. -async function hasRemoteTrackingRefSsh( +// Why: hasRemoteCommitObject resolves only SHAs, not symbolic refs; resolve any qualified ref (remote-tracking or local head) directly. +async function hasCommitRefSsh( provider: SshGitProvider, repoPath: string, ref: string @@ -1262,7 +1263,7 @@ async function resolveRemoteWorktreeCreateBasePlan( baseBranchCandidate ) if (remoteTrackingBase) { - if (await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref)) { + if (await hasCommitRefSsh(provider, repo.path, remoteTrackingBase.ref)) { return true } return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate) @@ -1313,7 +1314,7 @@ export async function prefetchRemoteWorktreeCreateBase( } if (basePlan.remoteTrackingBase) { if ( - (await hasRemoteTrackingRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) || + (await hasCommitRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) || !(await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) ) { await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase) @@ -1333,7 +1334,7 @@ async function refreshLocalBaseRefForRemoteWorktreeCreate( provider: SshGitProvider, repoPath: string, remoteTrackingBase: RemoteTrackingBase -): Promise { +): Promise { const evaluation = await evaluateRemoteLocalBaseRefRefreshability( provider, repoPath, @@ -1393,6 +1394,16 @@ async function evaluateRemoteLocalBaseRefRefreshability( } } } catch { + // Why (#15331): the probes above also fail when refs/heads/ is simply absent; the relay's + // `worktree add -b` is about to create it, so there is nothing stale to warn about. Only a proven + // absence suppresses: a dropped relay connection is not evidence the branch is missing. + const presence = await probeWorktreeBaseRefPresence( + (args) => provider.exec(args, repoPath), + fullRef + ) + if (presence === 'absent') { + return { refreshable: false, result: undefined } + } return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } @@ -1550,7 +1561,7 @@ export async function createRemoteWorktree( let baseFallback: WorktreeCreateBaseFallback | undefined if (remoteTrackingBase) { - const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( + const hasRemoteTrackingBaseRef = await hasCommitRefSsh( provider, repo.path, remoteTrackingBase.ref @@ -1700,7 +1711,7 @@ export async function createRemoteWorktree( await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase) } catch { // Why: a refresh failure shouldn't block create if a usable (stale) local base ref exists; probe after registerRoot and hard-fail only when none does. - if (!(await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref))) { + if (!(await hasCommitRefSsh(provider, repo.path, remoteTrackingBase.ref))) { throw new Error( `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` ) diff --git a/src/main/ipc/worktree-watcher-removal-binding.test.ts b/src/main/ipc/worktree-watcher-removal-binding.test.ts new file mode 100644 index 00000000000..b0b99ed1f79 --- /dev/null +++ b/src/main/ipc/worktree-watcher-removal-binding.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { getWorktreeWatcherRemoval, setWorktreeWatcherRemoval } from './worktree-watcher-removal' + +/** + * Why: the port's default is inert by design — a host with no renderer has no watchers + * to close. That is correct for orcad and silently wrong for the desktop, where an + * uninstalled port means worktree removal stops releasing the directory and Windows + * fails the delete on a locked file. The default is indistinguishable from a working + * install at the call site, so it gets asserted here. + */ +describe('WorktreeWatcherRemoval port', () => { + const METHODS = [ + 'closeLocal', + 'restoreLocal', + 'forgetLocal', + 'closeRemote', + 'restoreRemote', + 'forgetRemote' + ] as const + + it('defaults to inert so a renderer-less host is honest, not broken', async () => { + setWorktreeWatcherRemoval(null) + const inert = getWorktreeWatcherRemoval() + for (const method of METHODS) { + await expect( + Promise.resolve(inert[method]('repo::/tmp/w', '/tmp/w' as never)) + ).resolves.not.toThrow() + } + }) + + it('routes every method to the installed binding', async () => { + const calls: string[] = [] + setWorktreeWatcherRemoval({ + closeLocal: async () => void calls.push('closeLocal'), + restoreLocal: async () => void calls.push('restoreLocal'), + forgetLocal: () => void calls.push('forgetLocal'), + closeRemote: async () => void calls.push('closeRemote'), + restoreRemote: async () => void calls.push('restoreRemote'), + forgetRemote: () => void calls.push('forgetRemote') + }) + for (const method of METHODS) { + await getWorktreeWatcherRemoval()[method]('conn', '/tmp/w' as never) + } + expect(calls).toEqual([...METHODS]) + setWorktreeWatcherRemoval(null) + }) + + it('exposes a desktop binding that delegates rather than stubbing', async () => { + // Why import lazily: filesystem-watcher pulls electron, so it must not load in the + // inert-default case above. + const { desktopWorktreeWatcherRemoval } = await import('./filesystem-watcher') + for (const method of METHODS) { + expect(typeof desktopWorktreeWatcherRemoval[method], method).toBe('function') + // An inert stub is an empty arrow; a real delegation is not. + expect(desktopWorktreeWatcherRemoval[method].toString(), method).not.toMatch( + /^\s*(async )?\(\s*\)\s*=>\s*\{?\s*\}?\s*$/ + ) + } + }) +}) diff --git a/src/main/ipc/worktree-watcher-removal.ts b/src/main/ipc/worktree-watcher-removal.ts new file mode 100644 index 00000000000..b5de15592cd --- /dev/null +++ b/src/main/ipc/worktree-watcher-removal.ts @@ -0,0 +1,42 @@ +import type { WatcherRemovalDeadline } from './watcher-removal-drain' + +/** + * Removal-time coordination for the renderer-facing filesystem watchers. + * + * Worktree removal has to close any watcher holding the directory open, then either + * restore it (removal failed) or drop the snapshot (removal succeeded). The desktop + * implementation lives in `./filesystem-watcher`, where that bookkeeping is inseparable + * from the `WebContents` map it suspends and replays. + * + * Why the default is inert rather than a throw: every entry in those maps arrives through + * an `ipcMain` handler carrying a renderer `sender`. A host with no renderer never + * installs one, so there is nothing to close, restore, or forget — the no-op is what the + * desktop code itself would do against empty maps, not a silent stub hiding lost work. + */ +export type WorktreeWatcherRemoval = { + closeLocal(worktreePath: string, deadline?: WatcherRemovalDeadline): Promise + restoreLocal(worktreePath: string): Promise + forgetLocal(worktreePath: string): void + closeRemote(connectionId: string, worktreePath: string): Promise + restoreRemote(connectionId: string, worktreePath: string): Promise + forgetRemote(connectionId: string, worktreePath: string): void +} + +const inert: WorktreeWatcherRemoval = { + closeLocal: async () => {}, + restoreLocal: async () => {}, + forgetLocal: () => {}, + closeRemote: async () => {}, + restoreRemote: async () => {}, + forgetRemote: () => {} +} + +let current: WorktreeWatcherRemoval = inert + +export function setWorktreeWatcherRemoval(next: WorktreeWatcherRemoval | null): void { + current = next ?? inert +} + +export function getWorktreeWatcherRemoval(): WorktreeWatcherRemoval { + return current +} diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 7946466ba7e..798cc443442 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -47,6 +47,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index 721e0a8866d..ea398ddaf63 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-detected-scan-cache.test.ts b/src/main/ipc/worktrees-detected-scan-cache.test.ts index c0e9fba684e..6fb798fad3f 100644 --- a/src/main/ipc/worktrees-detected-scan-cache.test.ts +++ b/src/main/ipc/worktrees-detected-scan-cache.test.ts @@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts index a7d2a9964d8..b1fea8d41a5 100644 --- a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts +++ b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts @@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-existing-branch-checkout.test.ts b/src/main/ipc/worktrees-existing-branch-checkout.test.ts index b78018c4dbf..e455e176cf5 100644 --- a/src/main/ipc/worktrees-existing-branch-checkout.test.ts +++ b/src/main/ipc/worktrees-existing-branch-checkout.test.ts @@ -44,6 +44,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index 9971885d5aa..b1616f6eecc 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -52,6 +52,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index 00e58e49e31..ae6e89d51e1 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -38,6 +38,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index 156827e86b7..cb12b0095f9 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index 5d3fa00a9dd..45bea853e67 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts index 20f880cdbde..130816d901c 100644 --- a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts @@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index eb463ba6a62..570aa38c09b 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -52,6 +52,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 3a844f67179..954a994940d 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts index 45f1152c61f..bd3501abbe7 100644 --- a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts +++ b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts @@ -42,6 +42,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index 8472309261d..cc10e1929a1 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -61,6 +61,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-archive-hooks.test.ts b/src/main/ipc/worktrees-remove-archive-hooks.test.ts index fa80b5a0d51..cb1b39c8d6e 100644 --- a/src/main/ipc/worktrees-remove-archive-hooks.test.ts +++ b/src/main/ipc/worktrees-remove-archive-hooks.test.ts @@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts index 48af60bd6ca..289609a2b86 100644 --- a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts +++ b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts @@ -42,6 +42,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-preflight.test.ts b/src/main/ipc/worktrees-remove-preflight.test.ts index f113641a5ef..335eeda6e90 100644 --- a/src/main/ipc/worktrees-remove-preflight.test.ts +++ b/src/main/ipc/worktrees-remove-preflight.test.ts @@ -50,6 +50,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index e94b2ccacf4..6eb250af69a 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -44,6 +44,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts b/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts index 0b1f93914e4..599acb1bcc7 100644 --- a/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts @@ -40,6 +40,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts index 5d080170990..73605e2332e 100644 --- a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts +++ b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts @@ -39,6 +39,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts b/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts index 84a8f5028c4..a14d7572f03 100644 --- a/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts +++ b/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts @@ -35,6 +35,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts index 68651186f4d..632aba29d0c 100644 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts @@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts index 0c4a00a4746..0b76025a979 100644 --- a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts +++ b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts @@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -474,4 +477,118 @@ describe('registerWorktreeHandlers', () => { }) expect(result.localBaseRefUpdateSuggestion).toBeUndefined() }) + // #15331: the pre-create merge-base probe fails when refs/heads/ does not exist yet. + const buildMissingLocalBaseSshCase = (presence: 'absent' | 'present' | 'probe-failed') => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'for-each-ref' && args.at(-1) === 'refs/heads/main') { + if (presence === 'probe-failed') { + throw new Error('ssh: connection closed by remote host') + } + return { stdout: presence === 'present' ? 'refs/heads/main\n' : '', stderr: '' } + } + if (args[0] === 'rev-parse') { + const ref = args.at(-1) ?? '' + if (ref.startsWith('refs/heads/main')) { + return { stdout: presence === 'present' ? 'local-main\n' : '', stderr: '' } + } + // Other refs/heads probes are the new-branch conflict check; it must stay unresolvable. + return { stdout: ref.startsWith('refs/heads/') ? '' : 'remote-main\n', stderr: '' } + } + if (args[0] === 'merge-base') { + throw new Error('fatal: Not a valid object name refs/heads/main') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), + refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) + } + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: true, + workspaceDir: '/workspace' + }) + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + return provider + } + + it('does not report an SSH local base refresh when the local base branch does not exist', async () => { + const provider = buildMissingLocalBaseSshCase('absent') + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(provider.exec).toHaveBeenCalledWith( + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'], + '/remote/repo' + ) + expect(result.localBaseRefRefresh).toBeUndefined() + expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + }) + + it('keeps the SSH not-fast-forward status when the local base branch exists and diverged', async () => { + const provider = buildMissingLocalBaseSshCase('present') + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + }) + + // Losing the relay mid-probe is not evidence the branch is missing. + it('keeps the SSH not-fast-forward status when the local base ref probe fails', async () => { + const provider = buildMissingLocalBaseSshCase('probe-failed') + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index 96a827a3b3c..109dca653d3 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index 25f6df7329f..c801d0498f3 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 1f016f89b22..6a398e9b02d 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -45,6 +45,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-setup-launch.test.ts b/src/main/ipc/worktrees-ssh-setup-launch.test.ts index 8d2e39bbb0e..c43d4d7cb7c 100644 --- a/src/main/ipc/worktrees-ssh-setup-launch.test.ts +++ b/src/main/ipc/worktrees-ssh-setup-launch.test.ts @@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-test-module-mocks.ts b/src/main/ipc/worktrees-test-module-mocks.ts index bcf19650877..50bdc6a21d7 100644 --- a/src/main/ipc/worktrees-test-module-mocks.ts +++ b/src/main/ipc/worktrees-test-module-mocks.ts @@ -183,6 +183,13 @@ export const sshModuleMock = () => ({ getActiveMultiplexer: getActiveMultiplexerMock }) +// Why a second builder: getActiveMultiplexer moved to ../ssh/ssh-target-registry so the +// runtime could reach it without ipcMain. Production imports it from there now, so a +// vi.mock('./ssh') factory alone is inert. +export const sshTargetRegistryModuleMock = () => ({ + getActiveMultiplexer: getActiveMultiplexerMock +}) + export const hooksModuleMock = () => ({ getEffectiveHooks: getEffectiveHooksMock, loadHooks: loadHooksMock, diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index e47527bc6e8..23758ae5d77 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -55,6 +55,9 @@ vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/jira/authenticated-request.ts b/src/main/jira/authenticated-request.ts index 6da47706476..feb54167c9c 100644 --- a/src/main/jira/authenticated-request.ts +++ b/src/main/jira/authenticated-request.ts @@ -1,5 +1,5 @@ -import { net, session } from 'electron' import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' +import { getMainHttpClient } from '../network/http-client' import { withSpan } from '../observability/tracer' import type { JiraAuthType, JiraSite } from '../../shared/jira-types' @@ -58,8 +58,10 @@ async function jiraFetch(url: string, init: RequestInit): Promise { 'jira.request', async (span) => { span.setAttribute('jira.siteUrl', new URL(url).origin) + const httpClient = getMainHttpClient() + const proxySession = httpClient.proxySession() await ensureElectronProxyFromEnvironment({ - proxySession: session.defaultSession, + ...(proxySession ? { proxySession } : {}), probeUrl: url }).catch((error) => { span.addEvent('jira.proxySetupFailed', { @@ -68,9 +70,10 @@ async function jiraFetch(url: string, init: RequestInit): Promise { }) }) try { - // Why: Electron's network stack follows Chromium proxy/session state, - // avoiding undici's stale keep-alive sockets after VPN path changes. - return await net.fetch(url, init) + // Why the port: on the desktop this is Electron's net.fetch, which follows + // Chromium proxy/session state and avoids undici's stale keep-alive sockets + // after VPN path changes. A host without Chromium gets Node's fetch instead. + return await httpClient.fetch(url, init) } catch (error) { span.setAttribute( 'jira.transportErrorName', diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index faee0d2eaa4..7f57ad80b01 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -93,11 +93,6 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { vi.resetModules() vi.doMock('electron', () => ({ net: { fetch: netFetchMock }, - safeStorage: { - isEncryptionAvailable: () => options.encryptionAvailable ?? false, - encryptString: (value: string) => Buffer.from(value), - decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8')) - }, session: { defaultSession: { closeAllConnections: closeAllConnectionsMock, @@ -106,6 +101,21 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { } } })) + // Why here and not in beforeEach: vi.resetModules() above gives the http-client module + // a fresh singleton, so the port must be installed on that instance. The electron net + // mock alone is inert now that Jira fetches through the port. + const { setMainHttpClient } = await import('../network/http-client') + setMainHttpClient({ + fetch: (url, init) => netFetchMock(url, init), + proxySession: () => ({ resolveProxy: resolveProxyMock, setProxy: setProxyMock }) as never + }) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => options.encryptionAvailable ?? false, + encryptString: (value) => Buffer.from(value), + decryptString: options.decryptString ?? ((value) => value.toString('utf-8')), + describeProtectionGap: () => null + }) vi.doMock('os', async () => { const actual = await vi.importActual('os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/jira/site-credential-store.ts b/src/main/jira/site-credential-store.ts index 0f8933097d3..63c241ca62c 100644 --- a/src/main/jira/site-credential-store.ts +++ b/src/main/jira/site-credential-store.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { safeStorage } from 'electron' +import { getSecretStore } from '../../shared/secret-store' import { CredentialDecryptionError, credentialFileHasContent, @@ -157,11 +157,11 @@ export function writeSiteFile(file: JiraSiteFile): void { } function writeEncryptedToken(path: string, apiToken: string): void { - if (safeStorage.isEncryptionAvailable()) { - writeFileSync(path, safeStorage.encryptString(apiToken), { mode: 0o600 }) + if (getSecretStore().isEncryptionAvailable()) { + writeFileSync(path, getSecretStore().encryptString(apiToken), { mode: 0o600 }) return } - console.warn('[jira] safeStorage encryption unavailable — storing token in plaintext') + console.warn('[jira] secret encryption unavailable — storing token in plaintext') writeFileSync(path, apiToken, { encoding: 'utf-8', mode: 0o600 }) } diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index 7f518afe6e8..1043a0997ec 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -90,13 +90,13 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) { }) }) }) - vi.doMock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => options.encryptionAvailable ?? false, - encryptString: (value: string) => Buffer.from(value), - decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8')) - } - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => options.encryptionAvailable ?? false, + encryptString: (value) => Buffer.from(value), + decryptString: options.decryptString ?? ((value) => value.toString('utf-8')), + describeProtectionGap: () => null + }) vi.doMock('os', async () => { const actual = await vi.importActual('os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/linear/linear-token-store.ts b/src/main/linear/linear-token-store.ts index 4d724c32a00..7a2bf892213 100644 --- a/src/main/linear/linear-token-store.ts +++ b/src/main/linear/linear-token-store.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { getSecretStore } from '../../shared/secret-store' import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { LEGACY_WORKSPACE_ID, @@ -33,13 +33,13 @@ import { import type { LinearWorkspace } from '../../shared/linear/workspace-types' function writeEncryptedToken(path: string, apiKey: string): void { - if (safeStorage.isEncryptionAvailable()) { - const encrypted = safeStorage.encryptString(apiKey) + if (getSecretStore().isEncryptionAvailable()) { + const encrypted = getSecretStore().encryptString(apiKey) writeFileSync(path, encrypted, { mode: 0o600 }) return } - console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext') + console.warn('[linear] secret encryption unavailable — storing token in plaintext') writeFileSync(path, apiKey, { encoding: 'utf-8', mode: 0o600 }) } diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index 38046431777..22e55825923 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import os from 'node:os' import type { MemorySnapshotStore } from './collector' +import { setAppEnvironment } from '../../shared/app-environment' type AppMetricFixture = { pid: number @@ -16,12 +17,6 @@ const { appMetricsMock, runProcessMock, execMock, listRegisteredPtysMock } = vi. listRegisteredPtysMock: vi.fn() })) -vi.mock('electron', () => ({ - app: { - getAppMetrics: appMetricsMock - } -})) - vi.mock('child_process', () => ({ exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) => execMock(cmd, opts, cb) @@ -38,8 +33,22 @@ vi.mock('./pty-registry', () => ({ listRegisteredPtys: listRegisteredPtysMock })) +function appEnvironment() { + return { + getPath: () => process.cwd(), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: appMetricsMock + } +} + async function loadCollector() { vi.resetModules() + const { setAppEnvironment: setResetAppEnvironment } = await import('../../shared/app-environment') + setResetAppEnvironment(appEnvironment()) return await import('./collector') } @@ -240,6 +249,7 @@ describe('collectSubtree', () => { describe('collectMemorySnapshot', () => { beforeEach(() => { + setAppEnvironment(appEnvironment()) vi.restoreAllMocks() appMetricsMock.mockReset() appMetricsMock.mockReturnValue([]) diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 1f000706c1e..b48a6bb75ed 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -5,7 +5,7 @@ * Memory dashboard collector. * * One snapshot covers two sources: - * - Orca's own Electron processes, via `app.getAppMetrics()`, bucketed + * - Orca's own Electron processes, via `getAppEnvironment().getAppMetrics()`, bucketed * into main / renderer / other. * - Each registered PTY's process subtree, enumerated once from a host- * wide process sweep (PowerShell CIM with a Typeperf fallback on Windows). @@ -26,7 +26,7 @@ import { getProcessOutputFields, iterateProcessOutputLines } from '../../shared/process-output-field-scanner' -import { app } from 'electron' +import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import type { AppMemory, MemorySnapshot, @@ -247,14 +247,14 @@ export function collectSubtree(index: ProcIndex, root: number): number[] { type AppBucketsRaw = Omit function electronMetricMemoryBytes( - proc: ReturnType[number], + proc: ReturnType[number], processIndex: ProcIndex ): number { const hostMemory = processIndex.byPid.get(proc.pid)?.memory if (typeof hostMemory === 'number' && Number.isFinite(hostMemory) && hostMemory > 0) { return hostMemory } - // Why: on macOS, app.getAppMetrics().workingSetSize can include large shared + // Why: on macOS, getAppEnvironment().getAppMetrics().workingSetSize can include large shared // Chromium/Electron mappings. Prefer the host RSS sweep used elsewhere, but // keep workingSetSize as a fallback when the process disappears mid-snapshot. return clampNumber(proc.memory?.workingSetSize) * 1024 @@ -265,7 +265,7 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { const renderer = { cpu: 0, memory: 0 } const other = { cpu: 0, memory: 0 } - for (const proc of app.getAppMetrics()) { + for (const proc of getAppEnvironment().getAppMetrics()) { const cpu = clampNumber(proc.cpu?.percentCPUUsage) const memoryBytes = electronMetricMemoryBytes(proc, processIndex) diff --git a/src/main/mimo/hook-service.test.ts b/src/main/mimo/hook-service.test.ts index e30e415ca97..4f84d3ea12c 100644 --- a/src/main/mimo/hook-service.test.ts +++ b/src/main/mimo/hook-service.test.ts @@ -2,17 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() })) -vi.mock('electron', () => ({ - app: { - getPath: getPathMock - } -})) - import { MimoCodeHookService } from './hook-service' describe('MimoCodeHookService buildPtyEnv', () => { @@ -27,6 +22,15 @@ describe('MimoCodeHookService buildPtyEnv', () => { } throw new Error(`unexpected getPath: ${name}`) }) + setAppEnvironment({ + getPath: getPathMock, + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) mimocodeHome = mkdtempSync(join(tmpdir(), 'orca-mimocode-home-')) const configDir = join(mimocodeHome, 'config') diff --git a/src/main/mimo/hook-service.ts b/src/main/mimo/hook-service.ts index 3f879d93063..2c78c41a454 100644 --- a/src/main/mimo/hook-service.ts +++ b/src/main/mimo/hook-service.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' @@ -53,7 +53,11 @@ export class MimoCodeHookService { buildPtyEnv(_ptyId: string, existingMimocodeHome?: string): Record { // Why: MiMo currently uses a shared home; per-source subdirs can come // later if concurrent MiMo panes need isolated runtime state. - const home = join(app.getPath('userData'), MIMOCODE_HOOKS_DIR, MIMOCODE_SHARED_HOME) + const home = join( + getAppEnvironment().getPath('userData'), + MIMOCODE_HOOKS_DIR, + MIMOCODE_SHARED_HOME + ) try { for (const sub of ['config', 'data', 'cache', 'state'] as const) { mkdirSync(join(home, sub), { recursive: true }) diff --git a/src/main/network/http-client.ts b/src/main/network/http-client.ts new file mode 100644 index 00000000000..03dfbc18a3f --- /dev/null +++ b/src/main/network/http-client.ts @@ -0,0 +1,41 @@ +import type { Session } from 'electron' + +/** + * Outbound HTTP for main-process integrations. + * + * Why a port: the desktop uses Electron's Chromium-backed network stack — it follows + * session/proxy state, avoids undici's stale keep-alive sockets after a VPN path change, + * and sends a Chrome user agent that some APIs (Jira's XSRF check) depend on. None of + * that exists on a host with no Chromium. + * + * The Node default is the platform global. That is a real behavioural difference, not a + * transparent swap, which is why this is a named port rather than a silent fallback: + * a Node host reads proxy configuration from the environment instead of from Chromium, + * and sends Node's user agent. + * + * Body safety (orca#8695): the global uses undici, where an unread response body can + * crash the process. This port hands the Response straight to its caller and never + * inspects it, so the consume/cancel obligation stays exactly where it already was — + * with the caller, unchanged from when they called Electron's net directly. + */ + +export type MainHttpClient = { + fetch(url: string, init?: RequestInit): Promise + /** The Chromium session whose proxy state applies, or null on a host without one. */ + proxySession(): Session | null +} + +const nodeHttpClient: MainHttpClient = { + fetch: (url, init) => globalThis.fetch(url, init), + proxySession: () => null +} + +let current: MainHttpClient = nodeHttpClient + +export function setMainHttpClient(client: MainHttpClient | null): void { + current = client ?? nodeHttpClient +} + +export function getMainHttpClient(): MainHttpClient { + return current +} diff --git a/src/main/network/proxy-settings.ts b/src/main/network/proxy-settings.ts index c1f63e0ac99..6ef32f4be5c 100644 --- a/src/main/network/proxy-settings.ts +++ b/src/main/network/proxy-settings.ts @@ -1,4 +1,38 @@ -import { session } from 'electron' +import type { Session } from 'electron' + +/** + * The default proxy session, or null on a host with no Chromium. + * + * Why settable: `session.defaultSession` is the only Electron value this module needs, + * and callers already accept an explicit `options.proxySession`. Making the *default* + * injectable lets the module load under plain Node, where there is no Chromium proxy + * config to consult and the environment variables are the whole answer. + */ +let resolveDefaultProxySession: (() => Session | null) | null = null + +/** + * Why a resolver and not a Session: `session.defaultSession` throws until the Electron + * app is ready, and this is installed during pre-ready bootstrap. Passing a getter + * defers the access to first use, which is always after ready. + */ +export function setDefaultProxySessionResolver(resolve: (() => Session | null) | null): void { + resolveDefaultProxySession = resolve +} + +function defaultProxySession(): Session | null { + return resolveDefaultProxySession?.() ?? null +} + +/** Apply proxy rules only when a Chromium session exists; a Node host has none to configure. */ +async function setSessionProxyIfPresent( + proxySession: ProxySession | Session | null, + config: Parameters[1] +): Promise { + if (!proxySession) { + return + } + await setSessionProxy(proxySession as ProxySession, config) +} import { getProxyBypassRulesFromEnvironment, getProxyUrlFromEnvironment, @@ -50,8 +84,12 @@ export async function ensureElectronProxyFromEnvironment( return lastAppliedProxyConfig } - const proxySession = options.proxySession ?? session.defaultSession - const resolved = await proxySession.resolveProxy(options.probeUrl ?? PROXY_PROBE_URL) + const proxySession = options.proxySession ?? defaultProxySession() + // Why not bail: with no Chromium session there is no system proxy to discover, so the + // environment variables below are the complete answer rather than a fallback. + const resolved = proxySession + ? await proxySession.resolveProxy(options.probeUrl ?? PROXY_PROBE_URL) + : 'DIRECT' if (resolved !== 'DIRECT') { return { source: 'system' } } @@ -65,7 +103,7 @@ export async function ensureElectronProxyFromEnvironment( } const bypassRules = getProxyBypassRulesFromEnvironment(options.env ?? process.env) - await setSessionProxy(proxySession, { + await setSessionProxyIfPresent(proxySession, { mode: 'fixed_servers', proxyRules: proxy.value, ...(bypassRules ? { proxyBypassRules: bypassRules } : {}) @@ -86,20 +124,22 @@ export async function applyElectronProxySettings( probeUrl?: string } = {} ): Promise { - const proxySession = options.proxySession ?? session.defaultSession + const proxySession = options.proxySession ?? defaultProxySession() const proxy = normalizeProxyUrl(settings.httpProxyUrl) if (!proxy.ok) { return ensureElectronProxyFromEnvironment({ - proxySession, + ...(proxySession ? { proxySession } : {}), env: options.env, force: lastAppliedProxyConfig !== null, probeUrl: options.probeUrl }).then((result) => (result.source === 'none' ? { source: 'invalid-settings' } : result)) } + // Why guarded: applying proxy rules to a Chromium session is meaningless with no + // Chromium. The settings are still honoured — outbound requests read the environment. if (proxy.value) { const bypassRules = normalizeProxyBypassRules(settings.httpProxyBypassRules) - await setSessionProxy(proxySession, { + await setSessionProxyIfPresent(proxySession, { mode: 'fixed_servers', proxyRules: proxy.value, ...(bypassRules ? { proxyBypassRules: bypassRules } : {}) @@ -113,11 +153,11 @@ export async function applyElectronProxySettings( } if (lastAppliedProxyConfig !== null) { - await setSessionProxy(proxySession, { mode: 'system' }) + await setSessionProxyIfPresent(proxySession, { mode: 'system' }) lastAppliedProxyConfig = null } return ensureElectronProxyFromEnvironment({ - proxySession, + ...(proxySession ? { proxySession } : {}), env: options.env, force: true, probeUrl: options.probeUrl diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 7d88e98e2cd..10355c8e6d0 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -12,19 +12,26 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() })) -vi.mock('electron', () => ({ - app: { - getPath: getPathMock - } -})) - import { OpenCodeHookService, _internals } from './hook-service' +beforeEach(() => { + setAppEnvironment({ + getPath: getPathMock, + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) + const { isUsableId, toSafeDirName } = _internals describe('OpenCode hook plugin source', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 53d40f7a4de..60c4fbe9ead 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: holds an inline JS plugin source emitted as one file; splitting across TS modules would scatter tightly coupled string-template logic. */ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, @@ -1096,7 +1096,7 @@ export class OpenCodeHookService { } private getOverlayRoot(): string { - return join(app.getPath('userData'), OPENCODE_OVERLAY_DIR) + return join(getAppEnvironment().getPath('userData'), OPENCODE_OVERLAY_DIR) } private getSourceOverlayDir(sourceConfigDir: string): string { @@ -1104,7 +1104,11 @@ export class OpenCodeHookService { } private getSharedConfigDir(): string { - return join(app.getPath('userData'), OPENCODE_LEGACY_HOOKS_DIR, OPENCODE_SHARED_CONFIG_DIR) + return join( + getAppEnvironment().getPath('userData'), + OPENCODE_LEGACY_HOOKS_DIR, + OPENCODE_SHARED_CONFIG_DIR + ) } private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index 128dc56cea3..06f2efe0903 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -12,11 +13,10 @@ import { const testState = { dir: '' } -vi.mock('electron', () => ({ - app: { - getPath: () => testState.dir - } -})) +// Why the port and not vi.mock('electron'): profile path resolution reads AppEnvironment +// now, so an electron mock would be inert and every case would share the global fake's +// one temp dir instead of its own. +installFakeAppEnvironment({ getPath: () => testState.dir }) async function loadProfileIndexStore() { vi.resetModules() @@ -30,6 +30,8 @@ function readJson(path: string): unknown { describe('profile index store', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-')) + // Why re-install per test: the global setup's beforeEach reinstates its own fake. + installFakeAppEnvironment({ getPath: () => testState.dir }) }) afterEach(() => { diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts index 5f8dc0df241..81dc990b0e1 100644 --- a/src/main/orca-profiles/profile-storage-paths.ts +++ b/src/main/orca-profiles/profile-storage-paths.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' const LEGACY_DATA_FILE_NAME = 'orca-data.json' @@ -13,12 +13,12 @@ export const LEGACY_BACKUP_COUNT = 5 let profileUserDataPath: string | null = null export function initOrcaProfilePaths(): void { - profileUserDataPath = app.getPath('userData') + profileUserDataPath = getAppEnvironment().getPath('userData') } export function getProfileUserDataPath(): string { if (!profileUserDataPath) { - profileUserDataPath = app.getPath('userData') + profileUserDataPath = getAppEnvironment().getPath('userData') } return profileUserDataPath } diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts new file mode 100644 index 00000000000..e76ed873cb0 --- /dev/null +++ b/src/main/orcad/main.ts @@ -0,0 +1,8 @@ +/** Executable entry for `orcad`. See `./orcad-entry.ts`. */ +import process from 'node:process' +import { main } from './orcad-entry' + +main().catch((error: unknown) => { + console.error('orcad: failed to start:', error) + process.exit(1) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts new file mode 100644 index 00000000000..bab68282e53 --- /dev/null +++ b/src/main/orcad/orcad-entry.ts @@ -0,0 +1,268 @@ +/** + * `orcad` — the Orca runtime served from plain Node, with no Electron. + * + * Installs the Node host adapters, constructs the same `OrcaRuntimeService` the + * desktop uses, installs a PTY controller via `registerPtyHandlers(null, …)`, and + * serves runtime RPC. See docs/design/node-only-runtime-backend.html. + * + * The desktop-only surfaces are deliberately left uninstalled: no notifications, no + * renderer window, no browser panes. Those are declared rather than faked — see + * `runtime-desktop-surface.ts` and `pty-host-bindings.ts`. + */ +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' +import { setSecretStore, type SecretStore } from '../../shared/secret-store' +import type { ServeReadiness } from '../server/serve-readiness' + +/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ +function resolveUserDataPath(): string { + const explicit = process.env.ORCA_USER_DATA + if (explicit) { + return explicit + } + const xdg = process.env.XDG_DATA_HOME + return xdg ? join(xdg, 'Orca') : join(homedir(), '.orca') +} + +function createNodeAppEnvironment(): AppEnvironment { + const userData = resolveUserDataPath() + const quitHandlers: (() => void)[] = [] + // Why SIGTERM/SIGINT: this is the Node equivalent of electron's will-quit, and the + // runtime's teardown (daemon disconnect, PTY kill, store flush) hangs off it. + const runQuitHandlers = (): void => { + for (const handler of quitHandlers.splice(0)) { + try { + handler() + } catch (error) { + console.error('[orcad] shutdown handler failed:', error) + } + } + } + process.once('SIGTERM', () => { + runQuitHandlers() + process.exit(0) + }) + process.once('SIGINT', () => { + runQuitHandlers() + process.exit(0) + }) + return { + getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData), + getAppPath: () => process.cwd(), + getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', + isPackaged: () => true, + onWillQuit: (handler) => quitHandlers.push(handler), + exit: (code = 0) => process.exit(code), + // Why []: there are no Chromium processes on this host to measure. + getAppMetrics: () => [] + } +} + +/** + * Why not silently plaintext: `isEncryptionAvailable() === false` already makes every + * caller fall back to unsealed storage, which is a security posture, not a detail. + * `describeProtectionGap()` gives the reason a client can surface. + */ +function createNodeSecretStore(): SecretStore { + return { + isEncryptionAvailable: () => false, + encryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + decryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + describeProtectionGap: () => + 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' + } +} + +export function installOrcadHostAdapters(): void { + setAppEnvironment(createNodeAppEnvironment()) + setSecretStore(createNodeSecretStore()) +} + +export type OrcadOptions = { + port?: number + json?: boolean + noPairing?: boolean + pairingAddress?: string +} + +export type OrcadHandle = { + readiness: ServeReadiness + stop(): Promise +} + +/** + * Boot the runtime and serve RPC. Resolves once the transport is listening and the + * readiness payload has been published, mirroring the desktop `--serve` contract byte + * for byte so the same harnesses can drive either host. + */ +export async function startOrcad(options: OrcadOptions = {}): Promise { + installOrcadHostAdapters() + + const { OrcaRuntimeService } = await import('../runtime/orca-runtime') + const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') + const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = + await import('../ipc/pty') + const { getAppEnvironment } = await import('../../shared/app-environment') + const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') + const { ServeReadinessPublisher } = await import('../server/serve-readiness') + const { Store } = await import('../persistence/loading-store/store') + const { ensureActiveOrcaProfile, initOrcaProfilePaths } = + await import('../orca-profiles/profile-index-store') + const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') + + const userDataPath = getAppEnvironment().getPath('userData') + initOrcaProfilePaths() + const profile = ensureActiveOrcaProfile(userDataPath) + // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` + // and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead — + // a server that pairs and lists nothing looks healthy and is not. + const store = new Store({ dataFile: profile.dataFile }) + // Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted, + // which is safe but silently discards accept records on every launch. + initSshHostKeyStoreFile(profile.dataFile) + + const runtime = new OrcaRuntimeService(store, undefined, { + // Why lazy: a daemon swap replaces the provider after construction, so an eager + // reference would freeze the pre-daemon one. + getLocalProvider: () => getLocalPtyProvider(), + // Why: destructive worktree removal refuses to run without a provider to stop + // processes through — correctly, since it cannot otherwise verify the tree is idle. + getSshProvider: (connectionId) => getSshPtyProvider(connectionId), + // Why false: this host does not run the terminal daemon, so persistent local PTYs + // cannot be recovered. The constructor defaults this to true, which would claim a + // capability orcad does not have. + canRecoverPersistentLocalPtys: () => false, + // Why 'blocked': `'openable'` means a desktop window can be opened here, which is + // what powers serve→desktop promotion. A Node host can never do that, and the + // constructor's default would advertise it. + getDesktopWindowStatus: () => 'blocked' + }) + + // Why the headless entry point rather than registerPtyHandlers(null, …): this is the + // same call `--serve` makes, and it threads the store through. Without the store the + // handlers install fine and every terminal.create then fails at persistence time. + // + // Codex-home and Claude-auth preparation are left unset: both are desktop account + // flows. A launch that needs one fails with its own message rather than silently + // spawning an unauthenticated agent. + registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store) + + // Why: same post-registration reconciliation `--serve` performs. Skipping it leaves + // restored orchestration rows claiming an authority this host never took over. + await runtime.refreshRestoredOrchestrationAuthority() + await runtime.reconcileLegacyWorkerTerminals() + + const rpc = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + exposeNetworkByDefault: true, + ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) + }) + await rpc.start() + + const boundEndpoint = rpc.getWebSocketEndpoint() + const advertised = boundEndpoint + ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) + : null + const offer = options.noPairing + ? ({ + available: false, + reason: 'disabled_by_operator', + guidance: 'Restart without --no-pairing to create a client pairing offer.' + } as const) + : rpc.createPairingOffer({ + address: options.pairingAddress, + name: `CLI ${new Date().toLocaleDateString()}`, + scope: 'runtime' + }) + + const readiness: ServeReadiness = { + runtimeId: runtime.getRuntimeId(), + boundEndpoint, + advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, + // Why 'settled': the WSL CLI reconciliation barrier is a desktop-launch concern. + // orcad never runs it, so there is no pending repair a client could race. + managedWslCliReconciliation: 'settled', + pairing: offer.available + ? { + available: true, + url: offer.pairingUrl, + endpoint: offer.endpoint, + deviceId: offer.deviceId, + webClientUrl: offer.webClientUrl, + scope: 'runtime', + qr: null + } + : offer + } + + await new ServeReadinessPublisher().publish(readiness, { + mode: options.json ? 'json' : 'human' + }) + + return { + readiness, + stop: async () => { + await rpc.stop() + } + } +} + +function parseArgs(argv: string[]): OrcadOptions { + const options: OrcadOptions = {} + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--port') { + const raw = argv[i + 1] + const port = Number(raw) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`--port expects an integer 0-65535, got ${raw ?? "''"}`) + } + options.port = port + i += 1 + } else if (arg === '--json') { + options.json = true + } else if (arg === '--no-pairing') { + options.noPairing = true + } else if (arg === '--pairing-address') { + const value = argv[i + 1] + if (!value) { + throw new Error('--pairing-address expects a value') + } + options.pairingAddress = value + i += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + return options +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const handle = await startOrcad(parseArgs(argv)) + let stopping = false + const shutdown = (signal: NodeJS.Signals): void => { + if (stopping) { + return + } + stopping = true + handle + .stop() + .then(() => process.exit(0)) + // Why not rethrow: we are already tearing down on a signal, and an exit code is + // the only thing a supervisor can act on. + .catch((error) => { + console.error(`orcad: shutdown after ${signal} failed:`, error) + process.exit(1) + }) + } + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) +} diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts index 2d6d581599e..3a4c1f11e87 100644 --- a/src/main/persistence-async-write-syscalls.test.ts +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -13,6 +13,7 @@ import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { SshRemotePtyLeaseState } from '../shared/ssh-types' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -177,6 +178,9 @@ async function createStore(dir: string): Promise { testState.dir = dir vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() as unknown as TestStore } diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index f1a63e876ea..0da93c3a979 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -12,6 +12,7 @@ import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import { getDefaultPersistedState } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -52,6 +53,9 @@ async function createStoreFromState(state: Record) { ) vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts index 42d348af61c..26f49ea2e44 100644 --- a/src/main/persistence-protected-secret-fail-closed.test.ts +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { randomUUID } from 'node:crypto' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable' @@ -19,31 +20,7 @@ vi.mock('./ssh/ssh-config-parser', () => ({ })) vi.mock('electron', () => ({ - app: { getPath: () => testState.dir }, - safeStorage: { - isEncryptionAvailable: () => { - if (cipherState.availability === 'throws') { - throw new Error('keychain access denied') - } - return cipherState.availability === 'available' - }, - encryptString: (plaintext: string) => { - if (cipherState.encryptionThrows) { - throw new Error('keychain encryption failed') - } - return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8') - }, - decryptString: (ciphertext: Buffer) => { - if (cipherState.decryptionThrows) { - throw new Error('keychain decryption failed') - } - const decoded = ciphertext.toString('utf-8') - if (!decoded.startsWith('enc:')) { - throw new Error('invalid ciphertext') - } - return decoded.slice('enc:'.length + 36 + 1) - } - } + app: { getPath: () => testState.dir } })) vi.mock('./telemetry/client', () => ({ track: vi.fn() })) @@ -53,7 +30,36 @@ vi.mock('./telemetry/cohort-classifier', () => ({ async function createStore() { vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => { + if (cipherState.availability === 'throws') { + throw new Error('keychain access denied') + } + return cipherState.availability === 'available' + }, + encryptString: (plaintext) => { + if (cipherState.encryptionThrows) { + throw new Error('keychain encryption failed') + } + return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8') + }, + decryptString: (ciphertext) => { + if (cipherState.decryptionThrows) { + throw new Error('keychain decryption failed') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + }, + describeProtectionGap: () => null + }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts index 20bbe78d5cf..21aa20d13b8 100644 --- a/src/main/persistence-protected-secret-write-race.test.ts +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } const cipherState = { available: true } @@ -40,17 +41,22 @@ vi.mock('./telemetry/cohort-classifier', () => ({ })) vi.mock('electron', () => ({ - app: { getPath: () => testState.dir }, - safeStorage: { - isEncryptionAvailable: () => cipherState.available, - encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) - } + app: { getPath: () => testState.dir } })) async function createStore() { vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decryptString: (ciphertext) => ciphertext.toString('utf-8').slice('enc:'.length), + describeProtectionGap: () => null + }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index 769ebec69ba..176113192c7 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -28,26 +29,7 @@ vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, - session: { defaultSession: undefined }, - safeStorage: { - isEncryptionAvailable: () => { - if (cipherState.availabilityThrows) { - throw new Error('safeStorage cannot be used before the app is ready') - } - return cipherState.encryptionAvailable - }, - encryptString: (plaintext: string) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => { - if (cipherState.decryptAlwaysThrows) { - throw new Error('keychain access denied') - } - const decoded = ciphertext.toString('utf-8') - if (!decoded.startsWith('enc:')) { - throw new Error('invalid ciphertext') - } - return decoded.slice('enc:'.length + 36 + 1) - } - } + session: { defaultSession: undefined } })) vi.mock('./telemetry/client', () => ({ @@ -60,7 +42,31 @@ vi.mock('./telemetry/cohort-classifier', () => ({ async function createStore() { vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => { + if (cipherState.availabilityThrows) { + throw new Error('safeStorage cannot be used before the app is ready') + } + return cipherState.encryptionAvailable + }, + encryptString: (plaintext) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'), + decryptString: (ciphertext) => { + if (cipherState.decryptAlwaysThrows) { + throw new Error('keychain access denied') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + }, + describeProtectionGap: () => null + }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index c638ac12224..1293bb30f48 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -8,6 +8,7 @@ import { readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -27,21 +28,6 @@ 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) - } } })) @@ -55,7 +41,27 @@ vi.mock('./telemetry/cohort-classifier', () => ({ async function createStore() { vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ + isEncryptionAvailable: () => cipherState.encryptionAvailable, + encryptString: (plaintext) => + Buffer.from( + `enc:${cipherState.deterministic ? DETERMINISTIC_IV : randomUUID()}:${plaintext}`, + 'utf-8' + ), + decryptString: (ciphertext) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + }, + describeProtectionGap: () => null + }) const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 94b2c62d238..89f5f03a88f 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import type { TerminalTab } from '../shared/terminal-tab-types' @@ -13,6 +14,10 @@ export const testState = { dir: '' } /** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ export async function createStore() { vi.resetModules() + // Why here and not a per-file vi.mock('electron'): the data-file path resolves through + // AppEnvironment now, and it must point at this test's dir rather than the global + // fake's shared one. Re-installed after resetModules so the fresh graph sees it. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() return new Store() diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index ccafa4e3fab..1b092293c68 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -7,12 +7,12 @@ import { getDefaultPersistedState } from '../shared/constants' import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cleanup-browse-state' import { testState, - createStore, dataFile, writeDataFile, readDataFile, makeRepo } from './persistence-test-harness' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -32,19 +32,31 @@ const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ vi.mock('electron', () => ({ app: { getPath: () => testState.dir - }, - safeStorage: { + } +})) + +async function createStore() { + vi.resetModules() + const { setSecretStore } = await import('../shared/secret-store') + setSecretStore({ isEncryptionAvailable: () => true, - encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => { + encryptString: (plaintext) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext) => { const decoded = ciphertext.toString('utf-8') if (!decoded.startsWith('encrypted:')) { throw new Error('invalid ciphertext') } return decoded.slice('encrypted:'.length) - } - } -})) + }, + describeProtectionGap: () => null + }) + const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) + initDataPath() + return new Store() +} vi.mock('./telemetry/client', () => ({ track: trackMock diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index 1c98d9525a3..f57a2a1b603 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -18,6 +18,7 @@ import { makeSessionWithTerminalBuffers, makeSessionWithBrowserHistory } from './persistence-session-fixtures' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -188,6 +189,9 @@ describe('Store', () => { mkdirSync(profileDataDirectory, { recursive: true }) vi.resetModules() + // Why: the legacy snapshot dir hangs off userData, which resolves through + // AppEnvironment — without this it points at the global fake's shared dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) @@ -218,6 +222,9 @@ describe('Store', () => { writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') vi.resetModules() + // Why: the legacy snapshot dir hangs off userData, which resolves through + // AppEnvironment — without this it points at the global fake's shared dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index dcfb35a5954..2f89775ce87 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -8,6 +8,7 @@ import { createRetiredNameLookup } from '../shared/worktree/retired-name-registr import type { SshTarget } from '../shared/ssh-types' import { MAX_RETIREMENT_NAMESPACES } from './worktree-retirement-namespace' import { getRuntimeOwnedSshTargetId } from './ssh/ssh-connection-store' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const testState = { dir: '' } @@ -45,6 +46,9 @@ function sshTarget(id: string, overrides: Partial = {}): SshTarget { async function reloadStore() { vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why here: userData resolves through AppEnvironment, and this must point at this + // file's temp dir rather than the global fake's shared one, after resetModules. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() } diff --git a/src/main/persistence/loading-store/user-data-path.ts b/src/main/persistence/loading-store/user-data-path.ts index db62f706b7b..6dd4b50b1c8 100644 --- a/src/main/persistence/loading-store/user-data-path.ts +++ b/src/main/persistence/loading-store/user-data-path.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getAppEnvironment } from '../../../shared/app-environment' import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -11,7 +11,7 @@ let _dataFile: string | null = null let _userDataDir: string | null = null export function initDataPath(): void { - const userDataDir = app.getPath('userData') + const userDataDir = getAppEnvironment().getPath('userData') _userDataDir = userDataDir _dataFile = join(userDataDir, 'orca-data.json') } @@ -19,7 +19,7 @@ export function initDataPath(): void { export function getDataFile(): string { if (!_dataFile) { // Safety fallback — should not be hit in normal startup. - const userDataDir = app.getPath('userData') + const userDataDir = getAppEnvironment().getPath('userData') _userDataDir = userDataDir _dataFile = join(userDataDir, 'orca-data.json') } @@ -51,14 +51,14 @@ export function readGithubCacheSnapshot(dataFile: string): PersistedState['githu } /** - * Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves. + * Return the userData directory captured at initDataPath() time, before app.setName() can change how getAppEnvironment().getPath('userData') resolves. * * Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices. */ export function getCanonicalUserDataPath(): string { if (!_userDataDir) { // Safety fallback — should not be hit in normal startup. - _userDataDir = app.getPath('userData') + _userDataDir = getAppEnvironment().getPath('userData') } return _userDataDir } diff --git a/src/main/pi/titlebar-extension-overlay-path.test.ts b/src/main/pi/titlebar-extension-overlay-path.test.ts index 78c8fae7723..db5bfeedbd9 100644 --- a/src/main/pi/titlebar-extension-overlay-path.test.ts +++ b/src/main/pi/titlebar-extension-overlay-path.test.ts @@ -1,21 +1,11 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const userDataDir = mkdtempSync(join(tmpdir(), 'orca-pi-overlay-path-userdata-')) -vi.mock('electron', () => ({ - app: { - getPath: (name: string) => { - if (name === 'userData') { - return userDataDir - } - throw new Error(`unexpected app.getPath(${name})`) - } - } -})) - import { PiTitlebarExtensionService } from './titlebar-extension-service' const PATH_SHAPED_PTY_ID = [ @@ -34,6 +24,17 @@ function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { } describe('PiTitlebarExtensionService legacy overlay paths', () => { + beforeEach(() => { + installFakeAppEnvironment({ + getPath: (name) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + } + }) + }) + afterEach(() => { rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true }) rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true }) diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index e197a4a0c3a..3ad73bba6eb 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -17,6 +17,7 @@ import { tmpdir } from 'node:os' import type * as osModule from 'node:os' import type * as fsModule from 'node:fs' import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' // The service calls app.getPath('userData') for its overlay root. Point that // at a real tmp dir so we can exercise the filesystem behavior end-to-end. @@ -37,17 +38,6 @@ vi.mock('os', async (importOriginal) => { } }) -vi.mock('electron', () => ({ - app: { - getPath: (name: string) => { - if (name === 'userData') { - return userDataDir - } - throw new Error(`unexpected app.getPath(${name})`) - } - } -})) - import { PiTitlebarExtensionService, isSafeDescendCandidate } from './titlebar-extension-service' function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { @@ -62,6 +52,17 @@ function legacySourceOverlayPath(kind: 'pi' | 'omp', sourceAgentDir: string): st } describe('PiTitlebarExtensionService', () => { + beforeEach(() => { + installFakeAppEnvironment({ + getPath: (name) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + } + }) + }) + let piHome: string beforeEach(() => { diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 5e728fa0274..3a43ce4ac38 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { createHash } from 'node:crypto' import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE, @@ -74,7 +74,7 @@ function withOrcaManagedExtensionMarker(source: string): string { export class PiTitlebarExtensionService { private getOverlayRoot(kind: LegacyOverlayAgentKind): string { - return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) + return join(getAppEnvironment().getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) } private getSourceOverlayDir(sourceAgentDir: string, kind: LegacyOverlayAgentKind): string { @@ -122,7 +122,10 @@ export class PiTitlebarExtensionService { } private writeOmpFallbackStatusExtension(source: string): string | undefined { - const fallbackDir = join(app.getPath('userData'), OMP_MANAGED_STATUS_EXTENSION_DIR) + const fallbackDir = join( + getAppEnvironment().getPath('userData'), + OMP_MANAGED_STATUS_EXTENSION_DIR + ) try { mkdirSync(fallbackDir, { recursive: true }) } catch { diff --git a/src/main/plugins/plugin-client-list.ts b/src/main/plugins/plugin-client-list.ts new file mode 100644 index 00000000000..fe3026c2dec --- /dev/null +++ b/src/main/plugins/plugin-client-list.ts @@ -0,0 +1,17 @@ +import { getUserPluginsDir } from './plugin-discovery' +import { readPluginLockfile } from './plugin-install' +import { buildPluginList, type PluginListEntry } from './plugin-list-projection' +import type { PluginService } from './plugin-service' + +/** + * The plugin list paired clients see. Split out of `ipc/plugins.ts` so the runtime's + * `plugins.list` RPC can reach it without dragging `ipcMain` into its module graph — + * the same reason preflight and the SSH registry moved. + */ +export async function listPluginsForClients( + pluginService: PluginService +): Promise { + await pluginService.whenReady() + const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath)) + return buildPluginList(pluginService, lock) +} diff --git a/src/main/ports/port-scan-command-client.ts b/src/main/ports/port-scan-command-client.ts index 9748a8cfc2b..c2bc80bc883 100644 --- a/src/main/ports/port-scan-command-client.ts +++ b/src/main/ports/port-scan-command-client.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { Worker } from 'node:worker_threads' import { @@ -16,9 +17,9 @@ import { // the duplicated ~150 lines are cheaper than a premature shared abstraction, so // a third adopter should extract one. // -// This module contains the literal text require('electron'), so it must never -// become reachable from a plain-Node fork entry (build-plugins/ -// plain-node-entry-guard.ts fails the build on that text, try/catch or not). +// This module used to contain the literal text require('electron'), which fails the +// plain-Node entry guard even inside a try/catch. It reads the AppEnvironment port +// instead, so it is now safe to reach from a fork entry. // Why: the worker's own loop absorbs the spawn stall, so the client only needs // a backstop for a wedged thread. Kept at 30s because a scan sits on the @@ -324,14 +325,8 @@ export function resolveWorkerEntryPath(layout: WorkerEntryLayout): string { } function currentWorkerEntryLayout(): WorkerEntryLayout { - let app: { isPackaged: boolean } | null = null - try { - app = require('electron').app ?? null - } catch { - app = null - } return { - isPackaged: app?.isPackaged === true, + isPackaged: hasAppEnvironment() && getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, moduleDir: __dirname } diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts new file mode 100644 index 00000000000..700941de34f --- /dev/null +++ b/src/main/preflight/agent-detection.ts @@ -0,0 +1,304 @@ +/** + * Agent/tool preflight detection. Split out of `ipc/preflight.ts` so the Orca + * runtime — which calls `detectInstalledAgentsWithShellPathHydration` and + * `detectRemoteAgents` during normal operation — can reach this logic without + * dragging `ipcMain` into its module graph. The Electron handler registration + * stays in `ipc/preflight.ts` and imports from here. + */ +import type { + PathSource, + ShellHydrationFailureReason +} from '../../shared/shell-path-hydration-types' +import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getAzureDevOpsAuthStatus } from '../azure-devops/client' +import { getBitbucketAuthStatus } from '../bitbucket/client' +import { getGiteaAuthStatus } from '../gitea/client' +import { _resetKnownHostsCache } from '../gitlab/gl-utils' +import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' +import { + detectWslCommandsOnPath, + type WslPreflightTarget +} from '../ipc/preflight-wsl-agent-detection' +import { detectCommandsInInstallDirs } from '../ipc/local-agent-install-dir-detection' +import { + getPreflightWslTarget, + type PreflightRuntimeContext +} from '../ipc/preflight-runtime-target' + +export type { PreflightRuntimeContext } +import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell-path' +import { + execCommandInWsl, + execLocalPreflightCommand, + isCommandAvailable, + isCommandOnPath, + shellQuote +} from '../ipc/preflight-command-exec' +import { + detectRemoteWindowsTerminalCapabilities, + type RemoteWindowsTerminalCapabilities +} from '../ipc/preflight-remote-windows-terminal-capabilities' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from '../ipc/tui-agent-detection-commands' +import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' + +export type PreflightStatus = { + git: { installed: boolean } + gh: { installed: boolean; authenticated: boolean } + // Why: optional so existing renderer call sites that only render git/gh + // status keep typechecking. Consumers that surface GitLab-specific + // affordances (the GitLab tab in the source picker, MR list, etc.) + // gate on `glab?.authenticated`. + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } +} + +export { detectRemoteWindowsTerminalCapabilities } +export type { RemoteWindowsTerminalCapabilities } + +// Why: cache the result so repeated Landing mounts don't re-spawn processes. +// The check only runs once per app session — relaunch to re-check. +let cached: PreflightStatus | null = null + +/** @internal - tests need a clean preflight cache between cases. */ +export function _resetPreflightCache(): void { + cached = null +} + +function uniqueAgentIds(ids: Iterable): string[] { + return [...new Set(ids)] +} + +async function detectCommandRuntime( + command: string, + context?: PreflightRuntimeContext +): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + return (await isCommandAvailable(command, wslTarget)) + ? { installed: true, wslTarget } + : { installed: false } + } + if (await isCommandAvailable(command)) { + return { installed: true } + } + return { installed: false } +} + +export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + const foundCommands = await detectWslCommandsOnPath( + wslTarget, + getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') + ) + return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') + } + + const probeCommands = getTuiAgentDetectionProbeCommands( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + process.platform + ) + const pathChecks = await Promise.all( + probeCommands.map(async (cmd) => ({ + cmd, + installedOnPath: await isCommandOnPath(cmd) + })) + ) + const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) + // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution + // computes user install dirs once instead of blocking once per missed CLI. + const installDirCommands = detectCommandsInInstallDirs(missedCommands) + const foundCommands = new Set( + pathChecks + .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) + .map(({ cmd }) => cmd) + ) + return resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + foundCommands, + process.platform + ) +} + +export async function detectInstalledAgentsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgents(context) +} + +export type RefreshAgentsResult = { + /** Agents detected after hydrating PATH from the user's login shell. */ + agents: string[] + /** PATH segments that were added this refresh (empty if nothing new). */ + addedPathSegments: string[] + /** True when the shell spawn succeeded. False = relied on existing PATH. */ + shellHydrationOk: boolean + /** Whether `detectInstalledAgents` ran against shell-hydrated PATH or only + * the seed list from `patchPackagedProcessPath`. Drives the on_path:false + * triage in tile A on dashboard 1562016. */ + pathSource: PathSource + /** Why hydration failed (or `'none'` on success). Typed against the shared + * alias so the IPC boundary stays in lockstep with the renderer-visible + * enum on `onboardingAgentPickedSchema`. */ + pathFailureReason: ShellHydrationFailureReason +} + +/** + * Re-spawn the user's login shell to refresh process.env.PATH, then re-run + * agent detection. Called by the Agents settings pane when the user clicks + * Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case + * without requiring an app restart. + */ +export async function refreshShellPathAndDetectAgents( + context?: PreflightRuntimeContext +): Promise { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + // Why invalidate first: the guest PATH is cached per distro for the process + // lifetime, so Refresh would otherwise re-read the pre-install PATH and + // keep reporting a just-installed CLI as absent -- the exact case this + // function exists to handle. + invalidateWslGuestEnvironment(wslTarget.distro) + const agents = await detectInstalledAgents(context) + return { + agents, + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'sync_seed_only', + pathFailureReason: 'none' + } + } + + const hydration = await hydrateShellPath({ force: true }) + const added = hydration.ok ? mergePathSegments(hydration.segments) : [] + const agents = await detectInstalledAgents(context) + return { + agents, + addedPathSegments: added, + shellHydrationOk: hydration.ok, + pathSource: hydration.ok ? 'shell_hydrate' : 'sync_seed_only', + pathFailureReason: hydration.failureReason + } +} + +export async function detectRemoteAgents(args: { connectionId: string }): Promise { + const mux = getActiveMultiplexer(args.connectionId) + if (!mux || mux.isDisposed()) { + // Why: remote agent detection is passive UI polling. A disconnected host has + // no detectable agents until reconnect, but should not spam IPC errors. + return [] + } + const result = (await mux.request('preflight.detectAgents', { + commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS + })) as { agents: string[] } + return uniqueAgentIds(result.agents) +} + +async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { + try { + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`) + : execLocalPreflightCommand('gh', ['auth', 'status'])) + // Why: for plain-text `gh auth status`, exit 0 means gh did not detect any + // authentication issues for the checked hosts/accounts. + return true + } catch (error) { + // Why: some environments may surface partial command output on the thrown + // error object. Keep a compatibility fallback so we avoid a false auth + // warning if success markers are present despite a non-zero result. + const stdout = (error as { stdout?: string }).stdout ?? '' + const stderr = (error as { stderr?: string }).stderr ?? '' + const output = `${stdout}\n${stderr}` + return output.includes('Logged in') || output.includes('Active account: true') + } +} + +// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth +// status to stderr in some versions and stdout in others; check both. +async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise { + try { + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`) + : execLocalPreflightCommand('glab', ['auth', 'status'])) + return true + } catch (error) { + const stdout = (error as { stdout?: string }).stdout ?? '' + const stderr = (error as { stderr?: string }).stderr ?? '' + const output = `${stdout}\n${stderr}` + return output.includes('Logged in') + } +} + +export async function runPreflightCheck( + force = false, + context?: PreflightRuntimeContext +): Promise { + const wslTarget = getPreflightWslTarget(context) + const cacheable = !wslTarget + if (cacheable && cached && !force) { + return cached + } + + if (process.platform === 'win32' && !wslTarget) { + await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force }) + } + + if (force) { + // Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the + // first GitLab request and never invalidated within a session. A user who + // runs `glab auth login` for a self-hosted host after Orca starts would + // otherwise see "No GitLab project found" until app relaunch. The Re-check + // path in IntegrationsPane forces preflight, so piggyback on that signal + // to refresh the host list too. + _resetKnownHostsCache() + } + + const [gitProbe, ghProbe, glabProbe] = await Promise.all([ + detectCommandRuntime('git', context), + detectCommandRuntime('gh', context), + detectCommandRuntime('glab', context) + ]) + + const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ + ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false), + glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false), + getBitbucketAuthStatus(), + getAzureDevOpsAuthStatus(), + getGiteaAuthStatus() + ]) + + const result = { + git: { installed: gitProbe.installed }, + gh: { installed: ghProbe.installed, authenticated: ghAuthenticated }, + glab: { installed: glabProbe.installed, authenticated: glabAuthenticated }, + bitbucket, + azureDevOps, + gitea + } + + if (cacheable) { + cached = result + } + + return result +} diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts index 815edc5d477..28a9ca6cf43 100644 --- a/src/main/protected-secret-persistence.test.ts +++ b/src/main/protected-secret-persistence.test.ts @@ -1,18 +1,29 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { _resetSecretStoreForTests, setSecretStore } from '../shared/secret-store' const cipherState = { available: true } -vi.mock('electron', () => ({ - safeStorage: { - isEncryptionAvailable: () => cipherState.available, - encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`), - decryptString: (ciphertext: Buffer) => ciphertext.toString().slice('encrypted:'.length) - } -})) - describe('ProtectedSecretPersistence', () => { beforeEach(() => { cipherState.available = true + setSecretStore({ + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext) => Buffer.from(`encrypted:${plaintext}`), + decryptString: (ciphertext) => ciphertext.toString().slice('encrypted:'.length), + describeProtectionGap: () => null + }) + }) + + it('surfaces an uninstalled secret store instead of degrading silently', async () => { + // Why: a missing setSecretStore() is a startup bug. If the availability check + // swallows it, encrypt() hands back an empty blob and decryptWithStatus() reports + // 'unavailable' — a real secret silently not stored, which is the outcome the + // port throws to prevent. + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + _resetSecretStoreForTests() + + expect(() => secrets.encrypt('slot', 'token')).toThrow(/SecretStore not initialized/) }) it('evicts dynamic slots across repeated SSH recovery lifecycles', async () => { diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index 85ab3153043..8445e67680f 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { getSecretStore } from '../shared/secret-store' export const PROTECTED_SECRET_SLOT = { opencodeSessionCookie: 'settings.opencodeSessionCookie', @@ -77,7 +77,7 @@ export class ProtectedSecretPersistence { } } try { - const blob = safeStorage.encryptString(plaintext).toString('base64') + const blob = getSecretStore().encryptString(plaintext).toString('base64') return { blob, degraded: false, @@ -109,7 +109,7 @@ export class ProtectedSecretPersistence { } try { const decrypted = { - plaintext: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')), + plaintext: getSecretStore().decryptString(Buffer.from(ciphertext, 'base64')), status: 'decrypted' as const } this.sealedSlots.delete(slot) @@ -117,22 +117,27 @@ export class ProtectedSecretPersistence { } catch { if (isLegacyPlaintext?.(ciphertext)) { this.sealedSlots.delete(slot) - console.warn('[persistence] safeStorage decryption failed; accepting legacy plaintext.') + console.warn('[persistence] secret decryption failed; accepting legacy plaintext.') return { plaintext: ciphertext, status: 'failed' } } this.sealedSlots.add(slot) console.warn( - '[persistence] safeStorage decryption failed; retaining the protected value without exposing it.' + '[persistence] secret decryption failed; retaining the protected value without exposing it.' ) return { plaintext: '', status: 'failed' } } } private encryptionAvailable(): boolean { + // Why getSecretStore() sits outside the try: an uninstalled store is a startup bug, + // not a keyring failure. Swallowing it would degrade to an empty blob and report + // 'unavailable' — the silent-wrong-state outcome the port throws to prevent. Only + // the backend probe itself may fail softly. + const store = getSecretStore() try { - return safeStorage.isEncryptionAvailable() + return store.isEncryptionAvailable() } catch (err) { - console.warn('[persistence] safeStorage availability check failed:', err) + console.warn('[persistence] secret store availability check failed:', err) return false } } diff --git a/src/main/providers/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts index 3b504a39242..f12de81360f 100644 --- a/src/main/providers/local-pty-provider-spawn-env.test.ts +++ b/src/main/providers/local-pty-provider-spawn-env.test.ts @@ -435,6 +435,71 @@ describe('LocalPtyProvider', () => { expect(env.LD_LIBRARY_PATH).toBe('/opt/audio/lib') }) + it('does not forward a half-activated conda env into the shell', async () => { + // Why: CONDA_SHLVL asserts CONDA_PREFIX exists; forwarding the sentinel + // alone makes the user's rc-file conda hook raise a TypeError (#14195). + const saved = { + CONDA_SHLVL: process.env.CONDA_SHLVL, + CONDA_PREFIX: process.env.CONDA_PREFIX, + CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV, + CONDA_PROMPT_MODIFIER: process.env.CONDA_PROMPT_MODIFIER, + CONDA_EXE: process.env.CONDA_EXE + } + delete process.env.CONDA_PREFIX + process.env.CONDA_SHLVL = '1' + process.env.CONDA_DEFAULT_ENV = 'base' + process.env.CONDA_PROMPT_MODIFIER = '(base) ' + process.env.CONDA_EXE = '/opt/miniconda3/bin/conda' + + try { + await provider.spawn({ cols: 80, rows: 24 }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined() + expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + }) + + it('drops the conda sentinel when the client asks to delete CONDA_PREFIX', async () => { + // Why: envToDelete runs after the inherited-env scrub, so the coherence + // pass must run last or it re-creates the exact broken pair it prevents. + const saved = { + CONDA_SHLVL: process.env.CONDA_SHLVL, + CONDA_PREFIX: process.env.CONDA_PREFIX, + CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV + } + process.env.CONDA_SHLVL = '1' + process.env.CONDA_PREFIX = '/opt/miniconda3' + process.env.CONDA_DEFAULT_ENV = 'base' + + try { + await provider.spawn({ cols: 80, rows: 24, envToDelete: ['CONDA_PREFIX'] }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.CONDA_PREFIX).toBeUndefined() + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + }) + it('uses shell wrapper when MiMo home must survive shell startup', async () => { provider.configure({ buildSpawnEnv: (_id, env) => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index f0954fa123a..6678e75c288 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -44,6 +44,7 @@ import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' +import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' import { SessionNotFoundError } from '../daemon/daemon-errors' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' @@ -835,6 +836,8 @@ export class LocalPtyProvider implements IPtyProvider { ) // Why: raw requested PATH promotion runs after the host-env scrub. stripLegacyTerminalShimEnv(finalEnv, process.platform) + // Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind. + dropIncoherentCondaActivationEnv(finalEnv, process.platform) // Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10). const worktreeId = args.worktreeId diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 2ba058ffd9c..cf2d9979a88 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { setPtyHostBindings } from '../ipc/pty-host-bindings' const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -82,6 +83,16 @@ describe('PTY provider dispatch', () => { onMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { handlers.set(channel, handler) }) + // Why: pty.ts registers against an injected surface now, so the mocked ipcMain must + // be installed for this suite's own `handlers` map to capture registrations. + setPtyHostBindings({ + ipc: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } + }) registerPtyHandlers(mainWindow as never) } diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0ae94230829..c71b9b06722 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -390,7 +390,7 @@ describe('resolveWindowsShellLaunchArgs', () => { const zshEnv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') for (const wrapperFile of [bashRcfile, zshEnv]) { expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') - expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + expect(wrapperFile).toContain('function omp { __orca_omp "$@"; }') expect(wrapperFile).not.toContain('prime-agent()') expect(wrapperFile).not.toContain('__orca_prime_agent') expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts index 058626741c9..abf189de356 100644 --- a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -21,6 +29,7 @@ type Fixture = { intendedMarker: string hijackMarker: string codexMarker: string + postSnippetMarker: string homePath: string } @@ -30,7 +39,7 @@ function writeStub(path: string, markerPath: string): void { chmodSync(path, 0o755) } -function buildFixture(): Fixture { +function buildFixture(options: { aliasCodex?: boolean } = {}): Fixture { const root = mkdtempSync(join(tmpdir(), 'orca-codex-profile-path-')) roots.push(root) const resourcesPath = join(root, 'resources') @@ -40,6 +49,7 @@ function buildFixture(): Fixture { const intendedMarker = join(root, 'intended-ran') const hijackMarker = join(root, 'hijack-ran') const codexMarker = join(root, 'codex-ran') + const postSnippetMarker = join(root, 'rcfile-continued') mkdirSync(hijackDir, { recursive: true }) mkdirSync(codexDir, { recursive: true }) mkdirSync(homePath, { recursive: true }) @@ -54,17 +64,33 @@ function buildFixture(): Fixture { // Why: /etc/profile runs first and macOS path_helper rebuilds PATH from /etc/paths, // so the profile must re-prepend both dirs to model a real user's dotfile winning. + const aliasLine = options.aliasCodex + ? `alias codex='GIT_AUTHOR_NAME=Codex codex --alias-flag'\n` + : '' writeFileSync( join(homePath, '.bash_profile'), - `export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n` + `export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n${aliasLine}` ) - return { root, resourcesPath, intendedMarker, hijackMarker, codexMarker, homePath } + return { + root, + resourcesPath, + intendedMarker, + hijackMarker, + codexMarker, + postSnippetMarker, + homePath + } } function launchCodexThroughRcfile(fixture: Fixture, preflightValue: string): void { const rcfilePath = join(fixture.root, 'rcfile') writeFileSync(rcfilePath, getDaemonBashShellReadyRcfileContent(), 'utf8') - execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', 'codex --version'], { + // Why the second command: the rcfile defines __orca_osc133_preexec *below* the + // codex wrapper, so its presence proves bash parsed past the snippet. + // Why the trailing `:` — the markers are this file's oracle, so a failed probe + // must surface as a missing marker, not an opaque non-zero exit from bash. + const command = `codex --version; declare -F __orca_osc133_preexec >/dev/null && printf ran > ${JSON.stringify(fixture.postSnippetMarker)}; :` + execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', command], { stdio: 'ignore', env: { HOME: fixture.homePath, @@ -114,6 +140,28 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH' expect(existsSync(fixture.codexMarker)).toBe(true) }) + // Why: a user alias named codex used to expand into the wrapper's own `codex()` + // header, and bash abandons the rest of the rcfile at that syntax error — taking + // the OSC 133 hooks and the shell-ready marker defined below it with it. + it('finishes the rcfile and preserves the alias when the user aliased the name codex', () => { + const fixture = buildFixture({ aliasCodex: true }) + const preflightCommand = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: join(fixture.root, 'codex-home'), + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: fixture.resourcesPath + }) + + launchCodexThroughRcfile(fixture, preflightCommand as string) + + expect(existsSync(fixture.postSnippetMarker)).toBe(true) + expect(existsSync(fixture.intendedMarker)).toBe(true) + expect(existsSync(fixture.hijackMarker)).toBe(false) + // The user's own alias must survive and still reach the binary. + expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version') + }) + // Why: pins the defect itself, so a regression back to an unqualified name fails here. it('would run the impostor if the preflight carried an unqualified command name', () => { const fixture = buildFixture() diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index e642c03dd77..2aff785446b 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -1,4 +1,12 @@ -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' @@ -11,6 +19,8 @@ import { } from './codex-shell-launch-preflight' const roots: string[] = [] +const zshAvailable = existsSync('/bin/zsh') +const bashAvailable = existsSync('/bin/bash') const fishAvailable = spawnSync('fish', ['--version']).status === 0 const pwshAvailable = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-Command', 'exit 0']).status === 0 @@ -69,6 +79,57 @@ function runAliasLaunch( ).trim() } +/** Launches a startup file that aliases the very name Orca wraps, then asserts the + * wrapper installed, the preflight ran, and the user's alias still applies. */ +function expectNamedAliasSurvives(shell: string, enableAliases: string): void { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-named-alias-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + const preflightMarker = join(root, 'preflight-ran') + writeExecutable( + join(bin, 'codex'), + '#!/bin/sh\nprintf "launched args=[%s] author=[%s]\\n" "$*" "$GIT_AUTHOR_NAME"\n' + ) + writeExecutable( + join(bin, 'orca-test'), + `#!/bin/sh\nprintf '%s' "$*" > ${JSON.stringify(preflightMarker)}\n` + ) + // Why nested in `if true`: the shell parses a whole compound command before + // running any of it, so the alias expands into the wrapper's own header. + const startup = join(root, 'startup.sh') + writeFileSync( + startup, + [ + enableAliases, + "alias codex='GIT_AUTHOR_NAME=Codex codex --alias-flag'", + 'if true; then', + getPosixCodexShellLaunchPreflight(), + 'fi', + 'printf "parsed\\n"', + 'codex' + ].join('\n') + ) + + const result = spawnSync( + shell, + shell.endsWith('/zsh') ? ['-f', startup] : ['--noprofile', '--norc', startup], + { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_CODEX_LAUNCH_PREFLIGHT: join(bin, 'orca-test') + } + } + ) + + expect(result.status, result.stderr).toBe(0) + // The alias's own flags and env prefix must still reach the binary. + expect(result.stdout).toBe('parsed\nlaunched args=[--alias-flag] author=[Codex]\n') + expect(readFileSync(preflightMarker, 'utf-8')).toBe('agent hooks prepare-codex') +} + afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) @@ -92,6 +153,14 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () expect(runAliasLaunch(root, getPosixCodexShellLaunchPreflight(), '/bin/zsh')).toBe('normal') }) + it.skipIf(!zshAvailable)('keeps a user alias named codex working in zsh', () => { + expectNamedAliasSurvives('/bin/zsh', 'setopt aliases') + }) + + it.skipIf(!bashAvailable)('keeps a user alias named codex working in bash', () => { + expectNamedAliasSurvives('/bin/bash', 'shopt -s expand_aliases') + }) + it('still launches Codex when the best-effort preflight fails', () => { const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-failure-')) roots.push(root) @@ -101,31 +170,35 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () ) }) - it.each([ + // Why a loop over it.each: an early `return` reported green on hosts without the + // shell, so the zsh half silently never ran on Linux. skipIf reports it as a skip. + for (const [shell, strict] of [ ['/bin/bash', 'set -e'], ['/bin/zsh', 'setopt ERR_EXIT'] - ])('keeps %s startup alive under strict error handling when Codex is absent', (shell, strict) => { - if (!existsSync(shell)) { - return - } - const root = mkdtempSync(join(tmpdir(), 'orca-codex-strict-startup-')) - roots.push(root) + ]) { + it.skipIf(!existsSync(shell))( + `keeps ${shell} startup alive under strict error handling when Codex is absent`, + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-strict-startup-')) + roots.push(root) - const output = execFileSync( - shell, - [ - shell.endsWith('/zsh') ? '-f' : '--noprofile', - '-c', - `${strict}\n${getPosixCodexShellLaunchPreflight()}\nprintf alive` - ], - { - encoding: 'utf-8', - env: { ...process.env, PATH: root, ORCA_CODEX_LAUNCH_PREFLIGHT: 'orca-test' } + const output = execFileSync( + shell, + [ + shell.endsWith('/zsh') ? '-f' : '--noprofile', + '-c', + `${strict}\n${getPosixCodexShellLaunchPreflight()}\nprintf alive` + ], + { + encoding: 'utf-8', + env: { ...process.env, PATH: root, ORCA_CODEX_LAUNCH_PREFLIGHT: 'orca-test' } + } + ) + + expect(output).toBe('alive') } ) - - expect(output).toBe('alive') - }) + } it.skipIf(!fishAvailable)('preserves a user-defined fish function', () => { const output = execFileSync( diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 9242eba0559..4b2429f50f1 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -60,9 +60,15 @@ function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolea export function getPosixCodexShellLaunchPreflight(): string { return `# Why: a typed alias expands inside the shell, after pane launch prep. -__orca_codex_binary="$(command -v codex 2>/dev/null || :)" +# Why unalias inside the substitution: an alias named codex makes command -v +# report the alias text, and the subshell leaves the user's own alias intact. +# Why || : twice — zsh alone aborts inside the substitution, but every shell's +# assignment adopts its exit status, so an absent codex trips set -e in bash too. +__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then - codex() { + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : command codex "$@" } diff --git a/src/main/pty/conda-activation-env.test.ts b/src/main/pty/conda-activation-env.test.ts new file mode 100644 index 00000000000..a7ba9d72b2d --- /dev/null +++ b/src/main/pty/conda-activation-env.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { dropIncoherentCondaActivationEnv } from './conda-activation-env' + +describe('dropIncoherentCondaActivationEnv', () => { + it('drops the activation group when the sentinel outlived CONDA_PREFIX', () => { + // Why: this exact shape is what makes `conda activate` raise + // "TypeError: expected str, bytes or os.PathLike object, not NoneType" (#14195). + const env: Record = { + CONDA_SHLVL: '1', + CONDA_DEFAULT_ENV: 'base', + CONDA_PROMPT_MODIFIER: '(base) ', + CONDA_PREFIX_1: '/opt/miniconda3', + CONDA_STACKED_2: 'true', + CONDA_EXE: '/opt/miniconda3/bin/conda', + CONDA_ROOT: '/opt/miniconda3', + CONDA_PYTHON_EXE: '/opt/miniconda3/bin/python', + CONDA_BAT: 'C:\\miniconda3\\condabin\\conda.bat', + _CE_CONDA: '', + _CE_M: '', + PATH: '/usr/bin' + } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined() + expect(env.CONDA_PREFIX_1).toBeUndefined() + expect(env.CONDA_STACKED_2).toBeUndefined() + // Discovery vars are installation state, not activation state: keeping them + // is what lets the shell's conda hook re-activate from a clean slate. + expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + expect(env.CONDA_ROOT).toBe('/opt/miniconda3') + expect(env.CONDA_PYTHON_EXE).toBe('/opt/miniconda3/bin/python') + expect(env.CONDA_BAT).toBe('C:\\miniconda3\\condabin\\conda.bat') + expect(env._CE_CONDA).toBe('') + expect(env._CE_M).toBe('') + expect(env.PATH).toBe('/usr/bin') + }) + + it('leaves a genuinely activated environment untouched', () => { + const env: Record = { + CONDA_SHLVL: '2', + CONDA_PREFIX: '/opt/miniconda3/envs/ml', + CONDA_PREFIX_1: '/opt/miniconda3', + CONDA_DEFAULT_ENV: 'ml', + CONDA_PROMPT_MODIFIER: '(ml) ' + } + + dropIncoherentCondaActivationEnv(env, 'darwin') + + expect(env).toEqual({ + CONDA_SHLVL: '2', + CONDA_PREFIX: '/opt/miniconda3/envs/ml', + CONDA_PREFIX_1: '/opt/miniconda3', + CONDA_DEFAULT_ENV: 'ml', + CONDA_PROMPT_MODIFIER: '(ml) ' + }) + }) + + it("leaves conda's own CONDA_SHLVL=0 hook-ran-nothing state alone", () => { + const env: Record = { CONDA_SHLVL: '0', CONDA_EXE: '/opt/conda/bin/conda' } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env).toEqual({ CONDA_SHLVL: '0', CONDA_EXE: '/opt/conda/bin/conda' }) + }) + + it('treats an empty CONDA_PREFIX as missing', () => { + const env: Record = { + CONDA_SHLVL: '1', + CONDA_PREFIX: '', + CONDA_DEFAULT_ENV: 'base' + } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env).toEqual({}) + }) + + it('matches case-variant keys on win32 only', () => { + const windowsEnv: Record = { + Conda_Shlvl: '1', + CONDA_Default_Env: 'base', + Conda_Prefix_1: 'C:\\miniconda3', + CONDA_EXE: 'C:\\miniconda3\\Scripts\\conda.exe' + } + + dropIncoherentCondaActivationEnv(windowsEnv, 'win32') + + expect(windowsEnv).toEqual({ CONDA_EXE: 'C:\\miniconda3\\Scripts\\conda.exe' }) + + // Why the same input is untouched on POSIX: a lowercase name is a different variable there. + const posixEnv: Record = { + Conda_Shlvl: '1', + CONDA_Default_Env: 'base', + Conda_Prefix_1: '/opt/miniconda3' + } + + dropIncoherentCondaActivationEnv(posixEnv, 'linux') + + expect(posixEnv).toEqual({ + Conda_Shlvl: '1', + CONDA_Default_Env: 'base', + Conda_Prefix_1: '/opt/miniconda3' + }) + }) + + it('leaves an environment with no conda state alone', () => { + const env: Record = { PATH: '/usr/bin', HOME: '/home/dev' } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env).toEqual({ PATH: '/usr/bin', HOME: '/home/dev' }) + }) + + it('ignores a non-numeric sentinel rather than guessing', () => { + const env: Record = { CONDA_SHLVL: 'weird', CONDA_DEFAULT_ENV: 'base' } + + dropIncoherentCondaActivationEnv(env, 'linux') + + expect(env).toEqual({ CONDA_SHLVL: 'weird', CONDA_DEFAULT_ENV: 'base' }) + }) +}) diff --git a/src/main/pty/conda-activation-env.ts b/src/main/pty/conda-activation-env.ts new file mode 100644 index 00000000000..f66b6ef7637 --- /dev/null +++ b/src/main/pty/conda-activation-env.ts @@ -0,0 +1,71 @@ +// Conda's activation state is a coupled group, not independent keys: CONDA_SHLVL +// is a sentinel asserting CONDA_PREFIX exists. Every other PTY env scrubber here +// treats keys one at a time, so a half-activated host env (a client envToDelete, +// the wsl.exe/WSLENV boundary, a relay launched from a half-activated login +// shell, a launchd/PAM env reshuffle) is forwarded verbatim and `conda activate` +// dies in `_get_deactivate_scripts(None)` with a TypeError (#14195). + +/** Activation state: meaningful only while CONDA_PREFIX names an active env. */ +const ACTIVATION_STATE_KEYS = [ + 'CONDA_SHLVL', + 'CONDA_PREFIX', + 'CONDA_DEFAULT_ENV', + 'CONDA_PROMPT_MODIFIER' +] as const + +/** Stack depth is unbounded (`CONDA_PREFIX_1`, `CONDA_STACKED_2`, ...). */ +const POSIX_STACK_KEY = /^(?:CONDA_PREFIX|CONDA_STACKED)_\d+$/ +const WINDOWS_STACK_KEY = /^(?:CONDA_PREFIX|CONDA_STACKED)_\d+$/i + +/** + * Drops conda's activation-state group when the sentinel survived but the prefix + * did not, so the shell's conda hook starts from a clean slate instead of + * crashing. Installation/discovery vars (CONDA_EXE, CONDA_ROOT, CONDA_BAT, + * CONDA_PYTHON_EXE, _CE_CONDA, _CE_M) are preserved so `conda` still resolves. + * + * Why delete-only, never synthesize: the reverse shape (prefix without sentinel) + * does not crash — conda just reads it as inactive — and Orca cannot verify the + * prefix directory exists on the executing host (it routinely does not for WSL + * and SSH), so inventing activation state would be worse than leaving it. + */ +export function dropIncoherentCondaActivationEnv( + env: Record, + platform: NodeJS.Platform = process.platform +): void { + const windows = platform === 'win32' + // Why: PTY spawn is a hot path; one exact-key miss covers every non-conda posix env. + if (!windows && env.CONDA_SHLVL === undefined) { + return + } + const keys = Object.keys(env) + // Why case-insensitive on win32 only: Windows env names are case-insensitive, so a + // conda install or PowerShell profile may spell them `Conda_Shlvl` (same reasoning as + // stripLegacyTerminalShimEnv). On POSIX a lowercase name is a different variable. + const findKey = (name: string): string | undefined => { + if (!windows) { + return env[name] === undefined ? undefined : name + } + const lowered = name.toLowerCase() + return keys.find((key) => key.toLowerCase() === lowered) + } + + const shlvlKey = findKey('CONDA_SHLVL') + // CONDA_SHLVL=0 (or unparsable) is conda's own hook-ran-nothing-active state. + if (shlvlKey === undefined || !(Number.parseInt(env[shlvlKey] ?? '', 10) > 0)) { + return + } + const prefixKey = findKey('CONDA_PREFIX') + if (prefixKey !== undefined && env[prefixKey] !== '') { + return + } + + const stateKeys = new Set( + windows ? ACTIVATION_STATE_KEYS.map((key) => key.toLowerCase()) : ACTIVATION_STATE_KEYS + ) + const stackKey = windows ? WINDOWS_STACK_KEY : POSIX_STACK_KEY + for (const key of keys) { + if (stateKeys.has(windows ? key.toLowerCase() : key) || stackKey.test(key)) { + delete env[key] + } + } +} diff --git a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts new file mode 100644 index 00000000000..3863b3aacaf --- /dev/null +++ b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts @@ -0,0 +1,70 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getPosixOmpShellWrapper } from './omp-shell-wrapper' + +const roots: string[] = [] +const zshAvailable = existsSync('/bin/zsh') +const bashAvailable = existsSync('/bin/bash') + +/** Sources the omp wrapper from a startup file that already aliased `omp`, then + * asserts the file parsed to its end and the alias still reaches the binary. */ +function expectAliasedOmpNameSurvives(shell: string, enableAliases: string): void { + const root = mkdtempSync(join(tmpdir(), 'orca-omp-alias-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'omp'), '#!/bin/sh\nprintf "omp args=[%s]\\n" "$*"\n', { mode: 0o755 }) + const extension = join(root, 'status-extension') + writeFileSync(extension, '') + + const startup = join(root, 'startup.sh') + writeFileSync( + startup, + [ + enableAliases, + "alias omp='omp config --alias-flag'", + getPosixOmpShellWrapper(), + 'printf "parsed\\n"', + 'omp' + ].join('\n') + ) + + const result = spawnSync( + shell, + shell.endsWith('/zsh') ? ['-f', startup] : ['--noprofile', '--norc', startup], + { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_OMP_STATUS_EXTENSION: extension + } + } + ) + + expect(result.status, result.stderr).toBe(0) + // `parsed` proves the shell got past the wrapper instead of abandoning the file. + expect(result.stdout).toBe('parsed\nomp args=[config --alias-flag]\n') +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +// Why: a user alias named omp used to expand into the wrapper's own `omp()` +// header, and the shell abandons the rest of the startup file at that syntax +// error — taking every hook Orca defines below the wrapper with it. +describe.skipIf(process.platform === 'win32')('omp wrapper under a user alias named omp', () => { + it.skipIf(!bashAvailable)('keeps a user alias named omp working in bash', () => { + expectAliasedOmpNameSurvives('/bin/bash', 'shopt -s expand_aliases') + }) + + it.skipIf(!zshAvailable)('keeps a user alias named omp working in zsh', () => { + expectAliasedOmpNameSurvives('/bin/zsh', 'setopt aliases') + }) +}) diff --git a/src/main/pty/omp-shell-wrapper.ts b/src/main/pty/omp-shell-wrapper.ts index 48f50aa5585..be94a9c088f 100644 --- a/src/main/pty/omp-shell-wrapper.ts +++ b/src/main/pty/omp-shell-wrapper.ts @@ -66,7 +66,9 @@ __orca_omp() { fi } if [[ -n "\${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then - omp() { __orca_omp "$@"; } + # Why the function reserved word: it suppresses alias expansion of the name, which + # an \`alias omp\` otherwise rewrites at parse time, aborting the rest of the file. + function omp { __orca_omp "$@"; } fi ` } diff --git a/src/main/quit-path-durable-write-blocking.test.ts b/src/main/quit-path-durable-write-blocking.test.ts index 35ebe7460c3..58e055c5297 100644 --- a/src/main/quit-path-durable-write-blocking.test.ts +++ b/src/main/quit-path-durable-write-blocking.test.ts @@ -4,6 +4,7 @@ import type * as NodeFs from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously, // before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park @@ -115,6 +116,8 @@ async function createStore(dir: string): Promise { testState.dir = dir vi.resetModules() const { Store, initDataPath } = await import('./persistence') + // Why: userData resolves through AppEnvironment; point it at this file's temp dir. + installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return new Store() as unknown as TestStore } diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts new file mode 100644 index 00000000000..84c92e1ad22 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-mirror.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' +import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' + +function geminiSnapshot( + status: ProviderRateLimitStatus, + error: string | null, + usedPercent: number | null = null +): ProviderRateLimits { + return { + provider: 'gemini', + session: + usedPercent === null + ? null + : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: 1_700_000_000_000, + error, + status + } +} + +describe('deriveAntigravityRateLimits', () => { + it('mirrors a successful Gemini read as shared Code Assist quota', () => { + const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) + + expect(antigravity.provider).toBe('antigravity') + expect(antigravity.status).toBe('ok') + expect(antigravity.session?.usedPercent).toBe(42) + expect(antigravity.error).toBeNull() + }) + + it('reports unavailable without quoting the Gemini failure', () => { + const antigravity = deriveAntigravityRateLimits( + geminiSnapshot('error', 'Gemini project ID not found') + ) + + expect(antigravity.provider).toBe('antigravity') + expect(antigravity.status).toBe('unavailable') + expect(antigravity.error).not.toContain('Gemini project ID not found') + expect(antigravity.error).toContain('Antigravity usage is not available') + expect(antigravity.session).toBeNull() + expect(antigravity.weekly).toBeNull() + }) + + it('does not blame a missing sign-in when the quota read itself failed', () => { + const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) + + // Why: the reported symptom is a connected sign-in whose Code Assist read failed. + expect(antigravity.error).toContain('could not be read right now') + expect(antigravity.error).not.toContain('sign-in is connected') + }) + + it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { + const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) + + expect(antigravity.updatedAt).toBe(1_700_000_000_000) + }) + + it('points at the missing sign-in when the Gemini opt-in is off', () => { + const antigravity = deriveAntigravityRateLimits( + geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') + ) + + expect(antigravity.status).toBe('unavailable') + expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') + expect(antigravity.error).toContain('Antigravity usage is not available') + expect(antigravity.error).toContain('Gemini CLI sign-in is connected') + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts new file mode 100644 index 00000000000..3cea6decc11 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-mirror.ts @@ -0,0 +1,29 @@ +import type { ProviderRateLimits } from '../../shared/rate-limit-types' + +// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini +// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read +// describes shared Google Code Assist quota; republishing a Gemini failure under the +// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. +const ANTIGRAVITY_NO_SIGN_IN_REASON = + 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' +// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. +const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = + 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' + +export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { + if (gemini.status === 'ok') { + return { ...gemini, provider: 'antigravity' } + } + return { + provider: 'antigravity', + session: null, + weekly: null, + // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. + updatedAt: gemini.updatedAt, + error: + gemini.status === 'unavailable' + ? ANTIGRAVITY_NO_SIGN_IN_REASON + : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, + status: 'unavailable' + } +} diff --git a/src/main/rate-limits/gemini-cli-oauth-extractor.ts b/src/main/rate-limits/gemini-cli-oauth-extractor.ts index 80e841e7adf..7b5d5e7e3f3 100644 --- a/src/main/rate-limits/gemini-cli-oauth-extractor.ts +++ b/src/main/rate-limits/gemini-cli-oauth-extractor.ts @@ -1,10 +1,10 @@ -import { exec } from 'node:child_process' +import { execFile } from 'node:child_process' import { access, readdir, readFile, realpath } from 'node:fs/promises' import { promisify } from 'node:util' import { homedir } from 'node:os' import path from 'node:path' -const execAsync = promisify(exec) +const execFileAsync = promisify(execFile) async function fileExists(filePath: string): Promise { try { @@ -19,9 +19,16 @@ async function fileExists(filePath: string): Promise { const OAUTH2_SUBPATH = path.join('dist', 'src', 'code_assist', 'oauth2.js') async function resolveGeminiBinary(): Promise { - const whichCmd = process.platform === 'win32' ? 'where gemini' : 'which gemini' + const [lookup, args] = + process.platform === 'win32' ? ['where.exe', ['gemini']] : ['which', ['gemini']] try { - const { stdout } = await execAsync(whichCmd, { encoding: 'utf-8' }) + // execFile, not exec: `exec` implies `shell: true`, which silently makes + // windowsHide a no-op (see run-process.ts, #14543), so the console-subsystem + // `where` would still flash a conhost (#10488). + const { stdout } = await execFileAsync(lookup, args, { + encoding: 'utf-8', + windowsHide: true + }) const fromPath = stdout.trim().split(/\r?\n/)[0] if (fromPath && (await fileExists(fromPath))) { return fromPath diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts new file mode 100644 index 00000000000..b617d909944 --- /dev/null +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RateLimitService } from './service' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchCodexRateLimits } from './codex-fetcher' +import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { + errorProvider, + okProvider, + resetRateLimitProviderMocks +} from './rate-limit-service-test-harness' + +vi.mock('./claude-fetcher', () => ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: vi.fn() +})) + +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: vi.fn() +})) + +vi.mock('./opencode-go-usage-fetcher', () => ({ + fetchOpenCodeGoRateLimits: vi.fn() +})) + +vi.mock('./minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: vi.fn() +})) + +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + +describe('RateLimitService Antigravity usage', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + }) + + it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue( + errorProvider('gemini', 'Gemini project ID not found') + ) + const service = new RateLimitService() + + await service.refresh() + + const state = service.getState() + expect(state.antigravity?.status).toBe('unavailable') + expect(state.antigravity?.error).not.toContain('Gemini project ID not found') + expect(state.antigravity?.session).toBeNull() + // Why: the real Gemini failure must still surface under its own provider. + expect(state.gemini?.status).toBe('error') + expect(state.gemini?.error).toBe('Gemini project ID not found') + }) + + it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + const service = new RateLimitService() + + await service.refresh() + + const state = service.getState() + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.provider).toBe('antigravity') + expect(state.antigravity?.session?.usedPercent).toBe(42) + }) + + it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + const service = new RateLimitService() + await service.refresh() + + vi.mocked(fetchGeminiRateLimits).mockResolvedValue( + errorProvider('gemini', 'Token refresh failed') + ) + await service.refresh() + + // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. + expect(service.getState().antigravity?.status).toBe('unavailable') + expect(service.getState().antigravity?.session).toBeNull() + }) +}) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 02bd1930bb6..d6f291d6b38 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -20,6 +20,7 @@ import { type NormalizedClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' import { fetchKimiRateLimits } from './kimi-fetcher' import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' import { fetchGrokRateLimits } from './grok-fetcher' @@ -1766,11 +1767,8 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity shares Gemini credentials today; mirror the Gemini snapshot so its status-bar UI gets a real lifecycle instead of null. - const antigravity: ProviderRateLimits = { - ...gemini, - provider: 'antigravity' - } + // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. + const antigravity = deriveAntigravityRateLimits(gemini) const opencodeGo = opencodeGoResult.status === 'fulfilled' diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index 226d689a537..d1ff9ee24ca 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({ getSshFilesystemProvider: vi.fn() })) -vi.mock('./ipc/ssh', () => ({ +vi.mock('./ssh/ssh-target-registry', () => ({ getActiveMultiplexer: mocks.getActiveMultiplexer })) diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index a88aaceae58..b5eaaf6d2c0 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -1,6 +1,6 @@ import type { AgentTrustPreset } from './agent-trust-presets' import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' -import { getActiveMultiplexer } from './ipc/ssh' +import { getActiveMultiplexer } from './ssh/ssh-target-registry' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' import { diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index ce19f22a3ca..711e887b5c5 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_SUBMIT_DELAY_MS +} from '../../shared/agent-prompt-injection' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' @@ -673,7 +676,10 @@ describe('agent prompt submission runtime', () => { }) const rejected = expect(submission).rejects.toThrow('request_aborted') - await vi.advanceTimersByTimeAsync(500) + // Why: the submit delay is 1_500 on Windows (ConPTY); a hardcoded 500 aborts before the Enter there. + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_DELAY_MS) + // Why: pin the phase boundary so drift fails here instead of as an empty post-abort array. + expect(writes.filter((data) => data === '\r')).toHaveLength(1) controller.abort() await vi.runAllTimersAsync() diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts new file mode 100644 index 00000000000..fdb90700de1 --- /dev/null +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as AgentPromptInjection from '../../shared/agent-prompt-injection' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +// Why: vi.mock factories are hoisted above module-scope consts, so the delay must be hoisted too. +const { WINDOWS_SUBMIT_DELAY_MS } = vi.hoisted(() => ({ WINDOWS_SUBMIT_DELAY_MS: 1_500 })) +const WORKTREE_PATH = '/tmp/worktree-a' + +// Why: AGENT_PROMPT_SUBMIT_DELAY_MS is frozen from process.platform at import, so the 1_500 ConPTY +// path is otherwise unreachable on the Linux/macOS lanes that run this suite. +vi.mock('../../shared/agent-prompt-injection', async (importOriginal) => ({ + ...(await importOriginal()), + AGENT_PROMPT_SUBMIT_DELAY_MS: WINDOWS_SUBMIT_DELAY_MS +})) + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]) +})) + +// Why: 'aider' is not a settlement agent, so submission takes the fixed-delay branch under test. +async function createPromptRuntime(): Promise<{ + runtime: OrcaRuntimeService + handle: string + writes: string[] +}> { + const runtime = new OrcaRuntimeService(makeStore() as never) + const writes: string[] = [] + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { + launchAgent: 'aider' + }) + return { runtime, handle: terminal.handle, writes } +} + +function countSubmits(writes: readonly string[]): number { + return writes.filter((data) => data === '\r').length +} + +describe('agent prompt submission at the Windows submit delay', () => { + afterEach(() => vi.useRealTimers()) + + it('holds Enter until the full platform delay elapses', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime() + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(WINDOWS_SUBMIT_DELAY_MS - 1) + expect(countSubmits(writes)).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(countSubmits(writes)).toBe(1) + + await vi.runAllTimersAsync() + await stalled + }) + + it('does not send another Enter after cancellation during verification', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime() + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + signal: controller.signal + }) + const rejected = expect(submission).rejects.toThrow('request_aborted') + + await vi.advanceTimersByTimeAsync(WINDOWS_SUBMIT_DELAY_MS) + expect(countSubmits(writes)).toBe(1) + controller.abort() + await vi.runAllTimersAsync() + + await rejected + expect(countSubmits(writes)).toBe(1) + }) +}) diff --git a/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts b/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts new file mode 100644 index 00000000000..b26c7bfb62c --- /dev/null +++ b/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts @@ -0,0 +1,106 @@ +/** + * disposeHeadlessTerminal's unwritten contract: no model write is ever dropped. + * + * Two halves, both load-bearing and both silently reversible: + * 1. `headlessTerminals.delete(ptyId)` runs BEFORE disposal, so a write + * issued after the call lazily creates a fresh emulator instead of + * landing on the disposing one. + * 2. `dispose()` is registered on the CURRENT writeChain, so links already + * queued behind it parse before the emulator goes away. + * Swapping either ordering loses output with no error anywhere. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' + +const store = { + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true + }) +} + +const PTY_ID = 'pty-dispose-ordering' + +function createRuntime(): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: 80, rows: 24 }), + resize: () => true + }) + return runtime +} + +describe('disposeHeadlessTerminal write ordering', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('parses a write queued before disposal, then routes later writes to a fresh emulator', async () => { + const events: string[] = [] + const emulators: HeadlessEmulator[] = [] + const write = HeadlessEmulator.prototype.write + const dispose = HeadlessEmulator.prototype.dispose + vi.spyOn(HeadlessEmulator.prototype, 'write').mockImplementation(async function ( + this: HeadlessEmulator, + data: string, + opts + ) { + if (!emulators.includes(this)) { + emulators.push(this) + } + events.push(`write:${emulators.indexOf(this)}:${data.trim()}`) + return write.call(this, data, opts) + }) + vi.spyOn(HeadlessEmulator.prototype, 'dispose').mockImplementation( + function (this: HeadlessEmulator) { + events.push(`dispose:${emulators.indexOf(this)}`) + return dispose.call(this) + } + ) + + const runtime = createRuntime() + // Queued but not yet parsed: the chain link is still pending here. + runtime.onPtyData(PTY_ID, 'QUEUED-BEFORE-DISPOSE\r\n', 1) + runtime.resetPtyModelAfterMigrationFailure(PTY_ID) + runtime.onPtyData(PTY_ID, 'ISSUED-AFTER-DISPOSE\r\n', 2) + await runtime.serializeHiddenOutputRecoveryBuffer(PTY_ID) + + expect(events).toEqual([ + 'write:0:QUEUED-BEFORE-DISPOSE', + 'write:1:ISSUED-AFTER-DISPOSE', + 'dispose:0' + ]) + }) + + it('leaves the post-disposal emulator holding only post-disposal output', async () => { + const runtime = createRuntime() + runtime.onPtyData(PTY_ID, 'QUEUED-BEFORE-DISPOSE\r\n', 1) + runtime.resetPtyModelAfterMigrationFailure(PTY_ID) + runtime.onPtyData(PTY_ID, 'ISSUED-AFTER-DISPOSE\r\n', 2) + + const snapshot = await runtime.serializeHiddenOutputRecoveryBuffer(PTY_ID) + const painted = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}` + expect(painted).toContain('ISSUED-AFTER-DISPOSE') + expect(painted).not.toContain('QUEUED-BEFORE-DISPOSE') + }) +}) diff --git a/src/main/runtime/mobile-pairing-userdata-path.test.ts b/src/main/runtime/mobile-pairing-userdata-path.test.ts index be13c1ebbc3..441b5a57eb2 100644 --- a/src/main/runtime/mobile-pairing-userdata-path.test.ts +++ b/src/main/runtime/mobile-pairing-userdata-path.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' // Import from the production source of truth so a filename rename can't silently // pass these tests against stale names. import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' // Mutable userData the electron mock resolves. We flip it mid-test to simulate // app.setName('Orca') changing how app.getPath('userData') resolves (e.g. from @@ -15,8 +16,12 @@ import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairin // of whether the test host's filesystem is case-sensitive. const appState = { userData: '' } +// Why the port for getPath: userData now resolves through AppEnvironment, so an +// electron mock would be inert here. safeStorage is still mocked because the modules +// under test seal through it directly. +installFakeAppEnvironment({ getPath: () => appState.userData }) + vi.mock('electron', () => ({ - app: { getPath: () => appState.userData }, safeStorage: { isEncryptionAvailable: () => false, encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), @@ -38,6 +43,8 @@ describe('mobile pairing userData path stability', () => { lateDir = join(root, 'userdata-late') mkdirSync(canonicalDir, { recursive: true }) mkdirSync(lateDir, { recursive: true }) + // Why re-install: the global setup's beforeEach reinstates its own fake. + installFakeAppEnvironment({ getPath: () => appState.userData }) vi.resetModules() }) @@ -55,8 +62,9 @@ describe('mobile pairing userData path stability', () => { appState.userData = lateDir expect(getCanonicalUserDataPath()).toBe(canonicalDir) - const { app } = await import('electron') - expect(getCanonicalUserDataPath()).not.toBe(app.getPath('userData')) + // Why the port: this is the "resolve late" path the captured value must differ from. + const { getAppEnvironment } = await import('../../shared/app-environment') + expect(getCanonicalUserDataPath()).not.toBe(getAppEnvironment().getPath('userData')) }) it('writes DeviceRegistry + E2EE keypair under the canonical path, not the late one', async () => { diff --git a/src/main/runtime/orca-runtime-agent-skill-share.test.ts b/src/main/runtime/orca-runtime-agent-skill-share.test.ts index 4d8c3e42cca..e7b643b2019 100644 --- a/src/main/runtime/orca-runtime-agent-skill-share.test.ts +++ b/src/main/runtime/orca-runtime-agent-skill-share.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type { AgentSkillShareRequest } from '../../shared/agent-skill-sharing-contract' import { getDefaultSettings } from '../../shared/constants' import type { DiscoveredSkill } from '../../shared/skills' @@ -120,6 +121,7 @@ function runtimeWithCloud(options: { } beforeEach(async () => { + installFakeAppEnvironment({ getPath: () => mocks.userDataPath }) testRoot = await mkdtemp(join(tmpdir(), 'orca-agent-skill-share-')) mocks.userDataPath = testRoot }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index fa9041d0ae5..3c7e27b062c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,5 +1,9 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { RuntimeBrowserCommands } from './orca-runtime-browser' +import { setRuntimeBrowserCommandsFactory } from './runtime-browser-commands-factory' +import { setRuntimeDesktopSurface } from './runtime-desktop-surface' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type * as GitUsernameModule from '../git/git-username' import { performance } from 'node:perf_hooks' import { EventEmitter } from 'node:events' @@ -132,6 +136,7 @@ import { setTerminalViewAttributes } from './terminal-view-attribute-store' import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' +import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') @@ -215,14 +220,17 @@ const forgetRemoteWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn()) const scanLocalRepoWorktreesForResolutionMock = vi.hoisted(() => vi.fn()) vi.mock('electron', () => electronMocks) -vi.mock('../ipc/filesystem-watcher', () => ({ - closeLocalWatcherForWorktreePath: closeLocalWatcherForWorktreePathMock, - closeRemoteWatcherForWorktreePath: closeRemoteWatcherForWorktreePathMock, - restoreLocalWatcherAfterFailedRemoval: restoreLocalWatcherAfterFailedRemovalMock, - restoreRemoteWatcherAfterFailedRemoval: restoreRemoteWatcherAfterFailedRemovalMock, - forgetLocalWatcherRemovalSnapshot: forgetLocalWatcherRemovalSnapshotMock, - forgetRemoteWatcherRemovalSnapshot: forgetRemoteWatcherRemovalSnapshotMock -})) +// Why install the port instead of mocking ../ipc/filesystem-watcher: the runtime calls +// WorktreeWatcherRemoval now, so a module mock would be inert and every assertion below +// would silently pass against the inert default. Same mocks, same expectations. +setWorktreeWatcherRemoval({ + closeLocal: closeLocalWatcherForWorktreePathMock, + closeRemote: closeRemoteWatcherForWorktreePathMock, + restoreLocal: restoreLocalWatcherAfterFailedRemovalMock, + restoreRemote: restoreRemoteWatcherAfterFailedRemovalMock, + forgetLocal: forgetLocalWatcherRemovalSnapshotMock, + forgetRemote: forgetRemoteWatcherRemovalSnapshotMock +}) const { MOCK_GIT_WORKTREES, @@ -456,12 +464,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ unregisterSshGitProvider: unregisterSshGitProviderMock })) -vi.mock('../ipc/ssh', () => ({ +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: getActiveMultiplexerMock, getRegisteredSshState: () => ({ remotePlatform: 'linux' }) })) -vi.mock('../ipc/preflight', () => ({ +vi.mock('../preflight/agent-detection', () => ({ detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock })) @@ -675,8 +683,29 @@ vi.mock('../git/git-username', async () => { }) function resetRuntimeTestMocks(): void { + // Why: constructing the browser commands is what pulls the Chromium cluster in, so + // production installs this at the Electron entry. A Node host installs none and the + // browser RPCs reject rather than silently succeeding. + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) + // Why: the runtime's notification, window lookup and tab-create-reply channel are + // injected now, so the electron mock alone is inert. Back the surface with the same + // mocks so every existing expectation still holds. + setRuntimeDesktopSurface({ + showNotification: () => true, + findWindowById: (id) => electronMocks.BrowserWindow.fromId(id) as never, + onIpc: (channel, listener) => electronMocks.ipcMain.on(channel, listener as never), + removeIpcListener: (channel, listener) => + electronMocks.ipcMain.removeListener(channel, listener as never) + }) resetPlatform() electronMocks.app.isPackaged = false + // Why here and not the electron mock: the runtime reads paths and the packaged flag + // through the AppEnvironment port now, so the electron mock alone is inert. Reading + // electronMocks.app keeps the existing per-test toggles below working unchanged. + installFakeAppEnvironment({ + getPath: () => electronMocks.app.getPath(), + isPackaged: () => electronMocks.app.isPackaged + }) clearConfiguredWorktreeSharedDirectoriesCacheForTests() _resetTerminalViewAttributesForTest() advertisedUrlWatcher.clear() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index acba1a26aca..8ddbfa4fdf8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -371,7 +371,7 @@ import { type ExecutionHostId } from '../../shared/execution-host' import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' -import { getRegisteredSshState } from '../ipc/ssh' +import { getRegisteredSshState } from '../ssh/ssh-target-registry' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -570,7 +570,10 @@ import { } from '../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight' +import { + detectInstalledAgentsWithShellPathHydration, + detectRemoteAgents +} from '../preflight/agent-detection' import { markCodexProjectTrusted, markCopilotFolderTrusted, @@ -653,7 +656,8 @@ import { } from '../ports/workspace-port-ownership' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import type { AutomationService } from '../automations/service' -import { RuntimeBrowserCommands } from './orca-runtime-browser' +import type { RuntimeBrowserCommands } from './orca-runtime-browser' +import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { @@ -727,11 +731,13 @@ import { } from '../../shared/claude-agent-teams-tmux-compat' import { joinWorktreeRelativePath } from './runtime-relative-paths' import { collectMemorySnapshot } from '../memory/collector' -import { app, BrowserWindow, ipcMain, Notification } from 'electron' +import type { BrowserWindow } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' +import { getRuntimeDesktopSurface } from './runtime-desktop-surface' import { RendererPublicationThrottle } from '../window/renderer-publication-throttle' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' -import { BrowserError } from '../browser/cdp-bridge' +import { BrowserError } from '../browser/browser-error' import { getPRForBranch, getPRForBranchOutcome, @@ -1130,14 +1136,7 @@ import { } from '../worktree-removal-repo-owner' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' -import { - closeLocalWatcherForWorktreePath, - closeRemoteWatcherForWorktreePath, - forgetLocalWatcherRemovalSnapshot, - forgetRemoteWatcherRemovalSnapshot, - restoreLocalWatcherAfterFailedRemoval, - restoreRemoteWatcherAfterFailedRemoval -} from '../ipc/filesystem-watcher' +import { getWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' import { createWatcherRemovalDeadline, @@ -3921,7 +3920,7 @@ export class OrcaRuntimeService { return } await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, { - shouldHydrateShellPath: app.isPackaged, + shouldHydrateShellPath: getAppEnvironment().isPackaged(), onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const current = this.store?.getSettings() @@ -4229,13 +4228,12 @@ export class OrcaRuntimeService { return { projectId, workspaceMode: 'new_per_run', workspaceId: null } } - // Why: lazy initialization — the DB path depends on Electron's userData - // which may not be finalized until after app.ready. Also allows unit tests - // to inject an in-memory DB without touching the filesystem. + // Why: lazy initialization — the DB path depends on userData, which on the desktop + // is not finalized until after app.ready. Also allows unit tests to inject an + // in-memory DB without touching the filesystem. getOrchestrationDb(): OrchestrationDb { if (!this._orchestrationDb) { - const { app } = require('electron') - const dbPath = join(app.getPath('userData'), 'orchestration.db') + const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db') this._orchestrationDb = new OrchestrationDb(dbPath) this.ensureOrchestrationFederationRelay() this.scheduleRestoredMessageRepoints() @@ -4996,7 +4994,10 @@ export class OrcaRuntimeService { signal?: AbortSignal ): Promise { const selectedSkills = selectDiscoveredSkills(discoveredSkills, request.skillSelectors) - const operationRoot = join(app.getPath('userData'), 'agent-skill-share-operations') + const operationRoot = join( + getAppEnvironment().getPath('userData'), + 'agent-skill-share-operations' + ) const cloud = this.requireSkillCloudService() const preparations = new SkillSharePreparationService( operationRoot, @@ -5005,7 +5006,7 @@ export class OrcaRuntimeService { createShare: (packageId, input) => cloud.createShare(packageId, input) }, { - installStateDirectory: join(app.getPath('userData'), 'skill-installs') + installStateDirectory: join(getAppEnvironment().getPath('userData'), 'skill-installs') } ) let preparationId: string | null = null @@ -5212,7 +5213,7 @@ export class OrcaRuntimeService { if (sshTarget) { return installSkillBundleOnSshHost({ provider: sshTarget.provider, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), request: { ...request, destination: @@ -5221,14 +5222,14 @@ export class OrcaRuntimeService { : request.destination }, workspace: sshTarget.workspace, - requireHttps: app.isPackaged, + requireHttps: getAppEnvironment().isPackaged(), signal: controller.signal, onProgress: reportProgress }) } await this.skillTransactionRecovery const allowedDownloadOrigins = ['https://storage.googleapis.com'] - if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { + if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { allowedDownloadOrigins.push( ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') .map((origin) => origin.trim()) @@ -5237,9 +5238,9 @@ export class OrcaRuntimeService { } return await executeSkillBundleInstallRequest(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], - requireHttps: app.isPackaged, + requireHttps: getAppEnvironment().isPackaged(), resolveStagedUpload: (uploadId, identity) => this.requireSkillUploadSessions().take(uploadId, identity), detectProviders: detectInstalledAgentsWithShellPathHydration, @@ -5276,7 +5277,7 @@ export class OrcaRuntimeService { if (sshTarget) { return installSkillOnSshHost({ provider: sshTarget.provider, - userDataPath: app.getPath('userData'), + userDataPath: getAppEnvironment().getPath('userData'), request: { ...request, destination: @@ -5285,13 +5286,13 @@ export class OrcaRuntimeService { : request.destination }, workspace: sshTarget.workspace, - requireHttps: app.isPackaged, + requireHttps: getAppEnvironment().isPackaged(), signal }) } await this.skillTransactionRecovery const allowedDownloadOrigins = ['https://storage.googleapis.com'] - if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { + if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { allowedDownloadOrigins.push( ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') .map((origin) => origin.trim()) @@ -5300,9 +5301,9 @@ export class OrcaRuntimeService { } return executeSkillInstallRequest(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], - requireHttps: app.isPackaged, + requireHttps: getAppEnvironment().isPackaged(), resolveStagedUpload: (uploadId, identity) => this.requireSkillUploadSessions().take(uploadId, identity), detectProviders: detectInstalledAgentsWithShellPathHydration, @@ -5333,7 +5334,7 @@ export class OrcaRuntimeService { await this.skillTransactionRecovery return previewSharedSkillInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), detectProviders: detectInstalledAgentsWithShellPathHydration, resolveProviderRootOverrides: (destination) => this.resolveSkillProviderRootOverrides(destination) @@ -5361,7 +5362,7 @@ export class OrcaRuntimeService { const runtimeId = this.getStatus().runtimeId return previewSharedSkillBundleInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), detectProviders: detectInstalledAgentsWithShellPathHydration, resolveProviderRootOverrides: (destination) => this.resolveSkillProviderRootOverrides(destination) @@ -5387,7 +5388,7 @@ export class OrcaRuntimeService { await this.skillTransactionRecovery return removeSharedSkillInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: app.getPath('userData'), + stateDirectory: getAppEnvironment().getPath('userData'), detectProviders: detectInstalledAgentsWithShellPathHydration, resolveProviderRootOverrides: (destination) => this.resolveSkillProviderRootOverrides(destination) @@ -5406,7 +5407,7 @@ export class OrcaRuntimeService { await this.skillTransactionRecovery const runtimeId = this.getStatus().runtimeId const [installs, worktrees] = await Promise.all([ - listManagedSkillInstalls(join(app.getPath('userData'), 'skill-installs'), { + listManagedSkillInstalls(join(getAppEnvironment().getPath('userData'), 'skill-installs'), { observeReceipt: async (receipt) => { if (!receipt.wslDistro) { return nativeSkillInstallFilesystem.observeSkill( @@ -5702,7 +5703,11 @@ export class OrcaRuntimeService { throw new Error('skill-upload-service-disposed') } this.skillUploadSessions ??= new SkillUploadSessionService( - join(app.getPath('userData'), 'skill-installs', SKILL_UPLOAD_STAGING_ROOT_NAME) + join( + getAppEnvironment().getPath('userData'), + 'skill-installs', + SKILL_UPLOAD_STAGING_ROOT_NAME + ) ) return this.skillUploadSessions } @@ -10222,11 +10227,11 @@ export class OrcaRuntimeService { const results = await Promise.allSettled([ connectionId ? drainBeforeWatcherRemoval( - closeRemoteWatcherForWorktreePath(connectionId, worktreePath), + getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath), deadline, `remote watcher close for ${worktreePath}` ) - : closeLocalWatcherForWorktreePath(worktreePath, deadline), + : getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline), drainBeforeWatcherRemoval( this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId), deadline, @@ -10248,16 +10253,16 @@ export class OrcaRuntimeService { ): Promise => { await Promise.all([ connectionId - ? restoreRemoteWatcherAfterFailedRemoval(connectionId, worktreePath) - : restoreLocalWatcherAfterFailedRemoval(worktreePath), + ? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath) + : getWorktreeWatcherRemoval().restoreLocal(worktreePath), this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId) ]) } forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => { if (connectionId) { - forgetRemoteWatcherRemovalSnapshot(connectionId, worktreePath) + getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath) } else { - forgetLocalWatcherRemovalSnapshot(worktreePath) + getWorktreeWatcherRemoval().forgetLocal(worktreePath) } this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId) } @@ -14352,13 +14357,9 @@ export class OrcaRuntimeService { const body = input.body ?? '' let delivered = false try { - if (Notification.isSupported()) { - new Notification({ title, body }).show() - delivered = true - } + delivered = getRuntimeDesktopSurface().showNotification({ title, body }) } catch { - // Headless serve has no notification display; the mobile relay below - // still runs. + // A host with no notification display still relays to paired clients below. } this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body }) return { delivered } @@ -28390,7 +28391,7 @@ export class OrcaRuntimeService { // creates the tab and replies with the tabId so we can resolve the handle. const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) reject(new Error('Terminal creation timed out')) }, 10_000) @@ -28402,14 +28403,14 @@ export class OrcaRuntimeService { return } clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) if (r.error) { reject(new Error(r.error)) } else { resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' }) } } - ipcMain.on('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler) win.webContents.send('terminal:requestTabCreate', { requestId, worktreeId, @@ -28761,7 +28762,7 @@ export class OrcaRuntimeService { const requestId = randomUUID() const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) opts.signal?.removeEventListener('abort', onAbort) reject(new Error('Terminal creation timed out')) }, 10_000) @@ -28769,7 +28770,7 @@ export class OrcaRuntimeService { // its shell) stays alive for the host and mirrors on reconnect (#7718). const onAbort = (): void => { clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) reject(new Error('client_disconnected')) } @@ -28781,7 +28782,7 @@ export class OrcaRuntimeService { return } clearTimeout(timer) - ipcMain.removeListener('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) opts.signal?.removeEventListener('abort', onAbort) if (r.error) { reject(new Error(r.error)) @@ -28790,7 +28791,7 @@ export class OrcaRuntimeService { } } opts.signal?.addEventListener('abort', onAbort, { once: true }) - ipcMain.on('terminal:tabCreateReply', handler) + getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler) win.webContents.send('terminal:requestTabCreate', { requestId, worktreeId, @@ -38095,7 +38096,7 @@ export class OrcaRuntimeService { // ── Browser automation ── - private readonly browserCommands = new RuntimeBrowserCommands({ + private readonly browserCommands = createRuntimeBrowserCommands({ getAgentBrowserBridge: () => this.agentBrowserBridge, resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), getAuthoritativeWindow: () => this.getAuthoritativeWindow(), @@ -38572,10 +38573,7 @@ export class OrcaRuntimeService { if (this.authoritativeWindowId === null) { return null } - if (!BrowserWindow?.fromId) { - return null - } - const win = BrowserWindow.fromId(this.authoritativeWindowId) + const win = getRuntimeDesktopSurface().findWindowById(this.authoritativeWindowId) return win && !win.isDestroyed() ? win : null } } diff --git a/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts b/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts new file mode 100644 index 00000000000..7c2fd3e44db --- /dev/null +++ b/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +let db: OrchestrationDb | undefined + +afterEach(() => { + db?.close() + db = undefined +}) + +// Why: seeding before the row settles keeps the expectation off the row's initial null — a settle that cleared the column would satisfy `toBeNull()` too. +function seedHeartbeatedDispatch(): { d: OrchestrationDb; dispatchId: string } { + const d = new OrchestrationDb(':memory:') + db = d + const task = d.createTask({ spec: 'work' }) + const dispatch = d.createDispatchContext(task.id, 'term_worker') + d.recordHeartbeat(dispatch.id, '2026-05-03T00:00:00.000Z') + return { d, dispatchId: dispatch.id } +} + +// Why: recordHeartbeat matches status='dispatched' exactly, so every settled status needs its own case — a widened `status != 'completed'` guard shows up only on the failed one. +describe('recordHeartbeat straggler guard', () => { + it('ignores a straggler heartbeat after the dispatch completed', () => { + const { d, dispatchId } = seedHeartbeatedDispatch() + d.completeDispatch(dispatchId) + + d.recordHeartbeat(dispatchId, '2026-05-04T00:00:00.000Z') + + expect(d.getDispatchContextById(dispatchId)?.last_heartbeat_at).toBe('2026-05-03T00:00:00.000Z') + }) + + it('ignores a straggler heartbeat after the dispatch failed', () => { + const { d, dispatchId } = seedHeartbeatedDispatch() + d.failDispatch(dispatchId, 'exit') + + d.recordHeartbeat(dispatchId, '2026-05-04T00:00:00.000Z') + + expect(d.getDispatchContextById(dispatchId)?.last_heartbeat_at).toBe('2026-05-03T00:00:00.000Z') + }) +}) diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 04985a9ee0e..0894ca7c6a0 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -654,17 +654,6 @@ describe('OrchestrationDb', () => { expect(after?.last_heartbeat_at).toBe('2026-05-04T00:00:00.000Z') }) - it('recordHeartbeat is a no-op for completed rows (straggler ignored)', () => { - const d = createDb() - const task = d.createTask({ spec: 'work' }) - const ctx = d.createDispatchContext(task.id, 'term_a') - d.completeDispatch(ctx.id) - - d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') - const after = d.getDispatchContext(task.id) - expect(after?.last_heartbeat_at).toBeNull() - }) - it('getStaleDispatches returns only dispatched rows past the grace window', () => { const d = createDb() // Fixture: four rows, SQL-backdated timestamps (no fake clock): diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 81ee32d1988..0c3a079262f 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ipc/ssh' +import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' import { getPublicSshState } from '../../public-ssh-state' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts index f463feff1cf..bb035b984eb 100644 --- a/src/main/runtime/rpc/methods/plugins.ts +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcContext, type RpcMethod } from '../core' import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge' -import { listPluginsForClients } from '../../../ipc/plugins' +import { listPluginsForClients } from '../../../plugins/plugin-client-list' import type { PluginListEntry } from '../../../plugins/plugin-list-projection' import type { PluginService } from '../../../plugins/plugin-service' import { diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index e8f1ef82930..5b453f9ce40 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -18,7 +18,7 @@ const { runPreflightCheckMock: vi.fn() })) -vi.mock('../../../ipc/preflight', () => ({ +vi.mock('../../../preflight/agent-detection', () => ({ detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock, detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock, diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index aafbb96319c..f863a1941d1 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -6,7 +6,7 @@ import { detectInstalledAgentsWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck -} from '../../../ipc/preflight' +} from '../../../preflight/agent-detection' const PreflightCheck = z.object({ force: z.boolean().optional() diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index d19f5f85a53..450375e48f8 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -16,7 +16,7 @@ const { listRegisteredRemovedSshTargetLabelsMock: vi.fn() })) -vi.mock('../../../ipc/ssh', () => ({ +vi.mock('../../../ssh/ssh-target-registry', () => ({ connectRegisteredSshTarget: connectRegisteredSshTargetMock, getRegisteredSshState: getRegisteredSshStateMock, listRegisteredSshTargets: listRegisteredSshTargetsMock, diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 0e970af6864..41691dc6ae9 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -4,7 +4,7 @@ import { getRegisteredSshState, listRegisteredRemovedSshTargetLabels, listRegisteredSshTargets -} from '../../../ipc/ssh' +} from '../../../ssh/ssh-target-registry' import { defineMethod, type RpcMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' diff --git a/src/main/runtime/runtime-browser-commands-factory.ts b/src/main/runtime/runtime-browser-commands-factory.ts new file mode 100644 index 00000000000..99e321aa310 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-factory.ts @@ -0,0 +1,53 @@ +import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser' + +/** + * How `OrcaRuntimeService` obtains its browser-automation commands. + * + * Why a factory rather than a direct import: `orca-runtime-browser.ts` reaches the + * whole Chromium cluster — `BrowserWindow`, `session`, `webContents`, cookie jars — + * 15 modules that a Node host cannot load at all. Importing the class for its *type* + * is free; constructing it is what drags the cluster in. + * + * The desktop installs the real factory. A Node host installs none and every browser + * RPC rejects with `browser_unavailable`, which the runtime already advertises through + * capability filtering — clients do not offer the affordance. + * + * Deliberately NOT a stub object with silently-succeeding methods: that is the + * "looks fine, returns a lie" shape this codebase rejects. Absent means rejected. + */ + +export type RuntimeBrowserCommandsFactory = ( + host: RuntimeBrowserCommandHost +) => RuntimeBrowserCommands + +let currentFactory: RuntimeBrowserCommandsFactory | null = null + +export function setRuntimeBrowserCommandsFactory( + factory: RuntimeBrowserCommandsFactory | null +): void { + currentFactory = factory +} + +/** + * Build the commands, or a rejecting proxy when this host has no browser. The proxy + * throws per call rather than at construction so the runtime still starts — the + * capability is simply not advertised. + */ +export function createRuntimeBrowserCommands( + host: RuntimeBrowserCommandHost +): RuntimeBrowserCommands { + if (currentFactory) { + return currentFactory(host) + } + return new Proxy({} as RuntimeBrowserCommands, { + get: (_target, property) => { + if (property === 'then') { + // Why: an awaited undefined must not look like a thenable. + return undefined + } + return () => { + throw new Error(`browser_unavailable: ${String(property)} needs a desktop host`) + } + } + }) +} diff --git a/src/main/runtime/runtime-desktop-surface.ts b/src/main/runtime/runtime-desktop-surface.ts new file mode 100644 index 00000000000..ac1086e4f35 --- /dev/null +++ b/src/main/runtime/runtime-desktop-surface.ts @@ -0,0 +1,42 @@ +import type { BrowserWindow, IpcMainEvent } from 'electron' + +/** + * The desktop facilities `OrcaRuntimeService` uses, which a Node host does not have. + * + * Three sites, all optional by nature: a native notification toast, a lookup of the + * authoritative renderer window, and one ipcMain channel used only by the + * renderer-backed tab-create fallback. With no renderer that fallback is unreachable — + * `createTerminal` already takes the background spawn branch when there is no + * authoritative window (#10333) — so a Node host needs none of them. + * + * Defaults are inert rather than throwing, for the same reason as the PTY bindings: a + * host with no desktop legitimately has nothing here, and that is not a downgrade. + * Where absence IS user-visible — a notification that would have been shown — the + * runtime already routes to paired clients, which is the better destination anyway. + */ + +export type RuntimeDesktopSurface = { + /** Show a native notification. Returns false when the host cannot, so callers can say so. */ + showNotification(input: { title: string; body: string }): boolean + /** The renderer window with this id, or null when there is no desktop. */ + findWindowById(id: number): BrowserWindow | null + onIpc(channel: string, listener: (event: IpcMainEvent, ...args: never[]) => void): void + removeIpcListener(channel: string, listener: (...args: never[]) => void): void +} + +const inertDesktopSurface: RuntimeDesktopSurface = { + showNotification: () => false, + findWindowById: () => null, + onIpc: () => {}, + removeIpcListener: () => {} +} + +let current: RuntimeDesktopSurface = inertDesktopSurface + +export function setRuntimeDesktopSurface(surface: RuntimeDesktopSurface | null): void { + current = surface ?? inertDesktopSurface +} + +export function getRuntimeDesktopSurface(): RuntimeDesktopSurface { + return current +} diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.ts b/src/main/skills/claude-plugin-skill-sources-wsl.ts index 083446229b0..d290bc50305 100644 --- a/src/main/skills/claude-plugin-skill-sources-wsl.ts +++ b/src/main/skills/claude-plugin-skill-sources-wsl.ts @@ -61,9 +61,9 @@ async function executeWslMetadataRead(distro: string, script: string): Promise { // Roots are shared between scans for a few seconds; each case owns its own tree. clearSkillRootScanCache() + // Why: the Hermes root is the only home root that env vars can move outside the + // per-case tree, so a developer's real install must not leak into these scans. + vi.stubEnv('HERMES_HOME', '') + vi.stubEnv('LOCALAPPDATA', '') vi.spyOn(console, 'info').mockImplementation(() => undefined) }) afterEach(() => { unavailableRootPath = null + vi.unstubAllEnvs() vi.restoreAllMocks() }) @@ -367,6 +372,7 @@ describe('skill discovery', () => { '/home/test/.config/opencode/skills', '/home/test/.pi/agent/skills', '/home/test/.omp/agent/skills', + '/home/test/.hermes/skills', '/home/test/.gemini/skills', '/home/test/.gemini/antigravity/skills', '/home/test/.cursor/skills', @@ -393,6 +399,9 @@ describe('skill discovery', () => { expect( roots.find((root) => root.path.replace(/\\/g, '/') === '/home/test/.omp/agent/skills')?.owner ).toBe('omp') + expect( + roots.find((root) => root.path.replace(/\\/g, '/') === '/home/test/.hermes/skills')?.owner + ).toBe('hermes') }) it('does not add runtime-owned repository paths to local scan roots', () => { @@ -471,6 +480,72 @@ describe('skill discovery', () => { expect(skill?.providers).toEqual(['agent-skills']) }) + it('discovers Skills installed in the Hermes home', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const skillDir = join(home, '.hermes', 'skills', 'social-media', 'research') + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'SKILL.md'), + ['---', 'name: social-research', 'description: Research social sources.', '---', ''].join( + '\n' + ) + ) + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + expect(result.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'social-research', + sourceKind: 'home', + sourceLabel: 'Hermes home', + directoryPath: skillDir, + providers: ['agent-skills'] + }) + ]) + ) + expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({ + owner: 'hermes', + exists: true + }) + }) + + it('discovers Hermes Skills under a relocated HERMES_HOME profile', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const hermesHome = join(root, 'profiles', 'coder') + const skillDir = join(hermesHome, 'skills', 'research') + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'SKILL.md'), + ['---', 'name: profile-research', 'description: Research sources.', '---', ''].join('\n') + ) + vi.stubEnv('HERMES_HOME', hermesHome) + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + expect(result.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'profile-research', + sourceLabel: 'Hermes home', + directoryPath: skillDir + }) + ]) + ) + expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({ + path: join(hermesHome, 'skills'), + exists: true + }) + }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index c5b8515c319..77e1e477ffd 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -34,11 +34,11 @@ const MAX_MARKDOWN_BYTES = 256 * 1024 // them for a few seconds is what bounds that fan-out. export const SKILL_ROOT_SCAN_TTL_MS = 10_000 // Why: sized off the root formula, not a round number. One scan builds -// `12 fixed home roots + 2 per local repo (+ cwd) + plugin roots`, so a bound +// `17 fixed home roots + 7 per local repo (+ cwd) + plugin roots`, so a bound // smaller than a single scan's root count makes that scan evict its own earlier // entries and the cache degrades to a ~0% hit rate — exactly the walk this // exists to prevent. The live key space is the union across targets — the fixed -// home roots plus two per repo plus two per distinct workspace cwd — so this holds +// home roots plus seven per repo plus seven per distinct workspace cwd — so this holds // a few hundred repos with panes open, not an unbounded install. Past that the LRU // keeps the hot home roots and the repo roots thrash, which degrades rather than // breaks. Most repo roots do not exist, and a missing root caches as diff --git a/src/main/skills/skill-discovery-concurrency.test.ts b/src/main/skills/skill-discovery-concurrency.test.ts index da7079331bd..a51d6dc9d52 100644 --- a/src/main/skills/skill-discovery-concurrency.test.ts +++ b/src/main/skills/skill-discovery-concurrency.test.ts @@ -60,11 +60,16 @@ function readdirCountUnder(path: string): number { beforeEach(() => { clearSkillRootScanCache() readdirPaths.length = 0 + // Why: the Hermes root is the one home root an env var can move outside the + // fixture, so a developer with a real Hermes install would score it `present`. + vi.stubEnv('HERMES_HOME', '') + vi.stubEnv('LOCALAPPDATA', '') vi.spyOn(console, 'info').mockImplementation(() => undefined) }) afterEach(() => { clearSkillRootScanCache() + vi.unstubAllEnvs() vi.restoreAllMocks() }) @@ -180,7 +185,7 @@ describe('bounded concurrent skill discovery', () => { const line = String(info.mock.calls.at(0)?.at(0)) // `present` is the signal that separates "big tree" from "big root set", and // is not derivable from the other counts. - expect(line).toContain('[skills] scan roots=23 present=3 walked=23 skills=3') + expect(line).toContain('[skills] scan roots=24 present=3 walked=24 skills=3') expect(line).toContain('home-claude') expect(line).not.toContain(home) expect(line).not.toContain(tmpdir()) diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index 91221a55413..9d9781c3687 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -11,7 +11,11 @@ import type { AgentType } from '../../shared/agent-status-types' import type { Repo } from '../../shared/repo-types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { SkillProviderRootOverrides } from './skill-provider-destinations' -import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots' +import { + resolveDefaultHermesSkillsRoot, + resolveEnvironmentHermesSkillsRoot, + resolveEnvironmentSkillProviderRoots +} from './skill-provider-runtime-roots' export type SkillScanRoot = Omit type SkillDiscoveryPathApi = Pick @@ -77,6 +81,13 @@ export function buildSkillDiscoverySources( const cwd = args.cwd ?? process.cwd() const providerRootOverrides = args.providerRootOverrides ?? (args.pathApi ? {} : resolveEnvironmentSkillProviderRoots()) + // Why: HERMES_HOME moves the whole profile tree, so the default home path + // finds nothing for `hermes -p `. Only this process's own host can + // read it — a custom pathApi means the home belongs to another host, whose + // Hermes install is POSIX-shaped even when this process runs on Windows. + const hermesSkillsRoot = args.pathApi + ? pathApi.join(home, '.hermes', 'skills') + : (resolveEnvironmentHermesSkillsRoot() ?? resolveDefaultHermesSkillsRoot({ homeDir: home })) const roots: SkillScanRoot[] = [ source( 'home-codex', @@ -144,6 +155,7 @@ export function buildSkillDiscoverySources( ['agent-skills'], 'omp' ), + source('home-hermes', 'Hermes home', hermesSkillsRoot, 'home', ['agent-skills'], 'hermes'), source( 'home-prime-agent', 'Prime Agent home', diff --git a/src/main/skills/skill-discovery-wsl-plugins.test.ts b/src/main/skills/skill-discovery-wsl-plugins.test.ts index d3b5913fc57..f3ee9024ff2 100644 --- a/src/main/skills/skill-discovery-wsl-plugins.test.ts +++ b/src/main/skills/skill-discovery-wsl-plugins.test.ts @@ -1,6 +1,6 @@ import type { WslResult } from '../wsl/wsl-runner' import { posix as pathPosix } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runWslProcessMock = vi.hoisted(() => vi.fn()) @@ -19,10 +19,15 @@ function wslResult(stdout: string): WslResult { describe('WSL Claude plugin skill discovery', () => { beforeEach(() => runWslProcessMock.mockReset()) + afterEach(() => vi.unstubAllEnvs()) it('reads enabled plugin metadata and scans the selected install inside the distro', async () => { const homeDir = '/home/alice' const cwd = '/work/orca' + // Why: a Windows host's own Hermes location says nothing about the distro's, + // so neither variable may reach the posix scan script. + vi.stubEnv('HERMES_HOME', 'C:\\Users\\alice\\hermes') + vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\alice\\AppData\\Local') const pluginId = 'compound-engineering@compound-engineering-plugin' const installPath = '/home/alice/.claude/plugins/cache/compound/3.14.3' const installed = JSON.stringify({ @@ -58,6 +63,8 @@ describe('WSL Claude plugin skill discovery', () => { expect(runWslProcessMock).toHaveBeenCalledTimes(2) const scanScript = runWslProcessMock.mock.calls[1]?.[0].script as string + expect(scanScript).toContain('/home/alice/.hermes/skills') + expect(scanScript).not.toContain('AppData') expect(scanScript).toContain(`${installPath}/skills`) expect(result.skills).toEqual([ expect.objectContaining({ diff --git a/src/main/skills/skill-discovery-wsl.ts b/src/main/skills/skill-discovery-wsl.ts index 8662e166efa..6923066268e 100644 --- a/src/main/skills/skill-discovery-wsl.ts +++ b/src/main/skills/skill-discovery-wsl.ts @@ -57,9 +57,8 @@ async function executeWslSkillDiscovery(distro: string, script: string): Promise // dash rejects with `Syntax error: word unexpected` (#14292). const result = await runWslProcess({ distro, - lane: 'probe', - // Degrade rather than refuse: the scan reads the filesystem; it never needed the login PATH. - allowDegradedEnvironment: true, + // 'none': find/base64/head/printf/stat over $HOME roots, no bare tool. + loginPath: 'none', script, shell: 'bash', diff --git a/src/main/skills/skill-provider-runtime-roots.test.ts b/src/main/skills/skill-provider-runtime-roots.test.ts index 07d9d422dac..f933d4b2ff0 100644 --- a/src/main/skills/skill-provider-runtime-roots.test.ts +++ b/src/main/skills/skill-provider-runtime-roots.test.ts @@ -1,6 +1,8 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + resolveDefaultHermesSkillsRoot, + resolveEnvironmentHermesSkillsRoot, resolveEnvironmentSkillProviderRoots, resolveWslGrokSkillProviderRoot, withClaudeSkillProviderRoot @@ -30,6 +32,71 @@ describe('skill provider runtime roots', () => { }) }) + it('maps a relocated HERMES_HOME to its skill root and ignores relative ones', () => { + expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: join('/srv', 'hermes') })).toBe( + join('/srv', 'hermes', 'skills') + ) + expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: '../hermes' })).toBeNull() + expect(resolveEnvironmentHermesSkillsRoot({})).toBeNull() + }) + + it('defaults the Hermes skills root to the home dotfolder off Windows', () => { + expect( + resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'linux', + env: { LOCALAPPDATA: join('/local') }, + directoryExists: () => true + }) + ).toBe(join('/users', 'alice', '.hermes', 'skills')) + }) + + it('defaults the Hermes skills root under LOCALAPPDATA on Windows', () => { + const resolved = resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'win32', + env: { LOCALAPPDATA: join('/local') }, + directoryExists: (candidate) => candidate === join('/local', 'hermes') + }) + expect(resolved).toBe(join('/local', 'hermes', 'skills')) + }) + + it('keeps a pre-LOCALAPPDATA Windows dotfolder install discoverable', () => { + const resolved = resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'win32', + env: { LOCALAPPDATA: join('/local') }, + directoryExists: (candidate) => candidate === join('/users', 'alice', '.hermes') + }) + expect(resolved).toBe(join('/users', 'alice', '.hermes', 'skills')) + }) + + it('prefers the LOCALAPPDATA tree on Windows when both layouts exist', () => { + const resolved = resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'win32', + env: { LOCALAPPDATA: join('/local') }, + directoryExists: () => true + }) + expect(resolved).toBe(join('/local', 'hermes', 'skills')) + }) + + it('falls back to the dotfolder when Windows exposes no usable LOCALAPPDATA', () => { + const seen: string[] = [] + const resolved = resolveDefaultHermesSkillsRoot({ + homeDir: join('/users', 'alice'), + platform: 'win32', + env: { LOCALAPPDATA: 'relative\\local' }, + directoryExists: (candidate) => { + seen.push(candidate) + return true + } + }) + expect(resolved).toBe(join('/users', 'alice', '.hermes', 'skills')) + // A rejected LOCALAPPDATA must not cost a stat: there is nothing to compare. + expect(seen).toEqual([]) + }) + it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => { await expect( resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n') diff --git a/src/main/skills/skill-provider-runtime-roots.ts b/src/main/skills/skill-provider-runtime-roots.ts index 22e3a5fb94a..d4dec7bfba7 100644 --- a/src/main/skills/skill-provider-runtime-roots.ts +++ b/src/main/skills/skill-provider-runtime-roots.ts @@ -1,3 +1,4 @@ +import { statSync } from 'node:fs' import { isAbsolute, join, resolve } from 'node:path' import { toWindowsWslPath } from '../../shared/wsl-paths' import { runWslProcess } from '../wsl/wsl-runner' @@ -37,6 +38,48 @@ export function resolveEnvironmentSkillProviderRoots( } } +// Why: `HERMES_HOME` relocates a whole Hermes profile tree (`hermes -p coder`), +// and the rest of Orca already treats it as authoritative. Hermes is not an +// install provider, so it stays out of `SkillProviderRootOverrides`. +export function resolveEnvironmentHermesSkillsRoot( + env: NodeJS.ProcessEnv = process.env +): string | null { + const hermesHome = normalizedRoot(env.HERMES_HOME) + return hermesHome ? join(hermesHome, 'skills') : null +} + +function isExistingDirectory(candidate: string): boolean { + return statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true +} + +/** + * The Hermes home when `HERMES_HOME` is unset. Windows installs land in + * `%LOCALAPPDATA%\hermes`, not a dotfolder in the user profile, so the POSIX + * `~/.hermes` default discovers nothing there. The dotfolder still wins on + * Windows when it exists and the LOCALAPPDATA tree does not, which is the + * pre-LOCALAPPDATA install Hermes itself keeps honouring rather than orphaning. + */ +export function resolveDefaultHermesSkillsRoot(input: { + homeDir: string + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + directoryExists?: (candidate: string) => boolean +}): string { + const dotfolderHome = join(input.homeDir, '.hermes') + if ((input.platform ?? process.platform) !== 'win32') { + return join(dotfolderHome, 'skills') + } + const localAppData = normalizedRoot((input.env ?? process.env).LOCALAPPDATA) + if (!localAppData) { + return join(dotfolderHome, 'skills') + } + const localAppDataHome = join(localAppData, 'hermes') + const directoryExists = input.directoryExists ?? isExistingDirectory + return !directoryExists(localAppDataHome) && directoryExists(dotfolderHome) + ? join(dotfolderHome, 'skills') + : join(localAppDataHome, 'skills') +} + export function withClaudeSkillProviderRoot( roots: SkillProviderRootOverrides, claudeConfigDirectory: string | null | undefined @@ -50,10 +93,13 @@ type WslEnvironmentProbe = (distro: string) => Promise async function probeWslGrokHome(distro: string): Promise { const result = await runWslProcess({ distro, - lane: 'probe', - // Degrade rather than refuse: reads $HOME, which wsl.exe supplies without a login shell. - allowDegradedEnvironment: true, + // 'none': the script runs its own `"$login_shell" -lc`, so asking the + // runner to probe first buys a second login shell and spends up to half + // the 8s budget before the -lc that actually reads GROK_HOME starts. + loginPath: 'none', script: WSL_GROK_HOME_SCRIPT, + // POSIX (`case`, `exec`); declared because the payload is opaque here. + shell: 'sh', timeoutMs: WSL_ENV_PROBE_TIMEOUT_MS, maxOutputBytes: WSL_ENV_PROBE_MAX_BYTES }) diff --git a/src/main/skills/skill-wsl-install-filesystem.ts b/src/main/skills/skill-wsl-install-filesystem.ts index 8a5a863efb2..447b94efdfa 100644 --- a/src/main/skills/skill-wsl-install-filesystem.ts +++ b/src/main/skills/skill-wsl-install-filesystem.ts @@ -213,10 +213,10 @@ export class WslSkillInstallFilesystem implements SkillInstallFilesystem { private async runOutput(script: string, args: string[]): Promise { const result = await runWslProcess({ distro: this.distro, - lane: 'probe', - // Degrade rather than refuse: mkdir/mv/chmod on the default PATH; no login shell before. - allowDegradedEnvironment: true, + loginPath: 'none', script, + // POSIX file operations; declared because the payload is opaque here. + shell: 'sh', args, timeoutMs: GUEST_COMMAND_TIMEOUT_MS, maxOutputBytes: GUEST_COMMAND_MAX_OUTPUT_BYTES diff --git a/src/main/skills/skill-wsl-provider-detection.test.ts b/src/main/skills/skill-wsl-provider-detection.test.ts index c5117116c4a..677d0b6fdf9 100644 --- a/src/main/skills/skill-wsl-provider-detection.test.ts +++ b/src/main/skills/skill-wsl-provider-detection.test.ts @@ -5,7 +5,7 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection' -type RunWslProcessSpec = { distro: string; lane: string; script: string } +type RunWslProcessSpec = { distro: string; loginPath: string; script: string } describe('detectSkillProvidersInWsl', () => { beforeEach(() => { @@ -16,7 +16,7 @@ describe('detectSkillProvidersInWsl', () => { vi.restoreAllMocks() }) - it('runs on the probe lane so a PATH-only install (nvm/mise) is still found (regression)', async () => { + it('asks for the login PATH so a nvm/mise-only install is still found (regression)', async () => { // Before this migration the site ran `sh -c` with no login shell, so an // nvm-installed codex/claude -- reachable only through the PATH a login // shell assembles from rc files -- resolved to nothing via `command -v` @@ -32,7 +32,7 @@ describe('detectSkillProvidersInWsl', () => { const found = await detectSkillProvidersInWsl('Ubuntu') const [spec] = runWslProcessMock.mock.calls.at(-1) as [RunWslProcessSpec] - expect(spec.lane).toBe('probe') + expect(spec.loginPath).toBe('preferred') expect(spec.distro).toBe('Ubuntu') expect(found).toEqual(['codex']) }) @@ -65,6 +65,32 @@ describe('detectSkillProvidersInWsl', () => { expect(found).toEqual(['codex']) }) + it.each([ + ['reports unverifiable rather than empty', '', true], + ['reports unverifiable even on a partial hit', 'claude\n', true], + ['still trusts a genuine empty result', '', false] + ])('%s', async (_case, stdout, unresolved) => { + // The script ends in `|| true`, so "ran without the login PATH" and "no + // providers installed" are the same exit 0 with the same empty stdout. + // Callers skip the ~/.codex and ~/.claude skill roots on an empty list, so + // conflating them loses an nvm-installed provider's skills (#9725). + runWslProcessMock.mockResolvedValue({ + environmentResolved: !unresolved, + code: 0, + stdout, + stderr: '', + timedOut: false + }) + + await (unresolved + ? expect(detectSkillProvidersInWsl('Ubuntu')).rejects.toThrow( + 'skill-install-wsl-provider-detection-failed' + ) + : expect(detectSkillProvidersInWsl('Ubuntu')).resolves.toEqual( + stdout.trim() ? ['claude'] : [] + )) + }) + it('rejects when wsl.exe cannot be started', async () => { runWslProcessMock.mockRejectedValue(new Error('spawn wsl.exe ENOENT')) diff --git a/src/main/skills/skill-wsl-provider-detection.ts b/src/main/skills/skill-wsl-provider-detection.ts index 9b5ddf531ac..7b81d1d341c 100644 --- a/src/main/skills/skill-wsl-provider-detection.ts +++ b/src/main/skills/skill-wsl-provider-detection.ts @@ -13,8 +13,10 @@ export async function detectSkillProvidersInWsl(distro: string): Promise provider === 'codex' || provider === 'claude') diff --git a/src/main/speech/openai-api-key-store.test.ts b/src/main/speech/openai-api-key-store.test.ts index 9475d5afa54..e9dafd63781 100644 --- a/src/main/speech/openai-api-key-store.test.ts +++ b/src/main/speech/openai-api-key-store.test.ts @@ -14,9 +14,11 @@ let tempHome = '' async function loadStoreModule() { vi.resetModules() - vi.doMock('electron', () => ({ - safeStorage: safeStorageMock - })) + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + ...safeStorageMock, + describeProtectionGap: () => null + }) vi.doMock('os', async () => { const actual = await vi.importActual('os') return { ...actual, homedir: () => tempHome } diff --git a/src/main/speech/openai-api-key-store.ts b/src/main/speech/openai-api-key-store.ts index dc7345f3037..b587c878f4d 100644 --- a/src/main/speech/openai-api-key-store.ts +++ b/src/main/speech/openai-api-key-store.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { getSecretStore } from '../../shared/secret-store' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -43,7 +43,7 @@ function readLegacyJsonStoredOpenAiKey(): StoredOpenAiKey | null { export function hasOpenAiSpeechApiKey(): boolean { // Why: Settings and model-state refresh call this on startup; checking file - // existence avoids decrypting safeStorage and triggering macOS keychain prompts. + // existence avoids a decrypt that triggers macOS keychain prompts. return existsSync(getOpenAiKeyPath()) } @@ -53,15 +53,13 @@ export function saveOpenAiSpeechApiKey(apiKey: string): void { throw new Error('OpenAI API key is required') } ensureOrcaDir() - if (safeStorage.isEncryptionAvailable()) { - writeFileSync(getOpenAiKeyPath(), safeStorage.encryptString(trimmed), { mode: 0o600 }) + if (getSecretStore().isEncryptionAvailable()) { + writeFileSync(getOpenAiKeyPath(), getSecretStore().encryptString(trimmed), { mode: 0o600 }) cachedOpenAiSpeechApiKey = trimmed return } - console.warn( - '[speech] safeStorage encryption unavailable — storing OpenAI speech key in plaintext' - ) + console.warn('[speech] secret encryption unavailable — storing OpenAI speech key in plaintext') writeFileSync(getOpenAiKeyPath(), trimmed, { encoding: 'utf8', mode: 0o600 }) cachedOpenAiSpeechApiKey = trimmed } @@ -79,13 +77,13 @@ export function readOpenAiSpeechApiKey(): string { const raw = readFileSync(keyPath) const legacyJson = readLegacyJsonStoredOpenAiKey() if (legacyJson) { - cachedOpenAiSpeechApiKey = safeStorage.decryptString( + cachedOpenAiSpeechApiKey = getSecretStore().decryptString( Buffer.from(legacyJson.encryptedKeyBase64, 'base64') ) return cachedOpenAiSpeechApiKey } - cachedOpenAiSpeechApiKey = safeStorage.isEncryptionAvailable() - ? safeStorage.decryptString(raw) + cachedOpenAiSpeechApiKey = getSecretStore().isEncryptionAvailable() + ? getSecretStore().decryptString(raw) : raw.toString('utf8') return cachedOpenAiSpeechApiKey } catch { diff --git a/src/main/speech/speech-runtime-service.ts b/src/main/speech/speech-runtime-service.ts index 97f963ae57b..f5a65a939dc 100644 --- a/src/main/speech/speech-runtime-service.ts +++ b/src/main/speech/speech-runtime-service.ts @@ -1,28 +1,60 @@ -import { ModelManager } from './model-manager' -import { SttService } from './stt-service' +import type { ModelManager } from './model-manager' +import type { SttService } from './stt-service' import type { VoiceSettings } from '../../shared/speech-types' +/** + * Lazy accessors for the speech services. + * + * Why the construction is injected: `ModelManager` downloads models through Electron's + * streaming `net.request` — byte-range resume, progress events, a manual idle timeout — + * and `SttService` resolves paths inside the packaged app. Importing either for its + * *type* is free; constructing one is what pulls Electron in. + * + * The desktop installs the factories. A host without them rejects per call rather than + * returning a stub that silently does nothing: speech is a desktop feature, and a + * headless host saying so is more useful than one that appears to transcribe. + */ + type SpeechSettingsStore = { getSettings(): { voice?: VoiceSettings } } +export type SpeechServiceFactories = { + createModelManager(customModelsDir: string | undefined): ModelManager + createSttService(models: ModelManager): SttService +} + +let factories: SpeechServiceFactories | null = null let modelManager: ModelManager | null = null let sttService: SttService | null = null +export function setSpeechServiceFactories(next: SpeechServiceFactories | null): void { + factories = next + modelManager = null + sttService = null +} + +function requireFactories(): SpeechServiceFactories { + if (!factories) { + throw new Error('speech_unavailable: this host has no speech services') + } + return factories +} + export function getSpeechModelManager(store: SpeechSettingsStore): ModelManager { if (!modelManager) { const settings = store.getSettings() const customDir = settings.voice?.modelsDir || undefined - modelManager = new ModelManager(customDir || undefined) + modelManager = requireFactories().createModelManager(customDir || undefined) } return modelManager } export function getSpeechSttService(store: SpeechSettingsStore): SttService { if (!sttService) { - sttService = new SttService(getSpeechModelManager(store)) + sttService = requireFactories().createSttService(getSpeechModelManager(store)) } return sttService } diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index e8b8d5fadb4..7903c663c28 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -3,7 +3,7 @@ timeout teardown must stay co-located so dictation lifecycle state cannot drift. import { Worker } from 'node:worker_threads' import { existsSync } from 'node:fs' import { join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { getCatalogModel } from './model-catalog' import type { ModelManager } from './model-manager' import { OpenAiTranscriptionSession } from './openai-transcription-client' @@ -482,7 +482,7 @@ export class SttService { } private getWorkerPath(): string { - if (app.isPackaged) { + if (getAppEnvironment().isPackaged()) { return join(process.resourcesPath, 'app.asar', 'out', 'main', 'stt-worker.js') } return join(__dirname, 'stt-worker.js') @@ -564,7 +564,7 @@ export class SttService { ? 'sherpa-onnx-win-x64' : `sherpa-onnx-${process.platform}-${process.arch}` - if (app.isPackaged) { + if (getAppEnvironment().isPackaged()) { const resourcesNodeModule = join(process.resourcesPath, 'node_modules', nativePkg) if (existsSync(resourcesNodeModule)) { return resourcesNodeModule diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts new file mode 100644 index 00000000000..af71581af42 --- /dev/null +++ b/src/main/ssh/ssh-target-registry.ts @@ -0,0 +1,89 @@ +import type { SshConnectionStore } from './ssh-connection-store' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' + +/** + * The SSH target/state registry, split out of `ipc/ssh.ts`. + * + * Why: the Orca runtime reads registered SSH targets and state during normal + * operation, but `ipc/ssh.ts` also owns `ipcMain`, `powerMonitor` and a + * `BrowserWindow` accessor. Importing four thin accessors dragged all of Electron + * into the runtime's module graph. + * + * This holds only the registry: the store plus the two callbacks the handler layer + * installs. `registerSshHandlers` populates it; the runtime reads it. Keeping the + * indirection (rather than the runtime holding a manager directly) is deliberate — + * SSH providers register after construction and may reconnect, so callers must + * resolve the current generation rather than freeze one. + */ + +let sshStore: SshConnectionStore | null = null +let registeredConnectSshTarget: ((targetId: string) => Promise) | null = null +let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null + +export function setSshTargetRegistryStore(store: SshConnectionStore | null): void { + sshStore = store +} + +export function getSshTargetRegistryStore(): SshConnectionStore | null { + return sshStore +} + +export function setSshTargetRegistryHandlers(handlers: { + connect: ((targetId: string) => Promise) | null + getState: ((targetId: string) => SshConnectionState | undefined) | null +}): void { + registeredConnectSshTarget = handlers.connect + registeredGetSshState = handlers.getState +} + +export async function connectRegisteredSshTarget(targetId: string): Promise { + if (!registeredConnectSshTarget) { + // Why this still throws: a headless host that never registered handlers must fail + // loudly rather than report a target as unreachable, which would read as `exited`. + throw new Error('ssh_handlers_not_registered') + } + return registeredConnectSshTarget(targetId) +} + +export function getRegisteredSshState(targetId: string): SshConnectionState | undefined { + return registeredGetSshState?.(targetId) +} + +/** Public targets for runtime RPC clients — same list the desktop renderer gets. */ +export function listRegisteredSshTargets(): SshTarget[] { + return sshStore?.listTargets() ?? [] +} + +/** Removed-target id → last known label, for ghost-host display on paired clients. */ +export function listRegisteredRemovedSshTargetLabels(): Record { + return sshStore?.listRemovedTargetLabels() ?? {} +} + +let registeredGetActiveMultiplexer: + | ((connectionId: string) => SshChannelMultiplexer | undefined) + | null = null + +export function setSshActiveMultiplexerResolver( + resolve: ((connectionId: string) => SshChannelMultiplexer | undefined) | null +): void { + registeredGetActiveMultiplexer = resolve +} + +/** + * The live channel multiplexer for a connection, or undefined when the target is not + * connected. Undefined means "not connected", never "the connection died" — callers + * must not read absence here as an `exited` verdict (docs/reference/ssh-execution-boundary.md). + * + * Why it throws when no resolver is installed rather than returning undefined: that + * case is a wiring error, not a connection state, and the two are indistinguishable to + * callers. A host that never loaded the SSH layer would otherwise report every target as + * quietly "not connected" — which is precisely the unverifiable-reported-as-exited + * conflation the execution-boundary doc exists to prevent. + */ +export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined { + if (!registeredGetActiveMultiplexer) { + throw new Error('ssh_active_multiplexer_resolver_not_installed') + } + return registeredGetActiveMultiplexer(connectionId) +} diff --git a/src/main/startup/host-port-bootstrap-wiring.test.ts b/src/main/startup/host-port-bootstrap-wiring.test.ts new file mode 100644 index 00000000000..cfebf6cb0a8 --- /dev/null +++ b/src/main/startup/host-port-bootstrap-wiring.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guards the single point of failure for every host port. + * + * The ports deliberately split two ways when uninstalled: AppEnvironment and SecretStore + * throw, because a silent default writes real user state to the wrong place; the rest + * default to no-ops or inert stubs, because a host with no renderer legitimately has + * nothing to register. That asymmetry is only safe while the desktop installs all of + * them before anything reads state — a dropped or reordered line here does not fail a + * unit test, it silently degrades the shipped app (worktree removal stops closing + * watchers, notifications stop firing, browser panes start rejecting). + * + * Source-level because that is the property: these run once at module scope during + * startup, so there is no seam to assert against at runtime. + */ +describe('host port bootstrap wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + const INSTALLS = [ + 'setAppEnvironment(new ElectronAppEnvironment())', + 'setSecretStore(new ElectronSecretStore())', + 'setPtyHostBindings({', + 'setRuntimeDesktopSurface(electronRuntimeDesktopSurface)', + 'setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory)', + 'setDefaultProxySessionResolver(', + 'setMainHttpClient(electronHttpClient)', + 'setSpeechServiceFactories(electronSpeechServiceFactories)', + 'setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)' + ] + + it('installs every host port exactly once', () => { + for (const install of INSTALLS) { + expect(source.split(install).length - 1, `${install} should appear exactly once`).toBe(1) + } + }) + + it('installs every port before the runtime, the PTY handlers, or any window exists', () => { + // Why these three: they are the first things that resolve a path, seal a credential, + // or register against an injected surface. + const firstUse = Math.min( + ...['new OrcaRuntimeService(', 'registerHeadlessPtyRuntime(', 'function openMainWindow('] + .map((marker) => source.indexOf(marker)) + .filter((index) => index >= 0) + ) + expect(firstUse).toBeGreaterThan(0) + + for (const install of INSTALLS) { + expect(source.indexOf(install), `${install} must run before first use`).toBeLessThan(firstUse) + } + }) + + it('installs the ports at process level, not per window', () => { + // Why: installing per window registered the PTY surfaces against no-ops on the + // serve path, where no window ever opens. Caught in CI by the SSH docker E2E. + const openWindow = source.indexOf('function openMainWindow(') + for (const install of INSTALLS) { + expect( + source.indexOf(install, openWindow), + `${install} must not be re-installed per window` + ).toBe(-1) + } + }) +}) diff --git a/src/main/telemetry/client.ts b/src/main/telemetry/client.ts index 93bddaaab81..dc92cf1461d 100644 --- a/src/main/telemetry/client.ts +++ b/src/main/telemetry/client.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { PostHog } from 'posthog-node' import type { CommonProps, EventName, EventProps, OptInVia } from '../../shared/telemetry-events' import type { Store } from '../persistence' @@ -53,7 +53,7 @@ let appOpenedTrackedThisSession = false function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps { // Don't truncate here; the validator's `.max(64)` is authoritative, so an over-long string drops rather than being silently masked. return { - app_version: app.getVersion(), + app_version: getAppEnvironment().getVersion(), platform: osPlatform(), arch: osArch(), os_release: osRelease(), diff --git a/src/main/terminal-history-async-delete.test.ts b/src/main/terminal-history-async-delete.test.ts index 15eab30156c..180ba23e73e 100644 --- a/src/main/terminal-history-async-delete.test.ts +++ b/src/main/terminal-history-async-delete.test.ts @@ -1,16 +1,11 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' let userDataDir: string -vi.mock('electron', () => ({ - app: { - getPath: () => userDataDir - } -})) - import { hashWorktreeId } from './terminal-history-paths' import { deleteWorktreeHistoryDir, @@ -24,6 +19,7 @@ import { describe('deleteWorktreeHistoryDir main-thread safety', () => { beforeEach(() => { userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-async-')) + installFakeAppEnvironment({ getPath: () => userDataDir }) }) afterEach(async () => { diff --git a/src/main/terminal-history-paths.ts b/src/main/terminal-history-paths.ts index 263f39c8c35..ea02c6aa0ee 100644 --- a/src/main/terminal-history-paths.ts +++ b/src/main/terminal-history-paths.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../shared/app-environment' const HISTORY_DIR_NAME = 'terminal-history' const HISTORY_DIR_NAME_WSL = 'terminal-history-wsl' @@ -10,16 +10,16 @@ export const PENDING_DELETE_DIR_NAME = '.pending-delete' export { hashWorktreeId } from './terminal-history-id' export function getHistoryRoot(): string { - return join(app.getPath('userData'), HISTORY_DIR_NAME) + return join(getAppEnvironment().getPath('userData'), HISTORY_DIR_NAME) } export function getHistoryRootWsl(distro: string): string { - return join(app.getPath('userData'), HISTORY_DIR_NAME_WSL, distro) + return join(getAppEnvironment().getPath('userData'), HISTORY_DIR_NAME_WSL, distro) } /** Every per-distro WSL history root that exists on disk; empty when WSL history was never written. */ export function listWslHistoryRoots(): string[] { - const wslRoot = join(app.getPath('userData'), HISTORY_DIR_NAME_WSL) + const wslRoot = join(getAppEnvironment().getPath('userData'), HISTORY_DIR_NAME_WSL) if (!existsSync(wslRoot)) { return [] } diff --git a/src/main/terminal-history-tombstone-retry.test.ts b/src/main/terminal-history-tombstone-retry.test.ts index 67cb9926322..1d7a606206a 100644 --- a/src/main/terminal-history-tombstone-retry.test.ts +++ b/src/main/terminal-history-tombstone-retry.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' let userDataDir: string @@ -10,12 +11,6 @@ const { removeHostTreeMock, deleteWslFishHistoryFileMock } = vi.hoisted(() => ({ deleteWslFishHistoryFileMock: vi.fn<(distro: string, session: string) => Promise>() })) -vi.mock('electron', () => ({ - app: { - getPath: () => userDataDir - } -})) - vi.mock('./host-tree-removal', () => ({ removeHostTree: removeHostTreeMock })) @@ -40,6 +35,7 @@ import { describe('tombstoned history removal retries', () => { beforeEach(() => { userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-retry-')) + installFakeAppEnvironment({ getPath: () => userDataDir }) removeHostTreeMock.mockReset() deleteWslFishHistoryFileMock.mockReset() deleteWslFishHistoryFileMock.mockResolvedValue(undefined) diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index ef8a1a60e14..1cbb5f83438 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -1,6 +1,7 @@ import type * as FsPromises from 'node:fs/promises' import { sep } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' const { existsSyncMock, @@ -64,12 +65,6 @@ vi.mock('node:fs/promises', async () => ({ rm: rmAsyncMock })) -vi.mock('electron', () => ({ - app: { - getPath: getPathMock - } -})) - const { parseWslPathMock, toLinuxPathMock } = vi.hoisted(() => ({ parseWslPathMock: vi.fn((_path: string) => null as { distro: string; linuxPath: string } | null), toLinuxPathMock: vi.fn((p: string) => p) @@ -120,6 +115,7 @@ describe('terminal-history', () => { rmAsyncMock.mockReset() rmAsyncMock.mockResolvedValue(undefined) getPathMock.mockReturnValue('/fake/userData') + installFakeAppEnvironment({ getPath: getPathMock }) existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) openSyncMock.mockImplementation(() => 1) diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 296dd4a081b..70d4be18002 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -11,7 +11,7 @@ import { } from 'node:fs' import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../shared/app-environment' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT, @@ -27,7 +27,7 @@ export type TerminalScrollbackSnapshotStorage = { } function getLegacySnapshotRoot(): string { - return join(app.getPath('userData'), SNAPSHOT_DIR_NAME) + return join(getAppEnvironment().getPath('userData'), SNAPSHOT_DIR_NAME) } export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): string { diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 669605e81fd..cadbd677650 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -2,7 +2,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' @@ -39,7 +39,7 @@ export async function saveClipboardImageBufferAsTempFile( return remotePath } - const tempPath = path.join(app.getPath('temp'), fileName) + const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName) await fs.writeFile(tempPath, buffer) return tempPath } diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index b443f18dc7c..ce84c68e327 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, CLIPBOARD_IMAGE_MAX_PIXELS, @@ -191,6 +192,7 @@ function shellIdListArray(childCount: number): Buffer { describe('registerClipboardHandlers', () => { beforeEach(() => { + installFakeAppEnvironment({ getPath: () => '/tmp' }) vi.spyOn(Date, 'now').mockReturnValue(1760000000000) removeHandlerMock.mockReset() handleMock.mockReset() diff --git a/src/main/wsl-fish-history-cleanup.test.ts b/src/main/wsl-fish-history-cleanup.test.ts index 429c8fef826..ede30393049 100644 --- a/src/main/wsl-fish-history-cleanup.test.ts +++ b/src/main/wsl-fish-history-cleanup.test.ts @@ -26,8 +26,7 @@ describe('deleteWslFishHistoryFile', () => { expect(run).toHaveBeenCalledWith({ distro: 'Ubuntu Test', - lane: 'probe', - allowDegradedEnvironment: true, + loginPath: 'preferred', program: 'fish', args: ['--command', expect.stringContaining('orca_0123456789abcdef_history')], timeoutMs: 5_000 @@ -74,7 +73,10 @@ describe('deleteWslFishHistoryFile', () => { }) it('permits a retry once a failed cleanup settles', async () => { - const run = vi.fn().mockRejectedValueOnce(new Error('distro offline')).mockResolvedValue(okResult) + const run = vi + .fn() + .mockRejectedValueOnce(new Error('distro offline')) + .mockResolvedValue(okResult) const session = 'orca_0123456789abcdef' await expect(deleteWslFishHistoryFile('Ubuntu', session, run)).rejects.toThrow('distro offline') @@ -85,16 +87,16 @@ describe('deleteWslFishHistoryFile', () => { it('treats a non-zero exit as a failed cleanup even though the runner resolved', async () => { const run = vi.fn().mockResolvedValue({ ...okResult, code: 1 }) - await expect( - deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run) - ).rejects.toThrow('wsl fish history cleanup failed') + await expect(deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run)).rejects.toThrow( + 'wsl fish history cleanup failed' + ) }) it('treats a runner timeout as a failed cleanup', async () => { const run = vi.fn().mockResolvedValue({ ...okResult, code: null, timedOut: true }) - await expect( - deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run) - ).rejects.toThrow('wsl fish history cleanup failed') + await expect(deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run)).rejects.toThrow( + 'wsl fish history cleanup failed' + ) }) }) diff --git a/src/main/wsl-fish-history-cleanup.ts b/src/main/wsl-fish-history-cleanup.ts index c278d2b4bba..cedbe4b999e 100644 --- a/src/main/wsl-fish-history-cleanup.ts +++ b/src/main/wsl-fish-history-cleanup.ts @@ -50,11 +50,12 @@ async function runCleanup( ): Promise { const result = await run({ distro, - lane: 'probe', - // The old `--exec fish` spawn never sourced a login shell either, so a - // probe failure must run degraded rather than turn a working cleanup into - // an error. - allowDegradedEnvironment: true, + // 'preferred', not 'none': `fish` is a bare name, so it is a PATH lookup. + // A fish from linuxbrew or nix lives only on the login PATH, and without it + // this throws on a distro where the cleanup would have worked. The probe + // failing is still fine -- the old `--exec fish` spawn sourced no login + // shell either, so running on the default PATH is no worse than before. + loginPath: 'preferred', program: 'fish', args: ['--command', fishCleanupScript(session)], timeoutMs: 5_000 diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index 7e9689ad9e4..01dc111ae1b 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -1,6 +1,14 @@ # Files that spawn wsl.exe directly instead of through src/main/wsl/wsl-runner.ts. -# Enforced by ../wsl-invocation-boundary.test.ts. This list only shrinks -- its -# length is the W3 goalpost. +# +# Enforced by ../wsl-invocation-boundary.test.ts. This list only shrinks for a +# MIGRATION -- its length is the W3 goalpost. +# +# It may grow when the scanner learns to see a spawn it was blind to. That has +# happened once: pty-subprocess.ts, local-pty-provider.ts and claude-pty.ts +# bind 'wsl.exe' to a variable and spawn it later, which the old neighbourhood +# scan could not follow. They were recorded in a prose comment instead, so the +# count was wrong by three in the direction that hides offenders. Adding them +# is the guard getting honest, not the boundary regressing. # # Remaining entries need a runner mode that does not exist: a long-lived # streaming child (OAuth logins, the hook relay), a synchronous caller, or a @@ -13,10 +21,13 @@ main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts main/codex/codex-wsl-hook-install-plan.ts +main/daemon/pty-subprocess.ts main/git/runner.ts main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts main/local-worktree-filesystem.ts +main/providers/local-pty-provider.ts +main/rate-limits/claude-pty.ts main/rate-limits/codex-fetcher.ts main/wsl-availability.ts main/wsl-unc-delete.ts diff --git a/src/main/wsl/wsl-guest-environment.ts b/src/main/wsl/wsl-guest-environment.ts index a1e570fb08c..7f154314afb 100644 --- a/src/main/wsl/wsl-guest-environment.ts +++ b/src/main/wsl/wsl-guest-environment.ts @@ -87,6 +87,10 @@ async function probeGuestEnvironment( const result = await runProcess({ program: resolveWslExecutablePath(), args: buildWslExecArgs(distro, ['sh', '-c', captured.command]), + // The runner sets this for every command it launches (#9010); the probe + // spawns wsl.exe itself, so without it wsl.exe's own errors arrive UTF-16LE + // and the NUL-separated payload below is read through NUL-riddled text. + env: { ...process.env, WSL_UTF8: '1' }, timeoutMs: Math.min(PROBE_TIMEOUT_MS, budgetMs), maxOutputBytes: PROBE_MAX_OUTPUT_BYTES }) diff --git a/src/main/wsl/wsl-invocation-boundary.test.ts b/src/main/wsl/wsl-invocation-boundary.test.ts index 47d3398624d..6ff1b93404d 100644 --- a/src/main/wsl/wsl-invocation-boundary.test.ts +++ b/src/main/wsl/wsl-invocation-boundary.test.ts @@ -1,7 +1,11 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { stripComments } from '../../shared/source-scan/source-tree-scan' +import { + blankStringContents, + blankStringContentsDesynced, + stripComments +} from '../../shared/source-scan/source-tree-scan' /** * Every `wsl.exe` spawn must go through `runWslProcess`. @@ -41,28 +45,19 @@ const IGNORED = new Set(['node_modules', 'dist', 'out', 'build', '.git', '__fixt * * Any identifier followed by `(` counts as an opener, and the assignment-style * fields are matched by name. Indirection through a variable - * (`const f = cond ? 'wsl.exe' : x`) is still invisible; those files are listed - * explicitly below so the gap is recorded rather than implied. + * (`const f = cond ? 'wsl.exe' : x`) is caught separately, by + * `bindsWslBinaryToASpawnedIdentifier`. */ const SPAWN_OPENER = /\b[A-Za-z_$][\w$]*\s*\(\s*$|(?:program|binary|command|file|shellPath):\s*$/ /* - * Known blind spot, recorded rather than implied: these files assign - * `'wsl.exe'` to a variable and spawn it elsewhere, which no regex over the - * literal's neighbourhood can see. They are NOT in the allowlist, because the - * stale-entry check would reject an entry the scanner cannot find -- so the - * guard's count under-reports by these five, and this comment is the record. - * - * main/rate-limits/claude-pty.ts - * main/providers/local-pty-provider.ts - * main/daemon/pty-subprocess.ts - * relay/pty-shell-launch.ts - * relay/pty-handler.ts - * - * All five are PTY-pane launches, which are out of W3's scope anyway: they go - * through node-pty, not a child-process wrapper. + * The five files this comment used to list as an unscannable blind spot are + * now handled: three bind `wsl.exe` to a variable and spawn it, and are real + * allowlist entries; the other two never spawned it at all -- one compares a + * basename, one lists it among accepted shells. Recording a gap in prose was + * worse than it looked, because the count is the goalpost and it was wrong by + * three in the direction that hides offenders. */ - function isTestFile(path: string): boolean { return ( /\.(?:test|spec)\.tsx?$/.test(path) || @@ -88,6 +83,64 @@ function collectSourceFiles(root: string): string[] { return found } +/** + * `const binary = 'wsl.exe'` ... `spawn(binary)`, which no test over the + * literal's neighbourhood can see. + * + * Why it earns its place: the neighbourhood test was the whole guard, and a + * planted `const p = 'wsl.exe'; spawnProcess(p)` passed it. Five files were + * already known to spawn this way and were recorded in a comment instead of + * the allowlist, which means the count -- the actual goalpost -- was wrong by + * five and any NEW indirect spawner would have been invisible. + */ +function bindsWslBinaryToASpawnedIdentifier(source: string): boolean { + const bound = new Set() + // Covers `const x = 'wsl.exe'`, a ternary picking it, and `binary: 'wsl.exe'`. + // `const x =`, and the class-field spellings (`private readonly x =`). `[^=]` + // rather than `[^=;\n]` so a Prettier-wrapped ternary still binds. + for (const match of source.matchAll( + /(?:(?:const|let|var|readonly|private|public|protected|static)\s+)+([A-Za-z_$][\w$]*)[^=\n]*=[^;]{0,200}?['"`]wsl\.exe['"`]/g + )) { + bound.add(match[1]!) + } + for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*[^,;\n]*['"`]wsl\.exe['"`]/g)) { + bound.add(match[1]!) + } + // Assignment with no declarator: `this.binary = 'wsl.exe'`, and the split + // form `let shellPath: string` ... `shellPath = 'wsl.exe'`, which a + // declarator-anchored pattern cannot see. `[^;]{0,200}?` so a wrapped + // right-hand side still binds. + for (const match of source.matchAll( + /(?:\bthis\.)?([A-Za-z_$][\w$]*)\s*=[^=][^;]{0,200}?['"`]wsl\.exe['"`]/g + )) { + bound.add(match[1]!) + } + // A helper that hands back the binary is a spawn site one hop away, and the + // hop is untrackable by regex -- but only when this file also spawns + // something. Returning the name as terminal metadata is not a spawn. + if (/\breturn\s+['"`]wsl\.exe['"`]/.test(source) && /\b\w*(?:spawn|exec)\w*\s*\(/i.test(source)) { + return true + } + for (const name of bound) { + const identifier = name.replace(/[$]/g, '\\$&') + // The identifier reaching a call opener, a spawn-style field, or the first + // argument of a spawn-style call. + if ( + new RegExp(`\\b${identifier}\\s*\\(`).test(source) || + new RegExp( + `(?:program|binary|command|file|shellPath)\\s*:\\s*(?:this\\.)?${identifier}\\b` + ).test(source) || + // `this.` so a class field reaching `spawnProcess(this.binary)` counts. + new RegExp(`\\b\\w*(?:spawn|exec|run)\\w*\\s*\\(\\s*(?:this\\.)?${identifier}\\b`, 'i').test( + source + ) + ) { + return true + } + } + return false +} + function findSpawnSites(): string[] { const offenders = new Set() for (const path of collectSourceFiles(SOURCE_ROOT)) { @@ -96,7 +149,7 @@ function findSpawnSites(): string[] { continue } const source = readFileSync(path, 'utf8') - for (const match of source.matchAll(/['"]wsl\.exe['"]/g)) { + for (const match of source.matchAll(/['"`]wsl\.exe['"`]/g)) { // Collapse the preceding whitespace so a call broken across lines by the // formatter still reads as one opener. const preceding = source.slice(Math.max(0, match.index - 60), match.index) @@ -104,6 +157,9 @@ function findSpawnSites(): string[] { offenders.add(relativePath) } } + if (bindsWslBinaryToASpawnedIdentifier(source)) { + offenders.add(relativePath) + } } return [...offenders].sort() } @@ -124,6 +180,51 @@ function findSpawnSites(): string[] { const BASHISM = /<\s*<\(|\[\[|\blocal\s+\w+=|\bdeclare\s+-|\bmapfile\b|set\s+-[a-z]*o[a-z]*\s+pipefail|set\s+-euo\b|read\s+(?:-\w+\s+)*-d\b|<< { const offenders: string[] = [] for (const path of collectSourceFiles(SOURCE_ROOT)) { @@ -136,10 +237,97 @@ describe('bash-only payloads declare their interpreter', () => { // pipefail` to explain why it was removed would otherwise flag the file, // and a bash script written to a guest file is not a runner payload. const source = stripComments(readFileSync(path, 'utf8')) - if (!source.includes('runWslProcess') || !BASHISM.test(source)) { + if (!source.includes('runWslProcess')) { continue } - if (!source.includes("shell: 'bash'")) { + // Fail closed. A desynced lexer finds zero calls, and "zero calls" is + // indistinguishable from "zero violations" -- this guard passed a planted + // dash payload for exactly that reason, because one regex literal earlier + // in the file had inverted the scan. + if (blankStringContentsDesynced(source)) { + offenders.push(relativePath) + continue + } + const calls = collectRunnerCallArguments(source) + // Per-call: an unpinned payload sitting beside a pinned one. + if (calls.some(({ text }) => BASHISM.test(text) && !text.includes("shell: 'bash'"))) { + offenders.push(relativePath) + continue + } + // Anything that is not a plain object literal is judged unreadable, and an + // unreadable call must pin bash. + // + // Six review rounds of widening this regex produced more evasions -- a + // ternary with one pinned branch, an `as` assertion carrying the pin, + // `Object.assign` -- because a regex cannot tell which object a key + // belongs to. So stop guessing: a ternary, a spread or an assertion makes + // the call opaque, and opacity requires the pin rather than excusing it. + // + // A nested CALL is deliberately not exotic: `script: \`x ${shellQuote(p)}\`` + // is the ordinary way every payload here is built, and flagging it would + // demand `shell: 'bash'` on POSIX payloads that must not have it. + // A ternary only makes the call opaque when it CHOOSES the spec, i.e. it + // sits before the first `{`. One inside the object picks a script line and + // is both common and harmless (claude-accounts/service.ts:977). + const isExotic = (text: string): boolean => { + const body = text.replace(/^\(/, '') + const firstBrace = body.indexOf('{') + const prefix = firstBrace === -1 ? body : body.slice(0, firstBrace) + return /\?[^.:]|\.\.\./.test(prefix) || /\bas\s+[A-Za-z{]/.test(body) + } + // No `includes("shell: 'bash'")` escape here: in `cond ? {pinned} : {not}` + // the pin belongs to one branch and the substring test cannot tell which, + // so a pinned branch excused an unpinned one. An exotic call therefore + // cannot be excused -- write it as a plain object literal instead. + if (calls.some(({ text }) => isExotic(text)) && BASHISM.test(source)) { + offenders.push(relativePath) + continue + } + // An opaque payload is judged by the whole file, minus anything already + // declared bash. + // + // Requiring merely that `shell:` be PRESENT was strictly weaker than the + // file-wide rule it replaced: `shell: 'sh'` on a bash payload shipped + // green, which is #14292 with extra steps. Resolving the identifier is + // guesswork, so instead: strip the text of every call that already names + // bash, and if a bashism survives anywhere in the file while a + // script-carrying call is not bash-pinned, flag it. + // + // Stripping the bash-pinned calls is what keeps codex-accounts/service.ts + // clean -- it pins bash on four inline payloads and on a `bash -lc` + // execFileSync, and correctly leaves its printf/mkdir calls unpinned. + // Masked by POSITION, not by String.replace: replace() with a string + // pattern removes only the first match, so two identically-written pinned + // calls would leave one behind, and a body that also occurs earlier as a + // substring would blank the wrong region. + const pinnedRanges: [number, number][] = [ + ...calls + .filter(({ text }) => text.includes("shell: 'bash'")) + .map(({ start, end }): [number, number] => [start, end]), + ...[...source.matchAll(/'bash',\s*\n?\s*'-lc',[\s\S]{0,4000}?\n\s*\]/g)].map( + (m): [number, number] => [m.index, m.index + m[0].length] + ) + ] + const masked = source.split('') + for (const [from, to] of pinnedRanges) { + for (let index = from; index < to; index += 1) { + masked[index] = ' ' + } + } + const unpinnedRegion = masked.join('') + // A spread hides every key, `script` and `shell` alike, so it has to count + // as carrying a script -- otherwise `runWslProcess({ ...spec })` is a hole + // the file-wide arm never looks at. + const scriptCalls = calls.filter(({ text }) => /\bscript\b/.test(text) || /\.\.\./.test(text)) + // Zero collected calls is not zero risk: `Object.assign({...}, {script})` + // puts the payload in the second literal, and a renamed callee that the + // alias scan misses collects nothing at all. If the file carries a bashism + // and the guard cannot see any call object, that is unreadable, not clean. + const unreadable = calls.length === 0 + if ( + BASHISM.test(unpinnedRegion) && + (unreadable || scriptCalls.some(({ text }) => !text.includes("shell: 'bash'"))) + ) { offenders.push(relativePath) } } @@ -152,7 +340,6 @@ describe('bash-only payloads declare their interpreter', () => { describe('wsl.exe is spawned through one runner', () => { const offenders = findSpawnSites() - it('still detects a known spawn shape', () => { // Why name a specific file rather than assert a total: `offenders.length + // ALLOWLIST.length >= N` cannot fail while the allowlist alone exceeds N, diff --git a/src/main/wsl/wsl-runner.test.ts b/src/main/wsl/wsl-runner.test.ts index 6c71e4ee4cc..f92da4d18e3 100644 --- a/src/main/wsl/wsl-runner.test.ts +++ b/src/main/wsl/wsl-runner.test.ts @@ -54,23 +54,23 @@ afterEach(() => { }) describe('separator', () => { - it.each([ - ['probe', 'probe'], - ['interactive', 'interactive'] - ] as const)('uses --exec and never -- on the %s lane', async (_name, lane) => { - // Why this is pinned on both lanes: under `--`, wsl.exe expands $name in - // every forwarded argument before the guest runs -- even with no shell in - // the command -- so a script means something other than what it says - // (#12964). No escaping on our side is a reliable substitute. - seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane, program: '/usr/bin/git', args: ['status'] }) - expect(lastArgv()).toContain('--exec') - expect(lastArgv()).not.toContain('--') - }) + it.each([['none'], ['preferred']] as const)( + 'uses --exec and never -- with loginPath %s', + async (lane) => { + // Why this is pinned on both lanes: under `--`, wsl.exe expands $name in + // every forwarded argument before the guest runs -- even with no shell in + // the command -- so a script means something other than what it says + // (#12964). No escaping on our side is a reliable substitute. + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + await runWslProcess({ loginPath: lane, program: '/usr/bin/git', args: ['status'] }) + expect(lastArgv()).toContain('--exec') + expect(lastArgv()).not.toContain('--') + } + ) it('passes the distro before --exec', async () => { seedWslGuestEnvironmentForTests('Ubuntu', ENVIRONMENT) - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/true' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true' }) expect(lastArgv().slice(0, 3)).toEqual(['-d', 'Ubuntu', '--exec']) }) }) @@ -80,7 +80,7 @@ describe('probe lane', () => { // The whole point: the user's real PATH without paying for -- or being // blocked by -- a login shell on every call (#14288, #9768). seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', program: 'codex', args: ['--version'] }) + await runWslProcess({ loginPath: 'preferred', program: 'codex', args: ['--version'] }) expect(lastArgv()).toEqual([ '--exec', '/usr/bin/env', @@ -91,96 +91,93 @@ describe('probe lane', () => { ]) }) - it('reports an unresolved environment rather than pretending the PATH is real', async () => { - // Falling back to the login shell here would re-run ~/.profile -- the very - // stall the probe lane exists to avoid, and most likely to bite exactly - // when the probe just failed. So the call proceeds on the default PATH and - // says so, and callers deciding "installed?" must treat that as unknown. - let call = 0 - runProcessMock.mockImplementation(async (spec: { args: string[] }) => { - call += 1 - if (call === 1) { - return { environmentResolved: true, code: 1, signal: null, stdout: '', stderr: 'stopped', timedOut: false } - } - const script = spec.args.at(-1) ?? '' - const begin = /__ORCA_WSL_CAPTURE_BEGIN_[a-z0-9]+__/.exec(script)?.[0] ?? '' - const end = /__ORCA_WSL_CAPTURE_END_[a-z0-9]+__/.exec(script)?.[0] ?? '' - return { environmentResolved: true, code: 0, signal: null, stdout: `${begin}${end}`, stderr: '', timedOut: false } + it('runs anyway and says the PATH is unresolved', async () => { + // A missing login PATH used to throw, and every knob this runner carried -- + // cooldown tiers, budget splitting, a re-probe heuristic, an opt-out on 19 + // of 23 sites -- existed to work around that. It is now just a fact in the + // result. + runProcessMock.mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: 'distro is stopped', + timedOut: false }) - // Default is to refuse: answering "is codex installed?" on the bare default - // PATH reports an nvm install as absent, which is #9725. - await expect(runWslProcess({ lane: 'probe', program: 'codex' })).rejects.toThrow( - /guest environment/ - ) - - const degraded = await runWslProcess({ - lane: 'probe', - program: 'codex', - allowDegradedEnvironment: true - }) - expect(degraded.environmentResolved).toBe(false) + const result = await runWslProcess({ loginPath: 'preferred', program: 'codex' }) + expect(result.environmentResolved).toBe(false) expect(lastArgv()).toEqual(['--exec', 'codex']) }) it('reports a resolved environment on the happy path', async () => { seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - const result = await runWslProcess({ lane: 'probe', program: 'codex' }) + const result = await runWslProcess({ loginPath: 'preferred', program: 'codex' }) expect(result.environmentResolved).toBe(true) }) }) -describe('interactive lane', () => { - it('always fences stdout, even when the caller ignores it', async () => { - // Stock Ubuntu writes its rc hint to stdout, so an unfenced parse reads the - // banner as data (#11327, #11823). A caller that starts parsing later must - // not have to remember to opt in. - runProcessMock.mockImplementation(async (spec: { args: string[] }) => { - const script = spec.args.at(-1) ?? '' - const begin = /__ORCA_WSL_CAPTURE_BEGIN_[a-z0-9]+__/.exec(script)?.[0] ?? '' - const end = /__ORCA_WSL_CAPTURE_END_[a-z0-9]+__/.exec(script)?.[0] ?? '' - return { - environmentResolved: true, - code: 0, - signal: null, - stdout: `Ubuntu banner: run a command as administrator\n${begin}payload${end}`, - stderr: '', - timedOut: false - } - }) - const result = await runWslProcess({ lane: 'interactive', program: 'claude' }) - expect(result.stdout).toBe('payload') - }) -}) - describe('scripts', () => { - it('delivers a script on stdin through sh -s, not in argv', async () => { - // A script on stdin has no quoting boundary for its own quotes to escape - // from. That is what the base64 and eval wrappers were working around - // (#14292), and why this is the only supported way to run one. + it('passes a script in argv by default, leaving the command its own stdin', async () => { + // A script the runner pipes cannot coexist with a command that reads stdin: + // the command drains the rest of the script, the shell hits EOF and exits + // 0, and the truncation is silent. argv has no such conflict, and --exec + // means no host-side shell re-parses the quotes (#14292). seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) const script = `case "$x" in a) echo 'it'\\''s fine';; esac` - await runWslProcess({ lane: 'probe', script, args: ['/tmp/root'] }) - expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBe(script) + await runWslProcess({ loginPath: 'preferred', script, args: ['/tmp/root'] }) + expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBeUndefined() expect(lastArgv()).toEqual([ '--exec', '/usr/bin/env', 'PATH=/home/u/.nvm/bin:/usr/bin', 'HOME=/home/u', 'sh', - '-s', + '-c', + script, '--', '/tmp/root' ]) - expect(lastArgv().join(' ')).not.toContain('case') }) -}) -describe('WSLENV', () => { + it('leaves stdin free so a stdin-reading command cannot truncate the script', async () => { + // The regression this pins: with the script piped, `ssh -T` in a user hook + // drains the pipe, bash reads EOF, exits 0, and every later line of the + // hook silently never runs -- while the caller logs success. + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + await runWslProcess({ + loginPath: 'preferred', + shell: 'bash', + script: 'ssh -T git@github.com || true\npnpm install' + }) + const spec = runProcessMock.mock.calls.at(-1)?.[0] + expect(spec.input).toBeUndefined() + expect(spec.args).toContain('ssh -T git@github.com || true\npnpm install') + }) + + it('falls back to stdin for a script too large for a command line', async () => { + // A user hook is the one unbounded script here (`run-both` concatenates two + // orca.yaml scripts, and a vendored installer is ~15KB). Windows caps the + // command line at 32767, so argv would fail to spawn at all; stdin still + // runs it, which is the lesser loss. + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + const script = `echo ${'x'.repeat(9_000)}` + await runWslProcess({ loginPath: 'preferred', shell: 'bash', script }) + const spec = runProcessMock.mock.calls.at(-1)?.[0] + expect(spec.input).toBe(script) + expect(lastArgv().slice(-3)).toEqual(['bash', '-s', '--']) + }) + + it('keeps an ordinary-sized script in argv', async () => { + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + const script = `echo ${'x'.repeat(100)}` + await runWslProcess({ loginPath: 'preferred', shell: 'bash', script }) + expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBeUndefined() + }) + it('adds every propagated key so the value actually crosses the boundary', async () => { // Unset, a Windows-side variable silently never reaches the guest (#12557). seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', program: '/bin/true', env: { GITLAB_HOST: 'git.example.com', GH_TOKEN: 't' } }) @@ -189,17 +186,27 @@ describe('WSLENV', () => { expect(env.WSLENV?.split(':')).toEqual(expect.arrayContaining(['GITLAB_HOST', 'GH_TOKEN'])) }) - it('leaves the host environment alone when nothing is propagated', async () => { + it('always sets WSL_UTF8, even with nothing to propagate', async () => { + // Without it wsl.exe writes its own error text as UTF-16LE, so anything + // surfacing stderr shows NUL-riddled output (#9010). seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', program: '/bin/true' }) - expect(runProcessMock.mock.calls.at(-1)?.[0].env).toBeUndefined() + await runWslProcess({ loginPath: 'preferred', program: '/bin/true' }) + const env = runProcessMock.mock.calls.at(-1)?.[0].env as NodeJS.ProcessEnv + expect(env.WSL_UTF8).toBe('1') + }) + + it('adds no WSLENV entry when nothing is propagated', async () => { + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + await runWslProcess({ loginPath: 'preferred', program: '/bin/true' }) + const env = runProcessMock.mock.calls.at(-1)?.[0].env as NodeJS.ProcessEnv + expect(env.WSLENV).toBe(process.env.WSLENV) }) }) describe('guest cwd', () => { it('cds inside the guest rather than passing a Windows cwd to wsl.exe', async () => { seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', program: '/usr/bin/git', cwd: '/home/u/repo' }) + await runWslProcess({ loginPath: 'preferred', program: '/usr/bin/git', cwd: '/home/u/repo' }) expect(runProcessMock.mock.calls.at(-1)?.[0].cwd).toBeUndefined() expect(lastArgv()).toContain('/home/u/repo') expect(lastArgv()).toContain('sh') @@ -209,9 +216,9 @@ describe('guest cwd', () => { // A Windows path here means a mistake further up; converting it silently // hides that. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await expect(runWslProcess({ lane: 'probe', program: '/bin/true', cwd })).rejects.toThrow( - /guest path/ - ) + await expect( + runWslProcess({ loginPath: 'preferred', program: '/bin/true', cwd }) + ).rejects.toThrow(/guest path/) }) }) @@ -219,7 +226,9 @@ describe('program is a binary, not a shell string', () => { it.each([['sh -c echo hi'], ['a; b'], ['a | b'], ['a && b'], ['echo $HOME'], ['a > b']])( 'rejects %s', async (program) => { - await expect(runWslProcess({ lane: 'probe', program })).rejects.toThrow(/single binary/) + await expect(runWslProcess({ loginPath: 'preferred', program })).rejects.toThrow( + /single binary/ + ) } ) @@ -228,55 +237,17 @@ describe('program is a binary, not a shell string', () => { // rejecting it would fail legitimate installs under a spaced directory. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) await expect( - runWslProcess({ lane: 'probe', program: '/home/u/my tools/codex' }) + runWslProcess({ loginPath: 'preferred', program: '/home/u/my tools/codex' }) ).resolves.toBeDefined() }) }) -describe('a script gets the cached environment on both lanes', () => { - it('applies the cached PATH even when the caller asked for interactive', async () => { - // A script never runs under the login shell (it owns stdin), so without - // this the interactive lane would give a script LESS PATH than the probe - // lane -- for a caller that explicitly asked for the user's terminal PATH. - seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'interactive', script: 'command -v codex' }) - expect(lastArgv()).toEqual([ - '--exec', - '/usr/bin/env', - 'PATH=/home/u/.nvm/bin:/usr/bin', - 'HOME=/home/u', - 'sh', - '-s', - '--' - ]) - }) -}) - -describe('a missing fence is a failure, not empty output', () => { - it('throws rather than returning a clean empty result', async () => { - // readStdout returns null precisely to distinguish "no fence" from "empty - // payload". An rc that redirects stdout would otherwise yield a silent - // wrong answer. - runProcessMock.mockResolvedValue({ - environmentResolved: true, - code: 0, - signal: null, - stdout: 'banner only, no fence', - stderr: '', - timedOut: false - }) - await expect(runWslProcess({ lane: 'interactive', program: 'claude' })).rejects.toThrow( - /no fenced output/ - ) - }) -}) - describe('program is not an assignment', () => { it('rejects a name=value program that env would swallow', async () => { // `env PATH=… HOME=… FOO=bar` has no command left: it prints the whole // guest environment and exits 0 -- success, with the environment as stdout. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await expect(runWslProcess({ lane: 'probe', program: 'FOO=bar' })).rejects.toThrow( + await expect(runWslProcess({ loginPath: 'preferred', program: 'FOO=bar' })).rejects.toThrow( /assignment/ ) }) @@ -288,7 +259,7 @@ describe('timeout budget', () => { // 5s caller could reach runProcess with 1ms left and report a timeout for a // command that would have taken milliseconds. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', program: '/bin/true', timeoutMs: 5_000 }) + await runWslProcess({ loginPath: 'preferred', program: '/bin/true', timeoutMs: 5_000 }) const passed = runProcessMock.mock.calls.at(-1)?.[0].timeoutMs as number expect(passed).toBeGreaterThan(1_000) expect(passed).toBeLessThanOrEqual(5_000) @@ -305,13 +276,12 @@ describe('the probe never starves the command', () => { // ~1667ms -- less than the 5s it had before the runner existed, which is // how a cold distro came back as "not installed". // No seed: the probe must actually run, or this measures the command leg - // and passes for any split. allowDegradedEnvironment keeps the call alive - // once the probe fails. + // and passes for any split. A failed probe is non-fatal now, so the call + // still reaches its command. await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', program: '/bin/true', - timeoutMs, - allowDegradedEnvironment: true + timeoutMs }) const probeMs = runProcessMock.mock.calls[0]?.[0].timeoutMs as number expect(probeMs).toBeLessThanOrEqual(timeoutMs - floor) @@ -321,7 +291,7 @@ describe('the probe never starves the command', () => { describe('script interpreter', () => { it('defaults to sh', async () => { seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', script: 'echo hi' }) + await runWslProcess({ loginPath: 'preferred', script: 'echo hi' }) expect(lastArgv()).toContain('sh') expect(lastArgv()).not.toContain('bash') }) @@ -332,17 +302,17 @@ describe('script interpreter', () => { // unexpected` -- the #14292 signature -- so a bash caller must be able to // say so rather than be silently downgraded. seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) - await runWslProcess({ lane: 'probe', script: 'done < <(find .)', shell: 'bash' }) + await runWslProcess({ loginPath: 'preferred', script: 'done < <(find .)', shell: 'bash' }) expect(lastArgv()).toContain('bash') expect(lastArgv()).not.toContain('sh') }) }) -describe('a script never rides the interactive lane', () => { - it('runs sh -s even when the distro cannot be probed', async () => { - // The login shell owns stdin, and the script arrives on stdin. If the shell - // consumed it, `sh -s` would read EOF, run nothing and exit 0 -- a silent - // wrong answer, worse than the degraded PATH this avoids. +describe('a script never rides a login shell', () => { + it('runs the script shell-free even when the distro cannot be probed', async () => { + // No login shell in the way: it would own stdin and could consume it, + // leaving the script's shell to read EOF, run nothing and exit 0 -- a + // silent wrong answer, worse than the degraded PATH this avoids. runProcessMock.mockResolvedValue({ environmentResolved: true, code: 1, @@ -351,8 +321,26 @@ describe('a script never rides the interactive lane', () => { stderr: 'distro is stopped', timedOut: false }) - await runWslProcess({ lane: 'probe', script: 'echo hi', allowDegradedEnvironment: true }) - expect(lastArgv()).toEqual(['--exec', 'sh', '-s', '--']) - expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBe('echo hi') + await runWslProcess({ loginPath: 'preferred', script: 'echo hi' }) + expect(lastArgv()).toEqual(['--exec', 'sh', '-c', 'echo hi', '--']) + expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBeUndefined() + }) +}) + +describe('WSLENV', () => { + it('never forwards a path-shaped variable', async () => { + // wsl.exe translates path-shaped variables between Windows and Linux form, + // so naming PATH in WSLENV replaces the guest's own PATH with a translated + // Windows one. Silent, and fatal to every lookup that follows. + seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT) + await runWslProcess({ + loginPath: 'none', + program: '/bin/true', + env: { PATH: 'C:\\bin', HOME: 'C:\\home', GITLAB_HOST: 'git.example.com' } + }) + const wslenv = String(runProcessMock.mock.calls.at(-1)?.[0].env.WSLENV ?? '') + expect(wslenv.split(':')).toContain('GITLAB_HOST') + expect(wslenv.split(':')).not.toContain('PATH') + expect(wslenv.split(':')).not.toContain('HOME') }) }) diff --git a/src/main/wsl/wsl-runner.ts b/src/main/wsl/wsl-runner.ts index cebc88b3831..f1a51d4b923 100644 --- a/src/main/wsl/wsl-runner.ts +++ b/src/main/wsl/wsl-runner.ts @@ -1,10 +1,6 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import { runProcess } from '../../shared/child-process/run-process' -import { - buildWslCapturedLoginShellCommand, - buildWslExecArgs, - quotePosixShell -} from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { getWslGuestEnvironment, type WslGuestEnvironment } from './wsl-guest-environment' import { resolveWslExecutablePath } from './wsl-executable-path' @@ -17,22 +13,35 @@ import { resolveWslExecutablePath } from './wsl-executable-path' * docs/reference/wsl-command-execution.md. */ -export type WslLane = - /** No shell. Cached login PATH/HOME, applied via `env`. */ - | 'probe' - /** Login shell, always fenced. For anything whose PATH must match the user's terminal. */ - | 'interactive' +/** + * How much the call needs the user's login PATH. + * + * Why one axis and not `lane` + `allowDegradedEnvironment`: 19 of 23 sites + * passed the opt-out, and two of them said in comments that they did not want + * the login PATH at all -- the flag had become the `'none'` this union was + * missing. A required discriminator whose default nobody wants is not a + * discriminator. + */ +export type WslLoginPath = + /** The command reads $HOME or absolute paths only. No probe. */ + | 'none' + /** Use the cached login PATH when there is one; run anyway when there is not. */ + | 'preferred' /** * What to run: a single binary, or a script. * - * Why a union rather than an optional `script`: a script has to arrive on - * stdin, which means the program must be a shell reading stdin. Left to the - * caller that is a footgun — and the wrappers this replaces exist because it - * was never made explicit. `script` makes the runner supply `sh -s` itself. + * Why a union rather than an optional `script`: a script needs a shell to run + * it, and picking one per call site is how the wrappers this replaces drifted + * apart. `script` makes the runner supply the shell, and `shell` says which. */ export type WslCommand = - | { program: string; args?: readonly string[]; script?: never; shell?: never } + | { + program: string + args?: readonly string[] + script?: never + shell?: never + } | { script: string args?: readonly string[] @@ -53,39 +62,29 @@ export type WslCommand = export type WslSpec = WslCommand & { /** Undefined selects the distro's default. */ distro?: string - /** - * Required, with no default. The wrong lane is the most common WSL defect in - * this tree, and a default lets a call site pick it by omission. - */ - lane: WslLane + /** Required, with no default: the wrong answer here is the defect this file exists to prevent. */ + loginPath: WslLoginPath /** Guest (POSIX) path. */ cwd?: string /** Host variables to propagate into the guest; sets WSLENV automatically. */ env?: Readonly> timeoutMs?: number maxOutputBytes?: number - /** - * Proceed when the login PATH could not be established. - * - * Default is to throw. Three separate reviews found the same class of bug -- - * a call that answers "is this installed?" running on the bare default PATH - * and reporting an nvm-installed tool absent (#9725). Making degradation - * opt-in puts that decision in the one place a reader will look, instead of - * relying on every call site to remember to check `environmentResolved`. - */ - allowDegradedEnvironment?: boolean } export type WslResult = { /** - * False when the login PATH could not be established, so the call ran on the - * distro's default PATH. A caller deciding "is this tool installed?" must - * report unverifiable rather than absent -- an nvm-installed binary is - * invisible without the login PATH, which is #9725 exactly. + * False when `loginPath: 'preferred'` could not establish the login PATH, so + * the call ran on the distro's default PATH. A caller deciding "is this tool + * installed?" must then report unverifiable rather than absent -- an + * nvm-installed binary is invisible without it, which is #9725 exactly. + * + * Always true under `loginPath: 'none'`, because such a command asked for no + * PATH and cannot be let down by one. A PATH lookup marked 'none' therefore + * gets no protection from this field; the value has to be right. */ environmentResolved: boolean code: number | null - /** Payload only — on the interactive lane the rc banner is removed by the fence. */ stdout: string stderr: string timedOut: boolean @@ -93,21 +92,6 @@ export type WslResult = { export const DEFAULT_WSL_TIMEOUT_MS = 30_000 -/** - * The guest login PATH could not be established. - * - * Typed so a caller answering "is this installed?" can report unverifiable - * rather than absent -- reporting an nvm-installed tool absent is #9725, and a - * bare Error would just be swallowed by the same catch that handles real - * failures. - */ -export class WslGuestEnvironmentUnavailableError extends Error { - constructor(distro: string | undefined) { - super(`WSL guest environment for ${distro ?? 'the default distro'} is unavailable`) - this.name = 'WslGuestEnvironmentUnavailableError' - } -} - function assertGuestPath(cwd: string): void { // Why reject rather than convert: a caller passing a Windows path here has // usually made a different mistake further up, and silently translating it @@ -131,13 +115,27 @@ function assertNotShellString(program: string): void { } } -/** Host env plus the WSLENV entries that let it cross the boundary. */ -function buildHostEnv(env: WslSpec['env']): NodeJS.ProcessEnv | undefined { - if (!env || Object.keys(env).length === 0) { - return undefined +/** + * Host env plus WSL_UTF8 and the WSLENV entries that let values cross. + * + * Why WSL_UTF8 unconditionally: without it `wsl.exe` writes its OWN messages + * ("There is no distribution with the supplied name") as UTF-16LE, so every + * caller that surfaces stderr shows NUL-riddled text. Setting it per-call site + * is how it got lost -- the relay set it, the migration dropped it, and nothing + * noticed because the happy path is pure ASCII. Credit: #9010. + */ +function buildHostEnv(env: WslSpec['env']): NodeJS.ProcessEnv { + const merged: NodeJS.ProcessEnv = { ...process.env, ...env, WSL_UTF8: '1' } + // Never name a path-shaped variable in WSLENV. wsl.exe translates those + // between Windows and Linux form, so forwarding PATH replaces the guest's + // own PATH with a translated Windows one -- silently, and this runner exists + // to make that class of mistake impossible rather than to document it. + const crossable = Object.keys(env ?? {}).filter( + (key) => !['PATH', 'HOME', 'TMP', 'TEMP'].includes(key) + ) + if (crossable.length > 0) { + addWslEnvKeys(merged, crossable) } - const merged: NodeJS.ProcessEnv = { ...process.env, ...env } - addWslEnvKeys(merged, Object.keys(env)) return merged } @@ -161,54 +159,51 @@ function withGuestCwd(cwd: string | undefined, argv: readonly string[]): string[ return ['sh', '-c', 'cd "$1" || exit 1; shift; exec "$@"', 'orca-wsl', cwd, ...argv] } -/** ` -s --` for a script on stdin, otherwise the program itself. */ -function guestCommandArgv(spec: WslSpec): string[] { - return spec.script !== undefined - ? [spec.shell ?? 'sh', '-s', '--', ...(spec.args ?? [])] - : [spec.program, ...(spec.args ?? [])] +/** + * Argv is the default, but it has a hard ceiling that stdin does not. + * + * Windows caps a command line at 32767 characters, and the distro, `--exec`, + * the env prefix and the args all share it. A user's `orca.yaml` hook is the + * one unbounded script Orca runs -- `run-both` concatenates two of them, and a + * vendored installer is ~15KB -- so past this size the choice is between + * failing to spawn at all and accepting the stdin caveat. Degrading beats + * failing: a large script that also reads stdin was already broken, while a + * large script that does not now works where it would have died. + */ +const MAX_ARGV_SCRIPT_CHARS = 8_000 + +/** ` -c`/`-s` for a script, otherwise the program itself. */ +function guestCommandArgv(spec: WslSpec, delivery: 'argv' | 'stdin'): string[] { + if (spec.script === undefined) { + return [spec.program, ...(spec.args ?? [])] + } + const shell = spec.shell ?? 'sh' + // `--` keeps positional args starting at $1 under both forms. + return delivery === 'stdin' + ? [shell, '-s', '--', ...(spec.args ?? [])] + : [shell, '-c', spec.script, '--', ...(spec.args ?? [])] } /** Shell-free argv, with the cached environment applied when one is available. */ -function buildGuestArgv(environment: WslGuestEnvironment | null, spec: WslSpec): string[] { - const command = guestCommandArgv(spec) +function buildGuestArgv( + environment: WslGuestEnvironment | null, + spec: WslSpec, + delivery: 'argv' | 'stdin' +): string[] { + const command = guestCommandArgv(spec, delivery) const argv = environment ? [environment.envBinary, `PATH=${environment.path}`, `HOME=${environment.home}`, ...command] : command return withGuestCwd(spec.cwd, argv) } -function buildInteractiveArgv(spec: WslSpec): { - argv: string[] - readStdout: (stdout: string) => string -} { - // Why the whole invocation goes through the fence: a caller that does not - // parse stdout today may start tomorrow, and the banner is invisible until - // then. - const quoted = guestCommandArgv(spec).map(quotePosixShell).join(' ') - const body = spec.cwd ? `cd ${quotePosixShell(spec.cwd)} || exit 1\n${quoted}` : quoted - const captured = buildWslCapturedLoginShellCommand(body) - return { - argv: ['sh', '-c', captured.command], - // Why throw rather than default to '': readStdout returns null precisely to - // distinguish "the fence never appeared" from "the payload was empty". An rc - // that redirects stdout, or output truncated before the begin marker, would - // otherwise return a clean, empty, wrong answer. - readStdout: (stdout: string) => { - const payload = captured.readStdout(stdout) - if (payload === null) { - throw new Error('WSL login shell produced no fenced output') - } - return payload - } - } -} - /** * Run a program inside WSL. * - * Throws when the guest login PATH cannot be established, unless the caller - * passes `allowDegradedEnvironment`. Falling back to the login shell here would - * re-run ~/.profile -- the stall this exists to remove. + * A missing login PATH is never fatal: the call runs on the distro's default + * PATH and reports `environmentResolved: false`. Every knob this file used to + * carry -- cooldown tiers, budget splitting, a re-probe heuristic, an opt-out + * flag on 19 of 23 sites -- existed only because that case used to throw. */ export async function runWslProcess(spec: WslSpec): Promise { if (spec.program !== undefined) { @@ -219,13 +214,7 @@ export async function runWslProcess(spec: WslSpec): Promise { } const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS) - // Why both lanes when there is a script: a script never runs under the login - // shell (see below), so on the interactive lane it would otherwise get no - // login PATH at all -- strictly less than the probe lane, for a caller that - // explicitly asked for the user's terminal PATH. - const wantsEnvironment = spec.lane === 'probe' || spec.script !== undefined - // Leave the command at least a third of the budget: a probe that eats it all - // turns a healthy command into a spurious timeout. + const wantsEnvironment = spec.loginPath === 'preferred' // Cap the probe at half the budget and at 4s: a 5s caller was giving the // probe 3333ms and its own command 1667ms, tighter than the 5s it had before // the runner existed, which is how a cold distro read as "not installed". @@ -240,23 +229,20 @@ export async function runWslProcess(spec: WslSpec): Promise { // the probe most often fails *because* the distro is slow, so the fallback // would hit the hazard exactly when it is worst. Run shell-free with the // distro's default PATH instead: degraded, never blocking. - if (wantsEnvironment && environment === null && !spec.allowDegradedEnvironment) { - throw new WslGuestEnvironmentUnavailableError(spec.distro) - } - - const lane = - spec.lane === 'interactive' && spec.script === undefined - ? ({ kind: 'interactive', ...buildInteractiveArgv(spec) } as const) - : ({ kind: 'probe', argv: buildGuestArgv(environment, spec) } as const) + // Resolved once: argv shape and stdin payload must agree, and computing it + // in both places invites them to drift. + const delivery: 'argv' | 'stdin' = + spec.script !== undefined && spec.script.length > MAX_ARGV_SCRIPT_CHARS ? 'stdin' : 'argv' + const argv = buildGuestArgv(environment, spec, delivery) // One budget for the whole call: the probe used to run on its own 10s timer // ahead of the timed leg, so a 5s caller could wait 15s. const remainingMs = Math.max(1, deadline - Date.now()) const result = await runProcess({ program: resolveWslExecutablePath(), - args: buildWslExecArgs(spec.distro, lane.argv), + args: buildWslExecArgs(spec.distro, argv), env: buildHostEnv(spec.env), - input: spec.script, + input: delivery === 'stdin' ? spec.script : undefined, timeoutMs: remainingMs, maxOutputBytes: spec.maxOutputBytes }) @@ -264,7 +250,7 @@ export async function runWslProcess(spec: WslSpec): Promise { return { environmentResolved: !wantsEnvironment || environment !== null, code: result.code, - stdout: lane.kind === 'interactive' ? lane.readStdout(result.stdout) : result.stdout, + stdout: result.stdout, stderr: result.stderr, timedOut: result.timedOut } diff --git a/src/main/wsl/wsl-runner.wsl.test.ts b/src/main/wsl/wsl-runner.wsl.test.ts index cd75de1ac02..c31ac669140 100644 --- a/src/main/wsl/wsl-runner.wsl.test.ts +++ b/src/main/wsl/wsl-runner.wsl.test.ts @@ -43,16 +43,14 @@ describeOnWsl('runWslProcess against a real distro', () => { }, 120_000) it('probe lane survives a ~/.profile that blocks for a minute', async () => { - // The blocking profile makes the probe time out, so the lane degrades -- - // that is the point: the call must still answer inside its own budget - // rather than inheriting the 60s stall. This is #14288 against a real - // distro, and it needs allowDegradedEnvironment because the runner - // otherwise refuses an unresolved PATH. + // The blocking profile makes the probe time out -- that is the point: the + // call must still answer inside its own budget rather than inheriting the + // 60s stall. This is #14288 against a real distro, and it relies on a + // failed probe being non-fatal. const started = Date.now() const result = await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, - allowDegradedEnvironment: true, program: '/bin/echo', args: ['orca-probe-ok'], timeoutMs: 15_000 @@ -65,11 +63,10 @@ describeOnWsl('runWslProcess against a real distro', () => { it('second probe-lane call does not pay the login shell again', async () => { const started = Date.now() await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, program: '/bin/true', - timeoutMs: 15_000, - allowDegradedEnvironment: true + timeoutMs: 15_000 }) expect(Date.now() - started).toBeLessThan(5_000) }, 30_000) @@ -78,7 +75,7 @@ describeOnWsl('runWslProcess against a real distro', () => { // Stock Ubuntu writes its rc hint to stdout. Anything parsing that stream // reads the banner as data unless the fence removes it (#11327, #11823). const result = await runWslProcess({ - lane: 'interactive', + loginPath: 'preferred', distro: DISTRO, program: '/bin/echo', args: ['ORCA_PAYLOAD'], @@ -96,9 +93,8 @@ describeOnWsl('runWslProcess against a real distro', () => { `echo "x" | awk '{print $1}'` ].join('\n') const result = await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, - allowDegradedEnvironment: true, script, args: ['ORCA_ARG'], timeoutMs: 30_000 @@ -113,9 +109,8 @@ describeOnWsl('runWslProcess against a real distro', () => { it('propagated env crosses the boundary via WSLENV', async () => { const result = await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, - allowDegradedEnvironment: true, script: 'printf %s "$ORCA_WSLENV_PROBE"', env: { ORCA_WSLENV_PROBE: 'crossed' }, timeoutMs: 30_000 @@ -125,9 +120,8 @@ describeOnWsl('runWslProcess against a real distro', () => { it('runs in the requested guest cwd', async () => { const result = await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: DISTRO, - allowDegradedEnvironment: true, program: '/bin/pwd', cwd: '/tmp', timeoutMs: 30_000 diff --git a/src/main/wsl/wsl-w1-w3-contract.test.ts b/src/main/wsl/wsl-w1-w3-contract.test.ts index df297d9177a..0da8d0f900b 100644 --- a/src/main/wsl/wsl-w1-w3-contract.test.ts +++ b/src/main/wsl/wsl-w1-w3-contract.test.ts @@ -44,27 +44,32 @@ describe('W1: every WSL call inherits the spawn chokepoint', () => { it('resolves wsl.exe absolutely, never by bare name', async () => { // Bare-name resolution is what a Group Policy or a stripped Electron PATH // hijacks (#15749). - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/true' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true' }) expect(spawnSpec().program).toBe('C:\\Windows\\System32\\wsl.exe') }) it('passes argv as an array, never a command string', async () => { // A command string would put quoting back in the caller's hands, which is // the entire class W1 removed. - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/echo', args: ['a b'] }) + await runWslProcess({ + loginPath: 'preferred', + distro: 'Ubuntu', + program: '/bin/echo', + args: ['a b'] + }) expect(Array.isArray(spawnSpec().args)).toBe(true) expect(spawnSpec().args).toContain('a b') }) it('always bounds the call', async () => { - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/true' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true' }) expect(spawnSpec().timeoutMs).toBeGreaterThan(0) }) }) describe('W3: the five per-call decisions are made once', () => { it('never uses the -- separator, which expands $name host-side', async () => { - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', script: 'echo "$HOME"' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', script: 'echo "$HOME"' }) const argv = spawnSpec().args as string[] // Only wsl.exe's own separator matters. A later `--` belongs to `sh -s --` // and is the guest shell's end-of-options, so check the position rather @@ -74,17 +79,18 @@ describe('W3: the five per-call decisions are made once', () => { }) it('keeps a script byte-identical instead of encoding it', async () => { - // The base64 and eval wrappers existed because argv quoting kept breaking; - // stdin has no quoting boundary at all (#14292). + // The base64 and eval wrappers existed because a host-side shell re-parsed + // the quotes (#14292). --exec removes that shell, so the script crosses in + // argv exactly as written -- no encoding, and stdin stays the command's. const script = `case "$x" in a) echo 'it'\\''s fine';; esac` - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', script }) - expect(spawnSpec().input).toBe(script) - expect(JSON.stringify(spawnSpec().args)).not.toContain('case') + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', script }) + expect(spawnSpec().args).toContain(script) + expect(spawnSpec().input).toBeUndefined() }) it('propagates env only through WSLENV', async () => { await runWslProcess({ - lane: 'probe', + loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true', env: { GITLAB_HOST: 'git.example.com' } @@ -95,14 +101,14 @@ describe('W3: the five per-call decisions are made once', () => { it('runs no shell on the probe lane, so ~/.profile cannot stall it', async () => { // #14288: one blocking line in ~/.profile ate the whole timeout. - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: 'codex' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: 'codex' }) expect(JSON.stringify(spawnSpec().args)).not.toContain('_orca_wsl_shell') }) it('applies the user login PATH even with no shell in the loop', async () => { // The other half of the same trade: nvm-installed agents must still be // found (#9725, #7563, #8366). - await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: 'codex' }) + await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: 'codex' }) expect(spawnSpec().args).toContain('PATH=/home/u/.nvm/bin:/usr/bin') }) }) @@ -118,7 +124,11 @@ describe('failure modes stay distinguishable', () => { stderr: '', timedOut: false }) - const result = await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/false' }) + const result = await runWslProcess({ + loginPath: 'preferred', + distro: 'Ubuntu', + program: '/bin/false' + }) expect(result.code).toBe(3) expect(result.timedOut).toBe(false) }) @@ -131,11 +141,15 @@ describe('failure modes stay distinguishable', () => { stderr: '', timedOut: true }) - const result = await runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: '/bin/true' }) + const result = await runWslProcess({ + loginPath: 'preferred', + distro: 'Ubuntu', + program: '/bin/true' + }) expect(result.timedOut).toBe(true) }) - it('an unresolved login PATH refuses rather than answering wrongly', async () => { + it('an unresolved login PATH is reported, never fatal', async () => { invalidateWslGuestEnvironment(undefined, true) runProcessMock.mockResolvedValue({ code: 1, @@ -144,8 +158,12 @@ describe('failure modes stay distinguishable', () => { stderr: 'stopped', timedOut: false }) - await expect( - runWslProcess({ lane: 'probe', distro: 'Ubuntu', program: 'codex' }) - ).rejects.toThrow(/guest environment/) + // Every knob this runner used to carry existed because this case threw. + const result = await runWslProcess({ + loginPath: 'preferred', + distro: 'Ubuntu', + program: 'codex' + }) + expect(result.environmentResolved).toBe(false) }) }) diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index cedcf8b797c..0e7dfd282fc 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -31,12 +31,16 @@ describe('addWorktreeOp', () => { it('writes durable branch base config after creating an SSH new-branch worktree', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - await addWorktreeOp(git, { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main' - }) + await addWorktreeOp( + git, + { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main' + }, + 'linux' + ) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], @@ -75,13 +79,17 @@ describe('addWorktreeOp', () => { it('does not write branch base config when checking out an existing SSH branch', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - await addWorktreeOp(git, { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main', - checkoutExistingBranch: true - }) + await addWorktreeOp( + git, + { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main', + checkoutExistingBranch: true + }, + 'linux' + ) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'add', '/repo-feature', 'feature/test'] @@ -91,11 +99,15 @@ describe('addWorktreeOp', () => { it('does not write branch base config when SSH creation has no base', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - await addWorktreeOp(git, { - repoPath: '/repo', - branchName: 'feature/no-base', - targetDir: '/repo-feature' - }) + await addWorktreeOp( + git, + { + repoPath: '/repo', + branchName: 'feature/no-base', + targetDir: '/repo-feature' + }, + 'linux' + ) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'], @@ -103,6 +115,76 @@ describe('addWorktreeOp', () => { ]) }) + it('enables long paths when the SSH execution host is Windows', async () => { + // Why: only the host's OS matters — a macOS client can drive a Windows SSH host, + // which hits the same MAX_PATH ceiling (issue #15785). + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp( + git, + { + repoPath: 'C:\\repo', + branchName: 'feature/test', + targetDir: 'C:\\repo-feature', + checkoutExistingBranch: true + }, + 'win32' + ) + + expect(git.mock.calls.map((call) => call[0])).toEqual([ + ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'] + ]) + }) + + it('keeps --no-checkout ahead of -b once the long-path prefix is present', async () => { + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp( + git, + { + repoPath: 'C:\\repo', + branchName: 'feature/test', + targetDir: 'C:\\repo-feature', + noCheckout: true + }, + 'win32' + ) + + expect(git.mock.calls[0][0]).toEqual([ + '-c', + 'core.longpaths=true', + 'worktree', + 'add', + '--no-track', + '--no-checkout', + '-b', + 'feature/test', + 'C:\\repo-feature' + ]) + }) + + it('omits the long-path option on a WSL UNC target on a Windows SSH host', async () => { + const git = vi.fn(async () => ({ stdout: '', stderr: '' })) + + await addWorktreeOp( + git, + { + repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', + branchName: 'feature/test', + targetDir: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', + checkoutExistingBranch: true + }, + 'win32' + ) + + expect(git.mock.calls[0][0]).toEqual([ + 'worktree', + 'add', + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', + 'feature/test' + ]) + }) + it('warns and unsets stale branch base config when SSH base persistence fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const git = vi.fn(async (args) => { @@ -113,12 +195,16 @@ describe('addWorktreeOp', () => { }) await expect( - addWorktreeOp(git, { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main' - }) + addWorktreeOp( + git, + { + repoPath: '/repo', + branchName: 'feature/test', + targetDir: '/repo-feature', + base: 'origin/main' + }, + 'linux' + ) ).resolves.toBeUndefined() expect(warnSpy).toHaveBeenCalledWith( diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index 0cdb0761b47..4095851a82f 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -1,5 +1,6 @@ import * as path from 'node:path' import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' +import { windowsLongPathGitArgs } from '../shared/windows-long-path-git-args' import type { GitExec } from './git-handler-ops' export { removeWorktreeOp } from './git-handler-worktree-remove' export { readRelayWorktreeList } from './git-handler-worktree-list' @@ -28,7 +29,12 @@ async function persistRelayWorktreeCreationBase( } } -export async function addWorktreeOp(git: GitExec, params: Record): Promise { +export async function addWorktreeOp( + git: GitExec, + params: Record, + // Why: only the execution host's OS matters here — the client may be macOS while the SSH host is Windows. + platform: NodeJS.Platform = process.platform +): Promise { const repoPath = params.repoPath as string const branchName = params.branchName as string const targetDir = params.targetDir as string @@ -62,11 +68,14 @@ export async function addWorktreeOp(git: GitExec, params: Record { expect(spawnOptions.env.PATH).toBe(expectedEnv.PATH) }) + describe('half-activated conda env (#14195)', () => { + const CONDA_KEYS = [ + 'CONDA_SHLVL', + 'CONDA_PREFIX', + 'CONDA_DEFAULT_ENV', + 'CONDA_PROMPT_MODIFIER', + 'CONDA_EXE' + ] as const + + const withRelayCondaEnv = async ( + relayEnv: Partial>, + spawnParams: Record + ): Promise> => { + const saved = Object.fromEntries(CONDA_KEYS.map((key) => [key, process.env[key]])) + try { + for (const key of CONDA_KEYS) { + delete process.env[key] + } + Object.assign(process.env, relayEnv) + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, ...spawnParams }) + } finally { + for (const key of CONDA_KEYS) { + const value = saved[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + return (mockPtySpawn.mock.calls.at(-1)![2] as { env: Record }).env + } + + it('drops the sentinel the remote relay inherited without a prefix', async () => { + // Why the relay owns this: the execution host composes its own env, so a + // remote relay launched from a half-activated login shell would otherwise + // hand the broken pair to every SSH terminal. + const env = await withRelayCondaEnv( + { + CONDA_SHLVL: '1', + CONDA_DEFAULT_ENV: 'base', + CONDA_PROMPT_MODIFIER: '(base) ', + CONDA_EXE: '/opt/miniconda3/bin/conda' + }, + {} + ) + + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined() + expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') + }) + + it('keeps activation state a client explicitly repaired', async () => { + const env = await withRelayCondaEnv( + { CONDA_SHLVL: '1', CONDA_DEFAULT_ENV: 'base' }, + { env: { CONDA_PREFIX: '/opt/miniconda3' } } + ) + + expect(env.CONDA_PREFIX).toBe('/opt/miniconda3') + expect(env.CONDA_SHLVL).toBe('1') + expect(env.CONDA_DEFAULT_ENV).toBe('base') + }) + + it('drops the sentinel when the client deletes CONDA_PREFIX', async () => { + // Why: the relay's other inherited-env scrubbers run BEFORE envToDelete, + // so anchoring the coherence pass beside them would re-create the crash. + const env = await withRelayCondaEnv( + { CONDA_SHLVL: '1', CONDA_PREFIX: '/opt/miniconda3', CONDA_DEFAULT_ENV: 'base' }, + { envToDelete: ['CONDA_PREFIX'] } + ) + + expect(env.CONDA_PREFIX).toBeUndefined() + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + }) + }) + it('does not inherit legacy attribution state from the relay process', async () => { const keys = ['ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_ATTRIBUTION_SHIM_DIR', 'PATH'] as const const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index f9392ad74d3..a9fd21a4fe2 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -50,6 +50,7 @@ import type { TuiAgent } from '../shared/tui-agent' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir' +import { dropIncoherentCondaActivationEnv } from '../main/pty/conda-activation-env' import { dropInheritedOrcaFishHistory } from '../main/fish-history-session' import { dropInheritedOrcaHistFile } from '../main/worktree-history-file-path' import { @@ -694,6 +695,9 @@ export class PtyHandler { if (!result.TERM) { result.TERM = 'xterm-256color' } + // Why last, not beside the scrubbers above: the relay runs those BEFORE envToDelete, + // so an envToDelete of CONDA_PREFIX would otherwise re-create the broken pair. + dropIncoherentCondaActivationEnv(result, process.platform) expandWindowsPathEnvironmentVariables(result) return result } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5ef5ecdd9bc..cdd08d323d6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -37,7 +37,11 @@ function App(): React.JSX.Element { const onboardingGate = useOnboardingAndFeatureTips() const clearUnreadDockBadge = useUnreadDockBadge() - useAppShellServices() + // Why enabled && open: the overlay only renders while the feature is on, and its panel is + // aria-hidden while closed — so that pair is what "on screen" means for the floating workspace. + useAppShellServices({ + floatingPanelVisible: floatingWorkspace.enabled && floatingWorkspace.open + }) useAppStartupHydration(onboardingGate.applyStartupOnboardingState) useAppSessionPersistence() useRuntimeGraphSync() diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 1bffafdb4f2..68db2874593 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -22,7 +22,7 @@ import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime- * the component that consumes its result unmounts (right sidebar, explorer, terminal) or is * absent entirely on the landing path. */ -export function useAppShellServices(): void { +export function useAppShellServices(options: { floatingPanelVisible: boolean }): void { const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const persistedUIReady = useAppStore((s) => s.persistedUIReady) const primarySelectionMiddleClickPaste = useAppStore((s) => @@ -40,7 +40,7 @@ export function useAppShellServices(): void { // Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks). useEditorExternalWatch() useGlobalFileDrop() - useAutoAckViewedAgent() + useAutoAckViewedAgent(options.floatingPanelVisible) useAppMenuPaste() useAppMenuSelectionActions() useLargeTextControlPaste() diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9b4df1b4aa9..9d75836cd11 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -117,6 +117,7 @@ --color-git-graph-lane-4: var(--git-graph-lane-4); --color-git-graph-lane-5: var(--git-graph-lane-5); --color-annotation-highlight: var(--annotation-highlight); + --shadow-floating: 0 10px 24px rgb(0 0 0 / 0.18); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -395,7 +396,7 @@ flex-wrap: wrap; gap: 10px; padding: 14px 16px; - box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); + box-shadow: var(--shadow-floating); } [data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-icon] { @@ -1322,7 +1323,7 @@ html.native-shell .app-layout { background: var(--popover); color: var(--popover-foreground); /* STYLEGUIDE: floating elevation for popovers that escape the editor surface. */ - box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); + box-shadow: var(--shadow-floating); } /* Why: caret points down at the usage meters this notice explains. */ diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 2309ef54b50..903e85aaa92 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -97,7 +97,6 @@ function ComposerModalBody({ @@ -109,13 +108,11 @@ function ComposerModalBody({ function QuickTabBody({ modalData, onClose, - onDismiss, isSubmissionCancelled, active }: { modalData: ComposerModalData onClose: () => void - onDismiss: () => void isSubmissionCancelled: () => boolean active: boolean }): React.JSX.Element { @@ -238,45 +235,29 @@ function QuickTabBody({ ? translate('auto.components.NewWorkspaceComposerModal.createWorktree', 'Create worktree') : translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace') - // Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page). + // Cmd/Ctrl+Enter submits. Escape belongs to the dialog's dismissable layer: + // the page-style "blur the focused field first" rule assumes the user chose + // that field, but this dialog auto-focuses the name input on open, so handling + // Escape here swallowed every first press and left the composer stuck open. + // Radix also closes only the topmost layer, so nested popovers/selects/dialogs + // keep their own Escape without needing a guard here. const nestedDialogOpen = agentSettingsOpen || addProjectOpen || setLocationOpen useEffect(() => { if (!active || nestedDialogOpen) { - // Why: while a nested dialog (Add Project / Agents / Set location) is layered on top, - // this capture-phase handler must not steal its Escape (which should - // close only the nested dialog) or fire composer submit underneath it. + // Why: while a nested dialog (Add Project / Agents / Set location) is layered + // on top, this capture-phase handler must not fire composer submit underneath it. return } const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Enter' && event.key !== 'Escape') { + // Why: workspace creation is screen-local submit behavior, not a + // user-configurable app command. + if (!isScreenSubmitShortcut(event)) { return } const target = event.target if (!(target instanceof HTMLElement)) { return } - - if (event.key === 'Escape') { - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - target.isContentEditable - ) { - event.preventDefault() - target.blur() - return - } - event.preventDefault() - onDismiss() - return - } - - // Why: workspace creation is screen-local submit behavior, not a - // user-configurable app command. - if (!isScreenSubmitShortcut(event)) { - return - } if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current)) { return } @@ -288,7 +269,7 @@ function QuickTabBody({ } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen, onDismiss]) + }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen]) return ( <> diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index cf6e884b7b0..b0265713971 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1438,25 +1438,14 @@ function ReviewChipAvatar({ } function GitHubAssigneeAvatar({ assignee }: { assignee: GitHubAssignableUser }): React.JSX.Element { - if (assignee.avatarUrl) { - return ( - {assignee.login} - ) - } return ( - - {assignee.login.slice(0, 1).toUpperCase()} - + ) } @@ -1896,13 +1885,12 @@ function GHAssigneesCell({ ) : null} - {user.avatarUrl ? ( - - ) : ( - - {user.login.slice(0, 1).toUpperCase()} - - )} + {user.login} {user.name ? ( @@ -2388,13 +2376,12 @@ function PRReviewCell({ {selected ? : null} - {reviewer.avatarUrl ? ( - - ) : ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - )} + {reviewer.login} diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 7985c13befa..7a499a2eaf0 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -14,6 +14,7 @@ import { translate } from '@/i18n/i18n' import { showDictationStartErrorToast } from './dictation-start-error-toast' import { useHoldDictationGesture } from './use-hold-dictation-gesture' import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events' +import { publishDictationMeter } from './dictation-meter-store' export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) @@ -28,7 +29,7 @@ export function DictationController() { flushBufferedAudio, discardBufferedAudio, getCapturedChunkCount - } = useAudioCapture() + } = useAudioCapture(publishDictationMeter) const dictationStateRef = useRef(dictationState) dictationStateRef.current = dictationState diff --git a/src/renderer/src/components/dictation/DictationGrapes.tsx b/src/renderer/src/components/dictation/DictationGrapes.tsx new file mode 100644 index 00000000000..40c95c36684 --- /dev/null +++ b/src/renderer/src/components/dictation/DictationGrapes.tsx @@ -0,0 +1,47 @@ +import { cn } from '@/lib/utils' + +const GRAPES = [ + { base: 0.64, response: 0.45, lift: 1 }, + { base: 0.76, response: 0.75, lift: -1 }, + { base: 0.9, response: 1, lift: 1 }, + { base: 1, response: 1.25, lift: -1 }, + { base: 0.86, response: 0.9, lift: 1 }, + { base: 0.72, response: 1.1, lift: -1 }, + { base: 0.82, response: 0.7, lift: 1 }, + { base: 0.68, response: 0.55, lift: -1 }, + { base: 0.58, response: 0.35, lift: 1 } +] as const + +type DictationGrapesProps = { + level: number + active: boolean + transitioning: boolean +} + +export function DictationGrapes({ level, active, transitioning }: DictationGrapesProps) { + const normalizedLevel = Math.min(1, Math.max(0, level)) + + return ( + + ) +} diff --git a/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts b/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts new file mode 100644 index 00000000000..8b2863d7613 --- /dev/null +++ b/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import en from '@/i18n/locales/en.json' +import es from '@/i18n/locales/es.json' +import ja from '@/i18n/locales/ja.json' +import ko from '@/i18n/locales/ko.json' +import zh from '@/i18n/locales/zh.json' + +const englishLabels = en.auto.components.dictation.DictationIndicator as Record +const translatedLabels = { es, ja, ko, zh } + +describe('DictationIndicator localization', () => { + it('uses generated localization keys', () => { + expect(Object.keys(englishLabels).every((key) => /^[a-f0-9]{10}$/.test(key))).toBe(true) + }) + + it.each(Object.entries(translatedLabels))('translates every label in %s', (_locale, catalog) => { + const labels = catalog.auto.components.dictation.DictationIndicator as Record + + for (const [key, englishLabel] of Object.entries(englishLabels)) { + expect(labels[key]).toBeTruthy() + expect(labels[key]).not.toBe(englishLabel) + } + }) +}) diff --git a/src/renderer/src/components/dictation/DictationIndicator.test.tsx b/src/renderer/src/components/dictation/DictationIndicator.test.tsx index 89fb0e726af..6bc3572f520 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.test.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.test.tsx @@ -10,6 +10,11 @@ import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictatio const storeState = { dictationState: 'listening' as DictationState, partialTranscript: '', + dictationMeter: { + level: 0, + isSpeaking: false, + isClipping: false + }, settings: { voice: { dictationMode: 'toggle' } } as { voice?: { dictationMode?: 'toggle' | 'hold' } } | null @@ -20,6 +25,10 @@ vi.mock('@/store', () => { return { useAppStore } }) +vi.mock('./dictation-meter-store', () => ({ + useDictationMeter: () => storeState.dictationMeter +})) + vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -44,6 +53,11 @@ const originalUserAgent = navigator.userAgent beforeEach(() => { storeState.dictationState = 'listening' storeState.partialTranscript = '' + storeState.dictationMeter = { + level: 0, + isSpeaking: false, + isClipping: false + } storeState.settings = { voice: { dictationMode: 'toggle' } } setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') }) @@ -88,7 +102,7 @@ describe('DictationIndicator', () => { const { container } = render() const pill = container.firstChild as HTMLElement - expect(pill.textContent).toContain('Listening...') + expect(pill.textContent).toContain('Listening') expect(pill.textContent).not.toContain('⌘') }) @@ -104,7 +118,7 @@ describe('DictationIndicator', () => { storeState.dictationState = 'stopping' render() - expect(screen.getByText('Processing...')).toBeTruthy() + expect(screen.getByText('Processing…', { selector: '[aria-hidden="true"]' })).toBeTruthy() expect(screen.queryByRole('button', { name: 'Stop dictation' })).toBeNull() }) @@ -114,4 +128,38 @@ describe('DictationIndicator', () => { expect(container.firstChild).toBeNull() }) + + it('reacts to speaking audio and exposes the semantic state', () => { + storeState.dictationMeter = { + level: 0.72, + isSpeaking: true, + isClipping: false + } + render() + + expect(screen.getByText('Speaking')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe('Listening') + expect(screen.getByTestId('dictation-grapes').children).toHaveLength(9) + }) + + it('uses the destructive role only while clipping', () => { + storeState.dictationMeter = { + level: 1, + isSpeaking: true, + isClipping: true + } + const { container } = render() + + expect(screen.getByText('Too loud', { selector: '[aria-hidden="true"]' })).toBeTruthy() + expect((container.firstChild as HTMLElement).className).toContain('text-destructive') + }) + + it('keeps streaming transcript separate from the listening state', () => { + storeState.partialTranscript = 'A polished voice visualizer' + render() + + expect(screen.getByText('Listening', { selector: '[aria-hidden="true"]' })).toBeTruthy() + expect(screen.getByText('A polished voice visualizer')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe('Listening') + }) }) diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx index 2b8cce868da..caa9981ea62 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.tsx @@ -1,79 +1,106 @@ -import { useAppStore } from '@/store' -import { Mic, Square } from 'lucide-react' -import { Button } from '@/components/ui/button' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import { Square } from 'lucide-react' import { dispatchDictationControl } from './dictation-control-events' +import { DictationGrapes } from './DictationGrapes' +import { useDictationMeter } from './dictation-meter-store' export function DictationIndicator() { - const dictationState = useAppStore((s) => s.dictationState) - const partialTranscript = useAppStore((s) => s.partialTranscript) - const isHoldMode = useAppStore((s) => s.settings?.voice?.dictationMode === 'hold') + const dictationState = useAppStore((state) => state.dictationState) + const partialTranscript = useAppStore((state) => state.partialTranscript) + const dictationMeter = useDictationMeter() + const isHoldMode = useAppStore((state) => state.settings?.voice?.dictationMode === 'hold') const shortcut = useShortcutKeyDetails('voice.dictation') - if ( - dictationState !== 'listening' && - dictationState !== 'starting' && - dictationState !== 'stopping' - ) { + const isVisible = ['listening', 'starting', 'stopping'].includes(dictationState) + if (!isVisible) { return null } - const label = + const isListening = dictationState === 'listening' + const isClipping = isListening && dictationMeter.isClipping + const isSpeaking = isListening && dictationMeter.isSpeaking && !isClipping + const lifecycleLabel = dictationState === 'starting' - ? 'Starting...' + ? translate('auto.components.dictation.DictationIndicator.7f3660a7ba', 'Starting mic…') : dictationState === 'stopping' - ? 'Processing...' - : partialTranscript || 'Listening...' - - // Why: the stop path is a no-op once the session is already tearing down. + ? translate('auto.components.dictation.DictationIndicator.f082d0cb9d', 'Processing…') + : translate('auto.components.dictation.DictationIndicator.3de5a129e7', 'Listening') + const label = isClipping + ? translate('auto.components.dictation.DictationIndicator.4977162383', 'Too loud') + : isSpeaking + ? translate('auto.components.dictation.DictationIndicator.25f2b7a6a5', 'Speaking') + : lifecycleLabel + const announcedLabel = isClipping ? label : lifecycleLabel const canStop = dictationState !== 'stopping' - // Hold mode stops on key release, so a "press ⌘E" chip would misstate the binding. const showShortcut = !isHoldMode && shortcut.keys.length > 0 + const transcript = partialTranscript.trim() const stopLabel = translate( 'auto.components.dictation.DictationIndicator.335e1bc6cb', 'Stop dictation' ) return ( -
- - {label} - {canStop ? ( - <> - - - - - - - {stopLabel} - {showShortcut ? ( - - ) : null} - - - +
+
+ + + {label} + + + {announcedLabel} + + {canStop ? ( + <> + + + + + + + {stopLabel} + {showShortcut ? ( + + ) : null} + + + + ) : null} +
+ {transcript ? ( +

+ {transcript} +

) : null}
) diff --git a/src/renderer/src/components/dictation/dictation-audio-meter.test.ts b/src/renderer/src/components/dictation/dictation-audio-meter.test.ts new file mode 100644 index 00000000000..e7940664e02 --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-audio-meter.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + analyzeDictationAudioChunk, + createDictationMeterAnalyzerState, + measureDictationAudioChunk, + toPublicDictationMeterState +} from './dictation-audio-meter' + +describe('dictation audio meter', () => { + it('measures RMS and clamps peaks from copied audio samples', () => { + expect(measureDictationAudioChunk(new Float32Array([0.5, -0.5]))).toEqual({ + rms: 0.5, + peak: 0.5 + }) + expect(measureDictationAudioChunk(new Float32Array([2]))).toEqual({ rms: 2, peak: 1 }) + }) + + it('uses a fast attack and slower release for a stable voice envelope', () => { + const initial = createDictationMeterAnalyzerState() + const loud = analyzeDictationAudioChunk(new Float32Array(128).fill(0.3), 100, initial) + const quiet = analyzeDictationAudioChunk(new Float32Array(128), 200, loud) + + expect(loud.level).toBeGreaterThan(0.5) + expect(loud.isSpeaking).toBe(true) + expect(quiet.level).toBeGreaterThan(0.3) + expect(quiet.level).toBeLessThan(loud.level) + }) + + it('holds clipping briefly so a single peak does not flicker', () => { + const initial = createDictationMeterAnalyzerState() + const clipped = analyzeDictationAudioChunk(new Float32Array([1]), 1_000, initial) + const held = analyzeDictationAudioChunk(new Float32Array([0]), 1_400, clipped) + const released = analyzeDictationAudioChunk(new Float32Array([0]), 1_600, held) + + expect(clipped.isClipping).toBe(true) + expect(held.isClipping).toBe(true) + expect(released.isClipping).toBe(false) + }) + + it('publishes only presentation-safe meter fields', () => { + const analyzed = analyzeDictationAudioChunk( + new Float32Array(128).fill(0.25), + 250, + createDictationMeterAnalyzerState() + ) + + expect(toPublicDictationMeterState(analyzed)).toEqual({ + level: Math.round(analyzed.level * 100) / 100, + isSpeaking: analyzed.isSpeaking, + isClipping: analyzed.isClipping + }) + }) +}) diff --git a/src/renderer/src/components/dictation/dictation-audio-meter.ts b/src/renderer/src/components/dictation/dictation-audio-meter.ts new file mode 100644 index 00000000000..c7f9c98117a --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-audio-meter.ts @@ -0,0 +1,106 @@ +export type DictationMeterState = { + level: number + isSpeaking: boolean + isClipping: boolean +} + +export type DictationMeterAnalyzerState = DictationMeterState & { + peak: number + lastUpdatedAt: number + noiseFloor: number + smoothedLevel: number + clippingUntil: number +} + +export const DEFAULT_DICTATION_METER: DictationMeterState = { + level: 0, + isSpeaking: false, + isClipping: false +} + +const CLIPPING_THRESHOLD = 0.98 +const CLIPPING_HOLD_MS = 500 + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export function createDictationMeterAnalyzerState(): DictationMeterAnalyzerState { + return { + ...DEFAULT_DICTATION_METER, + peak: 0, + lastUpdatedAt: 0, + noiseFloor: 0.008, + smoothedLevel: 0, + clippingUntil: Number.NEGATIVE_INFINITY + } +} + +export function measureDictationAudioChunk(samples: Float32Array): { + rms: number + peak: number +} { + if (samples.length === 0) { + return { rms: 0, peak: 0 } + } + + let sumSquares = 0 + let peak = 0 + for (const sample of samples) { + const absoluteSample = Math.abs(sample) + sumSquares += sample * sample + peak = Math.max(peak, absoluteSample) + } + return { + rms: Math.sqrt(sumSquares / samples.length), + peak: clamp(peak, 0, 1) + } +} + +export function analyzeDictationAudioChunk( + samples: Float32Array, + now: number, + previous: DictationMeterAnalyzerState +): DictationMeterAnalyzerState { + const { rms, peak } = measureDictationAudioChunk(samples) + const noiseFloor = Math.max( + 0.004, + previous.noiseFloor * 0.96 + Math.min(rms, previous.noiseFloor * 2) * 0.04 + ) + const rawLevel = clamp((rms - noiseFloor) / 0.16, 0, 1) + const smoothing = rawLevel > previous.smoothedLevel ? 0.58 : 0.2 + const smoothedLevel = previous.smoothedLevel + (rawLevel - previous.smoothedLevel) * smoothing + const clippingUntil = peak >= CLIPPING_THRESHOLD ? now + CLIPPING_HOLD_MS : previous.clippingUntil + + return { + level: smoothedLevel, + peak, + isSpeaking: smoothedLevel >= 0.1 || peak >= 0.18, + isClipping: now <= clippingUntil, + lastUpdatedAt: now, + noiseFloor, + smoothedLevel, + clippingUntil + } +} + +export function toPublicDictationMeterState( + state: DictationMeterAnalyzerState +): DictationMeterState { + return { + level: Math.round(state.level * 100) / 100, + isSpeaking: state.isSpeaking, + isClipping: state.isClipping + } +} + +export function dictationMeterStatesEqual( + left: DictationMeterState, + right: DictationMeterState +): boolean { + return ( + left.level === right.level && + left.isSpeaking === right.isSpeaking && + left.isClipping === right.isClipping + ) +} diff --git a/src/renderer/src/components/dictation/dictation-meter-store.test.tsx b/src/renderer/src/components/dictation/dictation-meter-store.test.tsx new file mode 100644 index 00000000000..07a74612734 --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-meter-store.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { + publishDictationMeter, + resetDictationMeter, + useDictationMeter +} from './dictation-meter-store' + +beforeEach(resetDictationMeter) +afterEach(resetDictationMeter) + +it('notifies only the scoped meter consumer when presentation state changes', () => { + let renders = 0 + const { result } = renderHook(() => { + renders += 1 + return useDictationMeter() + }) + const speaking = { level: 0.72, isSpeaking: true, isClipping: false } + + act(() => publishDictationMeter(speaking)) + expect(result.current).toEqual(speaking) + expect(renders).toBe(2) + + act(() => publishDictationMeter({ ...speaking })) + expect(renders).toBe(2) +}) diff --git a/src/renderer/src/components/dictation/dictation-meter-store.ts b/src/renderer/src/components/dictation/dictation-meter-store.ts new file mode 100644 index 00000000000..66e97da6a0c --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-meter-store.ts @@ -0,0 +1,29 @@ +import { e2eConfig } from '@/lib/e2e-config' +import { useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' +import { + DEFAULT_DICTATION_METER, + dictationMeterStatesEqual, + type DictationMeterState +} from './dictation-audio-meter' + +const dictationMeterStore = createStore(() => DEFAULT_DICTATION_METER) + +export function publishDictationMeter(meter: DictationMeterState): void { + if (!dictationMeterStatesEqual(dictationMeterStore.getState(), meter)) { + dictationMeterStore.setState(meter, true) + } +} + +export function resetDictationMeter(): void { + dictationMeterStore.setState(DEFAULT_DICTATION_METER, true) +} + +export function useDictationMeter(): DictationMeterState { + return useStore(dictationMeterStore, (state) => state) +} + +if (e2eConfig.exposeStore && typeof window !== 'undefined') { + const testWindow = window as unknown as Record + testWindow.__dictationMeterE2E = { publish: publishDictationMeter } +} diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts index f30da28d603..533274b2a95 100644 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -51,4 +51,68 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { // Why: list chip must not hardcode github.com/{login}.png. expect(taskPage).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) }) + + // GHES URLs can exist but fail unauthenticated; target slots need onError fallbacks. + // Scope checks because TaskPage also renders non-GitHub provider avatars. + const GITHUB_AVATAR_SLOTS = [ + { + file: 'pull-request-page/reviewers/picker-row.tsx', + fn: 'ReviewerPickerRow', + login: 'reviewer.login', + displayName: 'reviewer.name' + }, + { + file: 'pull-request-page/conversation/comment-card.tsx', + fn: 'ConversationCommentCard', + login: 'comment.author', + displayName: null + }, + { + file: 'pull-request-page/mentions/textarea.tsx', + fn: 'MentionTextarea', + login: 'option.login', + displayName: 'option.name' + }, + { + file: 'TaskPage.tsx', + fn: 'GitHubAssigneeAvatar', + login: 'assignee.login', + displayName: 'assignee.name' + }, + { file: 'TaskPage.tsx', fn: 'GHAssigneesCell', login: 'user.login', displayName: 'user.name' }, + { + file: 'TaskPage.tsx', + fn: 'PRReviewCell', + login: 'reviewer.login', + displayName: 'reviewer.name' + } + ] as const + + function componentBody(file: string, fn: string): string { + const source = readFileSync(join(__dirname, '..', file), 'utf8') + const start = source.indexOf(`function ${fn}`) + expect(start, `${file}: function ${fn} not found`).toBeGreaterThanOrEqual(0) + const next = source.indexOf('\nfunction ', start + 1) + return source.slice(start, next === -1 ? undefined : next) + } + + it.each(GITHUB_AVATAR_SLOTS)( + 'renders the $fn avatar through GitHubUserAvatar (#13976)', + ({ file, fn, login }) => { + const body = componentBody(file, fn) + + // Reject aliases and resolver expressions as well as direct avatarUrl fields. + expect(body, `${fn} still renders a bare img`).not.toMatch(/]/) + expect(body, `${fn} does not use GitHubUserAvatar`).toContain(' slot.displayName !== null))( + 'passes the $fn display name so initials are not reduced to one letter (#13976)', + ({ file, fn, displayName }) => { + expect(componentBody(file, fn)).toContain(`name={${displayName}}`) + } + ) }) diff --git a/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx b/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx index 66ed253a6c3..598a23ad6ca 100644 --- a/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx +++ b/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx @@ -12,6 +12,7 @@ import { import { formatRelativeTime } from '@/components/github/work-item-state-presentation' import { CommentCodeContext } from '@/components/github/CommentCodeContext' import { CommentReactions } from '@/components/github/CommentReactions' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { translate } from '@/i18n/i18n' import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types' import type { PRComment } from '../../../../../shared/github/comment-types' @@ -62,15 +63,12 @@ export function ConversationCommentCard({ )} >
- {comment.authorAvatarUrl ? ( - {comment.author} - ) : ( -
- )} + - {option.avatarUrl ? ( - - ) : ( -
- {option.login.slice(0, 1).toUpperCase()} -
- )} + @{option.login} {option.name && ( diff --git a/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx b/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx index a8b347765f2..5e24fffdbbb 100644 --- a/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx +++ b/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Check } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' export function ReviewerPickerRow({ @@ -59,13 +60,12 @@ export function ReviewerPickerRow({ {selected ? : null} - {reviewer.avatarUrl ? ( - - ) : ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - )} + {reviewer.login} diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index dee03d0c6dd..777006cafad 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -18,7 +18,9 @@ import { function decodeWslLoginShellScript(command: string): string { const encoded = - /(?:--|--exec) sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec(command)?.[1] + /(?:--|--exec) sh -c '(?:eval \\"`|sh -c \\"\$\()?printf %s ([A-Za-z0-9+/=]+) \| base64 -d/.exec( + command + )?.[1] expect(encoded).toBeDefined() return Buffer.from(encoded!, 'base64').toString('utf8') } @@ -48,7 +50,7 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { expect(command).toBe(skillCommand) expect(setupCommand).toBe( - `& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe -d 'Ubuntu' --exec sh -c 'eval \\"\`printf %s ${encoded} | base64 -d\`\\"' } # Runs: ${skillCommand}` + `& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe -d 'Ubuntu' --exec sh -c 'sh -c \\"$(printf %s ${encoded} | base64 -d)\\"' } # Runs: ${skillCommand}` ) expect(decodeWslLoginShellScript(setupCommand)).toContain( 'exec "$_orca_wsl_shell" -ilc \'npx skills add orchestration --global\'' @@ -93,9 +95,11 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { const setupCommand = buildSkillSetupTerminalCommand(command, 'powershell.exe', runtime, 'win32') expect(setupCommand).toMatch( - /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe --exec sh -c 'eval \\"`printf/ + /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe --exec sh -c 'sh -c/ ) - expect(setupCommand).toContain('`\\"\' } # Runs: npx skills update orchestration --global') + // The command no longer ends in the eval wrapper's nested quotes; it is a + // plain pipe into sh, so the payload has no quoting layer to escape from. + expect(setupCommand).toContain('base64 -d)') }) it.skipIf(process.platform === 'win32')( diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 3208aa2cd04..9e454fcee7f 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -164,7 +164,15 @@ function buildPowerShellWslSkillCommand(command: string, runtime: LocalAgentRunt // Why: encoding preserves the user's configured login-shell PATH across the Windows argv boundary. const encodedScript = encodeWslLoginShellScript(command) const visibleCommand = command.replace(/[\r\n]+/g, ' ') - const shellScript = `eval "\`printf %s ${encodedScript} | base64 -d\`"` + // Why $(...) and not a backtick eval: PowerShell treats ` as its own escape + // character, so the old `eval "\`printf ...\`"` had the payload's quotes + // interacting with two escaping layers and dash saw `case in` -- the + // `word unexpected (expecting "in")` in #14292. Credit: #14785. + // + // Why not a plain pipe into sh: that hands the payload the pipe as its stdin, + // so a setup command that reads input gets base64 remnants instead. Command + // substitution runs in a subshell and leaves the terminal's stdin intact. + const shellScript = `sh -c "$(printf %s ${encodedScript} | base64 -d)"` // Why --exec: `--` makes wsl.exe expand $name in the argv it forwards to the guest. const wslCommand = `wsl.exe${distroArg} --exec sh -c ${quotePowerShellNativeArgument(shellScript)}` return `& { $PSNativeCommandArgumentPassing = 'Legacy'; ${wslCommand} } # Runs: ${visibleCommand}` @@ -180,7 +188,10 @@ function decodeWslSetupTerminalCommand(command: string): string | null { // Why both separators: commands persisted before the --exec switch must still decode. const encoded = - /(?:--|--exec) sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec(command)?.[1] + // Both shapes: commands persisted before the pipe switch still decode. + /(?:--|--exec) sh -c '(?:eval \\"`|sh -c \\"\$\()?printf %s ([A-Za-z0-9+/=]+) \| base64 -d/.exec( + command + )?.[1] if (!encoded) { return null } diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts index bb3b6e06c85..2e7523e5ecb 100644 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -30,7 +30,10 @@ describe('Project Group header drag DOM source', () => { const source = readHeaderDragSource() expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') - expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') + // Why: the commit must carry the group's owner host so a non-focused host still persists the order. + expect(source).toContain( + 'void updateProjectGroup(groupId, { tabOrder }, { hostId: ownerHostId ?? undefined })' + ) }) it('keeps grab cursor on the title surface and dual handle attrs on row + surface', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts index 80f608f8665..0127660c712 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react' import type React from 'react' import { useAppStore } from '@/store' +import { getProjectGroupHostId } from '@/store/slices/project-group-owner-routing' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types' import type { Repo } from '../../../../../../shared/repo-types' @@ -83,6 +84,16 @@ export function useWorktreeSidebarHeaderDrag(args: { () => new Map(projectGroups.map((group) => [group.id, group])), [projectGroups] ) + // Why: an id shared by two hosts has no single owner, so leave it unrouted rather than guess. + const projectGroupOwnerHostIdByGroupId = useMemo(() => { + const byGroupId = new Map() + for (const group of projectGroups) { + const hostId = getProjectGroupHostId(group) + const existing = byGroupId.get(group.id) + byGroupId.set(group.id, existing === undefined || existing === hostId ? hostId : null) + } + return byGroupId + }, [projectGroups]) // Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop). const suppressScrollCorrectionForHeaderCommit = useCallback(() => { @@ -157,9 +168,11 @@ export function useWorktreeSidebarHeaderDrag(args: { return } suppressScrollCorrectionForHeaderCommit() - void updateProjectGroup(groupId, { tabOrder }) + // Why: manual order persists on the group's own host; the focused host may not hold this row. + const ownerHostId = projectGroupOwnerHostIdByGroupId.get(groupId) + void updateProjectGroup(groupId, { tabOrder }, { hostId: ownerHostId ?? undefined }) }, - [suppressScrollCorrectionForHeaderCommit, updateProjectGroup] + [projectGroupOwnerHostIdByGroupId, suppressScrollCorrectionForHeaderCommit, updateProjectGroup] ) // Drag applies only in manual order; still construct the controller inert for stable hook order. const repoDrag = useRepoHeaderDrag({ diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index 727438f997a..b4ed149712a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -8,6 +8,8 @@ import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status' import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspace-path-status' import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { getProjectGroupHostId } from '@/store/slices/project-group-owner-routing' import type { WorkspaceStatus, WorkspaceStatusDefinition @@ -56,8 +58,8 @@ export type SectionHeaderRowContext = { }) => FolderWorkspacePathStatus | null toggleGroupWithScrollAnchor: (groupKey: string) => void projectActions: RepoHeaderProjectActions - onRenameProjectGroup: (groupId: string, currentName: string) => void - onDeleteProjectGroup: (groupId: string, groupName: string) => void + onRenameProjectGroup: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void + onDeleteProjectGroup: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void onCreateFolderWorkspace: (projectGroup: ProjectGroup) => void onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void onWorkspaceStatusDragLeave: (event: React.DragEvent) => void @@ -92,6 +94,11 @@ export function renderWorktreeSectionHeaderRow(args: { isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' ? row.projectGroup.id : undefined + // Why: rename/delete must route to the host that owns this row, not to whichever host has focus. + const projectGroupHostIdForHeader = + row.projectGroup && 'createdFrom' in row.projectGroup + ? getProjectGroupHostId(row.projectGroup) + : undefined const repoHeaderIndex = projectIdForHeader !== undefined ? headerDrag.repoHeaderIndexByRepoId.get(projectIdForHeader) @@ -353,6 +360,7 @@ export function renderWorktreeSectionHeaderRow(args: { {isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? ( void - onDelete: (groupId: string, groupName: string) => void + onRename: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void + onDelete: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void }): React.JSX.Element { return ( @@ -64,10 +68,10 @@ export function ProjectGroupHeaderMenu({ onClick={stopRepoHeaderMenuEvent} onKeyDown={stopRepoHeaderMenuEvent} > - onRename(groupId, label)}> + onRename(groupId, label, hostId)}> {translate('auto.components.sidebar.WorktreeList.4d7b73658c', 'Rename group')} - onDelete(groupId, label)}> + onDelete(groupId, label, hostId)}> {translate('auto.components.sidebar.WorktreeList.902115cdbe', 'Delete group')} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx new file mode 100644 index 00000000000..dd2346ecfce --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useProjectGroupDialogs, type ProjectGroupDialogs } from './use-project-group-dialogs' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' + +const mocks = vi.hoisted(() => ({ + moveProjectToGroup: vi.fn(), + createProjectGroup: vi.fn(), + updateProjectGroup: vi.fn(), + deleteProjectGroupWithContainedProjects: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + moveProjectToGroup: mocks.moveProjectToGroup, + createProjectGroup: mocks.createProjectGroup, + updateProjectGroup: mocks.updateProjectGroup, + deleteProjectGroupWithContainedProjects: mocks.deleteProjectGroupWithContainedProjects + }) +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError } +})) + +const remoteGroup: ProjectGroup = { + id: 'group-1', + name: 'Projects', + parentPath: '/srv/projects', + parentGroupId: null, + createdFrom: 'folder-scan', + executionHostId: 'runtime:env-1', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +let latest: ProjectGroupDialogs | null = null +const roots: Root[] = [] + +function HookProbe(): null { + latest = useProjectGroupDialogs({ + repos: [], + repoMap: new Map(), + projectGroups: [remoteGroup] + }) + return null +} + +async function renderHookProbe(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.updateProjectGroup.mockResolvedValue(true) + mocks.deleteProjectGroupWithContainedProjects.mockResolvedValue({ + status: 'deleted-group', + groupId: remoteGroup.id, + requestedProjectIds: [], + removedProjectIds: [], + failedProjectRemovals: [] + }) +}) + +afterEach(async () => { + await act(async () => { + for (const root of roots.splice(0)) { + root.unmount() + } + }) + latest = null +}) + +describe('project group dialogs carry the owner host', () => { + it('renames through the host that owns the group row', async () => { + await renderHookProbe() + await act(async () => { + latest!.handleRenameProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1') + }) + await act(async () => { + await latest!.handleSubmitProjectGroupName('Renamed') + }) + + expect(mocks.updateProjectGroup).toHaveBeenCalledWith( + remoteGroup.id, + { name: 'Renamed' }, + { hostId: 'runtime:env-1' } + ) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('surfaces an unconfirmed-rename toast when the owner host does not answer', async () => { + mocks.updateProjectGroup.mockResolvedValue(false) + await renderHookProbe() + await act(async () => { + latest!.handleRenameProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1') + }) + await act(async () => { + await latest!.handleSubmitProjectGroupName('Renamed') + }) + + expect(mocks.toastError).toHaveBeenCalledWith('Failed to rename group', { + description: + "Orca could not confirm the new name with the group's host. Recheck the group after reconnecting." + }) + }) + + it('deletes through the host that owns the group row', async () => { + await renderHookProbe() + await act(async () => { + latest!.handleDeleteProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1') + }) + await act(async () => { + await latest!.handleConfirmDeleteProjectGroup() + }) + + expect(mocks.deleteProjectGroupWithContainedProjects).toHaveBeenCalledWith(remoteGroup.id, { + removeContainedProjects: false, + hostId: 'runtime:env-1' + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts index b66eb737af5..fb027095064 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts @@ -5,15 +5,18 @@ import { translate } from '@/i18n/i18n' import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { Repo } from '../../../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' export type ProjectGroupNameDialogState = | { type: 'create-from-repo'; repo: Repo } - | { type: 'rename'; groupId: string; currentName: string } + // hostId is the group row's owner host, so the mutation is not routed to whichever host has focus. + | { type: 'rename'; groupId: string; currentName: string; hostId?: ExecutionHostId } export type ProjectGroupDeleteDialogState = { groupId: string groupName: string removeContainedProjects: boolean + hostId?: ExecutionHostId } export type ProjectGroupDialogs = ReturnType @@ -95,9 +98,12 @@ export function useProjectGroupDialogs(args: { [moveProjectToGroup] ) - const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => { - setNameDialog({ type: 'rename', groupId, currentName }) - }, []) + const handleRenameProjectGroup = useCallback( + (groupId: string, currentName: string, hostId?: ExecutionHostId) => { + setNameDialog({ type: 'rename', groupId, currentName, hostId }) + }, + [] + ) const handleSubmitProjectGroupName = useCallback( async (name: string) => { @@ -111,7 +117,26 @@ export function useProjectGroupDialogs(args: { } return } - await updateProjectGroup(nameDialog.groupId, { name }) + const renamed = await updateProjectGroup( + nameDialog.groupId, + { name }, + { hostId: nameDialog.hostId } + ) + if (!renamed) { + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.groupRenameFailed', + 'Failed to rename group' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeList.groupRenameFailedDesc', + // Why: a falsy result also covers RPC timeout/disconnect, so the copy must not assert the host refused. + "Orca could not confirm the new name with the group's host. Recheck the group after reconnecting." + ) + } + ) + } }, [createProjectGroup, moveProjectToGroup, nameDialog, updateProjectGroup] ) @@ -120,7 +145,12 @@ export function useProjectGroupDialogs(args: { if (!deleteDialog) { return null } - return selectProjectGroupRemovalTargets(projectGroups, repos, deleteDialog.groupId) + return selectProjectGroupRemovalTargets( + projectGroups, + repos, + deleteDialog.groupId, + deleteDialog.hostId + ) }, [deleteDialog, projectGroups, repos]) const deleteProjectCount = deleteTargets?.projectIds.length ?? 0 const deleteProjectNames = useMemo( @@ -133,9 +163,12 @@ export function useProjectGroupDialogs(args: { const removeContainedProjects = deleteProjectCount > 0 && deleteDialog?.removeContainedProjects === true - const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => { - setDeleteDialog({ groupId, groupName, removeContainedProjects: false }) - }, []) + const handleDeleteProjectGroup = useCallback( + (groupId: string, groupName: string, hostId?: ExecutionHostId) => { + setDeleteDialog({ groupId, groupName, removeContainedProjects: false, hostId }) + }, + [] + ) const handleConfirmDeleteProjectGroup = useCallback(async () => { if (!deleteDialog) { @@ -144,7 +177,8 @@ export function useProjectGroupDialogs(args: { try { reportProjectGroupDeleteFailures( await deleteProjectGroupWithContainedProjects(deleteDialog.groupId, { - removeContainedProjects + removeContainedProjects, + hostId: deleteDialog.hostId }) ) } finally { diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts index 570077a1c26..e341f77695b 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts @@ -47,8 +47,8 @@ export type VirtualizedWorktreeViewportProps = { handleCreateGroupFromRepo: (repo: Repo) => void handleMoveProjectToGroup: (repo: Repo, groupId: string) => void handleRemoveProjectFromGroup: (repo: Repo) => void - handleRenameProjectGroup: (groupId: string, currentName: string) => void - handleDeleteProjectGroup: (groupId: string, groupName: string) => void + handleRenameProjectGroup: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void + handleDeleteProjectGroup: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void activeModal: string pendingRevealWorktree: PendingSidebarWorktreeReveal | null diff --git a/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts new file mode 100644 index 00000000000..5ac0a49fcb6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts @@ -0,0 +1,263 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync })) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ toast: { info: toastInfo } })) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, getEagerPtyBufferHandle: vi.fn(() => undefined) } +}) + +/** Mode/attribute rehydration only — the shape a model emulator serializes when + * a seed or hydration write never landed and it holds no cell content. */ +const BLANK_MODEL_IMAGE = '\x1b[0m\x1b[?25h\x1b[?7h' + +describe('restored snapshot baseline shortfall (STA-5179)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function writtenData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } + + async function revealPaneWithModelSnapshot(snapshot: { + data: string + seq: number + pendingDeliveryStartSeq?: number + scrollbackAnsi?: string + }): Promise<{ + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + getMainBufferSnapshot: ReturnType + }> { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: true } + }) as never + ) + await flushAsyncTicks(6) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + cols: 100, + rows: 30, + ...snapshot + }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: snapshot.seq + }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + pane.terminal.write.mockClear() + return { + pane, + dataCallback: capturedDataCallback.current!, + getMainBufferSnapshot + } + } + + it('recovers the backlog a blank snapshot claimed to have painted', async () => { + // The model over-reports: seq 2_472 with no cell content behind it. Every + // chunk carrying the missing tail sits at or below that seq, so the old + // baseline dropped all of it as duplicates with nothing left to re-send. + const { pane, dataCallback } = await revealPaneWithModelSnapshot({ + data: BLANK_MODEL_IMAGE, + seq: 2_472, + pendingDeliveryStartSeq: 2_400 + }) + + dataCallback('TAIL-INSIDE-WINDOW', { seq: 2_460, rawLength: 18 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('TAIL-INSIDE-WINDOW') + + // Straddling the claimed baseline: the whole chunk is recovered, not sliced. + dataCallback('TAIL-STRADDLING', { seq: 2_475, rawLength: 15 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('TAIL-STRADDLING') + + // An idle shell never re-sends: no further restore is needed to heal. + dataCallback('LIVE-AFTER-BASELINE', { seq: 2_494, rawLength: 19 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('LIVE-AFTER-BASELINE') + }) + + it('recovers the backlog when only scrollback-less blank content was painted', async () => { + const { pane, dataCallback } = await revealPaneWithModelSnapshot({ + data: '\x1b[H\x1b[2J', + scrollbackAnsi: ' \r\n', + seq: 900 + }) + + dataCallback('BLANK-WITH-WHITESPACE-SCROLLBACK', { + seq: 880, + rawLength: 32 + }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('BLANK-WITH-WHITESPACE-SCROLLBACK') + }) + + it('still suppresses backlog duplicates behind a snapshot that painted content', async () => { + // Regression guard for the normal path: a snapshot with real cell content + // keeps its drop-everything baseline, so the ACK backlog stays suppressed. + const { pane, dataCallback } = await revealPaneWithModelSnapshot({ + data: 'restored snapshot\r\n', + seq: 2_472, + pendingDeliveryStartSeq: 2_400 + }) + + dataCallback('COVERED-DUPLICATE', { seq: 2_460, rawLength: 17 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('COVERED-DUPLICATE') + + // Only the post-baseline remainder of a straddling chunk is written. + dataCallback('ABCDEFGHIJKLMNOPQRSTU', { seq: 2_478, rawLength: 21 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('PQRSTU') + expect(writtenData(pane)).not.toContain('ABCDEF') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index 81d2f1912a9..e5c0ecbf404 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -18,6 +18,7 @@ import { } from '../ssh-reattach-model-restore' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' import type { ReattachPayloadContext } from './reattach-payload-context' import type { ReattachPayloadSession } from './reattach-payload-session' @@ -186,7 +187,11 @@ export function createReattachPayloadHandlers( session.writeReplayData(modelSnapshot.pendingEscapeTailAnsi) } // Why: main sampled its delivery backlog with the snapshot; the baseline drops/slices deferred and live chunks the snapshot already covers. - session.setRestoredSnapshotBaseline(ctx.ptyId, modelSnapshot) + session.setRestoredSnapshotBaseline( + ctx.ptyId, + modelSnapshot, + restoredSnapshotPaintsPrintableContent(modelSnapshot) + ) session.recordRendererOrderedSeq(modelSnapshot) session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration) if (ctx.connectResult?.coldRestore && !isRemoteRuntimePtyId(ctx.ptyId)) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index d2b1f9da783..192dd042a84 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -162,12 +162,19 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio bindCaptureTransportOutputCallbacks(session) session.setRestoredSnapshotBaseline = function ( ptyId: string, - snapshot: { seq?: number; pendingDeliveryStartSeq?: number } + snapshot: { seq?: number; pendingDeliveryStartSeq?: number }, + paintsContent: boolean ): void { if (typeof snapshot.seq !== 'number') { session.clearRestoredSnapshotBaseline() return } + // Why: arming drops the redelivery permanently, so the snapshot's painted + // content must be able to back the seq it claims; a blank image cannot (STA-5179). + if (snapshot.seq > 0 && !paintsContent) { + session.clearRestoredSnapshotBaseline() + return + } const windowStartSeq = typeof snapshot.pendingDeliveryStartSeq === 'number' ? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts index 950bd6fd408..3d08e5d495b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts @@ -75,7 +75,11 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession): pendingData += sliced ?? chunk.data } if (replayingSnapshot && replayedSeq !== null) { - session.setRestoredSnapshotBaseline(expectedPtyId, replayingSnapshot) + session.setRestoredSnapshotBaseline( + expectedPtyId, + replayingSnapshot, + replayingSnapshot.paintsContent === true + ) for (const chunk of pendingChunks) { if (typeof chunk.seq === 'number' && session.restoredSnapshotExpectedStartSeq !== null) { session.restoredSnapshotExpectedStartSeq = Math.max( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts index 1ea923bfccf..f245c9c16f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts @@ -16,6 +16,7 @@ import { HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS } from './hidden-output-restore-limits' import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -215,7 +216,11 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): return } // Why: everything at/before snapshot.seq is now painted; chunks still draining from main's ACK backlog below it are duplicates to suppress. - session.setRestoredSnapshotBaseline(currentPtyId, snapshot) + session.setRestoredSnapshotBaseline( + currentPtyId, + snapshot, + restoredSnapshotPaintsPrintableContent(snapshot) + ) session.hiddenOutputRestoreReplayingSnapshot = null const needsFreshSnapshot = session.hiddenOutputRestoreFreshSnapshotNeeded session.hiddenOutputRestoreFreshSnapshotNeeded = false diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index ab170dd4ee4..8714adbd84b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -21,6 +21,7 @@ import { import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-restore-limits' import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -80,6 +81,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): if (typeof snapshot.seq === 'number') { session.hiddenOutputRestoreReplayingSnapshot = { seq: snapshot.seq, + paintsContent: restoredSnapshotPaintsPrintableContent(snapshot), ...(typeof snapshot.pendingDeliveryStartSeq === 'number' ? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq } : {}) diff --git a/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts new file mode 100644 index 00000000000..4a8938b1266 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { restoredSnapshotPaintsPrintableContent } from './restored-snapshot-coverage' + +describe('restoredSnapshotPaintsPrintableContent', () => { + it('accepts a snapshot with cell content in the frame or the scrollback', () => { + expect(restoredSnapshotPaintsPrintableContent({ data: '\x1b[0m$ ls\r\n' })).toBe(true) + expect( + restoredSnapshotPaintsPrintableContent({ + data: '\x1b[H\x1b[2J', + scrollbackAnsi: 'older\r\n' + }) + ).toBe(true) + }) + + it('rejects a mode-rehydration-only image, which no amount of output renders to', () => { + expect( + restoredSnapshotPaintsPrintableContent({ + data: '\x1b[0m\x1b[?25h\x1b[?7h' + }) + ).toBe(false) + expect(restoredSnapshotPaintsPrintableContent({ data: '\x1b]0;title\x07' })).toBe(false) + expect(restoredSnapshotPaintsPrintableContent({})).toBe(false) + expect( + restoredSnapshotPaintsPrintableContent({ + data: '\x1b[2J', + scrollbackAnsi: ' \r\n\t' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts new file mode 100644 index 00000000000..8e80c6485bd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts @@ -0,0 +1,32 @@ +import { + stripAnsiEscapeSequences, + TERMINAL_CONTROL_CHARACTER_PATTERN +} from '../../../../shared/ansi-escape-sequences' + +/** + * Whether a restored model snapshot painted any printable cell content. + * + * Why this gates the restore baseline (STA-5179): the baseline makes every + * chunk at or below the snapshot's `seq` unrecoverable, on the model's word + * that those bytes are already painted. An image with no printable characters + * cannot be the rendering of the output it claims to cover, so the claim is + * disproven and the redelivery is the only remaining copy of those bytes. + * Replaying it into the blank screen the snapshot just produced also cannot + * duplicate visible output, so refusing to arm is the safe direction. + */ +export function restoredSnapshotPaintsPrintableContent(snapshot: { + data?: string + scrollbackAnsi?: string +}): boolean { + return hasPrintableContent(snapshot.data) || hasPrintableContent(snapshot.scrollbackAnsi) +} + +function hasPrintableContent(value: string | undefined): boolean { + if (!value) { + return false + } + return ( + stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '').trim().length > + 0 + ) +} diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index 60ab6e38055..5c574db1797 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -37,7 +37,7 @@ function ContextMenuSubTrigger({ data-slot="context-menu-sub-trigger" data-inset={inset} className={cn( - "flex cursor-default items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} @@ -58,7 +58,7 @@ function ContextMenuSubContent({ ) diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index eeebe9a4d2d..7d5c3fe8753 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -32,7 +32,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - 'z-[70] max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + 'z-[70] max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-[4px] text-black dark:text-white shadow-[0_12px_28px_rgba(0,0,0,0.22)],inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]', className )} // Why: Electron's -webkit-app-region: drag on the titlebar captures @@ -60,7 +60,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "relative flex cursor-default items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-medium outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", + "relative flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className )} {...props} @@ -78,7 +78,7 @@ function DropdownMenuCheckboxItem({ ) @@ -187,7 +187,7 @@ function DropdownMenuSubTrigger({ data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( - "flex cursor-default items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-medium outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-7 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-7 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} @@ -208,7 +208,7 @@ function DropdownMenuSubContent({ + disconnect: ReturnType + onaudioprocess: ((event: AudioProcessingEvent) => void) | null +} + function makeStream(track: FakeTrack): MediaStream { return { getTracks: () => [track], @@ -15,8 +21,8 @@ function makeStream(track: FakeTrack): MediaStream { } as unknown as MediaStream } -function installAudioContext(): void { - const processor = { +function installAudioContext(): FakeAudioProcessor { + const processor: FakeAudioProcessor = { connect: vi.fn(), disconnect: vi.fn(), onaudioprocess: null @@ -34,14 +40,16 @@ function installAudioContext(): void { close = vi.fn(async () => undefined) } ) + return processor } describe('useAudioCapture device loss', () => { let track: FakeTrack + let processor: FakeAudioProcessor beforeEach(() => { track = new FakeTrack() - installAudioContext() + processor = installAudioContext() vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: vi.fn(async () => makeStream(track)), @@ -57,6 +65,7 @@ describe('useAudioCapture device loss', () => { }) afterEach(() => { + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -80,4 +89,85 @@ describe('useAudioCapture device loss', () => { expect(onCaptureLost).not.toHaveBeenCalled() }) + + it('publishes the copied audio envelope without suppressing speech input', async () => { + const publishMeter = vi.fn() + const { result } = renderHook(() => useAudioCapture(publishMeter)) + await result.current.start({ sessionId: 'meter-session' }) + + processor.onaudioprocess?.({ + inputBuffer: { getChannelData: () => new Float32Array(128).fill(0.3) } + } as unknown as AudioProcessingEvent) + + expect(publishMeter).toHaveBeenLastCalledWith( + expect.objectContaining({ isSpeaking: true, level: expect.any(Number) }) + ) + expect(window.api.speech.feedAudio).toHaveBeenCalledWith( + expect.any(Float32Array), + 48_000, + 'meter-session' + ) + }) + + it('caps visual publications and deduplicates steady silence', async () => { + let now = 0 + vi.spyOn(performance, 'now').mockImplementation(() => now) + const publishMeter = vi.fn() + const { result } = renderHook(() => useAudioCapture(publishMeter)) + await result.current.start() + + const process = (sample: number): void => { + processor.onaudioprocess?.({ + inputBuffer: { getChannelData: () => new Float32Array(128).fill(sample) } + } as unknown as AudioProcessingEvent) + } + + process(0) + expect(publishMeter).toHaveBeenCalledTimes(1) + + now = 70 + process(0.3) + now = 90 + process(0) + expect(publishMeter).toHaveBeenCalledTimes(2) + + now = 140 + process(0) + expect(publishMeter).toHaveBeenCalledTimes(3) + }) + + it('skips hidden updates, resumes promptly, and resets immediately on stop', async () => { + let now = 0 + let hidden = true + vi.spyOn(performance, 'now').mockImplementation(() => now) + vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) + const publishMeter = vi.fn() + const { result } = renderHook(() => useAudioCapture(publishMeter)) + await result.current.start() + + const process = (): void => { + processor.onaudioprocess?.({ + inputBuffer: { getChannelData: () => new Float32Array(128).fill(0.3) } + } as unknown as AudioProcessingEvent) + } + + process() + expect(publishMeter).toHaveBeenCalledTimes(1) + expect(window.api.speech.feedAudio).toHaveBeenCalledTimes(1) + + hidden = false + now = 10 + process() + expect(publishMeter).toHaveBeenCalledTimes(2) + + hidden = true + now = 20 + result.current.stop() + expect(publishMeter).toHaveBeenLastCalledWith({ + level: 0, + isSpeaking: false, + isClipping: false + }) + expect(publishMeter).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/renderer/src/hooks/use-audio-capture.ts b/src/renderer/src/hooks/use-audio-capture.ts index 025e29092e2..702640ae1c9 100644 --- a/src/renderer/src/hooks/use-audio-capture.ts +++ b/src/renderer/src/hooks/use-audio-capture.ts @@ -1,5 +1,13 @@ import { useRef, useCallback } from 'react' import { openMicrophoneCaptureStream } from '@/components/dictation/microphone-devices' +import { + DEFAULT_DICTATION_METER, + analyzeDictationAudioChunk, + createDictationMeterAnalyzerState, + dictationMeterStatesEqual, + toPublicDictationMeterState, + type DictationMeterState +} from '@/components/dictation/dictation-audio-meter' type BufferedAudioChunk = { samples: Float32Array @@ -28,8 +36,11 @@ type StopAudioCaptureOptions = { const MAX_BUFFERED_AUDIO_SECONDS = 30 const MAX_BUFFERED_AUDIO_BYTES = 8 * 1024 * 1024 +const METER_PUBLISH_INTERVAL_MS = 1000 / 15 -export function useAudioCapture() { +type DictationMeterPublisher = (meter: DictationMeterState) => void + +export function useAudioCapture(publishMeter?: DictationMeterPublisher) { const streamRef = useRef(null) const contextRef = useRef(null) const processorRef = useRef(null) @@ -44,6 +55,9 @@ export function useAudioCapture() { const capturedChunkCountRef = useRef(0) const sessionIdRef = useRef('desktop') const trackLostCleanupRef = useRef<(() => void) | null>(null) + const meterAnalyzerRef = useRef(createDictationMeterAnalyzerState()) + const publishedMeterRef = useRef(DEFAULT_DICTATION_METER) + const lastMeterPublishedAtRef = useRef(Number.NEGATIVE_INFINITY) const cleanupCaptureResources = useCallback(() => { trackLostCleanupRef.current?.() @@ -70,6 +84,13 @@ export function useAudioCapture() { bufferedAudioSecondsRef.current = 0 }, []) + const resetMeter = useCallback(() => { + meterAnalyzerRef.current = createDictationMeterAnalyzerState() + publishedMeterRef.current = DEFAULT_DICTATION_METER + lastMeterPublishedAtRef.current = Number.NEGATIVE_INFINITY + publishMeter?.(DEFAULT_DICTATION_METER) + }, [publishMeter]) + const removeOldestBufferedAudioChunk = useCallback(() => { const chunk = bufferedAudioRef.current.shift() if (!chunk) { @@ -112,6 +133,7 @@ export function useAudioCapture() { bufferAudioRef.current = options.bufferAudio ?? false resetBufferedAudio() capturedChunkCountRef.current = 0 + resetMeter() const { stream, fellBackToDefaultMicrophone } = await openMicrophoneCaptureStream({ preferredDeviceId: options.microphoneDeviceId, @@ -175,6 +197,23 @@ export function useAudioCapture() { return } const samples = new Float32Array(e.inputBuffer.getChannelData(0)) + const now = performance.now() + meterAnalyzerRef.current = analyzeDictationAudioChunk( + samples, + now, + meterAnalyzerRef.current + ) + if ( + !document.hidden && + now - lastMeterPublishedAtRef.current >= METER_PUBLISH_INTERVAL_MS + ) { + lastMeterPublishedAtRef.current = now + const meter = toPublicDictationMeterState(meterAnalyzerRef.current) + if (!dictationMeterStatesEqual(publishedMeterRef.current, meter)) { + publishedMeterRef.current = meter + publishMeter?.(meter) + } + } capturedChunkCountRef.current += 1 if (bufferAudioRef.current) { appendBufferedAudioChunk({ @@ -242,7 +281,13 @@ export function useAudioCapture() { throw err } }, - [appendBufferedAudioChunk, cleanupCaptureResources, resetBufferedAudio] + [ + appendBufferedAudioChunk, + cleanupCaptureResources, + publishMeter, + resetBufferedAudio, + resetMeter + ] ) const flushBufferedAudio = useCallback(async () => { @@ -285,8 +330,9 @@ export function useAudioCapture() { resetBufferedAudio() } cleanupCaptureResources() + resetMeter() }, - [cleanupCaptureResources, resetBufferedAudio] + [cleanupCaptureResources, resetBufferedAudio, resetMeter] ) return { diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts new file mode 100644 index 00000000000..fc31eaa9506 --- /dev/null +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' +import { useAppStore } from '../store' +import { selectFloatingWorkspaceHasUnread } from '../store/selectors' +import { makeTab } from '../store/slices/store-test-helpers' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { makePaneKey } from '../../../shared/stable-pane-id' + +const FLOATING_TAB_ID = 'tab-floating' +const MAIN_TAB_ID = 'tab-main' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const MAIN_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const FLOATING_PANE_KEY = makePaneKey(FLOATING_TAB_ID, LEAF_ID) +const MAIN_PANE_KEY = makePaneKey(MAIN_TAB_ID, MAIN_LEAF_ID) + +function seedFloatingCompletion(): void { + useAppStore.setState({ + activeView: 'activity', + activeTabId: null, + activeWorktreeId: 'wt-1', + activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: FLOATING_TAB_ID }, + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab({ id: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + ] + }, + terminalLayoutsByTabId: { + [FLOATING_TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + }, + unreadTerminalTabs: {}, + unreadAgentCompletionPanes: {}, + acknowledgedAgentsByPaneKey: {} + }) +} + +describe('useAutoAckViewedAgent — floating workspace panel visibility', () => { + beforeEach(() => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + seedFloatingCompletion() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('keeps the attention dot lit while the panel is closed and clears it once it opens', () => { + const hook = renderHook( + ({ floatingPanelVisible }: { floatingPanelVisible: boolean }) => + useAutoAckViewedAgent(floatingPanelVisible), + { initialProps: { floatingPanelVisible: false } } + ) + + // The dot is the only signal a closed panel has, so a store write while hidden must not ack it. + useAppStore.getState().markAgentCompletionPaneUnread(FLOATING_PANE_KEY) + expect(selectFloatingWorkspaceHasUnread(useAppStore.getState())).toBe(true) + + hook.rerender({ floatingPanelVisible: true }) + + expect(selectFloatingWorkspaceHasUnread(useAppStore.getState())).toBe(false) + }) + + // Why: the only case where the scan acks two targets, and acking the first re-enters the scan + // synchronously — so this is the path where a stale mid-loop snapshot would surface. + it('clears both the active terminal tab and the floating tab in one pass', () => { + useAppStore.setState({ + activeView: 'terminal', + activeTabId: MAIN_TAB_ID, + activeWorktreeId: 'wt-1', + tabsByWorktree: { + 'wt-1': [makeTab({ id: MAIN_TAB_ID, worktreeId: 'wt-1' })], + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab({ id: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + ] + }, + terminalLayoutsByTabId: { + [MAIN_TAB_ID]: { root: null, activeLeafId: MAIN_LEAF_ID, expandedLeafId: null }, + [FLOATING_TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + } + }) + + const hook = renderHook( + ({ floatingPanelVisible }: { floatingPanelVisible: boolean }) => + useAutoAckViewedAgent(floatingPanelVisible), + { initialProps: { floatingPanelVisible: false } } + ) + + useAppStore.getState().markAgentCompletionPaneUnread(MAIN_PANE_KEY) + useAppStore.getState().markAgentCompletionPaneUnread(FLOATING_PANE_KEY) + expect(selectFloatingWorkspaceHasUnread(useAppStore.getState())).toBe(true) + + hook.rerender({ floatingPanelVisible: true }) + + const state = useAppStore.getState() + expect(state.unreadAgentCompletionPanes[MAIN_PANE_KEY]).toBeUndefined() + expect(state.unreadAgentCompletionPanes[FLOATING_PANE_KEY]).toBeUndefined() + expect(selectFloatingWorkspaceHasUnread(state)).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts index e396a7e5fa2..d461dd13b90 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts @@ -3,10 +3,13 @@ import { acknowledgeViewedAgentAttention, computeAutoAckTargets, computeViewedAgentCompletionPaneKey, + resolveAutoAckTabTargets, shouldClearViewedAgentWorktreeUnread } from './useAutoAckViewedAgent' import { createTestStore, makeTab } from '../store/slices/store-test-helpers' +import { selectFloatingWorkspaceHasUnread } from '../store/selectors' import type { RetainedAgentEntry } from '../store/slices/agent-status' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { makePaneKey } from '../../../shared/stable-pane-id' const CODEX_LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -379,3 +382,124 @@ describe('shouldClearViewedAgentWorktreeUnread', () => { ).toBe(false) }) }) + +describe('resolveAutoAckTabTargets', () => { + const FLOATING_TAB_ID = 'tab-floating' + const baseState = { + activeView: 'terminal', + activeTabId: 'tab-1', + activeWorktreeId: 'wt-1', + activeTabIdByWorktree: { + 'wt-1': 'tab-1', + [FLOATING_TERMINAL_WORKTREE_ID]: FLOATING_TAB_ID + } + } + + it('scans the floating tab alongside the main tab while the panel is visible', () => { + expect(resolveAutoAckTabTargets(baseState, { floatingPanelVisible: true })).toEqual([ + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { tabId: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + ]) + }) + + it('skips the floating tab while the panel is closed', () => { + expect(resolveAutoAckTabTargets(baseState, { floatingPanelVisible: false })).toEqual([ + { tabId: 'tab-1', worktreeId: 'wt-1' } + ]) + }) + + it('scans the floating tab outside the terminal view because the panel overlays every view', () => { + expect( + resolveAutoAckTabTargets( + { ...baseState, activeView: 'activity' }, + { floatingPanelVisible: true } + ) + ).toEqual([{ tabId: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }]) + }) + + it('scans nothing outside the terminal view with the panel closed', () => { + expect( + resolveAutoAckTabTargets( + { ...baseState, activeView: 'activity' }, + { floatingPanelVisible: false } + ) + ).toEqual([]) + }) + + it('keeps the real worktree when one tab id is claimed by both worktrees', () => { + expect( + resolveAutoAckTabTargets( + { ...baseState, activeTabId: FLOATING_TAB_ID }, + { floatingPanelVisible: true } + ) + ).toEqual([{ tabId: FLOATING_TAB_ID, worktreeId: 'wt-1' }]) + }) +}) + +// Why: the minimized toggle's attention dot is the only signal a closed floating panel has, so a +// hidden panel must never auto-ack (selectFloatingWorkspaceHasUnread → FloatingTerminalToggleButton). +describe('floating workspace auto-ack against the attention dot', () => { + const FLOATING_TAB_ID = 'tab-floating' + const floatingPaneKey = makePaneKey(FLOATING_TAB_ID, CODEX_LEAF_ID) + + function seedFloatingCompletion(): ReturnType { + const store = createTestStore() + store.setState({ + activeView: 'terminal', + activeTabId: 'tab-1', + activeWorktreeId: 'wt-1', + activeTabIdByWorktree: { + 'wt-1': 'tab-1', + [FLOATING_TERMINAL_WORKTREE_ID]: FLOATING_TAB_ID + }, + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })], + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab({ id: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + ] + } + }) + store.getState().markAgentCompletionPaneUnread(floatingPaneKey) + return store + } + + function runAutoAckScan( + store: ReturnType, + floatingPanelVisible: boolean + ): void { + const state = store.getState() + for (const target of resolveAutoAckTabTargets(state, { floatingPanelVisible })) { + const activePaneKey = computeViewedAgentCompletionPaneKey(state, target.tabId, CODEX_LEAF_ID) + const paneKeysToClear = new Set(activePaneKey ? [activePaneKey] : []) + acknowledgeViewedAgentAttention(store.getState(), { + activeWorktreeId: shouldClearViewedAgentWorktreeUnread(store.getState(), { + activeWorktreeId: target.worktreeId, + activeTabId: target.tabId, + paneKeysToClear + }) + ? target.worktreeId + : null, + activeTabId: target.tabId, + paneKeys: [], + activePaneKey + }) + } + } + + it('keeps the attention dot lit while the panel is closed', () => { + const store = seedFloatingCompletion() + expect(selectFloatingWorkspaceHasUnread(store.getState())).toBe(true) + + runAutoAckScan(store, false) + + expect(selectFloatingWorkspaceHasUnread(store.getState())).toBe(true) + }) + + it('clears the attention dot once the panel is visible', () => { + const store = seedFloatingCompletion() + + runAutoAckScan(store, true) + + expect(selectFloatingWorkspaceHasUnread(store.getState())).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index 4e471aa5eed..67f7be66742 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -1,5 +1,6 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' @@ -145,25 +146,69 @@ export function acknowledgeViewedAgentAttention( } } +export type AutoAckTabTarget = { tabId: string; worktreeId: string | null } + +/** + * Tabs whose visible pane counts as "seen" right now, each paired with the worktree that owns it. + * + * Why the floating workspace is gated on panel visibility rather than `activeView`: the panel is an + * overlay that sits above every view and stays mounted while closed, and its active tab never + * becomes the global `activeTabId` — so neither the view nor the tab id can stand in for "on screen". + */ +export function resolveAutoAckTabTargets( + state: { + activeView: string + activeTabId: string | null + activeWorktreeId: string | null + activeTabIdByWorktree: Record + }, + options: { floatingPanelVisible: boolean } +): AutoAckTabTarget[] { + const targets: AutoAckTabTarget[] = [] + if (state.activeView === 'terminal' && state.activeTabId) { + targets.push({ tabId: state.activeTabId, worktreeId: state.activeWorktreeId }) + } + if (options.floatingPanelVisible) { + const floatingTabId = state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null + // Why first-wins on a tab-id collision: tab ids can be claimed by two worktrees + // (see active-tab-owner-worktree), and acking under the wrong one strands its unread dot. + if (floatingTabId && !targets.some((target) => target.tabId === floatingTabId)) { + targets.push({ tabId: floatingTabId, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + } + } + return targets +} + // Auto-ack an agent row as "seen" when the user is already on its tab, so the dashboard/Dock don't stay bold for an event they watched happen. // Scans live + retained maps: Codex's title-revert (pty-connection.ts:onAgentExited) migrates `done` rows to retained mid-race — see docs/codex-agent-row-bold-stuck.md. -export function useAutoAckViewedAgent(): void { +export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { + // Why a ref: the scan loop is mounted once, but panel visibility is React-local state that never + // reaches the store, and re-subscribing on every open/close would drop the accumulated diff refs. + const floatingPanelVisibleRef = useRef(floatingPanelVisible) + const rescanRef = useRef<(() => void) | null>(null) + useEffect(() => { // Why: the store uses plain create() (no subscribeWithSelector), so manually track the slices we depend on to skip unrelated updates. // Init to undefined so the first maybeAck() (on mount) always passes the ref guard and scans. let lastActiveView: unknown = undefined let lastActiveTabId: unknown = undefined + let lastFloatingWorkspaceActiveTabId: unknown = undefined let lastAgentStatus: unknown = undefined let lastRetained: unknown = undefined let lastAcknowledged: unknown = undefined let lastLayouts: unknown = undefined let lastUnreadAgentCompletionPanes: unknown = undefined - const maybeAck = (): void => { + // `force` re-scans after a signal the store never sees: panel open/closed is React-local state. + const maybeAck = (options?: { force?: boolean }): void => { const s = useAppStore.getState() + const floatingWorkspaceActiveTabId = + s.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null if ( + !options?.force && s.activeView === lastActiveView && s.activeTabId === lastActiveTabId && + floatingWorkspaceActiveTabId === lastFloatingWorkspaceActiveTabId && s.agentStatusByPaneKey === lastAgentStatus && s.retainedAgentsByPaneKey === lastRetained && s.acknowledgedAgentsByPaneKey === lastAcknowledged && @@ -173,9 +218,6 @@ export function useAutoAckViewedAgent(): void { return } - if (s.activeView !== 'terminal') { - return - } // Why: tab-active only proxies "seen"; gate on window visible+focused so away-time transitions don't silently clear the bold signal. if (typeof document !== 'undefined') { if (document.visibilityState !== 'visible') { @@ -185,53 +227,75 @@ export function useAutoAckViewedAgent(): void { return } } - const activeTabId = s.activeTabId - if (!activeTabId) { + const targets = resolveAutoAckTabTargets(s, { + floatingPanelVisible: floatingPanelVisibleRef.current + }) + if (targets.length === 0) { return } - const activeLeafId = resolveActiveLeafId(s, activeTabId) // Why: advance refs only after gates pass, else the diff is consumed and a gated-out transition never re-acks when focus returns. lastActiveView = s.activeView lastActiveTabId = s.activeTabId + lastFloatingWorkspaceActiveTabId = floatingWorkspaceActiveTabId lastAgentStatus = s.agentStatusByPaneKey lastRetained = s.retainedAgentsByPaneKey lastAcknowledged = s.acknowledgedAgentsByPaneKey lastLayouts = s.terminalLayoutsByTabId lastUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes - const toAck = computeAutoAckTargets(s, activeTabId, activeLeafId) - const activePaneKey = computeViewedAgentCompletionPaneKey(s, activeTabId, activeLeafId) - if (toAck.length > 0 || activePaneKey) { - const paneKeysToClear = new Set(toAck) - if (activePaneKey) { - paneKeysToClear.add(activePaneKey) - } - acknowledgeViewedAgentAttention(s, { - activeWorktreeId: shouldClearViewedAgentWorktreeUnread(s, { - activeWorktreeId: s.activeWorktreeId, - activeTabId, - paneKeysToClear + + for (const target of targets) { + // Why re-read: acking target[0] writes to the store, which re-enters this scan synchronously + // and may already have handled target[1]; `s` is a pre-write snapshot that would re-ack it. + const current = useAppStore.getState() + const tabId = target.tabId + const activeLeafId = resolveActiveLeafId(current, tabId) + const toAck = computeAutoAckTargets(current, tabId, activeLeafId) + const activePaneKey = computeViewedAgentCompletionPaneKey(current, tabId, activeLeafId) + if (toAck.length > 0 || activePaneKey) { + const paneKeysToClear = new Set(toAck) + if (activePaneKey) { + paneKeysToClear.add(activePaneKey) + } + const worktreeId = target.worktreeId + acknowledgeViewedAgentAttention(current, { + activeWorktreeId: shouldClearViewedAgentWorktreeUnread(current, { + activeWorktreeId: worktreeId, + activeTabId: tabId, + paneKeysToClear + }) + ? worktreeId + : null, + activeTabId: tabId, + paneKeys: toAck, + activePaneKey }) - ? s.activeWorktreeId - : null, - activeTabId, - paneKeys: toAck, - activePaneKey - }) + } } } + rescanRef.current = (): void => maybeAck({ force: true }) // Why: run once on mount to catch a restored session that already has agents on the visible tab. maybeAck() // Subscribe to all store changes; the ref-equality guard above skips unrelated updates. - const unsubscribe = useAppStore.subscribe(maybeAck) + const unsubscribe = useAppStore.subscribe(() => maybeAck()) // Why: focus/visibility don't flow through zustand, so re-run the scan on these DOM events when focus returns. const onVisibility = (): void => maybeAck() const onFocus = (): void => maybeAck() document.addEventListener('visibilitychange', onVisibility) window.addEventListener('focus', onFocus) return () => { + rescanRef.current = null unsubscribe() document.removeEventListener('visibilitychange', onVisibility) window.removeEventListener('focus', onFocus) } }, []) + + // Why forced: opening the panel puts an already-active floating tab on screen without any store + // write, so the equality guard would skip the scan that clears its attention dot. + useEffect(() => { + floatingPanelVisibleRef.current = floatingPanelVisible + if (floatingPanelVisible) { + rescanRef.current?.() + } + }, [floatingPanelVisible]) } diff --git a/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts b/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts index 901bfe00250..437bcd9102b 100644 --- a/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts +++ b/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts @@ -23,6 +23,7 @@ const correctedValues = { '터미널과 개발 도구가 로컬 네트워크의 서비스에 연결할 수 있도록 허용합니다. macOS는 이 권한의 현재 상태를 Orca에 보고하지 않습니다.', 'auto.components.sidebar.AddRemoteHostDialog.sshImportSynced': '{{value0}} 호스트{{value1}}을(를) Orca에 추가했습니다.', + 'auto.components.editor.CheckRunJobs.1c0a4d7e02': '성공', 'components.native-chat.approval.allow': '허용', 'components.native-chat.approval.deny': '거부' } as const diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4c9fb2d9a5d..596ef2f4e3d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5333,6 +5333,8 @@ "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", + "groupRenameFailed": "Failed to rename group", + "groupRenameFailedDesc": "Orca could not confirm the new name with the group's host. Recheck the group after reconnecting.", "failedNestWorkspace": "Failed to nest workspace", "sidebarRowMissing": "Target no longer exists", "failedUnnestWorkspace": "Failed to unnest workspace" @@ -14608,7 +14610,12 @@ "micDisconnected": "Microphone disconnected. Dictation stopped." }, "DictationIndicator": { - "335e1bc6cb": "Stop dictation" + "335e1bc6cb": "Stop dictation", + "7f3660a7ba": "Starting mic…", + "f082d0cb9d": "Processing…", + "3de5a129e7": "Listening", + "4977162383": "Too loud", + "25f2b7a6a5": "Speaking" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index add3a3cf295..3689e21bd12 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13277,7 +13277,12 @@ "5d2c3e7ae3": "No se detectó ninguna voz." }, "DictationIndicator": { - "335e1bc6cb": "Detener dictado" + "335e1bc6cb": "Detener dictado", + "7f3660a7ba": "Iniciando micrófono…", + "f082d0cb9d": "Procesando…", + "3de5a129e7": "Escuchando", + "4977162383": "Volumen demasiado alto", + "25f2b7a6a5": "Hablando" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 22a47205978..304d5f1d5eb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13277,7 +13277,12 @@ "5d2c3e7ae3": "音声が検出されませんでした。" }, "DictationIndicator": { - "335e1bc6cb": "ディクテーションを停止する" + "335e1bc6cb": "ディクテーションを停止する", + "7f3660a7ba": "マイクを起動中…", + "f082d0cb9d": "処理中…", + "3de5a129e7": "聞き取り中", + "4977162383": "音量が大きすぎます", + "25f2b7a6a5": "発話中" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c7ec4f59e42..1108cdd735e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13211,7 +13211,7 @@ "tooLarge": "붙여넣기 내용이 너무 큽니다." }, "CheckRunJobs": { - "1c0a4d7e02": "성공했다", + "1c0a4d7e02": "성공", "2d3b8f1a55": "건너뛰었습니다", "3e6c9a2b71": "보류 중", "4f7d0c3e88": "단계 실패", @@ -13295,7 +13295,12 @@ "5d2c3e7ae3": "음성이 감지되지 않았습니다." }, "DictationIndicator": { - "335e1bc6cb": "받아쓰기 중지" + "335e1bc6cb": "받아쓰기 중지", + "7f3660a7ba": "마이크 시작 중…", + "f082d0cb9d": "처리 중…", + "3de5a129e7": "듣는 중", + "4977162383": "음량이 너무 큽니다", + "25f2b7a6a5": "말하는 중" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4d59715176a..cbb41b0fa66 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13297,7 +13297,12 @@ "5d2c3e7ae3": "未检测到语音。" }, "DictationIndicator": { - "335e1bc6cb": "停止听写" + "335e1bc6cb": "停止听写", + "7f3660a7ba": "正在启动麦克风…", + "f082d0cb9d": "正在处理…", + "3de5a129e7": "正在聆听", + "4977162383": "音量过大", + "25f2b7a6a5": "正在说话" } }, "dashboard": { diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index f825fc9758d..632332f3995 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -134,6 +134,16 @@ describe('agent sleep planner', () => { ).toEqual([]) }) + it('treats an idle done copilot pane as a hibernation candidate', () => { + const copilot = entry({ + agentType: 'copilot', + providerSession: { key: 'session_id', id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' } + }) + expect( + plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [copilot.paneKey]: copilot } })) + ).toEqual([copilot.paneKey]) + }) + it('blocks done panes until their live subagent roster clears', () => { const withIdleTeammate = entry({ subagents: [ diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 57dd3499bc5..e23968adac0 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -650,6 +650,44 @@ describe('ai vault resume command runtime', () => { ).toBe("$env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'") }) + it('applies the user default args to local Copilot AI Vault resumes', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.settings = { ...state.settings, agentDefaultArgs: { copilot: '--yolo' } } as never + + expect( + buildQueuedAiVaultResumeCommand({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'copilot', + sessionId: '940237d9-c712-48e8-bca1-fd75fc4a8d4b', + cwd: '/home/alice/repo', + codexHome: null + } + }) + ).toBe("copilot '--yolo' '--resume=940237d9-c712-48e8-bca1-fd75fc4a8d4b'") + }) + + it('keeps the scanner resume command for remote Copilot sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildQueuedAiVaultResumeCommand({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'copilot', + sessionId: '940237d9-c712-48e8-bca1-fd75fc4a8d4b', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "copilot --resume='940237d9-c712-48e8-bca1-fd75fc4a8d4b'" + } + }) + ).toBe("copilot --resume='940237d9-c712-48e8-bca1-fd75fc4a8d4b'") + }) + it('ignores a stored resume command for local-host sessions', () => { const state = makeState({ worktreePath: '/home/alice/repo' }) state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index 8196e5b4e05..07f5239f1ab 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -111,6 +111,9 @@ describe('openFileInBrowserTab', () => { sourceGroupId: 'group-1' }) + expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right', { + activate: false + }) expect(mocks.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({ worktreeId: 'wt-1', environmentId: 'runtime-1', @@ -124,6 +127,22 @@ describe('openFileInBrowserTab', () => { expect(mocks.createBrowserTab).not.toHaveBeenCalled() }) + it('creates local side previews in an activated right-hand split', () => { + openFilePreviewToSide({ + language: 'html', + filePath: '/tmp/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'group-1' + }) + + expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right') + expect(mocks.createBrowserTab).toHaveBeenCalledWith('wt-1', 'file:///tmp/example.html', { + title: 'example.html', + targetGroupId: 'group-2', + activate: true + }) + }) + it('reports a paired-runtime recovery failure without an unhandled rejection', async () => { mocks.environmentId = 'runtime-1' mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index 7f50873e3a8..7bea5d29012 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -207,8 +207,11 @@ export function openFilePreviewToSide(params: { let targetGroupId = existingSibling if (!targetGroupId) { // Why: no split yet — create one to the right so the preview lands beside - // the editor. createEmptySplitGroup returns the new (empty) group id. - targetGroupId = state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right') + // the editor. Remote previews stay unfocused until click, so do not activate + // the empty group or a host snapshot will treat it as a terminal pane. + targetGroupId = environmentId + ? state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right', { activate: false }) + : state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right') } if (!targetGroupId) { return diff --git a/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts b/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts index 09ca8760d7e..1434a8ef3d4 100644 --- a/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts +++ b/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveWebSessionVisibleTabId } from './web-session-focus-intent' +import { + resolveWebSessionSiblingVisibleTabId, + resolveWebSessionVisibleTabId +} from './web-session-focus-intent' import { toVisibleTabType, type Tab, @@ -235,6 +238,33 @@ describe('resolveWebSessionVisibleTabId — no-group compatibility path', () => }) }) +describe('resolveWebSessionSiblingVisibleTabId', () => { + it('returns the sibling whose active tab matches the remembered visible type', () => { + const editor = tab({ id: 'tab-editor', entityId: 'file-1', contentType: 'editor' }) + const terminal = tab({ + id: 'tab-terminal', + entityId: 'term-1', + contentType: 'terminal', + groupId: GROUP_B + }) + const state = makeState({ + unifiedTabsByWorktree: { [WT]: [editor, terminal] }, + groupsByWorktree: { + [WT]: [ + group({ id: GROUP_A, activeTabId: editor.id, tabOrder: [editor.id] }), + group({ id: GROUP_B, activeTabId: null, tabOrder: [] }) + ] + }, + activeGroupIdByWorktree: { [WT]: GROUP_B }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' } + }) + + expect(resolveWebSessionVisibleTabId(state, WT)).toBeNull() + expect(resolveWebSessionSiblingVisibleTabId(state, WT)).toBe(editor.id) + }) +}) + describe('toVisibleTabType', () => { it('collapses every editor-family kind and preserves the rest', () => { expect(toVisibleTabType('editor')).toBe('editor') diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts index e69d7edf467..5fd06192f40 100644 --- a/src/renderer/src/runtime/web-session-focus-intent.ts +++ b/src/renderer/src/runtime/web-session-focus-intent.ts @@ -95,6 +95,33 @@ export function resolveWebSessionVisibleTabId( ) } +export function resolveWebSessionSiblingVisibleTabId( + state: WebSessionVisibleTabState, + worktreeId: string, + tabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] +): string | null { + const activeGroupId = state.activeGroupIdByWorktree?.[worktreeId] ?? null + const preferredType = + state.activeTabTypeByWorktree?.[worktreeId] ?? + (state.activeWorktreeId === worktreeId ? state.activeTabType : null) + const tabById = new Map(tabs.map((tab) => [tab.id, tab])) + let fallback: string | null = null + for (const group of state.groupsByWorktree?.[worktreeId] ?? []) { + if (group.id === activeGroupId || group.activeTabId == null) { + continue + } + const tab = tabById.get(group.activeTabId) + if (!tab || tab.groupId !== group.id) { + continue + } + if (preferredType && toVisibleTabType(tab.contentType) === preferredType) { + return tab.id + } + fallback ??= tab.id + } + return fallback +} + function focusIntentPartitionKey(owner: WebSessionIntentOwner, worktreeId: string): string { return `${webSessionIntentOwnerKey(owner)}\0${worktreeId}` } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts new file mode 100644 index 00000000000..77ed83dcbf9 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { OpenFile } from '../store/slices/editor' +import type { Tab } from '../../../shared/tab-types' +import { recordWebSessionBrowserPlacement } from './web-session-browser-placement' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { + ENV, + LEAF_ID, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const EDITOR_GROUP = 'client-editor-group' +const PREVIEW_GROUP = 'client-preview-group' +const EDITOR_FILE_ID = '/repo/paired-html-focus.html' +const EDITOR_TAB_ID = 'local-html-editor' +const HOST_TERMINAL_PARENT = 'host-terminal' +const HOST_TERMINAL_SURFACE = `${HOST_TERMINAL_PARENT}::${LEAF_ID}` +const HOST_BROWSER_TAB = 'host-html-browser-tab' +const HOST_BROWSER_PAGE = 'host-html-browser-page' + +function htmlOpenFile(): OpenFile { + return { + id: EDITOR_FILE_ID, + filePath: EDITOR_FILE_ID, + relativePath: 'paired-html-focus.html', + worktreeId: WT, + language: 'html', + isDirty: false, + runtimeEnvironmentId: ENV, + mode: 'edit' + } +} + +function htmlEditorTab(): Tab { + return { + id: EDITOR_TAB_ID, + entityId: EDITOR_FILE_ID, + groupId: EDITOR_GROUP, + worktreeId: WT, + contentType: 'editor', + label: 'paired-html-focus.html', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: NOW, + isPreview: false, + isPinned: false + } +} + +function mirroredTerminalTab(terminalTabId: string): Tab { + return { + id: terminalTabId, + entityId: terminalTabId, + groupId: EDITOR_GROUP, + worktreeId: WT, + contentType: 'terminal', + label: 'host shell', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW, + isPreview: false, + isPinned: false + } +} + +function editorFocusedSplitState(activeGroupId: string): WebSessionTabsSyncState { + const terminalTabId = toWebTerminalSurfaceTabId(HOST_TERMINAL_PARENT) + const editorTab = htmlEditorTab() + const openFile = htmlOpenFile() + return makeState({ + activeFileId: openFile.id, + activeFileIdByWorktree: { [WT]: openFile.id }, + activeGroupIdByWorktree: { [WT]: activeGroupId }, + activeTabId: terminalTabId, + activeTabIdByWorktree: { [WT]: terminalTabId }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' }, + openFiles: [openFile], + tabsByWorktree: { + [WT]: [ + { + id: terminalTabId, + ptyId: 'remote:web-env-1@@terminal-1', + worktreeId: WT, + title: 'host shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + ] + }, + unifiedTabsByWorktree: { [WT]: [mirroredTerminalTab(terminalTabId), editorTab] }, + groupsByWorktree: { + [WT]: [ + { + id: EDITOR_GROUP, + worktreeId: WT, + activeTabId: editorTab.id, + tabOrder: [terminalTabId, editorTab.id], + recentTabIds: [terminalTabId, editorTab.id] + }, + { + id: PREVIEW_GROUP, + worktreeId: WT, + activeTabId: null, + tabOrder: [] + } + ] + }, + layoutByWorktree: { + [WT]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: EDITOR_GROUP }, + second: { type: 'leaf', groupId: PREVIEW_GROUP }, + ratio: 0.5 + } + } + }) +} + +function unfocusedHtmlPreviewSnapshot() { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_TERMINAL_SURFACE, + title: 'host shell', + parentTabId: HOST_TERMINAL_PARENT, + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + }, + { + type: 'browser', + id: HOST_BROWSER_TAB, + title: 'paired-html-focus.html', + browserWorkspaceId: 'host-html-workspace', + browserPageId: HOST_BROWSER_PAGE, + url: 'file:///repo/paired-html-focus.html', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: false + } + ], + { + activeGroupId: 'host-group-1', + activeTabId: HOST_TERMINAL_SURFACE, + activeTabType: 'terminal', + tabGroups: [ + { + id: 'host-group-1', + activeTabId: HOST_TERMINAL_PARENT, + tabOrder: [HOST_TERMINAL_PARENT, HOST_BROWSER_TAB] + } + ] + } + ) +} + +function previewFocus(state: WebSessionTabsSyncState): { + activeGroupId: string | null + activeTabId: string | null + activeTabType: WebSessionTabsSyncState['activeTabType'] | null +} { + const activeGroup = (state.groupsByWorktree[WT] ?? []).find( + (group) => group.id === state.activeGroupIdByWorktree[WT] + ) + return { + activeGroupId: activeGroup?.id ?? null, + activeTabId: activeGroup?.activeTabId ?? null, + activeTabType: state.activeTabTypeByWorktree[WT] ?? null + } +} + +describe('applyWebSessionTabsSnapshot — unfocused HTML side preview', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + it('keeps the source editor focused when the empty preview split is still the active group', () => { + // Why: Open Preview to the Side creates the right-hand group first. Until the + // host browser lands, that group is empty — a host snapshot with terminal still + // active must not treat that emptiness as "the user focused a terminal". + recordWebSessionBrowserPlacement({ + environmentId: ENV, + worktreeId: WT, + remotePageId: HOST_BROWSER_PAGE, + groupId: PREVIEW_GROUP, + callerCreatedGroup: true + }) + const state = editorFocusedSplitState(PREVIEW_GROUP) + + const patch = applyWebSessionTabsSnapshot( + state, + unfocusedHtmlPreviewSnapshot(), + ENV, + NOW + 10 + ) as Partial + const next = { ...state, ...patch } + + expect(previewFocus(next)).toEqual({ + activeGroupId: EDITOR_GROUP, + activeTabId: EDITOR_TAB_ID, + activeTabType: 'editor' + }) + expect( + next.unifiedTabsByWorktree[WT]?.find((tab) => tab.contentType === 'browser') + ).toMatchObject({ id: HOST_BROWSER_TAB, groupId: PREVIEW_GROUP }) + }) + + it('still follows a host terminal snapshot when the empty split is not a reserved preview', () => { + const state = editorFocusedSplitState(PREVIEW_GROUP) + + const patch = applyWebSessionTabsSnapshot( + state, + unfocusedHtmlPreviewSnapshot(), + ENV, + NOW + 10 + ) as Partial + const next = { ...state, ...patch } + + expect(previewFocus(next).activeGroupId).not.toBe(EDITOR_GROUP) + expect(previewFocus(next).activeTabType).toBe('editor') + }) + + it('keeps the source editor focused when the editor group is still active', () => { + recordWebSessionBrowserPlacement({ + environmentId: ENV, + worktreeId: WT, + remotePageId: HOST_BROWSER_PAGE, + groupId: PREVIEW_GROUP, + callerCreatedGroup: true + }) + const state = editorFocusedSplitState(EDITOR_GROUP) + + const patch = applyWebSessionTabsSnapshot( + state, + unfocusedHtmlPreviewSnapshot(), + ENV, + NOW + 10 + ) as Partial + const next = { ...state, ...patch } + + expect(previewFocus(next)).toEqual({ + activeGroupId: EDITOR_GROUP, + activeTabId: EDITOR_TAB_ID, + activeTabType: 'editor' + }) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index d4bdc8d971a..cff237cb129 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -64,6 +64,7 @@ import { clearWebSessionFocusIntent, clearWebSessionFocusIntentsForOwner, peekWebSessionFocusIntent, + resolveWebSessionSiblingVisibleTabId, resolveWebSessionVisibleTabId } from './web-session-focus-intent' import { @@ -2908,6 +2909,16 @@ function applyWebSessionTabsSnapshotWithContext( worktreeId, nextUnifiedTabs ?? [] ) + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + // Why: Open Preview to the Side can activate an empty reserved group before the host + // browser lands. A snapshot that still has the host terminal active must not treat + // that emptiness as a terminal focus change. + const reservedEmptyPreviewFallbackTabId = + currentVisibleUnifiedTabId == null && + activeGroupId != null && + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: activeGroupId }) + ? resolveWebSessionSiblingVisibleTabId(state, worktreeId, nextUnifiedTabs ?? []) + : null // Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab. const intentUnifiedTabId = honorSnapshotActiveFocus ? navigationIntentTab?.type === 'browser' @@ -2921,6 +2932,7 @@ function applyWebSessionTabsSnapshotWithContext( const nextActiveUnifiedTabId = intentUnifiedTabId ?? currentVisibleUnifiedTabId ?? + reservedEmptyPreviewFallbackTabId ?? (snapshot.activeTabType === 'browser' ? (activeMirroredBrowserTabId ?? mirroredBrowserTabs[0]?.unifiedTab.id ?? diff --git a/src/renderer/src/store/slices/project-group-owner-routing.ts b/src/renderer/src/store/slices/project-group-owner-routing.ts new file mode 100644 index 00000000000..78da4385a53 --- /dev/null +++ b/src/renderer/src/store/slices/project-group-owner-routing.ts @@ -0,0 +1,94 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { findIndexedProjectGroupOwner } from '@/lib/worktree-runtime-owner-index' + +type ProjectGroupHostParts = Pick +type ProjectGroupOwnerRecord = Pick +type RoutingSettings = Pick | null | undefined + +type ProjectGroupOwnerRoutingState = { + projectGroups: readonly ProjectGroupOwnerRecord[] + settings: RoutingSettings +} + +// Why: persisted rows predate host stamping and may carry padded/unparseable ids; normalize so +// routing and catalog identity agree on the same owner host. +export function getProjectGroupHostId(group: ProjectGroupHostParts): ExecutionHostId { + const executionHostId = normalizeExecutionHostId(group.executionHostId) + if (executionHostId) { + return executionHostId + } + const connectionId = group.connectionId?.trim() + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID +} + +export function catalogOwnsHost(catalogHostId: string, rowHostId: string): boolean { + if (catalogHostId !== LOCAL_EXECUTION_HOST_ID) { + return catalogHostId === rowHostId + } + return parseExecutionHostId(rowHostId)?.kind !== 'runtime' +} + +export function projectGroupMatchesOwnerHost( + group: ProjectGroupOwnerRecord, + groupId: string, + ownerHostId: ExecutionHostId | null +): boolean { + if (group.id !== groupId) { + return false + } + return ownerHostId ? catalogOwnsHost(ownerHostId, getProjectGroupHostId(group)) : true +} + +export function resolveProjectGroupOwnerHostId( + state: ProjectGroupOwnerRoutingState, + groupId: string, + hostId?: ExecutionHostId +): ExecutionHostId | null { + const owner = findIndexedProjectGroupOwner(state.projectGroups, groupId, hostId) + if (!owner) { + return null + } + if (hostId) { + return hostId + } + // Why: an unstamped row carries no owner, so keep the focused-host behavior instead of assuming local. + if (!owner.executionHostId && !owner.connectionId) { + return null + } + return getProjectGroupHostId(owner) +} + +// Why: the sidebar lists groups from every host, so mutations must route to the row's owner +// instead of whichever host currently has focus. Mirrors settingsForRepoOwner. +export function settingsForProjectGroupOwner( + state: ProjectGroupOwnerRoutingState, + groupId: string, + hostId?: ExecutionHostId +): RoutingSettings { + const ownerHostId = resolveProjectGroupOwnerHostId(state, groupId, hostId) + if (!ownerHostId) { + return state.settings + } + const parsed = parseExecutionHostId(ownerHostId) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : { activeRuntimeEnvironmentId: parsed.environmentId } + } + // Why: direct-SSH groups live in the local main process catalog, so they route through window.api. + if ( + (parsed?.kind === 'local' || parsed?.kind === 'ssh') && + state.settings?.activeRuntimeEnvironmentId + ) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + return state.settings +} diff --git a/src/renderer/src/store/slices/project-group-removal-targets.ts b/src/renderer/src/store/slices/project-group-removal-targets.ts index ee0c11c3c4b..667d74ceb1f 100644 --- a/src/renderer/src/store/slices/project-group-removal-targets.ts +++ b/src/renderer/src/store/slices/project-group-removal-targets.ts @@ -1,6 +1,8 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { catalogOwnsHost, getProjectGroupHostId } from './project-group-owner-routing' export type ProjectGroupRemovalTargets = { groupExists: boolean @@ -11,9 +13,16 @@ export type ProjectGroupRemovalTargets = { export function selectProjectGroupRemovalTargets( projectGroups: readonly ProjectGroup[], repos: readonly Repo[], - groupId: string + groupId: string, + // Why: ids repeat across hosts; without an owner host the caller would target another host's rows. + hostId?: ExecutionHostId | null ): ProjectGroupRemovalTargets { - const groupExists = projectGroups.some((group) => group.id === groupId) + const ownsRowHost = (rowHostId: string): boolean => + hostId ? catalogOwnsHost(hostId, rowHostId) : true + const ownerGroups = hostId + ? projectGroups.filter((group) => ownsRowHost(getProjectGroupHostId(group))) + : projectGroups + const groupExists = ownerGroups.some((group) => group.id === groupId) if (!groupExists) { return { groupExists: false, @@ -22,10 +31,14 @@ export function selectProjectGroupRemovalTargets( } } - const deletedGroupIds = getProjectGroupSubtreeIds(projectGroups, groupId) + const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) const projectIds: string[] = [] for (const repo of repos) { - if (repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)) { + if ( + repo.projectGroupId && + deletedGroupIds.has(repo.projectGroupId) && + ownsRowHost(getRepoExecutionHostId(repo)) + ) { projectIds.push(repo.id) } } diff --git a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts new file mode 100644 index 00000000000..c936001cc3d --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { createTestStore } from './store-test-helpers' + +const folderScanGroup: ProjectGroup = { + id: 'group-1', + name: 'Projects', + parentPath: '/srv/projects', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const baseRepo: Repo = { + id: 'repo', + path: '/repo', + displayName: 'Repo', + badgeColor: '#111', + addedAt: 1 +} + +const baseFolderWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: folderScanGroup.id, + name: 'Notes', + folderPath: '/srv/projects/notes', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 +} + +const projectGroupsUpdate = vi.fn() +const projectGroupsDelete = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + projectGroupsUpdate.mockResolvedValue(null) + projectGroupsDelete.mockResolvedValue(false) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { remove: vi.fn() }, + projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +function runtimeRpcResponse(result: unknown) { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-remote' } } +} + +describe('project group mutations route to the owning host', () => { + it('renames a runtime-owned folder-scan group while the local host is focused', async () => { + const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' } + runtimeEnvironmentCall.mockResolvedValue( + runtimeRpcResponse({ group: { ...runtimeGroup, name: 'Renamed' } }) + ) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [runtimeGroup] + }) + + await expect( + store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' }) + ).resolves.toBe(true) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.update', + params: { groupId: runtimeGroup.id, updates: { name: 'Renamed' } }, + timeoutMs: 15_000 + }) + expect(projectGroupsUpdate).not.toHaveBeenCalled() + expect(store.getState().projectGroups[0]).toMatchObject({ + name: 'Renamed', + executionHostId: 'runtime:env-1' + }) + }) + + it('deletes a local folder-scan group while a runtime host is focused', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [{ ...folderScanGroup, executionHostId: 'local' }] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + + expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toEqual([]) + }) + + it('routes a direct-SSH group through the local catalog, not the focused runtime', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [{ ...folderScanGroup, connectionId: 'conn-1' }] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + + expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('prefers an explicit hostId over both the focused host and a colliding row', async () => { + runtimeEnvironmentCall.mockResolvedValue(runtimeRpcResponse({ deleted: true })) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ] + }) + + await expect( + store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'runtime:env-1' }) + ).resolves.toBe(true) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.delete', + params: { groupId: folderScanGroup.id }, + timeoutMs: 15_000 + }) + expect(projectGroupsDelete).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toMatchObject([{ executionHostId: 'local' }]) + }) + + it('keeps the focused host when a colliding id has no unambiguous owner', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + + expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + // Why: a lost connection is unverifiable, not a refusal - the failure toast copy must stay non-asserting. + it('reports an unreachable owner as failure without touching local state', async () => { + const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' } + runtimeEnvironmentCall.mockRejectedValue(new Error('runtime rpc timed out')) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [runtimeGroup], + folderWorkspaces: [baseFolderWorkspace] + }) + + await expect( + store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' }) + ).resolves.toBe(false) + await expect(store.getState().deleteProjectGroup(runtimeGroup.id)).resolves.toBe(false) + + expect(projectGroupsUpdate).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toEqual([runtimeGroup]) + expect(store.getState().folderWorkspaces).toEqual([baseFolderWorkspace]) + }) +}) + +describe('project group state cascades stay scoped to the owner host', () => { + it('leaves another host rows intact when deleting a colliding local group', async () => { + projectGroupsDelete.mockResolvedValue(true) + const runtimeChild: ProjectGroup = { + ...folderScanGroup, + id: 'child', + parentGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' }, + runtimeChild + ], + folderWorkspaces: [ + { ...baseFolderWorkspace, executionHostId: 'local' }, + { + ...baseFolderWorkspace, + id: 'folder-workspace-remote', + executionHostId: 'runtime:env-1' + } + ], + repos: [ + { ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id }, + { + ...baseRepo, + id: 'remote-repo', + projectGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + ] + }) + + await expect( + store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'local' }) + ).resolves.toBe(true) + + expect(store.getState().projectGroups).toMatchObject([ + { id: folderScanGroup.id, executionHostId: 'runtime:env-1' }, + { id: 'child', executionHostId: 'runtime:env-1' } + ]) + expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'folder-workspace-remote' + ]) + expect(store.getState().repos).toMatchObject([ + { id: 'local-repo', projectGroupId: null }, + { id: 'remote-repo', projectGroupId: folderScanGroup.id } + ]) + }) + + it('renames only the owner host row when the id exists on two hosts', async () => { + projectGroupsUpdate.mockResolvedValue({ + ...folderScanGroup, + executionHostId: 'local', + name: 'Renamed' + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' } + ] + }) + + await expect( + store.getState().updateProjectGroup( + folderScanGroup.id, + { name: 'Renamed' }, + { + hostId: 'local' + } + ) + ).resolves.toBe(true) + + expect(store.getState().projectGroups).toMatchObject([ + { name: 'Renamed', executionHostId: 'local' }, + { name: 'Remote projects', executionHostId: 'runtime:env-1' } + ]) + }) + + it('removes contained projects only from the owner host', async () => { + projectGroupsDelete.mockResolvedValue(true) + const reposRemove = vi.fn().mockResolvedValue(undefined) + const reposRemoveForHost = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ], + repos: [ + { ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id }, + { + ...baseRepo, + id: 'remote-repo', + projectGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + ] + }) + + const result = await store + .getState() + .deleteProjectGroupWithContainedProjects(folderScanGroup.id, { + removeContainedProjects: true, + hostId: 'local' + }) + + expect(result).toMatchObject({ + status: 'deleted-group', + requestedProjectIds: ['local-repo'], + removedProjectIds: ['local-repo'] + }) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['remote-repo']) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 2a802bc57b2..f0fd6fcbae8 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -52,6 +52,13 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' +import { + catalogOwnsHost, + getProjectGroupHostId, + projectGroupMatchesOwnerHost, + resolveProjectGroupOwnerHostId, + settingsForProjectGroupOwner +} from './project-group-owner-routing' import { reconcileCatalogRows, reconcileFetchedRepos } from './repo-identity-reconcile' import { retainValidFilterRepoIds } from './repo-filter-selection' import { readRuntimeWorktreeVisibilitySnapshot } from './worktree-visibility-owner-settings' @@ -218,6 +225,8 @@ export type FolderWorkspacePathStatusCacheEntry = { export type DeleteProjectGroupWithContainedProjectsOptions = { removeContainedProjects: boolean + // hostId disambiguates which host's group row to delete when the id exists on multiple hosts. + hostId?: ExecutionHostId } type AllHostCatalogFetchOptions = { @@ -1120,24 +1129,10 @@ function mergeProjectCompatibilityForHostRepoChange({ }) } -function getProjectGroupHostId(group: Pick) { - if (group.executionHostId) { - return group.executionHostId - } - return group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID -} - function getProjectGroupHostIdentity(group: ProjectGroup): string { return JSON.stringify([getProjectGroupHostId(group), group.id]) } -function catalogOwnsHost(catalogHostId: string, rowHostId: string): boolean { - if (catalogHostId !== LOCAL_EXECUTION_HOST_ID) { - return catalogHostId === rowHostId - } - return parseExecutionHostId(rowHostId)?.kind !== 'runtime' -} - function mergeFetchedProjectGroupsForHost( previous: readonly ProjectGroup[], fetched: ProjectGroup[], @@ -1186,6 +1181,37 @@ function getFolderWorkspaceHostIdentity( return JSON.stringify([getFolderWorkspaceHostId(workspace, projectGroups), workspace.id]) } +// Why: a group id can exist on several hosts; only the deleted owner's rows may be cascaded away. +function applyProjectGroupDeleteCascade( + s: Pick, + groupId: string, + ownerHostId: ExecutionHostId | null +): Pick { + const ownsRowHost = (rowHostId: string): boolean => + ownerHostId ? catalogOwnsHost(ownerHostId, rowHostId) : true + const ownerGroups = s.projectGroups.filter((group) => ownsRowHost(getProjectGroupHostId(group))) + const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) + const isDeletedGroup = (group: ProjectGroup): boolean => + deletedGroupIds.has(group.id) && ownsRowHost(getProjectGroupHostId(group)) + return { + projectGroups: s.projectGroups.filter((group) => !isDeletedGroup(group)), + folderWorkspaces: s.folderWorkspaces.filter( + (workspace) => + !deletedGroupIds.has(workspace.projectGroupId) || + // Why: resolve the workspace's host against the pre-delete group list, which still holds its owner row. + !ownsRowHost(getFolderWorkspaceHostId(workspace, s.projectGroups)) + ), + repos: s.repos.map((repo) => + repo.projectGroupId && + deletedGroupIds.has(repo.projectGroupId) && + ownsRowHost(getRepoExecutionHostId(repo)) + ? { ...repo, projectGroupId: null } + : repo + ), + folderWorkspacePathStatuses: {} + } +} + function getFolderWorkspaceUpdateIdentity( hostId: ExecutionHostId, folderWorkspaceId: string @@ -1883,11 +1909,13 @@ export type RepoSlice = { options?: { executionHostId?: ExecutionHostId } ) => Promise deleteFolderWorkspace: (folderWorkspaceId: string) => Promise + // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the group's own host owns the call. updateProjectGroup: ( groupId: string, - updates: Partial> + updates: Partial>, + options?: { hostId?: ExecutionHostId } ) => Promise - deleteProjectGroup: (groupId: string) => Promise + deleteProjectGroup: (groupId: string, options?: { hostId?: ExecutionHostId }) => Promise deleteProjectGroupWithContainedProjects: ( groupId: string, options: DeleteProjectGroupWithContainedProjectsOptions @@ -2990,10 +3018,13 @@ export const createRepoSlice: StateCreator = (set, } }, - updateProjectGroup: async (groupId, updates) => { + updateProjectGroup: async (groupId, updates, options) => { try { - // Why: project groups are focused-host-scoped by design; all CRUD routes by the focused host and the list is replaced, not merged. - const target = getActiveRuntimeTarget(get().settings) + // Why: the sidebar lists groups from every host, so the mutation follows the group's owner, not the focused host. + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) + const target = getActiveRuntimeTarget( + settingsForProjectGroupOwner(get(), groupId, options?.hostId) + ) const updated = target.kind === 'local' ? await window.api.projectGroups.update({ groupId, updates }) @@ -3010,7 +3041,9 @@ export const createRepoSlice: StateCreator = (set, } const ownedGroup = projectGroupWithFetchedOwner(updated, target) set((s) => ({ - projectGroups: s.projectGroups.map((group) => (group.id === groupId ? ownedGroup : group)), + projectGroups: s.projectGroups.map((group) => + projectGroupMatchesOwnerHost(group, groupId, ownerHostId) ? ownedGroup : group + ), folderWorkspacePathStatuses: {} })) return true @@ -3020,10 +3053,13 @@ export const createRepoSlice: StateCreator = (set, } }, - deleteProjectGroup: async (groupId) => { + deleteProjectGroup: async (groupId, options) => { try { - // Why: project groups are focused-host-scoped by design (see updateProjectGroup). - const target = getActiveRuntimeTarget(get().settings) + // Why: deletion targets the group's owner host (see updateProjectGroup); focus may be elsewhere. + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) + const target = getActiveRuntimeTarget( + settingsForProjectGroupOwner(get(), groupId, options?.hostId) + ) const deleted = target.kind === 'local' ? await window.api.projectGroups.delete({ groupId }) @@ -3038,21 +3074,7 @@ export const createRepoSlice: StateCreator = (set, if (!deleted) { return false } - set((s) => { - const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId) - return { - projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)), - folderWorkspaces: s.folderWorkspaces.filter( - (workspace) => !deletedGroupIds.has(workspace.projectGroupId) - ), - repos: s.repos.map((repo) => - repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId) - ? { ...repo, projectGroupId: null } - : repo - ), - folderWorkspacePathStatuses: {} - } - }) + set((s) => applyProjectGroupDeleteCascade(s, groupId, ownerHostId)) return true } catch (err) { console.error('Failed to delete project group:', err) @@ -3061,7 +3083,13 @@ export const createRepoSlice: StateCreator = (set, }, deleteProjectGroupWithContainedProjects: async (groupId, options) => { - const targets = selectProjectGroupRemovalTargets(get().projectGroups, get().repos, groupId) + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options.hostId) + const targets = selectProjectGroupRemovalTargets( + get().projectGroups, + get().repos, + groupId, + ownerHostId + ) const requestedProjectIds = options.removeContainedProjects ? targets.projectIds : [] if (!targets.groupExists) { return { @@ -3073,7 +3101,9 @@ export const createRepoSlice: StateCreator = (set, } } - const deleted = await get().deleteProjectGroup(groupId) + const deleted = await get().deleteProjectGroup(groupId, { + hostId: ownerHostId ?? undefined + }) if (!deleted) { return { status: 'group-delete-failed', @@ -3096,16 +3126,26 @@ export const createRepoSlice: StateCreator = (set, const removedProjectIds: string[] = [] const failedProjectRemovals: ProjectRemovalFailure[] = [] + // Why: the group's catalog can hold rows from several hosts (a local catalog also owns SSH rows), + // so each project is removed on its own host rather than on the group's. + const findOwnedProjects = (projectId: string): Repo[] => + get().repos.filter( + (repo) => + repo.id === projectId && + (!ownerHostId || catalogOwnsHost(ownerHostId, getRepoExecutionHostId(repo))) + ) for (const projectId of targets.projectIds) { - const existedBeforeRemoval = get().repos.some((repo) => repo.id === projectId) + const ownedProjects = findOwnedProjects(projectId) + const projectHostId = + ownedProjects.length === 1 ? getRepoExecutionHostId(ownedProjects[0]) : undefined try { - if (existedBeforeRemoval) { - await get().removeProject(projectId) + if (ownedProjects.length > 0) { + await get().removeProject(projectId, { hostId: projectHostId }) } } catch (err) { console.error('Failed to remove contained project:', err) } - const stillExists = get().repos.some((repo) => repo.id === projectId) + const stillExists = findOwnedProjects(projectId).length > 0 if (stillExists) { failedProjectRemovals.push({ projectId, diff --git a/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts b/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts new file mode 100644 index 00000000000..0d29c0d7784 --- /dev/null +++ b/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { createTabsSliceMockApi } from './tabs-slice-test-harness' +import { createTestStore } from './store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) + } +}) + +createTabsSliceMockApi() + +const WT = 'repo1::/tmp/feature' + +describe('createEmptySplitGroup', () => { + let store: ReturnType + + beforeEach(() => { + store = createTestStore() + }) + + it('activates the new group by default so terminal splits land in the empty pane', () => { + const tab = store.getState().createUnifiedTab(WT, 'terminal') + const sourceGroupId = store.getState().groupsByWorktree[WT][0].id + + const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right') + + expect(targetGroupId).toBeTruthy() + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(targetGroupId) + expect( + store.getState().groupsByWorktree[WT].find((group) => group.id === sourceGroupId) + ).toEqual(expect.objectContaining({ tabOrder: [tab.id] })) + }) + + it('keeps the source group focused when activate is false', () => { + store.getState().createUnifiedTab(WT, 'editor', { id: 'file-a.ts', label: 'file-a.ts' }) + const sourceGroupId = store.getState().groupsByWorktree[WT][0].id + + const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right', { + activate: false + }) + + expect(targetGroupId).toBeTruthy() + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(sourceGroupId) + expect(store.getState().groupsByWorktree[WT].map((group) => group.id)).toEqual([ + sourceGroupId, + targetGroupId + ]) + }) +}) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 221c3fd7c4a..7a1c5b1150a 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -157,7 +157,8 @@ export type TabsSlice = { createEmptySplitGroup: ( worktreeId: string, sourceGroupId: string, - direction: TabSplitDirection + direction: TabSplitDirection, + opts?: { activate?: boolean } ) => string | null moveUnifiedTabToGroup: ( tabId: string, @@ -1645,7 +1646,7 @@ export const createTabsSlice: StateCreator = (set, return true }, - createEmptySplitGroup: (worktreeId, sourceGroupId, direction) => { + createEmptySplitGroup: (worktreeId, sourceGroupId, direction, opts) => { const newGroupId = createBrowserUuid() const newGroup: TabGroup = { id: newGroupId, @@ -1653,6 +1654,7 @@ export const createTabsSlice: StateCreator = (set, activeTabId: null, tabOrder: [] } + const shouldActivate = opts?.activate !== false set((state) => { const existing = state.groupsByWorktree[worktreeId] ?? [] const currentLayout = @@ -1669,7 +1671,14 @@ export const createTabsSlice: StateCreator = (set, ...state.layoutByWorktree, [worktreeId]: replaceLeaf(currentLayout, sourceGroupId, replacement) }, - activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId } + ...(shouldActivate + ? { + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: newGroupId + } + } + : {}) } }) get().recordFeatureInteraction?.('terminal-panes') diff --git a/src/shared/agent-resume-argv-drop.test.ts b/src/shared/agent-resume-argv-drop.test.ts index aec709e411d..a1703274d9a 100644 --- a/src/shared/agent-resume-argv-drop.test.ts +++ b/src/shared/agent-resume-argv-drop.test.ts @@ -56,6 +56,27 @@ describe('dropAgentResumeArgvFromCommand', () => { ).toEqual({ status: 'dropped', command: 'codex' }) }) + it('drops the joined Copilot resume token and keeps the user args', () => { + const providerSession = { + key: 'session_id' as const, + id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' + } + const plan = buildAgentResumeStartupPlan({ + agent: 'copilot', + providerSession, + cmdOverrides: {}, + agentArgs: '--yolo', + platform: 'darwin' + }) + expect( + dropAgentResumeArgvFromCommand({ + command: plan?.launchCommand ?? '', + agent: 'copilot', + providerSession + }) + ).toEqual({ status: 'dropped', command: "copilot '--yolo'" }) + }) + it('reports absent when the command never carried the resume locator', () => { expect( dropAgentResumeArgvFromCommand({ diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index 393d97eeeee..0fffb9728a9 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -20,6 +20,10 @@ describe('agent session resume metadata', () => { expect(isResumableTuiAgent('prime-agent')).toBe(true) }) + it('treats copilot as a resumable TUI agent', () => { + expect(isResumableTuiAgent('copilot')).toBe(true) + }) + it.each([ ['claude', { session_id: 'claude-session' }, { key: 'session_id', id: 'claude-session' }], ['codex', { session_id: 'codex-session' }, { key: 'session_id', id: 'codex-session' }], @@ -44,7 +48,13 @@ describe('agent session resume metadata', () => { 'prime-agent', { session_id: 'prime-session', session_file: '/tmp/prime-session.jsonl' }, { key: 'session_id', id: 'prime-session', transcriptPath: '/tmp/prime-session.jsonl' } - ] + ], + [ + 'copilot', + { session_id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' }, + { key: 'session_id', id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' } + ], + ['copilot', { sessionId: 'copilot-camel' }, { key: 'session_id', id: 'copilot-camel' }] ] as const)('extracts %s provider session ids', (source, payload, expected) => { expect(extractAgentProviderSession(source, payload)).toEqual(expected) }) @@ -69,7 +79,8 @@ describe('agent session resume metadata', () => { 'prime-agent', { key: 'session_id', id: 's1', transcriptPath: '/tmp/prime-session.jsonl' }, ['prime-agent', '--resume', '/tmp/prime-session.jsonl'] - ] + ], + ['copilot', { key: 'session_id', id: 's1' }, ['copilot', '--resume=s1']] ] as const)('builds %s resume argv', (agent, providerSession, expected) => { expect(getAgentResumeArgv(agent, providerSession)).toEqual(expected) }) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 66f7168e8ba..5cf14c8dd57 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -14,7 +14,8 @@ export const RESUMABLE_TUI_AGENTS = [ 'grok', 'devin', 'omp', - 'prime-agent' + 'prime-agent', + 'copilot' ] as const satisfies readonly TuiAgent[] export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number] @@ -230,10 +231,15 @@ export function extractAgentProviderSession( const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } + // Why: Copilot's hook `session_id` is also its `~/.copilot/session-state//` + // directory name, so the same id is the CLI's resume locator. + case 'copilot': { + const id = readSessionId(payload, ['session_id', 'sessionId']) + return id ? { key: 'session_id', id } : null + } case 'amp': case 'cursor': case 'command-code': - case 'copilot': case 'hermes': return null } @@ -276,5 +282,10 @@ export function getAgentResumeArgv( return providerSession.key === 'session_id' ? ['omp', '--resume', ompResumeFilePath?.trim() || id] : null + // Why: the joined form is the only one Copilot documents, and it matches the + // flag spelling buildAgentResumeInvocation bakes into persisted AI Vault + // resume commands, so local and remote resumes agree on one spelling. + case 'copilot': + return providerSession.key === 'session_id' ? ['copilot', `--resume=${id}`] : null } } diff --git a/src/shared/app-environment.ts b/src/shared/app-environment.ts new file mode 100644 index 00000000000..86aca4f8182 --- /dev/null +++ b/src/shared/app-environment.ts @@ -0,0 +1,90 @@ +/** + * AppEnvironment abstracts the host-process facilities Orca's core reads from + * Electron's `app`: data paths, version, packaged flag, and shutdown. The + * desktop installs an Electron-backed implementation; a plain-Node host (the + * headless runtime server) installs a Node one, so core modules never import + * `electron`. + * + * Why a settable singleton rather than constructor injection: `getPath('userData')` + * is read at module scope and through deep call chains in ~17 modules. Threading + * an instance through all of them would be a rewrite, not a refactor. Ports that + * already have an injection point (the browser backend) stay injected instead. + * + * This module must stay free of `node:` imports — `src/shared/**` is in the web + * build graph (config/tsconfig.tc.web.json), so a require() here ships to the + * renderer. Callers that need a Node-flavoured default install one explicitly. + */ + +export type AppPathName = 'userData' | 'home' | 'appData' | 'temp' | 'downloads' | 'logs' | 'exe' + +export type AppEnvironment = { + /** Mirrors electron `app.getPath`. `userData` is the load-bearing one. */ + getPath(name: AppPathName): string + /** Mirrors electron `app.getAppPath()` — the install/bundle root. */ + getAppPath(): string + getVersion(): string + isPackaged(): boolean + /** Shutdown hook: electron `will-quit`, or SIGTERM/SIGINT on a Node host. */ + onWillQuit(handler: () => void): void + exit(code?: number): void + /** + * Per-process Chromium metrics (electron `app.getAppMetrics`). A Node host has + * no Chromium processes to measure and returns []. Kept on the port rather than + * injected because `src/main/memory/collector.ts` is its only caller and reads + * it from module scope. + */ + getAppMetrics(): AppProcessMetric[] +} + +/** Loose structural mirror of electron's ProcessMetric, so shared code stays Electron-free. */ +export type AppProcessMetric = { + pid: number + type?: string + cpu?: { percentCPUUsage?: number } + memory?: { workingSetSize?: number; privateBytes?: number } + [key: string]: unknown +} + +/** + * Why a global symbol and not a module-level `let`: `vi.resetModules()` gives the + * re-imported graph a fresh copy of this module, so an environment installed before the + * reset would silently read back as uninstalled. Anchoring to the realm keeps one + * instance per process however often the module registry is rebuilt. + */ +const SLOT = Symbol.for('orca.host.appEnvironment') + +type Slot = { [SLOT]?: AppEnvironment | null } + +function slot(): Slot { + return globalThis as unknown as Slot +} + +function read(): AppEnvironment | null { + return slot()[SLOT] ?? null +} + +/** Install the active environment. Each entrypoint calls this once, before any consumer resolves a path. */ +export function setAppEnvironment(environment: AppEnvironment): void { + slot()[SLOT] = environment +} + +/** + * Whether an environment is installed. For callers that must work in BOTH the desktop + * and a plain-Node fork — those legitimately have no app root and want null, not a throw. + */ +export function hasAppEnvironment(): boolean { + return read() !== null +} + +export function getAppEnvironment(): AppEnvironment { + const current = read() + if (!current) { + // Why throw rather than fall back: a silent default answers `userData` with the + // wrong directory, and the caller writes real user state there before anyone + // notices. Failing at the first read makes a missing install() obvious. + throw new Error( + 'AppEnvironment not initialized — call setAppEnvironment() during startup before resolving app paths' + ) + } + return current +} diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 83bc2a69ac9..0d275d272db 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -12,16 +12,12 @@ main/automations/external-manager.ts main/automations/precheck-runner.ts main/browser/browser-cookie-import.ts main/claude-accounts/keychain.ts -main/claude-accounts/service.ts main/cli/cli-installer.ts -main/codex-accounts/runtime-home-service.ts -main/codex-accounts/service.ts main/computer/macos-computer-use-permission-status.ts main/computer/macos-computer-use-permissions.ts main/computer/macos-native-provider-transport.ts main/daemon/daemon-bash-wrapper-osc133-fixture.ts main/daemon/daemon-process-identity-query.ts -main/daemon/daemon-process-inspection.ts main/daemon/daemon-process-start-time.ts main/emulator/android/android-command-runner.ts main/emulator/android/scrcpy-stream-session.ts @@ -36,7 +32,6 @@ main/ipc/developer-permissions.ts main/ipc/filesystem.ts main/ipc/macos-keyboard-layout-snapshot.ts main/ipc/notification-authorization-status.ts -main/ipc/preflight-command-exec.ts main/ipc/repos.ts main/ipc/worktree-apfs-clone.ts main/local-builds/local-build-candidate.ts @@ -51,7 +46,6 @@ main/pty/posix-pty-foreground-group.ts main/pty/posix-pty-process-groups.ts main/pty/windows-environment-path.ts main/rate-limits/codex-fetcher.ts -main/rate-limits/gemini-cli-oauth-extractor.ts main/runtime/orca-runtime-files.ts main/runtime/tls-certificate.ts main/ssh/ssh-connection.ts @@ -59,7 +53,6 @@ main/startup/appimage-cli-redirect.ts main/startup/ensure-virtual-display.ts main/startup/packaged-cli-entry-redirect.ts main/startup/windows-install-dir-acl-probe.ts -main/text-generation/commit-message-text-generation.ts main/win32-utils.ts main/window/clipboard-ipc-handlers.ts main/workspace-space-analysis.ts @@ -73,7 +66,6 @@ relay/fs-handler-git-fallback.ts relay/fs-handler-utils.ts relay/fs-list-files-fallback-chain.ts relay/git-handler.ts -relay/preflight-handler.ts relay/pty-shell-utils.ts relay/subprocess-tree-termination.ts relay/windows-port-scan.ts diff --git a/src/shared/child-process/run-process.test.ts b/src/shared/child-process/run-process.test.ts index d1058df0af8..19cea26096e 100644 --- a/src/shared/child-process/run-process.test.ts +++ b/src/shared/child-process/run-process.test.ts @@ -160,3 +160,18 @@ describe('a signal that is already aborted', () => { expect(Date.now() - startedAt).toBeLessThan(10_000) }, 20_000) }) + +describe('output truncation', () => { + const emit = (bytes: number): string => + `for (let i = 0; i < ${bytes}; i += 1) process.stderr.write('n'); process.stderr.write('FAILED')` + + it('keeps the head by default', async () => { + const result = await runProcess({ + program: process.execPath, + args: ['-e', emit(200)], + maxOutputBytes: 64 + }) + expect(result.stderr).not.toContain('FAILED') + expect(result.stderr).toBe('n'.repeat(64)) + }) +}) diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index cae64347875..e98a97cfa98 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -46,9 +46,6 @@ const SOURCE_ROOT = resolve(__dirname, '../..') */ const OWNER_FILE = 'shared/child-process/run-process.ts' - - - /** The call's argument text, brace-matched so a nested options literal stays whole. */ function readCallArguments(source: string, openParenIndex: number): string { let depth = 0 @@ -74,9 +71,34 @@ function findOffenders(): string[] { const decommented = stripComments(file.source) // Resolve `import { spawn as sp }` so a renamed binding is still a spawn. // The previous comment claimed this; only three names were hardcoded. - const aliases = [...decommented.matchAll(/\b(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|fork)\s+as\s+(\w+)/g)].map( - (match) => match[1] - ) + const aliases = [ + ...decommented.matchAll( + /\b(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|fork)\s+as\s+(\w+)/g + ) + ].map((match) => match[1]!) + // Names that resolve to `exec`/`execSync`, which imply shell: true. + const shellImplying = new Set(['exec', 'execSync']) + for (const match of decommented.matchAll(/\b(exec|execSync)\s+as\s+(\w+)/g)) { + shellImplying.add(match[2]!) + } + // `const run = promisify(execFile)` mints a third name, and it can wrap a + // renamed binding, so this has to run after the aliases are known. A + // planted `promisify(renamedExecFile)` spawn passed the guard without it. + for (const match of decommented.matchAll( + // `\w*[Pp]romisify` so `import { promisify as p }` is still seen. + /(?:const|let|var)\s+(\w+)\s*=\s*\w*[Pp]romisify\s*\(\s*(\w+)\s*\)/g + )) { + const wrapped = match[2]! + if ( + aliases.includes(wrapped) || + /^(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|fork)$/.test(wrapped) + ) { + aliases.push(match[1]!) + if (shellImplying.has(wrapped)) { + shellImplying.add(match[1]!) + } + } + } // The import test needs the module name, which blanking would erase; the // call scan needs parens inside strings neutralised. Two views, one file. if (!CHILD_PROCESS_IMPORT.test(decommented)) { @@ -99,7 +121,24 @@ function findOffenders(): string[] { if (/^\(\s*\w+\s*\??\s*:\s*[A-Za-z{[(]/.test(args)) { continue } - if (!/windowsHide\s*:\s*true/.test(args)) { + // `shell: true` silently makes windowsHide a no-op (run-process.ts, + // #14543), and `exec`/`execSync` imply it. A site can therefore read as + // guarded while still flashing a conhost -- which is exactly how + // `execAsync('where gemini', { windowsHide: true })` got un-allowlisted. + const called = match[0].replace(/\s*\($/, '').trim() + // Any `shell:` that is not literally `false` counts. `shell: + // process.platform === 'win32'` IS shell: true on the platform this + // guard is about, and only the literal was being matched. + // Recorded gap, not an oversight: `shell: process.platform === 'win32'` + // IS shell: true on the platform this guard is about, but matching any + // non-`false` value also flags `shell: spawnConfig.shell` + // (claude-accounts/service.ts:1086), a pass-through that is false in + // every branch. A false positive there costs an allowlist entry, which + // disables the guard for that whole file -- worse than the gap. No + // computed `shell:` exists in the tree today; if one appears, resolve it + // rather than widening this regex. + const impliesShell = shellImplying.has(called) || /shell\s*:\s*true/.test(args) + if (!/windowsHide\s*:\s*true/.test(args) || impliesShell) { offenders.add(file.relativePath) } } diff --git a/src/shared/posix-version-manager-bin-dirs.ts b/src/shared/posix-version-manager-bin-dirs.ts new file mode 100644 index 00000000000..698b0f7f5b1 --- /dev/null +++ b/src/shared/posix-version-manager-bin-dirs.ts @@ -0,0 +1,46 @@ +/** + * Where a version manager puts binaries inside a POSIX guest. + * + * The guest-side twin of `detectCommandsInInstallDirs`, which the native + * preflight branch already consults for the same reason -- "PATH may still be + * unhydrated on a cold GUI launch". Without it, a WSL probe that cannot + * establish the login PATH reports an nvm-installed claude/codex as not + * installed, which is #9725. + * + * Kept in step with `getBaseVersionManagerDirectories` in + * node-cli-command-resolution.ts: a WSL user on asdf, mise, volta or fnm would + * otherwise still hit #9725 while the same user on native does not. + * + * Each entry is quoted so a `$HOME` containing a space cannot word-split into + * a relative path -- except the nvm glob, where only the prefix is quoted so + * the `*` still expands. + */ +const POSIX_VERSION_MANAGER_BIN_DIRS = [ + '"$HOME/.local/bin"', + '"$HOME/.local/share/pnpm"', + '"$HOME/.yarn/bin"', + '"$HOME/.bun/bin"', + '"$HOME/.volta/bin"', + '"$HOME/.asdf/shims"', + '"$HOME/.fnm/aliases/default/bin"', + '"$HOME/.local/share/mise/shims"', + '"/usr/local/bin"', + '"$HOME"/.nvm/versions/node/*/bin' +].join(' ') + +/** + * A prelude that APPENDS those directories to PATH. + * + * Append, never prepend: when the login PATH did resolve it is authoritative, + * and a prepended fallback could shadow the binary the user actually runs with + * an older one from a stale nvm version directory. + */ +export function buildPosixFallbackPathPrelude(): string { + return [ + `for _orca_dir in ${POSIX_VERSION_MANAGER_BIN_DIRS}; do`, + ' if [ -d "$_orca_dir" ]; then PATH="$PATH:$_orca_dir"; fi', + 'done', + 'export PATH', + 'unset _orca_dir' + ].join('\n') +} diff --git a/src/shared/secret-store.test.ts b/src/shared/secret-store.test.ts new file mode 100644 index 00000000000..7c325018047 --- /dev/null +++ b/src/shared/secret-store.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { + getSecretStore, + hasSecretStore, + _resetSecretStoreForTests, + setSecretStore, + type SecretStore +} from './secret-store' + +function fakeStore(overrides: Partial = {}): SecretStore { + return { + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(`sealed:${plainText}`), + decryptString: (cipher) => cipher.toString().slice('sealed:'.length), + describeProtectionGap: () => null, + ...overrides + } +} + +describe('SecretStore registry', () => { + beforeEach(() => { + _resetSecretStoreForTests() + }) + + it('throws until a store is installed, rather than defaulting to one that cannot seal', () => { + expect(hasSecretStore()).toBe(false) + expect(() => getSecretStore()).toThrow(/SecretStore not initialized/) + }) + + it('returns the installed store', () => { + const store = fakeStore() + setSecretStore(store) + expect(hasSecretStore()).toBe(true) + expect(getSecretStore()).toBe(store) + expect(getSecretStore().encryptString('token').toString()).toBe('sealed:token') + }) + + it('lets a later install replace an earlier one, so a test fake wins over the global default', () => { + setSecretStore(fakeStore()) + setSecretStore(fakeStore({ isEncryptionAvailable: () => false })) + expect(getSecretStore().isEncryptionAvailable()).toBe(false) + }) + + it('carries a reason when sealing is unavailable, so the degradation can be surfaced', () => { + setSecretStore( + fakeStore({ + isEncryptionAvailable: () => false, + describeProtectionGap: () => 'The OS keyring is unavailable.' + }) + ) + expect(getSecretStore().describeProtectionGap()).toBe('The OS keyring is unavailable.') + }) +}) diff --git a/src/shared/secret-store.ts b/src/shared/secret-store.ts new file mode 100644 index 00000000000..312c28e557f --- /dev/null +++ b/src/shared/secret-store.ts @@ -0,0 +1,70 @@ +/** + * SecretStore abstracts at-rest secret encryption that the desktop gets from + * Electron's `safeStorage` (OS keychain). A plain-Node host installs its own + * implementation so core modules never import `electron`. + * + * The contract mirrors safeStorage exactly, including the part that matters most: + * `isEncryptionAvailable()` may return false. A store that cannot seal must say so + * rather than throw; how a caller degrades is its own decision (persistence retains + * the prior sealed blob rather than writing plaintext). See `describeProtectionGap()`, + * which exists so the reason reaches the user, not a console warning nobody reads. + */ + +export type SecretStore = { + isEncryptionAvailable(): boolean + encryptString(plainText: string): Buffer + decryptString(cipher: Buffer): string + /** + * Why separate from `isEncryptionAvailable()`: that answers "can this host seal and + * unseal at all", which must stay true for a backend that seals weakly — flipping it + * would stop `decryptWithStatus()` even attempting, and every already-stored + * credential would read back empty. This answers the different question a user cares + * about: "is my data actually protected at rest?" + * + * Non-null means the protection is not what a user would assume — either no sealing, + * or sealing with a backend that does not meaningfully protect. Null means sealed. + */ + describeProtectionGap(): string | null +} + +/** + * Why a global symbol and not a module-level `let`: `vi.resetModules()` gives the + * re-imported graph a fresh copy of this module, so a store installed before the reset + * would silently read back as uninstalled — and `getSecretStore()` throws on that. + * Anchoring to the realm keeps one instance per process however often the module + * registry is rebuilt. + */ +const SLOT = Symbol.for('orca.host.secretStore') + +type Slot = { [SLOT]?: SecretStore | null } + +function slot(): Slot { + return globalThis as unknown as Slot +} + +function read(): SecretStore | null { + return slot()[SLOT] ?? null +} + +export function setSecretStore(store: SecretStore): void { + slot()[SLOT] = store +} + +export function getSecretStore(): SecretStore { + const current = read() + if (!current) { + throw new Error( + 'SecretStore not initialized — call setSecretStore() during startup before reading or writing secrets' + ) + } + return current +} + +export function hasSecretStore(): boolean { + return read() !== null +} + +/** Test-only: drop the installed store so suites do not leak one across files. */ +export function _resetSecretStoreForTests(): void { + slot()[SLOT] = null +} diff --git a/src/shared/source-scan/source-tree-scan.test.ts b/src/shared/source-scan/source-tree-scan.test.ts index 2297d91ddf4..be4b72e4ce9 100644 --- a/src/shared/source-scan/source-tree-scan.test.ts +++ b/src/shared/source-scan/source-tree-scan.test.ts @@ -36,12 +36,12 @@ describe('stripComments', () => { }) it('is not derailed by a quote inside a regex literal', () => { - const source = ["const re = /['\"]/", '/* spawn(bad) */', 'spawn(good)'].join('\n') + const source = ['const re = /[\'"]/', '/* spawn(bad) */', 'spawn(good)'].join('\n') expect(stripComments(source)).not.toContain('bad') }) it('handles an escaped quote inside a string', () => { - const source = ['const s = \'it\\\'s\'', '/* spawn(bad) */'].join('\n') + const source = ["const s = 'it\\'s'", '/* spawn(bad) */'].join('\n') expect(stripComments(source)).not.toContain('bad') }) }) @@ -77,3 +77,50 @@ describe('blankStringContents', () => { expect(blankStringContents(source).split('\n')).toHaveLength(source.split('\n').length) }) }) + +describe('regex literals', () => { + it('does not let an apostrophe in a pattern open a string', () => { + // The real shape, from a shell quoter: the `'` inside /'/g read as a string + // opener and desynced every later line, so a scan of the file silently + // found nothing and its guard reported clean. + const source = + "const q = (v: string): string => `'${v.replace(/'/g, \"x\")}'`\nspawn('wsl.exe')\n" + expect(blankStringContents(source, true)).not.toBe('desynced') + expect(blankStringContents(source)).toContain('spawn(') + }) + + it('treats a leading block comment as a comment, not a pattern', () => { + // `/*` at index 0 has no preceding token, so the prev-token test called it + // a pattern and swallowed to the next `/` -- 110k characters of a real + // file, in the direction that hides offenders. + const source = '/* banner with a renderer/Electron slash */\nspawn("wsl.exe")\n' + // Assert the swallowed span itself survives: `spawn(` sits after the bad + // region, so checking only for it passes even while the banner is eaten. + expect(blankStringContents(source)).toContain('banner with a renderer') + }) + + it.each([ + [ + 'postfix decrement', + "const half = n-- / 2; execFile(bin, args, { cwd: '/usr/bin' })", + 'execFile(bin, args' + ], + ['non-null assertion', 'const b = done! / total!\nexecFile(y)\n', 'execFile(y)'], + [ + 'JSX self-close', + 'const a = c ? : \nexecFile(x)\n', + 'execFile(x)' + ] + ])('reads %s as division, not a pattern', (_case, source, survives) => { + // Blanking live code is the dangerous direction: the swallowed span took a + // whole execFile call with it and left no desync behind, so the guard saw + // zero calls and called the file clean. + expect(blankStringContents(source)).toContain(survives) + }) + + it('still reads division as division', () => { + const source = 'const a = (x) / 2\nconst b = arr[i] / 2\nspawn("wsl.exe")\n' + expect(blankStringContents(source)).toContain('spawn(') + expect(blankStringContents(source, true)).not.toBe('desynced') + }) +}) diff --git a/src/shared/source-scan/source-tree-scan.ts b/src/shared/source-scan/source-tree-scan.ts index cb72122c81e..780354434c0 100644 --- a/src/shared/source-scan/source-tree-scan.ts +++ b/src/shared/source-scan/source-tree-scan.ts @@ -31,7 +31,10 @@ export type ScannedFile = { path: string; relativePath: string; source: string } * Dot-directories are skipped: they hold generated and vendored trees (the * cross-version e2e checkouts among them), which are not ours to fix. */ -export function scanSourceTree(root: string, options: { includeTests?: boolean } = {}): ScannedFile[] { +export function scanSourceTree( + root: string, + options: { includeTests?: boolean } = {} +): ScannedFile[] { const found: ScannedFile[] = [] const visit = (directory: string): void => { for (const entry of readdirSync(directory)) { @@ -151,6 +154,50 @@ export function blankStringContentsDesynced(source: string): boolean { return blankStringContents(source, true) !== '' } +/** + * After a value `/` is division; after an opener or a binary operator it opens + * a regex. + * + * The set is deliberately narrow, because the two errors are not symmetric. A + * false negative leaves a pattern unblanked, which at worst desyncs the lexer + * -- and every caller treats desync as an offender, so it fails closed. A + * false positive blanks live code, and a scan that cannot see a call reports + * it clean. A wider set cost 13 real JSX spans (` : ` `}` (JSX close). + */ +function startsRegexLiteral(emitted: string): boolean { + const prev = emitted.replace(/\s+$/, '').at(-1) + return prev === undefined || '(,=:[&|?;'.includes(prev) +} + +/** End index (exclusive) of the regex literal opening at `start`, or -1. */ +function findRegexLiteralEnd(source: string, start: number): number { + let inClass = false + for (let index = start + 1; index < source.length; index += 1) { + const char = source[index] + if (char === '\\') { + index += 1 + continue + } + // A `/` inside `[...]` is literal, so it must not close the pattern. + if (char === '[') { + inClass = true + } else if (char === ']') { + inClass = false + } else if (char === '\n') { + return -1 + } else if (char === '/' && !inClass) { + return index + 1 + } + } + return -1 +} + export function blankStringContents(source: string, reportDesync = false): string { let out = '' let index = 0 @@ -206,6 +253,22 @@ export function blankStringContents(source: string, reportDesync = false): strin index += 1 continue } + // A regex literal can carry a lone apostrophe (`/'/g` in a shell quoter), + // which reads as a string opener and desyncs the rest of the file. The + // classic prev-token test disambiguates it from division: after a value a + // `/` divides, after an operator or opener it starts a pattern. + // `/*` and `//` open comments, never patterns. Callers normally strip + // comments first, but this runs standalone too, and at index 0 a file + // starting with a banner comment read as one giant regex. + const next = source[index + 1] + if (char === '/' && next !== '/' && next !== '*' && startsRegexLiteral(out)) { + const end = findRegexLiteralEnd(source, index) + if (end !== -1) { + out += `/${' '.repeat(end - index - 1)}` + index = end + continue + } + } if (char === "'" || char === '"' || char === '`') { quote = char } @@ -215,8 +278,5 @@ export function blankStringContents(source: string, reportDesync = false): strin if (reportDesync) { return quote !== null || templates.length > 0 ? 'desynced' : '' } - if (reportDesync) { - return quote !== null || templates.length > 0 ? 'desynced' : '' - } return out } diff --git a/src/shared/tui-agent-startup-copilot-resume.test.ts b/src/shared/tui-agent-startup-copilot-resume.test.ts new file mode 100644 index 00000000000..d62bdb9293c --- /dev/null +++ b/src/shared/tui-agent-startup-copilot-resume.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentResumeStartupPlan } from './tui-agent-startup' + +const SESSION = { key: 'session_id' as const, id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' } + +describe('Copilot resume startup plan', () => { + it('uses the documented joined --resume form', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'copilot', + providerSession: SESSION, + cmdOverrides: {}, + agentArgs: '--yolo', + platform: 'darwin' + }) + + expect(plan?.launchCommand).toBe(`copilot '--yolo' '--resume=${SESSION.id}'`) + }) + + it('quotes the resume argv for cmd.exe', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'copilot', + providerSession: SESSION, + cmdOverrides: {}, + agentArgs: '--yolo', + platform: 'win32', + shell: 'cmd' + }) + + // Why: cmd.exe treats single quotes as literal characters. + expect(plan?.launchCommand).toBe(`copilot "--yolo" "--resume=${SESSION.id}"`) + }) + + it('honors a command override', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'copilot', + providerSession: SESSION, + cmdOverrides: { copilot: 'copilot --banner' }, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe(`copilot --banner '--resume=${SESSION.id}'`) + }) +}) diff --git a/src/shared/windows-long-path-git-args.test.ts b/src/shared/windows-long-path-git-args.test.ts new file mode 100644 index 00000000000..5ecf0cbde0f --- /dev/null +++ b/src/shared/windows-long-path-git-args.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { windowsLongPathGitArgs } from './windows-long-path-git-args' + +describe('windowsLongPathGitArgs', () => { + it('enables long paths for a Windows drive path', () => { + expect(windowsLongPathGitArgs('C:\\Users\\dev\\repo', 'win32')).toEqual([ + '-c', + 'core.longpaths=true' + ]) + }) + + it.each(['darwin', 'linux'] as const)('returns nothing on %s', (platform) => { + expect(windowsLongPathGitArgs('/home/dev/repo', platform)).toEqual([]) + }) + + it.each(['\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', '\\\\wsl$\\Ubuntu\\home\\dev\\repo'])( + 'returns nothing for the WSL UNC path %s', + (cwd) => { + expect(windowsLongPathGitArgs(cwd, 'win32')).toEqual([]) + } + ) + + it('never mutates the shared constant', () => { + const first = windowsLongPathGitArgs('C:\\repo', 'win32') + first.push('--bogus') + expect(windowsLongPathGitArgs('C:\\repo', 'win32')).toEqual(['-c', 'core.longpaths=true']) + }) +}) diff --git a/src/shared/windows-long-path-git-args.ts b/src/shared/windows-long-path-git-args.ts new file mode 100644 index 00000000000..c22393fcaa0 --- /dev/null +++ b/src/shared/windows-long-path-git-args.ts @@ -0,0 +1,25 @@ +import { parseWslUncPath } from './wsl-paths' + +const WINDOWS_LONG_PATH_GIT_ARGS = ['-c', 'core.longpaths=true'] as const + +/** + * Global `git -c` options that let a Windows checkout exceed MAX_PATH. + * + * Why command scope: Git for Windows aborts deep checkouts with "Filename too + * long" unless core.longpaths is on, and `-c` applies it to this invocation + * only — never `--global`, `--system`, or `--local`, so no user config is + * written. Available since Git 1.9, well under the 2.25 baseline. + * + * Why keyed off cwd rather than a wslDistro option: a `C:\...` cwd can still be + * served by host git.exe even when a distro is configured, and Linux git parses + * and ignores the key, so only a true `\\wsl.localhost\...` path opts out. + */ +export function windowsLongPathGitArgs( + cwd: string, + platform: NodeJS.Platform = process.platform +): string[] { + if (platform !== 'win32' || parseWslUncPath(cwd)) { + return [] + } + return [...WINDOWS_LONG_PATH_GIT_ARGS] +} diff --git a/tests/e2e/dictation-indicator.spec.ts b/tests/e2e/dictation-indicator.spec.ts new file mode 100644 index 00000000000..b7028c347e8 --- /dev/null +++ b/tests/e2e/dictation-indicator.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' + +type MeterFixture = { + level: number + isSpeaking: boolean + isClipping: boolean +} + +async function setDictationVisualState( + page: Page, + state: 'listening' | 'stopping', + meter: MeterFixture, + partialTranscript = '' +): Promise { + await page.evaluate( + ({ dictationState, transcript }) => { + const store = window.__store + if (!store) { + throw new Error('Expected the E2E store to be exposed') + } + store.setState({ + dictationState, + partialTranscript: transcript + }) + }, + { dictationState: state, transcript: partialTranscript } + ) + await page.waitForFunction(() => Boolean(window.__dictationMeterE2E)) + await page.evaluate((dictationMeter) => { + window.__dictationMeterE2E?.publish(dictationMeter) + }, meter) +} + +async function pauseForRecordedProof(page: Page): Promise { + if (process.env.ORCA_E2E_RECORD_VIDEO === '1') { + await page.waitForTimeout(700) + } +} + +test('dictation grapes react across the visible recording lifecycle', async ({ orcaPage }) => { + const quiet = { level: 0, isSpeaking: false, isClipping: false } + await setDictationVisualState(orcaPage, 'listening', quiet) + + const indicator = orcaPage.getByTestId('dictation-indicator') + const status = indicator.getByRole('status') + await expect(indicator).toBeVisible() + await expect(status).toHaveText('Listening') + await expect(indicator.getByTestId('dictation-grapes').locator('span')).toHaveCount(9) + await expect(indicator.getByRole('button', { name: 'Stop dictation' })).toBeVisible() + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + await expect(indicator.getByTestId('dictation-grapes').locator('span').first()).toHaveCSS( + 'transition-property', + 'none' + ) + await orcaPage.emulateMedia({ reducedMotion: 'no-preference' }) + await pauseForRecordedProof(orcaPage) + + const speaking = { + level: 0.76, + isSpeaking: true, + isClipping: false + } + await setDictationVisualState(orcaPage, 'listening', speaking) + await expect(indicator.getByText('Speaking')).toBeVisible() + await expect(status).toHaveText('Listening') + await pauseForRecordedProof(orcaPage) + + const clipping = { ...speaking, level: 1, isClipping: true } + await setDictationVisualState(orcaPage, 'listening', clipping) + await expect(status).toHaveText('Too loud') + await expect(indicator).toHaveClass(/text-destructive/) + await pauseForRecordedProof(orcaPage) + + await setDictationVisualState( + orcaPage, + 'listening', + speaking, + 'The visualizer follows every word without covering the workspace.' + ) + await expect( + orcaPage.getByText('The visualizer follows every word without covering the workspace.') + ).toBeVisible() + await expect(status).toHaveText('Listening') + await pauseForRecordedProof(orcaPage) + + await setDictationVisualState(orcaPage, 'stopping', quiet) + await expect(status).toHaveText('Processing…') + await expect(indicator.getByRole('button', { name: 'Stop dictation' })).toHaveCount(0) + await pauseForRecordedProof(orcaPage) +}) diff --git a/tests/e2e/helpers/paired-client-window-reveal.ts b/tests/e2e/helpers/paired-client-window-reveal.ts new file mode 100644 index 00000000000..60af9c71450 --- /dev/null +++ b/tests/e2e/helpers/paired-client-window-reveal.ts @@ -0,0 +1,52 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' + +/** + * Reveals a paired client's window so its renderer unparks runtime subscriptions. Leave the client + * hidden only when the hidden state is what the spec covers. + */ +export type PairedClientWindowRevealReport = { + isVisible: boolean + wasVisible: boolean + windowCount: number +} + +export type RevealablePairedClient = { + app: ElectronApplication + page: Page +} + +export function assertPairedClientWindowRevealed(report: PairedClientWindowRevealReport): void { + if (report.windowCount === 0) { + throw new Error('Paired client has no BrowserWindow to reveal') + } + if (!report.isVisible) { + throw new Error( + `Paired client window stayed hidden after show() (windows: ${report.windowCount})` + ) + } +} + +export async function revealPairedClientWindow( + client: RevealablePairedClient +): Promise { + const report = await client.app.evaluate(({ BrowserWindow }) => { + const windows = BrowserWindow.getAllWindows() + const window = windows[0] + const wasVisible = window?.isVisible() ?? false + if (window && !wasVisible) { + window.show() + } + return { + isVisible: window?.isVisible() ?? false, + wasVisible, + windowCount: windows.length + } + }) + assertPairedClientWindowRevealed(report) + // Why: the renderer unparks on `visibilitychange`; clicking before it lands races a parked + // host list. + await client.page.waitForFunction(() => document.visibilityState === 'visible', null, { + timeout: 30_000 + }) + return report +} diff --git a/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts b/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts new file mode 100644 index 00000000000..2f5cf79be5c --- /dev/null +++ b/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { assertPairedClientWindowRevealed } from './paired-client-window-reveal' + +describe('assertPairedClientWindowRevealed', () => { + it('accepts a window that the reveal made visible', () => { + expect(() => + assertPairedClientWindowRevealed({ + isVisible: true, + wasVisible: false, + windowCount: 1 + }) + ).not.toThrow() + }) + + it('accepts a window that was already visible', () => { + expect(() => + assertPairedClientWindowRevealed({ + isVisible: true, + wasVisible: true, + windowCount: 1 + }) + ).not.toThrow() + }) + + it('rejects a client with no window instead of letting the spec time out on a click', () => { + expect(() => + assertPairedClientWindowRevealed({ + isVisible: false, + wasVisible: false, + windowCount: 0 + }) + ).toThrow(/no BrowserWindow/) + }) + + it('rejects a window that stays hidden after show()', () => { + expect(() => + assertPairedClientWindowRevealed({ + isVisible: false, + wasVisible: false, + windowCount: 2 + }) + ).toThrow(/stayed hidden after show\(\)/) + }) +}) diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts index 99ce5b76fbe..215e1ce5bcc 100644 --- a/tests/e2e/helpers/runtime-types.ts +++ b/tests/e2e/helpers/runtime-types.ts @@ -11,6 +11,7 @@ import type { Repo } from '../../../src/shared/repo-types' import type { WorkspaceVisibleTabType } from '../../../src/shared/tab-types' import type { TerminalTab } from '../../../src/shared/terminal-tab-types' import type { Worktree } from '../../../src/shared/worktree/types' +import type { DictationMeterState } from '../../../src/renderer/src/components/dictation/dictation-audio-meter' // Why: window.__store is the Zustand bound store itself, so specs get the whole StoreApi. export type AppStore = { @@ -63,6 +64,7 @@ declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface interface Window { __store?: AppStore + __dictationMeterE2E?: { publish(meter: DictationMeterState): void } __paneManagers?: Map } } diff --git a/tests/e2e/pr11346-selected-runtime-add.spec.ts b/tests/e2e/pr11346-selected-runtime-add.spec.ts index 20e34121f88..04f4e8d52c0 100644 --- a/tests/e2e/pr11346-selected-runtime-add.spec.ts +++ b/tests/e2e/pr11346-selected-runtime-add.spec.ts @@ -6,6 +6,7 @@ import type { FolderWorkspace } from '../../src/shared/folder-workspace-types' import type { ProjectGroup } from '../../src/shared/project-group-types' import type { Repo } from '../../src/shared/repo-types' import { expect, test } from './helpers/orca-app' +import { revealPairedClientWindow } from './helpers/paired-client-window-reveal' import { createRuntimeDesktopPairingOffer, launchPairedElectronClient @@ -20,6 +21,11 @@ import { } from './pr11346-selected-runtime-identity-oracle' async function selectRuntimeHost(page: Page, runtimeName: string): Promise { + const crashDialog = page.getByRole('dialog', { name: /recoverable UI error/i }) + if (await crashDialog.isVisible()) { + // Why: same-ID collision fixtures intentionally exceed terminal-workbench invariants. + await crashDialog.getByRole('button', { name: /Don't Send/i }).click() + } await page .getByRole('button', { name: /Add Project/i }) .first() @@ -101,16 +107,12 @@ async function runSelectedRuntimeAddJourney( try { const measurements: Record = {} - if (visible) { - await client.app.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows()[0]?.show() - }) - expect( - await client.app.evaluate( - ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible() ?? false - ) - ).toBe(true) - } + // Why: the client is the Playwright-driven surface in both topologies; the hidden-window + // parity under test is the HUB's. + expect(await revealPairedClientWindow(client)).toMatchObject({ + isVisible: true, + wasVisible: false + }) let startedAt = Date.now() await setActiveRuntimePreference(client.page, null) await setActiveRuntimePreference(client.page, client.environmentId) @@ -731,6 +733,12 @@ async function runSelectedRuntimeAddJourney( await expect(client.page.getByText(projectName, { exact: false }).first()).toBeVisible() } expect(await client.getDirectSshAttemptTargetIds()).toEqual([]) + // Why: revealing the client must not leak into the HUB's window visibility. + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((window) => window.isVisible()) + ) + ).toBe(visible) console.info(`[pr11346-routing] ${JSON.stringify({ topology: runtimeName, ...measurements })}`) await client.page.screenshot({ path: testInfo.outputPath(`${visible ? 'headed' : 'hidden-window'}-selected-runtime-add.png`), diff --git a/tests/e2e/pr11346-selected-runtime-identity-oracle.ts b/tests/e2e/pr11346-selected-runtime-identity-oracle.ts index 2918a6928df..1d1e4588b35 100644 --- a/tests/e2e/pr11346-selected-runtime-identity-oracle.ts +++ b/tests/e2e/pr11346-selected-runtime-identity-oracle.ts @@ -183,8 +183,10 @@ export async function injectSameIdLocalActivationCollision( if (!runtimeWorktree) { throw new Error(`Runtime default checkout unavailable for ${runtimePath}`) } + const localRepoId = `${runtimeRepo.id}-local-collision` const localWorktree = { ...runtimeWorktree, + repoId: localRepoId, path: localCollisionPath, hostId: 'local' as const, runtimeOwnerEnvironmentId: null @@ -193,6 +195,7 @@ export async function injectSameIdLocalActivationCollision( repos: [ { ...runtimeRepo, + id: localRepoId, path: localCollisionPath, displayName: `Local collision for ${runtimeRepo.displayName}`, executionHostId: 'local', @@ -202,7 +205,7 @@ export async function injectSameIdLocalActivationCollision( ], worktreesByRepo: { ...state.worktreesByRepo, - [runtimeRepo.id]: [localWorktree, ...(state.worktreesByRepo[runtimeRepo.id] ?? [])] + [localRepoId]: [localWorktree] }, fetchWorktrees: gate.originalFetchWorktrees }) diff --git a/tests/e2e/worktree-jump-palette-filter.spec.ts b/tests/e2e/worktree-jump-palette-filter.spec.ts index 2253bc2d344..b4841cf059e 100644 --- a/tests/e2e/worktree-jump-palette-filter.spec.ts +++ b/tests/e2e/worktree-jump-palette-filter.spec.ts @@ -193,6 +193,10 @@ test.describe('Worktree jump-palette filters', () => { const createDialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) await expect(createDialog).toBeVisible() + // Why assert focus first: the composer auto-focuses the name field, so Escape + // always lands on an input the user never chose. A page-style "blur the field + // first" handler here would silently cost a second press. + await expect(createDialog.locator('[data-workspace-name-input="true"]')).toBeFocused() await orcaPage.keyboard.press('Escape') await expect(createDialog).toBeHidden() })