Skip to content

feat(orcad): run the terminal daemon, and the ops contract around it - #16375

Closed
nwparker wants to merge 5 commits into
nwparker/orcad-item5-libcfrom
nwparker/orcad-item34-ops
Closed

nwparker wants to merge 5 commits into
nwparker/orcad-item5-libcfrom
nwparker/orcad-item34-ops

Conversation

@nwparker

@nwparker nwparker commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 19 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​925 $\color{#cf222e}{\Huge{\mathbf{−}}}$​24 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​901
Prod 20 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1305 $\color{#cf222e}{\Huge{\mathbf{−}}}$​104 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1201

Plan items 3 and 4, stacked on #16374. The design says these must be co-designed, not sequential — the instance lock, supervision contract and health check are all wrong if written with one process in scope.

Item 4: work now survives an orcad restart

orcad previously declared canRecoverPersistentLocalPtys: () => false. But ssh-execution-boundary.md:77 recommends the peer model because "daemon-backed PTYs stay live across a normal runtime restart so the runtime can reattach". So orcad was shipping the peer model minus the property it is recommended for — every restart, update and rollback destroyed all running work.

The flag is now a function of real state, daemonOwnsFreshPersistentPtys(), rather than a hardcoded answer in either direction.

stopOrcadDaemon calls disconnectDaemon, never shutdownDaemon — shutdown kills the daemon and every PTY under it, which would make orcad's own restart destructive.

Verified end to end, not just unit-tested. With a live PTY, stopping orcad leaves the daemon running:

daemon pid=73596
terminal=term_86a2f71e-829a-45b9-af7b-59f9df8f2fa0
daemon alive after orcad stop, WITH a live PTY: YES

With no PTYs it exits promptly — correct idle behaviour, nothing to preserve. I checked that case first and briefly mistook it for a defect; testing the real scenario is what settled it.

Item 3: the ops contract

  • Bind policy. Was hardcoded exposeNetworkByDefault: true → 0.0.0.0. Now loopback by default, verified live:
    [orcad] orcad is bound to 127.0.0.1 (local only). Reach it from another machine with
    an SSH local port-forward, or re-launch with --bind to expose it.
    
    That matches the design's deploy model, and it is the inverse of what shipped.
  • Instance lock on the data root before profile load.
  • Supervision covering both relationships — who supervises orcad, and orcad supervising the daemon.
  • Health spanning both processes. Readiness now carries a real PTY-spawn self-test, so a green orcad with a dead daemon is not reportable as healthy:
    "terminalDaemon":{"state":"live","ownsFreshSessions":true,"pid":54172,
      "protocolVersion":36,"selfTest":{"ok":true,"coverage":"pty-spawn","verdict":"healthy","durationMs":8}}

It also closed a hole in item 0's gate

My smoke-load gate asserted on the text of an argv rejection — but that string is a literal in the bundle, so a crash printing it would falsely match, and the assertion could pass against a bundle that never loaded. It now uses an explicit --orcad-smoke-load-check flag that exits 0 before probing natives or binding a port, and asserts status === 0. Strictly better than what I wrote.

Proof

1,614 tests pass across src/main/orcad + src/main/runtime/rpc. Ratchet 0; build-orcad green (4.73 MB, zero electron and node:sqlite); smoke:orcad-terminal passes; typecheck and lint clean.

Full suite: the only failures are 4 tests that fail identically on main — verified by running the same set on both and diffing the failure names, which came out empty. This branch touches none of those files.

Trade-offs

One deliberate, and it is a security improvement: orcad now binds loopback instead of every interface. Anyone relying on the old wide bind must pass --bind explicitly. That is the inverse default from what shipped, chosen because an unattended server should not publish an authenticated endpoint on every interface without someone asking for it.

orcad's AppEnvironment implemented three of seven AppPathNames and returned the
userData directory for the rest — including 'exe', where a data directory is not
an executable. Every name now has a Node answer: 'appData' is the platform's
per-user application-data root, 'logs' lives inside the data root so a headless
deployment stays one removable directory, 'downloads' honours XDG_DOWNLOAD_DIR,
and 'exe' is the Node binary. getAppPath() is the directory orcad was launched
from rather than cwd, so children resolve against the bundle instead of wherever
the supervisor happened to be.

The watcher child was the load-bearing consequence: resolveWatcherProcessEntryPath
probed for the adjacent entry only when !isPackaged, so orcad resolved a desktop
out/main path that no deployment has — and build-orcad never emitted the child
anyway. isPackaged stays true (consumers read it as "production, not a dev
checkout" and it gates HTTPS-only skill downloads); the resolver now asks whether
the app root is an asar archive, which is the question it actually meant. The
child ships beside orcad.js, and the build forks it to prove it runs.
… host

`resolveWorkerEntryPath` branched on `isPackaged` alone and joined
`process.resourcesPath`. orcad reports `isPackaged` true — correctly, it is a
production build, and ~15 consumers read it that way to gate HTTPS-only skill
downloads and the real CLI name — but `process.resourcesPath` is Electron-only
and `undefined` under plain Node.

So the packaged branch threw
`TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string`
where a clean "worker unavailable" was the honest outcome. The type said
`resourcesPath: string`, which is how it went unnoticed; it is now
`string | undefined`, so the compiler carries the fact.

A host with no Electron resources tree has no asar to look in, so it falls back
to the module directory and lets the caller report a missing worker.

Found by the item 1 agent while auditing the same `isPackaged` defect class in
the watcher. Verified in both directions: reverting the guard reproduces the
TypeError.
Of the two ways node-pty fails, only one is catchable. A missing module throws
MODULE_NOT_FOUND. A module built against the wrong libc or Node ABI is refused by
the dynamic loader, and in the worst case takes the process down before any handler
exists — that is #9902, which crashed the desktop app on Ubuntu 20.04 before a
window appeared. There was no libc or ABI precondition anywhere in the tree.

So orcad now proves the load in a CHILD process, from main.ts, before anything
requires node-pty. Whatever the child does — throw, abort, die on a signal — is data
rather than our own death, and the operator gets a sentence naming the host's libc,
Node ABI and prebuild slot plus the command to run. Proven-unloadable exits 78
(EX_CONFIG), so a supervisor does not restart an unequippable host forever. A probe
that never answered is unverifiable, not blocked: refusing to boot on an inconclusive
signal would take down hosts that work.

The child dlopens the file node-pty would have chosen, before requiring the package.
node-pty's loader walks several directories and rethrows only the LAST error, so a
refused binary reads as "Cannot find module ./prebuilds/..." — which sends the
operator to install a module that is already there. It also reports through stdout:
node echoes the whole -e source above a stack trace, and matching tokens against
stderr made the probe's own source text answer for the verdict.

Verdicts reach clients as a terminal_unavailable degradation alongside the existing
browser_unavailable one, through the same cause-registry shape. degradations[].code
is now an open vocabulary; clients already render only `message`.

Prebuilds are compiled from PATCHED sources — the patch IS the glibc-floor fix, so an
upstream tarball reproduces #9902 — into linux-{x64,arm64}-{glibc,musl} and
darwin-{x64,arm64} slots. libc is in the slot name because node-pty's loader falls
back to prebuilds/<platform>-<arch> and cannot tell glibc from musl. orcad installs
the matching slot at boot, so a host with no compiler serves terminals.

The relay's five pure toolchain-diagnosis functions moved to a transport-free module
so the Node bundle can reuse them without dragging ssh2 in behind them; the relay
keeps its API by re-export. macOS gets `xcode-select --install` rather than the
cross-distro apt/dnf/pacman/apk menu, every line of which is wrong there.
…pared host

CI's test shard runs `vitest` directly, so `ensure-native-runtime --runtime=node`
never prepares node-pty for the Node ABI — `degraded` is the correct verdict
there, and asserting 'ok' encoded an environment the shard does not have.

Asserting whatever it returned would be vacuous, so the expectation is now
derived from an independent require() of node-pty. Verified it still bites:
forcing the precondition to always report 'ok' fails the suite.
@nwparker
nwparker force-pushed the nwparker/orcad-item5-libc branch from 255b1ff to 07a1a32 Compare August 25, 2026 05:22
orcad declared `canRecoverPersistentLocalPtys: () => false` because it did not
run the terminal daemon, so every restart, update and rollback SIGKILLed every
running terminal — on the host whose selling point is that work survives the
client going away. That is the one property `ssh-execution-boundary.md`
recommends the peer model for.

Item 4 — the daemon:

- Port the launch path off electron: `daemon-init.ts`,
  `daemon-host-relocation.ts` and `observability/logs-directory.ts` now read
  the `AppEnvironment` port. Relocation additionally asks whether the app root
  is an asar archive rather than whether the build is packaged, so a Node host
  answering `isPackaged() === true` no longer walks into an Electron-only
  NSIS-escape path (same precedent as `parcel-watcher-entry-path.ts`).
- `build-orcad.mjs` emits `daemon-entry.js` beside `orcad.js`, scans the
  forked children's metafiles for electron/node:sqlite, and load-checks the
  child under plain Node.
- orcad spawns and adopts the daemon; shutdown disconnects and never kills it.
  `canRecoverPersistentLocalPtys` now reads the live provider and is false
  under degraded routing, where fresh terminals would die with the process.

Item 3 — the ops contract (docs/reference/orcad-operations.md):

- Bind policy: `--bind`, default loopback, pinned so neither `orca serve`'s
  wide default nor the connected-device widen can override it, and so a paired
  client cannot rebind the listener from outside.
- Instance lock on the data root before profile load, scoped to the runtime
  role so it never refuses a restart that a live daemon makes worthwhile.
- Supervision: exit codes a supervisor can act on (78 = do not retry),
  second-signal escalation, a shutdown deadline, and crash-loop containment on
  daemon respawn.
- Health in the readiness payload: build hash, Node ABI, and a PTY self-test
  that spans both processes — the daemon spawns a real PTY in its own process
  and the verdict crosses its socket.

Both bundle load-checks now assert on exit codes: these bundles are minified
onto one line, so Node's uncaught-exception report echoes every string literal
in the bundle and the previous message match passed against a bundle that
never loaded.
@OrcaWin

OrcaWin commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Closing as superseded: the daemon and ops contract from this PR landed as part of #16398, and orcad has since become the managed server every SSH host runs (#24863).

@OrcaWin OrcaWin closed this Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants