mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* 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>
372 lines
13 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
}
|
|
}
|