Files
orca/config/build-plugins/plain-node-entry-guard.ts
T
NeilandJesse Sanford 0008e0cdf5 fix(orcad): add --reclaim-stale-lock for a lock no start can judge after a reboot on a shared home (#32915)
* fix(orcad): add --reclaim-stale-lock for a lock no start may judge

On a Linux host with no machine id whose ~/.orca is on a network or FUSE
home, a lock left by the host's own previous boot names a PID that is
evidence only on the machine that issued it, so every start after a
reboot refused it, and the only way out was deleting orcad.lock by hand.

Starts still refuse it: losing contact is not proof of exit, and a home
other machines may share must never be taken over automatically. Now:

- `orca serve --reclaim-stale-lock` (and `orcad --reclaim-stale-lock`)
  checks that the lock was written on this host, in an earlier boot, at
  least 2 minutes ago, and that nothing answers at its holder's
  endpoints. It prints the pid, host, boot and age and warns about other
  machines sharing the home, and clears the lock only with --yes,
  through the normal race-safe reclaim of that exact record.
- Lock records now name their host, as evidence for that command.
- The refusal has its own code, orcad_instance_lock_holder_unverifiable,
  names the command, and exits 3, so `orca serve --supervise` waits on it
  instead of giving up, and the desktop refuses on it like a held lock.
- orcad advertises orcad.stale-lock-reclaim.v1 so a client can offer
  the command on hosts that have it.

Fixes #32177

Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>

* fix(orcad): let --reclaim-stale-lock clear a reclaimer cut short by the reboot, and probe IPv6

A reclaimer from the same earlier boot on this host that the reboot cut
short left a recovery owner the command could never judge, so --yes
always failed. The confirmation now covers such an owner too, and only
one from this host's earlier boot. An IPv6 WebSocket endpoint is probed
by its bare address.

Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>

* fix(cli): ship the stale-lock reclaim module with packaged builds, and refuse an unreachable lock

The CLI loads main modules from out/main after the main build replaces
it, so the reclaim module becomes a stable plain-Node entry and is
unpacked from the asar. A lock that cannot be stat'ed for any reason other
than absence (a permission error, say) is now a refusal, not "nothing to
reclaim".

Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>

* fix(orcad): make --reclaim-stale-lock name the real data root and report a status

- The refusal prints a runnable command for the data root it refused:
  an orcad names its own runtime and entry plus `--data-root <dir>`,
  since managed hosts have neither `orca` nor `orcad` on PATH and their
  data root is not the default. The hint is Linux-only.
- Both entry points take `--data-root` and `--json` and report one of
  none, eligible, refused or reclaimed, each with its own exit code
  (4, 2, 1, 0), so a missing lock never reads as a recovery.
- The evidence says nothing answers on this machine, and that the host
  name and boot checks cannot rule out another machine with the same
  name; --yes stays the gate.
- wss endpoints default to port 443, a same-boot branch that could never
  run is gone, and the supervisor's standby line points at the command.

Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>

* fix(orcad): point the eligible message at the same command plus --yes

Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>

* fix(orcad): refuse an empty --data-root, and name the right program in reclaim hints

- `--data-root` with no usable value (a trailing flag, an empty or blank
  string) prints usage and exits 1 on both entry points, never falling
  back to the default profile.
- A refusal raised while the reclaim command itself runs names
  `orca serve --reclaim-stale-lock`, not the binary the CLI runs under;
  an orcad still names its own runtime and entry.
- The supervisor's standby hint includes this profile's --data-root.
- The unreadable-lock test is skipped when running as root.

Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>

* test(cli): reuse the existing client stub for the --data-root refusal test

Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>

---------

Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>
2026-10-23 15:26:03 -07:00

372 lines
13 KiB
TypeScript

import { spawn } from 'node:child_process'
import { isBuiltin } from 'node:module'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
type NormalizedInputOptions = Rollup.NormalizedInputOptions
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
type OutputBundle = Rollup.OutputBundle
type OutputChunk = Rollup.OutputChunk
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
// daemon is forked as a plain-Node process where electron cannot be required.
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
// stayed invisible until an adopted old daemon died. This guard fails the
// build when any chunk reachable from a plain-Node fork entry requires
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.
// The CLI loads these paths after electron-vite replaces out/main.
export const CLI_MAIN_ENTRY_NAMES = [
'agent-hooks/managed-agent-hook-controls',
'gitlab/project-ref-parser',
'orca-profiles/profile-index-store',
'claude-accounts/keychain',
'skills/bundled-skill-install/cli-entry',
'macos-tree-clone',
'orcad/orcad-stale-lock-reclaim',
...[
'access',
'active-location',
'storage-classification',
'offline-settings',
'backup-path',
'database-recovery',
'domain-reader',
'recovery-command'
].map((module) => `persistence/profile-state/profile-state-${module}`),
'persistence/profile-state/legacy-json/profile-state-export-path',
'persistence/profile-state/legacy-json/profile-state-recovery',
'startup/http1-compatibility-marker'
] as const
// Plain-Node processes and CLI modules cannot load Electron's API.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'session-scanner-service-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'orcad/orcad-local-serve-selection-entry',
...CLI_MAIN_ENTRY_NAMES
] as const
// Entries executed as worker threads of the main process. Electron's module is
// not registered on worker threads, so require("electron") throws
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
// the worker at startup. These carry hand-written "must stay electron-free"
// comments, which is convention, not enforcement — and the port-scan worker in
// particular sits one import away from a client module that deliberately does
// require electron.
const WORKER_THREAD_ENTRY_NAMES = [
'stt-worker',
'warp-theme-parser-worker',
'foreign-sqlite-reader-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry',
'worker-thread-child-process-entry',
'usage-scan-worker-entry',
'workspace-space-scan-worker-entry',
'claude-profile-setup-worker-entry',
'profile-state-backup-worker-entry',
'profile-state-writer-worker-entry'
] as const
export const GUARDED_ENTRY_NAMES = [
...PLAIN_NODE_ENTRY_NAMES,
...WORKER_THREAD_ENTRY_NAMES
] as const
type EntryRuntime = 'plain-Node process' | 'worker thread'
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
// Why: writeBundle skips any name missing from the bundle, so a renamed or
// removed rollup input would silently drop that entry from the guard and let the
// regression back in. Pin the lists to the input keys at build start instead.
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
if (typeof input === 'string' || Array.isArray(input)) {
return
}
const inputNames = new Set(Object.keys(input))
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
if (missing.length > 0) {
throw new Error(
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
`the current entry names — a stale name silently stops guarding that entry.`
)
}
}
function collectReachableChunks(
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): OutputChunk[] {
const seen = new Set<string>()
const reachable: OutputChunk[] = []
const stack = [entry.fileName]
while (stack.length > 0) {
const fileName = stack.pop() as string
if (seen.has(fileName)) {
continue
}
seen.add(fileName)
const chunk = byFileName.get(fileName)
if (!chunk) {
continue
}
reachable.push(chunk)
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
stack.push(imported)
}
}
return reachable
}
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>,
electronFreeChunkCode: Map<OutputChunk, string>,
runtime: EntryRuntime = 'plain-Node process'
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
const code = chunk.code
if (electronFreeChunkCode.get(chunk) === code) {
continue
}
if (ELECTRON_REQUIRE_RE.test(code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
}
electronFreeChunkCode.set(chunk, code)
}
}
// Why: on packaged Windows the daemon runs from a relocated daemon-host copy that mirrors only
// these packages (src/main/daemon/daemon-host-manifest.ts). Any other bare require in its graph
// resolves in the install dir but throws MODULE_NOT_FOUND in the copy, and the desktop silently
// falls back to an unrelocated daemon that the next update kills.
const DAEMON_HOST_MIRRORED_PACKAGES = [
'@orca/process-host',
'node-pty',
'@vscode/windows-process-tree'
]
const BARE_REQUIRE_RE = /require\(\s*["'`]([^"'`./][^"'`]*)["'`]\s*\)/g
function packageOfSpecifier(specifier: string): string {
const parts = specifier.split('/')
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!
}
function assertDaemonExternalsAreMirrored(
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
for (const match of chunk.code.matchAll(BARE_REQUIRE_RE)) {
const specifier = match[1]!
if (specifier.startsWith('node:') || isBuiltin(specifier)) {
continue
}
if (!DAEMON_HOST_MIRRORED_PACKAGES.includes(packageOfSpecifier(specifier))) {
throw new Error(
`[plain-node-entry-guard] "daemon-entry" reaches chunk "${chunk.fileName}" that ` +
`requires "${specifier}", which the relocated Windows daemon-host does not mirror ` +
`(daemon-host-manifest.ts). Keep it out of the daemon graph, or mirror it there.`
)
}
}
}
}
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
export type SmokeTimings = {
timeoutMs: number
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
// needs an uncatchable follow-up to stay a deadline.
killGraceMs: number
}
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
// can hold the pipes open long after the child is gone.
const SMOKE_STDERR_DRAIN_MS = 250
type SmokeResult = {
status: number | null
signal: NodeJS.Signals | null
stderr: string
error?: Error
timedOut: boolean
}
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
// build forever. Escalate to SIGKILL instead.
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
let timedOut = false
let settled = false
let forceKillTimer: NodeJS.Timeout | undefined
let drainTimer: NodeJS.Timeout | undefined
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => {
stderr += chunk
})
const deadlineTimer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
}, timings.timeoutMs)
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(deadlineTimer)
clearTimeout(forceKillTimer)
clearTimeout(drainTimer)
resolve({ status, signal, stderr, error, timedOut })
}
child.on('error', (error: Error) => finish(null, null, error))
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
// cannot outlast the process itself.
child.on('close', (status, signal) => finish(status, signal))
child.on('exit', (status, signal) => {
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
})
})
}
// Why: proves the whole daemon-entry graph resolves under plain Node (no
// unresolved requires). require("electron") does not throw in a dev tree with
// node_modules present, so the static scan above — not this smoke — is the
// electron regression guard; this only catches gross load failures.
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = await runDaemonEntry(entryPath, timings)
if (result.error) {
throw new Error(
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
`${result.error.message}`
)
}
// Almost always means the daemon stopped rejecting an empty argv and started
// listening instead.
if (result.timedOut) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
`empty argv under plain Node, so the smoke killed it.`
)
}
if (result.signal) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
)
}
const stderr = result.stderr
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
`${result.status}). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
): Plugin {
let daemonOutputDir: string | undefined
return {
name: 'orca-plain-node-entry-guard',
buildStart(options: NormalizedInputOptions) {
assertEntryNamesAreRollupInputs(options.input)
},
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
// every rebuild, and the guard only needs to gate produced builds.
if (this.meta.watchMode) {
return
}
const chunks = Object.values(bundle).filter(
(item): item is OutputChunk => item.type === 'chunk'
)
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
const entryByName = new Map<string, OutputChunk>()
for (const chunk of chunks) {
if (chunk.isEntry && chunk.name) {
entryByName.set(chunk.name, chunk)
}
}
const electronFreeChunkCode = new Map<OutputChunk, string>()
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(
entryName,
entry,
byFileName,
electronFreeChunkCode,
'plain-Node process'
)
}
}
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(
entryName,
entry,
byFileName,
electronFreeChunkCode,
'worker thread'
)
}
}
const daemonEntry = entryByName.get('daemon-entry')
if (daemonEntry) {
assertDaemonExternalsAreMirrored(daemonEntry, byFileName)
}
if (entryByName.has('daemon-entry') && options.dir) {
daemonOutputDir = options.dir
}
},
async closeBundle() {
if (daemonOutputDir) {
const outputDir = daemonOutputDir
daemonOutputDir = undefined
await smokeLoadDaemonEntry(outputDir, smokeTimings)
}
}
}
}