Merge remote-tracking branch 'origin/main' into split-pty-connection

This commit is contained in:
Jinjing
2026-08-22 23:08:56 -07:00
322 changed files with 9851 additions and 1854 deletions
+11
View File
@@ -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 \
+6
View File
@@ -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/).
+96
View File
@@ -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.`
)
}
@@ -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()
}
@@ -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([])
})
})
@@ -848,6 +848,9 @@
"auto.components.editor.CheckRunDetailsPanel.fd46a70f1a": {
"ko": "시작됨"
},
"auto.components.editor.CheckRunJobs.1c0a4d7e02": {
"ko": "성공"
},
"auto.components.editor.CombinedDiffFileTree.d5ac717d65": {
"ko": "commit 안 됨"
},
@@ -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 `<workspaces>/<repo-name>/` 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()
+67
View File
@@ -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> = {}): 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<AppEnvironment> = {}): 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
})
})
+1
View File
@@ -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/**/*",
+4 -1
View File
@@ -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',
+1 -1
View File
@@ -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.
+5 -1
View File
@@ -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",
@@ -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 "$@"
}
@@ -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 "$@"
}
@@ -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 "$@"
}
@@ -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 "$@"
}
@@ -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
@@ -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
@@ -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',
+13 -11
View File
@@ -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
@@ -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)
}
@@ -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()
}))
+1 -1
View File
@@ -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 {
+3
View File
@@ -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()
@@ -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<typeof Os>('node:os')
return { ...actual, homedir: () => tempHome }
+7 -7
View File
@@ -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<typeof Os>('node:os')
return { ...actual, homedir: () => tempHome }
+7 -7
View File
@@ -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<typeof Os>('node:os')
return { ...actual, homedir: () => tempHome }
+16
View File
@@ -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)
}
}
@@ -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
+4 -8
View File
@@ -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
@@ -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)
+10 -26
View File
@@ -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.')
+4 -4
View File
@@ -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 ??
+21
View File
@@ -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',
+18 -13
View File
@@ -459,22 +459,27 @@ export class WslCliInstaller {
async function runWslCommand(distro: string, command: string): Promise<string> {
// 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<ReturnType<typeof runWslProcess>>
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}.`)
}
@@ -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)
@@ -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
+18 -28
View File
@@ -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<void> {
// 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<ReturnType<typeof runWslProcess>>
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) {
@@ -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',
+1 -1
View File
@@ -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',
+7 -3
View File
@@ -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 {
+7 -1
View File
@@ -191,7 +191,13 @@ async function runInspectionCommand(
args: string[],
timeoutMs: number
): Promise<string> {
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
}
@@ -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)
+3
View File
@@ -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<S
promoteAgentTeamsShimPath(env, requestedPath)
// Why: raw requested PATH promotion runs after the inherited-env scrub.
stripLegacyTerminalShimEnv(env, process.platform)
// Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind.
dropIncoherentCondaActivationEnv(env, process.platform)
// Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix.
ensureNodePtySpawnHelperExecutable()
+42 -1
View File
@@ -1,5 +1,5 @@
import type * as FsPromises from 'node:fs/promises'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
const {
gitExecFileAsyncMock,
@@ -67,6 +67,10 @@ beforeEach(() => {
})
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 () => {
+15
View File
@@ -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')
})
})
+9 -1
View File
@@ -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 }
}
}
@@ -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.<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
@@ -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 <branch>` 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.<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.<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.<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.<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',
+29
View File
@@ -37,3 +37,32 @@ export async function hasWorktreeBaseCommitRef(
): Promise<boolean> {
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<WorktreeBaseRefPresence> {
try {
const { stdout } = await runGit([
'for-each-ref',
'--count=1',
'--format=%(refname)',
qualifiedRef
])
return stdout.trim() === qualifiedRef ? 'present' : 'absent'
} catch {
return 'unknown'
}
}
+22 -4
View File
@@ -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/<branch> 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<AddWorktreeResult> {
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 =
@@ -27,6 +27,11 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['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],
+66 -5
View File
@@ -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<string, string> | undefined
execMock.mockImplementation((_script, options, callback) => {
capturedEnv = (options as { env: Record<string, string> }).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)
+9 -11
View File
@@ -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) {
+38
View File
@@ -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[]
}
}
@@ -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)
+14
View File
@@ -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
}
@@ -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<typeof ipcMain.on>[1])
},
removeIpcListener: (channel, listener) => {
ipcMain.removeListener(channel, listener as Parameters<typeof ipcMain.removeListener>[1])
}
}
+116
View File
@@ -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<T>(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')
}
})
})
+39
View File
@@ -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
}
}
@@ -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)
}
@@ -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')
})
})
+30
View File
@@ -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
}
+66 -3
View File
@@ -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)
+7 -7
View File
@@ -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)
+14
View File
@@ -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<void> {
}
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
}
@@ -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()
+5 -2
View File
@@ -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()
+7 -7
View File
@@ -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)
}
+4 -8
View File
@@ -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<PluginListEntry[]> {
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,
+114 -18
View File
@@ -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' })
)
})
})
@@ -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/)
})
)
@@ -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
@@ -1,4 +1,4 @@
import { getActiveMultiplexer } from './ssh'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
export type RemoteWindowsTerminalCapabilities = {
wslAvailable: boolean
@@ -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<string> => {
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')
})
})
@@ -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
+78 -4
View File
@@ -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 })
}
})
})
+6 -7
View File
@@ -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
+14 -286
View File
@@ -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>): 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<string[]> {
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<string[]> {
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<RefreshAgentsResult> {
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<string[]> {
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<boolean> {
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<boolean> {
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<PreflightStatus> {
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(
+68
View File
@@ -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
}
+23
View File
@@ -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()
+21
View File
@@ -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
}
}
+37
View File
@@ -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
}
@@ -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]) {
+161 -162
View File
@@ -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<void> | 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<SerializeResult> {
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<boolean> => {
@@ -7388,8 +7388,11 @@ export function registerPtyHandlers(
const hostViewportClaimTails = new Map<string, Promise<boolean>>()
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<boolean> => {
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
ipc.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise<boolean> => {
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<string, number> }) => {
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<PtyListedSession[]> => {
ipc.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
const deduped = new Map<string, PtyListedSession>()
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<boolean | null> => {
ipc.handle('pty:hasPty', async (_event, args: { id: string }): Promise<boolean | null> => {
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<boolean> => {
if (!hasPtyProviderForInspection(args.id)) {
return false
}
return getProviderForPty(args.id).hasChildProcesses(args.id)
ipc.handle('pty:hasChildProcesses', async (_event, args: { id: string }): Promise<boolean> => {
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<string | null> => {
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<string | null> => {
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<string> => {
ipc.handle('pty:getCwd', async (_event, args: { id: string }): Promise<string> => {
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<number> => {
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<void> => {
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<void> => {
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<void> => {
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,
@@ -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'
@@ -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'
@@ -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'
+1
View File
@@ -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'
+1
View File
@@ -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'
@@ -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'
@@ -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'
+1
View File
@@ -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'
@@ -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.'
+62 -60
View File
@@ -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<SshConnectionState>) | 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<SshConnectionState> {
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<string, string> {
return sshStore?.listRemovedTargetLabels() ?? {}
}
export async function disconnectRegisteredSshTarget(targetId: string): Promise<void> {
invalidateConnectAttempt(targetId)
await runTargetLifecycle(targetId, () =>
@@ -132,10 +130,10 @@ export async function disconnectRegisteredSshTarget(targetId: string): Promise<v
}
export async function removeRegisteredSshTarget(targetId: string): Promise<void> {
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<void> {
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<SshTarget, 'id'> }) => {
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<Omit<SshTarget, 'id'>> }) => {
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<SshConnectionState> {
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<void> {
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()
}
+21 -10
View File
@@ -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<boolean> {
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<LocalBaseRefRefreshResult> {
): Promise<LocalBaseRefRefreshResult | undefined> {
const evaluation = await evaluateRemoteLocalBaseRefRefreshability(
provider,
repoPath,
@@ -1393,6 +1394,16 @@ async function evaluateRemoteLocalBaseRefRefreshability(
}
}
} catch {
// Why (#15331): the probes above also fail when refs/heads/<branch> 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.`
)
@@ -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*$/
)
}
})
})
+42
View File
@@ -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<void>
restoreLocal(worktreePath: string): Promise<void>
forgetLocal(worktreePath: string): void
closeRemote(connectionId: string, worktreePath: string): Promise<void>
restoreRemote(connectionId: string, worktreePath: string): Promise<void>
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
}
@@ -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(
@@ -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(

Some files were not shown because too many files have changed in this diff Show More