mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d7970fd50 |
No files matched your search
-162
@@ -8,168 +8,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.6.4]
|
||||
|
||||
0.6.4 is the code tested and published as the 0.6.4-rc.1 release candidate,
|
||||
now the normal release. Only its version and these notes changed. It was cut
|
||||
from main at `afde814f` and has changed since only by the repairs below, the web
|
||||
UI default, and browser access with its docs page. The changes since 0.6.3:
|
||||
|
||||
### Two changes you might notice
|
||||
|
||||
- Turn the web UI and its terminal connection off by default. Turn it on with
|
||||
`rig config set ui.enabled true`, then stop and start the daemon; while it is
|
||||
off, opening the page and `rig ui open` show those steps
|
||||
([#301](https://github.com/mvschwarz/openrig/pull/301) via
|
||||
[#303](https://github.com/mvschwarz/openrig/pull/303)).
|
||||
- Check which address and which web page a daemon request comes from. The CLI,
|
||||
the TUI, agents, the queue, Slack, and other OpenRig hosts that reach this
|
||||
machine by its IP address, its own hostname or its own Tailscale name are
|
||||
unaffected. A custom DNS name, an `/etc/hosts` alias or a reverse-proxy domain
|
||||
needs `OPENRIG_ALLOWED_HOSTS`; a local app or development server calling the
|
||||
daemon from a browser on another port needs `OPENRIG_ALLOWED_ORIGINS`. Each
|
||||
refusal names the setting, and both need a daemon restart. See
|
||||
[Browser access and allowed addresses](docs/reference/browser-access.md)
|
||||
([#358](https://github.com/mvschwarz/openrig/pull/358),
|
||||
[#372](https://github.com/mvschwarz/openrig/pull/372)).
|
||||
|
||||
### Repairs made after the candidate was cut
|
||||
|
||||
- Keep binary files byte-identical in rig bundles
|
||||
([#245](https://github.com/mvschwarz/openrig/issues/245),
|
||||
[#257](https://github.com/mvschwarz/openrig/pull/257)).
|
||||
- Send local commands to the daemon's recorded endpoint, so a daemon started
|
||||
with `rig daemon start --port` is the one they reach
|
||||
([#246](https://github.com/mvschwarz/openrig/issues/246),
|
||||
[#266](https://github.com/mvschwarz/openrig/pull/266)).
|
||||
- Relaunch a rig's only seat after `rig seat launch --fresh --stop`, instead of
|
||||
refusing once stopping it had ended the tmux server
|
||||
([#265](https://github.com/mvschwarz/openrig/issues/265),
|
||||
[#267](https://github.com/mvschwarz/openrig/pull/267)).
|
||||
- Give Claude's capability check five seconds instead of one when a managed
|
||||
Claude launch with an explicit permission mode reads `claude --help`. Answers
|
||||
within the longer budget can succeed; a timeout can still refuse the launch.
|
||||
Thanks to [@m3ac-AllbrittenJ](https://github.com/m3ac-AllbrittenJ) for
|
||||
[#260](https://github.com/mvschwarz/openrig/issues/260)
|
||||
([#271](https://github.com/mvschwarz/openrig/pull/271)).
|
||||
- Launch seats after an upgrade removes the old version: fresh launches and
|
||||
restores use the running version's built-in startup files. Thanks to
|
||||
[@m3ac-AllbrittenJ](https://github.com/m3ac-AllbrittenJ) for
|
||||
[#261](https://github.com/mvschwarz/openrig/issues/261)
|
||||
([#269](https://github.com/mvschwarz/openrig/pull/269)).
|
||||
- Accept ordinary messages again when a managed Claude seat resumes in auto mode,
|
||||
once OpenRig confirms the same Claude process is running in its pane; a resume
|
||||
it can't confirm asks for attention instead of starting a fresh conversation
|
||||
([#287](https://github.com/mvschwarz/openrig/pull/287), toward
|
||||
[#273](https://github.com/mvschwarz/openrig/issues/273)).
|
||||
- Recognize a native Claude install behind the managed shell wrapper, including
|
||||
versioned install paths. When the runtime can't be observed, ordinary delivery
|
||||
goes ahead with a warning; a pane that is plainly an idle shell, or the wrong
|
||||
recipient, is still refused
|
||||
([#310](https://github.com/mvschwarz/openrig/pull/310), a follow-up to
|
||||
[#197](https://github.com/mvschwarz/openrig/issues/197), reported by
|
||||
[@dmelo](https://github.com/dmelo)).
|
||||
- Keep an uncertain launch when restoring an older Claude seat: if the resume
|
||||
check can't tell whether the conversation came back, the restore keeps the new
|
||||
session and asks for attention instead of closing it
|
||||
([#345](https://github.com/mvschwarz/openrig/issues/345),
|
||||
[#346](https://github.com/mvschwarz/openrig/pull/346)).
|
||||
- Replace proof files without writing through links: `rig proof add --replace`
|
||||
now swaps in a new file, so a symlinked or hard-linked artifact no longer
|
||||
overwrites the other path's contents
|
||||
([#307](https://github.com/mvschwarz/openrig/pull/307)).
|
||||
- Store a registered person's Slack message with its complete text (anything
|
||||
past 1,800 characters was lost before), and log a storage error while the
|
||||
gateway retries undelivered events instead of letting it escape. Thanks to
|
||||
[@rudycelekli](https://github.com/rudycelekli) for
|
||||
[#361](https://github.com/mvschwarz/openrig/issues/361) and
|
||||
[#375](https://github.com/mvschwarz/openrig/issues/375) and their fixes
|
||||
([#404](https://github.com/mvschwarz/openrig/pull/404)).
|
||||
|
||||
### Messaging and delivery
|
||||
|
||||
- Distinguish an unverified runtime from a stopped agent when delivering
|
||||
([#240](https://github.com/mvschwarz/openrig/pull/240)).
|
||||
- Finish the printable-separator migration for tmux client and cursor reads
|
||||
([#179](https://github.com/mvschwarz/openrig/pull/179)), on top of a
|
||||
shell-free argv execution seam for tmux
|
||||
([#167](https://github.com/mvschwarz/openrig/pull/167)).
|
||||
- Observe native processes independently of the inherited locale
|
||||
([#239](https://github.com/mvschwarz/openrig/pull/239)).
|
||||
- Release SSE subscriptions aborted during replay
|
||||
([#233](https://github.com/mvschwarz/openrig/pull/233)).
|
||||
- Keep CLI request deadlines active through response bodies, and report a body
|
||||
that fails after headers as an unknown outcome
|
||||
([#201](https://github.com/mvschwarz/openrig/pull/201)).
|
||||
|
||||
### Queue, seats and rigs
|
||||
|
||||
- Keep a blocked row's park timer through a seat handover, and retire a periodic
|
||||
park timer when its row is rerouted
|
||||
([#242](https://github.com/mvschwarz/openrig/pull/242)). Thanks to
|
||||
[@korallis](https://github.com/korallis), who found and documented this in
|
||||
[korallis/agent-stack#61](https://github.com/korallis/agent-stack/pull/61).
|
||||
- Derive terminal pickup for closed queue items
|
||||
([#176](https://github.com/mvschwarz/openrig/pull/176)).
|
||||
- Preserve discovery claims during async rescans
|
||||
([#237](https://github.com/mvschwarz/openrig/pull/237)).
|
||||
- Report an unknown seat launch outcome on timeout
|
||||
([#191](https://github.com/mvschwarz/openrig/pull/191)).
|
||||
- Accept runtime guards for existing nodes when attaching
|
||||
([#228](https://github.com/mvschwarz/openrig/pull/228)).
|
||||
- Give kernel Claude seats the shared activity hooks
|
||||
([#178](https://github.com/mvschwarz/openrig/pull/178)).
|
||||
- Preserve an unset native Claude config selection
|
||||
([#225](https://github.com/mvschwarz/openrig/pull/225)).
|
||||
- Replace a stopped same-name rig generation on YAML import by archiving it,
|
||||
with its ID and unarchive command shown
|
||||
([#196](https://github.com/mvschwarz/openrig/pull/196)).
|
||||
|
||||
### Files, transfer and context
|
||||
|
||||
- Decode SSH and rsync output across UTF-8 chunks
|
||||
([#230](https://github.com/mvschwarz/openrig/pull/230)), and keep UTF-8
|
||||
intact across herdr socket chunks
|
||||
([#241](https://github.com/mvschwarz/openrig/pull/241)).
|
||||
- Preserve local rsync directory content operands
|
||||
([#231](https://github.com/mvschwarz/openrig/pull/231)).
|
||||
- Preserve executable mode during atomic file edits
|
||||
([#235](https://github.com/mvschwarz/openrig/pull/235)).
|
||||
- Project file-shaped entries instead of failing them on ENOTDIR
|
||||
([#185](https://github.com/mvschwarz/openrig/pull/185)).
|
||||
- Clamp oversized suffix ranges to asset bounds
|
||||
([#234](https://github.com/mvschwarz/openrig/pull/234)).
|
||||
- Keep fenced example headings out of context addresses
|
||||
([#236](https://github.com/mvschwarz/openrig/pull/236)).
|
||||
- Disclose stale environment service receipts in `rig env status`
|
||||
([#232](https://github.com/mvschwarz/openrig/pull/232)).
|
||||
|
||||
### Platform and setup
|
||||
|
||||
- Provider-aware Codex readiness for non-OpenAI providers
|
||||
([#222](https://github.com/mvschwarz/openrig/pull/222)).
|
||||
- Leave user config content untouched when stripping Codex activity hooks
|
||||
([#186](https://github.com/mvschwarz/openrig/pull/186)).
|
||||
- Skip `ps` for daemon process liveness on Windows
|
||||
([#173](https://github.com/mvschwarz/openrig/pull/173)).
|
||||
- Make module URLs and transcript containment portable
|
||||
([#168](https://github.com/mvschwarz/openrig/pull/168)).
|
||||
- Keep `ps` rows whose command name contains spaces
|
||||
([#82](https://github.com/mvschwarz/openrig/pull/82)).
|
||||
|
||||
### Repository
|
||||
|
||||
- The onboarding text that new seats read now covers both ways a build goes wrong,
|
||||
and the skills router says a project, rig or machine may add its own skills
|
||||
([#303](https://github.com/mvschwarz/openrig/pull/303)).
|
||||
- Skip release-surface tag diffs when old tags are absent
|
||||
([#152](https://github.com/mvschwarz/openrig/pull/152)), and ask security
|
||||
and integration PRs for practical scenarios
|
||||
([#224](https://github.com/mvschwarz/openrig/pull/224)).
|
||||
|
||||
See [0.6.4 release notes](docs/releases/v0.6.4.md) for how it was tested, the
|
||||
tested environments and the known limits.
|
||||
|
||||
## [0.6.3]
|
||||
|
||||
- Recognize running managed Claude Code seats behind OpenRig's shell wrappers
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
|
||||
## Browser access and allowed addresses
|
||||
|
||||
The OpenRig daemon answers its API only when a request is addressed to this machine and, if it
|
||||
comes from a web page, only when that page is the OpenRig UI or a page you allowed.
|
||||
|
||||
### What works with no setup
|
||||
|
||||
- The CLI, the TUI and agents on this machine, addressing the daemon by `localhost` or an IP
|
||||
address.
|
||||
- Other OpenRig hosts and clients that reach this daemon by its IP address, its own hostname, or
|
||||
its own Tailscale name (for example `my-machine.your-tailnet.ts.net`, or the short `my-machine`).
|
||||
The daemon learns its own Tailscale name automatically from Tailscale on this machine.
|
||||
- The web UI, when it is turned on (`rig config set ui.enabled true`, then restart the daemon) and
|
||||
opened at the daemon's own address and port.
|
||||
|
||||
### When you see a refusal
|
||||
|
||||
The daemon answers `403` with a sentence that says what to do. There are two kinds:
|
||||
|
||||
- **The address is not recognized** (`untrusted_host`). You reached the daemon by a name it does not
|
||||
know as its own, such as a custom DNS name, an alias in `/etc/hosts`, or a reverse proxy domain.
|
||||
Use `localhost`, an IP address or the machine's own name instead, or list the name you use:
|
||||
`OPENRIG_ALLOWED_HOSTS=rig.example.com` in the daemon's environment, then restart the daemon.
|
||||
A client that presents the daemon's configured bearer token is not refused for its address
|
||||
(it still needs whatever access each route requires).
|
||||
- **The web page is not allowed** (`browser_origin_refused`). A browser page that is not the
|
||||
OpenRig UI at the daemon's own address called the API. This includes another local app or
|
||||
development server on a different port. To allow a page, list its exact origin:
|
||||
`OPENRIG_ALLOWED_ORIGINS=http://localhost:5173` in the daemon's environment, then restart the
|
||||
daemon. A bearer token does not override this.
|
||||
|
||||
`OPENRIG_ALLOWED_HOSTS` lists names you use to reach the daemon. `OPENRIG_ALLOWED_ORIGINS` lists
|
||||
web pages allowed to call it. They are separate: a UI served through your own domain usually needs
|
||||
both. Pages on other origins cannot read the refusal; the message is for you, the CLI and agents.
|
||||
|
||||
### Limits
|
||||
|
||||
- If you rename the machine in Tailscale, the daemon keeps accepting its previous name until it
|
||||
restarts or until it next looks its name up after seeing an unrecognized one.
|
||||
- If the daemon cannot reach Tailscale's resolver, its Tailscale name is refused until a later
|
||||
lookup succeeds. `localhost` and IP addresses keep working.
|
||||
- This protects against unknown web pages and unknown names. It is not complete browser isolation:
|
||||
a page on another site can still cause a simple read request to an address the daemon accepts,
|
||||
although it cannot read the answer. Keep the daemon on loopback or your tailnet.
|
||||
@@ -58,7 +58,6 @@ Read [Have your agent configure permissions](getting-started.md#have-your-agent-
|
||||
(`rig context get reference/getting-started.md#have-your-agent-configure-permissions`). Identify the
|
||||
specific prompt or sandbox restriction. Work within the user's chosen permissions; don't switch the whole environment
|
||||
to unrestricted access to clear one prompt.
|
||||
For a `403` naming `untrusted_host` or `browser_origin_refused`, see [Browser access and allowed addresses](browser-access.md).
|
||||
|
||||
### A seat shows attention, waiting or a failed restore
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# OpenRig 0.6.4
|
||||
|
||||
0.6.4 is the code tested and published as the 0.6.4-rc.1 release candidate, now the normal release. Only its version and these notes changed.
|
||||
|
||||
```
|
||||
npm install -g @openrig/cli
|
||||
```
|
||||
|
||||
It was cut from main at `afde814f`, and since then it has changed only by the bounded changes described here: the repairs below, the web UI default, and browser access with its docs page. If something breaks for you, please open an issue.
|
||||
|
||||
## Two changes you might notice
|
||||
|
||||
**The web UI is off by default.** The daemon no longer serves the web UI's pages or its terminal connection unless you turn it on with `rig config set ui.enabled true` and then stop and start the daemon. While it's off, opening the page shows those steps, and `rig ui open` prints them. The web UI is in maintenance mode; the CLI and the TUI are the supported ways to use OpenRig.
|
||||
|
||||
**The daemon checks which address and which web page a request comes from.** Nothing changes for the CLI, the TUI, agents, the queue, Slack, or other OpenRig hosts that reach this machine by its IP address, its own hostname or its own Tailscale name. Two cases now need one setting:
|
||||
|
||||
- If you reach the daemon by a custom DNS name, an `/etc/hosts` alias or a reverse-proxy domain, add that name to `OPENRIG_ALLOWED_HOSTS`.
|
||||
- If a local app or development server on another port calls the daemon from a browser, add its origin to `OPENRIG_ALLOWED_ORIGINS`.
|
||||
|
||||
Each refusal says which setting to add, and both need a daemon restart. The details are in [Browser access and allowed addresses](https://github.com/mvschwarz/openrig/blob/v0.6.4/docs/reference/browser-access.md) ([#358](https://github.com/mvschwarz/openrig/pull/358), [#372](https://github.com/mvschwarz/openrig/pull/372)).
|
||||
|
||||
## Repairs made after the candidate was cut
|
||||
|
||||
These repairs went into the candidate after it was cut.
|
||||
|
||||
- **Bundles keep binary files intact.** `rig bundle create` re-encoded binary files in agent packages ([#245](https://github.com/mvschwarz/openrig/issues/245), [#257](https://github.com/mvschwarz/openrig/pull/257)).
|
||||
- **Local commands reach the daemon you started.** A daemon started with `rig daemon start --port` is now the one other commands talk to ([#246](https://github.com/mvschwarz/openrig/issues/246), [#266](https://github.com/mvschwarz/openrig/pull/266)).
|
||||
- **A rig's only seat can be relaunched fresh.** `rig seat launch --fresh --stop` on a single-seat rig no longer refuses after stopping the seat ended the tmux server ([#265](https://github.com/mvschwarz/openrig/issues/265), [#267](https://github.com/mvschwarz/openrig/pull/267)).
|
||||
- **More time for Claude's capability check.** A managed Claude launch with an explicit permission mode first reads `claude --help`. That check now gets five seconds instead of one. Answers arriving within the longer budget can succeed; a timeout can still refuse the launch. Slow answers were reported when several seats started at once ([#260](https://github.com/mvschwarz/openrig/issues/260), [#271](https://github.com/mvschwarz/openrig/pull/271)).
|
||||
- **Seats launch after an upgrade removes the old version.** Fresh launches and restores use the running version's built-in startup files instead of failing on paths into the removed install ([#261](https://github.com/mvschwarz/openrig/issues/261), [#269](https://github.com/mvschwarz/openrig/pull/269)).
|
||||
- **Claude seats take messages after a full down and up.** When a managed Claude seat resumes in auto mode, it accepts ordinary messages again once OpenRig confirms the same Claude process is running in its pane. A resume it can't confirm asks for your attention instead of starting a fresh conversation ([#287](https://github.com/mvschwarz/openrig/pull/287), toward [#273](https://github.com/mvschwarz/openrig/issues/273)).
|
||||
- **Natively installed Claude is recognized.** OpenRig recognizes a native Claude install behind its managed shell wrapper, including versioned install paths. When it can't observe the runtime, ordinary delivery goes ahead with a warning; a pane that is plainly an idle shell, or the wrong recipient, is still refused ([#310](https://github.com/mvschwarz/openrig/pull/310), a follow-up to [#197](https://github.com/mvschwarz/openrig/issues/197)).
|
||||
- **Restoring an older Claude seat keeps an uncertain launch.** If the resume check can't tell whether the conversation came back, the restore keeps the new session and asks for attention instead of closing it ([#345](https://github.com/mvschwarz/openrig/issues/345), [#346](https://github.com/mvschwarz/openrig/pull/346)).
|
||||
- **Replacing proof files doesn't write through links.** `rig proof add --replace` now swaps in a new file, so a symlinked or hard-linked artifact no longer overwrites the other path's contents ([#307](https://github.com/mvschwarz/openrig/pull/307)).
|
||||
- **Slack keeps long messages, and retry errors stay contained.** A message from a registered person is stored with its complete text; before, anything past 1,800 characters was lost. A storage error while the gateway retries undelivered events is now logged instead of escaping ([#361](https://github.com/mvschwarz/openrig/issues/361), [#375](https://github.com/mvschwarz/openrig/issues/375), [#404](https://github.com/mvschwarz/openrig/pull/404)).
|
||||
|
||||
## Everything else since 0.6.3
|
||||
|
||||
**Messaging and delivery.** An unverified runtime is reported separately from a stopped agent ([#240](https://github.com/mvschwarz/openrig/pull/240)). tmux reads use printable separators and a shell-free command path ([#179](https://github.com/mvschwarz/openrig/pull/179), [#167](https://github.com/mvschwarz/openrig/pull/167)). Native process checks no longer depend on your locale ([#239](https://github.com/mvschwarz/openrig/pull/239)). Event subscriptions cancelled during replay are released ([#233](https://github.com/mvschwarz/openrig/pull/233)). CLI request deadlines cover the whole response, and a body that fails after its headers is reported as an unknown outcome ([#201](https://github.com/mvschwarz/openrig/pull/201)).
|
||||
|
||||
**Queue, seats and rigs.** A blocked row keeps its park timer through a seat handover, and a rerouted row's periodic timer is retired ([#242](https://github.com/mvschwarz/openrig/pull/242)). Closed queue items show the right pickup state ([#176](https://github.com/mvschwarz/openrig/pull/176)). Discovery claims survive background rescans ([#237](https://github.com/mvschwarz/openrig/pull/237)). A seat launch that times out reports an unknown outcome ([#191](https://github.com/mvschwarz/openrig/pull/191)). Attaching accepts runtime guards for existing nodes ([#228](https://github.com/mvschwarz/openrig/pull/228)). Kernel Claude seats get the shared activity hooks ([#178](https://github.com/mvschwarz/openrig/pull/178)). An unset native Claude config selection stays unset ([#225](https://github.com/mvschwarz/openrig/pull/225)). Importing YAML over a stopped rig with the same name archives the old one and shows its ID and unarchive command ([#196](https://github.com/mvschwarz/openrig/pull/196)).
|
||||
|
||||
**Files, transfer and context.** SSH, rsync and Herdr output keeps UTF-8 intact across chunks ([#230](https://github.com/mvschwarz/openrig/pull/230), [#241](https://github.com/mvschwarz/openrig/pull/241)). Local rsync directory operands are preserved ([#231](https://github.com/mvschwarz/openrig/pull/231)). Atomic file edits keep the executable bit ([#235](https://github.com/mvschwarz/openrig/pull/235)). File-shaped entries are projected instead of failing ([#185](https://github.com/mvschwarz/openrig/pull/185)). Oversized suffix ranges are clamped ([#234](https://github.com/mvschwarz/openrig/pull/234)). Headings inside fenced examples aren't treated as context addresses ([#236](https://github.com/mvschwarz/openrig/pull/236)). `rig env status` says when a service receipt is stale ([#232](https://github.com/mvschwarz/openrig/pull/232)).
|
||||
|
||||
**Platform and setup.** Codex readiness works with providers other than OpenAI ([#222](https://github.com/mvschwarz/openrig/pull/222)). Removing Codex activity hooks leaves the rest of your config untouched ([#186](https://github.com/mvschwarz/openrig/pull/186)). Daemon liveness on Windows no longer uses `ps` ([#173](https://github.com/mvschwarz/openrig/pull/173)). Module URLs and transcript paths are portable ([#168](https://github.com/mvschwarz/openrig/pull/168)). `ps` rows whose command name contains spaces are kept ([#82](https://github.com/mvschwarz/openrig/pull/82)).
|
||||
|
||||
**Onboarding and repository.** The onboarding text that new seats read now covers both ways a build goes wrong, and the skills router says a project, rig or machine may add its own skills ([#303](https://github.com/mvschwarz/openrig/pull/303)). Release checks skip tag diffs when old tags are absent ([#152](https://github.com/mvschwarz/openrig/pull/152)), and security and integration PRs are asked for practical scenarios ([#224](https://github.com/mvschwarz/openrig/pull/224)).
|
||||
|
||||
Fixes merged to main after this candidate was cut, including many community contributions, aren't in this release. They'll ship in the next release, with their credits.
|
||||
|
||||
## How it was tested
|
||||
|
||||
Most installed and real-agent results below come from earlier builds of this candidate. The final package received the package-content/stock check and the affected Slack component checks; unchanged-path evidence was carried forward through checked package deltas.
|
||||
|
||||
- **Required checks:** the integrated release changes passed their recorded CI runs. The release checks cover build and packaging, types, repository checks, package test suites and an installed-package scenario.
|
||||
- **Scripted checks without real agents:** installed copies of candidate builds ran the repository's scripted checks. Three known failures remain: two cases in plugin usage reporting ([#251](https://github.com/mvschwarz/openrig/issues/251), fixed on main for the next release), and `rig proof add --file` dropping a leading byte order mark from the stored file ([#250](https://github.com/mvschwarz/openrig/issues/250)).
|
||||
- **A fresh first-time install** ran Claude-only, Codex-only and mixed teams through useful work, with seats handing work and review results to each other, then a warm resume and a graceful reboot. The tester approved the providers' ordinary permission prompts as a first-time user would.
|
||||
- **Real Claude Code agents on a test server:** after a full down and up, a Claude seat took and answered an ordinary message, carried on with its own queue work, and kept its conversation through a daemon restart.
|
||||
- **The final package's Slack checks:** tests of the installed Slack components kept long inbound messages whole. In both retry-fault cases, the gateway logged the error, kept running and delivered the event once on the next automatic retry. These used synthetic connections on Linux with the package's existing dependencies. They aren't a live Slack test or a fault trial of the whole daemon.
|
||||
|
||||
**Tested environments**, from the run receipts. Unlisted versions are not established by this summary.
|
||||
|
||||
| Run | Platform | Versions |
|
||||
|---|---|---|
|
||||
| Fresh install | macOS on Apple silicon | Claude Code 2.1.286, then 2.1.287 after the warm resume (the launcher version changed between phases); Codex 0.159.3; Node 24.21.0 |
|
||||
| Test server | Ubuntu Linux, x86-64 | Claude Code 2.1.220, Codex 0.145.0 |
|
||||
| Final Slack checks | Linux, x86-64 | Node 22.22.1; no providers |
|
||||
|
||||
The tested release-candidate package's SHA-256 is `5344a48a747629f71d4427ce52d24e6e1034e9097b4d5d34ec125d5396fa66b6`; 0.6.4 differs from it only in its version and release notes.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **A restore can report a problem with a seat that works.** In the test-server run, `rig up` after a full down exited with an error, reporting that the Claude seat needed attention, while that seat went on to take and answer an ordinary message. `rig restore-check` also reported a failure for that rig ([#273](https://github.com/mvschwarz/openrig/issues/273)).
|
||||
- **Codex under its default sandbox.** In the test-server run, Codex 0.145.0 under its default workspace-write sandbox could not reach the local daemon, blocking its queue work ([#275](https://github.com/mvschwarz/openrig/issues/275)). That run did not prove cross-seat queue handoffs; its successful queue continuation was Claude working its own row.
|
||||
- **After a reboot** the daemon doesn't come back on its own. Start it with `rig daemon start`, then bring your rig back with `rig up <name>`.
|
||||
- **Stopping the daemon** can report a timeout while closing connections ([#166](https://github.com/mvschwarz/openrig/issues/166)).
|
||||
- **Setup's optional cmux step** can report an error.
|
||||
- **Browser access** protects against unknown web pages and names. It isn't complete browser isolation, so keep the daemon on loopback or your tailnet. The browser-access page lists its other limits.
|
||||
- **Windows** wasn't tested.
|
||||
|
||||
## Thanks
|
||||
|
||||
To the people whose pull requests are in this release: [@rudycelekli](https://github.com/rudycelekli), [@nvtoan0201-swe](https://github.com/nvtoan0201-swe), [@MTG-Thomas](https://github.com/MTG-Thomas), [@hobostay](https://github.com/hobostay), [@hoklims](https://github.com/hoklims), [@mvdpoel](https://github.com/mvdpoel), [@oodadoudou](https://github.com/oodadoudou) and [@Coder8124](https://github.com/Coder8124).
|
||||
|
||||
And to the people whose reports shaped it: [@m3ac-AllbrittenJ](https://github.com/m3ac-AllbrittenJ) for [#260](https://github.com/mvschwarz/openrig/issues/260) and [#261](https://github.com/mvschwarz/openrig/issues/261), [@rudycelekli](https://github.com/rudycelekli) for [#361](https://github.com/mvschwarz/openrig/issues/361) and [#375](https://github.com/mvschwarz/openrig/issues/375) and their fixes, [@korallis](https://github.com/korallis) for the park-timer finding in [korallis/agent-stack#61](https://github.com/korallis/agent-stack/pull/61), and [@dmelo](https://github.com/dmelo) for [#197](https://github.com/mvschwarz/openrig/issues/197).
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7013,7 +7013,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7047,7 +7047,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7088,7 +7088,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
|
||||
import { readLocalOrigin } from "./local-origin.js";
|
||||
|
||||
@@ -109,22 +109,6 @@ interface DaemonClientOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
|
||||
function localDaemonUrl(): string | undefined {
|
||||
try {
|
||||
// Resolve at construction time: embedders may change OPENRIG_HOME after import.
|
||||
const state = JSON.parse(fs.readFileSync(path.join(getOpenRigHome(), "daemon.json"), "utf-8"));
|
||||
if (!state || !Number.isSafeInteger(state.pid) || state.pid <= 0
|
||||
|| !Number.isInteger(state.port) || state.port < 1 || state.port > 65535
|
||||
|| (state.host !== undefined && (typeof state.host !== "string" || !state.host.trim()))) return undefined;
|
||||
// A stale PID must not redirect reads or writes to another configured daemon.
|
||||
// Keep the recorded endpoint; the request decides reachability, even after exit.
|
||||
return `http://${state.host ?? "127.0.0.1"}:${state.port}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class DaemonClient {
|
||||
readonly baseUrl: string;
|
||||
private fetchImpl: typeof fetch = fetch;
|
||||
@@ -173,15 +157,9 @@ export class DaemonClient {
|
||||
if (envUrl) {
|
||||
this.baseUrl = envUrl;
|
||||
} else {
|
||||
// --port/--host can differ from configured startup defaults. Match the
|
||||
// lifecycle-aware commands by preferring this home's recorded endpoint.
|
||||
const localUrl = localDaemonUrl();
|
||||
if (localUrl) {
|
||||
this.baseUrl = localUrl;
|
||||
} else {
|
||||
const config = new ConfigStore().resolve(); // env > file > defaults
|
||||
this.baseUrl = `http://${config.daemon.host}:${config.daemon.port}`;
|
||||
}
|
||||
// Resolve from config (env > file > defaults)
|
||||
const config = new ConfigStore().resolve();
|
||||
this.baseUrl = `http://${config.daemon.host}:${config.daemon.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ Keys:
|
||||
files.allowlist name:/abs/path,name:/abs/path
|
||||
progress.scan_roots name:/abs/path,name:/abs/path
|
||||
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
|
||||
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
|
||||
ui.preview.* refresh_interval_seconds, max_pins, default_lines
|
||||
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
|
||||
agents.* advisor_session, operator_session
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Command } from "commander";
|
||||
import YAML from "yaml";
|
||||
import { DaemonClient } from "../client.js";
|
||||
@@ -459,12 +458,10 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
}
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
const frontmatter = YAML.stringify(header).trimEnd();
|
||||
const content = `---\n${frontmatter}\n---\n\n${body}`;
|
||||
try {
|
||||
if (opts.replace) replaceArtifactFile(target, content);
|
||||
else fs.writeFileSync(target, content, { encoding: "utf8", flag: "wx" });
|
||||
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, { encoding: "utf8", flag: opts.replace ? "w" : "wx" });
|
||||
} catch (err) {
|
||||
if (opts.replace || (err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
throw new ScopeCliError({
|
||||
fact: `Proof artifact '${fileName}' already exists.`,
|
||||
consequence: "The existing artifact was not changed.",
|
||||
@@ -509,38 +506,3 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/** Explicit --replace swaps the artifact's directory entry; it never writes through it. A symlink or hard
|
||||
* link at the artifact name therefore keeps its other path's bytes. The staging file is created
|
||||
* exclusively under a unique name, so cleanup only ever removes a file this call created, and a failed
|
||||
* write, close or rename leaves the target as it was. An existing regular artifact's permission bits carry
|
||||
* over, so a replacement never broadens access; a symlinked or new name gets default permissions. The
|
||||
* first failure is the one reported. There is no fsync: this is a same-directory swap, not a
|
||||
* crash-durability guarantee. */
|
||||
export function replaceArtifactFile(target: string, content: string, stagingId: string = randomUUID()): void {
|
||||
const staging = path.join(path.dirname(target), `.${path.basename(target)}.${stagingId}.replace-tmp`);
|
||||
const existing = fs.lstatSync(target, { throwIfNoEntry: false });
|
||||
const keepMode = existing?.isFile() ? existing.mode & 0o777 : undefined;
|
||||
const fd = fs.openSync(staging, "wx", keepMode ?? 0o666); // never created wider than the artifact it replaces
|
||||
let failure: unknown;
|
||||
try {
|
||||
if (keepMode !== undefined) fs.fchmodSync(fd, keepMode);
|
||||
fs.writeFileSync(fd, content, "utf8");
|
||||
} catch (err) {
|
||||
failure = err;
|
||||
}
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch (closeErr) {
|
||||
if (failure === undefined) failure = closeErr;
|
||||
else console.error(`warning: closing staging file ${staging} also failed: ${(closeErr as Error).message}`);
|
||||
}
|
||||
try {
|
||||
if (failure !== undefined) throw failure;
|
||||
fs.renameSync(staging, target);
|
||||
} catch (err) {
|
||||
try { fs.rmSync(staging, { force: true }); }
|
||||
catch (cleanupErr) { console.error(`warning: could not remove staging file ${staging}: ${(cleanupErr as Error).message}`); }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -924,8 +924,6 @@ Exit codes:
|
||||
const res = await client.get<PsEntry[]>("/api/ps?includeArchived=true");
|
||||
|
||||
if (res.status >= 400) {
|
||||
const reason = (res.data as unknown as { error?: unknown } | null)?.error;
|
||||
if (typeof reason === "string" && reason) console.error(reason);
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${res.status}). Check daemon status with: rig status`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
@@ -1079,8 +1077,6 @@ async function handleNodes(
|
||||
): Promise<void> {
|
||||
const rigRes = await client.get<PsEntry[]>(psApiPath(opts), requestHeaders ? { headers: requestHeaders } : undefined);
|
||||
if (rigRes.status >= 400) {
|
||||
const reason = (rigRes.data as unknown as { error?: unknown } | null)?.error;
|
||||
if (typeof reason === "string" && reason) console.error(reason);
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
|
||||
@@ -482,11 +482,7 @@ Examples:
|
||||
const route = `/api/seat/${path}/${encodeURIComponent(seat)}`;
|
||||
res = path === "launch"
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 120_000 })
|
||||
// #260: a dynamic Claude mode waits up to 5 s for the capability query before the
|
||||
// daemon answers, so the 5 s default deadline would abort before its refusal arrives.
|
||||
: path === "set-permissions"
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 10_000 })
|
||||
: await client.post<Record<string, unknown>>(route, body);
|
||||
: await client.post<Record<string, unknown>>(route, body);
|
||||
} catch (err) {
|
||||
if (path !== "launch" || !(err instanceof DaemonTimeoutError)) throw err;
|
||||
const error = {
|
||||
@@ -714,18 +710,12 @@ export async function runSeatHandover(seat: string, opts: HandoverActionOpts, de
|
||||
if (!daemonStatusGuard(daemon)) return; // B8-1b: epistemic-matched
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const handoverRoute = `/api/seat/handover/${encodeURIComponent(seat)}`;
|
||||
const handoverBody = {
|
||||
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
|
||||
source: opts.source,
|
||||
reason: opts.reason,
|
||||
operator: opts.operator,
|
||||
dryRun: opts.dryRun === true,
|
||||
};
|
||||
// #260: a mutating handover launches and readies the successor, so it gets the
|
||||
// launch request window. A dry run only plans, and keeps the default deadline.
|
||||
const res = opts.dryRun === true
|
||||
? await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody)
|
||||
: await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody, { timeoutMs: 120_000 });
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
|
||||
@@ -6,13 +6,8 @@ import { realDeps } from "./daemon.js";
|
||||
export interface UiDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
exec: (cmd: string, args: string[]) => Promise<void>;
|
||||
/** Reads the daemon's own answer for the UI page. Defaults to fetch with a short timeout. */
|
||||
probeUi?: (url: string) => Promise<{ status: number; headers: { get(name: string): string | null } }>;
|
||||
}
|
||||
|
||||
export const UI_DISABLED_GUIDANCE =
|
||||
"The OpenRig web UI is off on this daemon. To turn it on, run `rig config set ui.enabled true`, then stop and start the daemon (`rig daemon stop`, `rig daemon start`).";
|
||||
|
||||
export const UI_MAINTENANCE_NOTICE =
|
||||
"The OpenRig UI is experimental and in maintenance mode. It is not under active development; support is best-effort. The CLI is the primary supported interface. Contributions welcome.";
|
||||
|
||||
@@ -52,18 +47,6 @@ export function uiCommand(depsOverride?: UiDeps): Command {
|
||||
if (!daemonStatusGuard(status)) return;
|
||||
|
||||
const url = getDaemonUrl(status);
|
||||
// Ask the running daemon, not local config: the setting takes effect when the daemon starts.
|
||||
const probeUi = deps.probeUi ?? ((target: string) => fetch(target, { signal: AbortSignal.timeout(3000) }));
|
||||
try {
|
||||
const res = await probeUi(url);
|
||||
if (res.status === 404 && res.headers.get("x-openrig-web-ui") === "off") {
|
||||
console.error(UI_DISABLED_GUIDANCE);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// No answer: open the URL as before; the browser shows whatever the daemon serves.
|
||||
}
|
||||
console.log(url);
|
||||
|
||||
try {
|
||||
|
||||
@@ -76,8 +76,6 @@ export interface RiggedConfig {
|
||||
// Preview Terminal v0 (PL-018) — UI-side preferences for the live
|
||||
// terminal preview pane.
|
||||
ui: {
|
||||
/** Serve the web UI and its terminal WebSocket. Off by default; the daemon reads it at start. */
|
||||
enabled: boolean;
|
||||
timezone: string;
|
||||
preview: {
|
||||
refreshIntervalSeconds: number;
|
||||
@@ -241,7 +239,6 @@ const DEFAULTS = {
|
||||
files: { allowlist: "" },
|
||||
progress: { scanRoots: "" },
|
||||
ui: {
|
||||
enabled: false,
|
||||
timezone: "America/Los_Angeles",
|
||||
preview: {
|
||||
refreshIntervalSeconds: 3,
|
||||
@@ -380,7 +377,6 @@ export const VALID_KEYS = [
|
||||
"ui.preview.max_pins",
|
||||
"ui.preview.default_lines",
|
||||
"ui.timezone",
|
||||
"ui.enabled",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
@@ -475,7 +471,6 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
|
||||
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
@@ -556,7 +551,6 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.timezone": ["ui", "timezone"],
|
||||
"ui.enabled": ["ui", "enabled"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
@@ -1005,7 +999,6 @@ export class ConfigStore {
|
||||
scanRoots: v("progress.scan_roots") as string,
|
||||
},
|
||||
ui: {
|
||||
enabled: v("ui.enabled") as boolean,
|
||||
timezone: v("ui.timezone") as string,
|
||||
preview: {
|
||||
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// The real `rig ps` / `rig send` code paths, real DaemonClient and real HTTP, rendering the exact
|
||||
// refusal body produced by the daemon's browser boundary. (A real CLI request cannot present an
|
||||
// unlisted target name without DNS: Node fetch sends the URL's host, not an explicit Host header.)
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Command } from "commander";
|
||||
import { Hono } from "hono";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import { psCommand } from "../src/commands/ps.js";
|
||||
import { sendCommand } from "../src/commands/send.js";
|
||||
import { browserBoundary } from "../../daemon/src/middleware/browser-boundary.js";
|
||||
|
||||
let refusalBody = "";
|
||||
let server: http.Server;
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const daemon = new Hono();
|
||||
daemon.use("/api/*", browserBoundary({ webUiEnabled: false, bearerTokens: [], warn: () => {} }));
|
||||
daemon.all("/api/*", (c) => c.json({ ok: true }));
|
||||
const res = await daemon.request("/api/ps", { headers: { Host: "unlisted.example:7433" } });
|
||||
refusalBody = await res.text();
|
||||
server = http.createServer((_req, res2) => { res2.writeHead(403, { "content-type": "application/json" }); res2.end(refusalBody); });
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterAll(async () => { await new Promise<void>((r) => server.close(() => r())); });
|
||||
afterEach(() => { process.exitCode = undefined; });
|
||||
|
||||
function deps() {
|
||||
const lifecycleDeps: LifecycleDeps = {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => p === STATE_FILE
|
||||
? JSON.stringify({ pid: process.pid, port, db: "test.sqlite", startedAt: "2026-10-01T00:00:00Z" } as DaemonState)
|
||||
: null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
return { lifecycleDeps, clientFactory: (url: string) => new DaemonClient(url) };
|
||||
}
|
||||
|
||||
async function stderrOf(cmd: Command, argv: string[]): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
const orig = console.error;
|
||||
console.error = (...a: unknown[]) => { lines.push(a.map(String).join(" ")); };
|
||||
try { await cmd.parseAsync(["node", "rig", ...argv]); } catch { /* exitOverride */ } finally { console.error = orig; }
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("CLI shows the boundary's remedy sentence", () => {
|
||||
it("the fixture body is the real boundary refusal", () => {
|
||||
expect(JSON.parse(refusalBody)).toMatchObject({ code: "untrusted_host" });
|
||||
});
|
||||
|
||||
it("rig ps prints the daemon's sentence before its generic line", async () => {
|
||||
const prog = new Command().exitOverride();
|
||||
prog.addCommand(psCommand(deps() as never));
|
||||
const err = await stderrOf(prog, ["ps"]);
|
||||
expect(err).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
expect(err).toContain("Failed to fetch rig list from daemon (HTTP 403)");
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it("rig send already prints the daemon's error sentence (no change needed)", async () => {
|
||||
const prog = new Command().exitOverride();
|
||||
prog.addCommand(sendCommand(deps() as never));
|
||||
const err = await stderrOf(prog, ["send", "dev-impl@rig", "fixture"]);
|
||||
expect(err).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DaemonClient, remoteDaemonClient } from "../src/client.js";
|
||||
import { proofCommand } from "../src/commands/proof.js";
|
||||
import { parkedCommand } from "../src/commands/parked.js";
|
||||
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
|
||||
|
||||
describe("default client endpoint selection", () => {
|
||||
let root: string;
|
||||
let home: string;
|
||||
const servers: http.Server[] = [];
|
||||
const fallback = "http://configured.invalid:28123";
|
||||
const state = (port = 27451, extra = {}) => ({ pid: process.pid, port, host: "127.0.0.1", ...extra });
|
||||
const writeState = (value: unknown, target = home) => fs.writeFileSync(path.join(target, "daemon.json"), JSON.stringify(value));
|
||||
const configure = (target = home) => fs.writeFileSync(path.join(target, "config.json"), JSON.stringify({ daemon: { host: "configured.invalid", port: 28123 } }));
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "client-endpoint-"));
|
||||
home = path.join(root, "a");
|
||||
fs.mkdirSync(home);
|
||||
for (const key of ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_HOST", "RIGGED_HOST", "OPENRIG_PORT", "RIGGED_PORT", "RIGGED_HOME", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME", "OPENRIG_TERMINAL_BEARER_TOKEN"]) vi.stubEnv(key, undefined);
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
configure();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => server.close(e => e ? reject(e) : resolve()));
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
resetFetchAllowlist();
|
||||
vi.unstubAllEnvs();
|
||||
process.exitCode = 0;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses the actual live-home endpoint ahead of configured startup defaults without rewriting either file", () => {
|
||||
writeState(state());
|
||||
const before = fs.readFileSync(path.join(home, "daemon.json"));
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
expect(fs.readFileSync(path.join(home, "daemon.json"))).toEqual(before);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8")).daemon.port).toBe(28123);
|
||||
});
|
||||
|
||||
it("preserves explicit baseUrl, primary URL and legacy URL precedence over state", () => {
|
||||
writeState(state());
|
||||
vi.stubEnv("RIGGED_URL", "http://legacy.invalid:28001");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://legacy.invalid:28001");
|
||||
vi.stubEnv("OPENRIG_URL", "http://explicit.invalid:28002");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://explicit.invalid:28002");
|
||||
expect(new DaemonClient("http://argument.invalid:28003").baseUrl).toBe("http://argument.invalid:28003");
|
||||
});
|
||||
|
||||
it("uses host/port config and its env overrides when no state exists", () => {
|
||||
expect(new DaemonClient().baseUrl).toBe(fallback);
|
||||
vi.stubEnv("OPENRIG_HOST", "env.invalid");
|
||||
vi.stubEnv("OPENRIG_PORT", "28004");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://env.invalid:28004");
|
||||
vi.stubEnv("OPENRIG_HOST", undefined);
|
||||
vi.stubEnv("OPENRIG_PORT", undefined);
|
||||
vi.stubEnv("RIGGED_HOST", "legacy-config.invalid");
|
||||
vi.stubEnv("RIGGED_PORT", "28005");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://legacy-config.invalid:28005");
|
||||
});
|
||||
|
||||
it("uses the existing default when both state and config are absent (no network request)", () => {
|
||||
fs.unlinkSync(path.join(home, "config.json"));
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:7433");
|
||||
});
|
||||
|
||||
it("keeps a live endpoint when startup host/port configuration changes", () => {
|
||||
writeState(state());
|
||||
vi.stubEnv("OPENRIG_PORT", "28004");
|
||||
vi.stubEnv("OPENRIG_HOST", "future.invalid");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
});
|
||||
|
||||
it("keeps the recorded endpoint for a dead PID without rewriting state", () => {
|
||||
writeState(state());
|
||||
const before = fs.readFileSync(path.join(home, "daemon.json"));
|
||||
vi.spyOn(process, "kill").mockImplementation(() => { throw Object.assign(new Error("gone"), { code: "ESRCH" }); });
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
expect(fs.readFileSync(path.join(home, "daemon.json"))).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not mistake a permission-denied liveness check for a dead endpoint", () => {
|
||||
writeState(state());
|
||||
vi.spyOn(process, "kill").mockImplementation(() => { throw Object.assign(new Error("denied"), { code: "EPERM" }); });
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
});
|
||||
|
||||
it.each([null, [], {}, { pid: -1, port: 27451 }, { pid: "12", port: 27451 }, { pid: process.pid, port: "27451" }, { pid: process.pid, port: 0 }, { pid: process.pid, port: 65536 }, { pid: process.pid, port: 1.5 }, { pid: process.pid, port: 27451, host: 12 }])("falls back on unusable state shape: %j", value => {
|
||||
writeState(value);
|
||||
expect(new DaemonClient().baseUrl).toBe(fallback);
|
||||
});
|
||||
|
||||
it("falls back on malformed JSON", () => {
|
||||
fs.writeFileSync(path.join(home, "daemon.json"), "{");
|
||||
expect(new DaemonClient().baseUrl).toBe(fallback);
|
||||
});
|
||||
|
||||
it("supports old state without a host and honors a persisted custom host", () => {
|
||||
writeState({ pid: process.pid, port: 27451 });
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
writeState(state(27451, { host: "custom.invalid" }));
|
||||
expect(new DaemonClient().baseUrl).toBe("http://custom.invalid:27451");
|
||||
});
|
||||
|
||||
it("resolves home per construction, accepts RIGGED_HOME, and never discovers a sibling", () => {
|
||||
writeState(state());
|
||||
const first = new DaemonClient();
|
||||
const other = path.join(root, "b");
|
||||
fs.mkdirSync(other);
|
||||
configure(other);
|
||||
vi.stubEnv("OPENRIG_HOME", other);
|
||||
expect(new DaemonClient().baseUrl).toBe(fallback);
|
||||
writeState(state(27452), other);
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27452");
|
||||
expect(first.baseUrl).toBe("http://127.0.0.1:27451");
|
||||
vi.stubEnv("OPENRIG_HOME", undefined);
|
||||
vi.stubEnv("RIGGED_HOME", home);
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
});
|
||||
|
||||
it("preserves remote destination and origin identity while local state exists", async () => {
|
||||
writeState(state());
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "author@fixture");
|
||||
const fetchImpl = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => new Response("{}"));
|
||||
const client = remoteDaemonClient(url => new DaemonClient(url, { fetchImpl }), "http://remote.invalid:28006", "origin-fixture");
|
||||
await client.post("/api/proof/judge", { fixture: true });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImpl.mock.calls[0]).toEqual(["http://remote.invalid:28006/api/proof/judge", expect.objectContaining({ headers: expect.objectContaining({ "X-OpenRig-Session": "author@fixture@origin-fixture" }) })]);
|
||||
});
|
||||
|
||||
it("routes actual proof reads, intercepted judgments and parked to each selected home's server", async () => {
|
||||
const received: Array<{ server: string; method: string; url: string; body: unknown; sender?: string }> = [];
|
||||
const start = async (name: string, port: number) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let text = "";
|
||||
for await (const chunk of req) text += chunk;
|
||||
received.push({ server: name, method: req.method!, url: req.url!, body: text ? JSON.parse(text) : null, sender: req.headers["x-openrig-session"] as string | undefined });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(req.url?.startsWith("/api/proof?")
|
||||
? { items: [{ id: "fixture-item", index: 1, text: "fixture", revision: "r1", judgment: null }] }
|
||||
: { ok: true, fixture: name }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); });
|
||||
servers.push(server);
|
||||
const actualPort = (server.address() as { port: number }).port;
|
||||
allowFetchTarget(`http://127.0.0.1:${actualPort}`);
|
||||
return actualPort;
|
||||
};
|
||||
const a = await start("A", Number(process.env.F1_SERVER_A_PORT ?? 0));
|
||||
const b = await start("B", Number(process.env.F1_SERVER_B_PORT ?? 0));
|
||||
const other = path.join(root, "b"); fs.mkdirSync(other);
|
||||
writeState(state(a)); writeState(state(b), other);
|
||||
// Deliberately conflicting startup config exposes a wrong-home write on the old client.
|
||||
fs.writeFileSync(path.join(home, "config.json"), JSON.stringify({ daemon: { host: "127.0.0.1", port: b } }));
|
||||
fs.writeFileSync(path.join(other, "config.json"), JSON.stringify({ daemon: { host: "127.0.0.1", port: a } }));
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "author@fixture");
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const selected: string[] = [];
|
||||
for (const target of [home, other]) {
|
||||
vi.stubEnv("OPENRIG_HOME", target);
|
||||
selected.push(new DaemonClient().baseUrl);
|
||||
await proofCommand().parseAsync(["show", "fixture", "--json"], { from: "user" });
|
||||
await proofCommand().parseAsync(["judge", "fixture#1", "--verdict", "accept", "--reason", "routing fixture only", "--json"], { from: "user" });
|
||||
await parkedCommand().parseAsync(["author@fixture", "--json"], { from: "user" });
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
}
|
||||
process.stdout.write(`F1 routing receipt ${JSON.stringify({ endpoints: { A: a, B: b }, selected, received })}\n`);
|
||||
expect(selected).toEqual([`http://127.0.0.1:${a}`, `http://127.0.0.1:${b}`]);
|
||||
expect(received.map(r => [r.server, r.method, r.url])).toEqual(["A", "B"].flatMap(name => [
|
||||
[name, "GET", "/api/proof?scope=fixture"], [name, "GET", "/api/proof?scope=fixture"],
|
||||
[name, "POST", "/api/proof/judge"], [name, "GET", "/api/activity/parked?seat=author%40fixture"],
|
||||
]));
|
||||
expect(received.filter(r => r.method === "POST").map(r => r.body)).toEqual(["A", "B"].map(() => expect.objectContaining({ item: "fixture-item", expectedRevision: "r1", verdict: "accept" })));
|
||||
expect(received.every(r => r.sender === "author@fixture")).toBe(true);
|
||||
|
||||
// S1: the recorded daemon stops while the configured other daemon remains up.
|
||||
const stopped = servers.shift()!;
|
||||
stopped.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => stopped.close(e => e ? reject(e) : resolve()));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
vi.spyOn(process, "kill").mockImplementation(() => { throw Object.assign(new Error("gone"), { code: "ESRCH" }); });
|
||||
const staleClient = new DaemonClient();
|
||||
const count = received.length;
|
||||
const results = await Promise.allSettled([staleClient.get("/api/proof"), staleClient.post("/api/proof/judge", { fixture: "stale" })]);
|
||||
process.stdout.write(`F1 stale routing receipt ${JSON.stringify({ selected: staleClient.baseUrl, stoppedPort: a, otherPort: b, results: results.map(r => r.status === "rejected" ? { status: r.status, error: String(r.reason) } : r), extraRequests: received.slice(count) })}\n`);
|
||||
expect(staleClient.baseUrl).toBe(`http://127.0.0.1:${a}`);
|
||||
for (const result of results) {
|
||||
expect(result.status).toBe("rejected");
|
||||
if (result.status === "rejected") expect(String(result.reason)).toContain(`Cannot connect to the OpenRig daemon at http://127.0.0.1:${a}`);
|
||||
}
|
||||
expect(received).toHaveLength(count); // no read or judgment reaches B
|
||||
});
|
||||
});
|
||||
@@ -180,7 +180,7 @@ describe("DaemonClient", () => {
|
||||
cwd: resolve(import.meta.dirname, ".."),
|
||||
});
|
||||
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: \([0-9a-f]{8}(?:, dirty)?\))?$/);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(?: \([0-9a-f]{8}(?:, dirty)?\))?$/);
|
||||
});
|
||||
|
||||
// Test 7: Client non-2xx (409) -> returns { status: 409, data: errorBody }
|
||||
|
||||
@@ -132,7 +132,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"health.context_pressure.critical_percent",
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines", "ui.timezone",
|
||||
"ui.enabled",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
|
||||
|
||||
@@ -8,7 +8,6 @@ import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
proofCommand,
|
||||
replaceArtifactFile,
|
||||
validateC1Header,
|
||||
parseProofContract,
|
||||
C1_ARTIFACT_TYPES,
|
||||
@@ -431,211 +430,3 @@ describe("proof add — pristine-scaffold contract never canonical (KI-5.3-2 sec
|
||||
expect((out.contractItemsCovered ?? []).join(" ")).toContain("REAL ITEM ONE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig proof add --replace swaps the entry and never writes through links", () => {
|
||||
let workRoot: string;
|
||||
let sliceDir: string;
|
||||
let proofDir: string;
|
||||
let errs: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "proof-replace-"));
|
||||
sliceDir = path.join(workRoot, "missions", "release-x", "slices", "19-signal-layer");
|
||||
proofDir = path.join(sliceDir, "proof");
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(workRoot, "missions", "release-x", "README.md"), "---\nid: OPR.X\n---\n# m\n");
|
||||
fs.writeFileSync(path.join(sliceDir, "README.md"), "---\nid: OPR.X.19\nstatus: building\n---\n# slice\n");
|
||||
errs = [];
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { errs.push(a.join(" ")); });
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(workRoot, { recursive: true, force: true });
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
async function add(name: string, extra: string[] = []): Promise<void> {
|
||||
const cmd = proofCommand();
|
||||
cmd.exitOverride();
|
||||
await cmd.parseAsync(["node", "proof", "--workspace", workRoot, "add", "19-signal-layer", "--mission", "release-x",
|
||||
"--artifact-type", "qa", "--verdict", "CLEAR", "--candidate-sha", "abc1234", "--money-evidence", "m",
|
||||
"--name", name, ...(extra.includes("--file") ? [] : ["--body", "replacement body"]), ...extra]);
|
||||
}
|
||||
const stagingLeftovers = () => fs.readdirSync(proofDir).filter((f) => f.endsWith(".replace-tmp"));
|
||||
const png = (tag: string) => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from(tag)]);
|
||||
|
||||
it("a symlinked artifact name is replaced; the linked file keeps its bytes", async () => {
|
||||
const outside = path.join(workRoot, "outside");
|
||||
fs.mkdirSync(outside);
|
||||
const victim = path.join(outside, "victim.png");
|
||||
fs.writeFileSync(victim, png("symlink victim"));
|
||||
fs.symlinkSync(victim, path.join(proofDir, "linked.md"));
|
||||
await add("linked.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.readFileSync(victim)).toEqual(png("symlink victim"));
|
||||
expect(fs.lstatSync(path.join(proofDir, "linked.md")).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(path.join(proofDir, "linked.md"), "utf8")).toContain("replacement body");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a hard-linked artifact name is replaced; the other link keeps its bytes", async () => {
|
||||
const shot = path.join(proofDir, "shot.png");
|
||||
fs.writeFileSync(shot, png("hardlink victim"));
|
||||
fs.linkSync(shot, path.join(proofDir, "hard.md"));
|
||||
await add("hard.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.readFileSync(shot)).toEqual(png("hardlink victim"));
|
||||
expect(fs.statSync(shot).nlink).toBe(1);
|
||||
expect(fs.readFileSync(path.join(proofDir, "hard.md"), "utf8")).toContain("replacement body");
|
||||
});
|
||||
|
||||
it("a normal replace and a self-sourced replace both still work", async () => {
|
||||
fs.writeFileSync(path.join(proofDir, "normal.md"), "original\n");
|
||||
await add("normal.md", ["--replace"]);
|
||||
expect(fs.readFileSync(path.join(proofDir, "normal.md"), "utf8")).toContain("replacement body");
|
||||
const self = path.join(proofDir, "self.md");
|
||||
fs.writeFileSync(self, "self body\n");
|
||||
await add("self.md", ["--file", self, "--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const after = fs.readFileSync(self, "utf8");
|
||||
expect(after.startsWith("---\n")).toBe(true);
|
||||
expect(after).toContain("self body");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("without --replace an existing name is still refused and unchanged", async () => {
|
||||
const existing = path.join(proofDir, "kept.md");
|
||||
fs.writeFileSync(existing, "kept\n");
|
||||
await add("kept.md");
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errs.join("\n")).toContain("already exists");
|
||||
expect(fs.readFileSync(existing, "utf8")).toBe("kept\n");
|
||||
});
|
||||
|
||||
it("never touches a staging file it did not create", () => {
|
||||
const target = path.join(proofDir, "x.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
const foreign = path.join(proofDir, ".x.md.fixed.replace-tmp");
|
||||
fs.writeFileSync(foreign, "not ours\n");
|
||||
expect(() => replaceArtifactFile(target, "new\n", "fixed")).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
||||
expect(fs.readFileSync(foreign, "utf8")).toBe("not ours\n");
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
});
|
||||
|
||||
it("a failed write leaves the target as it was and removes only its own staging file", async () => {
|
||||
const target = path.join(proofDir, "w.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
const real = fs.writeFileSync;
|
||||
vi.spyOn(fs, "writeFileSync").mockImplementation(((file: unknown, ...rest: unknown[]) => {
|
||||
if (typeof file === "number") throw Object.assign(new Error("no space left"), { code: "ENOSPC" });
|
||||
return (real as (...a: unknown[]) => void)(file, ...rest);
|
||||
}) as typeof fs.writeFileSync);
|
||||
await expect(add("w.md", ["--replace"])).rejects.toMatchObject({ code: "ENOSPC" });
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a directory at the artifact name fails the replace and is left intact", async () => {
|
||||
const dir = path.join(proofDir, "dir.md");
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, "inside.txt"), "inside\n");
|
||||
await expect(add("dir.md", ["--replace"])).rejects.toMatchObject({ code: expect.stringMatching(/^E/) });
|
||||
expect(fs.statSync(dir).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(dir, "inside.txt"), "utf8")).toBe("inside\n");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps an existing regular artifact's permission bits; a symlinked name gets default permissions", async () => {
|
||||
const priv = path.join(proofDir, "private.md");
|
||||
fs.writeFileSync(priv, "original\n");
|
||||
fs.chmodSync(priv, 0o600);
|
||||
const inode = fs.statSync(priv).ino;
|
||||
await add("private.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.statSync(priv).mode & 0o777).toBe(0o600);
|
||||
expect(fs.statSync(priv).ino).not.toBe(inode);
|
||||
expect(fs.readFileSync(priv, "utf8")).toContain("replacement body");
|
||||
|
||||
const outside = path.join(workRoot, "outside-mode");
|
||||
fs.mkdirSync(outside);
|
||||
const linkedTarget = path.join(outside, "secret.png");
|
||||
fs.writeFileSync(linkedTarget, png("mode victim"));
|
||||
fs.chmodSync(linkedTarget, 0o600);
|
||||
fs.symlinkSync(linkedTarget, path.join(proofDir, "via-link.md"));
|
||||
await add("via-link.md", ["--replace"]);
|
||||
expect(fs.lstatSync(path.join(proofDir, "via-link.md")).mode & 0o777).toBe(0o666 & ~process.umask());
|
||||
expect(fs.statSync(linkedTarget).mode & 0o777).toBe(0o600);
|
||||
expect(fs.readFileSync(linkedTarget)).toEqual(png("mode victim"));
|
||||
});
|
||||
|
||||
it("creates the staging file no wider than the artifact it replaces", async () => {
|
||||
const priv = path.join(proofDir, "tight.md");
|
||||
fs.writeFileSync(priv, "original\n");
|
||||
fs.chmodSync(priv, 0o600);
|
||||
const createdModes: number[] = [];
|
||||
const realFchmod = fs.fchmodSync;
|
||||
vi.spyOn(fs, "fchmodSync").mockImplementation(((fd: number, mode: fs.Mode) => {
|
||||
createdModes.push(fs.fstatSync(fd).mode & 0o777); // the mode the exclusive open created, before narrowing
|
||||
return realFchmod(fd, mode);
|
||||
}) as typeof fs.fchmodSync);
|
||||
await add("tight.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(createdModes).toEqual([0o600]);
|
||||
expect(fs.statSync(priv).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("replaces a read-only artifact on explicit --replace and keeps it read-only", async () => {
|
||||
const ro = path.join(proofDir, "frozen.md");
|
||||
fs.writeFileSync(ro, "original\n");
|
||||
fs.chmodSync(ro, 0o444);
|
||||
await add("frozen.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.statSync(ro).mode & 0o777).toBe(0o444);
|
||||
expect(fs.readFileSync(ro, "utf8")).toContain("replacement body");
|
||||
});
|
||||
|
||||
it("reports the write failure, not a later close failure, and keeps the target", async () => {
|
||||
const target = path.join(proofDir, "wc.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
const realWrite = fs.writeFileSync;
|
||||
const realClose = fs.closeSync;
|
||||
vi.spyOn(fs, "writeFileSync").mockImplementation(((file: unknown, ...rest: unknown[]) => {
|
||||
if (typeof file === "number") throw Object.assign(new Error("no space left"), { code: "ENOSPC" });
|
||||
return (realWrite as (...a: unknown[]) => void)(file, ...rest);
|
||||
}) as typeof fs.writeFileSync);
|
||||
vi.spyOn(fs, "closeSync").mockImplementation(((fd: number) => {
|
||||
realClose(fd);
|
||||
throw Object.assign(new Error("i/o error on close"), { code: "EIO" });
|
||||
}) as typeof fs.closeSync);
|
||||
await expect(add("wc.md", ["--replace"])).rejects.toMatchObject({ code: "ENOSPC" });
|
||||
expect(errs.join("\n")).toContain("also failed");
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a close failure alone still refuses the rename", async () => {
|
||||
const target = path.join(proofDir, "c.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
const realClose = fs.closeSync;
|
||||
vi.spyOn(fs, "closeSync").mockImplementation(((fd: number) => {
|
||||
realClose(fd);
|
||||
throw Object.assign(new Error("i/o error on close"), { code: "EIO" });
|
||||
}) as typeof fs.closeSync);
|
||||
await expect(add("c.md", ["--replace"])).rejects.toMatchObject({ code: "EIO" });
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the original failure when cleanup also fails", async () => {
|
||||
const target = path.join(proofDir, "r.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
vi.spyOn(fs, "renameSync").mockImplementation(() => { throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); });
|
||||
vi.spyOn(fs, "rmSync").mockImplementation(() => { throw Object.assign(new Error("busy"), { code: "EBUSY" }); });
|
||||
await expect(add("r.md", ["--replace"])).rejects.toMatchObject({ code: "EXDEV" });
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
expect(errs.join("\n")).toContain("could not remove staging file");
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe("S03 policy permissions compatibility", () => {
|
||||
exists: (p: string) => p === STATE_FILE, isProcessAlive: () => true, fetch: async () => ({ ok: true }),
|
||||
}, clientFactory: () => ({ post: async (...a: unknown[]) => { posts.push(a); return { status: 409, data: response }; } }) };
|
||||
const result = await capture(seatCommand(deps as never), ["seat", "set-permissions", "owner@inert", "--mode", "auto", "--reason", "user chose", "--json"]);
|
||||
expect(posts).toEqual([["/api/seat/set-permissions/owner%40inert", { mode: "auto", reason: "user chose" }, { timeoutMs: 10_000 }]]);
|
||||
expect(posts).toEqual([["/api/seat/set-permissions/owner%40inert", { mode: "auto", reason: "user chose" }]]);
|
||||
expect(result.exit).toBe(1); expect(JSON.parse(result.logs.join(""))).toEqual(response); expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -744,88 +744,3 @@ describe("rig seat switch-client", () => {
|
||||
expect(errors.join("\n")).toContain("tmux switch-client failed");
|
||||
});
|
||||
});
|
||||
|
||||
// #260: the daemon may hold a dynamic Claude permission request for up to 5 s while it
|
||||
// queries `claude --help`; a mutating handover launches and readies a successor. These
|
||||
// bind the per-call request deadlines with fake timers (no wall-clock bound).
|
||||
describe("seat request deadlines (#260)", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const QUERY_REFUSAL = {
|
||||
ok: false,
|
||||
code: "permission_selection_refused",
|
||||
message: "Claude managed capability query failed; no fallback was selected.",
|
||||
};
|
||||
|
||||
function slowClient(respondAfterMs: number | null, response: () => Response) {
|
||||
const fetchImpl = vi.fn((_url: unknown, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
|
||||
const timer = respondAfterMs === null ? undefined : setTimeout(() => resolve(response()), respondAfterMs);
|
||||
init!.signal!.addEventListener("abort", () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(init!.signal!.reason);
|
||||
}, { once: true });
|
||||
}));
|
||||
const deps = makeDeps({ status: 200, data: {} }, []);
|
||||
deps.clientFactory = url => new DaemonClient(url, { fetchImpl });
|
||||
return { deps, fetchImpl };
|
||||
}
|
||||
|
||||
async function run(deps: StatusDeps, argv: string[], advanceMs: number) {
|
||||
const result = captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", ...argv]).then(() => undefined))
|
||||
.catch(error => ({ error: error as Error }));
|
||||
await vi.advanceTimersByTimeAsync(advanceMs);
|
||||
return result;
|
||||
}
|
||||
|
||||
const SET_PERMISSIONS = ["seat", "set-permissions", "dev-impl@seat-rig", "--mode", "auto", "--reason", "slow help", "--json"];
|
||||
const HANDOVER = ["seat", "handover", "dev-impl@seat-rig", "--reason", "context-wall", "--json"];
|
||||
|
||||
it("set-permissions receives the daemon's capability-query refusal after the 5 s default would have aborted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deps, fetchImpl } = slowClient(5_010, () => Response.json(QUERY_REFUSAL, { status: 409 }));
|
||||
const output = await run(deps, SET_PERMISSIONS, 5_010);
|
||||
if (!("logs" in output)) throw output.error;
|
||||
expect(JSON.parse(output.logs.join("\n"))).toMatchObject(QUERY_REFUSAL);
|
||||
expect(output.exitCode).toBe(1);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("set-permissions is still bounded, at 10 s", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deps, fetchImpl } = slowClient(null, () => Response.json({}));
|
||||
const output = await run(deps, SET_PERMISSIONS, 10_000);
|
||||
expect(output).toHaveProperty("error");
|
||||
expect((output as { error: Error }).error.message).toContain("timed out after 10000ms");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a mutating handover gets the 120 s launch window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deps, fetchImpl } = slowClient(null, () => Response.json({}));
|
||||
const output = await run(deps, HANDOVER, 120_000);
|
||||
expect(output).toHaveProperty("error");
|
||||
expect((output as { error: Error }).error.message).toContain("timed out after 120000ms");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a mutating handover receives a response that arrives after five seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deps } = slowClient(6_000, () => Response.json({ ok: false, code: "handover_refused", message: "synthetic slow refusal" }, { status: 409 }));
|
||||
const output = await run(deps, HANDOVER, 6_000);
|
||||
if (!("logs" in output)) throw output.error;
|
||||
expect(JSON.parse(output.logs.join("\n"))).toMatchObject({ code: "handover_refused" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["dry-run handover", [...HANDOVER, "--dry-run"]],
|
||||
["set-model", ["seat", "set-model", "dev-impl@seat-rig", "--model", "m", "--reason", "r", "--json"]],
|
||||
])("%s keeps the 5 s default deadline", async (_name, argv) => {
|
||||
vi.useFakeTimers();
|
||||
const { deps } = slowClient(null, () => Response.json({}));
|
||||
const output = await run(deps, argv as string[], 5_000);
|
||||
expect(output).toHaveProperty("error");
|
||||
expect((output as { error: Error }).error.message).toContain("timed out after 5000ms");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { createProgram } from "../src/index.js";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
UI_DISABLED_GUIDANCE,
|
||||
UI_MAINTENANCE_NOTICE,
|
||||
uiCommand,
|
||||
type UiDeps,
|
||||
@@ -55,8 +54,6 @@ function runningDeps(port: number, execFn?: UiDeps["exec"]): UiDeps {
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
exec: execFn ?? vi.fn(async () => {}),
|
||||
// The daemon serves the UI unless a test says otherwise; no test reaches a real port.
|
||||
probeUi: vi.fn(async () => ({ status: 200, headers: new Headers({ "content-type": "text/html" }) })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,34 +247,4 @@ describe("rig ui open", () => {
|
||||
else process.env["OPENRIG_UI_URL"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("daemon serving the UI off -> prints the enable and restart steps, exits 1, opens nothing", async () => {
|
||||
const execFn = vi.fn(async () => {});
|
||||
const deps = runningDeps(8888, execFn);
|
||||
deps.probeUi = vi.fn(async () => ({ status: 404, headers: new Headers({ "x-openrig-web-ui": "off" }) }));
|
||||
const prior = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
const program = new Command();
|
||||
program.addCommand(uiCommand(deps));
|
||||
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "ui", "open"]).then(() => undefined));
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = prior;
|
||||
expect(deps.probeUi).toHaveBeenCalledWith("http://127.0.0.1:8888");
|
||||
expect(execFn).not.toHaveBeenCalled();
|
||||
expect(logs).toContain(UI_DISABLED_GUIDANCE);
|
||||
expect(UI_DISABLED_GUIDANCE).toContain("rig config set ui.enabled true");
|
||||
expect(UI_DISABLED_GUIDANCE).toMatch(/rig daemon stop.*rig daemon start/);
|
||||
expect(logs).not.toContain("http://127.0.0.1:8888");
|
||||
});
|
||||
|
||||
it("an unanswered UI probe opens the URL as before", async () => {
|
||||
const execFn = vi.fn(async () => {});
|
||||
const deps = runningDeps(8888, execFn);
|
||||
deps.probeUi = vi.fn(async () => { throw new Error("connection refused"); });
|
||||
const program = new Command();
|
||||
program.addCommand(uiCommand(deps));
|
||||
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "ui", "open"]).then(() => undefined));
|
||||
expect(execFn).toHaveBeenCalledWith("open", ["http://127.0.0.1:8888"]);
|
||||
expect(logs).not.toContain(UI_DISABLED_GUIDANCE);
|
||||
});
|
||||
});
|
||||
@@ -39,21 +39,11 @@ A request can be vague in several directions: diagnose or change, contents or pr
|
||||
local symptom or intended outcome. Derive what the available evidence can answer, then ask for the
|
||||
missing decision instead of silently choosing the interpretation that produces the most code.
|
||||
|
||||
The recurring failure is easy to rationalize, and it comes in two shapes. Asked for a doghouse, an
|
||||
agent wonders whether it should have a light. The light needs power, power needs a generator, and the
|
||||
generator needs fuel. Every step is locally defensible, and an hour later there is a moon base while
|
||||
the dog is still waiting outside. The question that stops it: **Does the thing actually need this?**
|
||||
|
||||
The other shape is quieter. The agent builds exactly the doghouse that was asked for, tidy and on
|
||||
time, with a door the dog can't fit through. Nothing was over-built, but nobody asked the one fact
|
||||
that decided whether it works. The question that stops it: **How big is the dog?**
|
||||
|
||||
Both are the same failure: losing the plot. Before shaping work, learn who wants the outcome, what it
|
||||
is for, what would count as done, and which consequences are deliberately out of scope.
|
||||
|
||||
What prevents this is shared understanding of the goal, not tighter instructions. When you hand work
|
||||
to a capable peer, give them the intent, the context and where to find more, and let them decide how.
|
||||
A narrow script brings its own failure: you get exactly what was written, not what was wanted.
|
||||
The recurring failure is easy to rationalize. A doghouse seems to need a lock; the lock seems to
|
||||
need power; power suggests more infrastructure. Every step is locally defensible, yet the requested
|
||||
shelter never arrives. The cheapest corrective question is: **How big is the dog?** Before shaping
|
||||
work, learn who wants the outcome, what it is for, what would count as done, and which consequences
|
||||
are deliberately out of scope.
|
||||
|
||||
Run `rig context list` to discover whether this rig provides a world pack. If it does, load that
|
||||
pack's fresh profile with `rig context profile <world-pack-ref> --situation fresh`; otherwise,
|
||||
|
||||
@@ -42,12 +42,16 @@ looked like. State scoped absences honestly: “not represented on the surface c
|
||||
than a global absence you did not establish. If another seat must act, transfer durable work rather
|
||||
than printing a summary and disappearing.
|
||||
|
||||
## When you need more
|
||||
Run `rig context get onboarding-width` and read its `public-what-you-can-do.md` and
|
||||
`public-reference-material.md` members for the shipped capability map and source map. This
|
||||
retrieval belongs to this second onboarding step; it is not an additional walk step.
|
||||
|
||||
When you need to know what OpenRig can do, run `rig context get onboarding-width`: its
|
||||
`public-what-you-can-do.md` is the capability map and `public-reference-material.md` the documentation map. For
|
||||
any one command, `rig <command> --help` on the installed binary is the most current answer.
|
||||
|
||||
If you plan or route work, a project's world pack is worth loading in full. If you build, stay with the outcome you
|
||||
were handed and what counts as done. If you are setting up a world pack for your own project,
|
||||
`rig context get world-example` has a fill-in template.
|
||||
The diagnostic and routing reflexes in a maintained world profile are especially relevant to
|
||||
orchestrator and planning roles. Builders should keep their assigned boundary and proof standard;
|
||||
do not import judgment-seat ceremony into an implementation lane merely because the examples are
|
||||
available. Run `rig context list`; if this rig provides a world pack, load its fresh profile with
|
||||
`rig context profile <world-pack-ref> --situation fresh`. If it does not, run
|
||||
`rig context get world-example` for a fill-in template showing how to build one. These two
|
||||
onboarding steps plus `onboarding-width` are the complete public default mental model. Use
|
||||
`forming-an-openrig-mental-model` or
|
||||
`openrig-operating-model` at their named trigger moments rather than copying their content elsewhere.
|
||||
@@ -36,12 +36,6 @@ Before delegating to a blank slate, ask: **would a wrong-but-plausible answer he
|
||||
## It generalizes
|
||||
This is really **"route work to whoever holds the context,"** with subagents as the option for work that needs none. The audience is **every agent** — implementers and QA seats make this call as much as orchestrators do.
|
||||
|
||||
## Hand over intent, not instructions
|
||||
Whoever does the work, give them the goal, why it matters, the context you hold and where to find more, then let
|
||||
them decide how. When you have the requester's own words, pass them on instead of compressing them into a list of
|
||||
steps. A capable agent with the goal builds better than one painting inside a narrow spec, which gets exactly what
|
||||
was written and not what was wanted. Keep exact instructions for the few things that must be exact, and say why.
|
||||
|
||||
## Once you've decided
|
||||
- Decided on subagents for 2+ independent tasks → `dispatching-parallel-agents` (the fan-out mechanics).
|
||||
- Subagents executing a plan's independent tasks → `subagent-driven-development`.
|
||||
|
||||
@@ -122,5 +122,4 @@ and a useful report to the OpenRig team if you are still stuck. If `rig` won't r
|
||||
|
||||
## Need more than what ships here?
|
||||
|
||||
This index covers the **shipped** surface. Your project, rig or machine may add its own skills and routers. Check the
|
||||
skills your working directory loads before concluding that something doesn't exist.
|
||||
This index covers the **shipped** surface. A dev host carries far more (factory, architecture, PM-craft, studio skills) reached through the host's own routers/codemaps — if you're on a builder host and need something not listed above, that deeper routing is the next hop, not a wall. (Host-scale routing is the subject of the context-routing architecture doc; at product scale, this one file is the whole map.)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -122,5 +122,4 @@ and a useful report to the OpenRig team if you are still stuck. If `rig` won't r
|
||||
|
||||
## Need more than what ships here?
|
||||
|
||||
This index covers the **shipped** surface. Your project, rig or machine may add its own skills and routers. Check the
|
||||
skills your working directory loads before concluding that something doesn't exist.
|
||||
This index covers the **shipped** surface. A dev host carries far more (factory, architecture, PM-craft, studio skills) reached through the host's own routers/codemaps — if you're on a builder host and need something not listed above, that deeper routing is the next hop, not a wall. (Host-scale routing is the subject of the context-routing architecture doc; at product scale, this one file is the whole map.)
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
|
||||
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
|
||||
import { observeClaudePaneProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
|
||||
import { mergeManagedBlock, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { validateClaudeActivityHookDelivery } from "../domain/claude-activity-hooks.js";
|
||||
@@ -59,8 +58,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
private stateDir: string | null;
|
||||
private collectorAssetPath: string | null;
|
||||
private autoDriveProviderPrompts: boolean;
|
||||
private listProcesses?: NativeProcessLister;
|
||||
private autoLaunches = new Map<string, { binding: NodeBinding; token: string; executable?: string; fingerprint?: string }>();
|
||||
readonly claudeManagedLaunch?: ClaudeManagedLaunch;
|
||||
private activityRelayPath: string | null;
|
||||
private claudeHooksManifestPath: string | null;
|
||||
@@ -76,7 +73,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
stateDir?: string;
|
||||
collectorAssetPath?: string;
|
||||
autoDriveProviderPrompts?: boolean;
|
||||
listProcesses?: NativeProcessLister;
|
||||
claudeManagedLaunch?: ClaudeManagedLaunch;
|
||||
/** DI source of the activity-relay.cjs asset (parity with the Codex adapter). */
|
||||
activityRelayPath?: string;
|
||||
@@ -94,7 +90,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
this.stateDir = deps.stateDir ?? null;
|
||||
this.collectorAssetPath = deps.collectorAssetPath ?? null;
|
||||
this.autoDriveProviderPrompts = deps.autoDriveProviderPrompts ?? false;
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.claudeManagedLaunch = deps.claudeManagedLaunch;
|
||||
this.activityRelayPath = deps.activityRelayPath ?? null;
|
||||
this.claudeHooksManifestPath = deps.claudeHooksManifestPath ?? null;
|
||||
@@ -226,7 +221,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
binding = { ...binding };
|
||||
this.autoLaunches.delete(binding.nodeId);
|
||||
opts = { ...opts, ...(opts.forkSource ? { forkSource: { ...opts.forkSource } } : {}) };
|
||||
if (!binding.tmuxSession) {
|
||||
return { ok: false, error: "No tmux session bound — cannot launch Claude Code harness" };
|
||||
@@ -320,9 +314,8 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
return { ok: false, error: `Failed to send Enter: ${enterResult.message}` };
|
||||
}
|
||||
|
||||
this.autoLaunches.set(binding.nodeId, { binding, token: opts.resumeToken ?? generatedSessionId!, executable: managed?.executable });
|
||||
if (opts.resumeToken) {
|
||||
const verification = await this.verifyResumeLaunch(binding);
|
||||
const verification = await this.verifyResumeLaunch(binding.tmuxSession);
|
||||
if (!verification.ok) return verification;
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id", appliedLaunch };
|
||||
}
|
||||
@@ -334,7 +327,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
|
||||
binding = { ...binding };
|
||||
if (!binding.tmuxSession) {
|
||||
return { ready: false, reason: "No tmux session bound" };
|
||||
}
|
||||
@@ -345,7 +337,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
const paneCommand = await this.tmux.getPaneCommand(binding.tmuxSession);
|
||||
const paneContent = (await this.tmux.capturePaneContent(binding.tmuxSession, 40)) ?? "";
|
||||
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent);
|
||||
const probe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand,
|
||||
paneContent,
|
||||
});
|
||||
|
||||
if (probe.status === "resumed") return { ready: true };
|
||||
return { ready: false, reason: probe.detail, code: probe.code };
|
||||
@@ -363,45 +359,17 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
// -- Private helpers --
|
||||
|
||||
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string) {
|
||||
const input = { runtime: "claude-code", paneCommand, paneContent };
|
||||
const probe = assessNativeResumeProbe(input);
|
||||
// Only the new headerless auto-mode case needs this additional observation.
|
||||
// Screen-only callers cannot opt themselves into managed launch readiness.
|
||||
if (probe.code !== "claude_auto_identity_required") return probe;
|
||||
const launch = this.autoLaunches.get(binding.nodeId);
|
||||
if (!launch || !binding.tmuxPane || !binding.tmuxSession
|
||||
|| launch.binding.id !== binding.id || launch.binding.tmuxPane !== binding.tmuxPane
|
||||
|| launch.binding.tmuxSession !== binding.tmuxSession
|
||||
|| launch.binding.launchGeneration !== binding.launchGeneration) return probe;
|
||||
try {
|
||||
const panes = await this.tmux.listPanes(binding.tmuxSession);
|
||||
if (panes.length !== 1 || panes[0]?.id !== binding.tmuxPane) return probe;
|
||||
const observation = { target: binding.tmuxPane, tmux: this.tmux,
|
||||
listProcesses: this.listProcesses, expectedToken: launch.token, selectedExecutable: launch.executable };
|
||||
const first = await observeClaudePaneProcess(observation);
|
||||
if (!first) return probe;
|
||||
// Pin the first exact process for this launch; retries cannot adopt a replacement.
|
||||
launch.fingerprint ??= first.fingerprint;
|
||||
if (first.fingerprint !== launch.fingerprint) return probe;
|
||||
const native = await observeClaudePaneProcess(observation);
|
||||
if (native?.fingerprint !== launch.fingerprint) return probe;
|
||||
const currentPanes = await this.tmux.listPanes(binding.tmuxSession);
|
||||
if (this.autoLaunches.get(binding.nodeId) !== launch || currentPanes.length !== 1
|
||||
|| currentPanes[0]?.id !== binding.tmuxPane
|
||||
|| await this.tmux.getPanePid(binding.tmuxSession) !== native.panePid) return probe;
|
||||
return assessNativeResumeProbe({ ...input, claudeAutoIdentityVerified: true });
|
||||
} catch { return probe; }
|
||||
}
|
||||
|
||||
private async verifyResumeLaunch(binding: NodeBinding): Promise<HarnessLaunchResult> {
|
||||
const tmuxSession = binding.tmuxSession!;
|
||||
private async verifyResumeLaunch(tmuxSession: string): Promise<HarnessLaunchResult> {
|
||||
const attempts = 16;
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const paneCommand = await this.tmux.getPaneCommand(tmuxSession);
|
||||
const paneContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
|
||||
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent);
|
||||
const probe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand,
|
||||
paneContent,
|
||||
});
|
||||
|
||||
if (probe.code === "no_conversation_found") {
|
||||
return {
|
||||
@@ -443,13 +411,17 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
const finalCommand = await this.tmux.getPaneCommand(tmuxSession);
|
||||
const finalContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
|
||||
const finalProbe = await this.assessManagedProbe(binding, finalCommand, finalContent);
|
||||
const finalProbe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand: finalCommand,
|
||||
paneContent: finalContent,
|
||||
});
|
||||
|
||||
if (finalProbe.status === "resumed") {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (finalProbe.status === "attention_required" || finalProbe.code === "claude_auto_identity_required") {
|
||||
if (finalProbe.status === "attention_required") {
|
||||
return {
|
||||
ok: false,
|
||||
error: finalProbe.detail,
|
||||
|
||||
@@ -9,12 +9,14 @@ import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
|
||||
|
||||
export type ResumeResult =
|
||||
| { ok: true; appliedLaunch?: AppliedLaunchObservation }
|
||||
// Non-terminal: a chooser or an inconclusive observation must preserve the
|
||||
// launch. Attention is not proof of native identity or successful continuity.
|
||||
// L3: `attention_required` is a non-terminal failure — Claude is alive and
|
||||
// recoverable, but the resume-selection prompt is blocking. Caller maps to
|
||||
// restoreOutcome=attention_required (do NOT auto-answer per Decision 2).
|
||||
| { ok: false; code: "attention_required"; message: string; evidence?: string }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
const CLAUDE_TYPES = new Set(["claude_name", "claude_id"]);
|
||||
const SHELL_COMMANDS = new Set(["bash", "fish", "nu", "sh", "tmux", "zsh"]);
|
||||
|
||||
interface ClaudeResumeOptions {
|
||||
claudeManagedLaunch?: ClaudeManagedLaunch;
|
||||
@@ -85,11 +87,7 @@ export class ClaudeResumeAdapter {
|
||||
return { ok: false, code: "resume_failed", message: keyResult.message };
|
||||
}
|
||||
|
||||
const result = await this.verifyResume(tmuxSessionName).catch((error): ResumeResult => ({
|
||||
ok: false,
|
||||
code: "attention_required",
|
||||
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}));
|
||||
const result = await this.verifyResume(tmuxSessionName);
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
@@ -147,17 +145,11 @@ export class ClaudeResumeAdapter {
|
||||
paneContent: finalContent,
|
||||
});
|
||||
|
||||
if (finalProbe.code === "no_conversation_found") {
|
||||
return { ok: false, code: "retry_fresh", message: "Claude resume failed: no conversation found for the requested session" };
|
||||
}
|
||||
if (finalProbe.status === "attention_required") {
|
||||
return { ok: false, code: "attention_required", message: finalProbe.detail, evidence: finalContent.split("\n").slice(-12).join("\n") };
|
||||
}
|
||||
if (finalProbe.status === "resumed") {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (finalProbe.code === "returned_to_shell") {
|
||||
if (finalCommand && SHELL_COMMANDS.has(finalCommand)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "retry_fresh",
|
||||
@@ -167,9 +159,8 @@ export class ClaudeResumeAdapter {
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: "attention_required",
|
||||
message: `Claude resume could not be verified; launch retained: ${finalProbe.detail}`,
|
||||
evidence: finalContent.split("\n").slice(-12).join("\n"),
|
||||
code: "resume_failed",
|
||||
message: "Claude resume failed: timed out waiting for Claude to become active",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// Startup files and projection resources that ship with OpenRig are stored per seat as
|
||||
// absolute paths inside the install that created the seat (rigspec-instantiator resolves
|
||||
// them from import.meta.dirname, or from a rig spec that lives in the install). After an
|
||||
// upgrade that removes or moves that install, the stored paths go stale (#261). Delivery
|
||||
// consumers and restore-check re-anchor them to the RUNNING install, so seats get this
|
||||
// version's shipped content:
|
||||
// - the four built-in startup files under daemon/assets (by logical name and known path);
|
||||
// - startup files and projection resources under an install's daemon/specs (the kernel and
|
||||
// library rigs and agents), when both their root and their file lie in that same specs root.
|
||||
// A packaged install (@openrig/cli/daemon/...) is re-anchored even while it still exists, so
|
||||
// seats never keep an old version's shipped content. A dev checkout (packages/daemon/...) is
|
||||
// re-anchored only when the stored file is missing: its files may be newer than, or absent
|
||||
// from, the running install. Custom, user, plugin and other external paths are never rewritten.
|
||||
import { existsSync } from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Logical name -> path relative to the daemon assets root, as rigspec-instantiator produces them. */
|
||||
const BUILTIN_STARTUP_FILES: ReadonlyMap<string, string> = new Map([
|
||||
["CULTURE-default.md", "guidance/CULTURE-default.md"],
|
||||
["openrig-start.md", "guidance/openrig-start.md"],
|
||||
["openrig-onboarding-01.md", "onboarding/01-world-and-purpose.md"],
|
||||
["openrig-onboarding-02.md", "onboarding/02-self-and-competent-action.md"],
|
||||
]);
|
||||
|
||||
type Exists = (path: string) => boolean;
|
||||
type InstallLayout = "packaged" | "dev-checkout";
|
||||
|
||||
/** The running daemon's assets root (packages/daemon/assets, or <cli>/daemon/assets when packaged). */
|
||||
export function runningBuiltinAssetsRoot(): string {
|
||||
return nodePath.resolve(import.meta.dirname, "../../assets");
|
||||
}
|
||||
|
||||
/** The running daemon's shipped specs root (packages/daemon/specs, or <cli>/daemon/specs when packaged). */
|
||||
export function runningShippedSpecsRoot(): string {
|
||||
return nodePath.resolve(import.meta.dirname, "../../specs");
|
||||
}
|
||||
|
||||
/** The layout of the OpenRig install whose daemon/<dir> root is `parts[0..i+1]`, if recognized. */
|
||||
function layoutAt(parts: string[], i: number): InstallLayout | null {
|
||||
if (i >= 2 && parts[i - 1] === "cli" && parts[i - 2] === "@openrig") return "packaged";
|
||||
if (i >= 1 && parts[i - 1] === "packages") return "dev-checkout";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The recognized OpenRig install's daemon/specs root containing `p`, with its layout. */
|
||||
function shippedSpecsRootOf(p: string): { root: string; layout: InstallLayout } | null {
|
||||
const parts = nodePath.resolve(p).split(nodePath.sep);
|
||||
for (let i = parts.length - 2; i >= 1; i--) {
|
||||
if (parts[i] !== "daemon" || parts[i + 1] !== "specs") continue;
|
||||
const layout = layoutAt(parts, i);
|
||||
if (layout) return { root: parts.slice(0, i + 2).join(nodePath.sep), layout };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A stored file from a dev checkout is followed only when it is gone; a packaged one always. */
|
||||
function shouldReanchor(layout: InstallLayout, storedFile: string, exists: Exists): boolean {
|
||||
return layout === "packaged" || !exists(storedFile);
|
||||
}
|
||||
|
||||
/** Map a (root, file) pair stored under one install's daemon/specs onto the running specs root,
|
||||
* preserving both relative paths. Null when the root is not in a recognized install's specs,
|
||||
* the file lies outside that same specs root, or a dev-checkout file is still present. */
|
||||
function mapUnderShippedSpecs(root: string, file: string, specsRoot: string, exists: Exists): { root: string; file: string } | null {
|
||||
const stored = shippedSpecsRootOf(root);
|
||||
if (!stored) return null;
|
||||
const resolvedFile = nodePath.resolve(file);
|
||||
if (!resolvedFile.startsWith(stored.root + nodePath.sep)) return null;
|
||||
if (!shouldReanchor(stored.layout, file, exists)) return null;
|
||||
return {
|
||||
root: nodePath.join(specsRoot, nodePath.relative(stored.root, nodePath.resolve(root))),
|
||||
file: nodePath.join(specsRoot, nodePath.relative(stored.root, resolvedFile)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor one stored startup file to the running install when it ships with OpenRig:
|
||||
* - a built-in: its logical name is one of the four built-ins, its stored absolutePath is
|
||||
* exactly <ownerRoot>/<known relative path>, and ownerRoot is a recognized install's
|
||||
* daemon/assets directory; or
|
||||
* - a shipped-spec file: its ownerRoot and absolutePath both lie under the same recognized
|
||||
* install's daemon/specs (for example the kernel rig culture and agent role/startup files).
|
||||
* Dev-checkout files are re-anchored only when missing. Anything else (custom rig/agent files,
|
||||
* user content with a matching basename) is returned unchanged. Required, applicability,
|
||||
* delivery and every other field are preserved.
|
||||
*/
|
||||
export function reanchorBuiltinStartupFile<T extends { path: string; absolutePath: string; ownerRoot: string }>(
|
||||
file: T,
|
||||
assetsRoot: string = runningBuiltinAssetsRoot(),
|
||||
specsRoot: string = runningShippedSpecsRoot(),
|
||||
exists: Exists = existsSync,
|
||||
): T {
|
||||
// Persisted contexts from older versions may lack ownerRoot/path; leave them exactly as stored.
|
||||
if (typeof file.ownerRoot !== "string" || typeof file.absolutePath !== "string") return file;
|
||||
const relative = typeof file.path === "string" ? BUILTIN_STARTUP_FILES.get(file.path) : undefined;
|
||||
if (relative) {
|
||||
const storedRoot = nodePath.resolve(file.ownerRoot);
|
||||
const parts = storedRoot.split(nodePath.sep);
|
||||
const i = parts.length - 2;
|
||||
const layout = parts[i] === "daemon" && parts[i + 1] === "assets" ? layoutAt(parts, i) : null;
|
||||
if (layout && nodePath.resolve(file.absolutePath) === nodePath.join(storedRoot, relative)) {
|
||||
return shouldReanchor(layout, file.absolutePath, exists)
|
||||
? { ...file, absolutePath: nodePath.join(assetsRoot, relative), ownerRoot: assetsRoot }
|
||||
: file;
|
||||
}
|
||||
}
|
||||
const mapped = mapUnderShippedSpecs(file.ownerRoot, file.absolutePath, specsRoot, exists);
|
||||
return mapped ? { ...file, ownerRoot: mapped.root, absolutePath: mapped.file } : file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor one stored projection entry to the running install when it is a shipped-spec
|
||||
* resource: both its sourcePath and its resource absolutePath lie under the same recognized
|
||||
* OpenRig install's daemon/specs root (dev-checkout resources only when missing). A resource
|
||||
* stored elsewhere (a plugin under ~/.openrig/plugins, user specs, custom paths) is returned
|
||||
* unchanged even when its sourcePath is a shipped spec. Identifiers, category, target and
|
||||
* merge behavior are preserved.
|
||||
*/
|
||||
export function reanchorShippedProjectionEntry<T extends { sourcePath: string; absolutePath: string }>(
|
||||
entry: T,
|
||||
specsRoot: string = runningShippedSpecsRoot(),
|
||||
exists: Exists = existsSync,
|
||||
): T {
|
||||
// Persisted entries from older versions may lack sourcePath; leave them exactly as stored.
|
||||
if (typeof entry.sourcePath !== "string" || typeof entry.absolutePath !== "string") return entry;
|
||||
const mapped = mapUnderShippedSpecs(entry.sourcePath, entry.absolutePath, specsRoot, exists);
|
||||
return mapped ? { ...entry, sourcePath: mapped.root, absolutePath: mapped.file } : entry;
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export class ClaudeManagedLaunch {
|
||||
}
|
||||
|
||||
async prepare(request: ClaudeLaunchTarget, mode: string): Promise<{
|
||||
assertCurrent: () => void; command: (args: readonly string[]) => string; configDir: string; executable: string;
|
||||
assertCurrent: () => void; command: (args: readonly string[]) => string; configDir: string;
|
||||
}> {
|
||||
const target = Object.freeze({ ...request });
|
||||
const before = this.target(target.nodeId);
|
||||
@@ -100,7 +100,7 @@ export class ClaudeManagedLaunch {
|
||||
}
|
||||
};
|
||||
const help = await new Promise<string>((resolve, reject) => {
|
||||
execFile(context.executable, ["--help"], { cwd, env: context.env, encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 },
|
||||
execFile(context.executable, ["--help"], { cwd, env: context.env, encoding: "utf8", timeout: 1000, maxBuffer: 1024 * 1024 },
|
||||
(error, stdout) => error ? reject(new Error("Claude managed capability query failed; no fallback was selected.")) : resolve(stdout));
|
||||
});
|
||||
assertCurrent();
|
||||
@@ -111,7 +111,7 @@ export class ClaudeManagedLaunch {
|
||||
...(generation ? { OPENRIG_OCCUPANT_GENERATION: generation } : {}) };
|
||||
const assignments = Object.entries({ ...context.env, ...identity }).map(([key, value]) => shellQuote(`${key}=${value}`));
|
||||
const forwarded = inherited.filter(key => !(key in identity)).map(key => `"${key}=\${${key}-}"`);
|
||||
return Object.freeze({ assertCurrent, configDir: context.configDir, executable: context.executable, command: (args: readonly string[]) => {
|
||||
return Object.freeze({ assertCurrent, configDir: context.configDir, command: (args: readonly string[]) => {
|
||||
assertCurrent();
|
||||
return `cd ${shellQuote(cwd)} && /usr/bin/env -i ${[...assignments, ...forwarded, shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}`;
|
||||
} });
|
||||
|
||||
@@ -110,7 +110,7 @@ export class InboundRouter {
|
||||
constructor(private readonly deps: InboundDeps) {}
|
||||
|
||||
private summaryOf(ev: SlackEvent, transfer?: InboundFileResult | null, correlationQitemId?: string): { summary: string; body: string } {
|
||||
const text = String(ev.text ?? "");
|
||||
const text = String(ev.text ?? "").slice(0, 1800);
|
||||
const meta = `slack channel=${ev.channel} user=${ev.user} ts=${ev.ts}`;
|
||||
// OPR.0.5.6.2 — attachments ride the row BODY by LOCAL path (Slack owns
|
||||
// nothing; the media file is OUR copy). Failures are per-file and named:
|
||||
|
||||
@@ -73,12 +73,6 @@ export function startSocketInbound(appToken: string, router: InboundRouter, deps
|
||||
}
|
||||
};
|
||||
|
||||
const retryDeadLetters = (): void => {
|
||||
void router.retryDeadLetters().catch((error) => {
|
||||
log(`dead-letter retry failed: ${(error as Error).message}`);
|
||||
});
|
||||
};
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const connect = async (): Promise<void> => {
|
||||
if (stopped) return resolve();
|
||||
@@ -108,10 +102,10 @@ export function startSocketInbound(appToken: string, router: InboundRouter, deps
|
||||
status.state = "connected";
|
||||
status.connectedAt = stamp();
|
||||
receipt({ generation: connects, status: "connected" });
|
||||
retryDeadLetters(); // drain on connect (cold-init)…
|
||||
void router.retryDeadLetters(); // drain on connect (cold-init)…
|
||||
// …AND periodically WHILE connected (B1: recovery after a queue outage
|
||||
// must not wait for the next Slack reconnect). Cleared on close.
|
||||
retryTimer = setInterval(retryDeadLetters, retryIntervalMs);
|
||||
retryTimer = setInterval(() => void router.retryDeadLetters(), retryIntervalMs);
|
||||
if (typeof (retryTimer as unknown as { unref?: () => void }).unref === "function") {
|
||||
(retryTimer as unknown as { unref: () => void }).unref();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { runAsyncSite } from "./sync-site-wrap.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -25,29 +24,10 @@ function executableName(token: string): string {
|
||||
return (token.split("/").pop() ?? token).toLowerCase().replace(/\.exe$/, "");
|
||||
}
|
||||
|
||||
// The native installer resolves `claude` to this versioned path. A bare version
|
||||
// number is never executable identity. A launch receipt takes precedence over
|
||||
// layout recognition, so a later PATH update cannot replace that launch's binary.
|
||||
function claudeExecutable(token: string, selectedExecutable?: string): boolean {
|
||||
// An observed path must match the frozen launch path, even when its basename
|
||||
// is claude. A bare process title carries no path and retains legacy token proof.
|
||||
if (selectedExecutable && token.includes("/")) return token === selectedExecutable;
|
||||
if (executableName(token) === "claude") return true; // includes native process-title spelling
|
||||
if (selectedExecutable) return token === selectedExecutable;
|
||||
return token.startsWith("/") && !token.split("/").some(part => part === "." || part === "..")
|
||||
&& /\/\.local\/share\/claude\/versions\/\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(token);
|
||||
}
|
||||
|
||||
function claudeProcess(row: NativeProcessRow, selectedExecutable?: string): boolean {
|
||||
const argv0 = tokens(row.command)[0] ?? "";
|
||||
return claudeExecutable(argv0, selectedExecutable)
|
||||
&& executableName(row.executableName ?? "") === executableName(argv0);
|
||||
}
|
||||
|
||||
function commandUsesExpectedToken(command: string, runtime: NativeRuntime, expectedToken: string): boolean {
|
||||
const argv = tokens(command);
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
const executableIndex = argv.findIndex((token) => runtime === "claude-code" ? claudeExecutable(token) : executableName(token) === executable);
|
||||
const executableIndex = argv.findIndex((token) => executableName(token) === executable);
|
||||
if (executableIndex < 0) return false;
|
||||
const args = argv.slice(executableIndex + 1);
|
||||
if (runtime === "claude-code") {
|
||||
@@ -161,16 +141,15 @@ export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProce
|
||||
type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string };
|
||||
export type CodexProcessObservation = NativeProcessObservation;
|
||||
|
||||
function nativeProcessCandidates(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, selectedExecutable?: string): NativeProcessObservation[] {
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex"): NativeProcessObservation | null {
|
||||
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
||||
const root = byPid.get(panePid);
|
||||
if (byPid.size !== rows.length || !root?.startedAt || !root.tpgid || root.tpgid <= 0) return [];
|
||||
if (byPid.size !== rows.length || !root?.startedAt || !root.tpgid || root.tpgid <= 0) return null;
|
||||
const matches: { process: NativeProcessRow; chain: NativeProcessRow[] }[] = [];
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
for (const row of rows) {
|
||||
const osExecutable = runtime === "claude-code" ? executableName(row.executableName ?? "") : row.executableName;
|
||||
if ((runtime === "claude-code" ? !claudeProcess(row, selectedExecutable)
|
||||
: osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable)
|
||||
if (osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable
|
||||
|| row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
|
||||
const chain: NativeProcessRow[] = [];
|
||||
const visited = new Set<number>();
|
||||
@@ -182,15 +161,8 @@ function nativeProcessCandidates(rows: NativeProcessRow[], panePid: number, runt
|
||||
current = byPid.get(current.ppid);
|
||||
}
|
||||
}
|
||||
return matches.map(({ process, chain }) => ({ panePid, process,
|
||||
fingerprint: JSON.stringify(chain.map(row => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command])) }));
|
||||
}
|
||||
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string): NativeProcessObservation | null {
|
||||
const matches = nativeProcessCandidates(rows, panePid, runtime, selectedExecutable);
|
||||
if (matches.length !== 1) return null;
|
||||
const observation = matches[0]!;
|
||||
const { process } = observation;
|
||||
const { process, chain } = matches[0]!;
|
||||
if (runtime === "claude-code") {
|
||||
if (!expectedToken || claudeSessionToken(tokens(process.command).slice(1)) !== expectedToken) return null;
|
||||
} else {
|
||||
@@ -199,7 +171,7 @@ function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expected
|
||||
if ((requireResume || (expectedToken !== undefined && resumeToken !== undefined))
|
||||
&& (!expectedToken || resumeToken !== expectedToken)) return null;
|
||||
}
|
||||
return observation;
|
||||
return { panePid, process, fingerprint: JSON.stringify(chain.map((row) => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command])) };
|
||||
}
|
||||
|
||||
async function observeNativePaneProcess(input: {
|
||||
@@ -208,14 +180,12 @@ async function observeNativePaneProcess(input: {
|
||||
listProcesses?: NativeProcessLister;
|
||||
expectedToken?: string | null;
|
||||
requireResume?: boolean;
|
||||
/** Canonical executable frozen by the managed launch, never re-resolved at observation time. */
|
||||
selectedExecutable?: string;
|
||||
}, runtime: NativeRuntime): Promise<NativeProcessObservation | null> {
|
||||
try {
|
||||
const pid = await input.tmux.getPanePid(input.target);
|
||||
if (!pid) return null;
|
||||
const rows = await (input.listProcesses ?? listNativeProcesses)();
|
||||
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime, input.selectedExecutable);
|
||||
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@@ -230,67 +200,9 @@ export async function verifyCodexPaneProcess(input: Parameters<typeof observeCod
|
||||
return second?.fingerprint === first.fingerprint ? second : null;
|
||||
}
|
||||
|
||||
/** Single observation for callers that pin identity across their own launch checks. */
|
||||
export async function observeClaudePaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<NativeProcessObservation | null> {
|
||||
return observeNativePaneProcess(input, "claude-code");
|
||||
}
|
||||
|
||||
export async function verifyClaudePaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<NativeProcessObservation | null> {
|
||||
const first = await observeNativePaneProcess(input, "claude-code");
|
||||
if (!first) return null;
|
||||
const second = await observeNativePaneProcess(input, "claude-code");
|
||||
return second?.fingerprint === first.fingerprint ? second : null;
|
||||
}
|
||||
|
||||
export interface ClaudeDeliveryObservation {
|
||||
state: "verified" | "unknown" | "idle_shell" | "conflict";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** Ordinary delivery's uncertainty policy is separate from readiness/identity proof. */
|
||||
export async function observeClaudeDelivery(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<ClaudeDeliveryObservation> {
|
||||
const unknown = { state: "unknown" as const, detail: "Claude runtime identity could not be established" };
|
||||
const sample = async (): Promise<ClaudeDeliveryObservation & { fingerprint?: string }> => {
|
||||
try {
|
||||
const pid = await input.tmux.getPanePid(input.target);
|
||||
if (!pid) return unknown;
|
||||
const rows = await (input.listProcesses ?? listNativeProcesses)();
|
||||
const candidates = nativeProcessCandidates(rows, pid, "claude-code", input.selectedExecutable);
|
||||
if (candidates.length > 1) return { state: "conflict", detail: "Multiple Claude processes occupy the bound foreground" };
|
||||
const native = candidates[0];
|
||||
if (native) {
|
||||
const token = claudeSessionToken(tokens(native.process.command).slice(1));
|
||||
const fingerprint = native.fingerprint;
|
||||
if (!token || !input.expectedToken) return { ...unknown, fingerprint };
|
||||
return token === input.expectedToken
|
||||
? { state: "verified", detail: "Expected Claude conversation in the bound foreground", fingerprint }
|
||||
: { state: "conflict", detail: "The bound foreground names a different Claude conversation", fingerprint };
|
||||
}
|
||||
const other = selectNativeProcess(rows, pid);
|
||||
if (other) return { state: "conflict", detail: "A different native runtime occupies the bound foreground", fingerprint: other.fingerprint };
|
||||
const root = rows.find(row => row.pid === pid);
|
||||
// A wrapper's label is not an idle shell. Positive shell proof requires
|
||||
// the pane shell itself to own the foreground, with no receiving child.
|
||||
// A background child/helper in another group does not receive terminal input.
|
||||
if (new Set(rows.map(row => row.pid)).size === rows.length && root?.startedAt
|
||||
&& root.pgid === pid && root.tpgid === pid
|
||||
&& isShellForeground(executableName(root.executableName ?? ""))
|
||||
&& isShellForeground(executableName(tokens(root.command)[0]?.replace(/^-/, "") ?? ""))
|
||||
&& !rows.some(row => row.pid !== pid && row.pgid === root.tpgid)) {
|
||||
return { state: "idle_shell", detail: "The bound foreground is an idle shell with no receiving child", fingerprint: JSON.stringify(root) };
|
||||
}
|
||||
return unknown;
|
||||
} catch { return unknown; }
|
||||
};
|
||||
const first = await sample();
|
||||
const second = await sample();
|
||||
if (first.state === "conflict") return first;
|
||||
if (second.state === "conflict") return second;
|
||||
// An unavailable sample cannot erase a positive idle-shell refusal.
|
||||
if (first.state === "idle_shell" && second.state === "unknown") return first;
|
||||
if (second.state === "idle_shell" && first.state === "unknown") return second;
|
||||
if (first.fingerprint && second.fingerprint && first.fingerprint !== second.fingerprint) {
|
||||
return { state: "conflict", detail: "The observed foreground process changed during delivery verification" };
|
||||
}
|
||||
return first.state === second.state && first.fingerprint && first.fingerprint === second.fingerprint ? second : unknown;
|
||||
}
|
||||
@@ -10,8 +10,6 @@ export interface NativeResumeProbeInput {
|
||||
runtime: string | null;
|
||||
paneCommand: string | null;
|
||||
paneContent: string | null;
|
||||
/** Only the managed adapter supplies this after exact, stable native-process proof. */
|
||||
claudeAutoIdentityVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface NativeResumeProbeResult {
|
||||
@@ -133,12 +131,6 @@ export function assessNativeResumeProbe(
|
||||
detail: "Claude is the active foreground process in the probe pane.",
|
||||
};
|
||||
}
|
||||
if (/(^|\n)\s*❯/.test(paneContent)
|
||||
&& /^[ \t]*⏵⏵ auto mode on \(shift\+tab to cycle\)(?:[ \t]+·[^\r\n]*)?[ \t]*$/m.test(paneContent)) {
|
||||
return input.claudeAutoIdentityVerified
|
||||
? { status: "resumed", code: "active_runtime", detail: "Claude auto-mode TUI and the exact managed native identity were verified." }
|
||||
: { status: "inconclusive", code: "claude_auto_identity_required", detail: "Auto-mode screen text requires proof of the launched Claude identity." };
|
||||
}
|
||||
if (SHELL_COMMANDS.has(paneCommand)) {
|
||||
return {
|
||||
status: "failed",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { existsSync, accessSync, constants } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import { getCompatibleOpenRigPath } from "../openrig-compat.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -113,14 +112,12 @@ export interface StartupContextResolvedFile {
|
||||
required: boolean;
|
||||
path?: string | null;
|
||||
deliveryHint?: string | null;
|
||||
ownerRoot?: string | null;
|
||||
}
|
||||
|
||||
export interface StartupContextProjectionEntry {
|
||||
absolutePath: string;
|
||||
effectiveId?: string | null;
|
||||
category?: string | null;
|
||||
sourcePath?: string | null;
|
||||
}
|
||||
|
||||
export type StartupContextProbeResult =
|
||||
@@ -854,21 +851,7 @@ export class RestoreCheckService {
|
||||
break;
|
||||
}
|
||||
|
||||
// #261: inspect (and report) the paths replay will actually use — recognized built-in startup
|
||||
// files and shipped-spec projection resources follow the running install.
|
||||
const startupContext = {
|
||||
...probe,
|
||||
resolvedStartupFiles: probe.resolvedStartupFiles.map((file) => (
|
||||
typeof file.path === "string" && typeof file.ownerRoot === "string"
|
||||
? reanchorBuiltinStartupFile({ ...file, path: file.path, ownerRoot: file.ownerRoot }, undefined, undefined, this.deps.exists)
|
||||
: file
|
||||
)),
|
||||
projectionEntries: probe.projectionEntries.map((entry) => (
|
||||
typeof entry.sourcePath === "string"
|
||||
? reanchorShippedProjectionEntry({ ...entry, sourcePath: entry.sourcePath }, undefined, this.deps.exists)
|
||||
: entry
|
||||
)),
|
||||
};
|
||||
const startupContext = probe;
|
||||
const missingRequired = startupContext.resolvedStartupFiles.filter((file) => file.required && !this.deps.exists(file.absolutePath));
|
||||
const missingOptional = startupContext.resolvedStartupFiles.filter((file) => !file.required && !this.deps.exists(file.absolutePath));
|
||||
const missingProjectionEntries = startupContext.projectionEntries.filter((entry) => !this.deps.exists(entry.absolutePath));
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { CodexResumeAdapter } from "../adapters/codex-resume.js";
|
||||
import type { PiResumeAdapter } from "../adapters/pi-resume.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { assessNativeResumeProbe } from "./native-resume-probe.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
import type {
|
||||
RestoreOutcome,
|
||||
RestoreRigResult,
|
||||
@@ -696,9 +695,7 @@ export class RestoreOrchestrator {
|
||||
else consumesReplay = true;
|
||||
}
|
||||
|
||||
for (const storedFile of consumesReplay ? startupCtx.resolvedStartupFiles ?? [] : []) {
|
||||
// Validate the file replay will actually deliver (#261: built-ins follow the running install).
|
||||
const file = reanchorBuiltinStartupFile(storedFile, undefined, undefined, exists);
|
||||
for (const file of consumesReplay ? startupCtx.resolvedStartupFiles ?? [] : []) {
|
||||
if (!file.required) {
|
||||
if (this.pathLike(file.absolutePath) && !exists(file.absolutePath)) {
|
||||
warnings.push(`Restore pre-validation: optional startup file missing for ${node.logicalId}: ${file.absolutePath}`);
|
||||
@@ -740,8 +737,7 @@ export class RestoreOrchestrator {
|
||||
// gate from blocking the attempt entirely. Missing REQUIRED startup
|
||||
// files and genuinely-fatal blockers (malformed snapshot, missing nodes)
|
||||
// stay critical above.
|
||||
for (const storedEntry of startupCtx.projectionEntries ?? []) {
|
||||
const entry = reanchorShippedProjectionEntry(storedEntry, undefined, exists);
|
||||
for (const entry of startupCtx.projectionEntries ?? []) {
|
||||
if (this.pathLike(entry.sourcePath) && !exists(entry.sourcePath)) {
|
||||
warnings.push(`projection_drift: source root missing for ${node.logicalId}: ${entry.sourcePath} (projection will be skipped at startup; session continuity is unaffected)`);
|
||||
}
|
||||
@@ -1307,9 +1303,8 @@ export class RestoreOrchestrator {
|
||||
if (adapter) {
|
||||
// Prefilter: check which files/entries still exist
|
||||
const existsFn = opts.fsOps?.exists ?? (() => true);
|
||||
const sourceEntries = replayContained ? [] : startupCtx.projectionEntries.map((e) => reanchorShippedProjectionEntry(e, undefined, existsFn));
|
||||
// #261: recognized built-in startup files follow the running install.
|
||||
const sourceFiles = replayContained ? [] : startupCtx.resolvedStartupFiles.map((f) => reanchorBuiltinStartupFile(f, undefined, undefined, existsFn));
|
||||
const sourceEntries = replayContained ? [] : startupCtx.projectionEntries;
|
||||
const sourceFiles = replayContained ? [] : startupCtx.resolvedStartupFiles;
|
||||
const sourceActions = replayContained ? [] : startupCtx.startupActions;
|
||||
const filteredEntries = sourceEntries.filter((e) => {
|
||||
if (!existsFn(e.absolutePath)) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { RuntimeAdapter, ResolvedStartupFile } from "./runtime-adapter.js";
|
||||
import type { ProjectionEntry, ProjectionPlan } from "./projection-planner.js";
|
||||
import type { StartupAction } from "./types.js";
|
||||
import { resolveStartupProof } from "./startup-resolver.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
import type { OccupantInvalidator } from "./occupant-invalidator.js";
|
||||
import { rebindAndVerifyPaneIdentity } from "./seat-attention-reconciler.js";
|
||||
import { observeSolePane } from "./pane-binding-observation.js";
|
||||
@@ -551,11 +550,6 @@ export class SeatLifecycleService {
|
||||
input.operator,
|
||||
);
|
||||
if (!stopped.ok) return stopped;
|
||||
// Stopping the server's last session ends tmux's server, and every probe
|
||||
// below would then be transport_unavailable, never absence. Restore an
|
||||
// empty server (no session is invented; a no-op while the server is up)
|
||||
// so they get a positive answer. The classified probes still decide.
|
||||
await this.tmuxAdapter.startServer();
|
||||
}
|
||||
|
||||
// Reuse clean's exhaustive, positive-absence gate for stale/history rows.
|
||||
@@ -848,8 +842,7 @@ export class SeatLifecycleService {
|
||||
// S04 owns the live ambient skill set. Replaying the older catalog
|
||||
// selection here could reinstall a skill that work-install removed.
|
||||
if (raw["category"] === "skill") continue;
|
||||
// #261: shipped-spec resources follow the running install.
|
||||
entries.push(reanchorShippedProjectionEntry({
|
||||
entries.push({
|
||||
category: raw["category"],
|
||||
effectiveId: raw["effectiveId"],
|
||||
sourceSpec: raw["sourceSpec"],
|
||||
@@ -861,7 +854,7 @@ export class SeatLifecycleService {
|
||||
...(typeof raw["mergeStrategy"] === "string" ? { mergeStrategy: raw["mergeStrategy"] as ProjectionEntry["mergeStrategy"] } : {}),
|
||||
...(typeof raw["target"] === "string" ? { target: raw["target"] } : {}),
|
||||
...(typeof raw["pluginType"] === "string" ? { pluginType: raw["pluginType"] as ProjectionEntry["pluginType"] } : {}),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedStartupFiles: ResolvedStartupFile[] = [];
|
||||
@@ -874,8 +867,7 @@ export class SeatLifecycleService {
|
||||
|| !isOptionalOneOf(raw["kind"], ["file"] as const)) {
|
||||
return this.malformedStartupContext(nodeId, "resolved_files_json contains an invalid entry");
|
||||
}
|
||||
// #261: recognized built-in startup files follow the running install.
|
||||
resolvedStartupFiles.push(reanchorBuiltinStartupFile({
|
||||
resolvedStartupFiles.push({
|
||||
path: raw["path"],
|
||||
absolutePath: raw["absolutePath"],
|
||||
ownerRoot: raw["ownerRoot"],
|
||||
@@ -883,7 +875,7 @@ export class SeatLifecycleService {
|
||||
required: raw["required"],
|
||||
appliesOn: raw["appliesOn"],
|
||||
...(raw["kind"] === "file" ? { kind: "file" as const } : {}),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const startupActions: StartupAction[] = [];
|
||||
|
||||
@@ -11,7 +11,7 @@ import { wrapPaneEnvelope, appendDeliveredSegment, type EnvelopeScope } from "..
|
||||
import { getSelfHostId } from "./hosts/fanout-contract.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { observeClaudeDelivery, verifyCodexPaneProcess, type ClaudeDeliveryObservation, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { verifyClaudePaneProcess, verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
|
||||
|
||||
@@ -955,22 +955,6 @@ export class SessionTransport {
|
||||
let preVerifyContent: string | null = null;
|
||||
const sessionMeta = this.getSessionMeta(sessionName);
|
||||
const runtime = sessionMeta.runtime;
|
||||
let runtimeAdvisory: string | undefined;
|
||||
const bindingChanged = () => JSON.stringify(this.getSessionMeta(sessionName)) !== JSON.stringify(sessionMeta);
|
||||
const changedRecipient = (sent = false): SendResult => ({ ok: false, sessionName, sent, reason: "target_runtime_conflict",
|
||||
error: sent ? "Recipient binding changed after paste; Enter was not sent." : "Recipient binding changed; no text was sent." });
|
||||
const checkClaudeTarget = async (): Promise<SendResult | null> => {
|
||||
if (runtime !== "claude-code") return null;
|
||||
if (bindingChanged()) return changedRecipient();
|
||||
const observation = await this.claudeDeliveryObservation(sessionName, sessionMeta.pane, sessionMeta.resumeToken);
|
||||
if (bindingChanged()) return changedRecipient();
|
||||
if (observation.state === "idle_shell" || observation.state === "conflict") {
|
||||
return { ok: false, sessionName, sent: false, reason: observation.state === "idle_shell" ? "target_runtime_not_running" : "target_runtime_conflict",
|
||||
error: `Refused: ${observation.detail}. No text was sent.` };
|
||||
}
|
||||
if (observation.state === "unknown") runtimeAdvisory = `runtime: ${observation.detail}; delivery proceeds without verified native identity.`;
|
||||
return null;
|
||||
};
|
||||
// S01/S02 P2 observation context, frozen at attempt entry before any await.
|
||||
const observed = this.captureObserver ? {
|
||||
attemptId: randomUUID(),
|
||||
@@ -980,7 +964,6 @@ export class SessionTransport {
|
||||
sentHash: null as string | null,
|
||||
} : null;
|
||||
const observe = (result: SendResult): SendResult => {
|
||||
if (result.ok && runtimeAdvisory) result = { ...result, warning: [runtimeAdvisory, result.warning].filter(Boolean).join(" ") };
|
||||
if (observed && this.captureObserver) {
|
||||
safeRecord(this.captureObserver, {
|
||||
seam: "send_verify",
|
||||
@@ -1010,9 +993,9 @@ export class SessionTransport {
|
||||
}
|
||||
|
||||
// #142 — a shell label may be an idle shell or a managed launch wrapper.
|
||||
// Non-Claude runtimes retain their existing proof requirement. Claude ordinary
|
||||
// delivery applies its distinct uncertainty policy at the input boundary below.
|
||||
const unverifiedShell = runtime && runtime !== "terminal" && runtime !== "claude-code"
|
||||
// Only positive native process proof clears the refusal, but missing proof
|
||||
// does not establish that the runtime stopped. Terminal/unreadable behavior is unchanged.
|
||||
const unverifiedShell = runtime && runtime !== "terminal"
|
||||
? await this.unverifiedShellForeground(sessionName, runtime, sessionMeta.pane, sessionMeta.resumeToken) : null;
|
||||
if (unverifiedShell) {
|
||||
return observe({
|
||||
@@ -1179,8 +1162,6 @@ export class SessionTransport {
|
||||
error: `submitOnly refused: the pane of '${sessionName}' does not show the expected staged text — pressing Enter here could drive something else entirely. Nothing was submitted.`,
|
||||
};
|
||||
}
|
||||
const targetFailure = await checkClaudeTarget();
|
||||
if (targetFailure) return targetFailure;
|
||||
const submitResult = await this.runStage(
|
||||
"session_transport.submit",
|
||||
() => this.tmuxAdapter.sendKeys(sessionName, ["C-m"]),
|
||||
@@ -1189,7 +1170,7 @@ export class SessionTransport {
|
||||
if (!submitResult.ok) {
|
||||
return { ok: false, sessionName, reason: "submit_failed", outcome: "failed", error: `submitOnly: Enter did not land on '${sessionName}': ${submitResult.message}` };
|
||||
}
|
||||
return observe({ ok: true, sessionName, outcome: "rendered-unconfirmed", submitOnly: true });
|
||||
return { ok: true, sessionName, outcome: "rendered-unconfirmed", submitOnly: true };
|
||||
}
|
||||
|
||||
if (waitForIdleMs !== undefined) {
|
||||
@@ -1323,10 +1304,6 @@ export class SessionTransport {
|
||||
text = appendDeliveredSegment(text, this.now().getTime() - Date.parse(opts.stampISO));
|
||||
}
|
||||
|
||||
// Recheck the selected recipient after readiness/capture awaits, at the input boundary.
|
||||
const targetFailure = await checkClaudeTarget();
|
||||
if (targetFailure) return observe(targetFailure);
|
||||
|
||||
// 3. Send text (paste)
|
||||
if (observed) observed.sentHash = hashSentText(text);
|
||||
const textResult = await this.runStage(
|
||||
@@ -1348,8 +1325,6 @@ export class SessionTransport {
|
||||
// 4. Wait 200ms (spike-proven delay)
|
||||
await this.sleep(200);
|
||||
|
||||
if (runtime === "claude-code" && bindingChanged()) return observe(changedRecipient(true));
|
||||
|
||||
// 5. Submit (C-m)
|
||||
const submitResult = await this.runStage(
|
||||
"session_transport.submit",
|
||||
@@ -1466,27 +1441,6 @@ export class SessionTransport {
|
||||
}
|
||||
}
|
||||
|
||||
private async claudeDeliveryObservation(sessionName: string, pane: string | null, resumeToken: string | null): Promise<ClaudeDeliveryObservation> {
|
||||
const unknown = { state: "unknown" as const, detail: "Claude runtime observation or older launch binding is unavailable" };
|
||||
try {
|
||||
const panes = await this.tmuxAdapter.listPanes(sessionName);
|
||||
if (panes.length > 1 || (pane && panes.length === 1 && panes[0]!.id !== pane)) {
|
||||
return { state: "conflict", detail: "The session does not have the single expected bound pane" };
|
||||
}
|
||||
if (!pane || panes.length === 0) return unknown;
|
||||
const [sessionPid, panePid] = await Promise.all([this.tmuxAdapter.getPanePid(sessionName), this.tmuxAdapter.getPanePid(pane)]);
|
||||
if (sessionPid && panePid && sessionPid !== panePid) return { state: "conflict", detail: "The session and bound pane name different processes" };
|
||||
const observation = await observeClaudeDelivery({ target: pane, tmux: this.tmuxAdapter, listProcesses: this.listProcesses, expectedToken: resumeToken });
|
||||
// Refusal already has positive evidence; a later failed read cannot erase it.
|
||||
if (observation.state === "conflict" || observation.state === "idle_shell") return observation;
|
||||
const after = await this.tmuxAdapter.listPanes(sessionName);
|
||||
if (after.length > 1 || (after.length === 1 && after[0]!.id !== pane)) return { state: "conflict", detail: "The bound pane changed during delivery verification" };
|
||||
const currentPid = await this.tmuxAdapter.getPanePid(pane);
|
||||
if (panePid && currentPid && panePid !== currentPid) return { state: "conflict", detail: "The bound pane process changed during delivery verification" };
|
||||
return after.length === 0 || !sessionPid || !panePid || !currentPid ? unknown : observation;
|
||||
} catch { return unknown; }
|
||||
}
|
||||
|
||||
/** Shell label without positive native proof; not proof of an idle shell or stopped agent.
|
||||
* Null when no shell label is observed, or the expected native process is verified. */
|
||||
private async unverifiedShellForeground(sessionName: string, runtime: string, pane: string | null, resumeToken: string | null): Promise<string | null> {
|
||||
@@ -1497,11 +1451,12 @@ export class SessionTransport {
|
||||
return null;
|
||||
}
|
||||
if (!paneCommand || !isShellForeground(paneCommand)) return null;
|
||||
if (runtime === "codex" && pane) {
|
||||
// Reuse stable, foreground, pane-descendant Codex proof. A resumed process
|
||||
// must name this session's token. Stale UI, a Node
|
||||
if ((runtime === "codex" || runtime === "claude-code") && pane) {
|
||||
// Reuse stable, foreground, pane-descendant proof. Claude fresh/resume and
|
||||
// Codex resume must name this session's token. Stale UI, a Node
|
||||
// launcher alone, missing observations or a native process elsewhere cannot clear it.
|
||||
const native = await verifyCodexPaneProcess({ target: sessionName, tmux: this.tmuxAdapter,
|
||||
const verify = runtime === "codex" ? verifyCodexPaneProcess : verifyClaudePaneProcess;
|
||||
const native = await verify({ target: sessionName, tmux: this.tmuxAdapter,
|
||||
listProcesses: this.listProcesses, expectedToken: resumeToken });
|
||||
if (native && await this.tmuxAdapter.getPanePid(pane).catch(() => null) === native.panePid) return null;
|
||||
}
|
||||
|
||||
@@ -114,8 +114,6 @@ export const SETTINGS_VALID_KEYS = [
|
||||
"ui.preview.max_pins",
|
||||
"ui.preview.default_lines",
|
||||
"ui.timezone",
|
||||
// The web UI and its terminal WebSocket are off unless this is true (read at daemon start).
|
||||
"ui.enabled",
|
||||
// OPR.0.4.0.1 — global cap on simultaneously-live terminals (default 2).
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
@@ -237,7 +235,6 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
|
||||
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
|
||||
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"ui.terminal.max_live_terminals": { primary: "OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
@@ -319,7 +316,6 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.timezone": ["ui", "timezone"],
|
||||
"ui.enabled": ["ui", "enabled"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"ui.terminal.max_live_terminals": ["ui", "terminal", "maxLiveTerminals"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
@@ -576,7 +572,6 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
case "ui.preview.max_pins": return 4;
|
||||
case "ui.preview.default_lines": return 50;
|
||||
case "ui.timezone": return "America/Los_Angeles";
|
||||
case "ui.enabled": return false;
|
||||
case "recovery.auto_drive_provider_prompts": return false;
|
||||
case "recovery.provider_auth_env_allowlist": return "";
|
||||
// V1 Phase 4 — Advisor default per universal-shell.md L83;
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
import { promises as dnsPromises } from "node:dns";
|
||||
import { isIP } from "node:net";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { constantTimeEqual, detectTailscaleInterface } from "./auth-bearer-token.js";
|
||||
|
||||
/**
|
||||
* Browser boundary for /api/* (0.6.4). Runs once per request, before the remote
|
||||
* read-through and every route, WebSocket upgrades included:
|
||||
*
|
||||
* 1. Target name (Host). Accepted: `localhost`, IP literals, this machine's own
|
||||
* names (OS hostname, and its exact Tailscale MagicDNS name found by a reverse
|
||||
* lookup of its own tailnet IP at Tailscale's resolver) and OPENRIG_ALLOWED_HOSTS.
|
||||
* A valid configured bearer token in Authorization waives only this check.
|
||||
* 2. Browser origin (Origin), when present. Accepted: the daemon's own UI origin
|
||||
* while `ui.enabled` is on, or an entry in OPENRIG_ALLOWED_ORIGINS. Nothing
|
||||
* waives this check.
|
||||
*
|
||||
* Requests without Origin (CLI, TUI, MCP, daemon-to-daemon) meet only rule 1.
|
||||
* This is not complete browser isolation: a cross-site GET/HEAD that carries no
|
||||
* Origin and is addressed to an accepted name still reaches its handler.
|
||||
*/
|
||||
|
||||
export interface BrowserBoundaryOptions {
|
||||
webUiEnabled: boolean;
|
||||
/** Configured bearer tokens; a matching Authorization header waives only the target-name check. */
|
||||
bearerTokens: Array<string | null | undefined>;
|
||||
/** OPENRIG_ALLOWED_ORIGINS: browser origins only. */
|
||||
allowedOrigins?: string;
|
||||
/** OPENRIG_ALLOWED_HOSTS: target names only. */
|
||||
allowedHosts?: string;
|
||||
/** Returns this machine's own extra names (Tailscale MagicDNS). Absent: no discovery. */
|
||||
discoverSelfNames?: () => Promise<string[]>;
|
||||
discoveryTimeoutMs?: number;
|
||||
/** Minimum gap between lookups triggered by unrecognized names. */
|
||||
rediscoverAfterMs?: number;
|
||||
hostName?: () => string;
|
||||
warn?: (line: string) => void;
|
||||
now?: () => number;
|
||||
/** Test hook: one call per guarded request. */
|
||||
onDecision?: (decision: { outcome: "allow" | "refuse"; code?: BoundaryCode }) => void;
|
||||
}
|
||||
|
||||
export type BoundaryCode = "untrusted_host" | "browser_origin_refused";
|
||||
|
||||
export type ParsedHost =
|
||||
| { kind: "absent" }
|
||||
| { kind: "malformed" }
|
||||
| { kind: "ok"; hostname: string; port: string };
|
||||
|
||||
export type ParsedOrigin =
|
||||
| { kind: "opaque" }
|
||||
| { kind: "malformed" }
|
||||
| { kind: "ok"; scheme: string; hostname: string; port: string };
|
||||
|
||||
const HOST_HEADER = /^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+)(?::(\d{1,5}))?$/;
|
||||
const DNS_NAME = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
||||
const TAILSCALE_RESOLVER = "100.100.100.100";
|
||||
const MAX_LOGGED_REFUSALS = 20;
|
||||
|
||||
/** Lowercase, drop IPv6 brackets and one trailing dot. */
|
||||
export function normalizeName(name: string): string {
|
||||
let out = name.trim().toLowerCase();
|
||||
if (out.startsWith("[") && out.endsWith("]")) out = out.slice(1, -1);
|
||||
if (out.endsWith(".")) out = out.slice(0, -1);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseHostHeader(value: string | undefined): ParsedHost {
|
||||
if (value === undefined) return { kind: "absent" };
|
||||
const match = HOST_HEADER.exec(value);
|
||||
if (!match) return { kind: "malformed" };
|
||||
const rawName = match[1]!;
|
||||
if (rawName.startsWith("[") && isIP(rawName.slice(1, -1)) !== 6) return { kind: "malformed" };
|
||||
const port = match[2] ?? "80";
|
||||
if (Number(port) < 1 || Number(port) > 65535) return { kind: "malformed" };
|
||||
const hostname = normalizeName(rawName);
|
||||
if (!hostname) return { kind: "malformed" };
|
||||
return { kind: "ok", hostname, port: String(Number(port)) };
|
||||
}
|
||||
|
||||
export function parseOrigin(value: string): ParsedOrigin {
|
||||
if (value === "null") return { kind: "opaque" };
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return { kind: "malformed" };
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) {
|
||||
return { kind: "malformed" };
|
||||
}
|
||||
// An origin is scheme://host[:port]; anything after the authority is not one.
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\/[^/?#]+\/?$/i.test(value)) return { kind: "malformed" };
|
||||
const scheme = url.protocol.slice(0, -1).toLowerCase();
|
||||
const port = url.port || (scheme === "https" ? "443" : scheme === "http" ? "80" : "");
|
||||
return { kind: "ok", scheme, hostname: normalizeName(url.hostname), port };
|
||||
}
|
||||
|
||||
function isLoopbackOrIp(hostname: string): boolean {
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
/** The OS hostname, its first label, and `<label>.local`. */
|
||||
export function osOwnNames(hostName: () => string = osHostname): string[] {
|
||||
const full = normalizeName(hostName() || "");
|
||||
if (!full) return [];
|
||||
const label = full.split(".")[0]!;
|
||||
return [...new Set([full, label, `${label}.local`])];
|
||||
}
|
||||
|
||||
function namesWithShortForm(names: string[]): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const raw of names) {
|
||||
const name = normalizeName(raw);
|
||||
if (!DNS_NAME.test(name)) continue;
|
||||
out.add(name);
|
||||
out.add(name.split(".")[0]!);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* This machine's exact Tailscale MagicDNS name: a reverse lookup of its own tailnet IP
|
||||
* (found from local interfaces), asked of Tailscale's resolver only. One query, bounded
|
||||
* by `timeoutMs`; no subprocess, no peer list. Returns [] when no tailnet IP is active.
|
||||
*/
|
||||
export async function discoverTailscaleSelfNames(opts: {
|
||||
timeoutMs: number;
|
||||
tailscaleIp?: () => string | null;
|
||||
reverse?: (ip: string, timeoutMs: number) => Promise<string[]>;
|
||||
}): Promise<string[]> {
|
||||
const ip = (opts.tailscaleIp ?? detectTailscaleInterface)();
|
||||
if (!ip) return [];
|
||||
const reverse = opts.reverse ?? (async (address: string, timeoutMs: number) => {
|
||||
const resolver = new dnsPromises.Resolver({ timeout: timeoutMs, tries: 1 });
|
||||
resolver.setServers([TAILSCALE_RESOLVER]);
|
||||
return resolver.reverse(address);
|
||||
});
|
||||
return namesWithShortForm(await reverse(ip, opts.timeoutMs));
|
||||
}
|
||||
|
||||
function parseAllowedHosts(raw: string | undefined): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const item of (raw ?? "").split(",")) {
|
||||
const name = normalizeName(item);
|
||||
if (name && (DNS_NAME.test(name) || isIP(name) !== 0)) out.add(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** OPENRIG_ALLOWED_ORIGINS: a full `http(s)://…` entry matches that exact origin;
|
||||
* any other `scheme://…` entry matches the identical string; a bare hostname matches
|
||||
* that hostname on any scheme and port. */
|
||||
function parseAllowedOrigins(raw: string | undefined): { origins: Set<string>; hostnames: Set<string>; exact: Set<string> } {
|
||||
const origins = new Set<string>();
|
||||
const hostnames = new Set<string>();
|
||||
const exact = new Set<string>();
|
||||
for (const item of (raw ?? "").split(",")) {
|
||||
const entry = item.trim().toLowerCase();
|
||||
if (!entry) continue;
|
||||
if (entry.startsWith("http://") || entry.startsWith("https://")) {
|
||||
const parsed = parseOrigin(entry.replace(/\/$/, ""));
|
||||
if (parsed.kind === "ok") origins.add(`${parsed.scheme}://${parsed.hostname}:${parsed.port}`);
|
||||
} else if (entry.includes("://")) {
|
||||
exact.add(entry);
|
||||
} else {
|
||||
hostnames.add(normalizeName(entry));
|
||||
}
|
||||
}
|
||||
return { origins, hostnames, exact };
|
||||
}
|
||||
|
||||
function shown(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const clean = value.replace(/[\u0000-\u001f\u007f]/g, "?");
|
||||
return clean.length > 200 ? `${clean.slice(0, 200)}…` : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request header as the client sent it. Node adapters expose the original request as
|
||||
* `c.env.incoming`, whose `rawHeaders` keep duplicates and empty values; @hono/node-ws builds
|
||||
* its Request from the collapsed `incoming.headers`, which keep only the first duplicate and
|
||||
* drop empty values. Returns `undefined` when absent and `null` when duplicated (ambiguous).
|
||||
* Without a Node request (tests, other adapters) it falls back to the Request headers.
|
||||
*/
|
||||
export function rawHeader(c: Context, name: "host" | "origin"): string | undefined | null {
|
||||
const raw = (c.env as { incoming?: { rawHeaders?: unknown } } | undefined)?.incoming?.rawHeaders;
|
||||
if (!Array.isArray(raw)) return c.req.header(name);
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i + 1 < raw.length; i += 2) {
|
||||
if (String(raw[i]).toLowerCase() === name) values.push(String(raw[i + 1]));
|
||||
}
|
||||
if (values.length === 0) return undefined;
|
||||
return values.length === 1 ? values[0]! : null;
|
||||
}
|
||||
|
||||
function bearerFrom(header: string | undefined): string | null {
|
||||
if (!header) return null;
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||
const token = match?.[1]?.trim();
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
export function browserBoundary(options: BrowserBoundaryOptions): MiddlewareHandler {
|
||||
const now = options.now ?? Date.now;
|
||||
const warn = options.warn ?? ((line: string) => console.warn(line));
|
||||
const timeoutMs = options.discoveryTimeoutMs ?? 1500;
|
||||
const rediscoverAfterMs = options.rediscoverAfterMs ?? 10_000;
|
||||
const tokens = options.bearerTokens.filter((t): t is string => typeof t === "string" && t.length > 0);
|
||||
const allowedHosts = parseAllowedHosts(options.allowedHosts);
|
||||
const allowedOrigins = parseAllowedOrigins(options.allowedOrigins);
|
||||
const ownNames = new Set(osOwnNames(options.hostName));
|
||||
|
||||
let discovered = new Set<string>();
|
||||
let lastDiscoveryAt = Number.NEGATIVE_INFINITY;
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
const discover = (): Promise<void> => {
|
||||
const source = options.discoverSelfNames;
|
||||
if (!source) return Promise.resolve();
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const names = await Promise.race([
|
||||
source(),
|
||||
new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), timeoutMs + 250); }),
|
||||
]);
|
||||
// A completed lookup replaces the set; a failed one keeps the previous names.
|
||||
discovered = new Set(namesWithShortForm(names));
|
||||
} catch {
|
||||
// Unavailable, malformed or slow discovery never blocks loopback/IP use.
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
lastDiscoveryAt = now();
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
};
|
||||
if (options.discoverSelfNames) void discover();
|
||||
|
||||
const knownTarget = (hostname: string): boolean =>
|
||||
isLoopbackOrIp(hostname) || ownNames.has(hostname) || discovered.has(hostname) || allowedHosts.has(hostname);
|
||||
|
||||
const acceptedTarget = async (hostname: string): Promise<boolean> => {
|
||||
if (knownTarget(hostname)) return true;
|
||||
if (options.discoverSelfNames && (inflight || now() - lastDiscoveryAt >= rediscoverAfterMs)) {
|
||||
await discover();
|
||||
return knownTarget(hostname);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const explicitlyAllowedOrigin = (raw: string, origin: ParsedOrigin): boolean => {
|
||||
if (allowedOrigins.exact.has(raw.trim().toLowerCase())) return true;
|
||||
if (origin.kind !== "ok") return false;
|
||||
if (allowedOrigins.hostnames.has(origin.hostname)) return true;
|
||||
return allowedOrigins.origins.has(`${origin.scheme}://${origin.hostname}:${origin.port}`);
|
||||
};
|
||||
|
||||
const logged = new Set<string>();
|
||||
let suppressionLogged = false;
|
||||
const logRefusal = (c: Context, code: BoundaryCode, value: string): void => {
|
||||
const key = `${code}|${value}`;
|
||||
if (logged.has(key)) return;
|
||||
if (logged.size >= MAX_LOGGED_REFUSALS) {
|
||||
if (!suppressionLogged) {
|
||||
suppressionLogged = true;
|
||||
warn("[openrig] browser boundary: further distinct refusals are not logged");
|
||||
}
|
||||
return;
|
||||
}
|
||||
logged.add(key);
|
||||
warn(`[openrig] browser boundary refused ${code}: ${value} (${c.req.method} ${shown(c.req.path)})`);
|
||||
};
|
||||
|
||||
const refuse = (c: Context, code: BoundaryCode, value: string, error: string) => {
|
||||
logRefusal(c, code, value);
|
||||
options.onDecision?.({ outcome: "refuse", code });
|
||||
return c.json({ error, code }, 403);
|
||||
};
|
||||
|
||||
return async (c, next) => {
|
||||
const hostHeader = rawHeader(c, "host");
|
||||
// A duplicated (ambiguous) Host is malformed; an explicitly empty one fails parsing.
|
||||
const host = hostHeader === null ? { kind: "malformed" as const } : parseHostHeader(hostHeader);
|
||||
let hostAccepted = false;
|
||||
if (host.kind === "malformed") {
|
||||
const value = shown(hostHeader === null ? "(duplicate Host)" : hostHeader ?? "");
|
||||
return refuse(c, "untrusted_host", value,
|
||||
`This OpenRig daemon refused a request with an unusable Host header ("${value}"). Address the daemon by localhost, an IP address or this machine's own name.`);
|
||||
}
|
||||
if (host.kind === "ok") {
|
||||
hostAccepted = await acceptedTarget(host.hostname);
|
||||
if (!hostAccepted) {
|
||||
const presented = bearerFrom(c.req.header("Authorization"));
|
||||
const tokenValid = presented !== null && tokens.some((token) => constantTimeEqual(presented, token));
|
||||
if (!tokenValid) {
|
||||
const value = shown(host.hostname);
|
||||
return refuse(c, "untrusted_host", value,
|
||||
`This OpenRig daemon does not accept requests addressed to "${value}". Use localhost, an IP address or this machine's own name, or add this name to OPENRIG_ALLOWED_HOSTS in the daemon's environment and restart the daemon.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawOrigin = rawHeader(c, "origin");
|
||||
// A duplicated Origin is malformed; an explicitly empty one is present and fails parsing.
|
||||
const originHeader = rawOrigin === null ? "(duplicate Origin)" : rawOrigin;
|
||||
if (originHeader !== undefined) {
|
||||
const origin = rawOrigin === null ? { kind: "malformed" as const } : parseOrigin(originHeader);
|
||||
const ownUi = options.webUiEnabled && origin.kind === "ok" && origin.scheme === "http"
|
||||
&& host.kind === "ok" && hostAccepted
|
||||
&& origin.hostname === host.hostname && origin.port === host.port;
|
||||
if (!ownUi && !explicitlyAllowedOrigin(originHeader, origin)) {
|
||||
const value = shown(originHeader);
|
||||
return refuse(c, "browser_origin_refused", value,
|
||||
`This OpenRig daemon refuses browser requests from origin "${value}". Open the OpenRig UI from the daemon's own address with the web UI enabled (rig config set ui.enabled true, then restart the daemon), or add this exact origin to OPENRIG_ALLOWED_ORIGINS in the daemon's environment and restart. Web pages on other origins cannot read this response.`);
|
||||
}
|
||||
}
|
||||
|
||||
options.onDecision?.({ outcome: "allow" });
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -108,7 +108,6 @@ function getStartupContext(db: Database.Database, nodeId: string): StartupContex
|
||||
required: candidate["required"] !== false,
|
||||
path: typeof candidate["path"] === "string" ? candidate["path"] : null,
|
||||
deliveryHint: typeof candidate["deliveryHint"] === "string" ? candidate["deliveryHint"] : null,
|
||||
ownerRoot: typeof candidate["ownerRoot"] === "string" ? candidate["ownerRoot"] : null,
|
||||
}];
|
||||
}),
|
||||
projectionEntries: projectionEntries.value.flatMap((entry) => {
|
||||
@@ -119,7 +118,6 @@ function getStartupContext(db: Database.Database, nodeId: string): StartupContex
|
||||
absolutePath: candidate["absolutePath"].trim(),
|
||||
effectiveId: typeof candidate["effectiveId"] === "string" ? candidate["effectiveId"] : null,
|
||||
category: typeof candidate["category"] === "string" ? candidate["category"] : null,
|
||||
sourcePath: typeof candidate["sourcePath"] === "string" ? candidate["sourcePath"] : null,
|
||||
}];
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -11,10 +11,31 @@ import {
|
||||
const MAX_EARLY_TERMINAL_FRAMES = 32;
|
||||
const MAX_EARLY_TERMINAL_FRAME_BYTES = 256 * 1024;
|
||||
|
||||
/** The WebSocket route's guard: the terminal bearer token when one is set. Origin and target name
|
||||
* are checked once for every /api request, upgrades included, by middleware/browser-boundary.ts. */
|
||||
export function terminalAuthMiddleware(opts: { bearerToken: string | null }) {
|
||||
return async (c: { req: { header(name: string): string | undefined; query(name: string): string | undefined }; json(data: unknown, status: number): unknown }, next: () => Promise<void>) => {
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: Parameters<typeof import("@hono/node-ws").createNodeWebSocket>[0] extends { app: infer _A } ? never : never,
|
||||
opts: { bearerToken: string | null },
|
||||
): void;
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: (createHandler: (c: unknown) => unknown) => unknown,
|
||||
opts: { bearerToken: string | null; livenessIntervalMs?: number },
|
||||
): void {
|
||||
const terminalAuthMiddleware = async (c: { req: { header(name: string): string | undefined; query(name: string): string | undefined }; json(data: unknown, status: number): unknown }, next: () => Promise<void>) => {
|
||||
const upgrade = c.req.header("Upgrade");
|
||||
if (upgrade?.toLowerCase() === "websocket") {
|
||||
const origin = c.req.header("Origin");
|
||||
if (origin) {
|
||||
try {
|
||||
const originHost = new URL(origin).hostname;
|
||||
const requestHost = c.req.header("Host")?.split(":")[0] ?? "";
|
||||
const allowed = originHost === requestHost || originHost === "localhost" || originHost === "127.0.0.1";
|
||||
if (!allowed) return c.json({ error: "origin_rejected", hint: `Origin ${origin} does not match host` }, 403);
|
||||
} catch {
|
||||
return c.json({ error: "origin_rejected", hint: "Malformed Origin header" }, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
const token = opts.bearerToken;
|
||||
if (!token) { await next(); return; }
|
||||
const header = c.req.header("Authorization") ?? c.req.header("authorization");
|
||||
@@ -26,26 +47,6 @@ export function terminalAuthMiddleware(opts: { bearerToken: string | null }) {
|
||||
if (queryToken && constantTimeEqual(queryToken.trim(), token)) { await next(); return; }
|
||||
return c.json({ error: "unauthorized", hint: "Pass terminal token via Authorization header or ?token= query" }, 401);
|
||||
};
|
||||
}
|
||||
|
||||
/** With the web UI off the WebSocket is not registered, but this same guard still runs in front of the HTTP
|
||||
* terminal routes it matched before (GET /api/terminal/views, /preview, /status), so their behaviour is unchanged. */
|
||||
export function registerTerminalAuthOnly(app: Hono, opts: { bearerToken: string | null }): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(app as any).get("/api/terminal/:sessionName", terminalAuthMiddleware(opts));
|
||||
}
|
||||
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: Parameters<typeof import("@hono/node-ws").createNodeWebSocket>[0] extends { app: infer _A } ? never : never,
|
||||
opts: { bearerToken: string | null },
|
||||
): void;
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: (createHandler: (c: unknown) => unknown) => unknown,
|
||||
opts: { bearerToken: string | null; livenessIntervalMs?: number },
|
||||
): void {
|
||||
const terminalAuth = terminalAuthMiddleware(opts);
|
||||
|
||||
// One daemon-owned broker registry shared across every WebSocket connection,
|
||||
// created lazily from the first connection's tmux adapter (a daemon
|
||||
@@ -62,7 +63,7 @@ export function registerTerminalWs(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(app as any).get(
|
||||
"/api/terminal/:sessionName",
|
||||
terminalAuth,
|
||||
terminalAuthMiddleware,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(upgradeWebSocket as any)((c: any) => {
|
||||
const sessionName = decodeURIComponent(c.req.param("sessionName")!);
|
||||
|
||||
@@ -115,7 +115,7 @@ import { scopesRoutes } from "./routes/scopes.js";
|
||||
import { telemetryRoutes } from "./routes/telemetry.js";
|
||||
import { proofRoutes } from "./routes/proof.js";
|
||||
import { scopeApproveRoutes } from "./routes/scope-approve.js";
|
||||
import { registerTerminalAuthOnly, registerTerminalWs } from "./routes/terminal-ws.js";
|
||||
import { registerTerminalWs } from "./routes/terminal-ws.js";
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import { steeringRoutes } from "./routes/steering.js";
|
||||
import { healthSummaryRoutes } from "./routes/health-summary.js";
|
||||
@@ -140,7 +140,6 @@ import { envRoutes } from "./routes/env.js";
|
||||
import type { RigLifecycleService } from "./domain/rig-lifecycle-service.js";
|
||||
import { seatRoutes } from "./routes/seat.js";
|
||||
import { createRouteTimingMiddleware } from "./domain/route-timing-recorder.js";
|
||||
import { browserBoundary, type BrowserBoundaryOptions } from "./middleware/browser-boundary.js";
|
||||
|
||||
export interface AppDeps {
|
||||
proofSourceWatch?: import("./domain/proof/source-watch.js").ProofSourceWatch;
|
||||
@@ -292,13 +291,6 @@ export interface AppDeps {
|
||||
missionControlBearerToken?: string | null;
|
||||
terminalBearerToken?: string | null;
|
||||
enableNodeWebSocket?: boolean;
|
||||
/** `ui.enabled`: serve the web UI pages and its terminal WebSocket. Off unless true; /api routes are unaffected. */
|
||||
webUiEnabled?: boolean;
|
||||
/** This machine's own extra names for the /api browser boundary (createDaemon wires the
|
||||
* Tailscale MagicDNS self-name lookup). Absent: loopback, IP literals and OS names only. */
|
||||
selfNameDiscovery?: () => Promise<string[]>;
|
||||
/** Test hook: one call per /api browser-boundary decision. */
|
||||
browserBoundaryObserver?: BrowserBoundaryOptions["onDecision"];
|
||||
specReviewService?: SpecReviewService;
|
||||
specLibraryService?: SpecLibraryService;
|
||||
/**
|
||||
@@ -403,9 +395,6 @@ function resolveDefaultUiDistDir(): string {
|
||||
return nodePath.resolve(nodePath.dirname(fileURLToPath(import.meta.url)), "..", "..", "ui", "dist");
|
||||
}
|
||||
|
||||
export const WEB_UI_OFF_MESSAGE =
|
||||
"The OpenRig web UI is off. To turn it on, run `rig config set ui.enabled true`, then stop and start the daemon (`rig daemon stop`, `rig daemon start`).\n";
|
||||
|
||||
function safeResolveUiPath(uiDistDir: string, requestPath: string): string | null {
|
||||
const relativePath = requestPath.replace(/^\/+/, "") || "index.html";
|
||||
const resolvedPath = nodePath.resolve(uiDistDir, relativePath);
|
||||
@@ -633,17 +622,6 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.use("*", createSlowOpRequestMiddleware(deps.slowOpRecorder));
|
||||
}
|
||||
|
||||
// 0.6.4 browser boundary: target name and browser Origin, checked once per /api
|
||||
// request (WebSocket upgrades included), before the remote read-through and every route.
|
||||
app.use("/api/*", browserBoundary({
|
||||
webUiEnabled: deps.webUiEnabled === true,
|
||||
bearerTokens: [deps.terminalBearerToken, deps.missionControlBearerToken],
|
||||
allowedOrigins: process.env.OPENRIG_ALLOWED_ORIGINS,
|
||||
allowedHosts: process.env.OPENRIG_ALLOWED_HOSTS,
|
||||
discoverSelfNames: deps.selfNameDiscovery,
|
||||
onDecision: deps.browserBoundaryObserver,
|
||||
}));
|
||||
|
||||
// OPR.0.4.6.MH2 FR-2/FR-7 — the single-host READ-THROUGH edge (the read
|
||||
// twin of the mission-control remote-forward). Consumes a `?host=<id>`
|
||||
// envelope on allowlisted GET reads; refuses non-GET / non-allowlisted
|
||||
@@ -743,14 +721,11 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/compaction", compactionRoutes({ bearerToken: deps.terminalBearerToken ?? null }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let injectWebSocket: (server: any) => void = () => {};
|
||||
// The terminal WebSocket only serves the web UI, so it exists only while the web UI is on.
|
||||
if (deps.enableNodeWebSocket && deps.webUiEnabled === true) {
|
||||
if (deps.enableNodeWebSocket) {
|
||||
const ws = createNodeWebSocket({ app });
|
||||
injectWebSocket = ws.injectWebSocket as never;
|
||||
_lastInjectWebSocket = injectWebSocket;
|
||||
registerTerminalWs(app, ws.upgradeWebSocket as never, { bearerToken: deps.terminalBearerToken ?? null });
|
||||
} else if (deps.enableNodeWebSocket) {
|
||||
registerTerminalAuthOnly(app, { bearerToken: deps.terminalBearerToken ?? null });
|
||||
}
|
||||
app.route("/api/activity", activityRoutes);
|
||||
app.route("/api/ask", askRoutes);
|
||||
@@ -847,11 +822,6 @@ export function createApp(deps: AppDeps): Hono {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
if (deps.webUiEnabled !== true) {
|
||||
c.header("X-OpenRig-Web-UI", "off");
|
||||
return c.text(WEB_UI_OFF_MESSAGE, 404);
|
||||
}
|
||||
|
||||
if (!hasUiBundle) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { discoverTailscaleSelfNames } from "./middleware/browser-boundary.js";
|
||||
import { configureShadowCapture } from "./domain/shadow-capture.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "./domain/seat-delivery-guard.js";
|
||||
import { queueRecoveryOwnsWake } from "./domain/queue-wake-ladder.js";
|
||||
@@ -196,8 +195,6 @@ interface DaemonOptions {
|
||||
* terminal preview/transport/websocket routes.
|
||||
*/
|
||||
terminalBearerToken?: string | null;
|
||||
/** Overrides the `ui.enabled` setting (tests). */
|
||||
webUiEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface DaemonResult {
|
||||
@@ -2362,10 +2359,6 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// OPR.0.5.6.1 — bind the delivery policies' late gateway ref.
|
||||
lateGatewayDispatch.fn = (op, ref, payload, opts) => gatewaySubsystem.dispatch(op, ref, payload, opts);
|
||||
|
||||
// `ui.enabled` (default off): the web UI pages and its terminal WebSocket. Read once at start.
|
||||
deps.webUiEnabled = opts?.webUiEnabled ?? new ContextPackSettingsStore().resolveOne("ui.enabled").value === true;
|
||||
// /api browser boundary: this machine's exact Tailscale MagicDNS name, looked up on demand.
|
||||
deps.selfNameDiscovery = () => discoverTailscaleSelfNames({ timeoutMs: 1500 });
|
||||
const { app, injectWebSocket } = createAppWithWebSocket(deps);
|
||||
|
||||
return { app, db, deps, contextMonitor, eventLoopMonitor, injectWebSocket };
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
// Browser boundary on a real Node listener (@hono/node-server) and the real WebSocket upgrade
|
||||
// path (@hono/node-ws), plus real CLI/TUI/daemon-to-daemon request constructors. Inert handlers only.
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import WebSocket from "ws";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
import { terminalAuthMiddleware } from "../src/routes/terminal-ws.js";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { remoteJsonRequest } from "../src/domain/hosts/remote-daemon-http.js";
|
||||
import { DaemonClient as CliDaemonClient } from "../../cli/src/client.js";
|
||||
import { DaemonClient as TuiDaemonClient } from "../../tui/src/daemon-client.js";
|
||||
|
||||
const SELF = "openrig-vm.taile8a08.ts.net";
|
||||
type Server = ReturnType<typeof serve>;
|
||||
|
||||
function listen(app: Hono, hostname: string): Promise<{ server: Server; port: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = serve({ fetch: app.fetch, port: 0, hostname }, (info) => resolve({ server, port: info.port }));
|
||||
(server as unknown as net.Server).once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function raw(port: number, headers: Record<string, string>, path = "/api/ping", address = "127.0.0.1"): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: address, port, path, method: "GET", headers, setHost: !("Host" in headers) }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (d) => { body += d; });
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function rawSocket(port: number, request: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.connect(port, "127.0.0.1", () => sock.write(request));
|
||||
let data = "";
|
||||
sock.on("data", (d) => { data += d; });
|
||||
sock.on("close", () => resolve(data.split("\r\n")[0] ?? ""));
|
||||
sock.on("error", () => resolve(data.split("\r\n")[0] ?? "error"));
|
||||
setTimeout(() => sock.destroy(), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
describe("mirror of the server.ts order on a real listener: boundary, then terminal guard, then upgrade", () => {
|
||||
const decisions: string[] = [];
|
||||
let handled = 0;
|
||||
let opened = 0;
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
let v6: { server: Server; port: number } | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = new Hono();
|
||||
const ws = createNodeWebSocket({ app });
|
||||
app.use("/api/*", browserBoundary({
|
||||
webUiEnabled: true, bearerTokens: [], hostName: () => "box-1.local",
|
||||
discoverSelfNames: async () => [SELF], warn: () => {},
|
||||
onDecision: (d) => decisions.push(d.code ?? d.outcome),
|
||||
}));
|
||||
app.get("/api/ping", (c) => { handled++; return c.json({ ok: true }); });
|
||||
app.get("/api/terminal/:s", terminalAuthMiddleware({ bearerToken: null }) as never,
|
||||
ws.upgradeWebSocket(() => ({ onOpen: (_e, socket) => { opened++; socket.close(); } })));
|
||||
({ server, port } = await listen(app, "127.0.0.1"));
|
||||
ws.injectWebSocket(server);
|
||||
try { v6 = await listen(app, "::1"); } catch { v6 = null; }
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((r) => (server as unknown as net.Server).close(() => r()));
|
||||
if (v6) await new Promise<void>((r) => (v6!.server as unknown as net.Server).close(() => r()));
|
||||
});
|
||||
|
||||
it("actual Node headers: accepted names reach the inert handler once each; others never", async () => {
|
||||
const before = handled;
|
||||
for (const host of [`127.0.0.1:${port}`, `localhost:${port}`, `box-1.local:${port}`, `${SELF}:${port}`, `openrig-vm:${port}`]) {
|
||||
expect((await raw(port, { Host: host })).status, host).toBe(200);
|
||||
}
|
||||
expect(handled - before).toBe(5);
|
||||
// @hono/node-server 1.19.11 answers 400 "Invalid host header" before the app when the URL-normalized
|
||||
// hostname differs from the raw Host (case); a lowercase trailing dot passes through and the
|
||||
// boundary normalizes it.
|
||||
const upper = await raw(port, { Host: `LOCALHOST:${port}` });
|
||||
const dotted = await raw(port, { Host: `localhost.:${port}` });
|
||||
console.log(`ADAPTER-HOST-FORM LOCALHOST -> ${upper.status}; localhost. -> ${dotted.status}`);
|
||||
expect(upper.status).toBe(400);
|
||||
expect(dotted.status).toBe(200);
|
||||
expect(handled - before).toBe(6);
|
||||
for (const host of [`evil.example:${port}`, `other-vm.taile8a08.ts.net:${port}`]) {
|
||||
const r = await raw(port, { Host: host });
|
||||
expect(r.status, host).toBe(403);
|
||||
expect(JSON.parse(r.body).code).toBe("untrusted_host");
|
||||
}
|
||||
for (const host of ["a b", `x@y:${port}`]) {
|
||||
const r = await raw(port, { Host: host });
|
||||
console.log(`MALFORMED-HOST-FORM ${JSON.stringify(host)} -> ${r.status} ${r.body.slice(0, 80)}`);
|
||||
expect(r.status, host).not.toBe(200);
|
||||
}
|
||||
expect(handled - before).toBe(6);
|
||||
});
|
||||
|
||||
it("IPv6 loopback listener: [::1] Host is accepted", async () => {
|
||||
if (!v6) { console.log("IPV6-LISTENER: unavailable on this host (control not run)"); return; }
|
||||
const r = await raw(v6.port, { Host: `[::1]:${v6.port}` }, "/api/ping", "::1");
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("records what the real listener does with duplicate, missing and HTTP/1.0 Host forms", async () => {
|
||||
const dup = await rawSocket(port, `GET /api/ping HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nHost: evil.example\r\nConnection: close\r\n\r\n`);
|
||||
const missing11 = await rawSocket(port, "GET /api/ping HTTP/1.1\r\nConnection: close\r\n\r\n");
|
||||
const http10 = await rawSocket(port, "GET /api/ping HTTP/1.0\r\n\r\n");
|
||||
const dupOrigin = await rawSocket(port, `GET /api/ping HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nOrigin: http://127.0.0.1:${port}\r\nOrigin: http://evil.example\r\nConnection: close\r\n\r\n`);
|
||||
console.log(`LISTENER-FORMS duplicateHost=[${dup}] missingHost11=[${missing11}] http10NoHost=[${http10}] duplicateOrigin=[${dupOrigin}]`);
|
||||
expect(dup).not.toMatch(/ 200 /);
|
||||
expect(dupOrigin).not.toMatch(/ 200 /);
|
||||
});
|
||||
|
||||
it("real upgrade path: the guard runs exactly once per upgrade; refused upgrades never open", async () => {
|
||||
const attempt = (origin: string | undefined, host?: string) => new Promise<{ opened: boolean; status?: number }>((resolve) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (host) headers["Host"] = host;
|
||||
const sock = new WebSocket(`ws://127.0.0.1:${port}/api/terminal/s1`, { headers, ...(origin ? { origin } : {}) });
|
||||
sock.on("open", () => { sock.close(); resolve({ opened: true }); });
|
||||
sock.on("unexpected-response", (_req, res) => { resolve({ opened: false, status: res.statusCode }); });
|
||||
sock.on("error", () => resolve({ opened: false }));
|
||||
});
|
||||
const d0 = decisions.length;
|
||||
const o0 = opened;
|
||||
expect(await attempt(`http://127.0.0.1:${port}`)).toEqual({ opened: true });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(decisions.length - d0).toBe(1);
|
||||
expect(opened - o0).toBe(1);
|
||||
const d1 = decisions.length;
|
||||
expect(await attempt("http://evil.example")).toEqual({ opened: false, status: 403 });
|
||||
expect(await attempt(undefined, `evil.example:${port}`)).toEqual({ opened: false, status: 403 });
|
||||
expect(await attempt(`http://localhost:5173`, `localhost:${port}`)).toEqual({ opened: false, status: 403 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(decisions.length - d1).toBe(3);
|
||||
expect(opened - o0).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real request constructors against the real route table on a real listener", () => {
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
beforeAll(async () => {
|
||||
const db = createFullTestDb();
|
||||
const { app } = createTestApp(db);
|
||||
({ server, port } = await listen(app as unknown as Hono, "127.0.0.1"));
|
||||
});
|
||||
afterAll(async () => { await new Promise<void>((r) => (server as unknown as net.Server).close(() => r())); });
|
||||
|
||||
it("CLI DaemonClient (no Origin) reaches /api/ps by IP and localhost", async () => {
|
||||
for (const base of [`http://127.0.0.1:${port}`, `http://localhost:${port}`]) {
|
||||
const res = await new CliDaemonClient(base).get<unknown[]>("/api/ps?includeArchived=true");
|
||||
expect(res.status, base).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("records whether Node fetch honours an explicit Host header (needed to present an unlisted name without DNS)", async () => {
|
||||
const res = await new CliDaemonClient(`http://127.0.0.1:${port}`, { headers: { Host: "unlisted.example" } }).get<{ code?: string; error?: string }>("/api/ps");
|
||||
console.log(`CLI-HOST-OVERRIDE status=${res.status} code=${(res.data as { code?: string })?.code ?? "-"}`);
|
||||
if (res.status === 403) {
|
||||
expect(res.data.code).toBe("untrusted_host");
|
||||
expect(res.data.error).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
}
|
||||
});
|
||||
|
||||
it("TUI client read path carries the refusal sentence; normal reads pass", async () => {
|
||||
const ok = new TuiDaemonClient({ baseUrl: `http://127.0.0.1:${port}` } as never);
|
||||
await expect(ok.healthFindings(5)).resolves.toBeDefined();
|
||||
const named = new TuiDaemonClient({ baseUrl: `http://127.0.0.1:${port}`, headers: { Host: "unlisted.example" } } as never);
|
||||
const err = await named.healthFindings(5).then(() => null, (e: Error) => e.message);
|
||||
console.log(`TUI-READ-REFUSAL ${err ?? "no refusal (Host override not honoured)"}`);
|
||||
if (err) expect(err).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
});
|
||||
|
||||
it("daemon-to-daemon remoteJsonRequest (no Origin) reaches the daemon by IP and localhost", async () => {
|
||||
for (const url of [`http://127.0.0.1:${port}`, `http://localhost:${port}`]) {
|
||||
const r = await remoteJsonRequest({ id: "peer", transport: "http", url }, "/api/ps", { method: "GET", timeoutMs: 3000, env: {} });
|
||||
expect(r.ok, url).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
// F1/F2 controls on actual Node listeners. F1 drives the production createAppWithWebSocket and its
|
||||
// registered terminal route with raw socket requests, so duplicate and empty header forms reach the
|
||||
// adapters exactly as sent. F2 checks the refusal log's emitted content.
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import net from "node:net";
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type Database from "better-sqlite3";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
import { createFullTestDb, createTestApp, mockTmuxAdapter } from "./helpers/test-app.js";
|
||||
|
||||
type Server = ReturnType<typeof serve>;
|
||||
const listen = (fetch: Hono["fetch"]) => new Promise<{ server: Server; port: number }>((resolve) => {
|
||||
const server = serve({ fetch, port: 0, hostname: "127.0.0.1" }, (info) => resolve({ server, port: info.port }));
|
||||
});
|
||||
const close = (s: Server) => new Promise<void>((r) => (s as unknown as net.Server).close(() => r()));
|
||||
|
||||
/** Send raw request lines and return the status line. */
|
||||
function rawRequest(port: number, requestLine: string, headerLines: string[]): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.connect(port, "127.0.0.1", () => sock.write(`${requestLine}\r\n${headerLines.join("\r\n")}\r\n\r\n`));
|
||||
let data = "";
|
||||
const done = () => resolve(data.split("\r\n")[0] ?? "");
|
||||
sock.on("data", (d) => { data += d; if (data.includes("\r\n")) { sock.destroy(); } });
|
||||
sock.on("close", done);
|
||||
sock.on("error", done);
|
||||
setTimeout(() => sock.destroy(), 1500);
|
||||
});
|
||||
}
|
||||
const UPGRADE = ["Connection: Upgrade", "Upgrade: websocket", "Sec-WebSocket-Version: 13", `Sec-WebSocket-Key: ${Buffer.from("bb-ws-key-123456").toString("base64")}`];
|
||||
const settle = () => new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
describe("F1: production createAppWithWebSocket + registered terminal route, raw header forms", () => {
|
||||
let db: Database.Database;
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
let hasSessionCalls = 0;
|
||||
const decisions: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
db = createFullTestDb();
|
||||
const ok = async () => ({ ok: true });
|
||||
const tmux = {
|
||||
...mockTmuxAdapter(),
|
||||
hasSession: async () => { hasSessionCalls++; return true; },
|
||||
setWindowOption: ok, startPipePane: ok, stopPipePane: ok, sendKeys: ok, sendText: ok, resizeWindow: ok,
|
||||
};
|
||||
const built = createTestApp(db, {
|
||||
tmux: tmux as never,
|
||||
withWebSocket: true,
|
||||
appDeps: { webUiEnabled: true, browserBoundaryObserver: (d) => decisions.push(d.code ?? d.outcome) },
|
||||
});
|
||||
({ server, port } = await listen(built.app.fetch as Hono["fetch"]));
|
||||
built.injectWebSocket!(server);
|
||||
});
|
||||
afterAll(async () => { await close(server); db.close(); });
|
||||
|
||||
async function upgradeCase(headers: string[]) {
|
||||
const d0 = decisions.length, h0 = hasSessionCalls;
|
||||
const status = await rawRequest(port, "GET /api/terminal/seat-1 HTTP/1.1", [...headers, ...UPGRADE]);
|
||||
await settle();
|
||||
return { status, decisions: decisions.slice(d0), routeEffects: hasSessionCalls - h0 };
|
||||
}
|
||||
|
||||
it("positive control: own UI origin upgrades once, one allow decision, route effect present", async () => {
|
||||
const r = await upgradeCase([`Host: 127.0.0.1:${port}`, `Origin: http://127.0.0.1:${port}`]);
|
||||
console.log("F1-UPGRADE own", JSON.stringify(r));
|
||||
expect(r.status).toContain("101");
|
||||
expect(r.decisions).toEqual(["allow"]);
|
||||
expect(r.routeEffects).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("foreign origin control: 403, one refusal, zero route effects", async () => {
|
||||
const r = await upgradeCase([`Host: 127.0.0.1:${port}`, "Origin: http://evil.example"]);
|
||||
console.log("F1-UPGRADE foreign", JSON.stringify(r));
|
||||
expect(r.status).toContain("403");
|
||||
expect(r.decisions).toEqual(["browser_origin_refused"]);
|
||||
expect(r.routeEffects).toBe(0);
|
||||
});
|
||||
|
||||
for (const [name, headers, code] of [
|
||||
["duplicate Host", (p: number) => [`Host: 127.0.0.1:${p}`, "Host: evil.example", `Origin: http://127.0.0.1:${p}`], "untrusted_host"],
|
||||
["empty Host", (p: number) => ["Host: ", `Origin: http://127.0.0.1:${p}`], "untrusted_host"],
|
||||
["empty Host, no Origin", (_p: number) => ["Host: "], "untrusted_host"],
|
||||
["empty Origin", (p: number) => [`Host: 127.0.0.1:${p}`, "Origin: "], "browser_origin_refused"],
|
||||
["duplicate Origin", (p: number) => [`Host: 127.0.0.1:${p}`, `Origin: http://127.0.0.1:${p}`, "Origin: http://evil.example"], "browser_origin_refused"],
|
||||
] as const) {
|
||||
it(`${name}: refused on the upgrade path with one decision and zero route effects`, async () => {
|
||||
const r = await upgradeCase(headers(port));
|
||||
console.log(`F1-UPGRADE ${name}`, JSON.stringify(r));
|
||||
expect(r.status).toContain("403");
|
||||
expect(r.decisions).toEqual([code]);
|
||||
expect(r.routeEffects).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("the same shapes over plain HTTP are refused as well", async () => {
|
||||
for (const headers of [
|
||||
[`Host: 127.0.0.1:${port}`, "Host: evil.example"],
|
||||
["Host: "],
|
||||
[`Host: 127.0.0.1:${port}`, "Origin: "],
|
||||
]) {
|
||||
const status = await rawRequest(port, "GET /api/ps HTTP/1.1", [...headers, "Connection: close"]);
|
||||
console.log("F1-HTTP", JSON.stringify(headers), status);
|
||||
expect(status).not.toContain(" 200 ");
|
||||
}
|
||||
});
|
||||
|
||||
it("genuinely absent Host (HTTP/1.0, non-browser) is still admitted", async () => {
|
||||
const status = await rawRequest(port, "GET /api/ps HTTP/1.0", []);
|
||||
expect(status).toContain("200");
|
||||
});
|
||||
});
|
||||
|
||||
describe("F2: refusal log content on an actual listener", () => {
|
||||
const lines: string[] = [];
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
beforeAll(async () => {
|
||||
const app = new Hono();
|
||||
app.use("/api/*", browserBoundary({ webUiEnabled: false, bearerTokens: [], warn: (l) => lines.push(l) }));
|
||||
app.all("/api/*", (c) => c.json({ ok: true }));
|
||||
({ server, port } = await listen(app.fetch));
|
||||
});
|
||||
afterAll(async () => { await close(server); });
|
||||
|
||||
it("an encoded control character in the path is replaced in the log; a long path is bounded; the query never appears", async () => {
|
||||
await rawRequest(port, "GET /api/a%1Bb?token=SECRET HTTP/1.1", ["Host: evil-1.example", "Connection: close"]);
|
||||
await rawRequest(port, `GET /api/${"x".repeat(9000)} HTTP/1.1`, ["Host: evil-2.example", "Connection: close"]);
|
||||
console.log("F2-LOG", JSON.stringify(lines.map((l) => ({ length: l.length, hasEsc: l.includes("\u001b"), head: l.slice(0, 120) }))));
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).not.toContain("\u001b");
|
||||
expect(lines[0]).toContain("/api/a?b");
|
||||
expect(lines[0]).not.toContain("SECRET");
|
||||
expect(lines[1]!.length).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
// Browser boundary on the real route table (createApp via createTestApp): refused requests
|
||||
// reach no handler and no remote forward; admitted requests reach the inert handler once.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
|
||||
type Call = { method: string; args: unknown[] };
|
||||
|
||||
function inertTransport(calls: Call[]) {
|
||||
return {
|
||||
resolveSessions: async (...args: unknown[]) => { calls.push({ method: "resolveSessions", args }); return { ok: false, code: "not_found", error: "inert fixture" }; },
|
||||
send: async (...args: unknown[]) => { calls.push({ method: "send", args }); return { ok: false, error: "inert" }; },
|
||||
capture: async (...args: unknown[]) => { calls.push({ method: "capture", args }); return { ok: false, error: "inert" }; },
|
||||
broadcast: async (...args: unknown[]) => { calls.push({ method: "broadcast", args }); return { ok: false, error: "inert" }; },
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser boundary on the real route table", () => {
|
||||
let db: Database.Database;
|
||||
let home: string;
|
||||
let spy: http.Server;
|
||||
let spyHits = 0;
|
||||
const savedHome = process.env.OPENRIG_HOME;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createFullTestDb();
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "bb-routes-"));
|
||||
spyHits = 0;
|
||||
spy = http.createServer((_req, res) => { spyHits++; res.writeHead(200, { "content-type": "application/json" }); res.end("[]"); });
|
||||
await new Promise<void>((r) => spy.listen(0, "127.0.0.1", () => r()));
|
||||
const port = (spy.address() as AddressInfo).port;
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: peer\n transport: http\n url: http://127.0.0.1:${port}\n`);
|
||||
process.env.OPENRIG_HOME = home;
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = savedHome;
|
||||
await new Promise<void>((r) => spy.close(() => r()));
|
||||
db.close();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function build(extra: Record<string, unknown> = {}) {
|
||||
const calls: Call[] = [];
|
||||
const previews: unknown[] = [];
|
||||
const decisions: Array<{ outcome: string; code?: string }> = [];
|
||||
const { app } = createTestApp(db, {
|
||||
appDeps: {
|
||||
sessionTransport: inertTransport(calls) as never,
|
||||
queueRepo: new QueueRepository(db, new EventBus(db), { validateRig: () => true }),
|
||||
terminalService: { previewView: async (req: unknown) => { previews.push(req); return { ok: false, error: "inert" }; } } as never,
|
||||
browserBoundaryObserver: (d) => decisions.push(d),
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
const rows = () => (db.prepare("SELECT count(*) AS n FROM queue_items").get() as { n: number }).n;
|
||||
return { app, calls, previews, decisions, rows };
|
||||
}
|
||||
|
||||
const req = (app: { request: (p: string, i: RequestInit) => Response | Promise<Response> }, p: string, h: Record<string, string>, method = "GET", body?: unknown) =>
|
||||
app.request(p, { method, headers: { ...(body ? { "Content-Type": "application/json" } : {}), ...h }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||
|
||||
it("POST /api/transport/send: refused origin or target reaches no handler; admitted reaches it once", async () => {
|
||||
const b = build();
|
||||
const body = { session: "dev-impl@rig", text: "fixture" };
|
||||
for (const h of [
|
||||
{ Host: "evil.example:7433", Origin: "http://evil.example:7433" }, // rebinding shape
|
||||
{ Host: "127.0.0.1:7433", Origin: "http://evil.example" }, // cross-site
|
||||
{ Host: "127.0.0.1:7433", Origin: "null" },
|
||||
{ Host: "unknown.example:7433" }, // non-browser, unlisted name
|
||||
]) {
|
||||
const res = await req(b.app, "/api/transport/send", h, "POST", body);
|
||||
expect(res.status, JSON.stringify(h)).toBe(403);
|
||||
}
|
||||
expect(b.calls).toHaveLength(0);
|
||||
const ok = await req(b.app, "/api/transport/send", { Host: "127.0.0.1:7433" }, "POST", body);
|
||||
expect(ok.status).not.toBe(403);
|
||||
expect(b.calls.map((c) => c.method)).toEqual(["resolveSessions"]);
|
||||
expect(b.decisions).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("GET /api/terminal/preview: a no-Origin read addressed to an attacker name reaches no handler", async () => {
|
||||
const b = build();
|
||||
expect((await req(b.app, "/api/terminal/preview?view=x", { Host: "evil.example:7433" })).status).toBe(403);
|
||||
expect((await req(b.app, "/api/terminal/preview?view=x", { Host: "evil.example:7433", "Sec-Fetch-Site": "same-origin" })).status).toBe(403);
|
||||
expect(b.previews).toHaveLength(0);
|
||||
await req(b.app, "/api/terminal/preview?view=x", { Host: "localhost:7433" });
|
||||
expect(b.previews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("POST /api/queue/create: a refused request writes no row; an admitted one does", async () => {
|
||||
const b = build();
|
||||
const body = { sourceSession: "orch@rig-a", destinationSession: "dev@rig-a", body: "fixture" };
|
||||
const refused = await req(b.app, "/api/queue/create", { Host: "127.0.0.1:7433", Origin: "http://evil.example", "X-OpenRig-Session": "orch@rig-a" }, "POST", body);
|
||||
expect(refused.status).toBe(403);
|
||||
expect(b.rows()).toBe(0);
|
||||
const ok = await req(b.app, "/api/queue/create", { Host: "127.0.0.1:7433", "X-OpenRig-Session": "orch@rig-a" }, "POST", body);
|
||||
expect(ok.status).toBeLessThan(300);
|
||||
expect(b.rows()).toBe(1);
|
||||
});
|
||||
|
||||
it("GET /api/ps?host=peer: refused before the read-through forwards; admitted forwards once", async () => {
|
||||
const b = build();
|
||||
expect((await req(b.app, "/api/ps?host=peer", { Host: "127.0.0.1:7433", Origin: "http://evil.example" })).status).toBe(403);
|
||||
expect((await req(b.app, "/api/ps?host=peer", { Host: "evil.example:7433" })).status).toBe(403);
|
||||
expect(spyHits).toBe(0);
|
||||
const ok = await req(b.app, "/api/ps?host=peer", { Host: "127.0.0.1:7433" });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(spyHits).toBe(1);
|
||||
});
|
||||
|
||||
it("UI on admits the own UI origin through the real table; a token never waives an origin refusal", async () => {
|
||||
const b = build({ webUiEnabled: true, missionControlBearerToken: "tok-1" });
|
||||
expect((await req(b.app, "/api/ps", { Host: "localhost:7433", Origin: "http://localhost:7433" })).status).toBe(200);
|
||||
expect((await req(b.app, "/api/ps", { Host: "custom.example:7433", Authorization: "Bearer tok-1" })).status).toBe(200);
|
||||
const r = await req(b.app, "/api/ps", { Host: "custom.example:7433", Authorization: "Bearer tok-1", Origin: "http://evil.example" });
|
||||
expect(r.status).toBe(403);
|
||||
expect(((await r.json()) as { code: string }).code).toBe("browser_origin_refused");
|
||||
});
|
||||
|
||||
it("a waived target name keeps downstream route authorization: wrong-route token still refused by the route", async () => {
|
||||
const b = build({ missionControlBearerToken: "mc-token", terminalBearerToken: "term-token" });
|
||||
const body = { session: "dev-impl@rig", text: "fixture" };
|
||||
// Mission-control token waives the name check, but /api/transport still requires the terminal token.
|
||||
const r = await req(b.app, "/api/transport/send", { Host: "custom.example:7433", Authorization: "Bearer mc-token" }, "POST", body);
|
||||
expect(r.status).toBe(401);
|
||||
expect(b.calls).toHaveLength(0);
|
||||
const ok = await req(b.app, "/api/transport/send", { Host: "custom.example:7433", Authorization: "Bearer term-token" }, "POST", body);
|
||||
expect(ok.status).not.toBe(401);
|
||||
expect(b.calls.map((c) => c.method)).toEqual(["resolveSessions"]);
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
browserBoundary,
|
||||
discoverTailscaleSelfNames,
|
||||
osOwnNames,
|
||||
parseHostHeader,
|
||||
parseOrigin,
|
||||
type BrowserBoundaryOptions,
|
||||
} from "../src/middleware/browser-boundary.js";
|
||||
|
||||
const SELF = "openrig-vm.taile8a08.ts.net";
|
||||
|
||||
function guarded(opts: Partial<BrowserBoundaryOptions> = {}) {
|
||||
const lines: string[] = [];
|
||||
const decisions: Array<{ outcome: string; code?: string }> = [];
|
||||
let hits = 0;
|
||||
const app = new Hono();
|
||||
app.use("/api/*", browserBoundary({
|
||||
webUiEnabled: false,
|
||||
bearerTokens: [],
|
||||
hostName: () => "Box-1.local",
|
||||
warn: (line) => lines.push(line),
|
||||
onDecision: (d) => decisions.push(d),
|
||||
...opts,
|
||||
}));
|
||||
app.all("/api/*", (c) => { hits++; return c.json({ ok: true }); });
|
||||
const send = async (h: { host?: string; origin?: string; auth?: string; method?: string; path?: string }) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (h.host !== undefined) headers["Host"] = h.host;
|
||||
if (h.origin !== undefined) headers["Origin"] = h.origin;
|
||||
if (h.auth !== undefined) headers["Authorization"] = h.auth;
|
||||
const res = await app.request(h.path ?? "/api/ps", { method: h.method ?? "GET", headers });
|
||||
return { status: res.status, body: await res.json() as { error?: string; code?: string } };
|
||||
};
|
||||
return { send, lines, decisions, hits: () => hits };
|
||||
}
|
||||
|
||||
describe("parsing and normalization", () => {
|
||||
it("parses Host: case, trailing dot, IPv6, default and explicit ports, malformed forms", () => {
|
||||
expect(parseHostHeader(undefined)).toEqual({ kind: "absent" });
|
||||
expect(parseHostHeader("LOCALHOST.:7433")).toEqual({ kind: "ok", hostname: "localhost", port: "7433" });
|
||||
expect(parseHostHeader("[::1]:7433")).toEqual({ kind: "ok", hostname: "::1", port: "7433" });
|
||||
expect(parseHostHeader("example.test")).toEqual({ kind: "ok", hostname: "example.test", port: "80" });
|
||||
for (const bad of ["", "a b", "x@y", "host/path", "[zz]:1", "h:99999", "h:0", "a,b", "a, b", "h:"]) {
|
||||
expect(parseHostHeader(bad)).toEqual({ kind: "malformed" });
|
||||
}
|
||||
});
|
||||
|
||||
it("parses Origin: null is opaque, paths/userinfo/lists are malformed, default ports resolve", () => {
|
||||
expect(parseOrigin("null")).toEqual({ kind: "opaque" });
|
||||
expect(parseOrigin("http://LOCALHOST.:7433")).toEqual({ kind: "ok", scheme: "http", hostname: "localhost", port: "7433" });
|
||||
expect(parseOrigin("http://localhost")).toEqual({ kind: "ok", scheme: "http", hostname: "localhost", port: "80" });
|
||||
expect(parseOrigin("https://x.test")).toEqual({ kind: "ok", scheme: "https", hostname: "x.test", port: "443" });
|
||||
for (const bad of ["not a url", "http://localhost:7433/x", "http://u:p@localhost", "http://a, http://b", "", "http://h?q=1"]) {
|
||||
expect(parseOrigin(bad)).toEqual({ kind: "malformed" });
|
||||
}
|
||||
});
|
||||
|
||||
it("derives OS own names: full, first label, and .local", () => {
|
||||
expect(osOwnNames(() => "Box-1.local")).toEqual(["box-1.local", "box-1"]);
|
||||
expect(osOwnNames(() => "studio")).toEqual(["studio", "studio.local"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("target-name rule", () => {
|
||||
it("accepts absent Host, loopback, IPv4/IPv6 literals and OS own names", async () => {
|
||||
const g = guarded();
|
||||
for (const host of [undefined, "localhost:7433", "LOCALHOST.:7433", "127.0.0.1:7433", "[::1]:7433",
|
||||
"100.106.37.120:7433", "[::ffff:127.0.0.1]:7433", "192.168.1.5", "box-1.local:7433", "box-1:7433", "BOX-1.LOCAL."]) {
|
||||
expect((await g.send({ host })).status, String(host)).toBe(200);
|
||||
}
|
||||
expect(g.hits()).toBe(11);
|
||||
});
|
||||
|
||||
it("refuses unknown, peer-suffix and malformed names with zero handler effects", async () => {
|
||||
const g = guarded({ discoverSelfNames: async () => [SELF] });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
for (const host of ["evil.example:7433", "other-vm.taile8a08.ts.net:7433", "taile8a08.ts.net", `${SELF}.evil.example`]) {
|
||||
const r = await g.send({ host });
|
||||
expect(r.status, host).toBe(403);
|
||||
expect(r.body.code).toBe("untrusted_host");
|
||||
expect(r.body.error).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
}
|
||||
for (const host of ["a b", "x@y", "h:99999", "a, b"]) {
|
||||
expect((await g.send({ host })).body.code, host).toBe("untrusted_host");
|
||||
}
|
||||
expect(g.hits()).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts the exact discovered self MagicDNS name and its short form only", async () => {
|
||||
const g = guarded({ discoverSelfNames: async () => [`${SELF}.`] });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect((await g.send({ host: `${SELF}:7433` })).status).toBe(200);
|
||||
expect((await g.send({ host: "OPENRIG-VM:7433" })).status).toBe(200);
|
||||
expect((await g.send({ host: "other-vm:7433" })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("OPENRIG_ALLOWED_HOSTS admits names; OPENRIG_ALLOWED_ORIGINS does not", async () => {
|
||||
expect((await guarded({ allowedHosts: "rig.example.com" }).send({ host: "rig.example.com:7433" })).status).toBe(200);
|
||||
expect((await guarded({ allowedOrigins: "rig.example.com,http://rig.example.com:7433" }).send({ host: "rig.example.com:7433" })).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery controls", () => {
|
||||
it("unavailable, malformed and failing discovery leave loopback/IP working and the name refused", async () => {
|
||||
for (const discoverSelfNames of [
|
||||
async () => [] as string[],
|
||||
async () => ["bad name!", "", "a..b", "-x.ts.net"],
|
||||
async () => { throw new Error("ESERVFAIL"); },
|
||||
]) {
|
||||
const g = guarded({ discoverSelfNames, rediscoverAfterMs: 60_000 });
|
||||
expect((await g.send({ host: "127.0.0.1:7433" })).status).toBe(200);
|
||||
expect((await g.send({ host: `${SELF}:7433` })).status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("a timed-out lookup is bounded and never delays loopback/IP requests", async () => {
|
||||
const g = guarded({ discoverSelfNames: () => new Promise<string[]>(() => {}), discoveryTimeoutMs: 50 });
|
||||
const t0 = Date.now();
|
||||
expect((await g.send({ host: "127.0.0.1:7433" })).status).toBe(200);
|
||||
expect(Date.now() - t0).toBeLessThan(40);
|
||||
const t1 = Date.now();
|
||||
expect((await g.send({ host: `${SELF}:7433` })).status).toBe(403);
|
||||
expect(Date.now() - t1).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("an unrecognized name triggers at most one bounded re-lookup per window, single-flight", async () => {
|
||||
let calls = 0;
|
||||
let names: string[] = [];
|
||||
let clock = 1_000;
|
||||
const g = guarded({ discoverSelfNames: async () => { calls++; return names; }, rediscoverAfterMs: 10_000, now: () => clock });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(calls).toBe(1);
|
||||
names = [SELF];
|
||||
expect((await g.send({ host: `${SELF}:7433` })).status).toBe(403); // inside the window: no re-lookup
|
||||
expect(calls).toBe(1);
|
||||
clock += 10_000;
|
||||
const results = await Promise.all([g.send({ host: `${SELF}:7433` }), g.send({ host: `${SELF}:7433` })]);
|
||||
expect(results.map((r) => r.status)).toEqual([200, 200]);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("discoverTailscaleSelfNames: no tailnet IP means no lookup; names are validated and short forms added", async () => {
|
||||
const reverse = vi.fn(async () => [`${SELF}.`, "bad name!"]);
|
||||
expect(await discoverTailscaleSelfNames({ timeoutMs: 10, tailscaleIp: () => null, reverse })).toEqual([]);
|
||||
expect(reverse).not.toHaveBeenCalled();
|
||||
expect(await discoverTailscaleSelfNames({ timeoutMs: 10, tailscaleIp: () => "100.106.37.120", reverse })).toEqual([SELF, "openrig-vm"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser-origin rule", () => {
|
||||
it("UI off trusts no default origin; UI on trusts only the exact own UI origin", async () => {
|
||||
const off = guarded();
|
||||
expect((await off.send({ host: "localhost:7433", origin: "http://localhost:7433" })).body.code).toBe("browser_origin_refused");
|
||||
const on = guarded({ webUiEnabled: true });
|
||||
expect((await on.send({ host: "localhost:7433", origin: "http://localhost:7433" })).status).toBe(200);
|
||||
expect((await on.send({ host: "127.0.0.1:7433", origin: "http://127.0.0.1:7433" })).status).toBe(200);
|
||||
expect((await on.send({ host: "localhost", origin: "http://localhost" })).status).toBe(200);
|
||||
expect((await on.send({ host: "localhost:7433", origin: "http://LOCALHOST.:7433" })).status).toBe(200);
|
||||
for (const [host, origin] of [
|
||||
["localhost:7433", "http://localhost:5173"], // other-port local app or dev server
|
||||
["127.0.0.1:7433", "http://localhost:7433"], // origin is not this request's target
|
||||
["localhost:7433", "https://localhost:7433"],
|
||||
["evil.example:7433", "http://evil.example:7433"], // rebinding shape
|
||||
["localhost:7433", "null"],
|
||||
["localhost:7433", "http://localhost:7433/path"],
|
||||
] as const) {
|
||||
const r = await on.send({ host, origin });
|
||||
expect(r.status, `${host} ${origin}`).toBe(403);
|
||||
}
|
||||
expect((await on.send({ origin: "http://localhost:7433" })).body.code).toBe("browser_origin_refused"); // no Host
|
||||
});
|
||||
|
||||
it("explicit origins keep main's formats and are independent of target-name allowances", async () => {
|
||||
const g = guarded({ allowedOrigins: "http://localhost:5173, tools.example, chrome-extension://abc" });
|
||||
expect((await g.send({ host: "localhost:7433", origin: "http://localhost:5173" })).status).toBe(200);
|
||||
expect((await g.send({ host: "127.0.0.1:7433", origin: "https://tools.example:8443" })).status).toBe(200);
|
||||
expect((await g.send({ host: "127.0.0.1:7433", origin: "chrome-extension://abc" })).status).toBe(200);
|
||||
expect((await g.send({ host: "localhost:7433", origin: "http://localhost:5174" })).status).toBe(403);
|
||||
const hostOnly = guarded({ allowedHosts: "rig.example.com", webUiEnabled: false });
|
||||
const r = await hostOnly.send({ host: "rig.example.com:7433", origin: "http://rig.example.com:7433" });
|
||||
expect(r.body.code).toBe("browser_origin_refused");
|
||||
});
|
||||
|
||||
it("refusal bodies follow {error, code} with a remedy and never echo allowance lists", async () => {
|
||||
const g = guarded({ allowedOrigins: "http://secret-allowed.example" });
|
||||
const r = await g.send({ host: "localhost:7433", origin: "http://evil.example" });
|
||||
expect(r.body.code).toBe("browser_origin_refused");
|
||||
expect(r.body.error).toContain("OPENRIG_ALLOWED_ORIGINS");
|
||||
expect(r.body.error).toContain("cannot read this response");
|
||||
expect(JSON.stringify(r.body)).not.toContain("secret-allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bearer token waiver", () => {
|
||||
it("a valid configured Authorization token waives only the target-name refusal", async () => {
|
||||
const g = guarded({ bearerTokens: [null, "tok-123"] });
|
||||
expect((await g.send({ host: "custom.example:7433", auth: "Bearer tok-123" })).status).toBe(200);
|
||||
expect((await g.send({ host: "custom.example:7433", auth: "Bearer wrong" })).status).toBe(403);
|
||||
expect((await g.send({ host: "custom.example:7433", path: "/api/ps?token=tok-123" })).status).toBe(403);
|
||||
expect((await g.send({ host: "a b", auth: "Bearer tok-123" })).status).toBe(403); // malformed is not waived
|
||||
const r = await g.send({ host: "custom.example:7433", auth: "Bearer tok-123", origin: "http://evil.example" });
|
||||
expect(r.body.code).toBe("browser_origin_refused");
|
||||
});
|
||||
|
||||
it("no configured token means no waiver", async () => {
|
||||
const g = guarded({ bearerTokens: [null, ""] });
|
||||
expect((await g.send({ host: "custom.example:7433", auth: "Bearer " })).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one decision and bounded logging", () => {
|
||||
it("records one decision per request and logs distinct refusals up to 20, then one suppression line", async () => {
|
||||
const g = guarded();
|
||||
for (let i = 0; i < 100; i++) await g.send({ host: "same.example" });
|
||||
expect(g.lines).toHaveLength(1);
|
||||
for (let i = 0; i < 30; i++) await g.send({ host: `n${i}.example`, path: "/api/ps?token=SECRET" });
|
||||
expect(g.lines).toHaveLength(21);
|
||||
expect(g.lines.at(-1)).toContain("not logged");
|
||||
expect(g.lines.join("\n")).not.toContain("SECRET");
|
||||
expect(g.decisions).toHaveLength(130);
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
// #261: recognized built-in startup files re-anchor to the running install; nothing else moves.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry, runningBuiltinAssetsRoot, runningShippedSpecsRoot } from "../src/domain/builtin-startup-files.js";
|
||||
|
||||
const RUNNING = "/new/lib/node_modules/@openrig/cli/daemon/assets";
|
||||
const OLD = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/assets";
|
||||
const meta = { deliveryHint: "guidance_merge" as const, required: true, appliesOn: ["fresh_start" as const, "restore" as const] };
|
||||
|
||||
describe("reanchorBuiltinStartupFile", () => {
|
||||
it.each([
|
||||
["CULTURE-default.md", "guidance/CULTURE-default.md"],
|
||||
["openrig-start.md", "guidance/openrig-start.md"],
|
||||
["openrig-onboarding-01.md", "onboarding/01-world-and-purpose.md"],
|
||||
["openrig-onboarding-02.md", "onboarding/02-self-and-competent-action.md"],
|
||||
])("re-anchors %s from an old packaged install, preserving metadata", (name, rel) => {
|
||||
const stored = { path: name, absolutePath: `${OLD}/${rel}`, ownerRoot: OLD, ...meta, kind: "file" as const };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING)).toEqual({ ...stored, absolutePath: `${RUNNING}/${rel}`, ownerRoot: RUNNING });
|
||||
});
|
||||
|
||||
it("re-anchors a built-in stored from a dev checkout (packages/daemon/assets)", () => {
|
||||
const dev = "/src/openrig/packages/daemon/assets";
|
||||
const stored = { path: "openrig-start.md", absolutePath: `${dev}/guidance/openrig-start.md`, ownerRoot: dev, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING).absolutePath).toBe(`${RUNNING}/guidance/openrig-start.md`);
|
||||
});
|
||||
|
||||
it("leaves a custom rig file with the same basename unchanged", () => {
|
||||
const custom = { path: "CULTURE-default.md", absolutePath: "/home/u/rig/CULTURE-default.md", ownerRoot: "/home/u/rig", ...meta };
|
||||
expect(reanchorBuiltinStartupFile(custom, RUNNING)).toBe(custom);
|
||||
});
|
||||
|
||||
it("leaves a built-in name whose stored path is not the known relative path unchanged", () => {
|
||||
const odd = { path: "CULTURE-default.md", absolutePath: `${OLD}/custom/CULTURE-default.md`, ownerRoot: OLD, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(odd, RUNNING)).toBe(odd);
|
||||
});
|
||||
|
||||
it("leaves a known relative path under a non daemon/assets root unchanged", () => {
|
||||
const root = "/home/u/my-assets";
|
||||
const user = { path: "CULTURE-default.md", absolutePath: `${root}/guidance/CULTURE-default.md`, ownerRoot: root, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(user, RUNNING)).toBe(user);
|
||||
});
|
||||
|
||||
it("leaves non-built-in names unchanged even under daemon/assets", () => {
|
||||
const other = { path: "guidance/CULTURE-default.md", absolutePath: `${OLD}/guidance/CULTURE-default.md`, ownerRoot: OLD, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(other, RUNNING)).toBe(other);
|
||||
});
|
||||
|
||||
it.each(["constructor", "toString", "__proto__", "hasOwnProperty"])("a custom startup file named %s is returned unchanged (no inherited-key match)", (name) => {
|
||||
const custom = { path: name, absolutePath: `/home/u/rig/${name}`, ownerRoot: "/home/u/rig", ...meta };
|
||||
expect(reanchorBuiltinStartupFile(custom, RUNNING)).toBe(custom);
|
||||
const underAssets = { path: name, absolutePath: `${OLD}/${name}`, ownerRoot: OLD, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(underAssets, RUNNING)).toBe(underAssets);
|
||||
});
|
||||
|
||||
it("defaults to this daemon's assets root, which holds all four built-ins", () => {
|
||||
expect(runningBuiltinAssetsRoot()).toBe(path.resolve(import.meta.dirname, "../assets"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("reanchorShippedProjectionEntry", () => {
|
||||
const RUN_SPECS = "/new/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const OLD_SPECS = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/specs";
|
||||
const base = { category: "runtime_resource", effectiveId: "shared:claude-default-settings", sourceSpec: "shared",
|
||||
resourcePath: "runtime/claude-settings.fragment.json", resourceType: "claude_settings_fragment", mergeStrategy: "managed_block", target: ".claude/settings.local.json" };
|
||||
|
||||
it("re-anchors a shipped resource (source and resource under an old install's daemon/specs), preserving every other field", () => {
|
||||
const stored = { ...base, sourcePath: `${OLD_SPECS}/agents/shared`, absolutePath: `${OLD_SPECS}/agents/shared/runtime/claude-settings.fragment.json` };
|
||||
expect(reanchorShippedProjectionEntry(stored, RUN_SPECS)).toEqual({
|
||||
...base, sourcePath: `${RUN_SPECS}/agents/shared`, absolutePath: `${RUN_SPECS}/agents/shared/runtime/claude-settings.fragment.json`,
|
||||
});
|
||||
});
|
||||
|
||||
it("re-anchors kernel agent guidance stored under the rig's own shipped spec directory", () => {
|
||||
const dir = `${OLD_SPECS}/rigs/launch/kernel/agents/advisor/lead`;
|
||||
const stored = { ...base, category: "guidance", effectiveId: "role", sourceSpec: "advisor.lead", sourcePath: dir, absolutePath: `${dir}/guidance/role.md` };
|
||||
expect(reanchorShippedProjectionEntry(stored, RUN_SPECS).absolutePath).toBe(`${RUN_SPECS}/rigs/launch/kernel/agents/advisor/lead/guidance/role.md`);
|
||||
});
|
||||
|
||||
it("re-anchors from a dev checkout (packages/daemon/specs)", () => {
|
||||
const dev = "/src/openrig/packages/daemon/specs";
|
||||
const stored = { ...base, sourcePath: `${dev}/agents/shared`, absolutePath: `${dev}/agents/shared/runtime/claude-mcp.fragment.json` };
|
||||
expect(reanchorShippedProjectionEntry(stored, RUN_SPECS).absolutePath).toBe(`${RUN_SPECS}/agents/shared/runtime/claude-mcp.fragment.json`);
|
||||
});
|
||||
|
||||
it("leaves a plugin projected from ~/.openrig/plugins unchanged even though its sourcePath is a shipped spec", () => {
|
||||
const plugin = { ...base, category: "plugin", effectiveId: "shared:openrig-core", sourcePath: `${OLD_SPECS}/agents/shared`, absolutePath: "/home/u/.openrig/plugins/openrig-core" };
|
||||
expect(reanchorShippedProjectionEntry(plugin, RUN_SPECS)).toBe(plugin);
|
||||
});
|
||||
|
||||
it("leaves user spec resources unchanged", () => {
|
||||
const user = { ...base, sourcePath: "/home/u/rigs/acme/agents/dev", absolutePath: "/home/u/rigs/acme/agents/dev/guidance/role.md" };
|
||||
expect(reanchorShippedProjectionEntry(user, RUN_SPECS)).toBe(user);
|
||||
});
|
||||
|
||||
it("does not treat a user folder merely named daemon/specs as an OpenRig install", () => {
|
||||
const lookalike = { ...base, sourcePath: "/home/u/daemon/specs/agents/x", absolutePath: "/home/u/daemon/specs/agents/x/role.md" };
|
||||
expect(reanchorShippedProjectionEntry(lookalike, RUN_SPECS)).toBe(lookalike);
|
||||
});
|
||||
|
||||
it("defaults to this daemon's shipped specs root", () => {
|
||||
expect(runningShippedSpecsRoot()).toBe(path.resolve(import.meta.dirname, "../specs"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("reanchorBuiltinStartupFile — shipped-spec startup files", () => {
|
||||
const RUN_SPECS = "/new/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const OLD_SPECS = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/specs";
|
||||
const KERNEL = "rigs/launch/kernel";
|
||||
|
||||
it("re-anchors the kernel rig culture (pre-launch guidance_merge), preserving metadata", () => {
|
||||
const stored = { path: "culture/CULTURE.md", absolutePath: `${OLD_SPECS}/${KERNEL}/culture/CULTURE.md`, ownerRoot: `${OLD_SPECS}/${KERNEL}`, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS)).toEqual({
|
||||
...stored, absolutePath: `${RUN_SPECS}/${KERNEL}/culture/CULTURE.md`, ownerRoot: `${RUN_SPECS}/${KERNEL}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("re-anchors agent role and startup context (post-launch send_text)", () => {
|
||||
const agent = `${OLD_SPECS}/${KERNEL}/agents/advisor/lead`;
|
||||
for (const rel of ["guidance/role.md", "startup/context.md"]) {
|
||||
const stored = { path: rel, absolutePath: `${agent}/${rel}`, ownerRoot: agent, deliveryHint: "send_text" as const, required: true, appliesOn: ["fresh_start" as const, "restore" as const] };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS)).toMatchObject({
|
||||
absolutePath: `${RUN_SPECS}/${KERNEL}/agents/advisor/lead/${rel}`, ownerRoot: `${RUN_SPECS}/${KERNEL}/agents/advisor/lead`, deliveryHint: "send_text",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a file outside the shipped specs root unchanged even when its ownerRoot is shipped", () => {
|
||||
const stored = { path: "notes.md", absolutePath: "/home/u/notes.md", ownerRoot: `${OLD_SPECS}/${KERNEL}`, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS)).toBe(stored);
|
||||
});
|
||||
|
||||
it("leaves user rig culture and look-alike daemon/specs folders unchanged", () => {
|
||||
const user = { path: "culture/CULTURE.md", absolutePath: "/home/u/rigs/acme/culture/CULTURE.md", ownerRoot: "/home/u/rigs/acme", ...meta };
|
||||
const lookalike = { path: "culture/CULTURE.md", absolutePath: "/home/u/daemon/specs/r/culture/CULTURE.md", ownerRoot: "/home/u/daemon/specs/r", ...meta };
|
||||
expect(reanchorBuiltinStartupFile(user, RUNNING, RUN_SPECS)).toBe(user);
|
||||
expect(reanchorBuiltinStartupFile(lookalike, RUNNING, RUN_SPECS)).toBe(lookalike);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dev-checkout layouts re-anchor only when the stored file is missing", () => {
|
||||
const RUN_SPECS = "/new/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const DEV = "/src/openrig/packages/daemon";
|
||||
const present = () => true;
|
||||
const missing = () => false;
|
||||
|
||||
it("built-in from a present dev checkout stays as stored; a missing one re-anchors", () => {
|
||||
const stored = { path: "openrig-start.md", absolutePath: `${DEV}/assets/guidance/openrig-start.md`, ownerRoot: `${DEV}/assets`, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, present)).toBe(stored);
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, missing).absolutePath).toBe(`${RUNNING}/guidance/openrig-start.md`);
|
||||
});
|
||||
|
||||
it("shipped-spec startup file from a present dev checkout stays as stored; a missing one re-anchors", () => {
|
||||
const stored = { path: "culture/CULTURE.md", absolutePath: `${DEV}/specs/rigs/launch/kernel/culture/CULTURE.md`, ownerRoot: `${DEV}/specs/rigs/launch/kernel`, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, present)).toBe(stored);
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, missing).absolutePath).toBe(`${RUN_SPECS}/rigs/launch/kernel/culture/CULTURE.md`);
|
||||
});
|
||||
|
||||
it("projection resource from a present dev checkout stays as stored; a missing one re-anchors", () => {
|
||||
const entry = { sourcePath: `${DEV}/specs/agents/shared`, absolutePath: `${DEV}/specs/agents/shared/runtime/claude-mcp.fragment.json`, effectiveId: "shared:claude-default-mcp" };
|
||||
expect(reanchorShippedProjectionEntry(entry, RUN_SPECS, present)).toBe(entry);
|
||||
expect(reanchorShippedProjectionEntry(entry, RUN_SPECS, missing).absolutePath).toBe(`${RUN_SPECS}/agents/shared/runtime/claude-mcp.fragment.json`);
|
||||
});
|
||||
|
||||
it("packaged installs re-anchor even while the stored file is present", () => {
|
||||
const stored = { path: "CULTURE-default.md", absolutePath: `${OLD}/guidance/CULTURE-default.md`, ownerRoot: OLD, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, present).absolutePath).toBe(`${RUNNING}/guidance/CULTURE-default.md`);
|
||||
});
|
||||
|
||||
it("an unrecognized daemon/assets layout is not treated as an OpenRig install", () => {
|
||||
const other = "/opt/something/daemon/assets";
|
||||
const stored = { path: "CULTURE-default.md", absolutePath: `${other}/guidance/CULTURE-default.md`, ownerRoot: other, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, missing)).toBe(stored);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy persisted shapes are returned exactly as stored", () => {
|
||||
it("startup file without ownerRoot (or path) does not throw and is unchanged", () => {
|
||||
const noOwner = { path: "required-onboarding.md", absolutePath: "/tmp/never-read/required-onboarding.md", required: true } as unknown as { path: string; absolutePath: string; ownerRoot: string };
|
||||
expect(reanchorBuiltinStartupFile(noOwner, RUNNING)).toBe(noOwner);
|
||||
const noPath = { absolutePath: `${OLD}/guidance/CULTURE-default.md`, ownerRoot: OLD } as unknown as { path: string; absolutePath: string; ownerRoot: string };
|
||||
expect(() => reanchorBuiltinStartupFile(noPath, RUNNING)).not.toThrow();
|
||||
});
|
||||
|
||||
it("projection entry without sourcePath does not throw and is unchanged", () => {
|
||||
const legacy = { absolutePath: "/tmp/never-read/CLAUDE.md", category: "memory" } as unknown as { sourcePath: string; absolutePath: string };
|
||||
expect(reanchorShippedProjectionEntry(legacy)).toBe(legacy);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
|
||||
const token = "00000000-0000-4000-8000-000000000006";
|
||||
const autoScreen = "Restored conversation\n─────────\n❯\u00a0\n─────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n";
|
||||
const payload = "RC6 harmless startup content";
|
||||
const startedAt = "Thu Oct 1 05:53:16 2026";
|
||||
|
||||
describe("auto-mode startup content requires the launched Claude identity", () => {
|
||||
const dbs: ReturnType<typeof createFullTestDb>[] = [];
|
||||
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
|
||||
|
||||
it.each(["exact", "exit-zsh", "exit-bash", "missing", "wrong-token", "ambiguous", "background", "replaced-pane", "replaced-process", "unavailable", "plain-shell", "resume-missing", "native", "native-resume", "selected-custom", "selected-changed", "selected-wrong-named", "selected-wrong-conventional", "selected-title"])("startup: %s", async (mode) => {
|
||||
const isNative = mode.startsWith("native") || mode.startsWith("selected-");
|
||||
const good = ["exact", "native", "native-resume", "selected-custom", "selected-title"].includes(mode);
|
||||
const resumes = mode === "resume-missing" || mode === "native-resume";
|
||||
const executable = mode === "selected-title" ? "claude" : ["selected-wrong-named", "selected-wrong-conventional"].includes(mode) ? "/other/claude" : mode.startsWith("selected-") ? "/fixture/custom/2.1.285" : "/fixture/.local/share/claude/versions/2.1.285";
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), eventBus = new EventBus(db);
|
||||
const rig = rigRepo.createRig("auto-startup"), node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code" });
|
||||
const name = "test-c@auto-startup", session = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%1" });
|
||||
const calls: { kind: string; text?: string; keys?: string[] }[] = [];
|
||||
let processReads = 0;
|
||||
const listProcesses = vi.fn(async () => {
|
||||
processReads++;
|
||||
if (mode === "unavailable") throw new Error("fixture observation unavailable");
|
||||
const native = { pid: 102, ppid: 101, pgid: mode === "background" ? 999 : 101, tpgid: 101,
|
||||
executableName: ["selected-title", "selected-wrong-named", "selected-wrong-conventional"].includes(mode) ? "claude" : isNative ? "2.1.285" : "claude", startedAt: mode === "replaced-process" && processReads > 1 ? "changed" : startedAt,
|
||||
command: `${isNative ? executable : "/opt/claude.exe"} --permission-mode auto ${resumes ? "--resume" : "--session-id"} ${mode === "wrong-token" ? "other-token" : token} --name ${name}` };
|
||||
return [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh fixture-launch.txt", startedAt },
|
||||
...(["exit-zsh", "exit-bash", "missing", "plain-shell", "resume-missing"].includes(mode) ? [] : [native]),
|
||||
...(mode === "ambiguous" ? [{ ...native, pid: 103 }] : []),
|
||||
];
|
||||
});
|
||||
const tmux = {
|
||||
hasSession: vi.fn(async () => true),
|
||||
getPaneCommand: vi.fn(async () => mode === "exit-zsh" ? "zsh" : mode === "exit-bash" ? "bash" : "sh"),
|
||||
capturePaneContent: vi.fn(async () => mode === "plain-shell" ? "admin@host ~ % " : autoScreen + (mode.startsWith("exit-") ? "Resume this session with:\nclaude --resume old\nadmin@host ~ % " : "")),
|
||||
getPanePid: vi.fn(async () => 100),
|
||||
listPanes: vi.fn(async () => [{ id: mode === "replaced-pane" && processReads > 0 ? "%2" : "%1" }]),
|
||||
sendShellCommand: vi.fn(async (_target: string, text: string) => { calls.push({ kind: "launch", text }); return { ok: true as const }; }),
|
||||
sendText: vi.fn(async (_target: string, text: string) => { calls.push({ kind: "content", text }); return { ok: true as const }; }),
|
||||
sendKeys: vi.fn(async (_target: string, keys: string[]) => { calls.push({ kind: "submit", keys }); return { ok: true as const }; }),
|
||||
} as unknown as TmuxAdapter;
|
||||
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {}, sessionIdFactory: () => token,
|
||||
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`, assertCurrent: () => {}, configDir: "/fixture", ...(isNative ? { executable: mode === "selected-changed" ? "/fixture/custom/2.1.286" : ["selected-wrong-named", "selected-title"].includes(mode) ? "/fixture/custom/2.1.285" : mode === "selected-wrong-conventional" ? "/opt/a/claude" : executable } : {}) }) } as unknown as ClaudeManagedLaunch,
|
||||
fsOps: { exists: () => false, readFile: () => payload, writeFile: () => {}, mkdirp: () => {}, copyFile: () => {} },
|
||||
});
|
||||
const binding: NodeBinding = { id: "binding", nodeId: node.id, tmuxSession: name, tmuxPane: "%1", tmuxWindow: null,
|
||||
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/fixture", permissionMode: "auto" };
|
||||
const result = await new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux }).startNode({
|
||||
rigId: rig.id, nodeId: node.id, sessionId: session.id, binding, adapter,
|
||||
plan: { runtime: "claude-code", cwd: "/fixture", entries: [], startup: { files: [], actions: [] }, conflicts: [], noOps: [] } as never,
|
||||
resolvedStartupFiles: [{ path: "startup.txt", absolutePath: "/fixture/startup.txt", ownerRoot: "/fixture", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }],
|
||||
startupActions: [], isRestore: false, sessionName: name, readinessTimeoutMs: 1,
|
||||
...(resumes ? { resumeToken: token, resumeType: "claude_id", allowFreshFallback: true } : {}),
|
||||
});
|
||||
// Launch authorization is distinct from permission to paste/submit startup content.
|
||||
expect(calls.filter(c => c.kind === "launch")).toEqual([{ kind: "launch", text: `claude --permission-mode auto ${resumes ? "--resume" : "--session-id"} ${token} --name ${name}` }]);
|
||||
if (mode === "resume-missing") expect(result.startupStatus).toBe("attention_required");
|
||||
expect({ ok: result.ok, content: calls.filter(c => c.kind === "content"), submit: calls.filter(c => c.kind === "submit") }).toEqual({
|
||||
ok: good, content: good ? [{ kind: "content", text: payload }] : [], submit: good ? [{ kind: "submit", keys: ["C-m"] }] : [],
|
||||
});
|
||||
// Orchestrator checks readiness before content and once more at completion.
|
||||
if (good && !resumes) expect(listProcesses).toHaveBeenCalledTimes(4);
|
||||
if (mode === "plain-shell") expect(listProcesses).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
|
||||
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
|
||||
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
|
||||
import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
|
||||
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
|
||||
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
// Shape of Fleet's Claude 2.1.220 auto-mode screen after full down/up:
|
||||
// the header has scrolled out; the empty prompt and mode footer remain.
|
||||
const autoScreen = "Restored conversation\n────────────────\n❯\u00a0\n────────────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor\n ● high · /effort\n";
|
||||
const token = "00000000-0000-4000-8000-000000000006";
|
||||
|
||||
describe("managed Claude full down/up", () => {
|
||||
const dbs: ReturnType<typeof createFullTestDb>[] = [];
|
||||
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
|
||||
|
||||
it.each(["exact", "native", "bare-shell", "wrong-token", "unobserved-pane-process", "ambiguous"])(
|
||||
"keeps identity proof separate from ordinary delivery: %s", async (mode) => {
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
const snapshotRepo = new SnapshotRepository(db);
|
||||
const checkpointStore = new CheckpointStore(db);
|
||||
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
|
||||
const rig = rigRepo.createRig("restore-test");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("restore-pod", rig.id, "Test");
|
||||
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code", podId: "restore-pod" });
|
||||
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "retained Fleet posture");
|
||||
const name = "test-c@restore-test";
|
||||
const old = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(old.id, "running");
|
||||
sessionRegistry.updateResumeToken(old.id, "claude_id", token, "scrape");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%old" });
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "claude-code");
|
||||
let live = true;
|
||||
const tmux = {
|
||||
hasSession: vi.fn(async () => live),
|
||||
probeSession: vi.fn(async () => ({ state: live ? "present" : "absent" })),
|
||||
killSession: vi.fn(async () => { live = false; return { ok: true }; }),
|
||||
createSession: vi.fn(async () => { live = true; return { ok: true }; }),
|
||||
listSessions: vi.fn(async () => live ? [{ name }] : []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async () => (mode === "ambiguous" ? ["%new", "%other"] : ["%new"]).map(id => ({ id, index: 0, cwd: "/", width: 80, height: 24, active: true }))),
|
||||
getPanePid: vi.fn(async () => mode === "unobserved-pane-process" ? 999 : 100),
|
||||
getPaneCommand: vi.fn(async () => mode === "bare-shell" ? "bash" : "sh"),
|
||||
capturePaneContent: vi.fn(async () => autoScreen),
|
||||
sendText: vi.fn(async () => ({ ok: true })),
|
||||
sendShellCommand: vi.fn(async () => ({ ok: true })),
|
||||
sendKeys: vi.fn(async () => ({ ok: true })),
|
||||
} as unknown as TmuxAdapter;
|
||||
const startedAt = "Thu Oct 1 05:53:16 2026";
|
||||
const listProcesses = async () => [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: mode === "bare-shell" ? 100 : 101, executableName: "bash", command: "-bash", startedAt },
|
||||
...(mode === "bare-shell" ? [] : [{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /tmp/fixture-launch.txt", startedAt }]),
|
||||
...(mode === "bare-shell" ? [] : [{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, executableName: mode === "native" ? "2.1.285" : "claude", command: `${mode === "native" ? "/fixture/.local/share/claude/versions/2.1.285" : "/opt/claude.exe"} --permission-mode auto --resume ${mode === "wrong-token" ? "different" : token} --name ${name}`, startedAt }]),
|
||||
];
|
||||
// Real teardown captures the running occupant, exits the old row and clears bindings.
|
||||
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotCapture, tmuxAdapter: tmux }).teardown(rig.id);
|
||||
expect(down.errors).toEqual([]);
|
||||
expect(down.sessionsKilled).toBe(1);
|
||||
expect(snapshotRepo.getSnapshot(down.snapshotId!)?.kind).toBe("auto-pre-down");
|
||||
expect(sessionRegistry.getBindingForNode(node.id)).toBeNull();
|
||||
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {},
|
||||
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`, assertCurrent: () => {}, configDir: "/fixture", executable: mode === "native" ? "/fixture/.local/share/claude/versions/2.1.285" : "/opt/claude.exe" }) } as unknown as ClaudeManagedLaunch,
|
||||
fsOps: {
|
||||
exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {},
|
||||
} });
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
|
||||
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux),
|
||||
codexResume: new CodexResumeAdapter(tmux), listProcesses });
|
||||
const up = await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter } });
|
||||
expect(up.ok).toBe(true);
|
||||
if (!up.ok) throw new Error(up.message);
|
||||
const latest = db.prepare("SELECT id, status, startup_status, resume_type, resume_token, resume_provenance FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(node.id);
|
||||
// Preserve the failed-state observation in a red run, before any later scrape.
|
||||
expect({ outcome: up.result.nodes[0], latest }).toMatchObject({
|
||||
// Without #264 the RC's joined restore verdict remains attention-only.
|
||||
// Native metadata and delivery after the next identity sweep are separate assertions.
|
||||
outcome: { status: "attention_required" },
|
||||
latest: (mode === "exact" || mode === "native")
|
||||
? { status: "running", startup_status: "ready", resume_type: "claude_id", resume_token: token, resume_provenance: "scrape" }
|
||||
: { status: "running", startup_status: "attention_required", resume_token: token, resume_provenance: null },
|
||||
});
|
||||
expect(latest).not.toMatchObject({ id: old.id });
|
||||
expect(tmux.sendShellCommand).toHaveBeenCalledExactlyOnceWith(name, `claude --permission-mode auto --resume ${token} --name ${name}`, expect.any(Function));
|
||||
expect(db.prepare("SELECT status, resume_token FROM sessions WHERE id = ?").get(old.id)).toEqual({ status: "exited", resume_token: token });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
expect(tmux.sendKeys).not.toHaveBeenCalled();
|
||||
await new SeatIdentityReconciler({ db, tmux, listProcesses }).reconcileAll();
|
||||
vi.mocked(tmux.sendText).mockClear(); vi.mocked(tmux.sendKeys).mockClear();
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, listProcesses, sleep: async () => {} });
|
||||
const sent = await transport.send(name, "ordinary restored message");
|
||||
// A PID absent from the observation is uncertainty, not a positively wrong pane.
|
||||
const delivers = mode === "exact" || mode === "native" || mode === "unobserved-pane-process";
|
||||
expect(sent, JSON.stringify(sent)).toMatchObject({ ok: delivers });
|
||||
if (mode === "unobserved-pane-process") expect(sent.warning).toContain("without verified native identity");
|
||||
if (mode !== "exact") expect(sent.reason).not.toBe("tmux_unavailable");
|
||||
expect(tmux.sendText).toHaveBeenCalledTimes(delivers ? 1 : 0);
|
||||
expect(tmux.sendKeys).toHaveBeenCalledTimes(delivers ? 1 : 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
// #260: the managed Claude capability query (`claude --help`) must tolerate a slow but
|
||||
// valid help under load, and still end a hung query at a bounded timeout. Drives the real
|
||||
// ClaudeManagedLaunch.prepare() and real execFile against private fake executables; no
|
||||
// provider, tmux, daemon or credentials. Minimal tables as in s03-bound-launch.test.ts.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
|
||||
|
||||
const CHOICES = ' --permission-mode <mode> Permission mode to use for the session (choices: "acceptEdits", "auto", "default", "plan")';
|
||||
const WITHOUT_AUTO = ' --permission-mode <mode> Permission mode to use for the session (choices: "acceptEdits", "default", "plan")';
|
||||
const QUERY_FAILED = "Claude managed capability query failed; no fallback was selected.";
|
||||
|
||||
const open: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of open.splice(0)) db.close(); });
|
||||
|
||||
function fixture(script: string) {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "claude-help-timeout-")));
|
||||
const cwd = path.join(root, "seat"); const bin = path.join(root, "bin"); const calls = path.join(root, "calls");
|
||||
mkdirSync(cwd); mkdirSync(bin); mkdirSync(path.join(root, "home"));
|
||||
writeFileSync(path.join(bin, "claude"), `#!/bin/sh\necho call >> '${calls}'\n${script}\n`);
|
||||
chmodSync(path.join(bin, "claude"), 0o755);
|
||||
const db = new Database(":memory:"); open.push(db);
|
||||
db.exec(`CREATE TABLE nodes(id TEXT, runtime TEXT, cwd TEXT);
|
||||
CREATE TABLE bindings(id TEXT, node_id TEXT, tmux_session TEXT, tmux_pane TEXT);
|
||||
CREATE TABLE occupant_tenures(node_id TEXT, generation_uuid TEXT, generation_ordinal INTEGER);`);
|
||||
db.prepare("INSERT INTO nodes VALUES ('node','claude-code',?)").run(cwd);
|
||||
db.exec("INSERT INTO bindings VALUES ('binding','node','seat','%1'); INSERT INTO occupant_tenures VALUES ('node','generation-1',1)");
|
||||
const managed = new ClaudeManagedLaunch(db, { PATH: `${bin}:/usr/bin:/bin`, HOME: path.join(root, "home") }, {});
|
||||
const helpCalls = () => existsSync(calls) ? readFileSync(calls, "utf8").trim().split("\n").length : 0;
|
||||
return { managed, helpCalls };
|
||||
}
|
||||
|
||||
async function prepare(script: string, mode = "auto", target: { cwd?: string } = {}) {
|
||||
const f = fixture(script);
|
||||
const started = Date.now();
|
||||
try {
|
||||
await f.managed.prepare({ nodeId: "node", ...target }, mode);
|
||||
return { ok: true as const, ms: Date.now() - started, helpCalls: f.helpCalls() };
|
||||
} catch (error) {
|
||||
return { ok: false as const, ms: Date.now() - started, helpCalls: f.helpCalls(), message: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
describe("ClaudeManagedLaunch capability query timeout (#260)", () => {
|
||||
it("accepts a valid help that takes longer than one second", async () => {
|
||||
const result = await prepare(`sleep 1.5; printf '%s\\n' '${CHOICES}'`);
|
||||
expect(result).toMatchObject({ ok: true, helpCalls: 1 });
|
||||
expect(result.ms).toBeGreaterThanOrEqual(1400);
|
||||
}, 15_000);
|
||||
|
||||
it("still ends a hung help at the bounded timeout with the same refusal", async () => {
|
||||
const result = await prepare("exec sleep 30");
|
||||
expect(result).toMatchObject({ ok: false, message: QUERY_FAILED, helpCalls: 1 });
|
||||
// Waited for the 5 s bound, and ended long before the fake's 30 s sleep. The ceiling is
|
||||
// deliberately loose: it proves the query was cut off, not a scheduling-time bound.
|
||||
expect(result.ms).toBeGreaterThanOrEqual(4800);
|
||||
expect(result.ms).toBeLessThan(25_000);
|
||||
}, 40_000);
|
||||
|
||||
it("refuses an immediate non-zero exit without waiting", async () => {
|
||||
const result = await prepare("exit 1");
|
||||
expect(result).toMatchObject({ ok: false, message: QUERY_FAILED });
|
||||
expect(result.ms).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("refuses an empty successful help as unavailable options", async () => {
|
||||
expect(await prepare("exit 0")).toMatchObject({ ok: false, message: "Claude permission options are unavailable; selection was not changed." });
|
||||
});
|
||||
|
||||
it("refuses a requested mode the installed help does not advertise", async () => {
|
||||
expect(await prepare(`printf '%s\\n' '${WITHOUT_AUTO}'`)).toMatchObject({
|
||||
ok: false, message: "Claude permission mode 'auto' is not supported by the installed harness." });
|
||||
});
|
||||
|
||||
it("refuses a target that disagrees with the current binding before querying help", async () => {
|
||||
const result = await prepare(`printf '%s\\n' '${CHOICES}'`, "auto", { cwd: "/not/the/bound/cwd" });
|
||||
expect(result).toMatchObject({ ok: false, helpCalls: 0,
|
||||
message: "Claude managed launch target disagrees with the current binding; no input or selection changed." });
|
||||
});
|
||||
});
|
||||
@@ -172,48 +172,6 @@ describe("ClaudeResumeAdapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
["2.1.283", "Restored conversation\n❯\n ⏵⏵ bypass permissions on (shift+tab to cycle)"],
|
||||
["zsh", "Accessing workspace:\nYes, I trust this folder"],
|
||||
])("preserves inconclusive observations without claiming resume (%s)", async (command, content) => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux({
|
||||
getPaneCommand: async () => command,
|
||||
capturePaneContent: async () => content,
|
||||
}), { pollMs: 0, maxWaitMs: 0 });
|
||||
expect(await adapter.resume("worker", "claude_id", "retained-history", "/repo"))
|
||||
.toMatchObject({ ok: false, code: "attention_required", message: expect.stringContaining("launch retained") });
|
||||
});
|
||||
|
||||
it.each(["command", "capture"])("preserves the launch when %s observation throws", async failedRead => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux({
|
||||
getPaneCommand: async () => { if (failedRead === "command") throw new Error("observation unavailable"); return "2.1.283"; },
|
||||
capturePaneContent: async () => { throw new Error("observation unavailable"); },
|
||||
}), { pollMs: 0, maxWaitMs: 0 });
|
||||
expect(await adapter.resume("worker", "claude_id", "retained-history", "/repo"))
|
||||
.toMatchObject({ ok: false, code: "attention_required", message: expect.stringContaining("observation unavailable") });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["No conversation found", "retry_fresh"],
|
||||
["How would you like to resume?\n❯ Resume from summary\n Resume full session as-is", "attention_required"],
|
||||
])("retains the final observation's distinction: %s", async (content, code) => {
|
||||
const capturePaneContent = vi.fn().mockResolvedValueOnce("").mockResolvedValue(content);
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux({
|
||||
getPaneCommand: async () => "2.1.283", capturePaneContent,
|
||||
}), { pollMs: 0, maxWaitMs: 0 });
|
||||
expect(await adapter.resume("worker", "claude_id", "retained-history", "/repo"))
|
||||
.toMatchObject({ ok: false, code });
|
||||
});
|
||||
|
||||
it("retains positive returned-to-shell refusal", async () => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux({
|
||||
getPaneCommand: async () => "zsh", capturePaneContent: async () => "$ ",
|
||||
}), { pollMs: 0, maxWaitMs: 0 });
|
||||
expect(await adapter.resume("worker", "claude_id", "retained-history", "/repo"))
|
||||
.toMatchObject({ ok: false, code: "retry_fresh" });
|
||||
});
|
||||
|
||||
it("waits for Claude to become the foreground command before succeeding", async () => {
|
||||
const getPaneCommand = vi
|
||||
.fn<(_: string) => Promise<string | null>>()
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, mkdirSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
|
||||
// The child keeps Node's normal unhandled-rejection behavior. Use the built daemon
|
||||
// in package tests; the native source audit may supply its own checkout explicitly.
|
||||
const [phase, home, sourceRoot] = process.argv.slice(2);
|
||||
const moduleRoot = sourceRoot ? join(sourceRoot, "packages/daemon/src") : fileURLToPath(new URL("../../dist/", import.meta.url));
|
||||
const load = (path) => import(pathToFileURL(join(moduleRoot, path + (sourceRoot ? ".ts" : ".js"))));
|
||||
const { startSocketInbound } = await load("domain/gateway/slack/socket-inbound");
|
||||
const { InboundRouter } = await load("domain/gateway/slack/inbound");
|
||||
const { SeenStore, DeadLetterStore, InboundReceiptStore } = await load("domain/gateway/slack/state-store");
|
||||
const { makeQueuePorts } = await load("domain/gateway/slack/queue-access");
|
||||
const { createDb } = await load("db/connection");
|
||||
const { migrate } = await load("db/migrate");
|
||||
const { ALL_MIGRATIONS } = await load("db/all-migrations");
|
||||
const { QueueRepository } = await load("domain/queue-repository");
|
||||
const { EventBus } = await load("domain/event-bus");
|
||||
const deadDir = join(home, "dead");
|
||||
mkdirSync(deadDir);
|
||||
const dead = new DeadLetterStore(join(deadDir, "letters.jsonl"));
|
||||
const seen = new SeenStore(join(home, "seen.jsonl"));
|
||||
const db = createDb(join(home, "state.sqlite"));
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const repo = new QueueRepository(db, new EventBus(db));
|
||||
const router = new InboundRouter({
|
||||
queue: makeQueuePorts(repo), seen, deadLetter: dead, destination: "operator-agent@kernel",
|
||||
resolveSender: () => ({ admitted: true, source: "human-fixture@external" }),
|
||||
});
|
||||
const event = { type: "message", user: "U-FIXTURE", channel: "C-FIXTURE", text: "durable recovery", ts: "1000.1" };
|
||||
let faults = 0;
|
||||
let requests = 0;
|
||||
const sockets = new Set();
|
||||
const server = createServer((_request, response) => {
|
||||
requests++;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ ok: true, url: `ws://127.0.0.1:${server.address().port}/socket` }));
|
||||
});
|
||||
server.on("upgrade", (request, socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
const accept = createHash("sha1").update(String(request.headers["sec-websocket-key"]) + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
|
||||
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
||||
socket.once("data", () => socket.end(Buffer.from([0x88, 0])));
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const inject = () => { dead.append(event, 1); chmodSync(deadDir, 0o500); };
|
||||
if (phase === "onopen") inject();
|
||||
let handle;
|
||||
try {
|
||||
handle = startSocketInbound("synthetic-app-token", router, {
|
||||
retryIntervalMs: 30,
|
||||
receipts: new InboundReceiptStore(join(home, "receipts.jsonl")),
|
||||
fetchImpl: (_url, init) => fetch(`http://127.0.0.1:${server.address().port}`, init),
|
||||
log: (message) => {
|
||||
if (!message.includes("dead-letter retry failed")) return;
|
||||
assert.match(message, /EACCES|EPERM/);
|
||||
assert.equal(dead.readAll().length, 1, "failed rewrite must retain the owed disk entry");
|
||||
faults++;
|
||||
if (faults === 2) chmodSync(deadDir, 0o700);
|
||||
},
|
||||
});
|
||||
const deadline = Date.now() + 5000;
|
||||
while (handle.status().state !== "connected" && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(handle.status().state, "connected");
|
||||
if (phase === "periodic") inject();
|
||||
while ((faults < 2 || dead.readAll().length > 0) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(faults, 2, "both errors logged without terminating the child");
|
||||
assert.equal(dead.readAll().length, 0, "next scheduled pass recovers after the real fault clears");
|
||||
assert.equal(repo.list({ limit: 100 }).length, 1, "seen delivery is not duplicated by retry");
|
||||
assert.ok(seen.load().has(event.ts));
|
||||
assert.equal(handle.status().state, "connected");
|
||||
assert.equal(requests, 1, "recovery does not require reconnecting");
|
||||
console.log(JSON.stringify({ phase, faults, remaining: 0, rows: 1, state: "connected", requests }));
|
||||
} finally {
|
||||
chmodSync(deadDir, 0o700);
|
||||
handle?.stop();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
db.close();
|
||||
}
|
||||
@@ -111,7 +111,7 @@ import { PodBundleSourceResolver } from "../../src/domain/bundle-source-resolver
|
||||
import { NodeCmuxService } from "../../src/domain/node-cmux-service.js";
|
||||
import { AgentActivityStore } from "../../src/domain/agent-activity-store.js";
|
||||
import { SeatAttentionReconciler } from "../../src/domain/seat-attention-reconciler.js";
|
||||
import { createApp, createAppWithWebSocket } from "../../src/server.js";
|
||||
import { createApp } from "../../src/server.js";
|
||||
import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
@@ -260,10 +260,6 @@ export function createTestApp(
|
||||
/** Wire the ready runtime adapters into the routes' `runtimeAdapters`, as startup does, so a
|
||||
* route launch can start harnesses. Off by default: existing tests keep no route adapters. */
|
||||
wireRuntimeAdapters?: boolean;
|
||||
/** Extra or overriding createApp deps (browser-boundary route tests inject inert spies). */
|
||||
appDeps?: Partial<import("../../src/server.js").AppDeps>;
|
||||
/** Build through createAppWithWebSocket (production upgrade path) and return injectWebSocket. */
|
||||
withWebSocket?: boolean;
|
||||
/**
|
||||
* Agent Starter v1 vertical M2 R2: optionally expose the in-test
|
||||
* StartupOrchestrator + PodRigInstantiator so callers can spy on
|
||||
@@ -402,7 +398,7 @@ export function createTestApp(
|
||||
};
|
||||
const upRouter = new UpCommandRouter({ fsOps: upRouterFs });
|
||||
|
||||
const testAppDeps = {
|
||||
const app = createApp({
|
||||
rigRepo, sessionRegistry, eventBus, nodeLauncher, startupOrchestrator, tmuxAdapter: tmux, cmuxAdapter: cmux,
|
||||
snapshotCapture, snapshotRepo, restoreOrchestrator,
|
||||
rigSpecExporter, rigSpecPreflight, rigInstantiator,
|
||||
@@ -433,15 +429,9 @@ export function createTestApp(
|
||||
// and pass it here explicitly.
|
||||
permissionDriftObserver: opts?.permissionDriftObserver ?? { diagnose: () => null },
|
||||
runtimeAdapters: opts?.wireRuntimeAdapters ? adapters : undefined,
|
||||
...opts?.appDeps,
|
||||
};
|
||||
// withWebSocket: the production createAppWithWebSocket path, returning its injectWebSocket.
|
||||
const built = opts?.withWebSocket
|
||||
? createAppWithWebSocket(testAppDeps as never)
|
||||
: { app: createApp(testAppDeps as never), injectWebSocket: undefined };
|
||||
const app = built.app;
|
||||
});
|
||||
return {
|
||||
app, injectWebSocket: built.injectWebSocket, rigRepo, sessionRegistry, eventBus, nodeLauncher, snapshotRepo,
|
||||
app, rigRepo, sessionRegistry, eventBus, nodeLauncher, snapshotRepo,
|
||||
snapshotCapture, checkpointStore, restoreOrchestrator,
|
||||
rigSpecExporter, rigSpecPreflight, rigInstantiator,
|
||||
packageRepo, installRepo, installEngine, installVerifier,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// pre-B12 implementation violated: invoking the sampler must hand control back to the event loop
|
||||
// immediately instead of blocking for the whole spawn. The old code ran ps via execSync inside the
|
||||
// async body, so the CALL ITSELF stalled for the full ps duration (measured ~100-220ms on this
|
||||
// class of box) and every HTTP request queued behind it. The discriminator is a SETTLEMENT
|
||||
// class of box) and every HTTP request queued behind it. The discriminator is an ORDERING
|
||||
// property, not a wall-clock bound — the inline note records why a bound was tried and dropped.
|
||||
// Door test: revert the async wrap locally and this fails; on the candidate it passes.
|
||||
|
||||
@@ -14,23 +14,6 @@ import { describe, it, expect } from "vitest";
|
||||
import { defaultListProcesses as refresherListProcesses } from "../src/domain/resume-metadata-refresher.js";
|
||||
import { defaultListProcesses as codexListProcesses } from "../src/adapters/codex-runtime-adapter.js";
|
||||
|
||||
// The discriminator. Drain microtasks only — no timers, no I/O. An async ps sampler cannot settle
|
||||
// here, because its child-process I/O needs an event-loop turn that no microtask provides. A sampler
|
||||
// that ran ps synchronously inside the call settles within a few ticks (the pre-B12/F1 shape needs
|
||||
// about 3). The depth is a bounded assumption, not a proof that nothing can block: a synchronous
|
||||
// spawn hidden behind more than MICROTASK_DRAIN_TICKS awaits would also read as pending. An earlier
|
||||
// version raced a 0ms timer against the result instead. Timer order can race: in a local probe, a
|
||||
// 70ms synchronous stall after spawning a single-PID ps let the result settle before a 0ms timer in
|
||||
// most trials. A CI failure of that version is consistent with this race (inferred; the CI timing
|
||||
// was not measured), so timer order is not asserted.
|
||||
const MICROTASK_DRAIN_TICKS = 20;
|
||||
async function settlesWithinMicrotasks(pending: Promise<unknown>): Promise<boolean> {
|
||||
let settled = false;
|
||||
void pending.then(() => { settled = true; }, () => { settled = true; });
|
||||
for (let i = 0; i < MICROTASK_DRAIN_TICKS; i++) await Promise.resolve();
|
||||
return settled;
|
||||
}
|
||||
|
||||
const SITES = [
|
||||
["resume-metadata-refresher", refresherListProcesses],
|
||||
["codex-runtime-adapter", codexListProcesses],
|
||||
@@ -48,14 +31,19 @@ describe.each(SITES)("B12-T real async list_processes — %s", (_site, listProce
|
||||
it("hands control back to the event loop instead of blocking for the spawn (RED on the pre-B12 sync implementation)", async () => {
|
||||
// Note on what is NOT asserted: the invocation's synchronous-return time. Measured here, even
|
||||
// the async implementation spends 60-80ms in the call under load (child-process spawn setup),
|
||||
// so a wall-clock bound is environment-hostage. The pre-B12 execSync implementation finished ps
|
||||
// inside the call, so its promise settles within the microtask drain; the async one cannot.
|
||||
// so a wall-clock bound is environment-hostage. The deterministic discriminator is ORDER: the
|
||||
// pre-B12 execSync implementation finished ps inside the call, so its promise settles on the
|
||||
// first microtask — ahead of any timer — and `turnedBeforeResolve` reads false there, always.
|
||||
const pending = listProcesses();
|
||||
const settledEarly = await settlesWithinMicrotasks(pending);
|
||||
const rows = await pending;
|
||||
|
||||
// While ps runs, the loop must turn: a 0ms timer armed AFTER the call must fire BEFORE the
|
||||
// (much slower) spawn resolves.
|
||||
let loopTurnedFirst = false;
|
||||
setTimeout(() => { loopTurnedFirst = true; }, 0);
|
||||
const { rows, turnedBeforeResolve } = await pending.then((r) => ({ rows: r, turnedBeforeResolve: loopTurnedFirst }));
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0); // the non-blocking return was not an empty-result shortcut
|
||||
expect(settledEarly).toBe(false);
|
||||
expect(turnedBeforeResolve).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,12 +77,13 @@ describe("F1 real async resolve_home — codex-thread-id", () => {
|
||||
|
||||
it("hands control back to the event loop instead of blocking for the spawn (RED on the pre-F1 sync implementation)", async () => {
|
||||
const { defaultResolveHomeDirByPid } = await import("../src/domain/codex-thread-id.js");
|
||||
const { home, settledEarly } = await withChild(async (pid) => {
|
||||
const pending = Promise.resolve(defaultResolveHomeDirByPid(pid));
|
||||
const settledEarly = await settlesWithinMicrotasks(pending);
|
||||
return { home: await pending, settledEarly };
|
||||
const { home, turnedBeforeResolve } = await withChild(async (pid) => {
|
||||
const pending = defaultResolveHomeDirByPid(pid);
|
||||
let loopTurnedFirst = false;
|
||||
setTimeout(() => { loopTurnedFirst = true; }, 0);
|
||||
return Promise.resolve(pending).then((h) => ({ home: h, turnedBeforeResolve: loopTurnedFirst }));
|
||||
});
|
||||
expect(home).toBe("/tmp/f1-probe-home"); // the fast return was not an empty shortcut
|
||||
expect(settledEarly).toBe(false);
|
||||
expect(turnedBeforeResolve).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
import { it, expect } from "vitest";
|
||||
import { verifyClaudePaneProcess } from "../src/domain/native-process-lineage.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
const token = "00000000-0000-4000-8000-000000000197";
|
||||
const startedAt = "Thu Oct 1 11:00:00 2026";
|
||||
const autoScreen = "Restored conversation\n─────────\n❯\u00a0\n─────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n";
|
||||
const modes = [
|
||||
"npm-name", "versioned", "versioned-argv-only", "versioned-comm-only", "wrong-token",
|
||||
"missing-token", "wrong-pane", "bare-shell", "unavailable", "pane-command-unavailable",
|
||||
"background", "other-semver", "unknown-both", "versioned-direct-pane", "missing-metadata",
|
||||
"ambiguous-pane", "changed-binding", "onboarding", "changed-process", "ambiguous-process",
|
||||
"changed-after-paste", "bare-shell-helper", "bare-shell-job",
|
||||
"wrong-token-post-read-error", "wrong-token-post-read-empty",
|
||||
"bare-shell-post-read-error", "bare-shell-post-read-empty",
|
||||
"bare-shell-second-process-unavailable", "bare-shell-first-process-unavailable",
|
||||
];
|
||||
|
||||
it.each(modes)("selector and ordinary transport: %s", async (mode) => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), eventBus = new EventBus(db);
|
||||
const rig = rigRepo.createRig("native-test"), node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code" });
|
||||
const name = "test-c@native-test", session = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
if (mode !== "missing-metadata") sessionRegistry.updateResumeToken(session.id, "claude_id", token, "scrape");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%1" });
|
||||
const executable = ["versioned", "versioned-argv-only", "versioned-direct-pane"].includes(mode)
|
||||
? "/fixture/.local/share/claude/versions/2.1.285"
|
||||
: mode === "other-semver" ? "/unrelated/2.1.285" : "/opt/claude";
|
||||
const bare = mode.startsWith("bare-shell");
|
||||
const shell = mode === "bare-shell-helper" ? "zsh" : "bash";
|
||||
const rows = [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: bare ? 100 : 101,
|
||||
executableName: shell, command: `-${shell}`, startedAt },
|
||||
...(bare ? [] : [
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101,
|
||||
executableName: "sh", command: "/bin/sh /fixture/launch", startedAt },
|
||||
{ pid: 102, ppid: 101, pgid: mode === "background" ? 999 : 101, tpgid: 101,
|
||||
executableName: ["versioned", "versioned-comm-only", "other-semver", "versioned-direct-pane"].includes(mode) ? "2.1.285" : "claude",
|
||||
command: `${executable} --permission-mode auto ${mode === "missing-token" ? "" : `--session-id ${mode.startsWith("wrong-token") ? "different" : token}`} --name ${name}`, startedAt },
|
||||
]),
|
||||
];
|
||||
// The shell still owns the terminal; these children are in background groups.
|
||||
if (mode === "bare-shell-helper" || mode === "bare-shell-job") {
|
||||
rows.push({ pid: 103, ppid: 100, pgid: 103, tpgid: 100,
|
||||
executableName: mode === "bare-shell-helper" ? "gitstatusd" : "sleep",
|
||||
command: mode === "bare-shell-helper" ? "/fixture/gitstatusd" : "sleep 600", startedAt });
|
||||
}
|
||||
if (mode === "ambiguous-process") rows.push({ ...rows[2]!, pid: 103 });
|
||||
let reads = 0;
|
||||
const listProcesses = async () => {
|
||||
if (["unavailable", "unknown-both"].includes(mode)) throw new Error("fixture unavailable");
|
||||
reads++;
|
||||
if ((mode === "bare-shell-second-process-unavailable" && reads === 2)
|
||||
|| (mode === "bare-shell-first-process-unavailable" && reads === 1)) throw new Error("process sample unavailable");
|
||||
return mode === "changed-process" && reads % 2 === 0
|
||||
? rows.map(row => row.pid === 102 ? { ...row, startedAt: "replacement" } : row) : rows;
|
||||
};
|
||||
const calls: string[] = [];
|
||||
let paneReads = 0;
|
||||
const tmux = {
|
||||
hasSession: async () => true,
|
||||
probeSession: async () => ({ state: "present" }),
|
||||
getPanePid: async (target: string) => mode === "wrong-pane" && target === "%1" ? 999 : 100,
|
||||
getPaneCommand: async () => {
|
||||
if (["pane-command-unavailable", "unknown-both"].includes(mode)) throw new Error("fixture command unavailable");
|
||||
return mode === "versioned-direct-pane" ? "2.1.285" : "sh";
|
||||
},
|
||||
listPanes: async () => {
|
||||
paneReads++;
|
||||
if (paneReads > 1 && mode.endsWith("post-read-error")) throw new Error("later pane observation unavailable");
|
||||
if (paneReads > 1 && mode.endsWith("post-read-empty")) return [];
|
||||
return mode === "ambiguous-pane" ? [{ id: "%1" }, { id: "%2" }] : [{ id: "%1" }];
|
||||
},
|
||||
capturePaneContent: async () => {
|
||||
if (mode === "changed-binding") sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%2" });
|
||||
return mode === "onboarding" ? "Do you trust the files in this folder?\n❯ 1. Yes, I trust this folder\n 2. No, exit"
|
||||
: bare ? "admin@fixture ~ % " : autoScreen;
|
||||
},
|
||||
sendText: async () => {
|
||||
calls.push("text");
|
||||
if (mode === "changed-after-paste") sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%2" });
|
||||
return { ok: true };
|
||||
},
|
||||
sendKeys: async () => { calls.push("enter"); return { ok: true }; },
|
||||
} as unknown as TmuxAdapter;
|
||||
const observation = await verifyClaudePaneProcess({ target: "%1", tmux, listProcesses, expectedToken: token });
|
||||
reads = 0; // Each consumer starts its own observation sequence.
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, listProcesses, sleep: async () => {} });
|
||||
const sent = await transport.send(name, "harmless fixture message");
|
||||
const expectedSend = [
|
||||
"npm-name", "versioned", "pane-command-unavailable", "unknown-both", "versioned-direct-pane",
|
||||
"missing-token", "unavailable", "background", "other-semver", "missing-metadata",
|
||||
"versioned-argv-only", "versioned-comm-only",
|
||||
].includes(mode);
|
||||
if (bare) expect({ ok: sent.ok, calls }).toEqual({ ok: false, calls: [] });
|
||||
expect(sent.ok).toBe(expectedSend);
|
||||
expect(calls).toEqual(expectedSend ? ["text", "enter"] : mode === "changed-after-paste" ? ["text"] : []);
|
||||
// A strict process selector proves only the supplied process/token, not the DB/pane binding.
|
||||
expect(observation !== null).toBe([
|
||||
"npm-name", "versioned", "pane-command-unavailable", "versioned-direct-pane", "missing-metadata",
|
||||
"ambiguous-pane", "changed-binding", "onboarding", "changed-after-paste",
|
||||
].includes(mode));
|
||||
if (expectedSend && ["unavailable", "unknown-both", "missing-token", "missing-metadata", "background", "other-semver", "versioned-argv-only", "versioned-comm-only"].includes(mode)) {
|
||||
expect(sent.warning).toContain("without verified native identity");
|
||||
}
|
||||
reads = 0; paneReads = 0;
|
||||
const queue = new QueueRepository(db, eventBus, { transport, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
// Exercise the actual wake consumer without an API, scheduler, or native process.
|
||||
const wake = await (queue as unknown as {
|
||||
performWakeSend(id: string, destination: string, sender: string): Promise<{ classified: string }>;
|
||||
}).performWakeSend("fixture-q197", name, "fixture-sender@native-test");
|
||||
expect(wake.classified).toBe(expectedSend ? "indeterminate" : "failed");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
@@ -7,31 +7,6 @@ import {
|
||||
} from "../src/domain/native-resume-probe.js";
|
||||
|
||||
describe("native resume probe", () => {
|
||||
describe("headerless Claude auto-mode requires managed identity proof", () => {
|
||||
const screen = "Restored conversation\n❯\u00a0\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n";
|
||||
it("keeps every screen-only caller conservative", () => {
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent: screen }))
|
||||
.toMatchObject({ status: "inconclusive", code: "claude_auto_identity_required" });
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent: screen, claudeAutoIdentityVerified: true }))
|
||||
.toMatchObject({ status: "resumed", code: "active_runtime" });
|
||||
});
|
||||
it.each([
|
||||
" ⏵⏵ auto mode on (shift+tab to cycle)",
|
||||
"❯\nThe manual says auto mode on (shift+tab to cycle)",
|
||||
"❯\n ⏵⏵ auto mode on",
|
||||
])("still needs the actual prompt/footer shape: %s", (paneContent) => {
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent, claudeAutoIdentityVerified: true }).status).not.toBe("resumed");
|
||||
});
|
||||
it.each([
|
||||
["Accessing workspace:\nYes, I trust this folder", "trust_gate"],
|
||||
["new MCP servers found in .mcp.json\nSelect any you wish to enable\nEnter to confirm", "mcp_gate"],
|
||||
["Not logged in · Run /login", "login_required"],
|
||||
["How would you like to resume?\n❯ Resume from summary\n Resume full session as-is", "claude_resume_selection_prompt"],
|
||||
["No conversation found", "no_conversation_found"],
|
||||
])("does not let identity proof waive prerequisites: %s", (panel, code) => {
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent: `${screen}\n${panel}`, claudeAutoIdentityVerified: true }).code).toBe(code);
|
||||
});
|
||||
});
|
||||
describe("issue116 headerless custom status lines", () => {
|
||||
const reportedFooter = " 5h 71% left · weekly 24% left · GPT-6-Astra high · Context 81% left";
|
||||
it.each(["›", "»"])("recognizes the reported footer below a %s conversation prompt", (prompt) => {
|
||||
|
||||
@@ -117,8 +117,8 @@ function claudeNode(overrides?: Partial<NodeInventoryEntry> & { cwd?: string | n
|
||||
function startupContextProbe(options?: {
|
||||
status?: "ok" | "missing" | "malformed" | "probe_error";
|
||||
evidence?: string;
|
||||
resolvedStartupFiles?: Array<{ absolutePath: string; required: boolean; path?: string; deliveryHint?: string; ownerRoot?: string }>;
|
||||
projectionEntries?: Array<{ absolutePath: string; effectiveId?: string; category?: string; sourcePath?: string }>;
|
||||
resolvedStartupFiles?: Array<{ absolutePath: string; required: boolean; path?: string; deliveryHint?: string }>;
|
||||
projectionEntries?: Array<{ absolutePath: string; effectiveId?: string; category?: string }>;
|
||||
runtime?: string;
|
||||
}) {
|
||||
if (options?.status && options.status !== "ok") {
|
||||
@@ -1478,87 +1478,6 @@ describe("RestoreCheckService", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
// #261: restore-check inspects and REPORTS the paths replay will use. A stale old-install built-in or
|
||||
// shipped-spec resource is judged at the running install; red/yellow semantics are unchanged.
|
||||
describe("#261 stale old-install paths are judged at the running install", () => {
|
||||
const OLD = "/old-openrig/lib/node_modules/@openrig/cli/daemon";
|
||||
const RUN_ASSETS = path.resolve(import.meta.dirname, "../assets");
|
||||
const RUN_SPECS = path.resolve(import.meta.dirname, "../specs");
|
||||
const files = [{ path: "CULTURE-default.md", absolutePath: `${OLD}/assets/guidance/CULTURE-default.md`, ownerRoot: `${OLD}/assets`, required: true }];
|
||||
const entries = [{ absolutePath: `${OLD}/specs/agents/shared/runtime/claude-settings.fragment.json`, sourcePath: `${OLD}/specs/agents/shared`, effectiveId: "shared:claude-default-settings", category: "runtime_resource" }];
|
||||
const stoppedNode = () => claudeNode({ sessionStatus: "stopped", startupStatus: "failed", latestError: "seat crashed" });
|
||||
|
||||
it("old install removed, current present: green, and evidence names only the running paths", () => {
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
getNodeInventory: () => [stoppedNode()],
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: files, projectionEntries: entries }) as never,
|
||||
getLatestSnapshot: () => ({ id: "snap-261", kind: "auto-pre-down" }),
|
||||
exists: (p) => !p.startsWith("/old-openrig"),
|
||||
}));
|
||||
const startup = (service.check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
expect(startup.status).toBe("green");
|
||||
expect(startup.evidence).toContain(`${RUN_ASSETS}/guidance/CULTURE-default.md`);
|
||||
expect(startup.evidence).toContain(`${RUN_SPECS}/agents/shared/runtime/claude-settings.fragment.json`);
|
||||
expect(startup.evidence).not.toContain("/old-openrig");
|
||||
});
|
||||
|
||||
it("current built-in genuinely missing on a non-ready seat: still red, reporting the running path", () => {
|
||||
const runningCulture = `${RUN_ASSETS}/guidance/CULTURE-default.md`;
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
getNodeInventory: () => [stoppedNode()],
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: files }) as never,
|
||||
getLatestSnapshot: () => ({ id: "snap-261", kind: "auto-pre-down" }),
|
||||
exists: (p) => !p.startsWith("/old-openrig") && p !== runningCulture,
|
||||
}));
|
||||
const startup = (service.check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
expect(startup.status).toBe("red");
|
||||
expect(startup.evidence).toContain(runningCulture);
|
||||
expect(startup.remediation).toContain(runningCulture);
|
||||
expect(startup.evidence).not.toContain("/old-openrig");
|
||||
});
|
||||
|
||||
it("shipped-spec rig culture: stale old path is judged at the running specs (green when present, red naming it when missing)", () => {
|
||||
const culture = [{ path: "culture/CULTURE.md", absolutePath: `${OLD}/specs/rigs/launch/kernel/culture/CULTURE.md`, ownerRoot: `${OLD}/specs/rigs/launch/kernel`, required: true }];
|
||||
const runningCulture = `${RUN_SPECS}/rigs/launch/kernel/culture/CULTURE.md`;
|
||||
const check = (exists: (p: string) => boolean) => (new RestoreCheckService(mockDeps({
|
||||
getNodeInventory: () => [stoppedNode()],
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: culture }) as never,
|
||||
getLatestSnapshot: () => ({ id: "snap-261", kind: "auto-pre-down" }),
|
||||
exists,
|
||||
})).check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
const present = check((p) => !p.startsWith("/old-openrig"));
|
||||
expect(present.status).toBe("green");
|
||||
expect(present.evidence).toContain(runningCulture);
|
||||
const missing = check((p) => !p.startsWith("/old-openrig") && p !== runningCulture);
|
||||
expect(missing.status).toBe("red");
|
||||
expect(missing.evidence).toContain(runningCulture);
|
||||
expect(missing.evidence).not.toContain("/old-openrig");
|
||||
});
|
||||
|
||||
it("current built-in missing on a running/ready seat stays yellow (red/yellow rule unchanged)", () => {
|
||||
const runningCulture = `${RUN_ASSETS}/guidance/CULTURE-default.md`;
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: files }) as never,
|
||||
exists: (p) => !p.startsWith("/old-openrig") && p !== runningCulture,
|
||||
}));
|
||||
const startup = (service.check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
expect(startup.status).toBe("yellow");
|
||||
});
|
||||
|
||||
it("a missing custom file with a built-in basename is reported as stored, still red on a non-ready seat", () => {
|
||||
const custom = [{ path: "CULTURE-default.md", absolutePath: "/user-rig/CULTURE-default.md", ownerRoot: "/user-rig", required: true }];
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
getNodeInventory: () => [stoppedNode()],
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: custom }) as never,
|
||||
getLatestSnapshot: () => ({ id: "snap-261", kind: "auto-pre-down" }),
|
||||
exists: (p) => p !== "/user-rig/CULTURE-default.md",
|
||||
}));
|
||||
const startup = (service.check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
expect(startup.status).toBe("red");
|
||||
expect(startup.evidence).toContain("/user-rig/CULTURE-default.md");
|
||||
});
|
||||
});
|
||||
|
||||
it("running/ready node with missing required startup file is a yellow caveat", () => {
|
||||
const requiredPath = path.join(os.tmpdir(), "restore-check-startup-required-ready-missing.md");
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
|
||||
@@ -1122,45 +1122,6 @@ describe("RestoreOrchestrator", () => {
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it.each(["opaque", "command-error", "capture-error", "null"])("legacy %s observation preserves current binding and retained history", async mode => {
|
||||
const snap = seedRigAndSnapshot({
|
||||
nodes: [{ logicalId: "worker", role: "worker", runtime: "claude-code" }],
|
||||
edges: [], resumeType: "claude_id", resumeToken: "retained-history", withBinding: "worker",
|
||||
});
|
||||
const nodeId = snap.data.nodes[0]!.id;
|
||||
const readState = () => ({
|
||||
binding: sessionRegistry.getBindingForNode(nodeId),
|
||||
sessions: db.prepare("SELECT id, status, resume_type, resume_token FROM sessions WHERE node_id=? ORDER BY id").all(nodeId),
|
||||
});
|
||||
const tmux = mockTmux();
|
||||
vi.mocked(tmux.getPaneCommand).mockImplementation(async () => {
|
||||
if (mode === "command-error") throw new Error("command observation unavailable");
|
||||
return mode === "null" ? null : "2.1.283";
|
||||
});
|
||||
vi.mocked(tmux.capturePaneContent).mockImplementation(async () => {
|
||||
if (mode === "capture-error") throw new Error("capture observation unavailable");
|
||||
return mode === "null" ? null : "Restored conversation\n❯\n ⏵⏵ bypass permissions on (shift+tab to cycle)";
|
||||
});
|
||||
const claude = new ClaudeResumeAdapter(tmux, { pollMs: 0, maxWaitMs: 0 });
|
||||
const resume = claude.resume.bind(claude);
|
||||
let afterLaunch: ReturnType<typeof readState> | undefined;
|
||||
vi.spyOn(claude, "resume").mockImplementation(async (...args) => {
|
||||
afterLaunch = readState();
|
||||
return resume(...args);
|
||||
});
|
||||
const result = await createOrchestrator({ tmux, claude }).restore(snap.id);
|
||||
expect(result).toMatchObject({ ok: true, result: { rigResult: "partially_restored", nodes: [{ status: "attention_required" }] } });
|
||||
expect(afterLaunch?.binding).not.toBeNull();
|
||||
expect(afterLaunch).toBeDefined();
|
||||
expect(readState()).toEqual(afterLaunch);
|
||||
expect(readState().sessions).toContainEqual(expect.objectContaining({ resume_type: "claude_id", resume_token: "retained-history" }));
|
||||
expect(readState().sessions).toContainEqual(expect.objectContaining({ status: "running", resume_token: null }));
|
||||
expect(tmux.killSession).not.toHaveBeenCalled();
|
||||
// Only the resume command and its submit; never orientation/checkpoint input.
|
||||
expect(tmux.sendText).toHaveBeenCalledTimes(1);
|
||||
expect(tmux.sendKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("legacy Claude resume verification failure -> status 'failed'", async () => {
|
||||
const snap = seedRigAndSnapshot({
|
||||
nodes: [{ logicalId: "worker", role: "worker", runtime: "claude-code" }],
|
||||
@@ -3684,135 +3645,4 @@ describe("RestoreOrchestrator", () => {
|
||||
expect(subsetEvents[0]?.result?.warnings?.some((w) => w.includes("FR-5"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// #261: stored built-in startup files follow the running install on restore (pre-validation + replay);
|
||||
// custom files are delivered exactly as stored; a same-native resume still replays nothing.
|
||||
describe("#261 built-in startup files follow the running install", () => {
|
||||
const OLD_ASSETS = "/old-openrig/lib/node_modules/@openrig/cli/daemon/assets";
|
||||
const RUNNING_ASSETS = path.resolve(import.meta.dirname, "../assets");
|
||||
const builtin = (name: string, rel: string) => ({
|
||||
path: name, absolutePath: `${OLD_ASSETS}/${rel}`, ownerRoot: OLD_ASSETS,
|
||||
deliveryHint: "guidance_merge" as const, required: true, appliesOn: ["fresh_start" as const, "restore" as const],
|
||||
});
|
||||
const customSameBasename = {
|
||||
path: "CULTURE-default.md", absolutePath: "/user-rig/CULTURE-default.md", ownerRoot: "/user-rig",
|
||||
deliveryHint: "guidance_merge" as const, required: true, appliesOn: ["fresh_start" as const, "restore" as const],
|
||||
};
|
||||
const storedFiles = [
|
||||
builtin("CULTURE-default.md", "guidance/CULTURE-default.md"),
|
||||
builtin("openrig-start.md", "guidance/openrig-start.md"),
|
||||
customSameBasename,
|
||||
];
|
||||
const notOld = (p: string) => !p.startsWith("/old-openrig");
|
||||
|
||||
function seedPodAware(resume: boolean) {
|
||||
const rig = rigRepo.createRig("test-rig");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-261", rig.id, "Dev");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", podId: "pod-261" });
|
||||
const session = sessionRegistry.registerSession(node.id, "dev-impl@test-rig");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
if (resume) sessionRegistry.updateResumeToken(session.id, "claude_id", "resume-token-261");
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(node.id, "[]", JSON.stringify(storedFiles), "[]", "claude-code");
|
||||
const snap = snapshotCapture.captureSnapshot(rig.id, "test");
|
||||
sessionRegistry.updateStatus(session.id, "exited");
|
||||
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
|
||||
const deliverStartup = vi.fn(async () => ({ delivered: 0, failed: [] }));
|
||||
const adapter = {
|
||||
runtime: "claude-code",
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup,
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
launchHarness: vi.fn(async () => ({ ok: true as const, resumeToken: "t", resumeType: "claude_id" })),
|
||||
};
|
||||
return { snap, deliverStartup, adapter };
|
||||
}
|
||||
|
||||
it("fresh replay: old install removed -> no blocker, built-ins delivered from the running install, custom file unchanged", async () => {
|
||||
const { snap, deliverStartup, adapter } = seedPodAware(false);
|
||||
const result = await createOrchestrator().restore(snap.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: notOld }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const delivered = deliverStartup.mock.calls.flatMap((c) => c[0] as Array<{ path: string; absolutePath: string; ownerRoot: string; required: boolean; appliesOn: string[]; deliveryHint: string }>);
|
||||
const culture = delivered.filter((f) => f.path === "CULTURE-default.md");
|
||||
expect(culture.map((f) => f.absolutePath).sort()).toEqual([`${RUNNING_ASSETS}/guidance/CULTURE-default.md`, "/user-rig/CULTURE-default.md"].sort());
|
||||
expect(delivered.find((f) => f.path === "openrig-start.md")).toMatchObject({
|
||||
absolutePath: `${RUNNING_ASSETS}/guidance/openrig-start.md`, ownerRoot: RUNNING_ASSETS,
|
||||
required: true, appliesOn: ["fresh_start", "restore"], deliveryHint: "guidance_merge",
|
||||
});
|
||||
expect(delivered.some((f) => f.absolutePath.startsWith("/old-openrig"))).toBe(false);
|
||||
});
|
||||
|
||||
it("re-anchors even while the old install still exists (current shipped guidance is selected)", async () => {
|
||||
const { snap, deliverStartup, adapter } = seedPodAware(false);
|
||||
const result = await createOrchestrator().restore(snap.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: () => true }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const delivered = deliverStartup.mock.calls.flatMap((c) => c[0] as Array<{ absolutePath: string }>);
|
||||
expect(delivered.some((f) => f.absolutePath.startsWith("/old-openrig"))).toBe(false);
|
||||
expect(delivered.some((f) => f.absolutePath === `${RUNNING_ASSETS}/guidance/CULTURE-default.md`)).toBe(true);
|
||||
});
|
||||
|
||||
it("a genuinely missing CURRENT built-in still blocks restore honestly, naming the running path", async () => {
|
||||
const { snap, adapter } = seedPodAware(false);
|
||||
const runningCulture = `${RUNNING_ASSETS}/guidance/CULTURE-default.md`;
|
||||
const result = await createOrchestrator().restore(snap.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: (p) => notOld(p) && p !== runningCulture }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.result.blockers?.[0]).toMatchObject({ code: "required_startup_file_missing", path: runningCulture });
|
||||
});
|
||||
|
||||
it("extension: replay projects shipped-spec resources from the running install; plugin entry untouched; no drift warning for the old path", async () => {
|
||||
const OLD_SPECS = "/old-openrig/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const RUNNING_SPECS = path.resolve(import.meta.dirname, "../specs");
|
||||
const { snap, adapter } = seedPodAware(false);
|
||||
const entries = [
|
||||
{ category: "runtime_resource", effectiveId: "shared:claude-default-mcp", sourceSpec: "shared", sourcePath: `${OLD_SPECS}/agents/shared`, resourcePath: "r", absolutePath: `${OLD_SPECS}/agents/shared/runtime/claude-mcp.fragment.json`, resourceType: "claude_mcp_fragment" },
|
||||
{ category: "plugin", effectiveId: "shared:openrig-core", sourceSpec: "shared", sourcePath: `${OLD_SPECS}/agents/shared`, resourcePath: "p", absolutePath: "/home/u/.openrig/plugins/openrig-core" },
|
||||
];
|
||||
const fixed = updateSnapshotData(snap, (data) => { for (const k of Object.keys(data.nodeStartupContext)) data.nodeStartupContext[k].projectionEntries = entries; });
|
||||
const result = await createOrchestrator().restore(fixed.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: notOld }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const projected = adapter.project.mock.calls.flatMap((c) => ((c[0] as { entries: Array<{ effectiveId: string; absolutePath: string }> }).entries));
|
||||
expect(projected.find((e) => e.effectiveId === "shared:claude-default-mcp")?.absolutePath).toBe(`${RUNNING_SPECS}/agents/shared/runtime/claude-mcp.fragment.json`);
|
||||
expect(projected.find((e) => e.effectiveId === "shared:openrig-core")?.absolutePath).toBe("/home/u/.openrig/plugins/openrig-core");
|
||||
if (result.ok) {
|
||||
const drift = (result.result.warnings ?? []).filter((w) => w.includes("projection_drift"));
|
||||
// The re-anchored resource raises no drift; the deliberately untouched plugin entry keeps its honest
|
||||
// "source root missing" warning for the old shipped sourcePath while its own file still projects.
|
||||
expect(drift.some((w) => w.includes("claude-mcp.fragment.json"))).toBe(false);
|
||||
expect(drift.every((w) => w.includes("source root missing") && w.includes("/old-openrig/lib/node_modules/@openrig/cli/daemon/specs/agents/shared"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("startup extension: replay delivers a shipped-spec rig culture from the running install; custom unchanged", async () => {
|
||||
const OLD_SPECS = "/old-openrig/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const RUNNING_SPECS = path.resolve(import.meta.dirname, "../specs");
|
||||
const { snap, deliverStartup, adapter } = seedPodAware(false);
|
||||
const shippedCulture = { path: "culture/CULTURE.md", absolutePath: `${OLD_SPECS}/rigs/launch/kernel/culture/CULTURE.md`, ownerRoot: `${OLD_SPECS}/rigs/launch/kernel`, deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"] };
|
||||
const fixed = updateSnapshotData(snap, (data) => { for (const k of Object.keys(data.nodeStartupContext)) data.nodeStartupContext[k].resolvedStartupFiles = [shippedCulture, customSameBasename]; });
|
||||
const result = await createOrchestrator().restore(fixed.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: notOld }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const delivered = deliverStartup.mock.calls.flatMap((c) => (c[0] as Array<{ absolutePath: string }>).map((f) => f.absolutePath));
|
||||
expect(delivered.sort()).toEqual([`${RUNNING_SPECS}/rigs/launch/kernel/culture/CULTURE.md`, "/user-rig/CULTURE-default.md"].sort());
|
||||
});
|
||||
|
||||
it("same-native resume: replay stays contained (no startup files delivered), stored built-ins notwithstanding", async () => {
|
||||
const { snap, deliverStartup, adapter } = seedPodAware(true);
|
||||
const orch = createOrchestrator({ listProcesses: nativeLineage("claude-code", "resume-token-261") });
|
||||
const result = await orch.restore(snap.id, { adapters: { "claude-code": adapter }, fsOps: { exists: notOld } });
|
||||
expect(result.ok).toBe(true);
|
||||
const delivered = deliverStartup.mock.calls.flatMap((c) => c[0] as unknown[]);
|
||||
expect(delivered).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
@@ -76,7 +75,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
}),
|
||||
probeSession: vi.fn(async (name: string) => alive.has(name) ? { state: "present" as const } : { state: "absent" as const }),
|
||||
hasSession: vi.fn(async (name: string) => alive.has(name)),
|
||||
startServer: vi.fn(async (): Promise<TmuxResult> => ({ ok: true })),
|
||||
listSessions: vi.fn(async () => [...alive].map((name) => ({ name, windows: 1, created: "", attached: false }))),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async (name: string) => {
|
||||
@@ -321,144 +319,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
|
||||
expect(alive.has(seat.sessionName)).toBe(false);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Real tmux ends its server when the last session goes (exit-empty), and a
|
||||
// probe then fails "no server running": transport_unavailable, never absence.
|
||||
// new-session and startServer() each bring a server back.
|
||||
function modelServerLifetime() {
|
||||
const server = { up: true, starts: 0 };
|
||||
const createSession = vi.mocked(tmux.createSession).getMockImplementation()!;
|
||||
vi.mocked(tmux.createSession).mockImplementation(async (...args) => {
|
||||
server.up = true;
|
||||
return createSession(...args);
|
||||
});
|
||||
vi.mocked(tmux.killSession).mockImplementation(async (name: string) => {
|
||||
killed.push(name);
|
||||
alive.delete(name);
|
||||
livePanes.delete(name);
|
||||
if (alive.size === 0) server.up = false;
|
||||
return { ok: true };
|
||||
});
|
||||
vi.mocked(tmux.probeSession).mockImplementation(async (name: string) => {
|
||||
if (!server.up) return { state: "transport_unavailable", cause: "no server running on /tmp/tmux-1000/default" };
|
||||
return alive.has(name) ? { state: "present" } : { state: "absent" };
|
||||
});
|
||||
vi.mocked(tmux.hasSession).mockImplementation(async (name: string) => server.up && alive.has(name));
|
||||
vi.mocked(tmux.startServer).mockImplementation(async () => {
|
||||
if (!server.up) {
|
||||
server.up = true;
|
||||
server.starts += 1;
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
describe("when stopping the managed occupant ends the tmux server", () => {
|
||||
it.each(["sole", "pair"] as const)("continues the requested fresh launch for a %s seat", async (shape) => {
|
||||
const seat = seedSeat();
|
||||
const retiringGeneration = sessionRegistry.currentOccupantTenure(seat.node.id)!.generationUuid;
|
||||
if (shape === "pair") alive.add("dev-qa@fresh-rig");
|
||||
const server = modelServerLifetime();
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "sole-seat fresh" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
|
||||
if (!result.ok) return;
|
||||
expect(killed).toEqual([seat.sessionName]);
|
||||
expect(server.starts).toBe(shape === "sole" ? 1 : 0);
|
||||
expect(tmux.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
expect(result.generation).not.toBe(retiringGeneration);
|
||||
expect(result.supersededSessionIds).toContain(seat.session!.id);
|
||||
expect(sessionRegistry.getBindingForNode(seat.node.id)?.tmuxPane).toBe("%fresh");
|
||||
});
|
||||
|
||||
it("proves older non-terminal rows absent on the restored server before superseding them", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const older = sessionRegistry.registerSession(seat.node.id, "r00-dev-impl@fresh-rig");
|
||||
sessionRegistry.updateStatus(older.id, "running");
|
||||
const current = sessionRegistry.registerSession(seat.node.id, seat.sessionName);
|
||||
sessionRegistry.updateStatus(current.id, "running");
|
||||
sessionRegistry.updateBinding(seat.node.id, { tmuxSession: seat.sessionName, tmuxPane: "%old" });
|
||||
alive.add(seat.sessionName);
|
||||
livePanes.set(seat.sessionName, "%old");
|
||||
const server = modelServerLifetime();
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "old-row history" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready" });
|
||||
if (!result.ok) return;
|
||||
expect(server.starts).toBe(1);
|
||||
expect(vi.mocked(tmux.probeSession).mock.calls.map(([name]) => name)).toContain("r00-dev-impl@fresh-rig");
|
||||
expect(result.supersededSessionIds).toEqual(expect.arrayContaining([older.id, current.id]));
|
||||
expect(killed).toEqual([seat.sessionName]);
|
||||
});
|
||||
|
||||
it("still refuses a same-name session that appears on the restored server", async () => {
|
||||
const seat = seedSeat();
|
||||
const server = modelServerLifetime();
|
||||
vi.mocked(tmux.startServer).mockImplementation(async () => {
|
||||
server.up = true;
|
||||
server.starts += 1;
|
||||
alive.add(seat.sessionName);
|
||||
livePanes.set(seat.sessionName, "%recreated");
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "recreated occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
expect(killed).toEqual([seat.sessionName]);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(livePanes.get(seat.sessionName)).toBe("%recreated");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the server cannot be restored", () => undefined],
|
||||
["the probe fails with a permission error", () => { throw new Error("permission denied"); }],
|
||||
])("keeps the transport refusal when %s", async (_label, afterStop) => {
|
||||
const seat = seedSeat();
|
||||
const server = modelServerLifetime();
|
||||
const probe = vi.mocked(tmux.probeSession).getMockImplementation()!;
|
||||
vi.mocked(tmux.probeSession).mockImplementation(async (name: string) => {
|
||||
if (!server.up) afterStop();
|
||||
return probe(name);
|
||||
});
|
||||
vi.mocked(tmux.startServer).mockResolvedValue({ ok: false, code: "tmux_unavailable", message: "The terminal server did not become available." });
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "transport stays down" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
|
||||
expect(killed).toEqual([seat.sessionName]);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("neither stops nor starts a server when stop was not requested", async () => {
|
||||
const seat = seedSeat();
|
||||
const server = modelServerLifetime();
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "no stop" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "session_live" });
|
||||
expect(killed).toEqual([]);
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
expect(server).toEqual({ up: true, starts: 0 });
|
||||
});
|
||||
|
||||
it("does not start a server it did not stop: a seat with no server stays a transport refusal", async () => {
|
||||
seedSeat({ clean: true });
|
||||
const server = modelServerLifetime();
|
||||
server.up = false;
|
||||
|
||||
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, reason: "server already down" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("stops exactly the managed pod-aware occupant, launches fresh, and preserves sibling/work state", async () => {
|
||||
@@ -572,83 +432,4 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(sessionRegistry.currentOccupantTenure(seat.node.id)?.kind).toBe("fresh");
|
||||
expect(db.prepare("SELECT COUNT(*) AS c FROM events WHERE type = 'seat.fresh_launch_failed'").get()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
// #261: a fresh launch delivers stored built-in startup files from the RUNNING install;
|
||||
// a custom rig file with the same basename is delivered exactly as stored.
|
||||
it("#261 delivers stored built-ins from the running install and leaves custom files untouched", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const oldAssets = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/assets";
|
||||
const running = path.resolve(import.meta.dirname, "../assets");
|
||||
const meta = { deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"] };
|
||||
const stored = [
|
||||
{ path: "CULTURE-default.md", absolutePath: `${oldAssets}/guidance/CULTURE-default.md`, ownerRoot: oldAssets, ...meta },
|
||||
{ path: "openrig-onboarding-01.md", absolutePath: `${oldAssets}/onboarding/01-world-and-purpose.md`, ownerRoot: oldAssets, ...meta },
|
||||
{ path: "CULTURE-default.md", absolutePath: "/project/CULTURE-default.md", ownerRoot: "/project", ...meta },
|
||||
];
|
||||
db.prepare("UPDATE node_startup_context SET resolved_files_json=? WHERE node_id=?").run(JSON.stringify(stored), seat.node.id);
|
||||
const delivered: Array<{ path: string; absolutePath: string; ownerRoot: string; required: boolean }> = [];
|
||||
adapter.deliverStartup = async (files) => { delivered.push(...(files as typeof delivered)); return { delivered: files.length, failed: [] }; };
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "issue 261" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(delivered.map((f) => f.absolutePath).sort()).toEqual([
|
||||
"/project/CULTURE-default.md",
|
||||
`${running}/guidance/CULTURE-default.md`,
|
||||
`${running}/onboarding/01-world-and-purpose.md`,
|
||||
].sort());
|
||||
expect(delivered.every((f) => f.required)).toBe(true);
|
||||
const persisted = JSON.parse((db.prepare("SELECT resolved_files_json AS j FROM node_startup_context WHERE node_id=?").get(seat.node.id) as { j: string }).j) as Array<{ absolutePath: string }>;
|
||||
expect(persisted.some((f) => f.absolutePath.startsWith(oldAssets))).toBe(false);
|
||||
});
|
||||
|
||||
// #261 extension: shipped-spec projection resources follow the running install; a plugin stored
|
||||
// outside daemon/specs and user resources are projected exactly as stored.
|
||||
it("#261 projects shipped-spec resources from the running install and leaves plugin/user entries untouched", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const oldSpecs = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/specs";
|
||||
const runningSpecs = path.resolve(import.meta.dirname, "../specs");
|
||||
const entry = (over: Record<string, string>) => ({ category: "runtime_resource", effectiveId: "x", sourceSpec: "shared", resourcePath: "r", ...over });
|
||||
const stored = [
|
||||
entry({ effectiveId: "shared:claude-default-settings", sourcePath: `${oldSpecs}/agents/shared`, absolutePath: `${oldSpecs}/agents/shared/runtime/claude-settings.fragment.json`, resourceType: "claude_settings_fragment" }),
|
||||
entry({ category: "plugin", effectiveId: "shared:openrig-core", sourcePath: `${oldSpecs}/agents/shared`, absolutePath: "/home/u/.openrig/plugins/openrig-core" }),
|
||||
entry({ category: "guidance", effectiveId: "role", sourceSpec: "dev.impl", sourcePath: "/project/agents/impl", absolutePath: "/project/agents/impl/guidance/role.md" }),
|
||||
];
|
||||
db.prepare("UPDATE node_startup_context SET projection_entries_json=? WHERE node_id=?").run(JSON.stringify(stored), seat.node.id);
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "issue 261 projection" });
|
||||
expect(result.ok).toBe(true);
|
||||
const byId = Object.fromEntries((projectedPlan?.entries ?? []).map((e) => [e.effectiveId, e]));
|
||||
expect(byId["shared:claude-default-settings"]).toMatchObject({
|
||||
sourcePath: `${runningSpecs}/agents/shared`, absolutePath: `${runningSpecs}/agents/shared/runtime/claude-settings.fragment.json`,
|
||||
resourceType: "claude_settings_fragment", category: "runtime_resource",
|
||||
});
|
||||
expect(byId["shared:openrig-core"]).toMatchObject({ sourcePath: `${oldSpecs}/agents/shared`, absolutePath: "/home/u/.openrig/plugins/openrig-core" });
|
||||
expect(byId["role"]).toMatchObject({ absolutePath: "/project/agents/impl/guidance/role.md" });
|
||||
});
|
||||
|
||||
// #261 startup extension: shipped-spec startup files (kernel culture, agent role/startup context) follow the running install.
|
||||
it("#261 delivers shipped-spec startup files (pre- and post-launch) from the running install", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const oldSpecs = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/specs";
|
||||
const runningSpecs = path.resolve(import.meta.dirname, "../specs");
|
||||
const kernel = "rigs/launch/kernel";
|
||||
const agent = `${kernel}/agents/advisor/lead`;
|
||||
const stored = [
|
||||
{ path: "culture/CULTURE.md", absolutePath: `${oldSpecs}/${kernel}/culture/CULTURE.md`, ownerRoot: `${oldSpecs}/${kernel}`, deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"] },
|
||||
{ path: "guidance/role.md", absolutePath: `${oldSpecs}/${agent}/guidance/role.md`, ownerRoot: `${oldSpecs}/${agent}`, deliveryHint: "send_text", required: true, appliesOn: ["fresh_start", "restore"] },
|
||||
{ path: "culture/CULTURE.md", absolutePath: "/project/culture/CULTURE.md", ownerRoot: "/project", deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"] },
|
||||
];
|
||||
db.prepare("UPDATE node_startup_context SET resolved_files_json=? WHERE node_id=?").run(JSON.stringify(stored), seat.node.id);
|
||||
const delivered: string[] = [];
|
||||
adapter.deliverStartup = async (files) => { delivered.push(...files.map((f) => f.absolutePath)); return { delivered: files.length, failed: [] }; };
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "issue 261 shipped specs" });
|
||||
expect(result.ok).toBe(true);
|
||||
// Pre-launch culture and the post-launch send_text role both arrive at the running install; custom is as stored.
|
||||
expect(delivered.sort()).toEqual([
|
||||
`${runningSpecs}/${agent}/guidance/role.md`,
|
||||
`${runningSpecs}/${kernel}/culture/CULTURE.md`,
|
||||
"/project/culture/CULTURE.md",
|
||||
].sort());
|
||||
const persisted = JSON.parse((db.prepare("SELECT resolved_files_json AS j FROM node_startup_context WHERE node_id=?").get(seat.node.id) as { j: string }).j) as Array<{ path: string; absolutePath: string; deliveryHint: string }>;
|
||||
expect(persisted.find((f) => f.path === "guidance/role.md")).toMatchObject({ absolutePath: `${runningSpecs}/${agent}/guidance/role.md`, deliveryHint: "send_text" });
|
||||
expect(persisted.some((f) => f.absolutePath.startsWith(oldSpecs))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -569,33 +569,26 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Idle/busy activity does not establish native identity: this fixture has no bound pane or PID.
|
||||
it("idle activity sends with a runtime advisory; busy activity adds its advisory with or without force", async () => {
|
||||
const runtimeAdvisory = "runtime: Claude runtime observation or older launch binding is unavailable; delivery proceeds without verified native identity.";
|
||||
// No-regression: idle default still delivers; running + force still delivers.
|
||||
it("idle target sends by default; running target now DELIVERS WITH ADVISORY (OPR.0.4.3.28 fast-follow — mid_work downgraded, busy is not a block)", async () => {
|
||||
const idleSpy = vi.fn(async () => ({ ok: true as const }));
|
||||
const idle = makeTransport(mockTmux({ capturePaneContent: async () => "Done.\n❯ \n ⏵⏵ accept edits on (shift+tab to cycle)", sendText: idleSpy }));
|
||||
const idleRes = await idle.send("dev-impl@my-rig", "hi");
|
||||
expect(idleRes.ok).toBe(true);
|
||||
expect(idleRes.warning).toContain(runtimeAdvisory);
|
||||
expect(idleRes.warning).not.toContain("mid-task");
|
||||
expect(idleRes.warning).not.toContain("producer-link:");
|
||||
expect(idleRes.warning).toBeUndefined(); // idle → clean send, no advisory
|
||||
expect(idleSpy).toHaveBeenCalled();
|
||||
|
||||
const runSpy = vi.fn(async () => ({ ok: true as const }));
|
||||
const running = makeTransport(mockTmux({ capturePaneContent: async () => "Working on task...\n⠋ Processing\nesc to interrupt", sendText: runSpy }));
|
||||
// Default (non-force) send on a running/busy pane now PROCEEDS with a non-blocking advisory
|
||||
// (was: ok:false mid_work). The existing prompt refusal remains in force.
|
||||
// (was: ok:false mid_work). needs_input remains the ONLY hard refuse.
|
||||
const def = await running.send("dev-impl@my-rig", "hi");
|
||||
expect(def.ok).toBe(true);
|
||||
expect(def.warning).toContain(runtimeAdvisory);
|
||||
expect(def.warning).toContain("mid-task");
|
||||
expect(def.warning).toContain("busy is advisory");
|
||||
// --force is now a no-op on this path (kept for back-compat) — still delivers.
|
||||
const forced = await running.send("dev-impl@my-rig", "hi", { force: true });
|
||||
expect(forced.ok).toBe(true);
|
||||
expect(forced.warning).toContain(runtimeAdvisory);
|
||||
expect(forced.warning).toContain("mid-task");
|
||||
expect(forced.warning).toContain("busy is advisory");
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,6 @@ function tmuxWithPane(getPaneCommand: () => Promise<string | null>) {
|
||||
sendKeys,
|
||||
capturePaneContent: async () => "idle prompt\n❯ ",
|
||||
getPanePid: async () => null,
|
||||
listPanes: async () => [{ id: "%1" }],
|
||||
getPaneCommand,
|
||||
} as unknown as TmuxAdapter;
|
||||
return { tmux, sendText, sendKeys };
|
||||
@@ -69,16 +68,12 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
});
|
||||
|
||||
it.each([["claude-code", "zsh"], ["codex", "-bash"]])("%s seat showing %s: refused, nothing typed", async (runtime, shell) => {
|
||||
const { node } = seat(runtime, "dev-impl@my-rig");
|
||||
seat(runtime, "dev-impl@my-rig");
|
||||
const { tmux, sendText, sendKeys } = tmuxWithPane(async () => shell);
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@my-rig", tmuxPane: "%1" });
|
||||
tmux.getPanePid = async () => 1135;
|
||||
const listProcesses = async () => [{ pid: 1135, ppid: 1, pgid: 1135, tpgid: 1135,
|
||||
executableName: shell.replace(/^-/, ""), command: shell, startedAt: "Thu Oct 1 11:00:00 2026" }];
|
||||
const result = await watchdogSend(new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux, listProcesses }), "dev-impl@my-rig");
|
||||
const result = await watchdogSend(new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux }), "dev-impl@my-rig");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: runtime === "claude-code" ? "target_runtime_not_running" : "target_runtime_unverified" });
|
||||
expect(result.error).toContain(runtime === "claude-code" ? "idle shell" : `${shell.replace(/^-/, "")} as the foreground command`);
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(result.error).toContain(`${shell.replace(/^-/, "")} as the foreground command`);
|
||||
expect(result.error).toContain("No text was sent");
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
@@ -226,30 +221,28 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
["conflicting identity arguments", () => claudeProcesses(`--resume other --session-id ${nativeToken}`)],
|
||||
["identity flag only in name value", () => claudeProcesses(`--name --session-id ${nativeToken}`)],
|
||||
];
|
||||
it.each(unprovedClaude)("#197 distinguishes missing evidence from a conflict: %s", async (label, mutate) => {
|
||||
it.each(unprovedClaude)("#197 retains refusal for %s", async (_label, mutate) => {
|
||||
const { transport, sendText, sendKeys } = wrappedClaude(vi.fn(async () => mutate(claudeProcesses())));
|
||||
const result = await transport.send("dev-check@my-rig", "existing review");
|
||||
const conflict = ["wrong Claude identity", "ambiguous native children"].includes(label);
|
||||
expect(result.ok).toBe(!conflict);
|
||||
if (conflict) expect(result.reason).toBe("target_runtime_conflict");
|
||||
else expect(result.warning).toContain("without verified native identity");
|
||||
expect(sendText).toHaveBeenCalledTimes(conflict ? 0 : 1);
|
||||
expect(sendKeys).toHaveBeenCalledTimes(conflict ? 0 : 1);
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(result.error).toContain("could not verify");
|
||||
expect(result.error).not.toMatch(/runtime is not running|Relaunch the seat|would run as shell commands/);
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["missing identity", "process disappears from observation", "changed bound pane", "process lookup failed"])("#197 handles %s honestly", async kind => {
|
||||
it.each(["missing identity", "changed process", "changed bound pane", "process lookup failed"])("#197 refuses %s", async kind => {
|
||||
const { transport, session, tmux, listProcesses, sendText, sendKeys } = wrappedClaude();
|
||||
if (kind === "missing identity") sessionRegistry.clearResumeToken(session.id);
|
||||
if (kind === "process disappears from observation") listProcesses.mockResolvedValueOnce(claudeProcesses()).mockResolvedValueOnce(claudeProcesses().slice(0, -1));
|
||||
if (kind === "changed process") listProcesses.mockResolvedValueOnce(claudeProcesses()).mockResolvedValueOnce(claudeProcesses().slice(0, -1));
|
||||
if (kind === "changed bound pane") tmux.getPanePid = async target => target === "%1" ? 999 : 1135;
|
||||
if (kind === "process lookup failed") listProcesses.mockRejectedValue(new Error("unavailable"));
|
||||
const result = await transport.send("dev-check@my-rig", "existing review");
|
||||
const conflict = kind === "changed bound pane";
|
||||
expect(result.ok).toBe(!conflict);
|
||||
if (conflict) expect(result.reason).toBe("target_runtime_conflict");
|
||||
else expect(result.warning).toContain("without verified native identity");
|
||||
expect(sendText).toHaveBeenCalledTimes(conflict ? 0 : 1);
|
||||
expect(sendKeys).toHaveBeenCalledTimes(conflict ? 0 : 1);
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(result.error).toContain("could not verify");
|
||||
expect(result.error).not.toMatch(/runtime is not running|Relaunch the seat|would run as shell commands/);
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("#197 still refuses native approval after proving the Claude wrapper", async () => {
|
||||
@@ -279,7 +272,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
expect(sendKeys).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
expect(stored.lastNudgeResult).toContain("failed:");
|
||||
expect(stored.lastNudgeResult).toContain("different Claude conversation");
|
||||
expect(stored.lastNudgeResult).toContain("could not verify");
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
@@ -127,7 +127,6 @@ function createAppWithUiDist(db: ReturnType<typeof createFullTestDb>, uiDistDir:
|
||||
podInstantiator: fullSetup.podInstantiator,
|
||||
podBundleSourceResolver: fullSetup.podBundleSourceResolver,
|
||||
uiDistDir,
|
||||
webUiEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { InboundRouter, type SlackEvent } from "../src/domain/gateway/slack/inbound.js";
|
||||
import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js";
|
||||
import { SeenStore, DeadLetterStore } from "../src/domain/gateway/slack/state-store.js";
|
||||
|
||||
it("keeps complete short and long human briefs durable while bounding summaries and deduplicating retries", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "openrig-slack-complete-"));
|
||||
const db = createDb(join(home, "state.sqlite"));
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const seen = new SeenStore(join(home, "seen.jsonl"));
|
||||
const deadLetter = new DeadLetterStore<SlackEvent>(join(home, "dead.jsonl"));
|
||||
const router = new InboundRouter({
|
||||
queue: makeQueuePorts(repo), seen, deadLetter, destination: "operator-agent@kernel",
|
||||
resolveSender: () => ({ admitted: true, source: "human-fixture@external" }),
|
||||
});
|
||||
try {
|
||||
for (const [index, text] of ["Short complete brief.", "x".repeat(1799) + "🧪\nKeep this final condition."].entries()) {
|
||||
const event = { type: "message", user: "U-FIXTURE", channel: "C-FIXTURE", text, ts: `1000.${index}` };
|
||||
const result = await router.route(event);
|
||||
expect(result.disposition).toBe("accepted");
|
||||
const row = repo.getById(result.qitemId!)!;
|
||||
expect(row.body).toBe(`${text}\n\n---\nSource: slack channel=C-FIXTURE user=U-FIXTURE ts=${event.ts}\nRouted by openrig slack-inbound. Default destination per config; re-route via queue as needed.`);
|
||||
expect(row.summary).toBe(`Founder via Slack: ${text.slice(0, 90)}`);
|
||||
expect(seen.load().has(event.ts)).toBe(true);
|
||||
expect((await router.route(event)).disposition).toBe("ignored");
|
||||
}
|
||||
expect(repo.list({ limit: 100 })).toHaveLength(2);
|
||||
expect(deadLetter.readAll()).toEqual([]);
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
// POSIX directory write protection requires an ordinary user. The fixture does
|
||||
// not pretend permissions fail on a root/Windows process where they do not.
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0).each(["onopen", "periodic"])(
|
||||
"%s retry logs a real storage fault, survives, and recovers exactly one delivery",
|
||||
(phase) => {
|
||||
const home = mkdtempSync(join(tmpdir(), "openrig-slack-retry-child-"));
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [
|
||||
fileURLToPath(new URL("./fixtures/slack-retry-storage-fault.mjs", import.meta.url)), phase, home,
|
||||
], { env: { ...process.env, HOME: home, TMUX: "" }, encoding: "utf8", timeout: 10000 });
|
||||
expect(result.error, result.stderr).toBeUndefined();
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout.trim())).toEqual({ phase, faults: 2, remaining: 0, rows: 1, state: "connected", requests: 1 });
|
||||
} finally {
|
||||
// A fatal baseline child cannot run its cleanup; retain ownership here.
|
||||
if (existsSync(join(home, "dead"))) chmodSync(join(home, "dead"), 0o700);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 15000,
|
||||
);
|
||||
@@ -5,7 +5,6 @@ import { serve, type ServerType } from "@hono/node-server";
|
||||
import http from "node:http";
|
||||
import * as fs from "node:fs";
|
||||
import { registerTerminalWs } from "../src/routes/terminal-ws.js";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
|
||||
const TOKEN = "test-ws-route-token";
|
||||
const PORT = 19876;
|
||||
@@ -27,8 +26,6 @@ beforeAll(async () => {
|
||||
await next();
|
||||
});
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
// Production order (server.ts): the /api browser boundary runs before the terminal route.
|
||||
app.use("/api/*", browserBoundary({ webUiEnabled: true, bearerTokens: [TOKEN], warn: () => {} }));
|
||||
registerTerminalWs(app, upgradeWebSocket as never, { bearerToken: TOKEN });
|
||||
server = serve({ fetch: app.fetch, port: PORT, hostname: "127.0.0.1" });
|
||||
injectWebSocket(server);
|
||||
@@ -72,7 +69,7 @@ describe("terminal WebSocket route (production path)", () => {
|
||||
it("valid token WS upgrade does NOT return 404 (the QA blocker regression)", async () => {
|
||||
const result = await rawUpgrade(
|
||||
`/api/terminal/test-session?token=${TOKEN}`,
|
||||
{ Origin: `http://127.0.0.1:${PORT}` },
|
||||
{ Origin: "http://127.0.0.1" },
|
||||
);
|
||||
expect(result.statusCode, `expected non-404, got ${result.statusCode}: ${result.body}`).not.toBe(404);
|
||||
});
|
||||
@@ -80,7 +77,7 @@ describe("terminal WebSocket route (production path)", () => {
|
||||
it("missing token returns 401", async () => {
|
||||
const result = await rawUpgrade(
|
||||
"/api/terminal/test-session",
|
||||
{ Origin: `http://127.0.0.1:${PORT}` },
|
||||
{ Origin: "http://127.0.0.1" },
|
||||
);
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
@@ -96,7 +93,7 @@ describe("terminal WebSocket route (production path)", () => {
|
||||
it("wrong token returns 401", async () => {
|
||||
const result = await rawUpgrade(
|
||||
`/api/terminal/test-session?token=wrong`,
|
||||
{ Origin: `http://127.0.0.1:${PORT}` },
|
||||
{ Origin: "http://127.0.0.1" },
|
||||
);
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
@@ -1028,7 +1028,7 @@ describe("transport routes", () => {
|
||||
expect(String(data["warning"] ?? "")).toMatch(/no way of knowing who sent|no idea who sent/i);
|
||||
});
|
||||
|
||||
it("PROOF-4b: an attributed send delivers verbatim with a runtime advisory but no unknown-sender notice", async () => {
|
||||
it("PROOF-4b: an attributed send's response carries no nag and delivers verbatim", async () => {
|
||||
seedRig();
|
||||
const { app, sentTexts } = spyTransportApp();
|
||||
const res = await app.request("/api/transport/send", {
|
||||
@@ -1039,10 +1039,7 @@ describe("transport routes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(sentTexts[0]!.text).toBe("hello");
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
// Sender attribution does not establish the target's native identity.
|
||||
expect(data["warning"]).toContain("runtime: Claude runtime observation or older launch binding is unavailable; delivery proceeds without verified native identity.");
|
||||
expect(String(data["warning"])).not.toMatch(/no way of knowing who sent|no idea who sent/i);
|
||||
expect(String(data["warning"])).not.toMatch(/sign/i);
|
||||
expect(data["warning"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -105,9 +105,7 @@ describe("config routes (User Settings v0)", () => {
|
||||
// + 1 OPR.0.5.9.5 (context.system_world)
|
||||
// + 2 OPR.0.5.10.7 context-pressure policy thresholds → 68 total.
|
||||
// + 1 S07 local-time preference.
|
||||
// + 1 ui.enabled (web UI and its terminal WebSocket; default off).
|
||||
expect(Object.keys(body.settings).length).toBe(70);
|
||||
expect(body.settings["ui.enabled"]).toMatchObject({ value: false, source: "default" });
|
||||
expect(Object.keys(body.settings).length).toBe(69);
|
||||
expect(body.settings["ui.timezone"]).toMatchObject({ value: "America/Los_Angeles", source: "default" });
|
||||
expect(body.settings["daemon.port"]?.source).toBe("default");
|
||||
expect(body.settings["health.context_pressure.warning_percent"]).toMatchObject({
|
||||
|
||||
@@ -112,7 +112,6 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
"health.context_pressure.critical_percent",
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines", "ui.timezone",
|
||||
"ui.enabled",
|
||||
// OPR.0.4.0.1 — global live-terminal cap.
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// The web UI and its terminal WebSocket are off unless `ui.enabled` is true. Off covers the UI's pages, assets and
|
||||
// SPA routes and the production WebSocket registration; every /api route, /healthz and the HTTP terminal routes the
|
||||
// CLI and TUI use answer exactly as they do with the UI on.
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import { createFullTestDb, createTestApp, mockTmuxAdapter, unavailableCmuxAdapter } from "./helpers/test-app.js";
|
||||
import { createApp, createAppWithWebSocket, WEB_UI_OFF_MESSAGE, type AppDeps } from "../src/server.js";
|
||||
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
|
||||
const UI_INDEX_HTML = "<!doctype html><html><head><title>OpenRig UI</title></head><body><div id=\"root\"></div></body></html>";
|
||||
const TOKEN = "web-ui-off-token";
|
||||
|
||||
function tempUiDist(): string {
|
||||
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-ui-off-"));
|
||||
fs.mkdirSync(nodePath.join(dir, "assets"));
|
||||
fs.writeFileSync(nodePath.join(dir, "index.html"), UI_INDEX_HTML, "utf-8");
|
||||
fs.writeFileSync(nodePath.join(dir, "assets", "app.js"), "console.log('rig');", "utf-8");
|
||||
return dir;
|
||||
}
|
||||
|
||||
function appDeps(db: ReturnType<typeof createFullTestDb>, uiDistDir: string, extra: Partial<AppDeps>): AppDeps {
|
||||
const s = createTestApp(db) as unknown as Record<string, unknown>;
|
||||
return {
|
||||
rigRepo: s.rigRepo, sessionRegistry: s.sessionRegistry, eventBus: s.eventBus, nodeLauncher: s.nodeLauncher,
|
||||
tmuxAdapter: s.tmuxAdapter ?? mockTmuxAdapter(), cmuxAdapter: s.cmuxAdapter ?? unavailableCmuxAdapter(),
|
||||
snapshotCapture: s.snapshotCapture, snapshotRepo: s.snapshotRepo, restoreOrchestrator: s.restoreOrchestrator,
|
||||
rigSpecExporter: s.rigSpecExporter, rigSpecPreflight: s.rigSpecPreflight, rigInstantiator: s.rigInstantiator,
|
||||
packageRepo: s.packageRepo, installRepo: s.installRepo, installEngine: s.installEngine, installVerifier: s.installVerifier,
|
||||
bootstrapOrchestrator: s.bootstrapOrchestrator, bootstrapRepo: s.bootstrapRepo, discoveryCoordinator: s.discoveryCoordinator,
|
||||
discoveryRepo: s.discoveryRepo, claimService: s.claimService, psProjectionService: s.psProjectionService, upRouter: s.upRouter,
|
||||
teardownOrchestrator: s.teardownOrchestrator, podInstantiator: s.podInstantiator, podBundleSourceResolver: s.podBundleSourceResolver,
|
||||
uiDistDir,
|
||||
...extra,
|
||||
} as AppDeps;
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { while (cleanups.length) cleanups.pop()!(); });
|
||||
|
||||
function fixture(extra: Partial<AppDeps>) {
|
||||
const db = createFullTestDb();
|
||||
const dir = tempUiDist();
|
||||
cleanups.push(() => { db.close(); fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
return appDeps(db, dir, extra);
|
||||
}
|
||||
|
||||
async function listen(deps: AppDeps): Promise<number> {
|
||||
const { app, injectWebSocket } = createAppWithWebSocket(deps);
|
||||
let server: ServerType | undefined;
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, (info) => resolve(info.port));
|
||||
});
|
||||
injectWebSocket(server);
|
||||
cleanups.push(() => server?.close());
|
||||
return port;
|
||||
}
|
||||
|
||||
function request(port: number, path: string, headers: Record<string, string> = {}): Promise<{ status: number; body: string; upgraded: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ hostname: "127.0.0.1", port, path, method: "GET", headers });
|
||||
req.on("response", (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body, upgraded: false }));
|
||||
});
|
||||
req.on("upgrade", (_res, socket) => { socket.destroy(); resolve({ status: 101, body: "", upgraded: true }); });
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Non-browser upgrade shape (no Origin), so 404 vs not-404 isolates route registration.
|
||||
const UPGRADE = {
|
||||
Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Version": "13",
|
||||
"Sec-WebSocket-Key": Buffer.from("web-ui-off-key16").toString("base64"),
|
||||
};
|
||||
|
||||
describe("web UI default off (ui.enabled)", () => {
|
||||
it("defaults to false and turns on through the existing setting", () => {
|
||||
const prior = process.env.OPENRIG_UI_ENABLED;
|
||||
try {
|
||||
delete process.env.OPENRIG_UI_ENABLED;
|
||||
expect(new SettingsStore().resolveOne("ui.enabled")).toMatchObject({ value: false, source: "default" });
|
||||
process.env.OPENRIG_UI_ENABLED = "true";
|
||||
expect(new SettingsStore().resolveOne("ui.enabled").value).toBe(true);
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env.OPENRIG_UI_ENABLED; else process.env.OPENRIG_UI_ENABLED = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it("serves no UI page, asset or SPA route by default, and names the setting", async () => {
|
||||
const app = createApp(fixture({ terminalBearerToken: TOKEN }));
|
||||
for (const path of ["/", "/index.html", "/specs", "/rigs/rig-1/nodes/dev.impl", "/assets/app.js", "/apiX"]) {
|
||||
const res = await app.request(path);
|
||||
const body = await res.text();
|
||||
expect(res.status, path).toBe(404);
|
||||
expect(res.headers.get("x-openrig-web-ui"), path).toBe("off");
|
||||
expect(body, path).toBe(WEB_UI_OFF_MESSAGE);
|
||||
expect(body, path).toContain("rig config set ui.enabled true");
|
||||
expect(body, path).not.toContain("OpenRig UI");
|
||||
expect(body, path).not.toContain(TOKEN);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the UI again when enabled", async () => {
|
||||
const app = createApp(fixture({ webUiEnabled: true }));
|
||||
const res = await app.request("/specs");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe(UI_INDEX_HTML);
|
||||
expect((await app.request("/assets/app.js")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("answers health, API and HTTP terminal routes the same with the UI off and on", async () => {
|
||||
const off = createApp(fixture({ terminalBearerToken: TOKEN }));
|
||||
const on = createApp(fixture({ terminalBearerToken: TOKEN, webUiEnabled: true }));
|
||||
for (const path of ["/healthz", "/api/unknown", "/api/config", "/api/terminal/views", "/api/terminal/status", "/api/terminal/preview?view=x"]) {
|
||||
const a = await off.request(path), b = await on.request(path);
|
||||
expect(a.status, path).toBe(b.status);
|
||||
expect(a.headers.get("x-openrig-web-ui"), path).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the production terminal WebSocket only when enabled; HTTP terminal auth is unchanged", async () => {
|
||||
const offPort = await listen(fixture({ terminalBearerToken: TOKEN }));
|
||||
const onPort = await listen(fixture({ terminalBearerToken: TOKEN, webUiEnabled: true }));
|
||||
|
||||
const offUpgrade = await request(offPort, `/api/terminal/seat-1?token=${TOKEN}`, UPGRADE);
|
||||
expect(offUpgrade.upgraded).toBe(false);
|
||||
expect(offUpgrade.status).toBe(404);
|
||||
const onUpgrade = await request(onPort, `/api/terminal/seat-1?token=${TOKEN}`, UPGRADE);
|
||||
expect(onUpgrade.status, onUpgrade.body).not.toBe(404);
|
||||
// A browser upgrade (own-UI Origin) is refused by the /api browser boundary while the UI is off.
|
||||
const offBrowser = await request(offPort, `/api/terminal/seat-1?token=${TOKEN}`, { ...UPGRADE, Origin: `http://127.0.0.1:${offPort}` });
|
||||
expect(offBrowser.upgraded).toBe(false);
|
||||
expect(offBrowser.status).toBe(403);
|
||||
|
||||
// The guard in front of the HTTP terminal routes stays the same either way.
|
||||
for (const [path, headers] of [
|
||||
["/api/terminal/views", {}],
|
||||
["/api/terminal/views", { Authorization: `Bearer ${TOKEN}` }],
|
||||
["/api/terminal/status", {}],
|
||||
[`/api/terminal/status?token=${TOKEN}`, {}],
|
||||
] as const) {
|
||||
const a = await request(offPort, path, headers), b = await request(onPort, path, headers);
|
||||
expect(a.status, `${path} ${JSON.stringify(headers)}`).toBe(b.status);
|
||||
}
|
||||
expect((await request(offPort, "/api/terminal/views")).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,6 @@ const LABELS: Record<string, string> = {
|
||||
"ui.preview.refresh_interval_seconds": "Preview refresh", "ui.preview.max_pins": "Preview pins",
|
||||
"ui.preview.default_lines": "Preview lines", "context.system_world": "System world",
|
||||
"onboarding.default_pack.enabled": "Default onboarding pack",
|
||||
"ui.enabled": "Web UI",
|
||||
"health.context_pressure.warning_percent": "Context warning threshold",
|
||||
"health.context_pressure.critical_percent": "Context critical threshold",
|
||||
"recovery.auto_drive_provider_prompts": "Drive provider prompts",
|
||||
|
||||
@@ -97,11 +97,7 @@ export class DaemonClient {
|
||||
|
||||
private async get(route: string, fetchImpl = this.fetchImpl): Promise<unknown> {
|
||||
const res = await fetchImpl(`${this.baseUrl}${route}`, { headers: this.headers, signal: AbortSignal.timeout(5_000) });
|
||||
if (!res.ok) {
|
||||
const parsed = await res.json().catch(() => null);
|
||||
const detail = parsed && typeof parsed === "object" && "error" in parsed ? ` — ${(parsed as { error: unknown }).error}` : "";
|
||||
throw new Error(`daemon read failed: GET ${route} → ${res.status}${detail}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`daemon read failed: GET ${route} → ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// The real TUI daemon client read path, over real HTTP, carries the browser boundary's
|
||||
// refusal sentence (previously it reported only the status).
|
||||
import { afterAll, beforeAll, expect, it } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Hono } from "hono";
|
||||
import { DaemonClient } from "../src/daemon-client.js";
|
||||
import { browserBoundary } from "../../daemon/src/middleware/browser-boundary.js";
|
||||
|
||||
let server: http.Server;
|
||||
let port = 0;
|
||||
beforeAll(async () => {
|
||||
const daemon = new Hono();
|
||||
daemon.use("/api/*", browserBoundary({ webUiEnabled: false, bearerTokens: [], warn: () => {} }));
|
||||
daemon.all("/api/*", (c) => c.json({ ok: true }));
|
||||
const body = await (await daemon.request("/api/health", { headers: { Host: "unlisted.example:7433" } })).text();
|
||||
server = http.createServer((_q, r) => { r.writeHead(403, { "content-type": "application/json" }); r.end(body); });
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterAll(async () => { await new Promise<void>((r) => server.close(() => r())); });
|
||||
|
||||
it("a refused read names the remedy", async () => {
|
||||
const client = new DaemonClient({ baseUrl: `http://127.0.0.1:${port}` } as never);
|
||||
const message = await client.healthFindings(5).then(() => "", (e: Error) => e.message);
|
||||
expect(message).toContain("403");
|
||||
expect(message).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"core/openrig-architect/SKILL.md": "0c3f1b7ff5bf185c9da963905229e951ffe1fb6d45a3cc9247cb747fc1d9a59d",
|
||||
"core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc",
|
||||
"core/openrig-herdr/SKILL.md": "37508a02f50bb69ee97c22c37ccf70aa8e37dc0784061eb2a5ad813571eebd1a",
|
||||
"core/openrig-skills/SKILL.md": "0c700beac8210e0ad37021d34b0f9e2f457161a8bc9527e5d0a2e27b046bc7c1",
|
||||
"core/openrig-skills/SKILL.md": "fee88b59cf454156ab7d04bae9ae01ce6bba01a89643f3c39fea7425df247457",
|
||||
"core/openrig-software-factory/SKILL.md": "33930e72341661eaaa9d3c5ac945297926f430d643b6f279fcb7f693ac30b7f7",
|
||||
"core/openrig-software-factory/references/worked-example.md": "ff5485a0e84de43188cf791cc96258abe038d035d439997f21865e33a1916bc5",
|
||||
"core/openrig-upgrade/SKILL.md": "c3555c66abe0426e2990aec024849f644cf43320a65b8f1211e96d3b234f6e83",
|
||||
@@ -63,7 +63,7 @@
|
||||
"claude-compaction-restore/scripts/restore-from-jsonl.mjs": "5584cff4ab36cd843e14c81bd201e4a2f8e498e995abacc4f81ec6c1fc556b10",
|
||||
"claude-compaction-restore/templates/compact-instruction.md": "682c778464517b831902ddc2678c055545899b4751a1d32391ce965d5eb7061d",
|
||||
"claude-compaction-restore/templates/post-compact-restore-instruction.md": "10a5a7e5e052f3aa000a4209f386dc4eb90a3c2bba449ca685f124714f59e47a",
|
||||
"delegating-work/SKILL.md": "54c061d1b28435266952d431765b40f4170581279decfffd3b10839848cd65e4",
|
||||
"delegating-work/SKILL.md": "9809781b3ee8d0ffcb411af3ebd3b48257f9dbd9eac652350c0e30d000c0d236",
|
||||
"forming-an-openrig-mental-model/SKILL.md": "cff1ca1640fec65b180539a2c6c3c9044d73aa02f9d13d0d24763446ac974da0",
|
||||
"loading-addressable-markdown/SKILL.md": "104dc6a02ae6e2d0eff519387c6839cb09803945b9ee35ec3498af8feaaa8d1e",
|
||||
"loading-addressable-markdown/scripts/resolve-markdown.mjs": "ef113af7bb297058a8c23a82a4c171232ae8c2d6b1705a89a80140000ad3bca0",
|
||||
@@ -80,7 +80,7 @@
|
||||
"openrig-operating-model/templates/PLAYBOOK.md": "ff6f9dc8caab06dc7b9ae21977310ddefa23e925453bd54629ce0a669403bfb0",
|
||||
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
|
||||
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
|
||||
"openrig-skills/SKILL.md": "0c700beac8210e0ad37021d34b0f9e2f457161a8bc9527e5d0a2e27b046bc7c1",
|
||||
"openrig-skills/SKILL.md": "fee88b59cf454156ab7d04bae9ae01ce6bba01a89643f3c39fea7425df247457",
|
||||
"openrig-user/SKILL.md": "ed43062826294ad6879c125197b7d5752396372ac80b25a2f36e89afd617ae33",
|
||||
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
|
||||
"orienting-to-an-inherited-seat/SKILL.md": "4d2bb1f0dd522b0b956bf2191ce2121c36590936e217121f781525f77f8229a9",
|
||||
@@ -106,7 +106,7 @@
|
||||
"core/openrig-architect/SKILL.md": "0c3f1b7ff5bf185c9da963905229e951ffe1fb6d45a3cc9247cb747fc1d9a59d",
|
||||
"core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc",
|
||||
"core/openrig-herdr/SKILL.md": "37508a02f50bb69ee97c22c37ccf70aa8e37dc0784061eb2a5ad813571eebd1a",
|
||||
"core/openrig-skills/SKILL.md": "0c700beac8210e0ad37021d34b0f9e2f457161a8bc9527e5d0a2e27b046bc7c1",
|
||||
"core/openrig-skills/SKILL.md": "fee88b59cf454156ab7d04bae9ae01ce6bba01a89643f3c39fea7425df247457",
|
||||
"core/openrig-software-factory/SKILL.md": "33930e72341661eaaa9d3c5ac945297926f430d643b6f279fcb7f693ac30b7f7",
|
||||
"core/openrig-software-factory/references/worked-example.md": "ff5485a0e84de43188cf791cc96258abe038d035d439997f21865e33a1916bc5",
|
||||
"core/openrig-upgrade/SKILL.md": "c3555c66abe0426e2990aec024849f644cf43320a65b8f1211e96d3b234f6e83",
|
||||
|
||||
@@ -122,5 +122,4 @@ and a useful report to the OpenRig team if you are still stuck. If `rig` won't r
|
||||
|
||||
## Need more than what ships here?
|
||||
|
||||
This index covers the **shipped** surface. Your project, rig or machine may add its own skills and routers. Check the
|
||||
skills your working directory loads before concluding that something doesn't exist.
|
||||
This index covers the **shipped** surface. A dev host carries far more (factory, architecture, PM-craft, studio skills) reached through the host's own routers/codemaps — if you're on a builder host and need something not listed above, that deeper routing is the next hop, not a wall. (Host-scale routing is the subject of the context-routing architecture doc; at product scale, this one file is the whole map.)
|
||||
Reference in new issue
Block a user