mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge remote-tracking branch 'origin/main' into split-pty-connection
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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/**/*",
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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*$/
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user