Compare commits

...
Author SHA1 Message Date
dev-guard 71389bef06 test(daemon): cover complete message ownership at native Codex width 2026-10-28 21:39:36 +00:00
dev-guard ebf20ae616 fix(daemon): refine protected Codex paste admission and recovery 2026-10-28 21:35:18 +00:00
dev-guard 4c14ca76c4 test(daemon): cover native Codex paste limits and fitting envelopes 2026-10-28 21:35:17 +00:00
dev-guard 682bede406 fix(daemon): retain protected Codex messages before an unobservable paste 2026-10-28 21:13:18 +00:00
dev-guard db45c6bcb8 test(daemon): cover Codex paste visibility before input 2026-10-28 21:13:18 +00:00
Mike Schwarz 03a2828ce7 fix(daemon): keep prompt alerts routable with nonlocal senders (#2237)
When a seat sits at a choice prompt and its row came from another host, the alert about it now still reaches someone, and one such row no longer stops the whole pass. The wake ladder used the row's original sender as a local destination for the alert's carrier row; a sender on another host failed local destination validation and threw, aborting the shared prompt pass, and the same choice in the ordinary ladder failed the whole tick. A shared carrier choice now picks an admissible configured operator or the original sender, otherwise the blocked seat itself, at the prompt pass, its consumed-hop continuation, and the ordinary ladder's continuation and operator-floor alert. The carrier is never nudged, the original sender stays as the row's source, and the existing human-alert policy (including explicit-only) is unchanged.

3 files: source (queue-wake-ladder.ts, +13 -6) and tests (+51). No new refusal, error code, timer or setting.
2026-10-28 13:47:57 -07:00
Mike Schwarz c57f0173ab feat(tui): open the terminal scope being viewed (#2232)
The TUI's terminal button now opens the scope you're looking at: an agent's page opens just that agent, a pod's page gets an "Open terminals ▸ pod <pod>" button, and the rig's page opens the rig. An agent row's term link and the attention card's chat line now open that one seat instead of its whole pod. The daemon gains a seat:<rig>/<logical-id> terminal view beside pod:, built from the rig's own seat list; an unknown or malformed seat gets the existing view_not_found error, and rig terminal open's help lists the pod: and seat: forms.

11 files: daemon terminal view (+18 -1) with its test (+21), TUI render, attention and link styling, CLI help, and TUI tests; +112 -24 in total. No new refusal or error code.
2026-10-28 13:37:52 -07:00
Mike Schwarz 72086329ae docs: v0.7.4 install pins, current release-notes steps, and openrig-operator references routed to the help guide (#2238)
The README, getting-started and quickstart now install and add skills from the current release, v0.7.4, instead of v0.7.3. docs/releases/README.md says it holds the per-release notes for v0.1.12 through v0.6.3 plus v0.6.12, points to CHANGELOG.md and GitHub Releases for later ones, and its release steps match how releases are published now. Shipped skills and the kernel culture no longer route to a skill that isn't shipped: rig-lifecycle points to rig context get help, the kernel culture's restart case points to getting-started's "Incomplete setup and restart", and the dead sibling entries are dropped. Both skill copies stay identical and their digests are updated.

12 files, +28 -56: docs, skill text and the digest file only; no code change.
2026-10-28 13:31:55 -07:00
Mike Schwarz 8684c981a6 fix(daemon): retry original queue wakes after prompt clear (#2235)
A queue item created while its recipient sits at a selection prompt now gets its original wake retried once the prompt clears, even after a daemon restart or when an idle hook masked the pane's attention state. A definite no-write prompt refusal arms recovery; the first positive clear can retry the durable failed wake intents, reusing the same row and frozen wake body. Delivered and indeterminate wakes are never re-sent. Each clear pass holds per-row reservations, so overlapping passes can't release each other's rows or re-arm a refusal. For #2219.

3 files: source (queue-repository.ts, startup.ts; +44 -16) and tests (+107 -5). No new refusal, error code, timer or schema change.
2026-10-28 12:57:05 -07:00
Mike Schwarz e5c4cba385 perf(inventory): reuse node identity for activity reads (#2236)
Node inventory now reads each node's activity by the node ID it already has, alongside the current session name, instead of resolving the name back to a node, and attention enrichment no longer repeats a hook read whose result was discarded. A newer same-named session on another node can no longer supply this node's activity or attention state. No-session rows and the standalone attention fallback keep their behavior. In one matched measurement (15 rigs, 75 nodes) the change removed 900 SQL calls per command and cut the CLI median from about 1,028 ms to 622 ms; no general speedup is claimed.

3 files: source +6 -5, tests +71 -1. No cache, schema or API change.
2026-10-28 12:50:17 -07:00
Jay 10b5f155b3 fix(daemon): send wake-ladder retries as OpenRig and pass workflow entry sender (#2229)
Queue wakes from the daemon no longer arrive as "From: <unknown sender>". A wake-ladder retry now names OpenRig (daemon@kernel) as its sender and keeps the row's own source visible in the body ("Row source: <seat>."), and a workflow's first entry wake names the session that started the workflow. Retry timing, caps and admission are unchanged. For #2223.

4 files: source (queue-wake-ladder.ts, workflow-runtime.ts; +7 -2) and tests (+54 -3). No new refusal.
2026-10-28 12:27:11 -07:00
Brad Vincent 5700c6c2ff fix(daemon): preserve OS argv boundaries in Codex identity checks (#2212)
Codex resume identity stays correct when a supported profile or working directory contains spaces. The daemon now reads each foreground Codex process's exact arguments from the OS (Linux's existing /proc sample, bounded macOS KERN_PROCARGS2) instead of splitting flattened ps text, so a valid managed resume isn't rejected and a UUID inside an option value isn't taken for the resumed conversation. Resume and fork proof, restart in place, permission recovery, effort and claim still require exact arguments, and report unknown or unverified when they can't be read. Ordinary sends, queue nudges and watchdog wakes still reach a stable foreground Codex whose arguments can't be read, without treating it as identity proof.

35 files: 9 daemon source files (+286 -52) and 26 tests and helpers (+639 -147). No new public refusal code, migration, dependency or public API change. The macOS argument helper runs once per foreground sample, uncached.
2026-10-28 12:06:34 -07:00
Mike Schwarz d44cf3199a fix(daemon): a restore keeps the model saved with rig seat set-model (#2228)
A seat whose model was changed with `rig seat set-model` keeps that model when it's launched from a snapshot restore: restore now reads the seat's one saved model, as the live profile already does, for both the resume and the fresh restore paths, instead of the snapshot's older model. A seat with no saved model still gets the snapshot's. For #2224.

2 files: source and tests (restore-orchestrator.ts and its end-to-end test).
2026-10-28 11:47:58 -07:00
Mike Schwarz c2378fa315 fix(daemon): rig expand keeps a member's explicit effort (#2227)
`rig expand` with an explicit effort for a new member now keeps it, instead of silently launching at the default: the expansion route and the expansion spec both carry the member's effort as written, and the canonical RigSpec schema still trims a text effort and ignores any other value with an advisory, as before. No new refusal.

4 files: source +5 -0, tests +58 -0.
2026-10-28 11:38:05 -07:00
Mike SchwarzandRudy Celekli 884a5bfb6b fix(cli): keep chatroom watch --tmux rig names literal (#2218)
`rig chatroom watch --tmux` works for any rig name: the watcher goes to tmux as separate arguments after `--`, with no shell; a final `;` is escaped so tmux doesn't split the command; and the session name is encoded for tmux's format. The command prints the session name tmux reports, since tmux versions differ in how they rewrite characters such as `:` and `.` in a name. Finishes #1415 by @rudycelekli.

2 files: source +23 -4, tests +85 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 11:11:11 -07:00
Mike Schwarz 9ba9abe858 perf(inventory): scope startup proof reads to rig nodes (#2226)
Per-rig node inventory now reads startup-proof history only for that rig's current nodes, scoped by node identity rather than the events' own rig id, since a node's history can predate its current rig. The per-node fold and the inventory it produces are unchanged, and other callers keep reading all history. In one matched private measurement (75 nodes, 30,000 proof events, 15 rigs) proof rows read fell from 480,000 to 60,000 and the CLI median from about 1,973 ms to 1,135 ms; a single-rig control was slower, so no general speedup is claimed.

3 files: source +10 -5, tests +35 -5.
2026-10-28 10:58:27 -07:00
Mike Schwarz 2648083e96 perf(inventory): skip discarded rig graph hydration (#2225)
The nodes inventory route checks that the rig exists with one bound query instead of loading the rig's whole graph and discarding it. A missing rig still returns the same 404, and the inventory and its enrichment are unchanged. In one matched private measurement the route's SQL statements fell from 478 to 401 (1 rig) and from 688 to 583 (15 rigs); no general speedup is claimed.

1 file: +2 -1.
2026-10-28 10:51:13 -07:00
Mike Schwarzandakushonkamen 76c0170ec8 fix(daemon): let git over SSH use the daemon's SSH agent in managed seats (#2222)
Git over SSH inside a managed seat can now reach the daemon's ssh-agent for a passphrase-protected key: the daemon forwards its own SSH_AUTH_SOCK through the shared launch environment to every managed launch, and Pi and OMP pass it to the agent process. An empty daemon value counts as unset, so a socket only the seat's terminal has stays out. rig-spec.md says the socket is forwarded and how to check it with ssh-add -l. Finishes #1162 by @akushonkamen, for #1159.

5 files: source +7 -1, tests +18 -10, docs +3 -3.

Co-authored-by: akushonkamen <[email protected]>
2026-10-28 10:33:20 -07:00
Devulapalli Naga Sri Vaishnavi 0224e7710d fix(context): ignore HTML comment blocks in section addresses (#2211)
Context section addresses now ignore headings and code fences inside HTML comment blocks, following CommonMark's comment blocks. Commenting out an old section no longer truncates the section before it, creates a duplicate address, or hides every later section. None of the 245 commented markdown files in our own packs changes.

3 files: source and tests +61 -1.
2026-10-28 10:29:23 -07:00
Mike SchwarzandRudy Celekli 8efb5a59c1 fix(scope): find a slice's mission when the missions root ends in a slash (#2221)
A slice's mission is now found when the missions root is given with a trailing slash or as a relative path: the slice path is resolved once against the root, and a path outside the root falls back to the main walk as before. Nothing else in scope resolution changes. Finishes #1468 by @rudycelekli.

2 files: source +14 -4, tests +37 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 10:28:29 -07:00
Nash Aschenbath 36f971fa2c fix(daemon): clear Claude permission marks after tool batches (#2209)
A Claude permission notification whose dialog was never shown no longer leaves the seat marked as needing input until the next `Stop` or `UserPromptSubmit`. The openrig-core plugin now also relays `PostToolBatch`, and a batch settles only its own actor's strictly older permission marks once every tool in it has run, so a real pending prompt from another actor or a later batch is untouched. A Claude Code without `PostToolBatch` behaves as before.

11 files: source +144 -29, tests +431 -2, docs +21 -1.
2026-10-28 10:15:58 -07:00
Dery Ferdika b51500f024 test(scenarios): reject unbound step verbs at schema load (#2217)
A scenario that uses a step verb with no runtime binding (`restore`, `mutate` or `policy`) is now rejected when the schema loads, with `UNBOUND_STEP_VERB` guidance, instead of running its earlier steps (which can launch real seats) and then failing partway. `emit` stays separately gated and `seed_regression` stays accepted; the runtime check remains as a floor. Test system only. Three parked library scenarios now refuse at load; none is selected by CI.

4 files: test helper and test +37 -9 approx, docs; +62 -9 total.
2026-10-28 10:13:02 -07:00
Mike SchwarzandRudy Celekli 4926be0c0c fix(cli): treat a trailing slash in OPENRIG_URL as the same daemon (#2220)
`OPENRIG_URL=http://host:7433/` and `http://host:7433` now reach and identify the same daemon. The CLI reads the URL once through one shared reader that trims a trailing slash, so client requests, `rig send`, crash-cart, startup proof and the daemon-lifecycle identity checks all agree. Removing a trailing slash never changes a URL's origin, so the stop identity check still can't match a different daemon. Finishes #1462 by @rudycelekli.

8 files: source +62 -18, tests +169 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 10:07:54 -07:00
Mike Schwarz 413504a029 fix(host): rig host doctor names the entry replies actually reach (#2214)
`rig host doctor` now checks where a reply addressed to a machine's self-id actually goes, using the same lookup `rig send` uses (the registry id, then a declared `hostId`, then the first matching `hosts.yaml` entry), instead of assuming it reaches the entry being checked. A stale alias that wins over a working entry now shows up in the doctor's output. Routing is unchanged, and the new rows don't change the doctor's exit codes: the failing row appears only when the transport has already failed.

3 files: source +68 -14, tests +94 -0.
2026-10-28 09:42:58 -07:00
Dery Ferdika a70bf71d5f fix(daemon): bound the Slack queue-access active-row scan (#2183)
Slack alert selection now reads the latest owner-notification episode for each active row in one query, and leaves out episodes already posted before it loads full queue rows, instead of two lookups per active row. Bounded by eligibility, not by count: there is no row cap, so no pending ask is dropped, and queue creation order is unchanged. Open asks keep their posted episodes. A failed transition or receipt read now propagates instead of reading as an empty result, and the backlog seeding when a channel is enabled sees the same episodes. No live Slack or large-backlog timing result is claimed.

4 files: source +64 -15, tests +153 -3.
2026-10-28 09:41:36 -07:00
Mike Schwarz 3f17e625af fix: preserve configured Herdr session endpoints (#2205)
`rig terminal open ... --session <name>` now reaches the Herdr session the daemon is configured for, and never silently another one. A name that matches the configured session keeps its explicit socket; other names resolve separately with no fallback; Herdr's config home and session paths come from the daemon, not the caller. The resolved endpoint is carried through the CLI's follow and ensure paths too, which earlier stopped at the daemon's RPC. The Linux-only wiring test doesn't run in the macOS daemon CI job.

9 files: source +24 -19, tests +174 -10, docs +19 -4.
2026-10-28 09:39:10 -07:00
Mike Schwarz 470f55241e docs(cli): explain deferred escalation targets in edge help (#2204)
`rig edge add --help` no longer says both endpoints must be existing logical IDs. It says only `escalates_to` accepts a same-daemon canonical address that resolves when used, with `review-advisor@coord` as an example, and that cross-host and synthetic targets stay unsupported. Help text only; validation and routing are unchanged. Refs #1772.

1 file: +8 -4.
2026-10-28 09:35:54 -07:00
Mike Schwarz 3874c743df perf(test): exec tmux directly in scenario fixtures (#2216)
Scenario test fixtures no longer start a Node process for every tmux command. The fixture's tmux wrapper is now a POSIX shell script that keeps the private socket, the saved PATH and literal arguments, and execs tmux, so its exit codes and signals pass straight through. A new forwarding test covers both an exit and a signal with no Node on the saved PATH. Test helpers only; no product code changes, and no production speedup is claimed.

2 files: test helper +6 -11, tests +33 -1.
2026-10-28 09:33:55 -07:00
Mike Schwarz b0bc678833 fix(test): honor send verification in scenario adapter (#2215)
A scenario action that sets `send.verify: true` now runs `rig send --verify`, so scenario tests check what they say they check. Before, the adapter dropped the flag and ran an unverified send. False or absent values keep the existing arguments. Test helpers only; no product code changes.

2 files: test helper +2 -2, tests +5 -3.
2026-10-28 09:29:36 -07:00
Mike Schwarz 26a490938c fix(daemon): route a terminal seat's stuck-sweep finding to a person (#2192)
When work owned by a bound terminal seat (`runtime: terminal`, such as a plain shell) is stuck, the stuck sweep's finding now goes to someone who can act on it instead of becoming the terminal seat's own row. It joins the fallback the sweep already uses for an address with no wake route and for an unclaimed hosted member: the operator seat when it's a person, then the row's creator, then the operator agent, and a terminal seat is never chosen. When none of them can be reached, the finding stays with the terminal seat and says why. The typing guard is unchanged. This covers the stuck sweep only; parked-owner watchdog reminders are separate.

2 files: source +11 -5, tests +34 -0.
2026-10-28 09:27:37 -07:00
Mike Schwarz afee489108 fix(provider): retain the launcher and bound target during relaunch (#2196)
A same-conversation account switch no longer leaves a Claude seat stopped when its running executable is outside the daemon's PATH. Before the takeover ends the old process, in-place restore reads the executable the seat is actually running, through the existing native witness, and carries it through the classic and managed resume builders; when it can't be read, launch resolution stays as it is today. The takeover also keeps the selected node identity through the guarded TERM, KILL and respawn instead of resolving a bare pane id again, so it can't act on another seat that holds the same pane id. The guard still checks the bound session, pane and occupant before writing. Config-dir selection and the resume token step are unchanged.

13 files: source +58 -25, tests +54 -6.
2026-10-28 09:16:42 -07:00
Mike Schwarz 898b39ea5c fix(cli): verify cross-host queue receipts on the destination (#2193)
A cross-host `rig queue create --verify` now reads its delivery receipt from the destination daemon, so a successful remote create no longer reports a 404 and an indeterminate result. Verification reuses the destination reader that create reconciliation already selects, including the registered HTTP endpoint and its bearer, and a host naming the current daemon stays local. The receipt must match the created item's ID, destination, sender and body, so an older row reached by a reused ID can't read as verified. An unreadable or mismatched receipt stays indeterminate, and neither the create nor the wake is resent.

2 files: source +38 -11, tests +90 -0.
2026-10-28 09:13:13 -07:00
Mike Schwarz b71a9288c0 fix: recognize two-row Codex footers in viewer input checks (#2206)
In a task-following view, a departed Codex agent's tile now closes once its composer is confirmed empty. Codex 0.164 draws a model and path status row and then a shortcuts row; the capture reader already recognized that two-row footer, but the cursor-bound reader follow uses accepted only one row, so every Codex tile read as unconfirmed input and stayed. The two readers now share the existing footer boundary. Typed drafts, literal placeholders, moved cursors, menus, incomplete frames and ambiguous captures still keep the tile, and only the viewer closes, never the agent. The same reader feeds delivery's draft-aware and openrig-urgent typing guards, so a truly empty Codex 0.164 composer there can now receive typed delivery.

4 files: source +22 -12, tests +94 -2.
2026-10-28 08:46:38 -07:00
Mike Schwarz 6957751f5e feat(terminal): follow task membership in one workspace (#2199)
`rig terminal open qitem:<id> --follow` keeps one Herdr workspace following a task: an agent that takes an open obligation in the task joins the view without taking focus, and one whose work there ends loses only its viewer tile after a grace period, with the agent and its conversation left running. An explanation strip says why each tile joined or left. A focused tile is kept briefly for reading, typed or unreadable input keeps its tile, and pins, temporary suppression and an eight-tile limit can be changed on purpose. A failed or incomplete read keeps the previous view; a task with no open obligation retires its follower and keeps the workspace. Following wakes on queue, session and node changes and one-shot deadlines, not by polling.

Qualified on Linux with Herdr 0.9.5 (protocol 22). Other platforms and other view kinds keep ordinary one-shot open. Also fixes the viewer inventory call that Herdr 0.9.5 rejected (`workspace list --json`).

21 files, +1265 -44.
2026-10-27 14:13:32 -07:00
Mike Schwarz ae59c4287a fix(cli): wait for slow send results before reporting uncertainty (#2198)
`rig send` now waits up to 60 seconds for its result, on local and HTTP sends, plus any `--wait-for-idle` time. A send that answers after five seconds reports its real outcome instead of "Delivery UNCONFIRMED", which led agents to send the same message again. When there is still no answer, the guidance says a busy agent can hold delivered input off-screen, so an empty pane capture does not prove the message was lost: check with the recipient before sending again or filing the same work as a queue item. Nothing is retried, and no refusal is added.

Refs #2195. The send path's own latency on the reporter's machine is not changed here.

3 files, +57 -29 (source +17 -21, tests +40 -8).
2026-10-27 13:29:33 -07:00
Mike Schwarz 0c44b23ae9 fix(scope): accept slice folders numbered 100 and above in the audit route (#2194)
OpenRig's own `rig scope slice create` and `slice move` number slices past 99, and the CLI audit has accepted those folders since 1016, but the daemon's scope audit route, which the web UI's Scope pages read, still required two digits and reported each `100-…` folder as a high naming violation. Both audits now use one shared pattern. The conventions doc also says what a slice move does to proof: a judgment binds the slice's path, the item's text and the policy, so moving or renaming a slice reopens its judged items, `rig proof show` lists them, and the scope audit checks structure, not readiness.

6 files, +47 −4 (source +15 −4, tests +26, docs +6). No new refusals.
2026-10-27 11:49:36 -07:00
Mike Schwarz e5f8a8eee7 docs(roadmap): mark 0.7.4 released (#2197)
The roadmap now lists 0.7.4 as released, with a link to its release and its published title, and points readers to the release notes for what shipped, the experimental and security notes for hosted members, upgrade instructions, credits and the remaining testing gaps. It replaces the 0.7.3 section; "How to help", "Testing" and the backlog sections are unchanged. Text only.

1 file, +4 -10.
2026-10-27 11:49:06 -07:00
Mike Schwarz 84d6bbdbd9 docs(presets): say what the pi-tui example was checked to do, and its observed limits (#2184)
The Pi terminal UI example said that whether Pi takes a wake and acts on it hadn't been checked. One native check has now run, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in the same session file and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which picked it up unattended. The first seat's task named the commands, so this shows the wake reaching Pi and Pi acting on it, not Pi choosing to park by itself. The same check found two unsafe cases, now stated as observed: with the default typing guard a wake submits a person's half-typed draft together with the wake, and a send while a Pi dialog such as `/settings` is open types into the dialog. The docs say to run the seat unattended, not where a person also types, and promise no remedy. How Pi takes a wake while it's busy, resume and other Pi versions stay unshown. The launcher is unchanged.

2 files, +14 −8. Text only.
2026-10-27 10:12:52 -07:00
Mike Schwarz 4f1d031bab docs: mark hosted members experimental, with how to keep one safe (#2189)
The hosted-members reference described how to serve a hosted member over HTTP behind a tunnel or proxy, with no warning about what that exposes. It now opens with why the feature exists (assistants in a vendor's cloud can only reach a server on the internet) and a note: it's experimental, unsupported and off by default; nothing in OpenRig listens on the internet unless you set it up; serving `rig mcp serve --http` behind your own TLS tunnel or proxy with a token is like opening a port in your firewall, and anyone with the URL and a valid token can act as that member within its grant; treat the token like a password, give the member the narrowest grant, and revoke it when you're done with `rig mcp token revoke <name>`; OpenRig reachable from the internet isn't supported.

1 file, +4. Text only.
2026-10-27 10:11:14 -07:00
Wu Shuwen 81a48ce6c6 fix(ui): show the selected rig configuration in overview (#2188)
A running rig's Overview matched a Specs-library template by name, so a Claude-only kernel could appear to use the dual-runtime template, and a project rig outside the library said "NO RIG SPEC". The Overview now reads the selected local rig's existing `/api/rigs/:id/spec` export and labels it current configuration, not a library template. The existing review renderer supplies topology, configuration and YAML; if preview preparation fails, the actual export stays visible. A read failure no longer substitutes a same-named template, and a remote or undetermined host reads nothing local. The Specs library continues to show reusable templates, and nothing changes in the daemon.

3 files, +192 −53 (source +54 −53, tests +138). No new refusals.
2026-10-27 10:07:10 -07:00
Mike Schwarz c823dfedb4 fix: tell a hosted member how it gets work, what it may read and how to close (#2187)
Three things a hosted member is told were misleading in a native test, and the member repeated or acted on each. A hosted member with no wake trigger fell through to the general unroutable teaching, "Fix the address, or register the human with `rig gateway human add`". It now reads that the member is a hosted member with no wake trigger, so nothing can push work to it, and that it reads the queue when its owner next starts a session. The result is still `unroutable` with the same prefix, so the stuck sweep still tells the member's person, and an address no roster lists keeps the general teaching. The member workflow, now `member-workflow/v2` and quoted in `docs/reference/hosted-members.md`, says to describe reach from the grant rather than the tool list, and to close a finished item with `no-follow-on`. `rig_queue_update`'s closure-reason description lists the reasons it takes. No new reason is accepted, and nothing else changes.

7 files, +74 −9 (source +40 −5, tests +33 −3, docs +1 −1). No new refusals.
2026-10-27 10:00:10 -07:00
Mike Schwarz 30a3628b7d docs(presets): say a preset's first input carries only a send_text startup file (#2185)
The terminal-Pi recipe said a community preset with `first_input: true` carries the startup pointer in the seat's first startup text file. That's true only for a file declared with `delivery_hint: send_text`: the first such file is folded into the first input after the identity context. A `.md` file left at the `auto` hint resolves to guidance, which a community seat doesn't take, so a person who wrote the pointer that way got nothing but a launch warning. The bullet now says so.

1 file, +3 −2. Text only.
2026-10-27 09:38:52 -07:00
Mike Schwarz 4ee8bdca0c docs(presets): add a pi-tui example that runs Pi's terminal UI as a community harness (#2182)
A plain `runtime: terminal` seat running interactive Pi gets no queue wakes. A community-harness seat is an agent seat to OpenRig, so its wakes are delivered to the pane. This adds a copyable example that runs Pi's terminal UI that way: `docs/reference/community-harness-examples/pi-tui/`, with a `pi-tui` preset (`names_conversation` and `first_input`, no `resume`) and a POSIX sh launcher, a section in `community-harness-presets.md`, and stand-in tests against a fake `pi`.

The launcher, written for Pi 1.4.0 from its source, gives each seat its own Pi agent folder and session folder (a leading `~` in `PI_CODING_AGENT_DIR` is expanded as Pi does), copies the installed `openrig-core` skills it's given at each launch, and starts Pi with `--session-dir`, the session id OpenRig named and the first input passed byte for byte after `--`. On resume it opens the one session file whose first line, read as JSON, has the top-level `type` `session` and that `id`, and starts nothing otherwise, because Pi's own `--session-id` and `--session` can create or pick another session. Whether Pi takes a wake and acts on it, resume, and draft safety haven't been checked with a real Pi; the preset and docs say so. No core code, delivery or terminal behaviour changes.

4 files, +260 −1 (launcher +100, preset +14, docs +24 −1, tests +122). No new refusals.
2026-10-27 08:25:07 -07:00
Nash Aschenbath a50e56e868 fix(cli): show feed subscription hints after host add and pair (#2179)
Registering a remote host makes it visible in topology, but its attention items reach FEED only after `feed.subscriptions.<id>.enabled` is set, and nothing told the person so. After `rig host add` or `rig host pair`, the human-readable next steps now include, after `rig host doctor`, the local command `rig config set feed.subscriptions.<id>.enabled true`, using the registry id as given, dots included. An SSH entry is told that remote FEED needs an HTTP entry and how to pair one. An id that feed settings reserve, such as `progress`, gets a different-id suggestion instead of a command that wouldn't work. Registration still leaves the setting alone, and `--json` still returns its one result. The CLI reference describes the behaviour, and the tests run the printed command through the real `config` command.

4 files, +84 −7 (source +18 −2, tests +49 −5, docs +17). No new refusals.
2026-10-27 07:38:29 -07:00
Mike Schwarz ef5117441f docs: say what a terminal seat's typing guard set to off types into a shell (#2181)
A terminal member defaults to the `sends-only` typing guard, which holds OpenRig's own notices such as park reminders and watchdog messages. The reference didn't say what happens if someone sets a terminal member to `off`: those notices are typed into whatever the pane is running, and a shell prompt runs each line as a command, From and To lines included, because OpenRig doesn't check a terminal pane's foreground process. `openrig-urgent` and `draft-aware` type only into a recognized empty agent composer, so a shell prompt holds them. The paragraph says this below the modes table and advises keeping the default for a seat that may fall back to a shell.

1 file, +6. Text only.
2026-10-27 07:23:57 -07:00
Mike Schwarz a17fdc5faa docs(presets): say a queue wake doesn't reach Pi in a plain terminal seat (#2180)
The terminal-Pi recipe tells Pi to park a CI wait with `--wake-after`, which reads as if the wake will bring Pi back. In a plain `runtime: terminal` seat it doesn't: a parked row's wake isn't typed into Pi, and a handoff to that seat records a failed wake (`failed:terminal-seat`). A native check with Pi 1.4.0 in a real terminal seat showed both. The recipe now says so after its startup pointer, and that the person checks the seat's parked rows (`rig queue whoami --session <seat>` or `rig queue show <id>`) or tells Pi to continue the named row. It makes no claim about community-preset seats and promises no fix.

1 file, +5. Text only.
2026-10-27 07:07:48 -07:00
Mike Schwarz 9110ab9dc9 fix(cli): attach --self reads its tmux pane even when tmux rewrites separators (#2178)
After `rig attach --self` inside tmux, the session could send with its team address, but a reply to it failed with `transport_unavailable`. The command asked tmux for the session, window and pane joined by newlines and split on them, but tmux writes control characters in that output as `_`, so it returned `name_@2_%2`, the split found no pane, and the attachment silently fell back to the external CLI. It now asks for `#{pane_id} #{window_id} #{session_name}` separated by spaces and reads the two ids by their fixed shapes, leaving the session name as the rest of the line, so names with spaces, underscores or id-like words resolve too. The pane argument is shell-quoted. Outside tmux, a tmux error or unexpected output still gives the external CLI attachment. The tests now render the format the way tmux does, including a real tmux server, and the end-to-end check delivers a reply to the original pane.

3 files, +78 −19 (source +10 −9, tests +68 −10). No new refusals.
2026-10-27 06:12:40 -07:00
Mike Schwarz a6f61d9a0a fix(daemon): re-send a wake recorded unroutable once the seat's binding exists (#2170)
A row created before its seat's pane was bound records its wake as unroutable, and the stuck sweep then reported an undelivered wake that nothing retried: the seat spent a turn on a false finding, and the row's own wake was never sent. Now, once that destination has a terminal binding, the sweep delivers the row's own durable wake intent instead of reporting it: it claims the intent, sends, and settles it delivered together with the row's result, so the outbox drain doesn't send the wake again, and it sends nothing when the intent is already sending or delivered. A failed send is still reported on the next tick, a row whose result didn't change is reported as before, the retry ladder and quiet hours are untouched, and an address that still has no binding is reported as before.

2 files, +79 (source +10, tests +69). No new refusals.
2026-10-27 04:21:31 -07:00
Dery Ferdika bd1158660a fix(cli): send normalized line counts for transcript --tail and capture --lines (#2164)
rig transcript --tail and rig capture --lines now send the same line count on every path. A count of 0, a negative number or a non-numeric value falls back to the documented default of 20 instead of reaching the daemon as zero, so rig capture --lines 0 shows the default view (20 history lines plus the visible pane) rather than the visible pane alone, and rig transcript --tail 0 returns the default 50 lines. A positive count is unchanged. Over SSH, a value parseInt reads as a positive count is forwarded as typed, so the remote CLI applies the same rule, and the local, cross-host HTTP and SSH paths agree.

6 files, +70 -4 (source +13 -4, tests +57). No new refusals.
2026-10-27 04:12:02 -07:00
Mike Schwarz b9dd23db87 docs(bundles): say at submission that an exported team needs a declared README (#2176)
Someone submitting a team to openrig.dev/rigs now learns before they submit that the registry's check runs rig bundle check on their folder at the submitted ref with the latest published OpenRig, and that any finding fails it. An exported team has no README of its own, so the publishing guide's submit section and the rig-bundles-and-shareable-artifacts skill now say to add one, declare it in rig.yaml docs, and run rig bundle check at the commit being submitted. The skill gains a short section from an exported team to a submission; its mirror and edge digests are regenerated.

4 files, +29 -2 (docs and skill text only). No behaviour, flag or check changes.
2026-10-27 03:59:27 -07:00
Mike Schwarz b8fe8f1e11 fix(terminal): report unconfirmed panes without counting opens (#2173)
rig terminal open no longer counts a seat as opened when Herdr can't confirm its pane. When Herdr accepts a layout without a tab ID, or its pane list can't confirm a requested seat, that seat is reported as degraded with code pane_unconfirmed and a note to inspect before retrying. Confirmed seats still count, and a partly confirmed open still succeeds; an open with no seat confirmed returns ok false and a nonzero CLI exit. The CLI, the TUI's notices and the desktop failure path name unconfirmed panes separately from skipped seats, and unconfirmed panes get no restart-command report.

Labels are matched honestly. A listed pane counts for one seat at most. When a listed label could belong to more than one seat label (an exact label that is also a prefix of a longer one, or a truncation), those seats are confirmed only when the exact listed labels account for every candidate's seats; otherwise they read unconfirmed, and an undecidable pane never makes a seat read exited. Healthy paging, focus, starting-tab protection and workspace reuse are unchanged. Healthy provider fixtures now return real tab IDs and pane inventories.

15 files, +331 -84 (source +48 -28, tests +270 -56, docs +13). No new refusals; the new structured value is degraded[].code "pane_unconfirmed".
2026-10-27 03:50:34 -07:00
Mike Schwarz 588ba54d9d docs: carry 0.7.4's release notes and capability map to main (#2175)
Main's documentation catches up with the accepted 0.7.4 candidate's release text, and its install pins point at the latest published release. The CHANGELOG gains the 0.7.4 section and the capability map its 0.7.4 teaching, both identical to the candidate's; getting-started's reboot troubleshooting row now says a kernel that was already down before the reboot stays down and names rig up kernel --existing, while main's newer rig start --last text stays. README, getting-started and quickstart install pins move from v0.7.0 to v0.7.3, the latest published release; the move to v0.7.4 follows at publication. whats-new stays at main's 0.7.1 note, because the packaged note must name the CLI package's version, and package versions, the lockfile and the packaged-plugin fixture are unchanged.

5 files, +383 -14 (docs only). No behaviour, flag or check changes.
2026-10-27 03:41:46 -07:00
Mike Schwarz d87e843fd2 docs(harness): give an interactive Pi in a terminal seat the coordination skills (#2174)
Someone running interactive Pi from a terminal seat, or from a community preset's launcher, gets a written recipe for giving that agent OpenRig's coordination skills, which OpenRig doesn't install for those seats. The new section in the community harness presets guide copies whole skill directories from the installed openrig-core plugin into the seat's Pi agent directory, and puts one startup line in the seat's first turn that names the queue-handoff skill and carries the CI-wait instruction (rig queue block with an external blocker and a wake). Managed Pi seats and Workshop's pinned copies stay on their own routes, and the section says a copied folder and a startup line don't show that Pi discovered or follows the skill.

1 file, +43 (docs only). No behaviour, flag or check changes.
2026-10-27 03:21:35 -07:00
NIKHIL a04bc1cf2f fix(daemon): release unread notification response bodies (#2172)
The ntfy and webhook notification adapters now cancel each response body they don't read, right after reading its status, so a slow or streaming endpoint no longer holds a connection open until garbage collection. send() still decides success or failure from the status alone and doesn't wait for the cleanup; a cleanup error is ignored and can't change the result. The new tests use a loopback server that holds the body open and assert that the server sees the response close, plus a rejecting cleanup that leaves the result unchanged, for both adapters and both a 200 and a 503.

3 files, +50 (source +4, tests +46). No new refusals.
2026-10-27 03:10:53 -07:00
Nash Aschenbath be52fa0da2 test(daemon): share the compaction submission fixture clock (#2169)
The compaction receipt tests no longer fail when a scheduler pause uses up the fixture's 20 ms idle budget. The enforcer and the transport's injected clock read the fixture's stepped clock while the transport's idle deadline read the real Date.now and slept on real timers, so a pause could turn an idle first submission into not_sent before a cancellation assertion expected submitted_unconfirmed. The fixture now shares one clock for Date.now, the injected now() and the transport's sleeps, as production does with real time; the receipt helpers restore only their send spy, and afterEach restores all mocks. The test-layers guide documents the clock boundary. Production code, the 20 ms budget and the assertions are unchanged; real-timer deadline coverage stays in the transport suite.

2 files, +15 -9 (tests +10 -9, docs +5). No production change.
2026-10-27 02:40:44 -07:00
Nash Aschenbath bf893d3f4d fix(tui): show the configured local host name (#2162)
After rig host rename, the TUI's Topology explorer and host pages show the configured name beside the host's identity, for example "Mac mini 2 (mac)". The TUI reads the name the daemon already serves and keeps it separate from the identity used by commands, navigation, row selection and local reads, so a rename stays stable even when the name matches a registered remote host's id. Control and bidirectional-text characters are left out of the label, and the name is cleaned before the default check: the daemon's default name, an absent, blank or matching name, or one that becomes the default after cleaning, shows the identity once. Addresses the remaining local-name item in #2049, following #2052.

8 files, +154 -15 (source +58 -10, tests +85 -5, docs +11). No new refusals.
2026-10-27 02:39:16 -07:00
Mike Schwarz a3a48e171b fix(daemon): rig down keeps a rolled-back rig down; say which rigs rig start --last brings back (#2166)
An explicit rig down now records the stop for a rig whose latest seat sessions are blank rollback sessions left by a failed start, once their tmux targets are confirmed absent, so rig start --last keeps that rig down. Before, rig down marked only exited or detached sessions, and a rolled-back start left the rig eligible for revival. Genuinely live or racing sessions are never marked, and crash recovery for rigs that weren't explicitly stopped is unchanged. The node inventory reports such a recorded stop as stopped on purpose.

rig start --help and getting-started now say which rigs --last brings back: a rig comes back unless every seat it launched was stopped on purpose. A rig taken down with rig down before OpenRig 0.6.12, or whose seats exited or were lost without rig down, counts as last running, and running rig down <rig> once (by id when names are shared) keeps it down. --last never selects the kernel; under --no-kernel its automatic restore is skipped too, and rig up kernel --existing brings it back.

6 files, +83 -7 (source +23 -7, tests +50, docs +10). No new refusals.
2026-10-27 02:36:01 -07:00
Mike Schwarz de6b635c52 fix(daemon): rig ps reports periodic snapshots only for rigs the scheduler snapshots (#2168)
rig ps (through --full and the API) now reports periodicSnapshotActive as true only for rigs the periodic snapshot scheduler actually snapshots: not archived, with at least one node whose newest session is running. Stopped, zero-seat and archived rigs read false. The scheduler's running-rig query is exported and shared, so the flag and the scheduler can't disagree; the scheduler's own selection is unchanged. Following Christian Todie's change in cerebral-work/openrig PR57.

3 files, +80 -18. No new refusals.
2026-10-27 02:03:13 -07:00
Mike Schwarz 0e8bdd4488 docs(skills): park a row that waits on CI or a long job (#2167)
The queue-handoff skill now says how to wait on something outside the queue, such as CI or a long job: park the row with rig queue block <id> --on external:<what> --wake-after <duration> --continuation "<what resumes>". The row stays yours, reads parked rather than stalled, and the timer wakes you. Text only, with the skill digest regenerated.

2 files, +6 -2.
2026-10-27 01:56:58 -07:00
Dery Ferdika db26369483 docs(test-layers): correct CI scenario counts and case list (#2165)
docs/as-built/test-layers.md now matches scripts/run-pr-scenarios.sh and the CI README: the installed-scenario job starts fifteen fresh containers over nine case options (two baton cases three times each, the refocus-parked effect case three times, and six passing-only cases once each), and the --case and --mode options list them all. Text only.

1 file, +14 -9.
2026-10-27 01:54:57 -07:00
Mike Schwarz 80616b7926 feat(terminal): ensure verified task viewers without relayout (#2133)
A task preview can report a verified provider terminal mapping, and an owner can ensure a missing viewer through POST /api/terminal/viewer/ensure without opening another whole-team layout. The operation re-derives current membership and the preview precondition, serializes creation per endpoint, reuses the returned pane and terminal pair, and records presentation provenance before any effect; a lost reply is inspected before any repeat, and an uncertain or changed identity stays unconfirmed. It doesn't relayout, close departed viewers, take over control, or start or stop agents. Writable shared tile and recovery attaches add tmux's ignore-size flag (read-only attaches keep -r), and CLI window reuse recognizes both writable attach spellings.

The existing open path keeps its current results and accounting: a stricter accounting of id-less or unconfirmed opens is deferred to its own change, with its consumers updated together. Strict verification applies to the new ensure and lookup paths.

19 files, +651 -27 (source +296 -9, tests +321, docs +34). No new refusals.
2026-10-27 01:51:44 -07:00
Nash Aschenbath 2fbe0ddb49 fix(daemon): require source evidence to resolve stuck findings (#2153)
An existing stuck-sweep finding now closes only on the source's delivery, pickup or explicit custody evidence. A failed wake becoming unconfirmed, a fresh human-notification post window, an unreadable registry or a larger reporting-age threshold no longer closes a finding just by removing it from a report. Claimed items returned to pending take part in the existing stalled-pickup derivation, and human notifications stay separate from agent wakes.

Older human notices without owner-notification history can close on their item-scoped posted receipt when the recipient is known to be human and unchanged: a destination reroute, or a park whose person changed, needs a new matching notification episode, since the old receipt carries no recipient key. Closure notes name the evidence and record stuck-sweep@system as the actor, keeping finding ownership. Refs #1419, following #1449 and #1811; addresses the false findings reported in #2151.

5 files, +515 -19 (source +91 -8, tests +376, docs +48). No new refusals.
2026-10-27 01:08:45 -07:00
Nash Aschenbath aaa681c28a fix(daemon): require current launch evidence for fresh handovers (#2159)
A fresh in-place Claude handover now sends its context packet only after it sees Claude's own drawn input frame on the current screen and the matching launch process, stable across the existing process observations and pane checks. Retained scrollback that looks ready, or a shell prompt line that starts with the same prompt glyph, no longer counts. If that proof doesn't arrive within the existing readiness timeout, the handover stops with a clear message and commits nothing.

Ordinary fresh starts, adopted seats, resume and fork keep their readiness paths, and visible gates and identity conflicts keep their specific results. Refs #1544, following the current-screen qualification in #1553.

6 files, +237 -54 (source +43 -19, tests +157, docs +37). No new refusals.
2026-10-27 01:00:50 -07:00
Mike SchwarzandRudy Celekli 9ca37f1ede fix(cli): warn on out-of-range MCP daemon ports while serving (#2160)
rig mcp serve with a --port outside 1-65535 (including 0) now prints one warning to stderr and still starts, so an MCP host can connect and list tools; only the daemon calls fail, as before. Nothing goes to stdout, the MCP protocol channel, and no other port is substituted. A non-numeric --port is still refused, and an empty --port keeps using the daemon's own port. This finishes #1416.

3 files, +102 -1 (source +4 -1, tests +98). No new refusals.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-27 00:22:51 -07:00
Mike Schwarz a5e42d3d6b fix(daemon): return 409 for runtime send refusals and name the stale-pane repair (#2157)
A send refused because of the target seat's runtime state (target_runtime_conflict, target_runtime_not_running, target_runtime_unverified) now returns 409, like the other seat-state refusals, instead of 500. When the refusal is a stale bound pane, it ends with the repair: "If the seat was resumed by hand in its session, run: rig reconcile-session <seat>". No text is typed in any of these cases, and rig send --json now exits 1 for them rather than 2.

3 files, +30 -2. No new refusals.
2026-10-26 23:56:50 -07:00
icey 7fa0f386b1 feat(queue): honor parentless escalates_to routes across same-daemon rigs (#1772)
A rig's top-level seat has no delegation parent, so its recovery requests used to fall back to the operator or to itself. The wake ladder and the stuck sweep now honor a declared escalates_to destination, including a seat in another rig on the same daemon, and explain unavailable, ambiguous, self, cycle and unknown-topology fallbacks. Existing delegation parents keep precedence; named targets are kept as topology declarations through import, export, edges and snapshots, and resolved when used. Delivery is one hop per recovery handoff chain, and a consumed recovery uses its own operator continuation without suppressing unrelated work at the same destination.

An already-open recovery keeps its recorded owner even if the originating seat later gains a delegation parent or a changed escalation target; its wake still goes to that owner. Queue waiting and backstop views use the same resolution as delivery.

Implements the scope agreed in #605.

24 files, +1096 -105 (source +364 -90, tests +687, docs +45). No new refusals.
2026-10-26 23:46:39 -07:00
Mike Schwarz e9e18e4e68 docs(roadmap): mark 0.7.3 released (#2161)
The roadmap now lists 0.7.3 as released, with a link to its release and its three themes (participant foundation, attributed shared-channel conversations, actions and perspective for the primary user), in the same shape as the previous release's section. The active section is removed; "How to help", "Testing" and the backlog sections are unchanged. Text only.

1 file, +3 -9.
2026-10-26 23:44:28 -07:00
Mike Schwarz 7e74aefb76 feat(terminal): derive task views from current queue owners (#2131)
Terminal preview and open accept qitem:<id> and show the current owners of that task's active work: pending, in-progress and blocked obligations through explicitly linked descendants, excluding closed predecessors and historical senders. Stopped, missing, external and unresolved owners stay named, and responses say which qitems contributed and report incomplete lineage instead of silently truncating it. Preview and open also return viewDescriptor {kind, name}, so a client can tell a following view from a fixed saved layout.

This derives membership only: no provider terminals are provisioned, no existing workspace is changed, and no agent lifecycle moves. Task views keep the read-only policy of derived views and grant no input. Lineage reads are bounded by explicit row, byte, link and per-record limits, using existing indexes; reaching a limit keeps the known members and marks the view incomplete. No new schema or index.

8 files, +591 -6 (source +261 -6, tests +285, docs +45). No new refusals.
2026-10-26 23:16:44 -07:00
Mike Schwarz da9e404706 docs(quickstart): link the onboarding page and CLI reference relatively (#2158)
The quickstart's "Learn it in this order" links to the onboarding page and the CLI reference are relative, so a reader opens the version they're reading (GitHub resolves them per ref, and the website rewrites them to the release tag) instead of main. Text only.

1 file, +2 -2.
2026-10-26 23:07:38 -07:00
Mike Schwarz 1e2d7b0a89 fix(cli): daemon start --wait-for-kernel says why a skipped kernel is not up (#2156)
When daemon start skipped the kernel on purpose, `rig daemon start --wait-for-kernel` now says why and how to bring it back, with the same message `rig start` gives (one shared helper), instead of a timeout header and an unrelated sign-in hint. The exit code stays 1; the timeout and failure states keep their messages.

4 files, +79 -10 (source +20 -10).
2026-10-26 22:48:47 -07:00
Seán McNamara 0ad6269fdd test: share the clock in compaction restart fixtures (#2149)
The compaction restart fixture gives transport deadlines and the enforcer's simulated sleeps one shared clock (`Date.now` follows the fixture clock), so a simulated wait no longer uses up real preparation time. The spy is restored by the existing cleanup. Test only.

1 file, +2 -0.
2026-10-26 22:30:55 -07:00
Seán McNamara 4c0af6c536 fix: normalize diff prefixes in portability reports (#2148)
The portability report passes explicit `a/` and `b/` diff prefixes to its own `git diff`, so a user's `diff.mnemonicPrefix` setting no longer changes the file names it reports. The test covers both mnemonic-prefix settings with both `core.quotePath` settings. Default-config output is unchanged.

2 files, +4 -3.
2026-10-26 22:30:36 -07:00
Seán McNamara ffd7371e13 test: compare paired CLI symlinks using canonical paths (#2147)
The seat-launch environment test compares the paired CLI symlink's target by its canonical path, the same representation the launcher follows, so it passes where the temporary directory resolves through a symlink. Test only.

1 file, +2 -2.
2026-10-26 22:30:18 -07:00
Mike Schwarz 159a1abd1b docs(skills): say the queue-handoff writing guidance is not a filter (#2155)
The queue-handoff skill's writing paragraph is followed by its qualification: it's writing guidance, not a delivery filter or length limit. Uncertainty and limits that affect the action stay in. Early references, acknowledgements, requested progress updates and completion returns remain fine, and the sender judges how much context is useful. The skill's digest is regenerated. Text only; no behaviour change.

2 files, +3 -1.
2026-10-26 21:55:24 -07:00
Mike Schwarz 65dfcd8c31 fix(daemon,cli): say why a stale kernel stays down, and count a detached session before notifying (#2152)
A kernel that was already down before this daemon start now says why it stayed down, and names `rig up kernel --existing` to bring it back. `rig start`, `rig daemon start --wait-for-kernel` and `/api/kernel/status` show that reason; every other skip keeps `kernel rig already managed`, and `rig start` no longer gives the `--no-kernel` explanation for this case.

The reconciler records a detached session right after its transaction commits, before notifying, so a failure in the event bus's own post-commit work can't leave it uncounted.

6 files, +42 -5 (source +12 -4).
2026-10-26 21:32:12 -07:00
Mike Schwarz c85f9ee795 docs(skills): write queue handoffs to be acted on after one read (#2154)
The shipped `queue-handoff` skill gains one paragraph on writing a handoff the receiver can act on after one read: open with the request or change that affects their next step, use full words, keep the limits that change that step, put reference IDs after the action, and put the complete request near the start of the task body. The skill's digest is regenerated. Text only; no behaviour change.

2 files, +3 -1.
2026-10-26 21:23:03 -07:00
Mike Schwarz 88892bc595 chore: drop internal tracking references from source comments (#2142)
CLI source comments drop internal tracking references. The daemon's copies of the four shared scope classifier files get the same comment edits, so the CLI and daemon copies stay byte-identical. Comment changes only; no behaviour change.

14 files, +24 -28.
2026-10-26 20:57:20 -07:00
Mike Schwarz 5f76c83430 refactor(mcp): neutral names for the external grant service (#2141)
`rig mcp serve --http`'s external grant options take names that say what they do: `--grant-service <url>` and `--grant-service-header-file <path>`, the answer schema `openrig.grant/v1`, and the recorded principal source `grant-service`. An external caller's access and scope use the same wording.

Unchanged: the key and instance patterns, the request and answer fields and statuses, the 503 `grant_authority_unavailable`, every refusal and its code, and the OAuth discovery paths. No old name is kept as an alias. A grant service and an OpenRig on different names fail closed with the existing 503. Rows already recorded keep the principal they hold, because stored principals are read as text and never re-validated.

Restart a running `rig mcp serve --http` together with the daemon. An adapter from before the upgrade keeps working until the grant service's cutover, but its requests are recorded without the grant principal.

14 files, +297 -287 (source and docs +136 -130).
2026-10-26 20:47:54 -07:00
Mike Schwarz a37e4ed60e test: use documentation addresses (#2140)
Tests and one old release note use documentation addresses: `192.0.2.10` (RFC 5737), or a tailnet-range example where a test checks that range. One fetch-guard message no longer names an address. No behaviour change.

8 files, +23 -23.
2026-10-26 20:39:28 -07:00
Mike Schwarz fb551faf76 chore: drop internal tracking references from daemon, tui and ui sources (#2145)
Source comments in the daemon, TUI and UI packages drop internal tracking references, and the regenerated skill and context-pack mirrors follow. Comment and text changes only; no behaviour change. The scope classifier files are unchanged here, so their CLI and daemon copies stay byte-identical.

54 files, +114 -114.
2026-10-26 20:35:19 -07:00
Mike Schwarz 31efb407de test: use synthetic tailnet fixture values (#2144)
Test fixtures use a synthetic tailnet name in the browser-boundary tests and a synthetic tailnet-range address in a posture test, keeping each test's address class. One fixture uses a non-dated example id. Tests only; no behaviour change.

4 files, +7 -7.
2026-10-26 20:16:37 -07:00
Mike Schwarz c30438c410 fix(daemon): restore only a kernel this daemon start found down (#2146)
Daemon start brings back a kernel only when its own startup reconcile found that kernel's seats newly detached, as a reboot or crash leaves them. A kernel that was already down before this start, because an earlier start found it down and left it, now reads stale and stays down, with the reason in the boot log. Before, a later start restored it from an old snapshot and offered to resume its old conversations, even months later.

Startup reconcile reports the sessions it marks detached, and kernel boot receives them. A running kernel, one stopped with `rig down kernel`, an unclaimed one and `--no-kernel` are unchanged.

7 files, +70 -16 (source +24 -7).
2026-10-26 20:06:15 -07:00
Mike Schwarz e6b8086213 docs: describe mcp serve's grant option by what it does (#2139)
The changelog and `rig mcp serve`'s help describe the HTTP grant option by what it does: the not-in-this-release line reads "grants from an external grant service for `rig mcp serve --http` (#2096)", and the help text and startup message say each request needs a key the grant service issues. Flag names, behaviour and the OAuth discovery help are unchanged.

2 files, +8 -8.
2026-10-26 19:44:22 -07:00
Mike Schwarz 09c8e8a6c3 feat(mcp): OAuth discovery and authorization challenges for HTTP MCP callers (#2135)
An OAuth-capable MCP client pointed at `rig mcp serve --http` can now learn how to authenticate from the server itself. With `--oauth-resource <url>` and `--oauth-issuer <url>`, given together and only alongside the external grant service flag, the server answers `GET /.well-known/oauth-protected-resource<resource path>` with the resource, its authorization server, the `openrig.work` scope and header bearers. That happens before the `/mcp` 404 and before any credential or grant check, whatever the Host. Its 401 names that document in `resource_metadata`, and adds `error="invalid_token"` only when a bearer was presented and refused.

Both values must be https, with no userinfo, query or fragment, written exactly as a URL writes them, and the resource path must end in `/mcp`. Anything else is refused at startup, so nothing is rewritten on the way out. Without the two flags, every response is as before. The key, the grant check and the caller's binding are unchanged, and there is no root document, `/mcp` alias or per-tool scheme. The issuer is served exactly as configured, so it should match the authorization server's own issuer byte for byte.

4 files, +186 -11, of which 109 are test lines.
2026-10-26 18:59:43 -07:00
Mike Schwarz c4de1b2c38 build: read leak-guard rules from an optional local file (#2137)
The leak guard's term lists now come from an optional local rules file instead of a file tracked in the repository. Set `OPENRIG_LEAK_GUARD_RULES` (or pass `--rules`) to an absolute path, and every reader uses it through one loader: the leak guard, `build-package.sh`, the context packs, the skill mirror and the control-plane generator. The publishing rules, the frontmatter keys and the internal-section fence stay in the repository unchanged, so mirrored skills are the same.

Without the file, a contributor's `npm run build:package` still works and prints that the term checks were skipped. The release substance gate refuses to run without a rules file, so a release can't pass with the checks silently off. The tracked rules file is removed, and the tests write their own fixture rules.

16 files, +227 -244.
2026-10-26 18:46:46 -07:00
Mike Schwarz 7d809d5456 fix(review): count only open rows as held, and let in-progress work decide doing (#2136)
The review page's agents band and roster tell the truth about what a seat holds. Only open rows (pending, claimed, in progress or blocked) count as held, so a seat's handed-off and done rows no longer show as holdings. When the seat has in-progress work, that's what it's doing. A seat reads as parked only on a blocked row that names a blocker, and only when nothing is in progress, so a closed row's leftover `blocked_on` no longer marks it parked.

The roster's "recently holding" complement uses the same set, so a seat whose rows were all handed off still appears, holding nothing. The other readers of `ACTIVE_STATES` (needs-you and the slice phase) are unchanged.

Reported by @ziadeh.

2 files, +37 -10, of which 24 are test lines.
2026-10-26 18:10:35 -07:00
Mike Schwarz 63fd8fd015 fix(tui): list rigs by name, as rig ps does (#2134)
The TUI lists a host's rigs by name again, in the same order as `rig ps`, in the explorer sidebar and the host views. Before, it listed them in the order the daemon's rig summary serves them, which is creation order.

`hydrateSnapshot` sorts each host's rigs once, where the snapshot is built, comparing names the way SQLite's `ORDER BY r.name` does, so uppercase sorts before lowercase as in `rig ps`. The sort is stable, so two rigs with the same name keep their creation order. A drilled-into remote host's rigs are sorted the same way. Pods, seats and the daemon are unchanged.

Reported by @duncanleung.

3 files, +37 -17, of which 29 are test lines.
2026-10-26 17:32:58 -07:00
Mike Schwarz c178da735d feat(cli): teach a connected member its inbox, and asking a person while keeping the work (#2127)
An assistant connected to an OpenRig team over the MCP server now learns from the connection how to work: its inbox is the items addressed to its seat, a wake only points at the inbox, and it claims an item, does it, then closes it with the answer or hands it on. When an item needs a person's approval first, it keeps the item and asks instead of closing it or acting anyway: it blocks the item on the person who sent it, with the question and a short form of what it will do as the summary, or, for an item from a seat or a shared channel, asks a seat as a new item and blocks on that. The item reads blocked while it waits, and the same item continues after the answer. Both paths are the queue's existing behaviour; the guidance was missing, not the mechanism.

The guidance is one versioned text, member-workflow/v1, joined into the server's MCP instructions, and docs/reference/hosted-members.md quotes it word for word, with a test keeping the two identical. rig_queue_update's blockedOn and rig_queue_list's owned descriptions say the same. A hosted member with no node or session can call rig_whoami and learn the seat it acts as; before, that call failed. A caller connected through an external grant service asks the team for work rather than being a member, so its instructions keep only the shared lines. No new refusal, prompt or required step.

4 files, +154 -10, of which 98 are test lines.
2026-10-26 17:31:19 -07:00
Mike Schwarz 86c184e2be fix(mcp): a Cloud customer can't hand a task to a caller seat (#2132)
An OpenRig Cloud customer's connected assistant now hands its tasks only to the team it was granted, never to its own caller seat or another customer's caller seat. A customer's `rig_queue_create`, or a revision of one, addressed to a caller-shaped seat answers `out_of_scope` with the same message a customer already gets for a person's or another host's address, and nothing is written.

The change is one line: `customerScope`'s `destination` uses the existing `team` predicate (the grant's member seats minus the caller and any caller-shaped seat) instead of `member`. Self-hosted grants, the customer's show, list and artifact reads, the provenance-tag filter and every other tool are unchanged.

3 files, +32 -6, of which 25 are test lines.
2026-10-26 17:24:05 -07:00
Mike Schwarz 6dfa6fe4cc fix(slack): a person asked about a Slack message sees it without OpenRig's agent context (#2124)
When an agent asks a person a follow-up about a customer's Slack message, the person's Slack post now shows the customer's words and the agent's question without OpenRig's agent-only context: the routing footer, a shared channel's envelope (who wrote, the organization, its readable policy) and the attachments block OpenRig generated (workspace-local paths or transfer-failure diagnostics), in the blocks, the fallback text and long-ask parts alike.

The footer and envelope are cut by their last occurrence, since OpenRig always adds them after the sender's words. The attachments block is cut only by the length inbound recorded when it actually generated one (`slack-attachments:<n>`), at a section boundary, starting with OpenRig's own heading. And the projection runs only while the row's body still matches the digest the gateway recorded at creation (`slack-body:<hex>`): a body a handoff replaced or extended is shown whole, so a person never loses authored words. Look-alikes typed by a customer are kept verbatim. The durable row body, stored files, summary, thread, alert and mention are unchanged; there is no new refusal, setting or migration.

Slack messages received before this change carry no digest and are shown whole when later parked to a person.

3 files, +279 -6, of which 223 are test lines.
2026-10-26 16:47:18 -07:00
Wu Shuwen 6acef1be04 fix(restore): retain down-agent resume sources in periodic snapshots (#2123)
When an agent is down at a periodic snapshot tick, the snapshot now keeps that agent's last resume source, so a later `rig launch <rig> <agent>` resumes its conversation instead of coming back `awaiting-decision`.

The snapshot carries the agent's newest session only when it has a resume token and a `resume_if_possible` policy, and one source is chosen before FR-7 and passed into `postLaunchRestore`; the carry is revalidated before any launch effect. It applies only to a named agent's automatic periodic restore: an explicit `--snapshot-id`, `--fresh`, a whole-rig restore and `selectedHistory` keep their meaning, an agent without a token still starts fresh, and 0.7.2 still reads the new field. The idle-shell message points to the command that actually resumes.

Reported by @pr0xc3nt4ur1 in the linked issue, and built by @dajiaohuang, who traced the restore path past the early refusals first.

8 files, +407 -21, of which 290 are test lines.
2026-10-26 15:04:30 -07:00
Mike Schwarz e66cabeeb9 fix(cli): a Cloud customer's create can't claim OpenRig's Slack provenance tags (#2126)
When an OpenRig Cloud customer hands the team a task and the team later asks a person about it, that person now sees the customer's whole text. Before, a customer could tag its row with OpenRig's own Slack provenance tags, or with the workflow sign-off tag `gate`, so the person-facing view showed only part of the row, or only its summary, while the agent read the whole body.

The MCP server's `rig_queue_create` now drops, for a validated Cloud customer grant only (create and revision), the tags `founder-slack`, `inbound`, `shared-channel` and `gate`, and every `participant:` and `slack-` tag. The customer is identified only from the validated grant. The body, summary, other tags (including `workflow`, `workflow:` and `instance:`) and the request's success are kept; local, stdio and token-file callers and Slack ingestion are unchanged; there is no new refusal.

3 files, +80 -2, of which 71 are test lines, including real daemon routes where the person's render keeps the full body.
2026-10-26 14:51:42 -07:00
Mike Schwarz f5609a88bd fix(cli): serve a Cloud caller seat that was created but never launched (#2125)
An OpenRig Cloud customer's caller seat that was created ahead of time but never launched now works. Before, the MCP HTTP server asked the daemon's `whoami`, which knows only seats that have run, so a pre-created seat was refused with `seat_unavailable` before any tool.

Now, only when `whoami` answers 404, the server reads the daemon's own rig and node lists and serves the seat when exactly one existing node's canonical name equals the seat the Cloud grant names. Two active rigs or two nodes with that name, a malformed rig-list entry, a failed or timed-out read, and any other `whoami` error are refused with the existing `grant_authority_unavailable`; a missing seat or a rig that exists only archived reads `seat_unavailable`. Nothing is created, launched or bound, and the grant, instance and revocation checks, customer scope and `principal:v1` are unchanged. The shared rig-name resolver gains an opt-in strict mode that only this check uses.

5 files, +275 -7, of which 227 are test lines, including a real-daemon test with a created, never-launched caller.
2026-10-26 13:49:42 -07:00
Mike Schwarz 6778fcc496 feat(cli): show when a hosted member last called, and its latest ring (#2121)
`rig_team`, `rig roster show` and `rig roster find` now say when a hosted member last called the team's MCP server, and the outcome of its latest ring. When there's no evidence, the answer is unknown, never idle. A roster row for a member on another host reads unknown instead of borrowing this host's record.

After a caller's grant authenticates, the MCP HTTP server writes the seat, the grant's name and the time to `mcp-http-presence.json`: mode 0600, beside the token file and never in it, at most once a minute per seat. An unauthenticated or failed request writes nothing, and a failed write never fails the request. The latest ring is read from the member's recent rows. Callers using Cloud grants have no presence.

The hosted-members guide now says a webhook receiver should tolerate a repeated `qitem_id`.

10 files, +207 -13, of which 96 are test lines.
2026-10-26 12:11:20 -07:00
Mike Schwarz 22116a78d9 fix(pi): recover a saved history cursor Pi doesn't hold, and take Pi's cursor only from history (#2122)
A Pi seat whose saved history cursor isn't one Pi holds, such as an interface event id saved by OpenRig 0.6.11 to 0.7.1, now recovers it at the next start. When Pi refuses the history catch-up, the runner prints that the history catch-up didn't run, keeping that apart from whether the seat resumed, and re-reads the cursor from the tail with the same read-only refresh it already sends after every turn. Nothing is reset or replayed. It no longer prints a runner error and re-sends the same cursor at every restart until a turn completes.

On Pi, an event's own id no longer becomes the saved cursor; an explicit history entry id still does. OMP is unchanged.

Reported by smota in Discussion #2117.

2 files, +48 -2, of which 37 are test lines.
2026-10-26 11:39:47 -07:00
Mike Schwarz d3c7d790c5 fix(slack): route a reply on a self-parked ask to its asker, and never post a Slack message back as an ask (#2119)
When an agent parks its own ask on the person it asked, OpenRig's Slack side now takes the agent from the row's source. The park's post stays in the ask's thread instead of opening a new top-level post, and a colleague's reply in that thread reaches the agent who asked, attributed to the colleague. The ask stays parked on the person until they answer, and their own answer still resolves it.

A Slack message routed to a person is recorded for Steering and the TUI when it arrives and is no longer posted back to Slack as a new ask carrying OpenRig's routing lines and the shared channel's policy text. If an agent later parks that message on a person, that park is a new ask and is posted as usual.

Threads whose root was recorded under the person before this change keep routing replies to that person.

4 files, +172 -4, of which 153 are test lines.
2026-10-26 11:18:13 -07:00
Mike Schwarz 457aa6d002 fix(plugin): advance main metadata past released core payloads (#2118)
Main's openrig-core plugin manifests move from 0.1.17 to 0.1.21, and the packaged-plugin baseline fixture moves from 0.7.1 to the published 0.7.2 archive (plugin 0.1.19). The plugin's payload on main has changed since both published 0.7.2 and the 0.7.3 cut, so a build of main now passes the stock packaged-plugin version check instead of failing it.

3 files, +6 -6, metadata only.
2026-10-26 11:11:10 -07:00
Mike Schwarz c0fe03ad04 fix(usage): keep a seat's current Claude or Codex cost, and finish a charge lookup retried during a re-post (#2120)
With Pi cost reporting turned on, a seat name that once ran Pi and now runs Claude Code or Codex shows its current cost in `rig usage cost` and the TUI's Cost view, not its old Pi estimate: a stored Pi session replaces the seat's entry only when it's newer than the seat's latest context sample. An OpenRouter charge lookup that is retried while unacknowledged outcomes are being re-posted now finishes once the re-post ends, instead of staying pending until the seat's next turn. Both live behind the off-by-default `usage.pi_cost_facts` and `usage.pi_openrouter_charge` settings.

Found by oSquashBlossomo's review in their fork's PR #53.

4 files, +55 -2, of which 34 are test lines.
2026-10-26 11:04:06 -07:00
Mike Schwarz b72abee350 feat(daemon): ring a hosted member's webhook when work lands for it (#2115)
A hosted member, an outside tool on the team whose roster entry declares a webhook trigger, is now rung when work lands for it. The daemon POSTs a pointer to the row (its id, destination, sender and summary, never its body) to the member's declared webhook, taking credentials only from secret refs. Credentials in the URL are sent as a Basic Authorization header, and no credential appears in any recorded result or posted finding.

An accepted ring is recorded as accepted, not as picked up, on the wake that rang. A daemon restart doesn't ring that same accepted wake again, while a new wake for the row, such as an unblock or a resolve decision, still rings, and a failed ring is retried. A member whose entry declares no trigger stays unroutable, so its person is told, as before. `rig queue create --verify` says the trigger accepted the ring, not that the member picked it up.

14 files, +608 -75, of which 316 are test lines.
2026-10-26 10:47:36 -07:00
Mike Schwarz 5219120dd3 docs(harness): check that a harness answers on its own before writing its preset (#2116)
Before writing a preset, a community-harness author now checks that the harness answers one prompt on its own, outside OpenRig, using the folder the launcher gives the seat rather than the harness's default one. A provider, model or login problem then shows up in the harness itself instead of costing repeated seat relaunches, which can't fix it. "Write your own" points back to the step.

1 file, +9 -1, text only.
2026-10-26 10:11:51 -07:00
NIKHIL 4cc91e914e fix(review): keep asks to and parks on a person's own address in NEEDS YOU (#2108)
An ask to a registered person's own address, such as `sarah@external`, and a row parked on it now show in NEEDS YOU in the slice, mission and rig reviews (`/api/review/*`, read by the web review pages and the TUI), and the person is no longer listed as an AGENTS row holding that work. The reviews ask the queue's own participant facts, so a registered alias such as `sarah@host` counts too.

A registered shared channel takes correspondence, not decisions: a row sent to or parked on one never appears in NEEDS YOU, whatever its tier, and the channel isn't an AGENTS row. Typed gates such as `external:qa-pass` stay out of NEEDS YOU.

3 files, +110 -17, of which 47 are test lines.
2026-10-26 09:57:14 -07:00
NIKHIL 860e8d4c4d fix(daemon): verify Claude handover packet submission (#1863)
A fresh `rig handover --source fresh` on Claude no longer reports the restore packet delivered just because paste and Enter returned. The daemon reads Claude's composer after Enter. A submitted packet is reported delivered. One still staged gets a single Enter retry on complete staged-text evidence, and if it stays staged the handover fails with `context_delivery_failed` and the successor is unwound. When the composer can't be observed or the result is unclear, the handover succeeds with `restorePacket: { status: "unconfirmed", reason }`.

`rig seat handover` and `rig handover` say the packet was sent but its submission is unconfirmed, give the reason, and say to check the successor before sending the context again. Rebuild handovers and the rest of #1544 are unchanged.

7 files, +213 -16, of which 101 are test lines.
2026-10-26 09:28:05 -07:00
NIKHIL 563f1a2470 fix(daemon): exclude artifacts with unclosed frontmatter (#2016)
An artifact whose frontmatter opens with `---` but never closes is now treated as malformed frontmatter and excluded from artifact-ready wakes, so an unfinished file no longer wakes an unfiltered artifact pool early. A file that uses a leading `---` line as a horizontal rule is excluded the same way; the option's documentation says so. An edge target with an incomplete header still counts as existing, so a missing-downstream-artifact repair wake isn't manufactured for it.

3 files, +59 -2, of which 52 are test lines.
2026-10-26 09:09:54 -07:00
Nash Aschenbath 23070b6465 fix(claude): keep activity consumers in source order (#2113)
A delayed older Claude hook no longer replaces the current state in send readiness, attention clearing, review telemetry or the wake scheduler. An old `Stop` arriving after a current limit can no longer permit a wait-for-idle send, and an old `Stop` arriving after a permission prompt can no longer settle that prompt's open wake alert. These readers now use the activity store's existing source order for Claude, as the limit readers do since #1908. Other runtimes keep arrival order.

9 files, +141 -9, of which 113 are test lines.
2026-10-26 08:56:35 -07:00
Brad Vincent db90c6dad3 feat: resume explicitly reviewed history by node ID (#2109)
An agent's reviewed history can now be resumed by node ID. After `rig seat history` returns a history for an exact node, a client asks the startup review for it: the review names the node, the chosen snapshot or history, the planned settings sources and an opaque revision. Apply then rechecks all of them and resumes exactly that conversation through the existing restore engine. If anything changed, it asks for a new review instead of resuming something else, and it never starts a fresh conversation in its place.

The result reports what actually happened. A resume that ran but couldn't be confirmed reports `resume_unconfirmed`, and a permission that can't be resolved reports `permission_unavailable`. Default startup and restore are unchanged for existing callers.

This is the last of #1837's three stages, after #2051 and #2100. 18 files, +1056 -51, of which 621 are test lines.
2026-10-26 08:55:42 -07:00
Mike Schwarz e37443de4b feat(mcp): serve MCP 2026-07-28 clients beside 2025 ones (SDK 2.x) (#2112)
OpenRig's MCP server now also serves clients on the 2026-07-28 MCP protocol: they don't send `initialize`, carry the version on each request, and may open with `server/discover`. Those clients reach the same tools and durable work loop over stdio and `rig mcp serve --http`. Clients on the 2025 protocol keep working on the same endpoint and process.

It moves from the 1.x SDK and its Express stack to `@modelcontextprotocol/server` 2.4.0, `core` 2.4.0 and `node` 2.1.2 (Apache-2.0). The 1.x SDK will not get the 2026-07-28 protocol. Token-file grants, Cloud grants and customer scope, principal attribution and the loopback Host check stay in front, unchanged.

Behaviour changes every client sees in `tools/list`: each tool's input schema is now advertised as JSON Schema draft 2020-12 (was draft-07), and the `execution.taskSupport: forbidden` field is no longer sent.

7 files, +870 -346: the 36 tool registrations move mechanically to `registerTool`, tests +151, and the lockfile accounts for most of the rest.
2026-10-26 08:37:58 -07:00
Mike Schwarz 111b51fec5 fix(daemon): tell the person when work waits for a member nothing can wake (#2110)
When work waits for an address nothing can wake, such as a hosted member that lives outside a terminal or a mistyped address, the stuck sweep now tells a person instead of the address that can't hear it. It tries the operator seat when that's a person, else the row's creator, else the operator agent, and skips any that is unset, the same address, missing or itself unreachable. The finding says why, and that a hosted member sees the work only when it next checks.

A seat that has launched since its wake keeps its own finding. The finding still closes when the work is claimed. Nothing new is refused.

3 files, +91 -1, of which 64 are test lines.
2026-10-26 08:25:32 -07:00
Mike Schwarz e08d084465 docs(claude): name the other-login helper case for rate-limit attribution (#2111)
The Claude status-line reference now names the other-login case for rate-limit attribution. A helper Claude started from a seat's shell on a different login is attributed to that seat, like a same-login helper, so its rate limit can hold pending handoffs to every seat in that seat's Claude pool for one retry interval, even when the seat's own login still has room.

Docs only, one file, +5 -2.
2026-10-26 08:20:03 -07:00
NIKHIL d2a73083dc fix(slack): a park on a person's own address answers only from a reply after its question (#2106)
When a seat parks a row on a person's own address, such as the `<id>@external` address their inbound Slack rows carry, only a Slack reply written after the current question posted now resolves it. That's the reply-ordering rule legacy human seats (`human@kernel`) already follow. Before this, a registered person's address skipped the check, so an early follow-up could answer a question asked later.

The person's earlier messages still reach the agent as context; only the automatic resume waits for a reply after the question. The resolver uses the queue's existing person-address check, so a registered alias counts too.

2 files, +62 -3, of which +3 -2 is product code.
2026-10-26 08:15:36 -07:00
Nash Aschenbath 2adde8ffe0 test(ci): exercise seat environments on Linux (#2101)
CI now runs the Linux-only seat-environment test in its own job on pull requests and manual dispatch. The macOS package matrix skips it because it reads `/proc/<pid>/environ`, which left #2041's launch paths untested there. The test also checks that new and existing tmux servers give seats the runtime directory, the disabled session-bus default, and tmux's own terminal identity rather than the daemon parent's.

The job has read-only contents permission and no secrets, and takes about 90 seconds. The required checks are unchanged.

3 files, +61 -2.
2026-10-26 07:57:26 -07:00
Nash Aschenbath 2ed390de39 fix(claude): detect typed rate-limit interruptions (#1908)
A Claude seat that hits a rate limit now reads as at its limit, from Claude Code's typed `StopFailure` hook with `error: rate_limit`. OpenRig never reads the error text. The wake ladder then holds the seat's handoffs for its existing retry interval instead of typing into a stopped seat. Limit events are ordered by when the hook fired, so an older event arriving late doesn't clear or reassert a limit. When seats sharing a Claude home report conflicting limit windows, only the failing seat is held.

Known limit: a helper Claude started from the seat's shell inherits the seat's environment, so its rate-limit hook is attributed to the seat. On the same login that's right, since the quota is the account's. A helper on a different login can hold the seat's handoffs for one retry interval. Binding the hook to Claude's own session id would remove that.

17 files, +533 -47, of which 361 are test lines. The plugin version is unchanged.
2026-10-26 07:55:44 -07:00
NIKHIL 47fe5b7325 fix(queue): keep a park's summary and evidence when it parks on a registered person (#2107)
`rig queue block <id> --on sarah@external --summary "…" --evidence-ref …` now parks the row and keeps both fields when `sarah` is a registered person, as the same park on `human@kernel` already does. Before this it refused with `summary_evidence_not_persistable`. The check uses the queue's existing person-address predicate, so a registered person's address or alias counts. A bare park on a person still needs nothing new, and other transitions still reject the fields.

2 files, +26 -2.
2026-10-26 07:54:25 -07:00
Mike Schwarz 4c34d13cae feat(daemon): record which MCP grant's caller acted (principal:v1) (#2098)
`rig mcp serve --http` now tells the daemon which grant's caller acted, and the daemon records it beside the rows that caller writes (identity_provenance `principal:v1`, plus a nullable identity_principal column, migration 140). A token-file grant is recorded by its name. A Cloud customer is recorded by id, grant id and version. The principal is sent only to an endpoint proven to be this host's own daemon (its /healthz self id, loopback with no forwarding headers), and recorded only on direct hops. Draft-aware rows keep it.

The header carries non-ASCII names as JSON escapes, so a token-file grant named in any script keeps working. Rows written another way are unchanged.

20 files, +557 -58, of which 315 are test lines.
2026-10-26 07:43:36 -07:00
Mike Schwarz 62169765b2 docs: carry 0.7.3's capability map and changelog section to main (#2105)
Carries 0.7.3's capability map, with its `capability-delta-v0.7.3` marker, and the CHANGELOG 0.7.3 section from the release branch to main, so the next release builds on what 0.7.3 teaches. Both files are byte-identical to the 0.7.3 candidate (30a4ea13), and neither had changed on main since the cut. The CHANGELOG section is the candidate's text; the exact published notes are reconciled after publication.

`docs/reference/whats-new.md` waits for the version bump after publication, because a repo test checks that it names the packaged version. Versions, install pins and the plugin manifests also wait for the tag.

2 files, +456 -9, docs only.
2026-10-26 07:27:55 -07:00
Mike Schwarz c3c404f10e docs: state the 0.7.3 limits where people rely on them, and list rig seat history (#2104)
Docs only. These are the gaps the 0.7.3 docs check found, fixed on main:

- The Hermes preset and its example no longer say a stopped seat's gateway exits once idle; that hasn't been checked, so they say to check with `ps`.
- The Codex permission envelope in `rig-spec.md` says OpenRig's tests prove the launch flags, not that Codex enforces them, and that an OpenRig older than 0.7.3 launches the seat without the envelope.
- Stopping a community seat says whether the harness's own processes survive isn't exercised, so check them after `rig down`.
- `getting-started.md` and `help.md` say `rig setup` and `rig doctor` run the tmux capture check (`tmux_capture`).
- `docs/as-built/cli-reference.md` lists `rig seat history`.

6 files, +18 -9.
2026-10-26 06:57:45 -07:00
Mike Schwarz 43f3c9097b fix(cli): keep tmux's errors off the terminal during identity lookup (#2103)
`rig down`, `rig whoami`, `rig ask` and `rig health`, run in a tmux pane without OPENRIG_SESSION_NAME, no longer print "invalid option: @rigged_node_id" and "@rigged_session_name" lines. Those were tmux's own error output from the identity lookup asking for an unset option; the lookup already fell through correctly, and the error still reaches the code that handles it.

The OpenClaw native run found it. It has been there since rig down's own-rig check (#1838, in 0.7.2). CLI only, +21 -1, with a test that runs real tmux against an empty socket directory.
2026-10-26 06:51:23 -07:00
Mike Schwarz 7ec3121104 fix(tui): don't count a refused effort report as a failed read (#2102)
The TUI no longer shows "1 read(s) failed" on a seat whose runtime doesn't report effort, such as a community harness. The daemon's 409 effort_unsupported_runtime is an answer, and the panel already showed it correctly; the page reader just counted it as a failed read too. A real server error still counts.

The OpenClaw native run found it. It has been there since the effort read was added (#1529, in 0.7.2). TUI only, +18 -2, with one test for both the refused and the failing case.
2026-10-26 06:41:31 -07:00
Mike Schwarz cd850f0e54 feat(mcp): show a rig's hosted members on its team, and how a member works its rows (#2095)
A tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, can now be seen as a member of a rig's team. A roster entry can say it's hosted (`"hosted": true`), and `rig roster show` and `find` then report the member as hosted rather than unmatched. `rig_team` lists the rig's hosted members with their authored capabilities and limits, no pane, and activity `unknown`: OpenRig doesn't observe such a member between calls, so it never reads idle. A roster folder or file that can't be read gives a generic `hostedMembersNote`, never an empty list.

The MCP server's instructions now name the loop a member uses on its rows (list, claim, then answer with a resolution or a handoff). Addressing rows to the member and working them under a grant already worked; this adds the team view and the guidance.

9 files, +258 -12. Docs: `docs/reference/hosted-members.md`.
2026-10-26 06:15:55 -07:00
Brad Vincent a2dfc69bbf feat: inspect captured resume histories by node ID (#2100)
`rig seat history <node>` lists an agent's captured startup histories for an exact node ID and validates a chosen one, in text or JSON. Each entry shows its resume type and saved token state, never the token. Eligible means the engine can attempt that resume, not that the native conversation still exists. A reference that is invalid or no longer current is refused rather than replaced with another history. Codex and Claude are covered first.

It reuses startup's current snapshot lookup, its restore-preview classification and each adapter's resume check, and doesn't launch anything or call a native loader. Existing startup and default restore behaviour are unchanged for valid inputs; malformed imported metadata now reads as unverified instead of crashing discovery.

This is the shared-selector stage of #1837. Applying a chosen history is a separate later change.

10 files, +637 -29, of which 394 are test lines.
2026-10-26 05:53:39 -07:00
Mike Schwarz 7d4946e935 feat(mcp): grants from OpenRig Cloud for rig mcp serve --http (#2096)
`rig mcp serve --http --cloud-grants <url>` takes its grants from OpenRig Cloud instead of the local token file, for a Core instance whose callers are Cloud customers. Each request's customer key is checked against Cloud for this daemon's own instance id. Any answer but active is refused (401, or 503 when Cloud is unavailable), with no fallback to the token file. A customer grant reaches only the queue and artifact tools it names, sees only its own items and the team's questions to it, and every row it receives drops the parties outside its scope. Self-hosted token-file grants are unchanged.

The caller-seat create refusal (78f426ba) is reverted in this merge while it waits on a product decision; it comes back by reverting 0f93cfed. Named limits: the caller-seat read rule rests on Cloud's one-customer-per-rig provisioning contract, not general tenant isolation, and matching the instance is a configuration binding, not cryptographic authentication.

5 files, +765 -50, of which 374 are test lines.
2026-10-26 05:34:07 -07:00
Mike Schwarz ffdf0a3a8a fix(tui): don't draw into a zero-size terminal (#2099)
`rig` or `rig tui` started in a terminal that reports 0 rows or columns, such as a detached, headless or just-resizing pane, no longer crashes on its first frame. It skips that frame and draws on the next resize. A size the terminal doesn't report keeps the existing defaults.

The crash has been there since 0.7.2. TUI only, +13 -3, with a stub test at 0 rows and 0 columns.
2026-10-26 05:03:08 -07:00
Mike Schwarz bfaa244831 docs: a Hermes gateway runs per Hermes home, so per seat with the example launcher (#2097)
The Hermes example config and the community-harness presets guide now say that Hermes runs tool commands in its gateway, one per Hermes home, so one per seat with the example launcher. They said "one gateway per machine", which a two-seat run disproved: each seat had its own gateway, home and socket, and stopping one seat left the other running.

Docs only, two files, +5 -2. Hermes resume remains undeclared.
2026-10-26 04:51:43 -07:00
444 changed files with 19658 additions and 2266 deletions

No files matched your search

+36 -1
View File
@@ -3,7 +3,8 @@ name: Tests
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
# list each published package would contain. Each suite is its own job so one failure does not
# hide the others. No provider credentials or operator configuration are supplied.
# Package suites deny external networking; scenario runtime has no external route.
# Package suites and the Linux seat-environment check deny external networking;
# scenario runtime has no external route.
on:
push:
@@ -76,6 +77,40 @@ jobs:
- run: npm ci
- run: npm run test:repo
linux-seat-environment:
if: ${{ github.event_name != 'push' }}
# This integration test reads /proc/<pid>/environ and is skipped on macOS.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install the real terminal and staging tools
run: |
sudo apt-get update
sudo apt-get install --yes tmux rsync
- run: npm ci
- run: npm run build
- name: Check seat environments without ambient credentials or external network
run: |
set -euo pipefail
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-seat-env.XXXXXX")
mkdir -p "$suite_root/home"
# Only loopback exists in this namespace. Drop back to the runner user
# before the CLI, private daemon and private tmux server are started.
sudo unshare --net -- /bin/bash -s -- "$(id -un)" "$suite_root" "$PATH" <<'SUITE'
set -euo pipefail
ip link set lo up
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
CI=true TERM=xterm-256color \
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts --maxWorkers=1 --reporter=verbose
SUITE
package-tests:
if: ${{ github.event_name != 'push' }}
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
+4 -3
View File
@@ -290,9 +290,10 @@ packages.
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
frontmatter.
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`, with rules in
`scripts/internal-tokens.generated.json`). It refuses maintainer-internal names and paths in
content that ships. It runs in `generate-context-packs.mjs` (static pack sources, on every
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`). Its term lists come from an
optional local file that `OPENRIG_LEAK_GUARD_RULES` names (`scripts/leak-guard-rules.mjs`);
without one the term checks are skipped and every other check runs, and the release-time
substance gate requires it. It runs in `generate-context-packs.mjs` (static pack sources, on every
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
(the daemon `specs/` tree before it is copied into the package), and in the release-time
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
+734
View File
@@ -8,6 +8,740 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.4]
OpenRig 0.7.4 lets an assistant you already use work as a member of your team.
An assistant connected over MCP can be listed on its rig's team as a hosted
member, learns from the connection how to take, finish and hand on work, can
ask a person while keeping the item, and can be rung by a webhook when work
lands for it; when nothing can wake it, its person is told instead.
`rig mcp serve --http` can check each remote caller's key with an external
grant service, offer OAuth discovery, record whose grant acted, and serve MCP
clients on protocol revision 2026-07-28 beside 2025's. You can list the
conversations a seat could resume, and a client can resume the one a person
reviewed. Daemon start now brings back only a kernel that this start found
down. It is built from the commit tagged `v0.7.4`. The changes since 0.7.3:
### Before you upgrade
- **Restart the daemon after installing.** One database update applies on its
first start (migration 140). It adds a column, empty on earlier rows, that
records which grant's caller acted, to queue transitions and their archive,
inbox and outbox entries, and stream items.
```bash
npm install -g @openrig/cli@0.7.4
rig daemon stop && rig daemon start
```
- **The bundled openrig-core plugin goes from 0.1.20 to 0.1.21.** The
`queue-handoff` skill gains writing guidance, and the Claude hooks add
`StopFailure`, which reports a stop caused by a rate limit. As before,
startup first copies each file it replaces under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; if you edited one, reapply
your change from the copy. A Claude seat gets the new hook at its next
launch, resume, restore or handover. Codex sees its openrig-core plugin as
changed: if a Codex seat stops at "Hooks need review", review the hooks in
Codex.
- **Going back to 0.7.3** leaves migration 140's column in place, unread.
### What's new and fixed
**Assistants as team members**
- **A rig's team shows the members that live outside a terminal.** A roster
entry can mark a member as hosted (`"hosted": true`), such as an assistant
connected over MCP. `rig_team` lists a rig's hosted members with their
declared capabilities and caveat, no pane, and activity `unknown`: OpenRig
doesn't observe a hosted member between its calls, so it never reads idle.
A roster that can't be read gives a note, never "no members". `rig roster
show` and `find` say the member is hosted.
([#2095](https://github.com/mvschwarz/openrig/pull/2095))
- **The connection teaches the assistant how to work.** The MCP server's
instructions say that its inbox is the items addressed to its seat, that a
wake only points at the inbox, and to claim an item, do it, then close it
with the answer or hand it on. When an item needs a person's approval first,
it keeps the item and asks: it blocks the item on that person with the
question as the summary, and the answer returns the item in progress. A
hosted member can call `rig_whoami` to learn the seat it acts as.
([#2095](https://github.com/mvschwarz/openrig/pull/2095),
[#2127](https://github.com/mvschwarz/openrig/pull/2127))
- **When work waits for a member nothing can wake, the person is told.** The
stuck sweep used to address its finding to that same member. Now it goes to
the person, on Steering and the TUI's Feed, and to Slack under the default
subscription, saying to ask the member to check or to fix the address.
([#2110](https://github.com/mvschwarz/openrig/pull/2110))
- **A hosted member can be rung when work lands for it.** Its roster entry
declares a webhook trigger: `wake.webhook` with `url_ref`,
`authorization_ref` and optionally `secrets_env_file`. These are secret
names, read from the daemon's environment or a 0600 env file; values never
go in rosters or rows. OpenRig POSTs a pointer to the row (the event
`work-waiting`, the row id, destination, summary and sender, never the row's
body), waits up to 10 seconds and doesn't follow redirects. A 2xx is
recorded as the ring being accepted, not as the member waking. If the member
doesn't claim the work by the stuck sweep's unclaimed age, its person is
told. ([#2115](https://github.com/mvschwarz/openrig/pull/2115))
- **The team sees when a hosted member last showed up.** `rig_team` shows when
each hosted member last called the team's MCP server, and its latest ring
and how that went; `rig roster show` and `find` show the last call. With no
evidence it says unknown, never idle, and a claim on a row is still the only
pickup. ([#2121](https://github.com/mvschwarz/openrig/pull/2121))
**Remote MCP callers and grants**
- **`rig mcp serve --http --grant-service <url>`** checks each request's key
with an external grant service instead of the local token file, on every
request, with nothing cached. `--grant-service-header-file <path>` sends one
`Name: value` header with each lookup.
([#2096](https://github.com/mvschwarz/openrig/pull/2096),
[#2141](https://github.com/mvschwarz/openrig/pull/2141))
- **OAuth discovery:** `--oauth-resource <url>` with `--oauth-issuer <url>`
publishes OAuth protected-resource metadata (RFC 9728), so an MCP client
that signs in with OAuth can find out how to authenticate, and a request
with no credential or a bad one gets a standard challenge. The key and its
grant still decide what the caller may do.
([#2135](https://github.com/mvschwarz/openrig/pull/2135))
- **Whose grant acted is recorded.** The rows a granted caller's actions write
record the caller's principal (`principal:v1`): its source, its grant name
or id and, for a grant service, the grant's version. It's attribution, not
authentication, and never a credential.
([#2098](https://github.com/mvschwarz/openrig/pull/2098),
[#2141](https://github.com/mvschwarz/openrig/pull/2141))
- **A caller's seat that was created but never launched** (for example with
`rig import --materialize-only`) is served, instead of every request being
refused with `seat_unavailable`.
([#2125](https://github.com/mvschwarz/openrig/pull/2125))
- **A caller granted through a grant service** hands its tasks to the team it
was granted, never to its own caller seat or another caller's
([#2132](https://github.com/mvschwarz/openrig/pull/2132)), and can't attach
tags that claim OpenRig's own Slack provenance or a workflow's internal
sign-off, so a person asked about its task sees its whole text
([#2126](https://github.com/mvschwarz/openrig/pull/2126)).
**MCP protocol 2026-07-28**
- `rig mcp serve`, over stdio or `--http`, serves clients on MCP protocol
revision 2026-07-28, which has no `initialize` handshake, sends its version
on every request and may open with `server/discover`. They reach the same
tools and the same work loop. Clients that open with 2025's `initialize`
keep working unchanged, on the same endpoint and process.
([#2112](https://github.com/mvschwarz/openrig/pull/2112))
**Resuming the conversation you chose**
- **`rig seat history --rig <id> --node-id <id>`** lists the recorded Claude and Codex
conversations this exact node could resume, read-only and without native
tokens (`--json`; `--snapshot-id` with `--history-session-id` checks one
reference it returned). The rig ID is `rigId` in `rig ps --nodes --json`, and the
node ID is `identity.nodeId` in `rig whoami --session <seat> --json`. ([#2100](https://github.com/mvschwarz/openrig/pull/2100))
- **A client can resume the history a person reviewed** for an exact node,
through the daemon's startup review. Applying it rechecks the choice and the
settings through the existing restore path, and refuses, rather than
substituting another conversation or starting fresh, if the history changed
or became unavailable. A running or unverified terminal is left alone. With
0.7.3's saved seat settings by node id
([#2051](https://github.com/mvschwarz/openrig/pull/2051)), this completes
the three stages agreed in
[#1837](https://github.com/mvschwarz/openrig/issues/1837).
([#2109](https://github.com/mvschwarz/openrig/pull/2109))
**Task views**
- `rig terminal open` and the daemon's terminal preview accept `qitem:<id>`: the current local
owners of that task, meaning its pending, in-progress and blocked rows
through explicitly linked successors, without closed predecessors or past
senders. Stopped, missing, external and unresolved owners are named, and
incomplete lineage is reported rather than cut short. It's a read-only
membership view: it doesn't follow the task as it moves, and an explicit
open still has the provider's usual workspace effects. Preview and open
responses now carry `viewDescriptor` (`kind`, `name`).
([#2131](https://github.com/mvschwarz/openrig/pull/2131))
**The kernel at daemon start**
- **Startup brings back a kernel only when this start found it down,** which
is what a reboot leaves. A kernel that was already down before this start
stays down, with its old conversations. `rig start` and `rig daemon start
--wait-for-kernel` say why and how to bring it back (`rig up kernel
--existing`); `--wait-for-kernel` no longer prints a timeout and an
authentication hint for a kernel skipped on purpose, and still exits 1.
([#2146](https://github.com/mvschwarz/openrig/pull/2146),
[#2152](https://github.com/mvschwarz/openrig/pull/2152),
[#2156](https://github.com/mvschwarz/openrig/pull/2156))
**Seats and runtimes**
- **A Claude seat stopped by a rate limit is recognised** from Claude's
`StopFailure` hook, even when its status line shows no usage window, and
pending handoffs use the existing wake ladder and retry timer. A delayed
older Claude hook no longer replaces the current state in send readiness,
attention, review telemetry or the wake scheduler.
([#1908](https://github.com/mvschwarz/openrig/pull/1908),
[#2113](https://github.com/mvschwarz/openrig/pull/2113))
- **`rig handover --source fresh` on Claude** reports the restore packet
delivered only when Claude's composer shows it submitted. If it's still
staged after one retried Enter, the handover fails with
`context_delivery_failed`.
([#1863](https://github.com/mvschwarz/openrig/pull/1863))
- **A periodic snapshot between separate named-agent launches** keeps a down
agent's resume source, including its restore policy and community harness
selection, without claiming it was running.
([#2123](https://github.com/mvschwarz/openrig/pull/2123), fixes
[#1822](https://github.com/mvschwarz/openrig/issues/1822))
- **A resumed Pi seat** no longer prints `Entry not found` after `READY` on
every restart: a saved history cursor Pi doesn't hold is recovered.
([#2122](https://github.com/mvschwarz/openrig/pull/2122))
**People, parks and reviews**
- **A park on a registered person's own address** (`<id>@external`) resolves
only from a Slack reply written after its question was posted
([#2106](https://github.com/mvschwarz/openrig/pull/2106)), keeps its
`--summary` and `--evidence-ref`
([#2107](https://github.com/mvschwarz/openrig/pull/2107)), and shows in
NEEDS YOU in the slice, mission and rig reviews, with asks to that address,
instead of the person being listed as an agent holding work
([#2108](https://github.com/mvschwarz/openrig/pull/2108)).
- **The reviews' agents band** counts only open rows as held, and a seat with
work in progress reads as working even when it also has an older blocked
row. ([#2136](https://github.com/mvschwarz/openrig/pull/2136), fixes
[#2129](https://github.com/mvschwarz/openrig/issues/2129))
**TUI and CLI**
- `rig` or `rig tui` started in a terminal that reports 0 rows or columns
waits and draws on the next resize, instead of crashing on its first frame.
([#2099](https://github.com/mvschwarz/openrig/pull/2099))
- The TUI lists a host's rigs by name, in `rig ps`'s order.
([#2134](https://github.com/mvschwarz/openrig/pull/2134), fixes
[#2130](https://github.com/mvschwarz/openrig/issues/2130))
- Opening a seat whose runtime doesn't report effort no longer shows a failed
read in the TUI header. ([#2102](https://github.com/mvschwarz/openrig/pull/2102))
- `rig whoami`, `rig down`, `rig ask` and `rig health` in a tmux pane without
`OPENRIG_SESSION_NAME` no longer print tmux's `invalid option` errors.
([#2103](https://github.com/mvschwarz/openrig/pull/2103))
**Also:** an artifact whose frontmatter header isn't closed no longer wakes an
artifact-pool worker as ready
([#2016](https://github.com/mvschwarz/openrig/pull/2016)); with Pi cost
reporting on, a seat that now runs Claude or Codex shows its current cost, and
a charge lookup retried during a re-post finishes
([#2120](https://github.com/mvschwarz/openrig/pull/2120)); the
`queue-handoff` skill says to write a handoff that can be acted on after one
read, as guidance and not a filter
([#2154](https://github.com/mvschwarz/openrig/pull/2154),
[#2155](https://github.com/mvschwarz/openrig/pull/2155)); the harness preset
guide says to check that a harness answers on its own first
([#2116](https://github.com/mvschwarz/openrig/pull/2116)); seat environments
are exercised on Linux in CI
([#2101](https://github.com/mvschwarz/openrig/pull/2101)); and portability and
test fixes ([#2147](https://github.com/mvschwarz/openrig/pull/2147),
[#2148](https://github.com/mvschwarz/openrig/pull/2148),
[#2149](https://github.com/mvschwarz/openrig/pull/2149)).
### Behaviour that changed
- A kernel that was already down before daemon start is no longer restored on
that start; see "The kernel at daemon start".
- `rig daemon start --wait-for-kernel` for a kernel skipped on purpose prints
the reason and the way back instead of a timeout header and an
authentication fix. It still exits 1.
- Work waiting for a member nothing can wake is reported to its person, not
to that member.
- A caller granted through a grant service can't address its own or another
caller seat, or set OpenRig's Slack provenance or a workflow's internal
sign-off tags.
- A park on a registered person's address resolves only from a reply written
after its question.
### Known issues
- **A webhook ring proves only that the endpoint accepted it.** Whether the
member woke and took the work is its claim on the row. An ordinary chat app
can't receive unsolicited events, so a hosted assistant without a
webhook-capable runner sees work only when it checks.
([#2115](https://github.com/mvschwarz/openrig/pull/2115))
- **A recorded principal is attribution, not authentication.** The daemon
records what the MCP adapter tells it about the caller it checked.
([#2098](https://github.com/mvschwarz/openrig/pull/2098))
- **The task view (`qitem:<id>`) is membership only.** It doesn't follow a
task as it moves, keep a reusable viewer, or change who may type into a
seat. ([#2131](https://github.com/mvschwarz/openrig/pull/2131))
- **Resuming a reviewed history has no CLI yet.** `rig seat history` lists
and checks; applying a choice is through the daemon's startup review, for
clients. Choices are limited to this node's eligible Claude and Codex
histories in the current compatible snapshot.
([#2100](https://github.com/mvschwarz/openrig/pull/2100),
[#2109](https://github.com/mvschwarz/openrig/pull/2109))
- **The kernel's restore evidence lasts one daemon process.** If the first
daemon after a reboot exits before the kernel's restore starts a seat, the
next start finds it already down and leaves it down, saying so. Bring it
back with `rig up kernel --existing`. (0.7.3's issue of a long-gone kernel
returning on daemon start is fixed by
[#2146](https://github.com/mvschwarz/openrig/pull/2146).)
- **OpenClaw's interrupted local turns are still not recovered.** The seat
shows as needing attention, and a send submits a new turn: inspect the pane
with `rig capture` first. For an unattended seat, use the Gateway preset.
([#2092](https://github.com/mvschwarz/openrig/pull/2092))
- **Whether processes a community harness starts outside its pane survive is
still not exercised**, and `rig down` stops only the pane.
([#2088](https://github.com/mvschwarz/openrig/pull/2088),
[#2077](https://github.com/mvschwarz/openrig/pull/2077))
- **Hermes:** as in 0.7.3, the example preset doesn't declare `resume`, and
whether a seat's Hermes gateway exits once idle is unknown, although
`docs/reference/community-harness-presets.md` says it exits. Check with `ps`
before relying on it.
- **A Codex seat's own permission settings** were enforced natively in 0.7.3's
test with Codex 0.164.0; other Codex versions weren't tested.
([#2048](https://github.com/mvschwarz/openrig/pull/2048))
- **Upgrading doesn't restore Codex hook approvals that an earlier OpenRig
removed.** If Codex asks for hook review, approve the hooks you mean to
enable. ([#2035](https://github.com/mvschwarz/openrig/pull/2035))
- **After the last managed session ends, an empty tmux server may stay
running.** That alone doesn't mean an agent is still running.
- **Slack threads from before 0.7.3** keep 0.7.3's limits: a reply on an ask
parked before the fix can still reach the person asked, and a message
received before 0.7.3 can still show OpenRig's routing footer, policy text
and attachment paths when an agent asks a person about it.
([#2119](https://github.com/mvschwarz/openrig/pull/2119),
[#2124](https://github.com/mvschwarz/openrig/pull/2124))
- **Shared channels are Slack only**, and the primary user's authority in the
TUI is attribution, not authentication.
([#1992](https://github.com/mvschwarz/openrig/pull/1992),
[#2006](https://github.com/mvschwarz/openrig/pull/2006))
- **The cmux path wasn't tried in a real cmux**, and a remote host's rigs are
read over HTTP only. ([#2080](https://github.com/mvschwarz/openrig/pull/2080),
[#2052](https://github.com/mvschwarz/openrig/pull/2052))
- **Window sizing:** a browser that closes while the daemon is down leaves the
window at the browser's size until the next web-terminal visit, and a
`--readable` capture isn't atomic with a person's own edits.
([#2065](https://github.com/mvschwarz/openrig/pull/2065),
[#2075](https://github.com/mvschwarz/openrig/pull/2075))
- **The runtime-switch handover change** hasn't been shown to cure the
unsent-handover case that prompted it.
([#2054](https://github.com/mvschwarz/openrig/pull/2054))
- The community harness field keeps its name (`communityHarness`) and its
original database update; renaming them would break a return to 0.7.2.
- Not in this release: verified task viewers
([#2133](https://github.com/mvschwarz/openrig/pull/2133)), switching a seat
to an isolated account home
([#2138](https://github.com/mvschwarz/openrig/issues/2138),
[#2143](https://github.com/mvschwarz/openrig/pull/2143)), source evidence for
resolving stuck findings
([#2153](https://github.com/mvschwarz/openrig/pull/2153)), the stale-pane
repair message ([#2157](https://github.com/mvschwarz/openrig/pull/2157)),
current launch evidence for fresh handovers
([#2159](https://github.com/mvschwarz/openrig/pull/2159)), and a warning for
out-of-range MCP daemon ports
([#2160](https://github.com/mvschwarz/openrig/pull/2160)). Hermes resume and
measured idle exit, and OpenClaw interrupted-turn recovery, aren't in this
release either. The limits listed for 0.7.3 still apply unless a change
above names them fixed.
### Credits
Thank you to everyone whose work is in this release: @Aschenbath (#1908,
#2101, #2113), @Nikhi00718 (#1863, #2016, #2106, #2107, #2108), @bradvin
(#2100, #2109), @dajiaohuang (#2123) and @seanmcn (#2147, #2148, #2149). And
thank you to @pr0xc3nt4ur1 (#1822), @duncanleung (#2130), @ziadeh (#2129),
@alperta (#99), @smota (discussion #2117) and @oSquashBlossomo, whose reports
and reviews led to fixes here.
## [0.7.3]
OpenRig 0.7.3 lets a team work with several people. You can register more than
one person and choose a default, add a Slack shared channel whose members reach
the team with each message labelled by who sent it, give organizations and
groups a policy the agents can read, and see in the TUI's Feed whose queue you
are looking at. Community harness authors get a written contract and tests, and
an OpenClaw seat whose last local turn was refused now shows as needing
attention. Codex seats can keep their own sandbox and approval settings, and
Codex's own trust and config edits survive OpenRig's next launch. It is built
from the commit tagged `v0.7.3`. The changes since 0.7.2:
### Before you upgrade
- **Restart the daemon after installing.** One database update applies on its
first start: it adds a column for a Codex seat's own permission settings.
```bash
npm install -g @openrig/cli@0.7.3
rig daemon stop && rig daemon start
```
- **Startup replaces OpenRig's own plugin and skill files** (openrig-core goes
from 0.1.19 to 0.1.20; the `messaging-the-human` skill changed). As in 0.7.2,
it first copies each file it replaces under
`$OPENRIG_HOME/backups/managed-refresh/` and logs where, edited or not. If you
did edit one, reapply your change from the copy. The shared `openrig-herdr`
skill that seats can load also changed.
- **If you register a second person, choose a default person** with
`rig gateway default <id>`. With several people registered and no default, the
last escalation step posts to nobody and the notice digest waits. An install
with one person is unaffected.
([#1992](https://github.com/mvschwarz/openrig/pull/1992))
- **If a seat relied on inheriting the daemon's own environment**
(`ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL` or `CLAUDE_CODE_USE_BEDROCK`, for
example), declare the variable in the rig's or member's `env:`. The first seat
on a new tmux server used to get the daemon's whole environment; it now gets
the same scoped environment as every other seat.
([#2047](https://github.com/mvschwarz/openrig/pull/2047))
- **If you might go back to 0.7.2, know what it ignores.** 0.7.2 loads every
registered person, but not the default person, shared channels,
organizations, groups or policy kept in
`$OPENRIG_HOME/gateway/participants.yaml`, and its guard against another
person's reply is weaker. It keeps a Codex seat's recorded permission settings
but doesn't read them, so its launches of that seat use the earlier posture,
which can be broader.
([#1992](https://github.com/mvschwarz/openrig/pull/1992),
[#2048](https://github.com/mvschwarz/openrig/pull/2048))
### What's new and fixed
**Several people, shared channels and a default person**
- More than one person can be registered: `rig gateway human add <id>
--display-name <name> --binding …` now works for a second and third person.
Choose the default with `rig gateway default <id>` (or `--default` on
`human add`; `--clear` removes it). The default, or the only registered
person, receives the last escalation step, the notice digest, Mission
Control's my-queue view, workflow fallbacks and pairing approvals. For a workflow's
human step, `workspace.operator_seat_name` is still read first. Everything new
lives in `$OPENRIG_HOME/gateway/participants.yaml`, and each person's existing
file keeps the keys an older daemon reads.
([#1992](https://github.com/mvschwarz/openrig/pull/1992))
- **Slack shared channels.** `rig gateway channel add <id> --display-name
<name> --binding slack:<connectorRef>:<secretsRef>:<channelId>` registers a
Slack channel as one participant, `<id>@external`. Anyone writing in that
channel reaches the team, whether or not they are a registered person and
even when nobody is registered. Each row ends with a block OpenRig adds after
the message: the channel, its organization and groups, who sent it (a
registered person, or a Slack user who isn't one) and the policy that applies
(cut at 6,000 characters; the full text is in `rig gateway participants
--json`). ([#1992](https://github.com/mvschwarz/openrig/pull/1992),
[#1998](https://github.com/mvschwarz/openrig/pull/1998))
- A row sent to a shared channel is posted as a notice, and `--reply-to`
answers in the sender's Slack thread. An ask to a channel is accepted with an
advisory that nothing there will answer it, and its questions show as a plain
list. A channel's messages never answer, close or resume an ask, and a channel
never acts on a row. `rig gateway channel remove <id>` refuses while open rows
are addressed to it, unless you pass `--force`.
([#1998](https://github.com/mvschwarz/openrig/pull/1998),
[#2068](https://github.com/mvschwarz/openrig/pull/2068))
- **Organizations, groups and readable policy.** `rig gateway org set <id>
--name <name> [--policy <text> | --policy-file <path>]` and `rig gateway group
set <id> --organization <id> --name <name> [--policy …]`. Policy on an
organization, group, person or channel is context for the agents' judgment:
`rig gateway participants [--organization <id>] [--json]` and `rig gateway
human show --json` list each line with where it comes from. Nothing enforces
it. ([#1992](https://github.com/mvschwarz/openrig/pull/1992))
- **Whose queue the Feed shows.** The TUI's `whose <person|all|default>` command
chooses it, and the Feed's first line says whose queue it is and who acts.
With a default person, the TUI opens on their Feed. Answering, approving or
resolving someone else's ask from the TUI records the primary user (the
default person, or the only one registered) as acting, and the note keeps the
person who was asked. This is attribution on a trusted network, not a login.
([#2006](https://github.com/mvschwarz/openrig/pull/2006))
- A park addressed to a registered person (`<id>@external`) can be resolved.
([#2061](https://github.com/mvschwarz/openrig/pull/2061))
- `rig slack post-open-asks` posts the asks that are still open, for example
after `rig slack enable`, which now points to it when there are some.
([#2086](https://github.com/mvschwarz/openrig/pull/2086), suggested by
@rakoort in [#1679](https://github.com/mvschwarz/openrig/issues/1679))
- The reply hint on a person's message now carries `--reply-to`, so an agent
following it answers in the person's Slack thread. This was a known issue in
0.7.2. ([#2055](https://github.com/mvschwarz/openrig/pull/2055))
**Community harnesses**
- `docs/reference/community-harness-presets.md` says what a community harness
gets from OpenRig and what it doesn't (no turn-cancel verb, no approvals
reaching OpenRig), how to write one in five steps, and how promotion is
judged. Tests now cover the coordination and resume contract.
([#2078](https://github.com/mvschwarz/openrig/pull/2078),
[#2088](https://github.com/mvschwarz/openrig/pull/2088))
- **OpenClaw:** when OpenClaw refuses its last local turn ("Session … changed
while starting work. Retry."), `rig ps`, the seat list and the TUI show the
seat as needing attention (`OPENCLAW_TURN_NOT_RECOVERED`), and a send to it
says it submits a new turn. OpenRig doesn't recover the refused turn (see
Known issues). ([#2092](https://github.com/mvschwarz/openrig/pull/2092))
- `rig down` names the community harness seats whose own processes may keep
running after their pane stops (`Pane stopped: …`, and `paneOnly` in
`--json`). ([#2077](https://github.com/mvschwarz/openrig/pull/2077))
- **Hermes:** the example's docs now say a Hermes gateway runs per Hermes home,
so one per seat with the example launcher. A two-seat run showed separate
gateways, homes and sockets, and stopping one seat left the other running.
([#2097](https://github.com/mvschwarz/openrig/pull/2097))
**Codex seats**
- A Codex seat can keep its own sandbox and approval settings: `rig seat
set-permissions <seat> --codex-permissions <file>` (`sandbox`, `approval`, and
for `workspace-write` also `network_access`, `writable_roots`,
`exclude_tmpdir_env_var` and `exclude_slash_tmp`), or `codex_permissions` in
the spec. `--codex-profile <name|inherit>` sets an existing seat's Codex
profile. They're passed as explicit flags on every managed launch, resume,
restore and handover; seats without either launch as before.
([#2048](https://github.com/mvschwarz/openrig/pull/2048))
- A launch finds the trust tables Codex saves with single-quoted keys, instead
of adding a duplicate that stopped every Codex session on the machine from
starting. ([#2034](https://github.com/mvschwarz/openrig/pull/2034), reported
by @borfast in [#2029](https://github.com/mvschwarz/openrig/issues/2029))
- After "Trust all" in Codex, the trust survives OpenRig's next launch, and
OpenRig no longer adds its hook groups again when Codex has moved them.
([#2035](https://github.com/mvschwarz/openrig/pull/2035))
- OpenRig finds its own Codex config tables wherever Codex moved them, keeps a
table of yours that Codex printed between its markers, and refuses a write
that would change anything beyond its own tables.
([#2046](https://github.com/mvschwarz/openrig/pull/2046))
- A Codex resume command now puts the conversation id right after `resume`, so
a directory with a space in it can't be read as the id.
([#2038](https://github.com/mvschwarz/openrig/pull/2038))
- A verified Codex seat started under a shell or launcher gets its generation
record; a real conversation mismatch is refused with its own message.
([#2084](https://github.com/mvschwarz/openrig/pull/2084) by @Aschenbath,
diagnosed by @fsafey in [#1799](https://github.com/mvschwarz/openrig/issues/1799);
[#2094](https://github.com/mvschwarz/openrig/pull/2094))
**Terminals, capture and the TUI**
- `rig capture --readable` (120x40) or `--size <WxH>` reads a small seat at a
readable size and then gives its window back. `--json` adds `readable`.
([#2075](https://github.com/mvschwarz/openrig/pull/2075))
- After someone watches a seat in the web terminal, or follows capture's resize
hint, the window gets its own size and sizing mode back.
([#2065](https://github.com/mvschwarz/openrig/pull/2065))
- A TUI running inside cmux opens terminal views in cmux, and a
`cmux_unavailable` answer names cmux's automation setting.
([#2080](https://github.com/mvschwarz/openrig/pull/2080), reported and traced
by @R14014 in [#1069](https://github.com/mvschwarz/openrig/issues/1069))
- The TUI keeps a registered remote host in the topology tree on every tab, and
drilling in lists its rigs.
([#2052](https://github.com/mvschwarz/openrig/pull/2052), reported by
@AdamWhitehurst in [#2049](https://github.com/mvschwarz/openrig/issues/2049))
- `rig setup` and `rig doctor` check that tmux can capture a pane, on a
throwaway server. No tmux version is refused.
([#2081](https://github.com/mvschwarz/openrig/pull/2081), from
[#980](https://github.com/mvschwarz/openrig/issues/980) by @MichelMabinuola)
- New seats keep tmux's `TERM_PROGRAM` and `TERM_PROGRAM_VERSION`, which Claude
Code uses for Shift+Enter and image paste in tmux, and the first seat on a new
tmux server gets the scoped environment too.
([#2043](https://github.com/mvschwarz/openrig/pull/2043),
[#2047](https://github.com/mvschwarz/openrig/pull/2047), reported by
@AdamWhitehurst in [#2041](https://github.com/mvschwarz/openrig/issues/2041))
**Sending and the queue**
- `rig send --wait-for-idle` longer than 300 seconds no longer fails at 300
seconds, and `rig skill loadout --apply --wait-for-idle` waits as long as asked.
([#2020](https://github.com/mvschwarz/openrig/pull/2020),
[#2024](https://github.com/mvschwarz/openrig/pull/2024))
- A local `rig send` whose connection drops says delivery is unconfirmed,
not that the message wasn't sent.
([#2060](https://github.com/mvschwarz/openrig/pull/2060))
- Claude's "Resume session" picker reads as waiting for a person, and a send
there refuses with `target_needs_input` instead of typing into its search box.
([#2040](https://github.com/mvschwarz/openrig/pull/2040))
- `rig workflow project` past an unanswered human gate says, in its advisories
and the transition note, that it doesn't record the person's consent, and the
`messaging-the-human` skill says a response isn't consent.
([#2093](https://github.com/mvschwarz/openrig/pull/2093), refs
[#1710](https://github.com/mvschwarz/openrig/issues/1710) by @george661)
- The delivery digest says why it sent nothing.
([#2063](https://github.com/mvschwarz/openrig/pull/2063) by @Coder8124, part
of [#800](https://github.com/mvschwarz/openrig/issues/800) by @d3fvxl;
[#2066](https://github.com/mvschwarz/openrig/pull/2066))
- `rig config set feed.subscriptions.<host>.enabled` accepts a host id with
dots. ([#2062](https://github.com/mvschwarz/openrig/pull/2062), reported by
@AdamWhitehurst in [#2057](https://github.com/mvschwarz/openrig/issues/2057))
**Seats, launch, export and workspaces**
- A seat's saved settings (permissions, policy, model, effort) can be targeted
by the node id a client showed; if that node is gone, a same-named
replacement isn't chosen in its place. `docs/reference/seat-node-targeting.md`
describes it. ([#2051](https://github.com/mvschwarz/openrig/pull/2051) by
@bradvin, the first part of
[#1837](https://github.com/mvschwarz/openrig/issues/1837))
- A runtime switch's handover to Claude Code waits for Claude's input box, and
sends Enter again while the handover is still in it.
([#2054](https://github.com/mvschwarz/openrig/pull/2054))
- `rig export` keeps each pod's startup files.
([#2087](https://github.com/mvschwarz/openrig/pull/2087) by @Aschenbath)
- Applying a pod, launching a member or retrying a first start resolves launch
profiles off the daemon's request thread.
([#2067](https://github.com/mvschwarz/openrig/pull/2067) by @Aschenbath, refs
[#1653](https://github.com/mvschwarz/openrig/issues/1653) by @VltrnOne)
- `rig scope --workspace <path> mission create` creates the first mission in
that workspace, and refuses a path that doesn't exist; `slice create` no
longer falls back to the global missions root.
([#1093](https://github.com/mvschwarz/openrig/pull/1093) by @Coder8124,
reported by @kainne44 in [#70](https://github.com/mvschwarz/openrig/issues/70))
- The execution view skips pickup history it doesn't use for blocked rows, and
large candidate reads let other daemon requests run in between. No overall
speed-up is claimed. ([#2042](https://github.com/mvschwarz/openrig/pull/2042),
[#2039](https://github.com/mvschwarz/openrig/pull/2039))
**Messages and docs**
- A Slack channel-map change says to restart the daemon, not to disable and
enable Slack, which would mark waiting alerts as already posted.
([#2072](https://github.com/mvschwarz/openrig/pull/2072))
- A queue verb run outside a managed seat says to set `OPENRIG_SESSION_NAME`,
instead of naming a flag it ignores.
([#2074](https://github.com/mvschwarz/openrig/pull/2074))
- New or corrected docs: what a seat is set to versus what it runs, and a
newcomer's reading order
([#2079](https://github.com/mvschwarz/openrig/pull/2079)); an exact resume
delivers no startup actions, and a managed Claude seat keeps its session name
([#2085](https://github.com/mvschwarz/openrig/pull/2085)); separate work goes
in a queue row, and `--wait-for-idle` sends nothing if the wait ends busy
([#2089](https://github.com/mvschwarz/openrig/pull/2089)); the A2A walkthrough
([#2082](https://github.com/mvschwarz/openrig/pull/2082)); one continuing
Slack conversation keeps its earlier context, whoever writes
([#2083](https://github.com/mvschwarz/openrig/pull/2083)); two cases where
Herdr reads a waiting seat as idle
([#2056](https://github.com/mvschwarz/openrig/pull/2056)); the account and
permission choices in the CLI reference
([#2073](https://github.com/mvschwarz/openrig/pull/2073)); and which scope
commands refuse a missing `--workspace` tree
([#2064](https://github.com/mvschwarz/openrig/pull/2064)).
- For contributors: mocked startup tests no longer wait in real time
([#2091](https://github.com/mvschwarz/openrig/pull/2091) by @sn1thy), the
first-run reference check works with Windows paths
([#2050](https://github.com/mvschwarz/openrig/pull/2050) by @DeryFerd), and
test cleanup waits for private tmux children
([#2070](https://github.com/mvschwarz/openrig/pull/2070)).
### Behaviour that changed
- A registered person can't settle an ask that was asked of someone else: it's
refused with `answer_not_recorded`. Using a terminal no longer overrides a
different registered person's identity; from the TUI, the primary user can.
([#2061](https://github.com/mvschwarz/openrig/pull/2061),
[#2006](https://github.com/mvschwarz/openrig/pull/2006))
- A shared channel is refused for every action on a row, not only for answers
and approvals, and an act on a channel's ask is recorded as the person at the
terminal. ([#2068](https://github.com/mvschwarz/openrig/pull/2068))
- With several people registered and no default, the last escalation step reads
`undeliverable:no-default-person`, and the notice digest waits until a default
is chosen. ([#1992](https://github.com/mvschwarz/openrig/pull/1992))
- The first seat on a new tmux server no longer inherits the daemon's whole
environment. ([#2047](https://github.com/mvschwarz/openrig/pull/2047))
- `rig scope --workspace <path> mission create` refuses a path that doesn't
exist instead of creating it.
([#1093](https://github.com/mvschwarz/openrig/pull/1093))
- A send to a Claude seat at its "Resume session" picker refuses
(`target_needs_input`), and startup there reports `attention_required` at
once instead of waiting out its timeout.
([#2040](https://github.com/mvschwarz/openrig/pull/2040))
- A Codex resume command reads `codex resume <id> --add-dir …`; older process
lines are still read. ([#2038](https://github.com/mvschwarz/openrig/pull/2038))
- `rig capture`'s small-pane hint points to `--readable`.
([#2075](https://github.com/mvschwarz/openrig/pull/2075))
- In the execution view, a blocked row no longer carries a last-activity time.
([#2042](https://github.com/mvschwarz/openrig/pull/2042))
### Known issues
- **OpenClaw's interrupted local turns are not recovered.** OpenRig shows the
seat as needing attention and a send submits a new turn, but it doesn't
repair or retry the refused one: inspect the pane with `rig capture` first.
Recognition covers OpenClaw 2026.10.6's local layout with its complete
two-rule editor; a cropped, changed or ambiguous layout isn't recognised, and
any other full-width rule in the captured lines leaves a real refusal reading
unknown. For an unattended seat, use the Gateway preset
(`openclaw-gateway.yaml`), within what that preset declares. This was checked
against a retained OpenClaw screen, not yet against a running OpenClaw.
([#2092](https://github.com/mvschwarz/openrig/pull/2092))
- **Whether processes a community harness starts outside its pane survive is
not exercised** by the contract tests. `rig down` stops only the pane.
([#2088](https://github.com/mvschwarz/openrig/pull/2088),
[#2077](https://github.com/mvschwarz/openrig/pull/2077))
- **Hermes:** the example preset doesn't declare `resume`, because reopening a
named session (and a missing one) hasn't been checked with a real Hermes. The
remaining real-Hermes checks weren't run, whether a seat's Hermes gateway
exits once idle is unknown, and the pane-only stop hasn't been checked again
on 0.7.3. ([#2077](https://github.com/mvschwarz/openrig/pull/2077),
[#2097](https://github.com/mvschwarz/openrig/pull/2097))
- **`docs/reference/community-harness-presets.md` says a seat's Hermes gateway
exits once idle** ("Stopping"). That hasn't been checked: whether it exits is
unknown. Check with `ps` that a stopped seat's gateway has gone before relying
on it.
- **Shared channels are Slack only.** A binding must be `slack:…`. If
`participants.yaml` can't be read, no shared channel is known: no channel
sender is admitted, and `rig slack post-open-asks` can miss asks to channels.
([#1992](https://github.com/mvschwarz/openrig/pull/1992),
[#1998](https://github.com/mvschwarz/openrig/pull/1998),
[#2086](https://github.com/mvschwarz/openrig/pull/2086))
- **The primary user's authority is attribution, not authentication.** Only a
current TUI sends it (a request without a seat identity); an older TUI's
answer on someone else's ask is refused.
([#2006](https://github.com/mvschwarz/openrig/pull/2006))
- **A Codex seat's permission settings are passed as launch flags; that Codex
enforces them was not proven natively.** Going back to an older OpenRig keeps
them unread (see Before you upgrade). The Codex trust fixes stop new duplicate
hook groups but don't remove existing ones.
([#2048](https://github.com/mvschwarz/openrig/pull/2048),
[#2035](https://github.com/mvschwarz/openrig/pull/2035))
- **The cmux path wasn't tried in a real cmux**, and a remote host's rigs are
read over HTTP only: an SSH host stays listed, but drilling in can't read it.
([#2080](https://github.com/mvschwarz/openrig/pull/2080),
[#2052](https://github.com/mvschwarz/openrig/pull/2052))
- **`rig` or `rig tui` started in a terminal that reports 0 rows or columns
crashes on its first frame** (a detached, headless or just-resizing pane).
It's present since 0.7.2 and fixed on main by
[#2099](https://github.com/mvschwarz/openrig/pull/2099) for the next release.
Start the TUI in a terminal with a size.
- **Window sizing:** a browser that closes while the daemon is down leaves the
window at the browser's size until the next web-terminal visit, and a
`--readable` capture isn't atomic with a person's own edits to the window.
([#2065](https://github.com/mvschwarz/openrig/pull/2065),
[#2075](https://github.com/mvschwarz/openrig/pull/2075))
- **The runtime-switch handover change** hasn't been shown to cure the
unsent-handover case that prompted it.
([#2054](https://github.com/mvschwarz/openrig/pull/2054))
- The community harness field keeps its name (`communityHarness`) and its
original database update; renaming them would break a same-database return
to 0.7.2.
- Not in this release: per-instance tmux isolation
([#1260](https://github.com/mvschwarz/openrig/pull/1260)), the read-only
client submit retry ([#2090](https://github.com/mvschwarz/openrig/pull/2090)),
the prime-agent runtime
([#2069](https://github.com/mvschwarz/openrig/pull/2069)), hosted members on
a team ([#2095](https://github.com/mvschwarz/openrig/pull/2095)) and grants
from an external grant service for `rig mcp serve --http`
([#2096](https://github.com/mvschwarz/openrig/pull/2096)). The limits listed
for 0.7.2 still apply unless a change above names them fixed.
### Credits
Thank you to everyone whose work is in this release: @Aschenbath, @Coder8124,
@bradvin, @sn1thy and @DeryFerd.
Thanks also to @AdamWhitehurst, who reported
[#2041](https://github.com/mvschwarz/openrig/issues/2041) (addressed by
[#2043](https://github.com/mvschwarz/openrig/pull/2043) and
[#2047](https://github.com/mvschwarz/openrig/pull/2047)),
[#2049](https://github.com/mvschwarz/openrig/issues/2049) (addressed by
[#2052](https://github.com/mvschwarz/openrig/pull/2052)) and
[#2057](https://github.com/mvschwarz/openrig/issues/2057) (addressed by
[#2062](https://github.com/mvschwarz/openrig/pull/2062)); @borfast, who reported
[#2029](https://github.com/mvschwarz/openrig/issues/2029) (addressed by
[#2034](https://github.com/mvschwarz/openrig/pull/2034)) and the repeated trust
prompt in [#17](https://github.com/mvschwarz/openrig/issues/17), which @jimallen
opened (addressed further by
[#2035](https://github.com/mvschwarz/openrig/pull/2035)); @R14014, who reported
[#1069](https://github.com/mvschwarz/openrig/issues/1069) and traced it through
the code (partly addressed by
[#2080](https://github.com/mvschwarz/openrig/pull/2080)); @kainne44, who
reported [#70](https://github.com/mvschwarz/openrig/issues/70) (addressed by
[#1093](https://github.com/mvschwarz/openrig/pull/1093)); @VltrnOne, who
reported [#1653](https://github.com/mvschwarz/openrig/issues/1653) (worked on in
[#2067](https://github.com/mvschwarz/openrig/pull/2067)); @d3fvxl, who reported
[#800](https://github.com/mvschwarz/openrig/issues/800) (part addressed by
[#2063](https://github.com/mvschwarz/openrig/pull/2063)); @fsafey, who reported and diagnosed
[#1799](https://github.com/mvschwarz/openrig/issues/1799) (addressed by
[#2084](https://github.com/mvschwarz/openrig/pull/2084)); @MichelMabinuola, who
reported [#980](https://github.com/mvschwarz/openrig/issues/980) (addressed by
[#2081](https://github.com/mvschwarz/openrig/pull/2081)); @rakoort, whose
suggestion in [#1679](https://github.com/mvschwarz/openrig/issues/1679) became
[#2086](https://github.com/mvschwarz/openrig/pull/2086) (the issue's main
report is still open); @george661, whose
[#1710](https://github.com/mvschwarz/openrig/issues/1710) led to
[#2093](https://github.com/mvschwarz/openrig/pull/2093).
---
## [0.7.2] - 2026-10-25
Published as `@openrig/[email protected]`, built from the commit tagged `v0.7.2`
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.0` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
+4 -16
View File
@@ -6,23 +6,11 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.2
## Released in 0.7.4
[0.7.2 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.2).
See its release notes for what shipped, upgrade instructions, contributor credits and the remaining testing gaps.
Hermes and Cursor through the shared plug-in point were not included. The Codex counterpart to per-member Claude
permission rules was not included either; that work has since merged on main in
[#2048](https://github.com/mvschwarz/openrig/pull/2048). These follow-ups have no release promise here.
## Active: 0.7.3 — people and agent teams through one gateway
- **Participant foundation:** people and vetted shared channels, an explicit default, organization and groups, and readable policy.
- **Attributed shared-channel conversations:** reach a support seat with who spoke, the configured role and policy, and the thread;
send answers back to that thread.
- **Actions and perspective for the primary user:** see whose work needs attention and preserve who actually spoke or acted.
These are the active themes, not claims that the changes have shipped or a release-date promise.
[0.7.4 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.4): a first, experimental step toward every
assistant you use working on one team. See its release notes for what shipped, the experimental and security notes for
hosted members, upgrade instructions, contributor credits and the remaining testing gaps.
## How to help
@@ -281,6 +281,34 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
`no_conversation_found` (`:429–434`) and with `recovery:
"attention_required"` plus pane evidence on an `attention_required` probe
(`:444–450`) — the adapter never relaunches on its own.
- **Fresh in-place handover readiness.** `assessFreshInPlaceProbe` qualifies an
otherwise ready history probe only for the matching fresh launch, binding and
launch generation. The current pane must show a prompt, and `assessManagedProbe`
must confirm the new launch's exact session token in the same foreground process
across two observations, with the pane and launch still current. It reuses the
existing managed identity proof, including its process fingerprint. A process
label alone, old scrollback, an unavailable screen, an unreadable process list
or an unidentified Claude process cannot admit the handover. Those cases stay
`awaiting_runtime`; a positive identity conflict and visible approval or login
gates keep their specific results. The handover waits within its existing
readiness budget before sending the context packet.
The fresh path requires the last `❯` prompt to sit inside Claude's complete
input frame: its top rule directly above the prompt and a bottom rule below.
A shell's echoed launch command cannot borrow an earlier header or input
frame, even when the matching Claude process is already running. A header
and prompt alone, or a partially drawn frame, remain pending. This may delay
readiness until the full frame is visible; other launch paths keep their
existing screen predicates.
Previously, unavailable observations and ambiguous headerless prompts could
retain the old ready result. `claude-fresh-in-place-scope.test.ts` now checks
missing observations, unknown or changing process identity, retained UI above
direct and script launch commands, partial frames, recovery and normal prompt
controls. `claude-handover-readiness.test.ts` exercises the real handover
service with migrated SQLite, modeled pane/process observations and one synthetic
clock. Readiness and the later packet-submission receipt remain separate checks;
these tests do not run a live Claude account.
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:65`)
@@ -178,15 +178,58 @@ the destination's orchestrator when one is derivable, otherwise to the
destination itself, and that seat may be dead. Overdue claims go to their
destination. Repeated detections reuse the existing finding without changing
its route; when the source condition resolves, the sweep closes its own finding.
Previously, falling out of a reporting query could itself close a delivery or
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
an unreadable human registry or a larger reporting-age setting could therefore
look like successful recovery. These findings now require source evidence:
closure, pickup or parking of the work, a verified/accepted wake, or a posted
notification for the current human recipient and episode, as appropriate to the
finding. `delivered-ack-pending`, `indeterminate:…`, missing results and unrelated
legacy human receipts do not prove resolution. Uncertainty keeps an existing
finding open without itself sending another wake or creating another finding.
A verified wake resolves a wake failure, not an unclaimed obligation: delivery
does not prove pickup.
A legacy human notice with no owner-notification history can still resolve from
the existing item-scoped posted ledger when its recipient is confirmed human.
This covers older pending asks and parks without inventing an episode that was
never recorded. A registered alias needs a readable registry to establish its
human recipient; reserved human references remain known during an outage.
Legacy receipts carry no recipient key, so a destination reroute requires a new
matching notification episode. A legacy park is accepted only when no earlier
notice or reroute predates it, and later history contains only receipts or
asking-agent reroutes within that park. A re-park, an exit from the park or an
ambiguous append cannot establish the same recipient without owner history;
the finding waits for a new matching episode or other source resolution.
Rerouting only the asking agent within an otherwise unchanged park preserves
its human recipient (`blockedOn`).
Modern notifications still require their current episode, and an unrelated
legacy human receipt cannot resolve an agent wake.
A known hold still leaves recovery with the existing mechanism: an ask on
Steering without an alert, a wake held by the seat typing guard, or a live
quiet-release job. Its closure note names that disposition rather than claiming
delivery or pickup. An unreadable quiet hold is not positive closure evidence.
Claimed work in either `pending` or `in-progress` uses the same pickup derivation;
returning a row to `pending` while retaining its claim no longer hides a later
stall or closes its existing stalled-claim finding. A retained failed wake cannot
override that pickup evidence. The agent's claim still does not establish delivery
of a separate notification to a person.
That automatic closure records `stuck-sweep@system` as the actor in both the
transition and the `queue.updated` audit event. Previously it credited the
finding's source agent with the detector's action. The finding's source,
transition and the `queue.updated` audit event, with the source evidence in the
delivery and unclaimed-obligation closure notes. Previously it credited the finding's source agent
with the detector's action. The finding's source,
destination and claim remain its existing ownership records; the underlying work
is not reassigned, and existing transition history is not rewritten.
`queue-stuck-sweep.test.ts` checks this with migrated SQLite: pending and claimed
findings close once, their transition and event name the sweep, and ownership
stays intact. The posted-human-notice case also checks the actor while leaving
`queue-stuck-sweep-resolution.test.ts` checks unconfirmed retries, retained
claims, current human-notification episodes and registry outages with migrated
SQLite, including positive recovery controls and no repeated send on an
unresolved retry. `quiet-hold-custody.test.ts` checks existing findings with
readable and unreadable quiet custody. `queue-stuck-sweep.test.ts` checks that
pending and claimed findings close once, name the sweep in their transition and
event, and keep their ownership. The posted-human-notice case also checks the actor while leaving
the person's original ask open. These checks exercise the detector and queue
repository, not a live agent or connector.
@@ -73,10 +73,26 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
first three; `not_attempted` is set when pre-restore validation fails
(`:281`).
- **SnapshotData** (`types.ts:351`) — the serialized snapshot payload. It has
**7** optional fields, kept optional so older snapshots still load
**8** optional fields, kept optional so older snapshots still load
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `topologyRoster?`,
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `periodicResumeSourcesByNode?`,
`topologyRoster?`, `pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
Periodic snapshots retain a down agent's whole authoritative resume-source
session in `periodicResumeSourcesByNode`, without marking it running. A named
single-agent launch using automatic periodic selection can resume that exact
carried source; an explicit snapshot or whole-rig restore still asks about an
absent agent with resumable history. Tokenless and non-resume-policy agents
keep their existing fresh path. Only the node's newest session with a token
and `resume_if_possible` policy is carried; a current fresh-successor relation
must agree. Named launch rechecks the live newest session, resume policy and
fresh-successor relation under its lifecycle lease, before changing any seat
state; a source retired after capture stops and asks without launching.
Idle-shell carry advice uses automatic selection again, not an
explicit snapshot that disables carry. The selected source is shared by preflight and post-launch
restore; explicit reviewed-history selection keeps its existing checks.
Ranking, periodic cadence and `--fresh` are unchanged. Retention is best-effort
if the earlier-source lookup fails: capture still records current crash
insurance and logs that the resume sources were unavailable.
- **NodeStartupSnapshot** (`types.ts:344`) — persisted restore replay input:
classification-free projection entries, resolved startup files, startup
actions, runtime.
@@ -389,6 +405,20 @@ daemon's `/api/transcripts/<session>/full`, `:156`), `read <packet-dir> [--json]
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
### Shared history inspection
`GET /api/startup/:rigId/nodes/:nodeId/history` lists captured histories for one durable node in the current, policy-ranked restore snapshot. `POST` to the same path plus `/validate` with exactly `{ "snapshotId": "…", "historySessionId": "…" }` rechecks one listed choice. Both routes use startup's terminal bearer authentication. The CLI exposes the same server decisions with `rig seat history --rig <rig-id> --node-id <node-id> --json`; pass both `--snapshot-id` and `--history-session-id` to validate. These calls perform no snapshot capture, token refresh, launch, or database write.
The list includes captured Claude and Codex rows that the current restore policy and native resume adapter can structurally attempt. Codex requires an explicit `codex_id` and token; implicit `codex_last` cannot name a particular history. Claude accepts `claude_id` and `claude_name` with tokens. The server checks exact rig and node membership, the snapshot's intended roster and runtime, and the node's current occupant compatibility. A change to a sibling node does not retarget or invalidate this node's list. A changed or pruned snapshot, removed history row, same-name replacement node, ambiguous current occupant, or incompatible source is refused; validation never substitutes another history.
`tokenState` (`present`, `stale`, or `unverified` for listed rows) describes the saved verification record and its age. Eligibility means a future launch may **attempt** native resume. A stored row can share a native token with another row, and neither listing nor validation proves that a native conversation still exists.
### Selected history startup
`GET /api/startup/:rigId/nodes/:nodeId?snapshotId=<listed-id>&historySessionId=<listed-id>` returns a read-only review for one listed choice: the selected row's forecast, current terminal observation, consumed launch setting sources (including a member permission envelope or Claude rules), and an opaque revision. The permission view uses restore's pure policy resolution: a readable custom policy's current launch posture can supersede its persisted cache, and a changed resolved decision requires re-review. It refuses a captured row whose native type/token, provenance, restore policy, or harness no longer agrees with the live registry row. The response contains no native token. `POST /api/startup/:rigId/nodes/:nodeId` accepts exactly `{ "action": "resume", "snapshotId": "…", "historySessionId": "…", "revision": "…" }`. Both routes require startup's terminal bearer token. The selected route accepts no fresh, continue, arbitrary token, or alternate target. Review does not capture a snapshot or prepare a provider. `rig seat history` also uses a strictly read-only daemon status check, so inspection does not remove stale daemon state.
Apply revalidates the same current compatible source, durable node and saved launch settings after prerequisite and presence checks. The existing narrow restore engine carries that exact historical row through legacy and pod-aware native resume, with a second identity check at native dispatch after preparation. Before the new registry row is committed, selected startup also checks that no other attachment or running occupant claimed the node during terminal creation or pane observation; a collision leaves that claimant intact and reports the terminal outcome for inspection. An occupied or uncertain terminal blocks the attempt. No alternate history or fresh conversation is selected. The response projects exact typed references and outcome fields; only diagnostic text is redacted, so even a short native name cannot corrupt an ID. It names the selected source row and the newly registered result row when one exists; only the engine's joined native continuity proof produces success. If a change occurs after shell creation, the result reports a partial or uncertain attempt for inspection. The existing engine handles native prerequisites, compensation and proof. This surface adds no separate per-agent prelaunch probe; native checks remain at execution. Physical tmux cleanup still addresses sessions by name, so it cannot prove ownership against an external same-name replacement outside the selected registry guard.
## See also
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
+19 -1
View File
@@ -354,6 +354,23 @@ the sections that were not read are `null`.
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
`laptop`, run this on the local instance to include its attention items:
```sh
rig config set feed.subscriptions.laptop.enabled true
```
The key uses the registry id, including any dots, and defaults to `false`. Registration
does not change that setting. SSH entries instead explain that FEED needs an HTTP entry;
ids reserved by feed settings (such as `progress`) get a different-id suggestion rather
than an invalid command. `--json` keeps its single JSON result without these hints.
The host CLI tests execute the advertised command against an isolated config and cover
registration, reserved names and JSON output; pairing responses are simulated, so they
do not establish connectivity to a live peer.
### ps
| Invocation | Aliases | Declared options |
@@ -376,7 +393,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig mcp` | — | — |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>` |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
| `rig mcp token` | — | — |
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
| `rig mcp token list` | — | `--json` |
@@ -956,6 +973,7 @@ for input and receipt examples, rollback, and snapshot behavior.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig seat` | — | — |
| `rig seat history` | — | `--rig <id>` **required**<br>`--node-id <id>` **required**<br>`--snapshot-id <id>`<br>`--history-session-id <id>`<br>`--json` |
| `rig seat set-typing-guard <seat>` | — | `--enabled <boolean>`<br>`--mode <mode>`<br>`--reason <text>` **required**<br>`--json` |
| `rig seat held-messages <seat>` | — | `--limit <n>`<br>`--offset <n>`<br>`--id <id>`<br>`--json` |
| `rig seat retire-held-message <seat> <id>` | — | `--reason <text>` **required**<br>`--json` |
+34 -14
View File
@@ -88,6 +88,11 @@ are not yet runnable checks.
credentials, and outbound network blocked except to localhost (see below). A test that reads
your real home directory, a running daemon, the network or a credential can pass on your
machine and fail in CI.
- **Mixed fixture clocks.** `compaction-command-submission.test.ts` uses one clock for the
enforcer, the transport's injected `now` and sleeps, and its `Date.now` idle deadline. Previously,
real-time transport waits could exhaust the fixture's 20 ms budget during a scheduler pause
even though the modeled seat was idle. Restore the `Date.now` spy after each test; keep real
deadline behavior covered by the transport tests. This changes the fixture, not production timeouts.
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
exception.
@@ -104,7 +109,17 @@ credentials are supplied.
| `typecheck` | ubuntu | `npm run lint` |
| `repo-checks` | ubuntu | `npm run test:repo` |
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
| `linux-seat-environment` | ubuntu | Builds all packages, then runs `seat-env-parent-session.integration.test.ts` with real CLI, daemon, tmux and stub seats. A network namespace has loopback only; tests run as the unprivileged runner user under `env -i` with scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR`. Runs on pull requests and manual dispatch, like `package-tests`. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (fifteen container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
The Linux seat-environment job covers a gap in the macOS matrix: the integration test
reads `/proc/<pid>/environ`, so macOS skips it. It checks both a new tmux server and an
existing server carrying a parent's session identity. It verifies the scoped environment,
the runtime directory and disabled session-bus default, and tmux's terminal identity in
the launched panes; the existing owner session stays unchanged. This adds one focused
Linux job to the existing suite. Stub seats prove the environment passed to the pane;
native Claude image paste and Shift+Enter handling still need separate verification
(see [#2041](https://github.com/mvschwarz/openrig/issues/2041)).
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
lists lines your PR adds that contain machine-specific values: credentials, home or temp
@@ -143,7 +158,7 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
| Piece | Where |
|---|---|
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
@@ -175,10 +190,12 @@ node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
each run three times as healthy, then lost-baton, then healthy. Then the two stub-script
cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`) run once each, healthy only. That is eight fresh
containers.
each run three times as healthy, then lost-baton, then healthy (six containers); the
`refocus-parked` effect case runs healthy, seeded, then healthy (three containers); and the
six passing-only cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`, `daemon-restart`, `refocus-active`,
`refocus-parked-off`, `prompt-state`) run once each, healthy only (six containers). That is
fifteen fresh containers.
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
@@ -188,10 +205,13 @@ DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
--remote --case library --mode healthy --out dist/scenario-check
```
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
always run once, healthy. `--mode lost-baton` leaves them out of a full run, and
- Options: `--case fixture|library|transcript|capture|daemon-restart|refocus-parked|
refocus-active|refocus-parked-off|prompt-state` (default: all nine), `--mode
healthy|lost-baton|seeded` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to the baton cases (`fixture` and `library`) and to
the `refocus-parked` effect case; the six passing-only cases (`transcript`, `capture`,
`daemon-restart`, `refocus-active`, `refocus-parked-off`, `prompt-state`) always run once
each, healthy. `--mode lost-baton` leaves them out of a full run, and
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
directory for each run you want to keep.
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
@@ -258,7 +278,7 @@ means:
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
@@ -299,9 +319,9 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
In most of these files, `seed_regression` steps that come *after* the assertions are
+9
View File
@@ -244,6 +244,15 @@ interactive"), and the post-launch files aren't sent. A Codex fork that the rig
`session_source.mode: fork`, or an agent image's fork) is the exception: its timeout ends as `attention_required`,
with its startup context kept for `rig seat continue` (see below). A seat handover's fork has its own readiness check.
**Fresh Claude handovers.** `rig handover --source fresh` keeps the previous agent's scrollback. Before sending the
handover context, OpenRig waits for the latest prompt inside Claude's complete input frame, and confirms that the
process in the pane belongs to the new launch. A shell's `❯` prompt or echoed launch command is not enough, even
with an old Claude header or input frame above it and Claude already running. A header alone or a partially drawn
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
unavailable; inspect its pane before deciding how to recover.
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
same prompt still sitting in the input, it presses Enter once more; if it's still there, it warns "Startup prompt still
+28 -1
View File
@@ -41,6 +41,13 @@ state. Attention/alarm values likewise stay in their own machinery (`attentionCo
Both herdr and omnigent converged on this exclusion, with warning comments on the
collision in both codebases.
The legacy `agentActivity` field distinguishes a typed provider interruption from
a request for a person: fresh Codex `at_limit` and Claude `StopFailure/rate_limit`
events carry `state: unknown` and `reason: usage_limit`, never a `needs_input`
hook. A permission prompt can independently supply human-input evidence. See
[Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection)
for the separate provider signal and queue timer.
### Per-seat attention in the node inventory
`rig ps --nodes` carries the daemon's `needsAttention` result, using the same predicate
@@ -95,7 +102,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
means fall down the ladder, never error.
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
hour. Exactly-once turn boundaries; SubagentStop filtered (it
@@ -132,6 +139,26 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
runner) keeps the hold-until-the-next-boundary behaviour.
Claude permission notifications raise needs-input immediately. A visible permission can clear
after it has been observed and then disappears. A notification whose dialog was never observed
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
so its earlier permission requests are no longer pending. The relay carries the native actor
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
because another tool in the batch may still be waiting for permission.
Batch completion must come from the current occupant and have a later source timestamp than
the matching notification. Missing actor attribution from an older relay, equal or older
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
A notification with an equal or later timestamp remains eligible; swapping or retiring the
occupant discards its remembered completions.
Completing a batch changes the needs-input signal without inferring that the seat is idle or
overwriting a later elicitation. The hook contract is documented in
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
trust, unless its inventory declares it positive-only. A positive-only source can report
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
+42
View File
@@ -67,3 +67,45 @@ directory: its timestamp should advance and its `session_id` should match the
current Claude session. A visible custom status line alone does not verify
collection or end-to-end adoption/handover recovery. The wrapper above suppresses
collector errors; remove its stderr redirection temporarily when diagnosing it.
## Claude usage-limit detection
The status line supplies subscription usage windows and their reset timestamps.
OpenRig also registers Claude's [`StopFailure` hook](https://code.claude.com/docs/en/hooks#stopfailure),
available since Claude Code 2.1.78. Its exact `error: "rate_limit"` value reports a
provider interruption even when no status line window is available. This path
requires the updated OpenRig hook configuration and the current seat's runtime
and occupant generation. Unknown identity or an expired event cannot trigger a
usage-limit pause; a hook with a newer source timestamp supersedes the event,
regardless of arrival order. The event expires after five minutes in any case.
A helper Claude started from a seat's shell inherits that seat's environment,
so its rate-limit hook is attributed to that seat too. This is appropriate when
the helper uses the same login, since the quota belongs to the account. A
helper on a different login is attributed the same way, so its rate limit can
hold pending handoffs to every seat in that seat's Claude pool for one retry
interval, even when the seat's own login still has room. This attribution does
not bind the hook to Claude's own native session.
The event carries no usage percentage, account identity or reset timestamp.
It marks the Claude provider pool as limited and preserves a current
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
count as a request for human input; a newer permission prompt still takes
precedence in the seat's activity display. Conflicting seat windows retain the
existing `explicit_unknown` host diagnosis.
Pending work handled by the wake ladder uses the existing local Claude pool
(the one-account-per-host assumption), with separate pools for seats that
declare different Claude config directories. If reset windows conflict within
a proposed shared pool, only the seats reporting exhaustion are held; another
seat's pending handoffs can still retry. An exhausted status line window with
a known reset supplies the timer. Otherwise a typed failure uses
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
existing wake jitter, and records `config-fallback`. That is a retry time, not a
claim that the provider's quota will reset then. The timer releases the shared
blocker once and retains the existing durable wake delivery behavior.
Authentication, billing and overloaded errors are not classified as usage
limits. Claude limit banners are not scraped, and older runtimes or failures
that do not emit this hook still depend on status line data. Without either
source, provider usage remains unknown.
@@ -1,7 +1,8 @@
# Merge this into each Hermes seat's config.yaml, in that seat's Hermes home ($OPENRIG_HOME/state/hermes/<node id> with
# the example launcher), before the seat's first start.
#
# Hermes runs every tool command in one gateway per machine, with the gateway's environment. For each session it swaps
# Hermes runs every tool command in its gateway (one per Hermes home, so one per seat with the example launcher), with
# the gateway's environment. For each session it swaps
# in the client's own values only for its toolchain names and the names listed here. Without this list, `rig` inside
# Hermes runs as whichever seat started the gateway, or as no seat. With it, each seat runs `rig` as itself.
# These are the seat's own OpenRig variables; none is a secret, and Hermes refuses credential-like names anyway.
@@ -5,7 +5,7 @@ command: openrig-hermes-launch # no model, provider or --max-turns here: they
maintainer: Your name or team
limits:
- resume not declared until reopening a named session, and a missing one, have been checked with a real Hermes
- closing a seat's pane detaches it; Hermes finishes any accepted turn, and its gateway exits when idle
- closing a seat's pane detaches it; Hermes finishes any accepted turn; whether its gateway exits when idle is unchecked
- creation settings (model, provider, max turns) come from the seat's Hermes config.yaml, not from flags
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
@@ -0,0 +1,100 @@
#!/bin/sh
# A reference launcher for Pi's own terminal UI (interactive `pi`) as an OpenRig community harness. Copy it, own it, and
# change it: OpenRig doesn't ship or run it. It's written for Pi 1.4.0 from its source and not yet checked with a real Pi.
# It follows docs/reference/community-harness-presets.md (`names_conversation`, `first_input`):
# - a new launch (OPENRIG_CONVERSATION set): Pi creates the session whose id is that name, in the seat's own session
# folder, and takes the first input as its first prompt;
# - a resume (OPENRIG_RESUME_REF set): the launcher opens the one session file whose id is that name, and starts nothing
# when there isn't exactly one. Pi's --session-id creates a new session for an id it doesn't find, and --session with
# an id can open another session whose id starts the same way, so neither is used to resume.
set -eu
pi="${OPENRIG_PI:-pi}"
state="${OPENRIG_HOME:-$HOME/.openrig}/state/pi-tui/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
sessions="$state/sessions"
# Each seat gets its own Pi agent folder (settings, logins, skills) unless PI_CODING_AGENT_DIR is already set, so seats
# don't share settings and a person's ~/.pi/agent isn't touched. Give the seat its provider key in its environment, or its
# login and settings in this folder, before its first start.
PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$state/agent}"
# Pi expands a leading ~ in this folder; do the same, so the skills land where Pi looks.
case "$PI_CODING_AGENT_DIR" in
"~") PI_CODING_AGENT_DIR="$HOME" ;;
"~/"*) PI_CODING_AGENT_DIR="$HOME/${PI_CODING_AGENT_DIR#"~/"}" ;;
esac
export PI_CODING_AGENT_DIR
mkdir -p "$PI_CODING_AGENT_DIR/skills" "$sessions"
# Copy the installed OpenRig's coordination skills in at each launch, so an upgrade reaches the seat. Pi lists the skills
# in <agent folder>/skills/ by itself; the seat's first input should still name queue-handoff (see the recipe).
skills_from="${OPENRIG_PI_SKILLS_FROM:-${OPENRIG_HOME:-$HOME/.openrig}/plugins/openrig-core/skills}"
set -f
for skill in ${OPENRIG_PI_SKILLS:-queue-handoff}; do
case "$skill" in
*[!a-z0-9-]*|-*) echo "openrig-pi-tui-launch: skipping '$skill': not a skill name" >&2; continue ;;
esac
if [ -d "$skills_from/$skill" ]; then
rm -rf "$PI_CODING_AGENT_DIR/skills/$skill"
cp -Rp "$skills_from/$skill" "$PI_CODING_AGENT_DIR/skills/"
else
echo "openrig-pi-tui-launch: no $skill skill in $skills_from; starting without it" >&2
fi
done
set +f
# Pi's session ids: letters, digits, '.', '_' and '-', starting and ending with a letter or digit.
valid_id() {
case "$1" in
"" | *[!A-Za-z0-9._-]* | [!A-Za-z0-9]* | *[!A-Za-z0-9]) return 1 ;;
esac
}
# The first input goes over as Pi's first prompt, byte for byte, after `--`. OpenRig's starts with its identity-context
# line; Pi would read one starting with @ as a file even after `--`, and may run one starting with / as a command.
set --
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input="${first_input%.}"
if [ -n "$first_input" ]; then set -- -- "$first_input"; fi
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
id="$OPENRIG_RESUME_REF"
if ! valid_id "$id"; then
echo "openrig-pi-tui-launch: '$id' isn't a Pi session id; starting nothing" >&2
exit 2
fi
# Pi names a session file <time>_<id>.jsonl, but the file's first line, its header, is what says which session it is:
# its top-level type and id, read as JSON as Pi reads it. Pi writes the file only once the conversation has a message, so
# a session that never got one can't be reopened. Pi runs on Node, so node reads the header.
node="${OPENRIG_PI_NODE:-node}"
if ! command -v "$node" >/dev/null 2>&1; then
echo "openrig-pi-tui-launch: $node isn't on PATH to read Pi's session headers; starting nothing" >&2
exit 1
fi
found="" count=0
for file in "$sessions"/*_"$id".jsonl; do
[ -s "$file" ] || continue
if head -n 1 "$file" | "$node" -e '
let text = ""; process.stdin.on("data", c => { text += c; }).on("end", () => {
try { const h = JSON.parse(text); process.exit(h && h.type === "session" && h.id === process.argv[1] ? 0 : 1); }
catch { process.exit(1); }
});' "$id"; then
found="$file" count=$((count + 1))
fi
done
if [ "$count" -ne 1 ]; then
echo "openrig-pi-tui-launch: expected one Pi session $id in $sessions, found $count; starting nothing" >&2
exit 1
fi
exec "$pi" --session-dir "$sessions" --session "$found" "$@"
fi
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
if ! valid_id "$OPENRIG_CONVERSATION"; then
echo "openrig-pi-tui-launch: '$OPENRIG_CONVERSATION' isn't a Pi session id; starting nothing" >&2
exit 2
fi
exec "$pi" --session-dir "$sessions" --session-id "$OPENRIG_CONVERSATION" "$@"
fi
echo "openrig-pi-tui-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -0,0 +1,15 @@
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
format: 1
name: pi-tui
command: openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
maintainer: Your name or team
limits:
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
- how Pi takes a wake that arrives while it's busy isn't shown
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
names_conversation: true
first_input: true
+93 -4
View File
@@ -24,6 +24,13 @@ it never reads an unsupported capability as idle, working, ready or verified.
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
configuration.
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
the harness's default one: the example launchers keep one per seat under `$OPENRIG_HOME/state/<harness>/` (`HERMES_HOME`,
`OPENCLAW_STATE_DIR` or `CURSOR_CONFIG_DIR`), so a check with your own defaults can pass while the seat's setup fails. If no answer comes
back, the problem is in the harness's own setup, such as its model, provider or login, and relaunching an OpenRig seat won't fix it. Fix
it in the harness, then connect it.
## The preset
One YAML file per harness: `$OPENRIG_HOME/community-harnesses/<name>.yaml`. `rig community-harness list` shows what's
@@ -148,7 +155,9 @@ Agent's CLI.
flags and then reopens it, refusing any creation flag (model, provider, `--max-turns`) that differs. So the preset's
command carries none, and each seat's `config.yaml`, in its Hermes home, sets them before the seat's first start:
the model and provider, and `agent.max_turns` if you want a cap (nothing else sets it once the flag is gone).
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in one gateway per machine, with that gateway's
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in its gateway, one per Hermes home, so with
this launcher one per seat (a two-seat run showed separate gateways, homes and sockets, and stopping one seat left
the other's running). It runs them with that gateway's
environment, and swaps in a session's own values only for its toolchain names and the names its profile lists in
`terminal.client_env_passthrough`. So put the seat's OpenRig variables in that list, in each seat's `config.yaml`
([`config.yaml.example`](community-harness-examples/hermes/config.yaml.example)). Without it, `rig` inside Hermes
@@ -159,7 +168,8 @@ Agent's CLI.
- **The preset:** it declares `names_conversation` but not `resume`, until reopening a named session, and a missing one,
have been checked with a real Hermes.
- **Stopping:** Hermes's CLI is a client of a gateway, so closing a seat's pane detaches it. Hermes finishes any turn it
accepted, and the seat's gateway exits once idle. The preset says so in its limits.
accepted. Whether the seat's gateway then exits once idle hasn't been checked: check with `ps` that a stopped seat's
gateway has gone before relying on it. The preset says so in its limits.
Copy them and maintain them as your own.
@@ -180,7 +190,8 @@ OpenClaw's CLI.
Copy the launcher and the preset for your mode, and maintain them as your own.
**Stopping a community seat stops its pane.** `rig down` names those seats ("Pane stopped: …"), since a harness's own
processes, a gateway say, may keep running after it. OpenRig doesn't stop them; each preset's limits say what to expect.
processes, a gateway say, may keep running after it. OpenRig doesn't stop them, and whether they survive isn't exercised
by OpenRig's tests, so check the harness's own processes after `rig down`. Each preset's limits say what to expect.
**Not supported for community harnesses:** restarting a seat in place (`rig seat restart` is for Codex seats) and
switching harness on a running seat. Each says so before anything changes.
@@ -203,6 +214,33 @@ CLI.
Copy both and maintain them as your own.
## An example: Pi's terminal UI
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
on it, not Pi choosing by itself to park or hand work on.
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
gave>` and passes the first input as Pi's first prompt, byte for byte.
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
it), and other Pi versions.
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
agent folder.
Copy both and maintain them as your own.
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted` or `first_input` is refused by an older
OpenRig that doesn't know the field.
@@ -217,9 +255,60 @@ OpenRig records `node.startup_first_input_passed` with the path and `submission:
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
## Interactive Pi in a terminal seat: the coordination skills
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
seats in its launcher.
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
source. The seat's setup does two things.
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
```bash
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
mkdir -p "$PI_AGENT_DIR/skills"
cp -Rp "$CORE/skills/queue-handoff" "$PI_AGENT_DIR/skills/"
```
Copy again after each OpenRig upgrade: a copy doesn't follow the plugin.
2. **Point the agent at it when it starts.** Don't count on Pi finding the skill by itself; name it in the seat's first turn.
For example:
> Before you work your queue, read `<Pi agent directory>/skills/queue-handoff/SKILL.md` in full: it's how this team hands
> work on, parks it and waits. When you wait on CI or a long job, park the row with `rig queue block <id> --on
> external:<what> --wake-after <duration> --continuation "<what resumes>"`.
- A community preset with `first_input: true` carries it in the seat's first input when the file is declared with
`delivery_hint: send_text` (see "Pass the first startup input at launch" above). A `.md` file left at the `auto` hint
counts as guidance, which a community seat doesn't take.
- A plain terminal seat can pass it as Pi's initial message in the command its startup runs. Check `pi --help` for that
argument in your Pi version.
- `first_input` carries text, not files: it doesn't install the skills.
**In a plain terminal seat, a queue wake doesn't reach Pi.** OpenRig doesn't treat a `runtime: terminal` seat as an agent
seat. When a parked row's wake fires, OpenRig doesn't type it into Pi, and a handoff to that seat records a failed wake
(`failed:terminal-seat`). Parking still records the wait, but `--wake-after` won't bring Pi back. Check the seat's parked
rows yourself (`rig queue whoami --session <seat>`, or `rig queue show <id>`), or tell Pi to continue the named row.
**What this shows, and what it doesn't.** A copied folder and a startup line put the text in front of the agent. They don't
show that Pi discovered the skill by itself, or that the agent follows it. Check those separately: that the agent read the
whole file (its tool trace), and what its rows record, such as a CI wait parked on `external:` with a wake, and a handoff
made with `handoff-and-complete`, whose next row names it as its source.
## Write your own
Start from the example closest to your harness, and change it.
Start from the example closest to your harness, and change it. First check that the harness answers a prompt on its own
([above](#what-a-community-harness-gets)).
1. **Choose how the conversation is named.**
- **Your harness creates an id you can read before it starts,** as Cursor's `create-chat` does: declare `launch_result: true`, and the
+40 -11
View File
@@ -1,7 +1,8 @@
# Edge Types Reference
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; its sources unchanged in edge behaviour since then)
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`
Historical whole-document validation: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`).
The fallback escalation section and deferred-target exceptions below describe #605.
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`, `packages/daemon/src/domain/escalation-owner.ts`
---
@@ -16,8 +17,7 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by the pod-aware validator (the legacy flat-node format accepts only `delegates_to`,
`spawned_by` and `can_observe`):
Five edge kinds are accepted by both the pod-aware and legacy flat-node validators:
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
@@ -25,13 +25,13 @@ Five edge kinds are accepted by the pod-aware validator (the legacy flat-node fo
| `spawned_by` | yes | **yes — affects launch order** | Source was spawned by target |
| `can_observe` | yes | no | Source can observe target's output |
| `collaborates_with` | yes | no | Peer collaboration |
| `escalates_to` | yes | no | Source escalates to target |
| `escalates_to` | yes | **yes — fallback queue escalation** | Source escalates to target |
## What Edges Actually Do Today
### Launch ordering
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
Only `delegates_to` and `spawned_by` constrain the order in which nodes are launched:
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
@@ -46,13 +46,42 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
### Fallback escalation across rigs
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
parent still takes precedence, preserving the existing parent route and its fallback.
```yaml
edges:
- kind: escalates_to
from: work.lead
to: review-advisor@coord
```
The target is a canonical same-daemon seat address (`pod-member@rig`), or an existing local member using the usual
edge syntax. Named targets are stored without requiring the other rig to exist and resolved when needed. A matching
physical session name takes precedence over a derived canonical address. Deferred declarations survive export and
snapshots; they don't constrain launch order or make the rigs depend on each other's lifecycle.
Delivery is one hop. Recovery rows carry `escalation-route:one-hop`, so a destination's own `escalates_to` declaration
does not forward the recovery again. A missing or stopped destination, multiple matches or declarations, self route,
or declared cycle preserves the existing fallback: the wake ladder proceeds to its operator rung; the sweep retains
its existing owner fallback. The recovery body/transition explains the result. Cycle inspection includes stopped
seats and delegation parents; an unresolved downstream endpoint is reported as unknown, never as proof of no cycle.
Queue address admission is unchanged. Cross-host and synthetic destinations are not supported.
On an existing rig, use `rig edge add <rig> work.lead review-advisor@coord --kind escalates_to`; `--plan` writes nothing.
The receipt's target node ID is null for a deferred address. Remove it with the exact edge ID returned by add.
A rig-local graph omits deferred targets, since those seats are outside that graph; the raw topology and export retain them.
### Graph visualization
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
Local-node edges are rendered in the UI topology graph; deferred cross-rig targets are omitted. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
### Identity projection
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
Local-node edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
### Attach hint heuristic
@@ -64,7 +93,7 @@ node, in creation order, whose session is running, or else the first node with a
Edges do NOT currently:
- **Route messages** — `rig send` can target any session, regardless of edges (queue escalation, above, is the one
daemon path that follows `delegates_to`)
daemon path that follows `delegates_to` or fallback `escalates_to`)
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
@@ -128,7 +157,7 @@ If none fit, `can_observe` is the safest default — it documents the relationsh
## Validation Rules
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
2. Pod-local edges use unqualified member IDs — both endpoints must exist in the same pod, except a named `escalates_to` target as above.
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members, except a named `escalates_to` target as above.
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
5. Self-edges are not validated at the schema level but are not meaningful
+17 -5
View File
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
@@ -331,6 +331,16 @@ every seat while those seats still run the sessions it started, reports that
restore's outcome instead of starting another. Explicitly repeating the saved
`--non-interruptive` or `--no-non-interruptive` choice also joins that restore.
`rig start --last` restores a rig unless every seat it launched was stopped on
purpose with `rig down` or `rig seat stop`. A rig whose stop was never recorded
counts as last running: one taken down with `rig down` before OpenRig 0.6.12, or
one whose seats exited or were lost without `rig down`. So does a rig with no snapshot
whose saved state can be restored. To keep such a rig down, run `rig down <rig>`
once, by rig id when names are shared; it records the stop, and `--last` then
leaves the rig down. `--last` never selects the kernel. Under `--no-kernel`
(`OPENRIG_NO_KERNEL=1`) the daemon skips the kernel's automatic restore too;
bring it back with `rig up kernel --existing`.
An explicit `rig up kernel --existing --fresh <seat...>` waits for startup's
restore to finish, then replaces only the named seats that still hold the exact
sessions startup launched. Other seats keep running. An operator's conversation
@@ -841,14 +851,16 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| Shared terminal is absent | Inspect the existing kernel binding and recovery state; use standalone `rig tui` while resolving it. |
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
| Daemon restarted but tmux survived | Re-read `rig status` and the existing queue; a daemon restart is not a fresh project. |
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when all of its seats were lost; check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when that start finds all of its seats lost; a kernel that was already down before the reboot stays down, and `rig start` names `rig up kernel --existing`. Check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Launch reports no usable snapshot | Inspect the existing rig and retained project files, then follow the same-seat recovery below. |
| Work is waiting on a prompt or decision | Read the row, transition and named prompt; preserve the obligation until the missing decision arrives. |
**When the kernel's seats all fail at the first start.** A report on Oracle Linux 10
([#980](https://github.com/mvschwarz/openrig/issues/980)) traced this to the distribution's packaged tmux, whose
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. To check your tmux on
a throwaway socket, without touching your own sessions:
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. `rig setup` (its tmux
step) and `rig doctor` (`tmux_capture` in `rig doctor --json`) run this check for you on a throwaway server and name the
fix when capture fails; they test what tmux does, not its version. To run it by hand, without touching your own
sessions:
```sh
tmux -L "openrig-check-$$" -f /dev/null new-session -d -s check
+6 -3
View File
@@ -139,7 +139,9 @@ issue's current status; a similar symptom alone is not a diagnosis.
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86),
[#273](https://github.com/mvschwarz/openrig/issues/273).
- Claude usage limits may not be detected: [#99](https://github.com/mvschwarz/openrig/issues/99).
- Claude usage limits may not be detected when neither a status line window nor a current
`StopFailure/rate_limit` hook is available: [#99](https://github.com/mvschwarz/openrig/issues/99).
See [Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection) for the inputs and retry behavior.
- Codex prompt variants not recognised as idle: [#79](https://github.com/mvschwarz/openrig/issues/79).
- Codex asks about hook trust at first launch: [#17](https://github.com/mvschwarz/openrig/issues/17), not reproduced
by the team.
@@ -147,8 +149,9 @@ issue's current status; a similar symptom alone is not a diagnosis.
daemon, when its Codex configuration sets network access off, a managed requirement could restrict it, or Codex
doesn't answer OpenRig's configuration read in time: [#275](https://github.com/mvschwarz/openrig/issues/275).
- Kernel seats fail within a second of the first start on a distribution tmux whose server exits on `capture-pane`
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). The check and recovery
are in [Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). `rig doctor` checks for
it (`tmux_capture`); the manual check and recovery are in
[Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
For everything else, search [open issues](https://github.com/mvschwarz/openrig/issues).
+114
View File
@@ -0,0 +1,114 @@
# Hosted members: a tool outside the terminal on your team
A hosted member is a tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, and works as a member of a rig. You
address work to it like any seat. It reaches the team through OpenRig's MCP server under a grant you issue and can revoke. It never gets a pane.
Assistants that run on your machine can reach OpenRig's MCP server locally. Many assistants live in a vendor's cloud, and those can only reach a server on the internet.
> **Experimental and unsupported. Off by default.** Nothing in OpenRig listens on the internet unless you set it up yourself. You serve `rig mcp serve --http`, put it behind your own TLS tunnel or proxy, and issue a token. Doing that is like opening a port in your firewall. Anyone with the URL and a valid token can act as that member within its grant. Treat the token like a password, give the member the narrowest grant, and revoke it when you're done (`rig mcp token revoke <name>`). We don't support, and take no responsibility for, an OpenRig reachable from the internet, just as with running OpenRig itself on the open internet. Use at your own risk.
## Set one up
1. **Pick its address** on an existing rig: `<member>@<rig>`, for example `grok@research`. No node is added; the queue takes rows for it.
2. **Say what it's good at, and its limits,** in a roster: an entry in `<workspace.root>/rosters/<file>.json` with its seat, this machine's host id
(`hostSelfId` from `rig ps --nodes -A --json`), `capabilities`, `use_when`, `why`, a `caveat` for its limits, and `"hosted": true` (see
[Rosters](rosters.md)).
3. **Issue its grant:**
```sh
rig mcp token add grok --seat grok@research --rig research
```
It prints the token once. The member acts as `grok@research`: every row it creates or changes is attributed to that seat, and it reaches only
that rig's seats and work.
4. **Serve the MCP server over HTTP** behind a tunnel or proxy that adds TLS: `rig mcp serve --http`. See `rig mcp serve --help` for binding and
for how authentication works. If the proxy runs browser checks (such as Cloudflare's Browser Integrity Check), skip them for `/mcp`:
MCP clients aren't browsers.
5. **Add the server in the tool:** its `https://…/mcp` URL and the token as a bearer credential.
## How it works its rows
- **Hand it work** as you would any seat: `rig queue create --destination grok@research …`.
- **The member works it** through the MCP tools:
- `rig_queue_list` with `owned` lists its rows;
- `rig_queue_claim` takes one;
- `rig_queue_update` closes it done with the answer in `resolution`, or `rig_queue_handoff` hands it on to a seat in its rig.
- **It can send work too:** `rig_queue_create` to its rig's seats.
- **Its rows outlive the conversation.** A new conversation with the same grant reaches the same work.
- **`rig_whoami`** answers the seat its grant names, though the member has no session.
## Asking a person while keeping the work
When a row needs a person's approval before the member can finish it, the member keeps the row and asks:
- **The row came from a person:** the member sets it `blocked` with `blockedOn` that person, and puts the question, with a short form of
what it will do, in the row's `summary`. The person is asked with the summary and the row; a transition note stays in the row's history
and isn't part of the ask. A longer draft goes at an https link in `evidenceRef`, which the ask carries. The person's answer returns the
row to the member in progress.
- **The row came from a seat, or from a shared channel:** the member asks as a new row, to that seat, or for a channel's row to its rig's
lead (a shared channel takes correspondence and can't answer an ask). Then it sets its own row `blocked` on the new row's id. When the
question closes, the member's row returns to pending.
- **Keep the question and action summary short.** A long row body continues as numbered thread replies, within the existing delivery
limits. Use an https `evidenceRef` for a longer approval draft. The summary and framing can still be too large, so nothing promises
unlimited delivery.
Either way the row reads blocked, not done, while it waits, and the member finishes that same row. Nothing promises to wake the
member, so it checks its rows again.
The server tells the member this in its MCP instructions. The text is versioned, so you can check which version a tool received:
> Member workflow (member-workflow/v2). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
## Ring it when work lands
If the tool has a trigger that starts it from outside, such as a webhook on a scheduled routine, add it to the member's roster
entry. The entry holds secret names, never values:
```json
"wake": { "webhook": { "url_ref": "GROK_WAKE_URL", "authorization_ref": "GROK_WAKE_AUTH", "secrets_env_file": "/abs/path/grok-wake.env" } }
```
- **The secrets:**
- `url_ref` names the secret holding the webhook URL.
- `authorization_ref`, when the tool needs one, names the secret holding the `Authorization` header value.
- Credentials in the URL itself (`https://user:pass@host/…`) are sent as Basic `Authorization` instead, unless
`authorization_ref` is set: then its value is sent, and the URL's credentials are dropped.
- The daemon reads each from its environment (the name, or `OPENRIG_<NAME>`), else from `secrets_env_file`, an env file kept at mode 0600.
- **The ring:** when a row lands for the member, the daemon POSTs
`{"source":"openrig.queue","schema_version":1,"event":"work-waiting","qitem_id":…,"destination":…,"summary":…,"from":…}`.
- It's a pointer, not the row's body: the member reads the row under its grant.
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
is rung again on recovery, the same at-least-once window a terminal wake has.
- It waits up to 10 seconds and doesn't follow redirects.
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
## What the record says, and doesn't
These facts stay separate on the row:
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
created the row.
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
- **Pickup:** its claim, a transition by its seat. Listing or reading a row isn't pickup.
- **Result:** the closing transition and its resolution, or the handoff.
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
`<OPENRIG_HOME>/mcp-http-presence.json`, beside the token file;
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
## Revoke
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
isn't rung, and its person is told instead.
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one.
@@ -120,6 +120,11 @@ or **"Not tested by OpenRig"**, which means exactly that. You're welcome to run
## 6. Submit it to openrig.dev/rigs
Before you submit, run `rig bundle check` on the folder at the commit you'll submit. openrig-registry's registry check
runs the same check, with the latest published OpenRig, on the folder at your `ref`, and any finding fails it. A team
made with `rig export` has no README of its own: add one and declare it in `rig.yaml` as `docs: [{ path: README.md }]`
(see [section 1](#1-lay-out-the-folder)).
Open a pull request to [mvschwarz/openrig-registry](https://github.com/mvschwarz/openrig-registry) that adds one file,
`registry/submissions/<your-team>.yaml`, with three fields: `repository` (your GitHub repository URL), `folder` (the
folder holding `rig.yaml`, or `.` for the repository root) and `ref` (a branch, tag or commit). No pull requests? Open
+3 -3
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.0`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
@@ -195,13 +195,13 @@ it as the step to finish: sign in, then continue at step 3.
## Learn it in this order
1. **How OpenRig works:** [the world and its purpose](https://github.com/mvschwarz/openrig/blob/main/packages/daemon/assets/onboarding/01-world-and-purpose.md),
1. **How OpenRig works:** [the world and its purpose](../../packages/daemon/assets/onboarding/01-world-and-purpose.md),
the first page a new agent in a team gets. It's written to agents, and it's the shortest account of rigs, seats and
queued work. Your install has the same page under `$(npm root -g)/@openrig/cli/daemon/assets/onboarding/`.
2. **First use:** this page, and [what these words mean](#what-these-words-mean).
3. **One command:** `rig <command> --help` is the exact answer for the version you have installed, and `rig --help`
lists every command.
4. **Every command and flag:** the [CLI reference](https://github.com/mvschwarz/openrig/blob/main/docs/as-built/cli-reference.md).
4. **Every command and flag:** the [CLI reference](../as-built/cli-reference.md).
It describes the source commit it names, so where it and your installed `--help` disagree, `--help` is right for
your version.
+13 -6
View File
@@ -314,8 +314,11 @@ ambient `OPENRIG_YOLO` it replaces. With workspace-write, OpenRig still adds its
and, on a fresh launch, git metadata folders; with read-only it adds no writable roots. It adds
no network setting of its own. Codex's implicit roots (the working directory and temporary
folders) still apply: this is a native sandbox and approval selection, not a complete
filesystem or credential-isolation guarantee. The field is also supported on legacy flat
Codex nodes; other runtimes reject it.
filesystem or credential-isolation guarantee. OpenRig's tests prove these launch flags, not
that Codex enforces them, so check the seat's actual behaviour before relying on a narrower
envelope. An OpenRig older than 0.7.3 doesn't read a recorded envelope, so after a downgrade
the seat launches without it. The field is also supported on legacy flat Codex nodes; other
runtimes reject it.
For an existing seat, put the envelope in a JSON file, or change its profile, with the existing
audited permission command:
@@ -508,9 +511,9 @@ pods:
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
@@ -650,7 +653,7 @@ own defaults/settings apply. Other runtimes ignore this field.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
@@ -715,7 +718,11 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
cycle detection and visible fallback outcomes.
### Pod-Local Edges
+6
View File
@@ -86,3 +86,9 @@ UNKNOWN is `{ "status": "unknown", "reason": "..." }`; older daemons may yield
`null`. `--full` and `--fields modelObservation` retain the full object, including
its status and occupant generation. Human output and roster reports use the full
labelled observation.
A member marked `"hosted": true` is a hosted member: a tool outside the terminal that reaches the team over MCP, with no node or pane.
`rig_team` lists it with its capabilities and caveat, no pane and activity unknown, unless its seat is one of the rig's nodes.
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
+6
View File
@@ -165,6 +165,12 @@ commit. Evidence capture (`proof add`), policy acceptance, higher outcome
judgment and publication are distinct. This optional component does not select
Part B or add a mandatory review gate.
A judgment binds the slice's path, the item's text and the policy. Moving or
renaming a slice (`rig scope slice move`, or by hand) reopens its judged items:
`rig proof show` reports each as `unknown`, and each needs a new judgment on its
current revision. `rig scope audit` checks structure only and never reads proof
readiness, so a passing audit doesn't mean the proof still stands.
The project readiness that `rig proof show --project` reports counts only
missions whose `mission.yaml` carries `metadata.status: active` or
`metadata.status: release-candidate`. `rig scope mission create` writes
+6
View File
@@ -16,6 +16,12 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
Lifecycle work is refused at `openrig-urgent` and `urgent-only`, as at `hold`.
A terminal member (`runtime: terminal`) defaults to `sends-only`, which holds OpenRig's own notices. Set one
to `off` and those notices, such as park reminders and other watchdog messages, type into whatever the pane
is running, and a shell prompt runs each line as a command, From and To lines included: OpenRig doesn't check
a terminal pane's foreground process. `openrig-urgent` and `draft-aware` type only into a recognized empty agent composer, so
a shell prompt holds them. Keep the default for a seat that may fall back to a shell.
Runtime permissions are separate. Recipient and interactive-prompt checks still
apply. `--raw` and `--force` do not bypass the typing guard. A deliberate
`--dangerously-interact --reason` remains available at every interruption level
+171 -4
View File
@@ -30,16 +30,44 @@ With neither a provider nor a session, the daemon's open and preview APIs try
a live Herdr first, then a live cmux. An explicit session always selects Herdr
and skips that fallback, including when the daemon's default Herdr endpoint is down.
An explicit session resolves under the daemon user's Herdr session directory;
it takes precedence over that daemon's default socket override. The selection
belongs to the request. Saved views still describe membership and layout, so
pass `--session` again when reopening the view in that session.
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
when unset. Relative paths (including an empty value) resolve from the daemon's
startup directory, matching Herdr's configuration selection. Named sessions live under
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
These settings are captured when the daemon starts.
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
configuration-tree path, without reusing the override or inferring a session
from an arbitrary socket path. With an explicit socket but no configured
session name, omit `--session` to keep that exact endpoint. A missing selected
socket never falls back to another session. Viewer and follow operations carry
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
CLI `--session` that would override it. They check that the CLI reports the
selected socket before changing its panes. Daemon-side CLI calls retain the
startup environment; desktop follow uses the socket confirmed by the daemon.
The selection belongs to the request. Saved views still describe membership
and layout, so pass `--session` again when reopening the view in that session.
The existing view rules apply: saved views, rig and pod views, read-only mission
and slice views, pagination, absent seats and partial results all use the same
composer. Selecting a session changes where those tiles are placed. It does
not relaunch the seats' tmux sessions or move queue ownership.
## Open results
For Herdr, a successful layout request alone does not count its seats as opened.
OpenRig checks the returned tab's pane list. A missing tab identity or an
unreadable, empty or unattributable listing puts the affected seats in
`degraded`, with `code: "pane_unconfirmed"`, instead of `opened`. The CLI and
TUI name those seats as unconfirmed. Inspect the terminal before retrying:
the layout may have taken effect even though its panes could not be confirmed.
Confirmed seats on other pages still count; with none confirmed, the command
returns a nonzero exit status. Reusing an existing workspace keeps its separate
reuse result. This pane check does not prove desktop visibility or create a
verified viewer mapping.
## Replacing a seat's session
New tiles keep their place when a seat's tmux session is replaced. They wait up
@@ -139,3 +167,142 @@ session keeps the normal HTTP 200 provider result with `ok: false`, its selected
`session`, an error code and direct-attach guidance.
The `kernel_seats_unavailable` result also keeps the selected session, so the CLI
reports that known failure instead of replacing it with an unconfirmed-session error.
## Task terminal views
`rig terminal open qitem:<id> --provider herdr --json` opens a derived, read-only
view of the current owners of a local queue episode. The episode includes the
requested row and explicitly linked descendants; only pending, in-progress and
blocked obligations contribute owners. It does not include historical senders,
closed predecessors, parent episodes or siblings. Membership is not input
permission.
The existing daemon endpoints accept the same view:
```text
GET /api/terminal/preview?view=qitem:<id>&provider=herdr
POST /api/terminal/open {"view":"qitem:<id>","provider":"herdr","expectedPlan":"<preview planId>"}
```
Preview and open include `task: {qitemId, name, complete, issues, members}`. Each member
names its structured `{hostId, nodeId}` key (null when unresolved), seat,
contributing `obligations`, `availability` and `viewerStatus`. Stopped, missing,
external and unresolved remote owners remain visible. Remote queues are not
crawled and no remote tmux socket is guessed. A bounded or damaged lineage is
explicitly incomplete; it is never silently presented as the whole team.
The local lineage read has work limits: 50,000 scanned rows (plus one overflow
sentinel), 8 MiB of encoded lineage, 100,000 links, and 16 KiB per chain record.
It reads one snapshot from the requested task forward, then probes earlier active
obligations using the queue's existing state index and follows references by exact
ID, including closed intermediaries. A child may arrive before its referenced root.
All these reads share the work limits; unrelated older closed history is not scanned.
Only the requested episode is expanded, up to 256 rows. Caller-supplied task IDs
are supported.
Hitting a work limit preserves known members and reports incomplete membership;
it does not claim that an owner outside the scanned history is absent.
Preview and open also include `viewDescriptor: {kind, name}`: `following` for
derived views and `fixed` for a saved layout. A task uses its row summary as
its name, falling back to the qitem ID. The existing preview `view` string is
unchanged. A following view derives membership when requested; it does not
mean an open workspace updates itself.
Preview is read-only. A member without owned viewer provenance is
`not-provisioned`; the ensure operation below can establish a reusable provider
mapping. An explicit open retains the provider's existing workspace behavior;
it does not continuously reconcile the view or change an agent's lifecycle.
## Following one task
`rig terminal open qitem:<id> --follow` keeps one workspace following the
task's current obligation owners. A joining member gets a verified local
viewer tile without taking focus. A departing member loses only its viewer,
after a grace period; the agent and its conversation keep running. The
explanation strip names joins, departures, unavailable terminals and limits.
The first workspace on an empty provider may become focused.
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
endpoint. Other platforms/providers and other selector kinds retain ordinary
one-shot open. Use `--provider herdr --json` to operate without opening a
desktop window, and `--session <name>` to select that endpoint. Opening a tile
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
```bash
rig terminal open qitem:example --follow --provider herdr --json
rig terminal open qitem:example --follow --provider herdr --pin reviewer@team
rig terminal open qitem:example --follow --provider herdr --max-tiles 12
rig terminal open qitem:example --follow --provider herdr --suppress reviewer@team --suppression-ms 60000
rig terminal open qitem:example --no-follow
```
`--unpin` and `--unsuppress` reverse those presentation choices. The default
cap is eight tiles; explicitly raising `--max-tiles` overrides it. Pins can
exceed the cap rather than being evicted. A departed tile retained for typed or
unconfirmed input does not consume a slot for joining members. Disabling removes
the saved following selector and keeps the existing layout intact.
The default departure grace is four seconds (`--grace-ms`). A focused leaver
gets another twelve seconds for reading (`--focus-retention-ms`); before its
tile closes, focus moves to a verified surviving member, or to the explanation
strip when none remains. Typed or unconfirmed input keeps the tile, labeled
as no longer participating. Explicit suppression also retains a typed draft.
Pi, terminal and community harness input may be unreadable to the composer
inspector: those tiles remain while input is unconfirmed, even after unpinning.
Each input-retained departure gets one delayed recheck after thirty seconds.
After that, queue or node/session lifecycle events, or an explicit repeat of
the follow command, can reconsider it. Activity hooks do not wake following,
and input is not repeatedly polled. Draft text is never saved.
Following consumes Core events and one-shot retention/suppression deadlines,
not repeated CLI membership queries. Complete empty membership drains the
tiles and preserves the workspace. Once no retained tiles remain, following
stops and the selector releases its active slot; new work does not automatically
restart it. Run `--follow` again to resume the same workspace. An incomplete source, failed provider read
or changed pane/terminal generation retains the previous view and reports
the uncertainty. It never authorizes a blind workspace rebuild or close.
`rig terminal views --json` and `rig terminal status --json` include the saved
active selectors and latest following status. Active selectors and owned viewer provenance
survive daemon restarts; task membership is always re-derived.
The owner-authorized HTTP operation is `POST /api/terminal/follow` with
`{view:"qitem:<id>",provider:"herdr",session?,enabled:true,maxTiles?,pin?,unpin?,
suppress?,unsuppress?,suppressionMs?,graceMs?,focusRetentionMs?,expectedPlan?}`.
Seat controls are arrays of names from the current/retained view. It uses the
existing terminal bearer boundary, like ensure-viewer. The response includes
the stable `workspaceId`, normal open partitions, `viewDescriptor`, and
`following:{id,enabled,state,maxTiles,tiles,events,overflow,nextAt?}`. Presentation
provenance grants no membership or input authority. This is a local native
viewer operation; browser input and remote-host provisioning are separate.
## Ensuring an owner viewer
The owner-facing HTTP operation `POST /api/terminal/viewer/ensure` accepts
`{view:"qitem:<id>", member:{hostId,nodeId}, provider:"herdr", session,
expectedPlan}`. Use a current preview's member key and plan. This explicit
operation uses the daemon's existing terminal bearer and browser boundary;
a bridge must retain its signed-in owner, Origin and expiry controls. It does
not grant authority based on task membership or accept a caller terminal ID.
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
A repeated/concurrent request does not create another viewer. A lost create
reply is reconciled before starting anything else; a lost run reply is checked
against the actual foreground process, never blindly repeated. Unknown or
changed terminal identity remains unconfirmed. The operation does not relayout,
close departed viewers, take over control, focus existing panes or launch/stop
agents. A first workspace on an otherwise empty Herdr server may become focused.
Preview remains passive and names `not-provisioned`, `verified` or `unconfirmed`
viewer status. Studio can ensure a new member, add its browser frame, and retain
a pinned leaver labeled as no longer participating. The provider mapping is
small presentation provenance, separate from queue membership. Herdr display
labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
`-r` (which also implies ignore-size). This can avoid contributing to sizing
when other clients establish the agent window's dimensions. It is **not resize
isolation**: opening a tile can change the agent's terminal size when it is the
only sizing client. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
+8 -33
View File
@@ -1,38 +1,15 @@
# Release Notes
This directory is the lightweight release history for OpenRig.
This directory holds one note per release for OpenRig v0.1.12 through v0.6.3, plus v0.6.12 (for example `v0.1.12.md`).
It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.2.0.md`
- `v0.3.0.md`
- and so on
## Why This Exists
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
- npm package release
- optional Git tag
- optional GitHub Release
- short, human-written summary of what is included
This keeps release notes:
- easy to author
- easy to link in GitHub Releases
- easy to paste into announcements
- durable in the repo
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
## Minimal Process
For each release:
1. Copy `_template.md` to `vX.Y.Z.md`.
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
1. Add the release's section to [`CHANGELOG.md`](../../CHANGELOG.md).
2. Write the release's GitHub Release notes. No new file is added to this directory.
3. Run the **substance gate** at the exact release cut. The gate derives every
file under the shipped pack sources, verifies a hash-bound human judgment
and an individual disposition for each mechanical candidate, scans the
@@ -79,16 +56,16 @@ For each release:
git push origin vX.Y.Z
```
5. Create a GitHub Release using the same file:
5. Create the GitHub Release with the notes from step 2:
```bash
gh release create vX.Y.Z \
--repo mvschwarz/openrig \
--title "OpenRig vX.Y.Z" \
--notes-file docs/releases/vX.Y.Z.md
--title "OpenRig X.Y.Z" \
--notes-file <release-notes.md>
```
6. Publish the npm package if that is part of the release flow.
6. Publish the npm package.
## Guidance
@@ -101,5 +78,3 @@ For each release:
## Scope
This directory is a release-note archive, not a full historical changelog taxonomy.
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
+1 -1
View File
@@ -260,7 +260,7 @@ Operators can install `gstack` (45 skills) or `obra-superpowers`
- TypeScript checks clean on all touched files.
- Code review (review-lead, advisory-clean) on every merge in the
release window.
- Founder-walk VM at `http://100.95.124.51:17459/` walked end-to-end
- Founder-walk VM at `http://192.0.2.10:17459/` walked end-to-end
on host-main `4d88d98d` with realistic demo seed (8 seeded queue
items, 8 feed events, 4 verified `/for-you` action scenarios
including the inline error path).
+366 -86
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -47,7 +47,8 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/sdk": "^1.28.0",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/server": "^2.4.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^13.0.3",
@@ -62,6 +63,8 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
+53 -23
View File
@@ -3,7 +3,7 @@ import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner
export { ForeignLocalDaemonError } from "./local-daemon-owner.js";
import path from "node:path";
import { ConfigStore } from "./config-store.js";
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
@@ -153,6 +153,9 @@ interface DaemonClientOptions {
timeoutMs?: number;
/** The seat this client acts as, for a client built per authenticated caller; default: the process env. */
sessionName?: string;
/** With sessionName: the caller whose grant the MCP adapter checked, sent as X-OpenRig-Principal (JSON of its source,
* id and, for a grant-service grant, grant id and version; never a credential). */
principal?: { source: "file" | "grant-service"; id: string; grant?: string; version?: number | string };
}
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
@@ -183,36 +186,62 @@ export class DaemonClient {
private identity?: Promise<Record<string, string>>;
private originUnknown = false;
private readonly sessionName?: string;
private readonly principal?: DaemonClientOptions["principal"];
private async identityHeaders(): Promise<Record<string, string>> {
const { headers, local } = await this.senderHeaders();
// Only beside a seat this client was built for, and only to this host's own daemon: the endpoint itself must answer
// as this host's instance, whichever way it was chosen (OPENRIG_URL, or an explicit URL such as rig mcp serve --port,
// which can be a forwarded port). The daemon records it only on a direct, attributed request.
if (!this.principal || this.sessionName === undefined || !local || !(await this.endpointIsThisHost())) return headers;
// A header value can't carry characters beyond Latin-1 (fetch throws), and a grant name may hold any: everything past
// ASCII travels as JSON's own \u escapes, which the daemon's JSON.parse reads back.
const principal = JSON.stringify(this.principal).replace(/[\u0080-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
return { ...headers, "X-OpenRig-Principal": principal };
}
/** Whether this client's endpoint answers /healthz with the id this host's own daemon minted. */
private async endpointIsThisHost(): Promise<boolean> {
if (this.remoteTarget) return false;
// Imported here, not at the top: it loads better-sqlite3 and yaml.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
return !!origin && (await this.endpointSelfHostId()) === origin;
}
/** The endpoint's own instance id from /healthz, or undefined when it doesn't answer within a second. */
private async endpointSelfHostId(): Promise<unknown> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
} catch { return undefined; }
finally { clearTimeout(timer); controller.abort(); }
}
/** The sender headers, and whether the endpoint is this host's own daemon: no direct endpoint is configured, or the
* endpoint answered with this host's own instance id. */
private async senderHeaders(): Promise<{ headers: Record<string, string>; local: boolean }> {
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
const session = localHeaders[SENDER_IDENTITY_HEADER];
if (!session || session.split("@").length >= 3) return localHeaders;
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (!this.remoteTarget && !directTarget) return localHeaders;
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
const directTarget = readOpenRigUrl();
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
// Imported here, not at the top: it loads better-sqlite3 and yaml, which only a seat on a direct endpoint needs.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
if (!origin) {
this.originUnknown = true;
return { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" };
return { headers: { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" }, local: false };
}
if (!this.remoteTarget) {
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const target = await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
if (target === origin) return localHeaders;
} catch { /* Unproved locality: carry the known origin, never the endpoint's identity. */ }
finally { clearTimeout(timer); controller.abort(); }
}
return senderIdentityHeaders(origin, this.sessionName);
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality; unproved, the sender carries
// the known origin, never the endpoint's identity.
if (!this.remoteTarget && (await this.endpointSelfHostId()) === origin) return { headers: localHeaders, local: true };
return { headers: senderIdentityHeaders(origin, this.sessionName), local: false };
}
/** The sender this client stamps on its requests (the X-OpenRig-Session value, absent without a seat), and whether it marked
@@ -227,7 +256,7 @@ export class DaemonClient {
if (baseUrl) {
this.baseUrl = baseUrl;
} else {
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const envUrl = readOpenRigUrl();
if (envUrl) {
this.baseUrl = envUrl;
} else {
@@ -248,6 +277,7 @@ export class DaemonClient {
this.ownerProbe = options?.ownerProbe ?? ((url, timeoutMs) => assessLocalDaemonOwner(url, { fetchImpl: this.fetchImpl, timeoutMs }));
this.timeoutMs = options?.timeoutMs ?? 5_000;
this.sessionName = options?.sessionName;
this.principal = options?.principal;
}
/** Shared with raw CLI data readers. Constructor URLs alone do not establish explicit intent. */
+1 -1
View File
@@ -94,7 +94,7 @@ Ctrl-C (or rig acp cancel <runId>) asks the agent to cancel the turn; a second C
Ending the run closes the agent's stdin and reports whether any process carrying the run's marker remains.
Example:
rig acp run qitem-20261023-abc --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
rig acp run <qitem-id> --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
Guide: rig context get reference/acp-agents.md`)
.action(async (qitemId: string, agent: string[], opts: { cwd: string; permissionsTo?: string; loadSession?: string; pollSeconds: string }) => {
+10 -9
View File
@@ -13,6 +13,11 @@ function shellQuote(value: string): string {
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
// tmux writes a control character in its output as "_", so a newline between fields can't separate them, and a
// session name may hold spaces or underscores. Ask for the two fixed-shape ids first; the rest of the line is the name.
const TMUX_CONTEXT_FORMAT = "#{pane_id} #{window_id} #{session_name}";
const TMUX_CONTEXT_LINE = /^(%\d+) (@\d+) (.+)$/;
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
| { attachmentType: "external_cli" }
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
@@ -22,15 +27,11 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
}
try {
const output = tmuxExec(`tmux display-message -p -t ${JSON.stringify(tmuxPane)} "#{session_name}\n#{window_id}\n#{pane_id}"`);
const [tmuxSession, tmuxWindow, resolvedPane] = output.split("\n").map((part) => part.trim());
if (tmuxSession && resolvedPane) {
return {
attachmentType: "tmux",
tmuxSession,
tmuxWindow: tmuxWindow || undefined,
tmuxPane: resolvedPane,
};
const output = tmuxExec(`tmux display-message -p -t ${shellQuote(tmuxPane)} ${shellQuote(TMUX_CONTEXT_FORMAT)}`);
const match = TMUX_CONTEXT_LINE.exec(output.trim());
if (match) {
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane! };
}
} catch {
// Fall back to external_cli if tmux metadata cannot be resolved.
+12 -3
View File
@@ -265,8 +265,10 @@ Supported notes:
const client = deps.clientFactory(getDaemonUrl(status));
const lines = parseInt(opts.lines ?? "20", 10);
// The capture default is 20: a missing or non-positive count reads as 20, never 0 or negative.
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
const body: Record<string, unknown> = { lines: requestedLines };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
if (session) body.session = session;
@@ -334,7 +336,13 @@ async function runCrossHostCapture(
if (opts.rig) argv.push("--rig", opts.rig);
if (opts.pod) argv.push("--pod", opts.pod);
// Only an explicit --lines is forwarded: the remote's own default stays the default.
if (opts.linesGiven || opts.historyPlusPane) argv.push("--lines", opts.lines ?? "20");
// A value that parses as a positive count goes over as given (the remote decides);
// only a missing, zero, negative or non-numeric count sends the documented default.
if (opts.linesGiven || opts.historyPlusPane) {
const given = opts.lines ?? "20";
const parsed = parseInt(given, 10);
argv.push("--lines", Number.isNaN(parsed) || parsed < 1 ? "20" : given);
}
if (opts.historyPlusPane) argv.push("--history-plus-pane");
if (opts.readable) argv.push("--readable");
if (opts.size !== undefined) argv.push("--size", opts.size);
@@ -375,7 +383,8 @@ async function runHttpHostCapture(
hint?: string,
): Promise<void> {
const lines = parseInt(opts.lines ?? "20", 10);
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
const body: Record<string, unknown> = { lines: requestedLines };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
if (session) body.session = session;
+23 -4
View File
@@ -4,7 +4,24 @@ import { readOpenRigEnv } from "../openrig-compat.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
* reading as an option. tmux reads the `-s` value as a format, where `##` is one `#` and a run of `#` before `[` is a
* style it keeps as written, so every other `#` is doubled and the session is `chatroom@<rig>`. Some tmux versions
* still change a session name (3.5a writes `_` for `:` and `.`, and escapes a backslash or a control character such
* as a newline or tab), so `-P -F` prints the name tmux used. */
export function chatroomWatchTmuxArgs(rig: string): string[] {
const session = `chatroom@${rig}`.replace(/#+(\[)?/g, (run, style?: string) => style ? run : run + run);
return ["new-session", "-d", "-P", "-F", "#{session_name}", "-s", tmuxArg(session),
"rig", "chatroom", "watch", "--", tmuxArg(rig)];
}
/** tmux ends a command at an argument that ends in `;`, unless a `\` comes before it, so a final `;` gets that `\`. */
function tmuxArg(value: string): string {
return value.endsWith(";") ? `${value.slice(0, -1)}\\;` : value;
}
interface RigSummary {
id: string;
@@ -175,9 +192,11 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
if (opts.tmux) {
const sessionName = `chatroom@${rig}`;
try {
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} "rig chatroom watch ${JSON.stringify(rig)}"`, { stdio: "ignore" });
console.log(`Started watch in tmux session: ${sessionName}`);
console.log(`Attach with: tmux attach -t ${sessionName}`);
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim() || sessionName;
console.log(`Started watch in tmux session: ${created}`);
console.log(`Attach with: tmux attach -t ${created}`);
} catch {
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
process.exitCode = 1;
+11 -5
View File
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { getOpenRigHome } from "../openrig-compat.js";
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { formatDaemonHostForUrl } from "../client.js";
@@ -54,6 +54,14 @@ function existsOrThrow(path: string): boolean {
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
}
/** The evidence probe's URL: OPENRIG_URL with one trailing slash trimmed, as the detector trims it, else daemon.json's
* host and port. The shared reader leaves a query or fragment URL as written, so the trim stays here. */
export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { host?: string; port: number } | undefined): string {
return openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
}
/**
* The real verdict: LAZY-imports the narrow @openrig/daemon/crash-cart subpath at invocation (dep
* rail 2 — rig startup + other verbs never load the daemon module) and composes the shipped detector
@@ -63,7 +71,7 @@ function existsOrThrow(path: string): boolean {
async function realEmit(): Promise<CrashCartEmit> {
const cc = await import("@openrig/daemon/crash-cart");
const home = getOpenRigHome();
const openrigUrl = process.env.OPENRIG_URL;
const openrigUrl = readOpenRigUrl();
const probeClassified = (url: string) =>
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
const detectDeps = {
@@ -83,9 +91,7 @@ async function realEmit(): Promise<CrashCartEmit> {
? `alive (pid ${state.pid})`
: `dead (pid ${state.pid})`
: "no daemon.json";
const url = openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
const url = crashCartEvidenceUrl(openrigUrl, state);
const probeResult = await probeClassified(url);
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
},
+5 -1
View File
@@ -264,7 +264,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// know whether the kernel itself reached ready. Operators who
// need a kernel-ready signal opt in here.
if (opts.waitForKernel) {
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
@@ -272,6 +272,10 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const result = await waitForKernelReady(baseUrl, timeoutMs);
if (result.ok) {
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
} else if (result.kernelState === "skipped") {
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
console.error(skippedKernelMessage(result.detail));
process.exitCode = 1;
} else {
// Honest 3-part error per banked discipline.
console.error(
+11 -6
View File
@@ -8,7 +8,7 @@ import type { StatusDeps } from "./status.js";
interface EdgeEnd {
logicalId: string;
nodeId: string;
nodeId: string | null;
}
interface EdgeSuccess {
@@ -39,7 +39,8 @@ function report(result: EdgeOpResult, opts: { json?: boolean }): void {
switch (result.outcome) {
case "would_add":
console.log(`plan add ${line} in rig ${result.rigId}`);
console.log(` from node ${edge.from.nodeId}, to node ${edge.to.nodeId}`);
console.log(` from node ${edge.from.nodeId}, to ${edge.to.nodeId === null
? `${edge.to.logicalId} (resolved when used)` : `node ${edge.to.nodeId}`}`);
console.log(" nothing written (--plan)");
break;
case "added":
@@ -124,10 +125,10 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
cmd
.command("add")
.description("Ensure one edge between two existing seats; an existing edge is unchanged")
.description("Ensure one typed edge; an existing edge is unchanged")
.argument("<rig>", "Target rig name or ID")
.argument("[from]", "Source seat logical ID, such as orch.lead")
.argument("[to]", "Target seat logical ID, such as review.reviewer")
.argument("[from]", "Existing source seat logical ID, such as orch.lead")
.argument("[to]", "Target logical ID, or a same-daemon address for escalates_to")
.option("--kind <kind>", "delegates_to, spawned_by, can_observe, collaborates_with or escalates_to")
.option("--plan", "Validate the request and show the change without writing")
.option("--json", "JSON output for agents")
@@ -135,8 +136,12 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
Examples:
rig edge add my-rig orch.lead review.reviewer --kind delegates_to
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
rig edge add my-rig work.lead review-advisor@coord --kind escalates_to
Endpoints are fully qualified logical IDs for both same-pod and cross-pod edges.
Use existing, fully qualified logical IDs in <rig> for both same-pod and
cross-pod edges. For escalates_to only, the target may instead be a canonical
same-daemon seat address (pod-member@rig), resolved when used; that rig or seat
need not exist yet. Cross-host and synthetic destinations are unsupported.
Run it once per edge. A repeat reports the existing edge and its ID, and writes
nothing. No seats or sessions are changed.`)
.action(async (rig: string, from: string | undefined, to: string | undefined, opts: { kind?: string; plan?: boolean; json?: boolean }) => {
+86 -16
View File
@@ -23,6 +23,7 @@ import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executo
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
import { DaemonClient } from "../client.js";
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
import { parseFeedHostSubscriptionKey } from "../config-store.js";
// ---------------------------------------------------------------------------
// rig host pair — the approval wait is BOUNDED.
@@ -71,6 +72,46 @@ export interface DoctorDeps {
* sidecar (TOFU + loud-conflict). Optional so existing fixtures keep compiling AND unit runs never
* write the real sidecar — absent means learning is skipped, which is the fail-open contract. */
learnHostBinding?: (alias: string, observedHostId: string, observedUrl: string) => HostObservationOutcome;
/** The registry entry a reply addressed `@<hostId>` resolves to, by the lookup `rig send` makes.
* Optional like the learner: absent means doctor makes no claim about where replies go. */
replyRoute?: (hostId: string) => HostEntry | null;
/** The self-id learned for a registry alias, if any. */
learnedHostId?: (alias: string) => string | null;
}
/** Where `rig send` takes a target ending `@<hostId>`: registry id, then a declared hostId, then the
* first hosts.yaml entry with that learned binding (cross-host-target.ts). Null when none matches. */
export function replyRouteFromDisk(hostId: string): HostEntry | null {
const loaded = loadHostRegistry();
if (!loaded.ok) return null;
const resolved = resolveHost(loaded.registry, hostId, loadHostBindings().bindings);
return resolved.ok ? resolved.host : null;
}
const REPLY_ROUTE_ORDER = "rig send resolves it by registry id, then a declared hostId, then the first hosts.yaml entry that learned it";
/** A reachable entry whose self-id another entry answers for. Unknown, not fail: doctor can't tell
* from here whether that other entry still reaches the machine. */
function replyRouteElsewhereRow(host: HostEntry, hostId: string, winner: HostEntry): CheckRow {
return {
step: "reply-route",
status: "unknown",
detail: `replies addressed @${hostId} go to '${winner.id}', not '${host.id}' (${REPLY_ROUTE_ORDER})`,
fix: `run rig host doctor ${winner.id}; if it no longer reaches this machine, delete its entry from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any)`,
};
}
/** An unreachable entry that replies still resolve to: the failure a stale entry causes, named on
* the entry itself. Null when the entry has no self-id or another entry answers for it. */
function replyRouteThroughDeadEntryRow(host: HostEntry, deps: DoctorDeps): CheckRow | null {
const hostId = host.hostId ?? deps.learnedHostId?.(host.id) ?? null;
if (!hostId || deps.replyRoute?.(hostId)?.id !== host.id) return null;
return {
step: "reply-route",
status: "fail",
detail: `replies addressed @${hostId} resolve to '${host.id}', so they fail while it is unreachable`,
fix: `if another entry reaches this machine, delete '${host.id}' from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any), then run rig host doctor on that entry; otherwise restore this entry's connectivity`,
};
}
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
@@ -114,6 +155,8 @@ function defaultDoctorDeps(): DoctorDeps {
sock.on("error", () => { sock.destroy(); resolve("closed"); });
}),
learnHostBinding: (alias, observedHostId, observedUrl) => recordHostObservation({ alias, observedHostId, observedUrl }),
replyRoute: replyRouteFromDisk,
learnedHostId: (alias) => loadHostBindings().bindings[alias]?.hostId ?? null,
};
}
@@ -163,6 +206,8 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
});
const routeRow = reach.failedStep === "ssh-unreachable" ? replyRouteThroughDeadEntryRow(host, deps) : null;
if (routeRow) rows.push(routeRow);
return rows; // later legs are meaningless without the shell
}
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
@@ -255,20 +300,28 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
const parsed = JSON.parse(health.body) as { selfHostId?: unknown };
if (typeof parsed.selfHostId === "string" && parsed.selfHostId.length > 0) {
const obs = deps.learnHostBinding(host.id, parsed.selfHostId, (host as HttpHostEntry).url);
rows.push(obs.outcome === "conflict"
? {
step: "host-identity-binding",
status: "fail",
detail: describeBindingConflict(host.id, obs.binding),
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
}
: {
step: "host-identity-binding",
status: "pass",
detail: obs.outcome === "bound"
? `learned host identity '${obs.binding.hostId}' (first contact) — reply hints ending @${obs.binding.hostId} now resolve to '${host.id}'`
: `host identity '${obs.binding.hostId}' confirmed`,
});
if (obs.outcome === "conflict") {
rows.push({
step: "host-identity-binding",
status: "fail",
detail: describeBindingConflict(host.id, obs.binding),
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
});
} else {
// Ask the route rig send takes rather than assume it: an entry listed earlier in
// hosts.yaml with the same identity keeps the replies, even when it no longer answers.
const id = obs.binding.hostId;
const winner = deps.replyRoute?.(id) ?? null;
const routedHere = winner?.id === host.id;
rows.push({
step: "host-identity-binding",
status: "pass",
detail: obs.outcome === "bound"
? `learned host identity '${id}' (first contact)${routedHere ? ` — reply hints ending @${id} now resolve to '${host.id}'` : ""}`
: `host identity '${id}' confirmed`,
});
if (winner && !routedHere) rows.push(replyRouteElsewhereRow(host, id, winner));
}
}
} catch {
// body not JSON — an older or non-daemon endpoint; learning is skipped, nothing breaks.
@@ -276,6 +329,8 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
}
} catch (err) {
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
const routeRow = replyRouteThroughDeadEntryRow(host, deps);
if (routeRow) rows.push(routeRow);
return rows;
}
rows.push({
@@ -481,6 +536,21 @@ function authPointer(h: HostEntry): string {
return h.bearer_env ? `env:${h.bearer_env}` : h.bearer_file ? `file:${h.bearer_file}` : "—";
}
function printHostNextSteps(host: HostEntry): void {
console.log(`Verify it: rig host doctor ${host.id}`);
if (host.transport !== "http") {
console.log("Remote FEED requires an HTTP host entry. Pair its daemon URL with: rig host pair <url>");
return;
}
const key = `feed.subscriptions.${host.id}.enabled`;
if (!parseFeedHostSubscriptionKey(key)) {
console.log(`For remote FEED, register this host with a different id: '${host.id}' is reserved by feed settings.`);
return;
}
console.log("Remote FEED is off by default. To include this host's attention items, run locally:");
console.log(` rig config set ${key} true`);
}
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
@@ -516,7 +586,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
return;
}
console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`);
console.log(`Verify it: rig host doctor ${res.entry.id}`);
printHostNextSteps(res.entry);
});
cmd
@@ -786,7 +856,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
return;
}
console.log(`Paired. Host '${id}' registered (${targetBase}, bearer_file ${tokenPath}).`);
console.log(`Verify it: rig host doctor ${id}`);
printHostNextSteps(added.entry);
});
cmd
+61 -14
View File
@@ -1,9 +1,9 @@
import { Command } from "commander";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js";
import { createMcpServer } from "../mcp-server.js";
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrantServiceHeader, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -23,24 +23,46 @@ export function mcpCommand(depsOverride?: StatusDeps): Command {
.option("--port <port>", "Daemon port override")
.option("--http", "Serve over Streamable HTTP at /mcp instead of stdio")
.option("--bind <host:port>", "With --http: where to listen (default 127.0.0.1:7437). Beyond loopback needs a grant (rig mcp token add)")
.option("--grant-service <url>", "With --http: check each request's key with the grant service at this URL instead of the token file")
.option("--grant-service-header-file <path>", "With --grant-service: a file holding one 'Name: value' header to send with each grant request")
.option("--oauth-resource <url>", "With --grant-service and --oauth-issuer: the public https URL clients reach this server as (its path ends in /mcp), for OAuth discovery")
.option("--oauth-issuer <url>", "With --oauth-resource: the OAuth authorization server that issues the keys --grant-service checks")
.addHelpText("after", `
Both MCP protocol revisions are served, over stdio and --http alike: clients on 2026-07-28 (which may start with
server/discover) and clients that open with 2025's initialize handshake.
With --http and no token file, the server stays on loopback and acts as OPENRIG_SESSION_NAME, declared and not
authenticated, like stdio. Once a grant exists (rig mcp token add), every request needs its bearer credential, loopback
included, and each caller acts as its grant's seat within its rigs and roots. Add a grant before reaching the port
through a tunnel or proxy: they arrive on loopback too. If the token file then goes missing or can't be read, every
request is refused; serving unauthenticated again takes a restart with no token file. TLS comes from the tunnel or
proxy, not from this server.`)
.action(async (opts: { port?: string; http?: boolean; bind?: string }) => {
proxy, not from this server.
With --grant-service, every request needs a key the grant service issued, checked with that service on each request
for this daemon's instance (its self id); the token file isn't read. A caller acts as the seat its grant names, which
must already exist here (created is enough; it needn't have been launched), and reaches only what the grant names. If
the grant service can't answer, the request is refused.
With --grant-service, --oauth-resource and --oauth-issuer (both or neither) add OAuth discovery (RFC 9728) for clients
that sign in with OAuth. The resource is the public URL this server is reached as, and the issuer is the authorization
server that issues the keys --grant-service checks. GET /.well-known/oauth-protected-resource<resource path> then answers
with both, exactly as given, without a credential or a call to the grant service, and every 401 points there. Both must
be https with no userinfo, query or fragment, written as a URL writes them (a lowercase host, no default port). /mcp
stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; oauthResource?: string; oauthIssuer?: string }) => {
const deps = getDepsF();
let daemonUrl: string;
if (opts.port) {
const daemonPort = parseInt(opts.port, 10);
if (isNaN(daemonPort)) {
if (Number.isNaN(daemonPort)) {
console.error("Invalid port number");
process.exitCode = 1;
return;
}
if (daemonPort < 1 || daemonPort > 65535) {
console.error(`Warning: daemon port ${daemonPort} is outside [1, 65535]; MCP will start, but daemon tool calls may fail. Check --port.`);
}
daemonUrl = `http://127.0.0.1:${daemonPort}`;
} else {
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -48,29 +70,48 @@ proxy, not from this server.`)
daemonUrl = getDaemonUrl(status);
}
if (!opts.http && (opts.grantService || opts.grantServiceHeaderFile)) {
console.error("--grant-service and --grant-service-header-file need --http");
process.exitCode = 1;
return;
}
if (opts.grantServiceHeaderFile && !opts.grantService) {
console.error("--grant-service-header-file needs --grant-service");
process.exitCode = 1;
return;
}
if ((opts.oauthResource === undefined) !== (opts.oauthIssuer === undefined)) {
console.error("--oauth-resource and --oauth-issuer go together: give both, or neither");
process.exitCode = 1;
return;
}
const oauth = opts.oauthResource !== undefined && opts.oauthIssuer !== undefined ? { resource: opts.oauthResource, issuer: opts.oauthIssuer } : undefined;
if (oauth && !(opts.http && opts.grantService)) {
console.error("--oauth-resource and --oauth-issuer need --http --grant-service");
process.exitCode = 1;
return;
}
if (opts.http) {
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437");
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth);
return;
}
const client = deps.clientFactory(daemonUrl);
const server = createMcpServer(client);
const transport = new StdioServerTransport();
let finish!: () => void;
const closed = new Promise<void>((resolve) => { finish = resolve; });
server.server.onclose = finish;
process.stdin.once("end", finish);
process.once("SIGINT", finish);
process.once("SIGTERM", finish);
// Serves the protocol revision the client opens with: 2026-07-28 per request, or 2025's initialize handshake.
const served = serveStdio(() => createMcpServer(client), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
try {
// EOF is a normal stdio disconnect, not an unresolved top-level await.
await server.connect(transport);
if (process.stdin.readableEnded) finish();
await closed;
} finally {
process.stdin.removeListener("end", finish);
process.removeListener("SIGINT", finish);
process.removeListener("SIGTERM", finish);
await server.close();
await served.close();
}
});
@@ -127,7 +168,8 @@ proxy, not from this server.`)
return cmd;
}
async function serveHttp(daemonUrl: string, bind: string): Promise<void> {
async function serveHttp(daemonUrl: string, bind: string, grantService?: string, grantServiceHeaderFile?: string,
oauth?: { resource: string; issuer: string }): Promise<void> {
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(bind);
if (!match) {
console.error(`--bind takes host:port (for example 127.0.0.1:7437 or [::1]:7437), not ${bind}`);
@@ -138,16 +180,21 @@ async function serveHttp(daemonUrl: string, bind: string): Promise<void> {
const tokenFile = mcpTokenFilePath();
let served;
try {
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile });
const header = grantServiceHeaderFile ? readGrantServiceHeader(grantServiceHeaderFile) : undefined;
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile,
...(grantService ? { grantService: { url: grantService, ...(header ? { header } : {}) } } : {}), ...(oauth ? { oauth } : {}) });
} catch (err) {
console.error(err instanceof McpHttpConfigError ? err.message : `Can't serve on ${bind}: ${(err as Error).message}`);
process.exitCode = 1;
return;
}
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
console.error(served.authenticated
console.error(grantService
? `OpenRig MCP over HTTP at ${served.url}: each request needs a key from the grant service, checked with ${grantService}.`
: served.authenticated
? `OpenRig MCP over HTTP at ${served.url}: each request needs a bearer credential from ${tokenFile}.`
: `OpenRig MCP over HTTP at ${served.url}: no grants, so loopback only, acting as ${seat || "no seat"}, declared and not authenticated. Add a grant (rig mcp token add) before reaching it through a tunnel or proxy.`);
if (oauth) console.error(`OAuth discovery names ${oauth.resource}, issued by ${oauth.issuer}.`);
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
+42 -13
View File
@@ -144,7 +144,7 @@ function terminalWakeOutcome(wakeResult: string | null): TerminalDeliveryResult[
/** Only these known states mean "not delivered yet, nothing went wrong". An unknown value never gets that label. */
function isKnownUnconfirmed(wakeResult: string | null, outcome: TerminalDeliveryResult["outcome"]): boolean {
if (outcome === "not-requested" || wakeResult === null || wakeResult === "delivered-ack-pending") return true;
return wakeResult.startsWith("indeterminate:") || wakeResult.startsWith("retained:");
return wakeResult.startsWith("indeterminate:") || wakeResult.startsWith("retained:") || wakeResult.startsWith("wake-accepted:");
}
function terminalDeliveryResult(
@@ -165,7 +165,9 @@ function terminalDeliveryResult(
"claimed": "Terminal receipt unconfirmed, but the seat has claimed the item (claimed).",
"retained": `The wake is held (${wakeResult}): nothing was typed into the pane. A held create wake is not retried automatically; inspect it with rig seat held-messages ${seat}.`,
"failed": `The wake failed (${wakeResult}).`,
"indeterminate": `Terminal receipt unconfirmed (${wakeResult}).`,
"indeterminate": wakeResult?.startsWith("wake-accepted:")
? `The member's trigger accepted the ring (${wakeResult}). That isn't pickup: its claim is.`
: `Terminal receipt unconfirmed (${wakeResult}).`,
"still-pending": waitedMs === null ? "No terminal receipt was recorded before the claim." : `No terminal receipt was recorded within ${waitedMs}ms.`,
"not-requested": "No terminal wake was requested (--no-nudge).",
};
@@ -513,13 +515,10 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
return null;
}
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
* was sent with, from the daemon that holds it: this one for a local row, the destination's registered daemon for a
* cross-host one. Only a row that is this create counts as committed; anything else leaves the outcome unknown. It never
* sends the create again. */
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
const remote = remoteHostOf(hostId);
const where = remote ? ` on host '${remote}'` : "";
/** Both reconciliation and --verify read the daemon that holds the row, with its own bearer. */
async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: string | undefined) {
let remote = remoteHostOf(hostId);
if (remote && await daemonSelfHostId(client) === remote) remote = undefined;
let reader = client;
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
if (remote) {
@@ -527,15 +526,45 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
const match = registry.ok ? resolveHost(registry.registry, remote, loadHostBindings().bindings) : undefined;
if (!match?.ok || match.host.transport !== "http") {
const why = match?.ok ? "its entry in this CLI's host registry is SSH, not a daemon URL" : "it isn't in this CLI's host registry";
return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${why}).` };
return { error: `from host '${remote}' (${why})` };
}
const bearer = resolveRemoteBearer(match.host);
if (!bearer.ok) return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${bearer.error}).` };
if (!bearer.ok) return { error: `from host '${remote}' (${bearer.error})` };
reader = deps.clientFactory(match.host.url);
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
}
return { reader, options };
}
/** A successful forward isn't a delivery receipt. Never accept a different remote row as this create's receipt. */
function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Pick<DaemonClient, "get"> {
if (!remoteHostOf(hostId)) return client;
let target: ReturnType<typeof queueReadTarget> | undefined;
let source: ReturnType<typeof storedSourceOf> | undefined;
return {
get: async <T>(path: string) => {
const selected = await (target ??= queueReadTarget(deps, client, hostId));
if ("error" in selected) throw new Error(`receipt could not be read ${selected.error}`);
const response = await selected.reader.get<T>(path, selected.options);
const row = response.data as Record<string, unknown> | undefined;
if (response.status === 200 && (!row || row.qitemId !== qitemId
|| notThisCreate(row, sent, await (source ??= storedSourceOf(client, sent))) !== null)) {
throw new Error("receipt row does not match the created qitem");
}
return response;
},
};
}
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
* was sent with, from the daemon that holds it. Only this create's row counts; it never sends the create again. */
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
const remote = remoteHostOf(hostId);
const where = remote ? ` on host '${remote}'` : "";
const target = await queueReadTarget(deps, client, hostId);
if ("error" in target) return { row: null, note: `This CLI couldn't read the row back ${target.error}.` };
try {
const res = await reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
const res = await target.reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, target.options);
if (res.status === 200 && res.data?.qitemId === qitemId) {
const differs = notThisCreate(res.data, sent, await storedSourceOf(client, sent));
if (differs === "unread") {
@@ -1077,7 +1106,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
if (opts.verify && status < 400) {
const createdId = typeof created.qitemId === "string" ? created.qitemId : null;
const delivery = createdId
? await waitForDeliveryOutcome(client, createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
? await waitForDeliveryOutcome(createReceiptReader(deps, client, hostResolved.hostId, qitemId, sent), createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
: unreadableDeliveryResult(null, hostResolved.destination, {}, "create response did not include a qitem id; delivery cannot be correlated");
await printWriteResult(client, opts.json ?? false, { ...created, qitemId: createdId, persisted: true, delivery }, status, receipt);
return;
+3 -4
View File
@@ -180,8 +180,7 @@ function emitOperatingPosture(value: OperatingPosture | undefined): void {
* scope rule applies at invocation, NOT only at raw HTTP URL parsing.
*
* Reject:
* - explicit qualifier with global_host (BLOCKING re-verify finding from
* guard qitem-20260518044650): operators who type
* - explicit qualifier with global_host (a review finding): operators who type
* `--scope global_host --qualifier <id>` get an error and the daemon
* is never contacted. The CLI does NOT silently drop the qualifier.
* - missing qualifier for any non-global scope.
@@ -269,7 +268,7 @@ export function rigModeCommand(depsOverride?: RigModeDeps): Command {
return;
}
// BLOCKING re-verify-2 (qitem-20260518045300): when --scope is
// Review finding: when --scope is
// explicit, validate scope + qualifier LOCALLY before touching
// the daemon. Otherwise an invalid operator invocation
// (e.g. `--scope global_host --qualifier X`) surfaces as
@@ -542,7 +541,7 @@ export function rigModeCommand(depsOverride?: RigModeDeps): Command {
return;
}
const scope = scopeArg as Scope;
// BLOCKING re-verify (qitem-20260518044650): never silently drop
// Review finding: never silently drop
// an explicit operator-supplied qualifier on global_host on unset.
// Same hazard class as set; same shared helper.
const normalized = normalizeScopeQualifier(scope, qualifierArg);
+19 -60
View File
@@ -1,81 +1,32 @@
import { modelObservation, unknownModelObservation, formatModelObservation } from "../lib/model-observation.js";
import { Command } from "commander";
import { closeSync, constants, fstatSync, openSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { ConfigStore } from "../config-store.js";
import { mcpPresenceFilePath, readPresence } from "../mcp-presence.js";
import { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake } from "@openrig/daemon/rosters";
// One roster parser, shared with the daemon (a hosted member's wake route reads the same files).
export { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake };
type Row = Record<string, unknown>;
type Reference = { seat: string; host: string };
export interface RosterMember extends Reference {
capabilities: string[];
engagement: string[];
use_when: string;
why: string;
caveat?: string;
}
export interface Roster {
version: 1;
id: string;
name: string;
purpose: string;
curator: Reference;
updated_at: string;
members: RosterMember[];
}
export interface RosterInventory {
readAt: string;
nodes: Row[];
sources: Row[];
/** This host's id, from the local daemon's own nodes; null when it has none to read it from. */
localHostId?: string | null;
}
const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
const text = (v: unknown): string | null => typeof v === "string" && v.trim() ? v : null;
const strings = (v: unknown): v is string[] => Array.isArray(v) && v.every(x => text(x) !== null);
const reference = (v: unknown): v is Row & Reference => object(v) && text(v.seat) !== null && text(v.host) !== null;
/** Select authored fields only: a file cannot supply runtime or observed-model facts. */
export function parseRoster(value: unknown): Roster {
if (!object(value) || value.version !== 1 || !reference(value.curator) ||
![value.id, value.name, value.purpose, value.updated_at].every(v => text(v) !== null) || !Array.isArray(value.members)) {
throw new Error("Expected a version 1 roster with id, name, purpose, curator, updated_at and members");
}
const members = value.members.map((m): RosterMember => {
if (!reference(m) || !object(m) || !strings(m.capabilities) || !strings(m.engagement) ||
text(m.use_when) === null || text(m.why) === null || (m.caveat !== undefined && typeof m.caveat !== "string")) {
throw new Error("Each member needs seat, host, capabilities, engagement, use_when and why (optional caveat)");
}
return { seat: m.seat, host: m.host, capabilities: m.capabilities, engagement: m.engagement,
use_when: m.use_when as string, why: m.why as string, ...(m.caveat === undefined ? {} : { caveat: m.caveat as string }) };
});
return { version: 1, id: value.id as string, name: value.name as string, purpose: value.purpose as string,
curator: { seat: value.curator.seat, host: value.curator.host }, updated_at: value.updated_at as string, members };
}
export function readRosters(folder: string): { rosters: Roster[]; warnings: string[] } {
const rosters: Roster[] = [], warnings: string[] = [];
let names: string[];
try { names = readdirSync(folder).filter(n => n.endsWith(".json")).sort(); }
catch (error) { return { rosters, warnings: [`Cannot read roster folder ${folder}: ${(error as Error).message}`] }; }
for (const name of names) {
let fd: number | undefined;
try {
// Inspect the opened target before reading; NONBLOCK also handles a FIFO
// without waiting for a writer. Regular-file symlinks remain readable.
fd = openSync(join(folder, name), constants.O_RDONLY | constants.O_NONBLOCK);
if (!fstatSync(fd).isFile()) throw new Error("Not a regular file; skipped");
rosters.push(parseRoster(JSON.parse(readFileSync(fd, "utf8"))));
} catch (error) { warnings.push(`${name}: ${(error as Error).message}`); }
finally { if (fd !== undefined) closeSync(fd); }
}
return { rosters, warnings };
}
// Reuse ps's local routing and observation-only HTTP fan-out, including its partial failures.
// Explicit fields keep native transcripts, resume tokens and fresh pane captures out of this read.
const fields = "rigId,logicalId,canonicalSessionName,hostSelfId,runtime,model,modelObservation,sessionStatus,agentActivity";
export async function readRosterInventory(): Promise<RosterInventory> {
const nodes: Row[] = [], sources: Row[] = [];
let localHostId: string | null = null;
for (const remote of [false, true]) {
const argv = [fileURLToPath(new URL("../index.js", import.meta.url)), "ps", "--no-cleanup", "--nodes", "-A", "--json", "--fields", fields,
...(remote ? ["--all-hosts"] : [])];
@@ -89,6 +40,7 @@ export async function readRosterInventory(): Promise<RosterInventory> {
const entries = Array.isArray(data) ? data : object(data) ? data.entries ?? data.items : null;
if (!Array.isArray(entries)) throw new Error("ps returned no node array");
nodes.push(...entries.filter(object));
if (!remote) localHostId = entries.filter(object).map((n) => text(n.hostSelfId)).find((id) => id !== null) ?? null;
sources.push({ scope, status: result.error || result.stderr.trim() ? "partial" : "ok",
...(object(data) && Array.isArray(data.hosts) ? { hosts: data.hosts } : {}),
...(result.stderr.trim() ? { warning: result.stderr.trim() } : {}),
@@ -97,7 +49,7 @@ export async function readRosterInventory(): Promise<RosterInventory> {
sources.push({ scope, status: "unavailable", error: result.stderr.trim() || (error as Error).message });
}
}
return { readAt: new Date().toISOString(), nodes, sources };
return { readAt: new Date().toISOString(), nodes, sources, localHostId };
}
export function enrichRosterMember(member: RosterMember, inventory: RosterInventory) {
@@ -114,7 +66,9 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
: unknownModelObservation(matches.length ? "conflicting-observations" : "host-or-seat-unavailable");
return { ...member, live: {
status: !node ? "unknown" : current ? "reported" : "stale",
reason: !node ? (matches.length ? "Conflicting node observations" : "No exact hostSelfId and seat match; check observation gaps")
reason: !node ? (matches.length ? "Conflicting node observations"
: member.hosted ? "A hosted member: it has no pane, and OpenRig doesn't observe it between calls"
: "No exact hostSelfId and seat match; check observation gaps")
: current ? "Registry, activity and labelled last model observation; not a promise about the next turn" : "Seat is not reported running; current runtime and model are unknown",
runtime: current ? text(node?.runtime) : null,
configuredModel: current ? text(node?.model) : null,
@@ -122,6 +76,11 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
modelObservation: observation,
sessionStatus,
agentActivity: { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) },
// A hosted member's last call to this host's MCP server, for a roster row on this host only: another host's member,
// or one whose host can't be told, is unknown (null), never this host's record for a same-named seat. Its last ring
// is in rig_team.
...(member.hosted ? { lastCallAt: inventory.localHostId && member.host === inventory.localHostId
? readPresence(mcpPresenceFilePath())[member.seat]?.lastCallAt ?? null : null } : {}),
} };
}
@@ -163,7 +122,7 @@ export function rosterCommand(deps: Deps = {}): Command {
else {
const members = (verb === "show" ? (result.roster as Row).members : result.matches) as Array<ReturnType<typeof enrichRosterMember> & { roster?: { id: string } }>;
if (verb === "show") { const r = result.roster as Roster; console.log(`${r.name} — ${r.purpose}\nCurator: ${r.curator.seat} (${r.curator.host})`); }
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}\n ${m.live.reason}`);
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}` : ""}\n ${m.live.reason}`);
if (!members.length) console.log("No matching members.");
const obs = result.observations as { sources: Row[] };
for (const source of obs.sources) if (source.status !== "ok") console.error(`Observation gap (${source.scope}): ${source.warning ?? source.error ?? source.status}`);
+3 -3
View File
@@ -1068,7 +1068,7 @@ function buildResolveNotesCommand(): Command {
function buildAuditCommand(): Command {
return new Command("audit")
.description("Read-only scope audit: flag scope findings and show the advisory dependency graph")
.description("Read-only scope audit: flag scope structure findings and show the advisory dependency graph. It doesn't read proof readiness: use rig proof show")
.requiredOption("--mission <name>", "Mission to audit")
.option("--json", "Machine-readable JSON output")
.action(async (opts, command) => {
@@ -1076,7 +1076,7 @@ function buildAuditCommand(): Command {
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const { classifyScopeItem } = await import("../lib/scope/scope-audit.js");
const { classifyScopeItem, SLICE_DIRECTORY_NAME } = await import("../lib/scope/scope-audit.js");
const missionName = opts.mission as string;
const missionDir = path.join(missionsRoot, missionName);
@@ -1215,7 +1215,7 @@ function buildAuditCommand(): Command {
: null,
});
if (!/^\d{2,}-/.test(entry)) {
if (!SLICE_DIRECTORY_NAME.test(entry)) {
sliceResult.findings.push({
kind: "id_convention_violation",
severity: "high",
+36
View File
@@ -160,6 +160,8 @@ interface SeatHandoverMutationResult {
sourceOutcome?:
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
restorePacket?: { status: "unconfirmed"; reason: string };
sideEffects: {
departingSessionKilled: false;
startupContextDelivered: boolean;
@@ -339,6 +341,11 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log("The captured startup context (restore packet) was delivered to the successor.");
}
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
} else if (result.restorePacket?.status === "unconfirmed") {
// The packet was pasted and Enter pressed: the successor may already be working on it, so don't send it again.
console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
console.log("Check the successor's screen before sending the context again; it may already be working on it.");
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
} else if (result.source.mode === "fork") {
console.log("No packet delivery: fork context rides the native conversation itself.");
} else {
@@ -388,6 +395,35 @@ export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promis
console.log(JSON.stringify(result.data, null, json ? undefined : 2));
if (result.status >= 400) process.exitCode = result.status >= 500 ? 2 : 1;
};
cmd.command("history")
.requiredOption("--rig <id>", "Exact rig ID")
.requiredOption("--node-id <id>", "Exact durable node ID")
.option("--snapshot-id <id>", "Validate a reference returned by this list")
.option("--history-session-id <id>", "Validate a reference returned by this list")
.option("--json", "Print the daemon response as JSON")
.description("List or validate captured histories eligible for a resume attempt (read-only)")
.action(async (opts: { rig: string; nodeId: string; snapshotId?: string; historySessionId?: string; json?: boolean }) => {
if (!!opts.snapshotId !== !!opts.historySessionId) {
console.error("Supply both --snapshot-id and --history-session-id to validate a history.");
process.exitCode = 1;
return;
}
const deps = getDeps(); const daemon = await getDaemonStatus(deps.lifecycleDeps, { cleanupStaleState: false });
if (!daemonStatusGuard(daemon)) return;
const client = deps.clientFactory(getDaemonUrl(daemon));
const path = `/api/startup/${encodeURIComponent(opts.rig)}/nodes/${encodeURIComponent(opts.nodeId)}/history`;
const response = opts.snapshotId && opts.historySessionId
? await client.post<Record<string, unknown>>(`${path}/validate`, { snapshotId: opts.snapshotId, historySessionId: opts.historySessionId }, { headers: terminalAuthHeaders() })
: await client.get<Record<string, unknown>>(path, { headers: terminalAuthHeaders() });
if (opts.json || response.status >= 400) console.log(JSON.stringify(response.data, null, 2));
else {
const body = response.data as { snapshotId?: string; history?: { historySessionId: string; resumeType: string; tokenState: string }; histories?: Array<{ historySessionId: string; resumeType: string; tokenState: string }> };
console.log(`Current snapshot: ${body.snapshotId ?? "none"}`);
for (const item of body.history ? [body.history] : body.histories ?? []) console.log(`${item.historySessionId} ${item.resumeType} token: ${item.tokenState}`);
if (!body.history && !body.histories?.length) console.log("No eligible captured histories.");
}
if (response.status >= 400) process.exitCode = response.status >= 500 ? 2 : 1;
});
cmd.command("set-typing-guard").argument("<seat>")
.option("--enabled <boolean>", "Compatibility: true selects hold; false selects off")
.option("--mode <mode>", "off, draft-aware, openrig-urgent, urgent-only, sends-only or hold; choose this or --enabled")
+23 -27
View File
@@ -9,17 +9,17 @@ import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
import { resolveCrossHostTarget } from "../cross-host-target.js";
import { requestMayHaveArrived, runRemoteHttpOp } from "../remote-host-ops.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { readOpenRigUrl } from "../openrig-compat.js";
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
import { COMPOSER_PROMPT_CLASS } from "@openrig/daemon/composer-prompts";
import { resolveContextRef, walkSizedWarning } from "../context-resolve.js";
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
// A cross-host send waits for the remote to deliver, and the remote has no fixed deadline of its own (its tmux
// calls run without a timeout), so no client budget is exact. This one sits well above a normal send, and above
// fetch's 10 s connect timeout, so a host that never accepts the connection still reads as unreachable. A send
// that outlasts it reads as unconfirmed, never as failed.
const CROSS_HOST_SEND_TIMEOUT_MS = 30_000;
// The reply follows the guarded paste/Enter (and optional verification), not acceptance of the request.
// Successful sends can exceed the ordinary client's 5 s read budget. Keep a finite response wait on both
// local and remote paths; a longer send or lost response remains unconfirmed and is never retried here.
const SEND_RESPONSE_TIMEOUT_MS = 60_000;
const UNCONFIRMED_SEND_GUIDANCE = "A busy agent may hold delivered input off-screen, so absence from a pane capture does not prove non-delivery. " +
"Wait for recipient acknowledgment or reconcile with the recipient before resending; another send or queue item risks duplicate work.";
/** The shared composer glyph set (Claude `❯`, Codex `›`, Codex 0.153 `»`) from the daemon's
* composer-prompts surface, so the CLI staged check can never disagree with the daemon guard. */
@@ -111,7 +111,7 @@ export function wrapSendBody(
* fact / consequence / action shape (`daemon-lifecycle.ts:61-73`).
*
* Deliberately NOT `daemonNotRunningError()`: that helper's text ("Daemon not
* running." + restart advice) is the probe-derived claim qitem-c113bd41
* running." + restart advice) is the probe-derived claim that was
* removed. The SHAPE is reused; the TEXT is not.
*
* A connection error can follow delivery; only positive no-connect evidence
@@ -140,8 +140,7 @@ function printTransportFailure(err: DaemonConnectionError, opts?: { json?: boole
? "Delivery UNCONFIRMED — the daemon may have received and delivered the message."
: "The message was not sent.";
const action = outcomeUnknown
? "Reconcile by EFFECT before any resend: check the pane/target for the message (rig capture <session>). " +
"A resend without checking risks a duplicate. Then 'rig daemon status' for the probe picture."
? "Reconcile by EFFECT before any resend: check the target (rig capture <session>). " + UNCONFIRMED_SEND_GUIDANCE
: "Inspect the configured target with 'rig status'; a failed health probe does not prove the daemon is stopped. " +
"If the target is wrong, check OPENRIG_URL / RIGGED_URL or daemon.host + daemon.port. " +
"If the daemon is confirmed stopped, run 'rig daemon start'.";
@@ -169,7 +168,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
return true;
}
/** qitem-c113bd41 — the LOCAL send target. The status probe is advisory,
/** The LOCAL send target. The status probe is advisory,
* never authoritative: a busy/wedged daemon fails the probe while the
* transport would succeed (the false-daemon-down incident). Resolution:
* the configured env alias FIRST (exact string, custom port preserved;
@@ -187,7 +186,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
* keeps the single-seat and fan-out callers from having to sequence it
* identically in two places. */
async function resolveLocalDaemonUrl(deps: SendDeps): Promise<string> {
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const envUrl = readOpenRigUrl();
if (envUrl) return envUrl;
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state === "running" && status.port !== undefined) return getDaemonUrl(status);
@@ -283,7 +282,11 @@ determined (unknown / missing / stale telemetry) the send PROCEEDS with an
advisory note — telemetry is advisory, not authority over whether agents can
communicate. Use --wait-for-idle to send only after explicit idle evidence. Use
--verify to confirm the message appeared in the pane only; it is not agent
acknowledgement. With transport.verify_input_box on, a Claude Code or Codex send
acknowledgement. Local and HTTP sends wait up to 60 s for the response, plus any
--wait-for-idle duration. A timeout or missing pane text does not prove the
message was not delivered: a busy agent can hold input off-screen. Reconcile
with the recipient before resending or filing the same work as a queue item.
With transport.verify_input_box on, a Claude Code or Codex send
reads verified only when the seat was proven idle before the paste (its screen
matched an idle layout recorded from Claude Code 2.1.296 or Codex 0.160.0; a
screen that names another version reads unconfirmed, and a matching layout that
@@ -482,7 +485,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
return;
}
// qitem-c113bd41 — the status probe is ADVISORY (target discovery
// The status probe is ADVISORY (target discovery
// only); the actual transport is authoritative. A probe-timeout or
// running/unhealthy verdict no longer refuses the send. ff13bcdf —
// the resolver takes that probe lazily, and skips it entirely when an
@@ -880,7 +883,7 @@ async function runCrossHostSend(
* sender verbatim — honest provenance; unknown on the remote it degrades to
* the shipped non-blocking advisory, never a refusal. `--verify` prints the
* REMOTE route's verified/outcome verbatim (remote-authoritative, never
* locally synthesized). Deadline: CROSS_HOST_SEND_TIMEOUT_MS, plus waitForIdleMs
* locally synthesized). Deadline: SEND_RESPONSE_TIMEOUT_MS, plus waitForIdleMs
* when --wait-for-idle. A send that may have arrived reads as unconfirmed.
*
* Auth posture (named, v0): runRemoteHttpOp presents the REGISTRY bearer
@@ -918,7 +921,7 @@ async function runHttpHostSend(
session, text: outboundText, exactText: raw ? undefined : text, verify: opts.verify, force: opts.force, waitForIdleMs,
dangerouslyInteract: opts.dangerouslyInteract, typeWithoutChecking: opts.typeWithoutChecking, reason: opts.reason, actorSession: senderSession ?? null,
...(opts.urgent ? { urgent: true } : {}),
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + CROSS_HOST_SEND_TIMEOUT_MS });
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + SEND_RESPONSE_TIMEOUT_MS });
// A send that may have reached the remote is unconfirmed, not unreachable: it may have been delivered, and a
// resend without checking risks a duplicate.
@@ -926,7 +929,7 @@ async function runHttpHostSend(
const shown = unconfirmed ? { ...result, failedStep: "remote-outcome-unknown" as const } : result;
const unconfirmedGuidance = {
consequence: "Delivery UNCONFIRMED — the remote daemon may have received and delivered the message.",
action: `Check the target before any resend: rig capture ${session} --host ${host.id}. A resend without checking risks a duplicate.`,
action: `Check the target before any resend: rig capture ${session} --host ${host.id}. ${UNCONFIRMED_SEND_GUIDANCE}`,
};
if (opts.json) {
@@ -1019,7 +1022,7 @@ async function runFanOutSend(params: {
}): Promise<void> {
const { toList, pod, rig, message, opts, deps, seatSender } = params;
// qitem-c113bd41 — same advisory-probe/transport-authoritative contract
// The same advisory-probe/transport-authoritative contract
// as the single-seat path, including ff13bcdf's lazy probe (see
// resolveLocalDaemonUrl).
const client = deps.clientFactory(await resolveLocalDaemonUrl(deps));
@@ -1162,18 +1165,11 @@ function parseWaitForIdleMs(value: string | undefined): number | undefined | nul
return Math.ceil(seconds * 1000);
}
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
if (waitForIdleMs === undefined) return undefined;
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
}
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs?: number; headers?: Record<string, string> } | undefined {
const waitOptions = waitForIdleRequestOptions(waitForIdleMs);
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs: number; headers?: Record<string, string> } {
const headers = terminalAuthHeaders();
const hasHeaders = Object.keys(headers).length > 0;
if (!waitOptions && !hasHeaders) return undefined;
return {
...(waitOptions ?? {}),
timeoutMs: (waitForIdleMs ?? 0) + SEND_RESPONSE_TIMEOUT_MS,
...(hasHeaders ? { headers } : {}),
};
}
+10 -5
View File
@@ -10,6 +10,7 @@ import {
getDaemonUrl,
startDaemon,
waitForKernelReady,
skippedKernelMessage,
type LifecycleDeps, daemonStatusGuard } from "../daemon-lifecycle.js";
import { shouldAutoStartDaemon } from "../daemon-reachability.js";
import { freshSeatCommand, launchFreshSeat } from "../fresh-seat-command.js";
@@ -190,6 +191,13 @@ Examples:
When the daemon is restoring a kernel a reboot left down, rig start goes on to
your other rigs. Exit 0 then does not mean the kernel is ready (check rig
status); a restore that returned without a ready kernel exits 1.
--last restores a rig unless every seat it launched was stopped on purpose. A
rig taken down with rig down before OpenRig 0.6.12, or whose seats exited or
were lost without rig down, counts as last running: run rig down <rig> once
(its id when names are shared) and --last keeps it down. --last never selects
the kernel; under --no-kernel its automatic restore is skipped too, so bring it
back with rig up kernel --existing.
`);
const getDepsF = (): StartDeps =>
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -286,11 +294,8 @@ status); a restore that returned without a ready kernel exits 1.
if (!opts.json) console.log("Continuing with your other rigs.");
} else if (!kernelResult.ok) {
if (kernelResult.kernelState === "skipped") {
// The daemon also skips its boot when a kernel rig already exists (kernel-boot.ts).
// That rig need not be running, after a reboot it is not, and this command does not restore it.
if (!opts.json) console.log(kernelResult.detail === "kernel rig already managed"
? "Kernel auto-boot skipped (kernel rig already managed). If its seats are not running, for example after a reboot, bring it back with: rig up kernel --existing"
: "Kernel auto-boot skipped (--no-kernel or test mode), including the operator that helps you start a team. To start it later, run rig, set up or select kernel, then start its operator seat.");
// This command does not restore a kernel the daemon skipped; say why, and the way back.
if (!opts.json) console.log(skippedKernelMessage(kernelResult.detail));
} else {
const terminalFailure = ["auth_blocked", "spec_missing", "bootstrap_failed", "degraded"].includes(kernelResult.kernelState ?? "");
console.error(`${terminalFailure ? "Kernel failed to start" : "Kernel did not report ready before the deadline"}: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
+4 -1
View File
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { formatDaemonHostForUrl } from "../client.js";
import { readOpenRigUrl } from "../openrig-compat.js";
export interface StartupProofDeps {
env?: NodeJS.ProcessEnv;
@@ -33,7 +34,9 @@ export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoi
const env = deps.env ?? process.env;
const readFile = deps.readFile ?? ((p: string) => fs.readFileSync(p, "utf8"));
const home = first(env["OPENRIG_HOME"], env["RIGGED_HOME"]) ?? path.join(os.homedir(), ".openrig");
let baseUrl = first(env["OPENRIG_URL"], env["RIGGED_URL"]);
// Startup proof has always trimmed these and skipped blank ones; keep that, then pick and
// normalize through the shared reader like every other OPENRIG_URL reader.
let baseUrl = readOpenRigUrl({ OPENRIG_URL: env["OPENRIG_URL"]?.trim(), RIGGED_URL: env["RIGGED_URL"]?.trim() }) ?? null;
let token = first(env["OPENRIG_ACTIVITY_HOOK_TOKEN"], env["RIGGED_ACTIVITY_HOOK_TOKEN"]);
if (!baseUrl || !token) {
+41 -11
View File
@@ -17,22 +17,30 @@
// exit 0 unless the daemon is unreachable.
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { DaemonClient, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { openTerminalWindow, type WindowDeps } from "../terminal-window.js";
export interface TerminalDeps extends StatusDeps { windowDeps?: WindowDeps }
export interface FollowControls {
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[]; suppressionMs?: number;
}
/** The one shared open-result shape (mirrors the daemon `OpenViewResult`). */
export interface OpenViewResult {
workspaceId?: string;
following?: { id: string; enabled: boolean; state: "following" | "empty" | "retained" | "disabled";
maxTiles: number; tiles: Array<{ key: string; seat: string; departedAt?: number; reason?: string }>;
events: string[]; overflow: Array<{ key: string; seat: string; reason: string }>; nextAt?: number };
provider: string;
session?: string;
ok: boolean;
opened: string[];
absent: { seat: string; host: string | null; reason: string }[];
degraded: { seat: string; host: string; reason: string }[];
degraded: { seat: string; host: string; reason: string; code?: "pane_unconfirmed" }[];
pages: number;
error?: string;
code?: string;
@@ -76,14 +84,19 @@ function humanOpen(r: OpenViewResult): string {
? `Terminal window requested. Reused ${target} workspace ${r.reusedWorkspace.id} for view ${JSON.stringify(r.reusedWorkspace.view)}.`
: tiled > 0
? `${r.window ? "Terminal window requested. " : ""}Prepared ${tiled} tile(s) in ${target}${r.pages > 1 ? ` across ${r.pages} page(s)` : ""}.`
: `No tiles opened in ${target}.`,
: `No tiles ${r.degraded.some(d => d.code === "pane_unconfirmed") ? "confirmed" : "opened"} in ${target}.`,
);
if (r.window) lines.push(` Terminal: ${r.window.app} (${r.window.surface}).`);
if (r.space) lines.push(` ${r.provider} session: ${JSON.stringify(r.space.label)} (${r.space.id}).`);
if (r.error && r.code !== "terminal_window_failed") lines.push(` provider: ${r.error}${r.code ? ` (${r.code})` : ""}`);
if (r.following) {
lines.push(` Following: ${r.following.state}; tile limit ${r.following.maxTiles}.`);
for (const tile of r.following.tiles) if (tile.reason) lines.push(` ${tile.seat}: ${tile.reason}`);
for (const hidden of r.following.overflow) lines.push(` ${hidden.seat}: ${hidden.reason}`);
}
for (const seat of r.opened) lines.push(` ● ${seat}`);
for (const a of r.absent) lines.push(` ○ ${a.seat} — absent: ${a.reason}`);
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — skipped (${d.host}): ${d.reason}`);
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — ${d.code === "pane_unconfirmed" ? "unconfirmed" : "skipped"} (${d.host}): ${d.reason}`);
for (const n of r.notes ?? []) lines.push(` note: ${n}`);
return lines.join("\n");
}
@@ -95,7 +108,7 @@ function printOpen(json: boolean, r: OpenViewResult, status: number): void {
} else {
console.log(humanOpen(r));
}
if (status >= 400 || (r.opened.length === 0 && !(r.ok && r.reusedWorkspace))) {
if (status >= 400 || (r.following ? !r.ok : r.opened.length === 0 && !(r.ok && r.reusedWorkspace))) {
process.exitCode = status >= 500 ? 2 : 1;
}
}
@@ -113,27 +126,43 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
cmd
.command("open")
.argument("<view>", "a rig name, mission:<id>, slice:<id>, or a saved-view id")
.argument("<view>", "a rig name, pod:<rig>/<pod>, seat:<rig>/<logical-id>, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
.description("Open a desktop terminal showing the view's live agents as interactive tiles")
.option("--provider <name>", "herdr, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one); tmux requires --window. Inside Rex, the default opens in Rex")
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
.option("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted)")
.option("--expected-plan <id>", "Open only if the view still matches this preview")
.option("--follow", "Keep one qitem:<id> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
.option("--max-tiles <count>", "Following tile limit, 1–256; explicitly raise it to show more members", Number)
.option("--pin <seats...>", "Retain these following tiles after they leave the task")
.option("--unpin <seats...>", "Remove pins from these following tiles")
.option("--suppress <seats...>", "Temporarily hide these following tiles; agents keep running")
.option("--unsuppress <seats...>", "Show these following members again")
.option("--suppression-ms <ms>", "Suppression duration, default 60000, at most 86400000", Number)
.option("--grace-ms <ms>", "Departure grace, default 4000, at most 60000", Number)
.option("--focus-retention-ms <ms>", "Extra focused-reading retention, default 12000, at most 300000", Number)
.option("--json", "JSON output for agents")
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string }) => {
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string;
follow?: boolean; maxTiles?: number; pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[];
suppressionMs?: number; graceMs?: number; focusRetentionMs?: number }) => {
const deps = getDeps();
const session = opts.session?.trim();
const { maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } = opts;
const hasControls = [maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs].some(v => v !== undefined);
const following: FollowControls | undefined = opts.follow !== undefined || hasControls
? { enabled: opts.follow !== false, maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } : undefined;
await withClient(deps, async (client) => {
// Herdr's control socket can answer with no desktop client attached.
// The default requests a desktop; an explicit provider reuses its workspace.
if (opts.window || !opts.provider) {
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session);
if (following?.enabled !== false && (opts.window || !opts.provider)) {
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session, following);
printOpen(opts.json ?? false, result, 200);
return;
}
let expectedPlan = opts.expectedPlan;
if (session !== undefined) {
const preview = await client.get<{ session?: string; planId?: string; error?: string }>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=${encodeURIComponent(opts.provider)}&session=${encodeURIComponent(session)}`);
const preview = await client.get<{ session?: string; planId?: string; error?: string }>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=${encodeURIComponent(opts.provider ?? "herdr")}&session=${encodeURIComponent(session)}`);
if (preview.status >= 400 || preview.data.session !== session || typeof preview.data.planId !== "string") {
printResult(opts.json ?? false, { error: preview.data.error ?? "The daemon did not confirm the requested Herdr session. Update the daemon before opening a named session.", code: "terminal_session_unconfirmed" }, preview.status >= 400 ? preview.status : 409); return;
}
@@ -143,7 +172,8 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
expectedPlan = preview.data.planId;
}
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}), ...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) };
const res = await client.post<OpenViewResult>("/api/terminal/open", body, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
const res = await client.post<OpenViewResult>(following ? "/api/terminal/follow" : "/api/terminal/open",
{ ...body, ...following }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS, ...(following ? { headers: terminalAuthHeaders() } : {}) });
if (session !== undefined && res.status < 400 && res.data?.session !== session) {
printResult(opts.json ?? false, { ...res.data, ok: false, code: "terminal_session_unconfirmed",
error: "The open response did not confirm the requested Herdr session. Its outcome is unknown; inspect that session before retrying." }, 409); return;
+1 -1
View File
@@ -76,7 +76,7 @@ OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN=0 — relaunch it to restore scrollback.
const requestedLines = Number.isNaN(tailLines) || tailLines < 1 ? 50 : tailLines;
const apiPath = useGrep
? `/api/transcripts/${encodeURIComponent(session)}/grep?pattern=${encodeURIComponent(opts.grep!)}`
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${isNaN(tailLines) ? 50 : tailLines}`;
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${requestedLines}`;
// --- Cross-host path (CLI-direct GET to the remote daemon's shipped
// transcript routes — the SAME paths the local path builds, origin
+2 -1
View File
@@ -161,7 +161,8 @@ function whoamiPeersBound(): number {
type TmuxExecFn = (cmd: string) => string;
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
// tmux's stderr (an unset @rigged_* option, no server) rides the thrown error; callers fall through.
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
function buildPartialWhoamiResult(source: { nodeId?: string; sessionName?: string }): Record<string, unknown> {
return {
+36 -6
View File
@@ -7,7 +7,7 @@ export { daemonMayAnswer } from "./daemon-reachability.js";
import type { DaemonStartLock } from "./daemon-start-lock.js";
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
// Types only: the module itself (with the daemon's yaml-reading system world) is imported where an
// instance is initialized, so a command that only talks to the daemon never loads it.
import type {
@@ -72,6 +72,18 @@ export interface GetDaemonStatusOptions {
cleanupStaleState?: boolean;
}
/** True for `http://host:port` with an explicit, non-default port and no path, query or
* fragment: the only OPENRIG_URL shape getDaemonUrl's host-and-port rebuild reaches unchanged
* (without a port, or with :80, which URL reports as no port, status falls back to 7433). */
function isPlainHttpOrigin(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" && u.port !== "" && u.pathname === "/" && !u.search && !u.hash && !url.endsWith("/");
} catch {
return false;
}
}
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
export function getDaemonUrl(status: DaemonStatus): string {
return `http://${formatDaemonHostForUrl(status.host ?? DEFAULT_HOST)}:${status.port}`;
@@ -356,7 +368,7 @@ export function resolveCliBaseDir(baseDir: string): string {
* stale daemon through `rig daemon start` whenever they've rebuilt
* `@openrig/daemon` but haven't rerun `build-package.sh`. That hid the
* fact that QA on local main 55e01f2e was getting a daemon WITHOUT the
* shipped slice-09 `/api/rig-mode/*` routes (qitem-20260518054224).
* shipped slice-09 `/api/rig-mode/*` routes.
*
* In an npm-install layout `packages/daemon/dist` does not exist
* (only the bundled `packages/cli/daemon` lives next to the cli);
@@ -450,7 +462,7 @@ const ENV_SCRUB_PREFIXES = ["CODEX_", "GHOSTTY_", "XPC_", "__CF"];
// OPENRIG_BIND_HOST (exported below when opts.host declares it). THE RULE FOR FUTURE
// PASSTHROUGH EDITS: any env var that doubles as client routing state must join this
// set — routing state silently becoming bind policy is the incident class
// (operator baton qitem-20260827070400: parent lost its Tailscale listener).
// (an incident where a host lost its Tailscale listener).
const ROUTING_ENV_SCRUB = new Set(["OPENRIG_HOST", "RIGGED_HOST"]);
const ENV_SCRUB_EXACT = new Set([
@@ -905,7 +917,7 @@ function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: Daem
export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-target"> {
const state = readState(deps);
const configured = resolveConfiguredDaemonTarget();
const explicitUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const explicitUrl = readOpenRigUrl();
const stateUrl = state ? `http://${formatDaemonHostForUrl(state.host ?? DEFAULT_HOST)}:${state.port}` : undefined;
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}`;
if (stateUrl && explicitUrl && new URL(explicitUrl).origin !== new URL(stateUrl).origin) {
@@ -1028,8 +1040,14 @@ export async function getDaemonStatus(
deps: LifecycleDeps,
options: GetDaemonStatusOptions = {},
): Promise<DaemonStatus> {
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly
const openrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly. Commands reach
// it through getDaemonUrl's http://host:port rebuild, so only a plain http origin uses
// the normalized form; any other URL is probed as written, exactly as before.
const rawOpenrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const normalizedOpenrigUrl = readOpenRigUrl();
const openrigUrl = normalizedOpenrigUrl !== undefined && isPlainHttpOrigin(normalizedOpenrigUrl)
? normalizedOpenrigUrl
: rawOpenrigUrl;
if (openrigUrl) {
// An unverified daemon keeps the endpoint it was probed at, so a command can still make its request.
const endpoint = (() => {
@@ -1318,6 +1336,18 @@ export interface KernelReadyResult {
existingRestore?: "in_progress" | "finished";
}
/** What to tell a person when daemon start skipped the kernel on purpose (kernel_state `skipped`).
* The daemon also skips when a kernel rig already exists (kernel-boot.ts); that rig need not be running.
* A longer detail with the same start is the daemon's own reason, such as a kernel already down before it started. */
export function skippedKernelMessage(detail: string | null): string {
const d = detail ?? "";
return d === "kernel rig already managed"
? "Kernel auto-boot skipped (kernel rig already managed). If its seats are not running, for example after a reboot, bring it back with: rig up kernel --existing"
: d.startsWith("kernel rig already managed")
? `Kernel auto-boot skipped: ${d}`
: "Kernel auto-boot skipped (--no-kernel or test mode), including the operator that helps you start a team. To start it later, run rig, set up or select kernel, then start its operator seat.";
}
export async function waitForKernelReady(
baseUrl: string,
timeoutMs: number,
+2 -2
View File
@@ -1,5 +1,5 @@
// release-0.3.2 slice 12 — dot-ID grammar per
// `openrig-work/conventions/scope-and-versioning/README.md` §1.
// release-0.3.2 slice 12 — dot-ID grammar per the scope-and-versioning
// convention, §1.
//
// Grammar (v0 single-project):
// project = <PFX> (2-3 letter; v0 hardcodes "OPR")
@@ -62,7 +62,7 @@ export function isPlaceholderOnlyBlock(block: string | null): boolean {
return lines.every((l) => isScaffoldPlaceholderText(l));
}
/** PM dogfood #1 (qitem-20260720015700-630eef64) — true when `body` is a
/** PM dogfood #1 — true when `body` is a
* PRESENT section whose EVERY non-blank line is scaffold-placeholder
* content: either a bare placeholder line, or a structural list row
* (numbered `1.`/`1)`, checkbox `- [ ]`, or bullet `-`/`*`) whose text is a
+6 -1
View File
@@ -166,6 +166,11 @@ export function deriveMissionDependencyGraph(input: MissionDependencyGraphInput)
// without this file changing too).
export const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"];
// A slice directory's name: two or more digits, a dash, then the slug. `rig scope slice create` numbers past 99
// (100-…), so a two-digit-only check flags names OpenRig made. The CLI audit and the daemon's audit route both read
// this one pattern.
export const SLICE_DIRECTORY_NAME = /^\d{2,}-/;
function childPath(parent: string, child: string): string {
return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`;
}
@@ -422,7 +427,7 @@ export function classifyScopeItem(input: ScopeAuditInput): ScopeAuditResult {
});
}
// PM dogfood #1 (qitem-20260720015700-630eef64) — per-SECTION source
// PM dogfood #1 — per-SECTION source
// selection, decided independently for `## Mini-requirements` and
// `## Proof contract`: an authored PRD section is canonical, but a
// PRESENT-and-PRISTINE scaffold-only PRD section yields to an authored
+14 -4
View File
@@ -554,8 +554,9 @@ export function findSlice(
continue;
}
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
const sliceRoot = path.dirname(candidate);
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
// The directory and the name both come from the resolved path, so `<slice>/.` and `<slice>/sub/..` select the slice.
const resolved = path.resolve(candidate);
return buildSliceInfo(mission, path.dirname(resolved), path.basename(resolved));
}
}
if (firstContextError) throw firstContextError;
@@ -567,9 +568,18 @@ export function findSlice(
}
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
// path.dirname never ends in a separator, so compare against the normalized root
// (a configured or --workspace root may be written as `/w/missions/`); resolve the
// slice the same way so a relative root and a relative candidate stay comparable.
// A path that leaves the root, such as `<root>/../other/m1/slices/x`, then falls back
// to the walk as written, where it reads as before.
return walkToMission(path.resolve(missionsRoot), path.resolve(slicePath)) ?? walkToMission(missionsRoot, slicePath);
}
function walkToMission(root: string, slicePath: string): string | null {
let dir = path.dirname(slicePath);
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
if (path.dirname(dir) === missionsRoot) return dir;
while (dir.startsWith(root) && dir !== root) {
if (path.dirname(dir) === root) return dir;
dir = path.dirname(dir);
}
return null;
+2 -3
View File
@@ -1,8 +1,7 @@
// release-0.3.2 slice 12 — scope CLI primitive types.
//
// Aligned with the substrate convention at
// `openrig-work/conventions/scope-and-versioning/README.md` (stage:
// provisional, authored 2026-05-15). The CLI mints stable dot-IDs
// Aligned with the scope-and-versioning convention (stage: provisional,
// authored 2026-05-15). The CLI mints stable dot-IDs
// into created mission/slice frontmatter per §1 of that convention.
/** Scope tiers. v0 ships `mission` + `slice`; `project` and `sub-slice`
+294 -37
View File
@@ -6,14 +6,23 @@
// proves nothing about the caller. A missing, invalid or revoked credential is refused and never falls back to the env
// seat, and so is every request once the file is gone or malformed, until a restart. Each request gets its own
// DaemonClient, carrying its grant's seat, so concurrent callers never share an identity.
//
// With --grant-service, grants come from an external grant service instead and the token file is never read: each
// request's key is checked with that service (openrig.grant/v1) for this daemon's instance, every answer but active is
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts).
//
// The endpoint serves both protocol revisions: 2026-07-28 per request, and 2025's initialize and exchanges statelessly.
import http from "node:http";
import fs from "node:fs";
import nodePath from "node:path";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { DaemonClient } from "./client.js";
import { createMcpServer, type McpAccess } from "./mcp-server.js";
import { getOpenRigHome } from "./openrig-compat.js";
import { MCP_PRESENCE_FILE, presenceRecorder } from "./mcp-presence.js";
import { resolveRigHandle } from "./rig-handle.js";
/** One caller's grant. Only the token's sha256 is stored. */
export type McpGrant = {
@@ -138,13 +147,174 @@ export function isLoopbackHost(host: string): boolean {
export class McpHttpConfigError extends Error {}
/** The caller whose grant this server checked, as the daemon records it beside the rows it writes (principal:v1). */
export type McpPrincipal = { source: "file" | "grant-service"; id: string; grant?: string; version?: number | string };
/** One request's caller as this server decided it: no seat, access or principal for the loopback local owner. */
type RequestCaller = { seat?: string; access?: McpAccess; principal?: McpPrincipal };
/** An external grant service's contract. Core asks on every request with the caller's raw key and its own instance id;
* nothing is cached, and every answer but `active` is refused. */
export const GRANT_SCHEMA = "openrig.grant/v1";
/** A grant key as the grant service issues it, `<connection id>.cus_<principal id>.<secret>`. Anything else is refused
* before any call. */
const GRANT_KEY = /^[a-f0-9]{24}\.cus_[A-Za-z0-9]{8,64}\.[a-f0-9]{48}$/;
/** An instance id as the grant service takes it. */
const INSTANCE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export type GrantServiceOptions = {
/** The grant endpoint. It must be https, except on loopback. */
url: string;
/** One header sent with every grant request and nowhere else, such as a protected deployment's bypass. */
header?: { name: string; value: string };
timeoutMs?: number;
};
/** Reads a `Name: value` header line from a secret file. */
export function readGrantServiceHeader(file: string): { name: string; value: string } {
const line = fs.readFileSync(file, "utf8").trim();
const match = /^([A-Za-z0-9-]+):[ \t]*(\S(?:.*\S)?)$/.exec(line);
if (!match || line.includes("\n")) throw new McpHttpConfigError(`${file} must hold one line, Name: value`);
// The request's own headers carry the key and the body; this one can't replace them.
if (["authorization", "content-type", "content-length", "host"].includes(match[1]!.toLowerCase())) {
throw new McpHttpConfigError(`${file} can't set ${match[1]}: the grant request sets it`);
}
return { name: match[1]!, value: match[2]! };
}
function grantServiceUrl(url: string): URL {
let parsed: URL;
try { parsed = new URL(url); } catch { throw new McpHttpConfigError(`--grant-service takes the grant endpoint's URL, not ${url}`); }
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) {
throw new McpHttpConfigError(`The grant endpoint must be https: ${url}`);
}
return parsed;
}
/** OAuth discovery (RFC 9728) for a server whose keys a grant service checks: the one metadata document, where it's
* served, and the 401 challenge that points at it. */
type OAuthDiscovery = { path: string; document: Record<string, unknown>; challenge: string };
/** The resource clients reach this server as and the authorization server that issues its keys, served exactly as
* configured. Each must be https with no userinfo, query or fragment, written as a URL writes it (a lowercase host, no
* default port), so nothing is normalized on the way out; the resource's path ends in /mcp. Its metadata is at the
* path-inserted address, and nowhere else. */
function oauthDiscovery(oauth: { resource: string; issuer: string }): OAuthDiscovery {
const exact = (flag: string, value: string): URL => {
let url: URL | undefined;
try { url = new URL(value); } catch { /* refused below */ }
// A bare origin is written without the slash a URL adds. An empty query or fragment shows only in the text.
const written = url !== undefined && (url.href === value || (url.pathname === "/" && url.href === `${value}/`));
if (!url || !written || url.protocol !== "https:" || url.username || url.password || /[?#]/.test(value)) {
throw new McpHttpConfigError(`${flag} takes an https URL with no userinfo, query or fragment, written as a URL writes it (a lowercase host, no default port), not ${value}`);
}
return url;
};
const resource = exact("--oauth-resource", oauth.resource);
exact("--oauth-issuer", oauth.issuer);
if (!resource.pathname.endsWith("/mcp")) throw new McpHttpConfigError(`--oauth-resource's path must end in /mcp, not ${oauth.resource}`);
const path = `/.well-known/oauth-protected-resource${resource.pathname}`;
return {
path,
document: { resource: oauth.resource, authorization_servers: [oauth.issuer], scopes_supported: ["openrig.work"], bearer_methods_supported: ["header"] },
challenge: `Bearer realm="openrig", resource_metadata="${resource.origin}${path}"`,
};
}
type Refusal = { status: number; error: string; message: string; log?: string };
const unauthenticated = (message: string): Refusal => ({ status: 401, error: "unauthenticated", message });
const NOT_VALID_HERE = "This credential isn't valid here: it's unknown or was revoked.";
const unavailable = (log: string): Refusal => ({
status: 503, error: "grant_authority_unavailable", message: "The grant authority can't answer now, so this request isn't served. Try again later.", log,
});
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
/** One request's caller from the grant service's answer for its key. A refusal never falls back to the token file or
* the env's seat. */
async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: string, instance: string): Promise<{ seat: string; access: McpAccess; principal: McpPrincipal } | Refusal> {
let res: Response;
try {
res = await fetch(url, {
method: "POST",
// A redirect would carry the key and the header to another origin.
redirect: "error",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}`, ...(service.header ? { [service.header.name]: service.header.value } : {}) },
body: JSON.stringify({ instance }),
signal: AbortSignal.timeout(service.timeoutMs ?? 5_000),
});
} catch (err) {
return unavailable(`the grant request failed: ${(err as Error).message}`);
}
let body: Record<string, any>;
try { body = await res.json() as Record<string, any>; } catch { return unavailable(`HTTP ${res.status} answered with a body that isn't JSON`); }
if (body?.schema !== GRANT_SCHEMA) return unavailable(`HTTP ${res.status} answered schema ${JSON.stringify(body?.schema)}`);
if (body.status === "unknown" || body.status === "wrong_instance") return unauthenticated(NOT_VALID_HERE);
if (body.status === "revoked") return unauthenticated("This connection was disconnected. Reconnect it where it was made.");
if (body.status === "expired") return unauthenticated("This credential has expired.");
// malformed (this server's request), unavailable, or anything else.
if (body.status !== "active" || res.status !== 200) return unavailable(`HTTP ${res.status} answered status ${JSON.stringify(body.status)}`);
const { principal, grant, environment, seat, rigs, artifact_roots: roots, access_class: accessClass, objects } = body;
const seatRig = typeof seat === "string" ? seat.split("@") : [];
const problem = typeof principal?.id !== "string" || principal.id === "" ? "principal.id"
: typeof grant?.id !== "string" || grant.id === "" ? "grant.id"
: environment?.core_instance !== instance ? "environment.core_instance"
: seatRig.length !== 2 || seatRig[0] === "" ? "seat"
: !strings(rigs) || !rigs.includes(seatRig[1]!) ? "rigs"
: !strings(roots) ? "artifact_roots"
: accessClass !== "work" ? "access_class"
: !objects || typeof objects !== "object" || Array.isArray(objects) || !Object.values(objects).every(strings) ? "objects"
: grant.expires_at !== null && grant.expires_at !== undefined && !(Date.parse(grant.expires_at) > Date.now()) ? "expired" : null;
if (problem === "expired") return unauthenticated("This credential has expired.");
if (problem) return unavailable(`an active answer with an unusable ${problem}`);
// The principal the daemon records beside this caller's rows. A version that isn't a finite number is left out (a v1
// answer sends a number).
return { seat, access: { principal: principal.id, owner: false, rigs, roots, workspace: false, external: { objects } },
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...(Number.isFinite(grant.version) ? { version: grant.version } : {}) } };
}
/** A node's seat name as the daemon derives it (deriveCanonicalFromEntry): {pod}-{member}@{rig}, the logical id split at
* its first dot. */
function canonicalSeat(logicalId: string, rigName: string): string | null {
const dot = logicalId.indexOf(".");
return dot > 0 && dot < logicalId.length - 1 ? `${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}@${rigName}` : null;
}
/** Whether a seat whoami doesn't know is one of this daemon's nodes: a seat created without a launch (rig import
* --materialize-only) has no session yet. The rig resolves by name as rig commands resolve one (resolveRigHandle: only
* active rigs, and two active rigs of that name are ambiguous), and the seat must be exactly one of its nodes' names.
* Read only: nothing is created, launched or bound. A rig list or node list that can't be read, or has a malformed
* entry, and a name two nodes share, never count as the seat. */
async function nodeSeat(client: DaemonClient, seat: string): Promise<"found" | "absent" | Refusal> {
const rig = await resolveRigHandle(client, seat.slice(seat.indexOf("@") + 1), { strict: true });
if (rig.kind === "not_found") return "absent";
if (rig.kind === "passthrough") return unavailable(`the rig list couldn't be read, or has a malformed entry, to look for seat ${seat}`);
if (rig.kind === "ambiguous") return unavailable(`seat ${seat}'s rig name matches ${rig.ids.length} rigs (${rig.ids.join(", ")})`);
const nodes = await client.get(`/api/rigs/${encodeURIComponent(rig.id)}/nodes`);
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return unavailable(`the node list for seat ${seat} answered HTTP ${nodes.status}`);
let matches = 0;
for (const node of nodes.data as unknown[]) {
const { logicalId, rigName } = (node ?? {}) as Record<string, unknown>;
if (typeof logicalId !== "string" || typeof rigName !== "string") return unavailable(`the node list for seat ${seat} has a malformed entry`);
if (canonicalSeat(logicalId, rigName) === seat) matches += 1;
}
return matches === 1 ? "found" : matches === 0 ? "absent" : unavailable(`${matches} nodes are named ${seat}`);
}
export type McpHttpOptions = {
daemonUrl: string;
host: string;
port: number;
tokenFile: string;
/** Builds the daemon client for one request; sessionName is the grant's seat (absent: the env's). */
clientFactory?: (daemonUrl: string, sessionName?: string) => DaemonClient;
/** Builds the daemon client for one request; sessionName is the grant's seat (absent: the env's), and principal the
* caller whose grant was checked. */
clientFactory?: (daemonUrl: string, sessionName?: string, principal?: McpPrincipal) => DaemonClient;
/** Grants come from an external grant service instead of the token file, which is then never read. */
grantService?: GrantServiceOptions;
/** With grantService: OAuth discovery for clients that sign in with OAuth. The public resource this server is reached
* as and the authorization server that issues its keys, both served exactly as given. */
oauth?: { resource: string; issuer: string };
/** How often one seat's call is recorded (default 60 s). */
presenceIntervalMs?: number;
};
function reply(res: http.ServerResponse, status: number, body: Record<string, unknown>, headers: Record<string, string> = {}): void {
@@ -167,46 +337,130 @@ function readBody(req: http.IncomingMessage): Promise<string> {
export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: string; authenticated: boolean; close(): Promise<void> }> {
const loopback = isLoopbackHost(opts.host);
const configured = readGrants(opts.tokenFile) !== null;
const service = opts.grantService;
const serviceUrl = service ? grantServiceUrl(service.url) : undefined;
// The metadata describes the keys a grant service checks, so it's served only with one.
if (opts.oauth && !service) throw new McpHttpConfigError("--oauth-resource and --oauth-issuer need --grant-service");
const discovery = opts.oauth ? oauthDiscovery(opts.oauth) : undefined;
const configured = service !== undefined || readGrants(opts.tokenFile) !== null;
if (!loopback && !configured) {
throw new McpHttpConfigError(`Serving on ${opts.host} needs authentication: add a grant with rig mcp token add, then start again. With no grants, the server stays on loopback.`);
}
const makeClient = opts.clientFactory ?? ((url: string, sessionName?: string) => new DaemonClient(url, sessionName === undefined ? undefined : { sessionName }));
const makeClient = opts.clientFactory ?? ((url: string, sessionName?: string, principal?: McpPrincipal) =>
new DaemonClient(url, sessionName === undefined ? undefined : { sessionName, ...(principal ? { principal } : {}) }));
let boundPort = opts.port;
// A token-file grant's last call, for rig_team's presence. An external caller isn't a team member, so none is kept.
const presenceFile = nodePath.join(nodePath.dirname(opts.tokenFile), MCP_PRESENCE_FILE);
const recordCall = presenceRecorder(presenceFile, opts.presenceIntervalMs);
// Once auth has been configured here, a missing token file is a broken configuration, not a return to local-owner
// mode: that takes a restart with no file. A beyond-loopback listener always starts with auth.
let authSeen = configured;
// The instance a grant key must be bound to: the self id of the daemon whose seats this server acts on, read from that
// daemon, never from a caller. Kept once known.
let instance: string | undefined;
const instanceId = async (): Promise<string | undefined> => {
if (instance) return instance;
try {
const res = await fetch(`${opts.daemonUrl.replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(2_000) });
const self = res.ok ? ((await res.json()) as { selfHostId?: unknown }).selfHostId : undefined;
if (typeof self === "string" && INSTANCE_ID.test(self)) instance = self;
} catch { /* Unknown: refused below, and asked again on the next request. */ }
return instance;
};
// A 401 names the realm. With OAuth discovery it also points at the metadata, and says invalid_token when a bearer
// credential was presented and refused (RFC 6750 §3); a request with none gets no error.
const refuse = (res: http.ServerResponse, refusal: Refusal, presented = false): void => {
// The detail stays in this server's log: the reply goes to a caller not yet authenticated.
if (refusal.log) console.error(`rig mcp serve --http: ${refusal.log}`);
const challenge = !discovery ? 'Bearer realm="openrig"' : `${discovery.challenge}${presented ? ', error="invalid_token"' : ""}`;
reply(res, refusal.status, { error: refusal.error, message: refusal.message }, refusal.status === 401 ? { "WWW-Authenticate": challenge } : {});
};
// One handler for both protocol revisions: a request carrying 2026-07-28's per-request envelope is served as that
// revision, anything else (initialize and the rest of 2025) statelessly as before. Each request gets a fresh server for
// the caller decided below, handed over as the request's authInfo.
const mcpHandler = createMcpHandler(({ authInfo }) => {
const caller = (authInfo?.extra as { caller?: RequestCaller } | undefined)?.caller;
// Never a default seat: a request reaches the handler only with its caller decided.
if (!caller) throw new Error("a request reached the MCP handler without its caller");
return createMcpServer(makeClient(opts.daemonUrl, caller.seat, caller.principal), { seat: caller.seat, access: caller.access, presenceFile: () => presenceFile });
});
const serveMcp = toNodeHandler(mcpHandler);
const bearer = (req: http.IncomingMessage): string | undefined => /^Bearer\s+(\S+)\s*$/i.exec(req.headers.authorization ?? "")?.[1];
const NO_BEARER = "This server needs a bearer credential: Authorization: Bearer <token>.";
/** The caller of one request on a server with a grant service: its key's grant, checked there, for a seat on this
* instance. */
const serviceRequestCaller = async (req: http.IncomingMessage): Promise<{ seat: string; access: McpAccess; principal: McpPrincipal } | Refusal> => {
const key = bearer(req);
if (!key) return unauthenticated(NO_BEARER);
if (!GRANT_KEY.test(key)) return unauthenticated(NOT_VALID_HERE);
const self = await instanceId();
if (!self) return unavailable("this daemon reports no self id, so a grant key can't be checked against this instance");
const caller = await grantServiceCaller(service!, serviceUrl!, key, self);
if (!("seat" in caller)) return caller;
// The grant service names the seat; it must already exist here. It's checked, never created.
try {
const res = await fetch(`${opts.daemonUrl.replace(/\/+$/, "")}/api/whoami?${new URLSearchParams({ sessionName: caller.seat, compact: "1" }).toString()}`,
{ signal: AbortSignal.timeout(5_000) });
if (res.status === 404) {
// whoami knows a seat by its sessions, and a seat created without a launch has none yet: it's then this daemon's
// node of exactly that name, or it isn't here.
const node = await nodeSeat(makeClient(opts.daemonUrl), caller.seat);
if (node === "absent") return { status: 503, error: "seat_unavailable", message: "This connection's seat isn't on this instance yet.", log: `The grant service named seat ${caller.seat}, which this daemon doesn't have` };
if (node !== "found") return node;
} else if (!res.ok) return unavailable(`the seat check for ${caller.seat} answered HTTP ${res.status}`);
} catch (err) {
return unavailable(`the seat check for ${caller.seat} failed: ${(err as Error).message}`);
}
return caller;
};
const handle = async (req: http.IncomingMessage, res: http.ServerResponse): Promise<void> => {
if (new URL(req.url ?? "/", "http://localhost").pathname !== "/mcp") {
const { pathname } = new URL(req.url ?? "/", "http://localhost");
// The OAuth metadata is static and public: served before any credential or grant-service call, whatever the Host.
if (discovery && req.method === "GET" && pathname === discovery.path) {
reply(res, 200, discovery.document);
return;
}
if (pathname !== "/mcp") {
reply(res, 404, { error: "not_found", message: "The MCP endpoint is /mcp." });
return;
}
let grants: McpGrant[] | null;
try {
grants = readGrants(opts.tokenFile);
if (grants === null && authSeen) throw new Error(`${opts.tokenFile} is gone after auth was configured; restore it, or restart with no token file to serve loopback unauthenticated`);
} catch (err) {
// The detail stays in this server's log: the reply goes to a caller not yet authenticated.
console.error(`rig mcp serve --http: the token file can't be read: ${(err as Error).message}`);
reply(res, 500, { error: "auth_config_unreadable", message: "The token file can't be read." });
return;
}
if (grants !== null) authSeen = true;
let access: McpAccess | undefined;
let seat: string | undefined;
if (grants !== null) {
const token = /^Bearer\s+(\S+)\s*$/i.exec(req.headers.authorization ?? "")?.[1];
const grant = token ? grantFor(grants, token) : null;
if (!grant) {
reply(res, 401, {
error: "unauthenticated",
message: token ? "This credential isn't valid here: it's unknown or was revoked." : "This server needs a bearer credential: Authorization: Bearer <token>.",
}, { "WWW-Authenticate": 'Bearer realm="openrig"' });
let principal: McpPrincipal | undefined;
let authenticated = false;
if (service) {
const caller = await serviceRequestCaller(req);
if (!("seat" in caller)) { refuse(res, caller, bearer(req) !== undefined); return; }
({ access, seat, principal } = caller);
authenticated = true;
} else {
let grants: McpGrant[] | null;
try {
grants = readGrants(opts.tokenFile);
if (grants === null && authSeen) throw new Error(`${opts.tokenFile} is gone after auth was configured; restore it, or restart with no token file to serve loopback unauthenticated`);
} catch (err) {
// The detail stays in this server's log: the reply goes to a caller not yet authenticated.
console.error(`rig mcp serve --http: the token file can't be read: ${(err as Error).message}`);
reply(res, 500, { error: "auth_config_unreadable", message: "The token file can't be read." });
return;
}
access = { principal: grant.name, owner: grant.owner === true, rigs: grant.rigs ?? [], roots: grant.roots ?? [], workspace: grant.workspace === true };
seat = grant.seat;
if (grants !== null) authSeen = true;
if (grants !== null) {
const token = bearer(req);
const grant = token ? grantFor(grants, token) : null;
if (!grant) {
refuse(res, unauthenticated(token ? NOT_VALID_HERE : NO_BEARER));
return;
}
access = { principal: grant.name, owner: grant.owner === true, rigs: grant.rigs ?? [], roots: grant.roots ?? [], workspace: grant.workspace === true };
seat = grant.seat;
// A token-file grant has a name and nothing more: no grant id or version is invented for it.
principal = { source: "file", id: grant.name };
authenticated = true;
recordCall(grant.seat, grant.name);
}
}
let body: unknown;
if (req.method === "POST") {
@@ -218,16 +472,16 @@ export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: s
return;
}
}
const mcp = createMcpServer(makeClient(opts.daemonUrl, seat), { seat, access });
// With no auth (loopback only), refuse a Host that isn't loopback, as a page reaching localhost through DNS rebinding
// or a forward from elsewhere would send. With auth, the credential is the check, so a tunnel's Host is fine.
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
...(grants === null ? { enableDnsRebindingProtection: true, allowedHosts: [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`] } : {}),
});
res.on("close", () => { void transport.close(); void mcp.close(); });
await mcp.connect(transport);
await transport.handleRequest(req, res, body);
// or a forward from elsewhere would send. With auth, the credential is the check, so a tunnel's Host is fine. The SDK's
// handler checks no Host, so this is the refusal its 1.x transport gave.
if (!authenticated && ![`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`].includes(req.headers.host ?? "")) {
reply(res, 403, { jsonrpc: "2.0", error: { code: -32000, message: `Invalid Host header: ${req.headers.host}` }, id: null });
return;
}
const caller: RequestCaller = { seat, access, principal };
(req as http.IncomingMessage & { auth?: AuthInfo }).auth = { token: "", clientId: principal?.id ?? "local-owner", scopes: [], extra: { caller } };
await serveMcp(req, res, body);
};
const server = http.createServer((req, res) => {
@@ -244,6 +498,9 @@ export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: s
return {
url: `http://${shownHost}:${boundPort}/mcp`,
authenticated: configured,
close: () => new Promise<void>((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); }),
close: async () => {
await new Promise<void>((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); });
await mcpHandler.close();
},
};
}
+51
View File
@@ -0,0 +1,51 @@
// A hosted member's last call: `rig mcp serve --http` records when each token-file grant's seat last called, and rig_team
// reads it. It's presence, a fact about the member, never pickup (a claim on a row) and never "idle".
import fs from "node:fs";
import nodePath from "node:path";
import { getOpenRigHome } from "./openrig-compat.js";
/** The file, beside the token file and never in it (that stays auth config, as rig mcp token writes it). */
export const MCP_PRESENCE_FILE = "mcp-http-presence.json";
export function mcpPresenceFilePath(home: string = getOpenRigHome()): string {
return nodePath.join(home, MCP_PRESENCE_FILE);
}
/** A seat's last authenticated call to this host's MCP server, and the grant that made it. */
export type McpCallPresence = { grant: string; lastCallAt: string };
/** Each seat's last recorded call. None recorded, or a file that can't be read, is an empty map: unknown, never idle. */
export function readPresence(file: string): Record<string, McpCallPresence> {
try {
const seats = (JSON.parse(fs.readFileSync(file, "utf8")) as { seats?: unknown } | null)?.seats;
if (!seats || typeof seats !== "object" || Array.isArray(seats)) return {};
const out: Record<string, McpCallPresence> = {};
for (const [seat, value] of Object.entries(seats as Record<string, unknown>)) {
const entry = (value ?? {}) as Record<string, unknown>;
if (typeof entry.grant === "string" && typeof entry.lastCallAt === "string") out[seat] = { grant: entry.grant, lastCallAt: entry.lastCallAt };
}
return out;
} catch {
return {};
}
}
/** Records a seat's call at most once per interval for this server, by atomic rename (several servers: the last writer
* wins). Presence is a convenience: a write that fails is logged here and never refuses the caller. */
export function presenceRecorder(file: string, intervalMs = 60_000, now: () => number = Date.now): (seat: string, grant: string) => void {
const lastWrite = new Map<string, number>();
return (seat, grant) => {
const at = now();
if (at - (lastWrite.get(seat) ?? Number.NEGATIVE_INFINITY) < intervalMs) return;
lastWrite.set(seat, at);
try {
const seats = readPresence(file);
seats[seat] = { grant, lastCallAt: new Date(at).toISOString() };
const temp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temp, `${JSON.stringify({ version: 1, seats }, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(temp, file);
} catch (err) {
console.error(`rig mcp serve --http: couldn't record ${seat}'s call: ${(err as Error).message}`);
}
};
}
File diff suppressed because it is too large. Load diff
+20 -3
View File
@@ -55,12 +55,12 @@ function warnOnce(key: string, message: string): void {
console.warn(message);
}
export function readOpenRigEnv(primary: string, legacy?: string): string | undefined {
const primaryValue = process.env[primary];
export function readOpenRigEnv(primary: string, legacy?: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
const primaryValue = env[primary];
if (primaryValue !== undefined && primaryValue !== "") return primaryValue;
if (legacy) {
const legacyValue = process.env[legacy];
const legacyValue = env[legacy];
if (legacyValue !== undefined && legacyValue !== "") {
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
return legacyValue;
@@ -70,6 +70,23 @@ export function readOpenRigEnv(primary: string, legacy?: string): string | undef
return undefined;
}
/** An explicit daemon URL with trailing slashes removed, so `http://h:7433/` addresses the
* same daemon as `http://h:7433`. A URL with a query or fragment is returned exactly as
* written, and so is a value that would strip down to no host (such as `/`), which stays
* an explicit (if invalid) target. */
export function normalizeOpenRigUrl(raw: string): string {
if (/[?#]/.test(raw)) return raw;
const stripped = raw.replace(/\/+$/, "");
return /:\/\/[^/]/.test(stripped) ? stripped : raw;
}
/** OPENRIG_URL (legacy RIGGED_URL), normalized by normalizeOpenRigUrl. `env` defaults to
* process.env; callers with an injected environment pass theirs. */
export function readOpenRigUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
const raw = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL", env);
return raw === undefined ? undefined : normalizeOpenRigUrl(raw);
}
export function getPreferredOpenRigHome(): string {
const openrigHome = getOpenRigHome();
const legacyRiggedHome = getLegacyRiggedHome();
@@ -8,8 +8,7 @@
// with 13.1's deterministic half. 13.3 (the dispatcher that WRITES skill
// updates) decides invocation; this stays invocation-agnostic.
//
// Join grammar - ADVISOR-RATIFIED (qitem-20260608235706-64ba9ef2; see the slice
// governance-watch.md SHARP WATCH #1):
// Join grammar (reviewed):
// - Skill tokens are stored WITHOUT the `rig ` prefix, in 13.1's command-path
// grammar (`scope slice create`, `queue create`). We strip a leading `rig `
// defensively so a skill written either way still matches.
@@ -64,7 +64,7 @@ function firstLine(text: string): string {
* message text but its file references stay in the touched-files
* inventory).
*
* R2 fix (per M2b R2 dispatch qitem-20260502011841-fb53d7fd): inspect
* R2 fix: inspect
* nested content parts BEFORE discarding non-text. Previously dropped
* tool_use / tool_result silently, leading to misleading all-zero
* omittedCounts on real Claude transcripts where nearly every assistant
+10 -1
View File
@@ -24,6 +24,12 @@ export type HandleResolution =
// name posted this way cannot reach the wrong rig.
| { kind: "passthrough"; handle: string };
/** A rig list entry as the daemon writes it: a string id and name, and archivedAt a string or null. */
function isSummaryEntry(entry: unknown): boolean {
const rig = (entry ?? {}) as Record<string, unknown>;
return typeof rig.id === "string" && typeof rig.name === "string" && (rig.archivedAt === null || typeof rig.archivedAt === "string");
}
/**
* Resolve a rig handle (rig name OR id) to a concrete rig id, mirroring the
* `/api/rigs/summary` path `rig up` uses. Resolution is a PRE-STEP: each
@@ -49,7 +55,9 @@ export async function resolveRigHandle(
client: DaemonClient,
handle: string,
// `archived: true` matches names among ARCHIVED rigs instead (for `rig unarchive`, whose target is archived).
opts: { archived?: boolean } = {},
// `strict: true` reads a rig list with any malformed entry as unavailable (passthrough), for a caller that must not
// count an incomplete list as an answer.
opts: { archived?: boolean; strict?: boolean } = {},
): Promise<HandleResolution> {
let summaries: RigSummaryEntry[];
try {
@@ -59,6 +67,7 @@ export async function resolveRigHandle(
if (res.status !== 200 || !Array.isArray(res.data)) {
return { kind: "passthrough", handle };
}
if (opts.strict && !res.data.every(isSummaryEntry)) return { kind: "passthrough", handle };
summaries = res.data;
} catch {
return { kind: "passthrough", handle };
+29 -7
View File
@@ -4,11 +4,11 @@ import { homedir, hostname, networkInterfaces } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import type { DaemonClient } from "./client.js";
import { terminalAuthHeaders, type DaemonClient } from "./client.js";
import { shellQuote } from "./cross-host-executor.js";
import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
import { openRexView } from "./terminal-window-rex.js";
import type { OpenViewResult } from "./commands/terminal.js";
import type { FollowControls, OpenViewResult } from "./commands/terminal.js";
interface Pane { seat: string; label: string; paneCommand: string; recoveryCommand?: string }
interface HerdrTab { workspace_id: string; tab_id: string; label: string }
@@ -226,7 +226,8 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
if (!pid || !/^\d+$/.test(pid) || !session || !["0", "1"].includes(readOnly ?? "")) continue;
// Compare the composer's exact command, including session aliases and read-only mode.
// SSH/custom commands cannot be confirmed from this local client inventory.
clients.set(pid, ["tmux", quote(tmux)].map(binary => `${binary} attach ${readOnly === "1" ? "-r " : ""}-t ${quote(session)}`));
const flags = readOnly === "1" ? ["-r "] : ["", "-f ignore-size "];
clients.set(pid, ["tmux", quote(tmux)].flatMap(binary => flags.map(flag => `${binary} attach ${flag}-t ${quote(session)}`)));
}
const used = new Set<string>();
for (const [index, page] of pages.entries()) {
@@ -259,12 +260,12 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
}
/** Render the daemon's existing composition; never rediscover/relaunch kernel seats here. */
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps(), expectedPlan?: string, requestedSession?: string): Promise<OpenViewResult> {
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps(), expectedPlan?: string, requestedSession?: string, following?: FollowControls): Promise<OpenViewResult> {
const session = requestedSession?.trim();
if (session === undefined && (requestedProvider === "rex" || (requestedProvider === undefined && inRex(deps.env)))) {
if (!following && session === undefined && (requestedProvider === "rex" || (requestedProvider === undefined && inRex(deps.env)))) {
return openRexView(client, view, deps, expectedPlan);
}
const selectedProvider = requestedProvider ?? (session !== undefined ? "herdr" : undefined);
const selectedProvider = requestedProvider ?? (session !== undefined || following ? "herdr" : undefined);
const sessionFlag = session === undefined ? "" : ` --session ${shellQuote(session)}`;
const sessionQuery = session === undefined ? "" : `&session=${encodeURIComponent(session)}`;
let provider = selectedProvider ?? "herdr";
@@ -282,6 +283,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
const windowNotes: string[] = [];
try {
if (!localDaemon(client.baseUrl)) throw new Error("The window launcher must run on the daemon's own desktop; the configured daemon is remote.");
if (following && selectedProvider !== "herdr") throw new Error("Following requires the qualified Herdr provider.");
if (session !== undefined && selectedProvider !== "herdr") throw new Error("--session selects a Herdr session; use --provider herdr.");
if (selectedProvider && !["herdr", "tmux"].includes(selectedProvider)) throw new Error("--window supports herdr, rex or tmux. Use cmux without --window.");
const launchWindow = await windowLauncher(deps, windowNotes);
@@ -306,7 +308,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
if (expectedPlan !== undefined && expectedPlan !== planId) {
return failure(provider, "The terminal view changed since preview. Refresh the preview before opening.");
}
if (!composed.opened.length) return { ...failed("No conversations are attachable."), absent: composed.absent, degraded: composed.degraded };
if (!composed.opened.length && !following) return { ...failed("No conversations are attachable."), absent: composed.absent, degraded: composed.degraded };
const measureWindow = async () => {
if (window && "columns" in window && window.columns !== viewportColumns && composed.kernelLayout) {
viewportColumns = window.columns;
@@ -392,6 +394,26 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
// Inventory is read-only: a failed read must not prevent the first open.
// Scope each tab list to its workspace, on the daemon's exact endpoint.
const herdrArgs = ["-u", "TMUX", "-u", "HERDR_SESSION", "-u", "HERDR_SOCKET_PATH", `HERDR_SOCKET_PATH=${endpoint.socketPath}`, herdr];
if (following) {
// Follow has a stable journaled workspace. A changed membership plan
// must never enter the one-shot plan-label/new-workspace path below.
const answer = await client.post<OpenViewResult>("/api/terminal/follow", { view, provider: "herdr", ...following,
...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) },
{ timeoutMs: 45_000, headers: terminalAuthHeaders() });
if (!answer.data.ok || !answer.data.workspaceId) return { ...answer.data, window, notes: [...(answer.data.notes ?? []), ...windowNotes] };
const selectedArgs = herdrArgs;
const selectedStatus = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "status", "server", "--json"]));
const selectedBody = selectedStatus?.result ?? selectedStatus;
if (selectedStatus.error || selectedBody?.running !== true || selectedBody.socket !== endpoint.socketPath) {
throw new Error("The Herdr CLI did not confirm the daemon's exact following endpoint; no tab lookup or focus was requested.");
}
const listed = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "tab", "list", "--workspace", answer.data.workspaceId]));
const tab = (listed?.result?.tabs as HerdrTab[] | undefined)?.find(t => t.workspace_id === answer.data.workspaceId);
if (!tab) throw new Error("The stable following workspace has no confirmed tab. Inspect it before retrying.");
const focused = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "tab", "focus", tab.tab_id]));
if (focused?.result?.tab?.tab_id !== tab.tab_id || focused.result.tab.workspace_id !== tab.workspace_id) throw new Error("Following tab selection was not confirmed.");
return { ...answer.data, window, notes: [...(answer.data.notes ?? []), ...windowNotes] };
}
const closeCommand = (id: string) => `${session === undefined ? "herdr" : `env -u HERDR_SESSION HERDR_SOCKET_PATH=${shellQuote(endpoint.socketPath)} ${shellQuote(herdr)}`} workspace close ${shellQuote(id)}`;
let tabs: HerdrTab[] = [];
const spaceLabels = new Map<string, string>();
@@ -31,7 +31,10 @@ import type { TmuxAdapter } from "../../daemon/src/adapters/tmux.js";
vi.mock("node:child_process", async (actual) => ({ ...await actual<typeof import("node:child_process")>(), execSync: (cmd: string) => {
if (!cmd.startsWith("tmux display-message ")) throw new Error(`Unexpected shell command in inert fixture: ${cmd}`);
return `${process.env.TEST_PHYSICAL_SESSION}\n@12\n%42\n`;
// Render the requested format as real tmux does, which writes a control character as "_".
const format = /'([^']*)'\s*$/.exec(cmd)?.[1] ?? /"([^"]*)"\s*$/.exec(cmd)?.[1] ?? "";
return `${format.replace(/#\{session_name\}/g, process.env.TEST_PHYSICAL_SESSION ?? "").replace(/#\{window_id\}/g, "@12")
.replace(/#\{pane_id\}/g, "%42").replace(/[\u0000-\u001f]/g, "_")}\n`;
} }));
it.each(["0", "person-terminal"])("real attach -> returned env -> actual From -> reply to original %s pane", async (physical) => {
+64 -9
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { execFileSync, execSync, spawnSync } from "node:child_process";
import { Command } from "commander";
import { attachCommand, resolveAttachContext } from "../src/commands/attach.js";
import { DaemonClient } from "../src/client.js";
@@ -170,15 +171,8 @@ describe("Attach CLI", () => {
});
it("resolveAttachContext detects tmux session metadata when TMUX_PANE is set", () => {
const savedPane = process.env["TMUX_PANE"];
process.env["TMUX_PANE"] = "%42";
const mockTmuxExec = vi.fn(() => "dev1-impl2@rigged-buildout\n@12\n%42");
const result = resolveAttachContext(mockTmuxExec);
if (savedPane === undefined) delete process.env["TMUX_PANE"];
else process.env["TMUX_PANE"] = savedPane;
const result = withTmuxPane("%42", () =>
resolveAttachContext(fakeTmux({ session: "dev1-impl2@rigged-buildout", window: "@12", pane: "%42" })));
expect(result).toEqual({
attachmentType: "tmux",
tmuxSession: "dev1-impl2@rigged-buildout",
@@ -186,4 +180,65 @@ describe("Attach CLI", () => {
tmuxPane: "%42",
});
});
// Real tmux rewrote the newlines of a combined format as "_" ("unrelated-shell_@2_%2"), and the session fell back to
// external_cli, so replies to the self-attached member had no transport.
it.each([
["the shape real tmux returned", "unrelated-shell"],
["a name holding underscores, spaces and id-like words", "team_1 rig_b @7 %9"],
])("resolveAttachContext resolves the pane from %s", (_label, session) => {
const result = withTmuxPane("%2", () => resolveAttachContext(fakeTmux({ session, window: "@2", pane: "%2" })));
expect(result).toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: "@2", tmuxPane: "%2" });
});
it("resolveAttachContext keeps external_cli outside tmux, and when tmux can't answer", () => {
const notCalled = vi.fn(() => "");
expect(withTmuxPane(undefined, () => resolveAttachContext(notCalled))).toEqual({ attachmentType: "external_cli" });
expect(notCalled).not.toHaveBeenCalled();
for (const tmux of [vi.fn(() => { throw new Error("no server running"); }), vi.fn(() => ""), vi.fn(() => "unexpected")]) {
expect(withTmuxPane("%2", () => resolveAttachContext(tmux))).toEqual({ attachmentType: "external_cli" });
}
});
});
const TMUX_SOCKET = `openrig-attach-test-${process.pid}`;
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
describe.runIf(tmuxAvailable)("resolveAttachContext against a real tmux server (private socket)", () => {
afterAll(() => { spawnSync("tmux", ["-L", TMUX_SOCKET, "kill-server"]); });
it("resolves the session, window and pane of a session whose name holds spaces and underscores", () => {
const session = "unrelated_shell b";
execFileSync("tmux", ["-L", TMUX_SOCKET, "-f", "/dev/null", "new-session", "-d", "-s", session]);
const read = (format: string) => execFileSync("tmux", ["-L", TMUX_SOCKET, "list-panes", "-t", `=${session}`, "-F", format],
{ encoding: "utf8" }).trim();
const pane = read("#{pane_id}");
const window = read("#{window_id}");
const onSocket = (cmd: string) => execSync(cmd.replace(/^tmux /, `tmux -L ${TMUX_SOCKET} `), { encoding: "utf8" }).trim();
expect(withTmuxPane(pane, () => resolveAttachContext(onSocket)))
.toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: window, tmuxPane: pane });
});
});
/** A `tmux display-message -p` double that renders the requested format as real tmux does, control characters
* included: tmux writes each one as "_". */
function fakeTmux(values: { session: string; window: string; pane: string }) {
return vi.fn((cmd: string) => {
const format = /'([^']*)'\s*$/.exec(cmd)?.[1] ?? /"([^"]*)"\s*$/.exec(cmd)?.[1];
if (format === undefined) throw new Error(`unexpected tmux command: ${cmd}`);
return format.replace(/#\{session_name\}/g, values.session).replace(/#\{window_id\}/g, values.window)
.replace(/#\{pane_id\}/g, values.pane).replace(/[\u0000-\u001f]/g, "_");
});
}
function withTmuxPane<T>(pane: string | undefined, run: () => T): T {
const saved = process.env["TMUX_PANE"];
if (pane === undefined) delete process.env["TMUX_PANE"];
else process.env["TMUX_PANE"] = pane;
try {
return run();
} finally {
if (saved === undefined) delete process.env["TMUX_PANE"];
else process.env["TMUX_PANE"] = saved;
}
}
+11
View File
@@ -156,6 +156,17 @@ describe("Capture CLI", () => {
expect(out).toEqual(["Do you want to\n"]);
expect(err).toEqual([`[rig capture: small@my-rig: ${SMALL_PANE_HINT}]`]);
});
it("sends 20 lines for non-positive --lines values", async () => {
for (const lines of ["0", "-1"]) {
const before = requests.length;
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "capture", "tall@my-rig", `--lines=${lines}`]);
});
expect(requests.length).toBeGreaterThan(before);
expect(requests[requests.length - 1]!.lines).toBe(20);
}
});
});
describe("--readable reads a small seat at a readable size", () => {
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { chatroomWatchTmuxArgs } from "../src/commands/chatroom.js";
const sessionArg = (rig: string) => { const args = chatroomWatchTmuxArgs(rig); return args[args.indexOf("-s") + 1]; };
describe("chatroom watch --tmux arguments", () => {
it("hands tmux the watcher as separate arguments, with the rig name after --, and asks for the name it used", () => {
expect(chatroomWatchTmuxArgs("my rig")).toEqual(["new-session", "-d", "-P", "-F", "#{session_name}", "-s", "chatroom@my rig",
"rig", "chatroom", "watch", "--", "my rig"]);
});
// tmux reads -s as a format: `##` is one `#`, and a run of `#` before `[` is a style it keeps as written.
it.each([
["my-rig", "chatroom@my-rig"],
["a#b", "chatroom@a##b"],
["a##b", "chatroom@a####b"],
["a#", "chatroom@a##"],
["a#{b}", "chatroom@a##{b}"],
["a#[fg=red]b", "chatroom@a#[fg=red]b"],
["a##[b", "chatroom@a##[b"],
])("encodes %j for tmux's session-name format as %j", (rig, session) => {
expect(sessionArg(rig)).toBe(session);
});
// tmux ends a command at an argument ending in `;` unless a `\` comes before it.
it.each([
["a;", "chatroom@a\\;", "a\\;"],
[";", "chatroom@\\;", "\\;"],
["a\\;", "chatroom@a\\\\;", "a\\\\;"],
["a;b", "chatroom@a;b", "a;b"],
])("puts a \\ before a final ; in %j", (rig, session, watcherArg) => {
const args = chatroomWatchTmuxArgs(rig);
expect(sessionArg(rig)).toBe(session);
expect(args[args.length - 1]).toBe(watcherArg);
});
});
const TMUX_SOCKET = `openrig-chatroom-test-${process.pid}`;
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "a$HOME", "-rig",
"a#b", "a##b", "a#{b}", "a#[fg=red]b", "a##[b", "a;", ";"];
describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server (private socket)", () => {
let dir = "";
let recorder = "";
beforeAll(() => {
dir = mkdtempSync(path.join(tmpdir(), "openrig-chatroom-watch-"));
// Stands in for `rig`: records the arguments the watcher gets, then stays up so the session lives.
recorder = path.join(dir, "record.cjs");
writeFileSync(recorder, "require('node:fs').writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3)));\nsetTimeout(() => {}, 30000);\n");
});
afterAll(() => {
spawnSync("tmux", ["-L", TMUX_SOCKET, "kill-server"]);
rmSync(dir, { recursive: true, force: true });
});
// The exact arguments, with only the `rig` program swapped for the recorder; tmux prints the session it made.
function start(rig: string, out: string): string {
const args = chatroomWatchTmuxArgs(rig);
const program = args.indexOf("-s") + 2;
return execFileSync("tmux", ["-L", TMUX_SOCKET, "-f", "/dev/null", ...args.slice(0, program), process.execPath, recorder, out,
...args.slice(program + 1)], { encoding: "utf8" }).trim();
}
it.each(names.map((rig, i) => [rig, i] as const))("keeps the session chatroom@%s and hands the watcher the name as one argument", async (rig, i) => {
const out = path.join(dir, `argv-${i}.json`);
expect(start(rig, out)).toBe(`chatroom@${rig}`);
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
});
// Whether tmux changes a `.` depends on its version (3.5a writes `_`; the tmux CI runs keeps the `.`), so the CLI
// prints the name tmux reports, which is always the session tmux has.
it("reports the name tmux used for a rig name with a ., whichever form its version keeps", async () => {
const out = path.join(dir, "argv-dot.json");
const reported = start("release-0.7", out);
expect(["[email protected]", "chatroom@release-0_7"]).toContain(reported);
expect(execFileSync("tmux", ["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"], { encoding: "utf8" }).split("\n"))
.toContain(reported);
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", "release-0.7"]), { timeout: 5000 });
});
});
@@ -0,0 +1,67 @@
// The MCP adapter's per-request client tells the daemon whose grant it checked (X-OpenRig-Principal), and only this host's
// own daemon: an endpoint that's forwarded (a loopback port reaching another instance) or remote never receives it.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DaemonClient } from "../src/client.js";
const PRINCIPAL = { source: "file" as const, id: "alice" };
const BASE = "http://127.0.0.1:7999";
describe("DaemonClient's principal header goes only to this host's own daemon", () => {
let home: string;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "client-principal-"));
vi.stubEnv("OPENRIG_HOME", home);
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(home, { recursive: true, force: true });
});
/** The headers of the first daemon request, with the endpoint answering /healthz as `endpointId`. */
async function headersSent(opts: { direct?: boolean; endpointId?: string; remote?: boolean; principal?: { source: "file" | "grant-service"; id: string } }): Promise<Record<string, string>> {
vi.stubEnv("OPENRIG_URL", opts.direct ? BASE : "");
const sent: Array<Record<string, string>> = [];
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
if (String(url).endsWith("/healthz")) return Response.json({ ok: true, ...(opts.endpointId ? { selfHostId: opts.endpointId } : {}) });
sent.push({ ...(init?.headers as Record<string, string>) });
return Response.json([]);
}) as typeof fetch;
const client = new DaemonClient(BASE, { sessionName: "alice@team", principal: opts.principal ?? PRINCIPAL, fetchImpl });
client.originSelfHostId = "this-host";
if (opts.remote) client.remoteTarget = true;
await client.get("/api/queue/list");
return sent[0]!;
}
it("sends it to an endpoint that answers with this host's own id, configured or not", async () => {
for (const headers of [await headersSent({ endpointId: "this-host" }), await headersSent({ direct: true, endpointId: "this-host" })]) {
expect(headers["X-OpenRig-Principal"]).toBe(JSON.stringify(PRINCIPAL));
expect(headers["X-OpenRig-Session"]).toBe("alice@team");
}
});
it("carries a name beyond Latin-1 as JSON escapes, which a real request's headers accept and the daemon parses back", async () => {
const principal = { source: "file" as const, id: "测试-café" };
const headers = await headersSent({ endpointId: "this-host", principal });
const value = headers["X-OpenRig-Principal"]!;
expect(value).toMatch(/^[\x20-\x7e]*$/);
expect(() => new Headers(headers)).not.toThrow();
expect(JSON.parse(value)).toEqual(principal);
});
it("never sends it to a forwarded endpoint (another instance's id, or none) or a remote target", async () => {
for (const headers of [
await headersSent({ direct: true, endpointId: "other-host" }),
await headersSent({ direct: true }),
// rig mcp serve --port <forwarded port>: no OPENRIG_URL, an explicit loopback URL that reaches another instance.
await headersSent({ endpointId: "other-host" }),
await headersSent({}),
await headersSent({ remote: true }),
]) {
expect(headers["X-OpenRig-Principal"]).toBeUndefined();
}
});
});
@@ -285,6 +285,32 @@ describe("capture --host (cross-host short-circuit)", () => {
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "5x"]);
});
it("forwards a fractional --lines as given: a positive parseInt means the remote decides", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = captureCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "--lines=3.5"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "3.5"]);
});
it("forwards a non-positive or non-numeric --lines as the documented default of 20", async () => {
for (const value of ["0", "-1", "abc"]) {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = captureCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", `--lines=${value}`], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "20"]);
}
});
it("forwards --history-plus-pane with the --lines it applies to", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = captureCommand(deps({
+16 -6
View File
@@ -351,7 +351,7 @@ describe("send --host (http branch)", () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--wait-for-idle", "30"], { from: "user" });
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000 + 30_000);
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000 + 60_000);
expect((h.calls[0]!.body as Record<string, unknown>).waitForIdleMs).toBe(30_000);
});
@@ -359,14 +359,14 @@ describe("send --host (http branch)", () => {
const h = mockClient(() => ({ status: 200, data: { ok: true } }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--wait-for-idle", "400"], { from: "user" });
expect(h.calls.map((c) => [(c.body as Record<string, unknown>).waitForIdleMs, c.options?.timeoutMs])).toEqual([[400_000, 400_000 + 30_000]]);
expect(h.calls.map((c) => [(c.body as Record<string, unknown>).waitForIdleMs, c.options?.timeoutMs])).toEqual([[400_000, 400_000 + 60_000]]);
});
it("a cross-host send waits 30 s for the remote to deliver, not the 5 s read default", async () => {
it("a cross-host send waits 60 s for the remote to deliver, not the 5 s read default", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello"], { from: "user" });
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000);
expect(h.calls[0]!.options?.timeoutMs).toBe(60_000);
});
it("a remote that delivers after more than 5 s reports the send, not a failure", { timeout: 20_000 }, async () => {
@@ -414,7 +414,10 @@ describe("send --host (http branch)", () => {
const err = captured.stderrLines.join("\n");
expect(err).toContain("cross-host (host=vps-b, http://vps-b:7433): http remote-outcome-unknown:");
expect(err).toContain("Delivery UNCONFIRMED — the remote daemon may have received and delivered the message.");
expect(err).toContain("Check the target before any resend: rig capture dev-impl@my-rig --host vps-b. A resend without checking risks a duplicate.");
expect(err).toContain("rig capture dev-impl@my-rig --host vps-b");
expect(err).toContain("off-screen");
expect(err).toContain("does not prove non-delivery");
expect(err).toContain("duplicate");
expect(err).not.toContain("remote-daemon-unreachable");
expect(captured.stdoutLines).not.toContain("Sent to dev-impl@my-rig");
expect(process.exitCode).toBe(1);
@@ -438,7 +441,7 @@ describe("send --host (http branch)", () => {
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--json"], { from: "user" });
const parsed = JSON.parse(captured.stdoutLines[0]!) as Record<string, any>;
expect(parsed.result).toMatchObject({ ok: false, failedStep: "remote-outcome-unknown", outcomeUnknown: true });
expect(parsed.error.action).toContain("Check the target before any resend");
expect(parsed.error.action).toContain("does not prove non-delivery");
expect(process.exitCode).toBe(1);
});
@@ -501,6 +504,13 @@ describe("capture --host (http branch)", () => {
expect(captured.stdoutLines).toContain("pane content here");
});
it("http host: a non-positive --lines posts the documented default of 20, not 0", async () => {
const h = mockClient(() => ({ status: 200, data: { content: "pane content here" } }));
const cmd = captureCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "--lines=0"], { from: "user" });
expect(h.calls[0]!.body).toEqual({ lines: 20, session: "dev-impl@my-rig", inputBox: true });
});
it("anonymous (URL-only) http host: capture POSTs with NO Authorization header; still succeeds", async () => {
const h = mockClient(() => ({ status: 200, data: { content: "pane content here" } }));
const cmd = captureCommand(httpDeps(h));
@@ -21,18 +21,18 @@ describe("S20 — buildDaemonEnv: routing env never crosses into the daemon", ()
it("declared intent exports the DEDICATED bind env plus a COHERENT routing env (seats route where the daemon binds)", () => {
const env = buildDaemonEnv(
{ HOME: "/Users/op", OPENRIG_HOST: "10.0.0.9" }, // stale inherited routing is still ignored
{ port: 7433, host: "100.95.124.51", db: "/tmp/t.db" },
{ port: 7433, host: "100.88.0.20", db: "/tmp/t.db" },
);
expect(env["OPENRIG_BIND_HOST"]).toBe("100.95.124.51");
expect(env["OPENRIG_HOST"]).toBe("100.95.124.51"); // the routing half follows the DECLARED intent
expect(env["OPENRIG_BIND_HOST"]).toBe("100.88.0.20");
expect(env["OPENRIG_HOST"]).toBe("100.88.0.20"); // the routing half follows the DECLARED intent
});
it("an inherited OPENRIG_BIND_HOST passes through — the dedicated surface IS unambiguous operator opt-in", () => {
const env = buildDaemonEnv(
{ HOME: "/Users/op", OPENRIG_BIND_HOST: "100.95.124.51" },
{ HOME: "/Users/op", OPENRIG_BIND_HOST: "100.88.0.20" },
{ port: 7433, db: "/tmp/t.db" },
);
expect(env["OPENRIG_BIND_HOST"]).toBe("100.95.124.51");
expect(env["OPENRIG_BIND_HOST"]).toBe("100.88.0.20");
});
});
@@ -133,13 +133,13 @@ describe("S20 — the restored listener gate: adoption fails loudly on a dropped
it("explicit mode requires exactly the declared host", async () => {
const ok = await verifyRequiredListeners({
bind: { mode: "explicit", hosts: ["100.95.124.51"], tailscaleDetected: true },
bind: { mode: "explicit", hosts: ["100.88.0.20"], tailscaleDetected: true },
port: 7433,
probe: probeUp(new Set(["100.95.124.51"])),
probe: probeUp(new Set(["100.88.0.20"])),
});
expect(ok.ok).toBe(true);
const bad = await verifyRequiredListeners({
bind: { mode: "explicit", hosts: ["100.95.124.51"], tailscaleDetected: false },
bind: { mode: "explicit", hosts: ["100.88.0.20"], tailscaleDetected: false },
port: 7433,
probe: probeUp(new Set()),
});
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { waitForKernelReady, type KernelReadyResult } from "../src/daemon-lifecycle.js";
import { daemonCommand } from "../src/commands/daemon.js";
vi.mock("../src/daemon-lifecycle.js", async (original) => {
const actual = await original<typeof import("../src/daemon-lifecycle.js")>();
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "stopped" })),
startDaemon: vi.fn(async () => ({ pid: 4242, port: 7433, host: "127.0.0.1" })),
waitForKernelReady: vi.fn(),
};
});
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { run = async () => ({ ready: true, checks: [] }); }, quietPreflightExec: async () => "" }));
let home: string;
let errors: string[];
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "daemon-start-wait-for-kernel-"));
vi.stubEnv("OPENRIG_HOME", home);
errors = [];
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { errors.push(args.join(" ")); });
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); process.exitCode = undefined; });
async function startWaiting(result: Partial<KernelReadyResult>) {
vi.mocked(waitForKernelReady).mockResolvedValue({ ok: false, kernelState: null, variant: null, detail: null, ...result });
await daemonCommand({} as never).parseAsync(["start", "--wait-for-kernel"], { from: "user" });
return errors.join("\n");
}
it("a kernel already down before this start: says so and how to bring it back, not a timeout or an auth fix", async () => {
const out = await startWaiting({ kernelState: "skipped",
detail: "kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing" });
expect(out).toContain("Kernel auto-boot skipped: kernel rig already managed; it was already down before this daemon start");
expect(out).toContain("rig up kernel --existing");
expect(out).not.toContain("did not reach ready");
expect(out).not.toContain("auth status");
expect(process.exitCode).toBe(1);
});
it("a kernel skipped on purpose: names the opt-out, not a timeout or an auth fix", async () => {
const out = await startWaiting({ kernelState: "skipped", detail: "OPENRIG_NO_KERNEL=1" });
expect(out).toContain("Kernel auto-boot skipped (--no-kernel or test mode)");
expect(out).not.toContain("did not reach ready");
expect(out).not.toContain("auth status");
expect(process.exitCode).toBe(1);
});
it("a kernel still booting at the deadline keeps the timeout and its fix", async () => {
const out = await startWaiting({ kernelState: "booting" });
expect(out).toContain("Error: kernel did not reach ready / partial_ready within 60000ms.");
expect(out).toContain("Reason: kernel_state=booting");
expect(out).toContain("Fix: inspect `rig ps --rig kernel` for stalled agents");
expect(process.exitCode).toBe(1);
});
+8
View File
@@ -52,6 +52,14 @@ describe("rig edge", () => {
beforeEach(() => { vi.stubEnv("OPENRIG_HOST_SELECTED", "local"); });
afterEach(() => { vi.unstubAllEnvs(); });
it("plans a deferred target by its address rather than printing a null node", async () => {
const named = { ...edge, kind: "escalates_to", to: { logicalId: "review-advisor@coord", nodeId: null } };
const post = vi.fn(async () => ({ status: 200, data: { ok: true, rigId: "rig-1", outcome: "would_add", edge: named } }));
const result = await run({ post } as never, ["add", "rig-1", "orch.lead", "review-advisor@coord", "--kind", "escalates_to", "--plan"]);
expect(result.out.join("\n")).toContain("review-advisor@coord (resolved when used)");
expect(result.out.join("\n")).not.toContain("node null");
});
it.each(["add", "remove"].flatMap((operation) =>
["ECONNREFUSED", "EPERM"].map((causeCode) => ({ operation, causeCode })),
))("returns JSON without an edge request when the daemon is unavailable: $operation / $causeCode", async ({ operation, causeCode }) => {
+1 -1
View File
@@ -41,7 +41,7 @@ describe("fetch guard — allowlist by shape, fail-closed, three-sided", () => {
});
it("REFUSES a NON-LOOPBACK target (the machine-boundary arm — hosts.yaml's real remote)", async () => {
await expect(fetch("http://100.95.124.51:7433/api/x")).rejects.toThrow(/FETCH GUARD|non-loopback/i);
await expect(fetch("http://192.0.2.10:7433/api/x")).rejects.toThrow(/FETCH GUARD|non-loopback/i);
});
it("FAILS CLOSED on a loopback WELL-KNOWN/low port (< ephemeral) that is not registered", async () => {
+1 -1
View File
@@ -121,7 +121,7 @@ function refusal(t: Target): Error {
(isDaemon ? " (the canonical OpenRig daemon port)" : nonLoopback ? " (a NON-LOOPBACK / cross-machine target)" : "") +
" — a cli test made a REAL request to a target that is neither a loopback-ephemeral fixture nor registered. " +
(nonLoopback
? "Non-loopback is refused unconditionally (a test must not cross a machine boundary — hosts.yaml names a real remote at 100.95.124.51)."
? "Non-loopback is refused unconditionally (a test must not cross a machine boundary)."
: "Loopback on a well-known/canonical port is a request-layer leak to the daemon (a hardcoded :7433 literal, a mocked URL getter + un-mocked client, or a production default constant). Inject a mock client, use an in-process fixture (server.listen(0) → ephemeral, permitted), or register a fixed-low-port fixture with allowFetchTarget(url)."),
);
}
+74
View File
@@ -232,6 +232,80 @@ describe("doctorLegs — Source-1 learning from the healthz body already in hand
} as never);
expect(rows.find((r) => r.step === "host-identity-binding")).toBeUndefined();
});
it("first contact where replies do resolve here keeps the claim and adds no route row", async () => {
const { doctorLegs } = await import("../src/commands/host.js");
const rows = await doctorLegs(anonHost, {
...baseDeps,
httpGet: async () => ({ status: 200, body: JSON.stringify({ selfHostId: "host-84c37990" }) }),
learnHostBinding: (_alias: string, id: string) => ({ outcome: "bound", binding: { hostId: id, firstObservedAt: "x", lastObservedAt: "x" } }),
replyRoute: () => anonHost,
} as never);
expect(rows.find((r) => r.step === "host-identity-binding")!.detail).toContain("now resolve to 'mm2-host'");
expect(rows.find((r) => r.step === "reply-route")).toBeUndefined();
});
// An earlier hosts.yaml entry that learned the same identity keeps the replies, even after its
// address stops answering. Real learner and real resolver over one sidecar file.
it("an earlier entry with the same identity: doctor names it on the working entry and fails it when dead", async () => {
const { doctorLegs } = await import("../src/commands/host.js");
const p = tmpPath();
const stale = { id: "old-box-b", transport: "http", url: "http://10.0.0.9:7433" } as const;
const working = { id: "box-b", transport: "http", url: "http://10.0.0.5:7433" } as const;
const registry: HostRegistry = { hosts: [stale, working] };
recordHostObservation({ alias: stale.id, observedHostId: "host-0b0b0b0b", observedUrl: stale.url, path: p });
const deps = {
...baseDeps,
learnHostBinding: (alias: string, id: string, url: string) => recordHostObservation({ alias, observedHostId: id, observedUrl: url, path: p }),
replyRoute: (id: string) => {
const r = resolveHost(registry, id, loadHostBindings(p).bindings);
return r.ok ? r.host : null;
},
learnedHostId: (alias: string) => loadHostBindings(p).bindings[alias]?.hostId ?? null,
};
const live = await doctorLegs(working, { ...deps, httpGet: async () => ({ status: 200, body: JSON.stringify({ selfHostId: "host-0b0b0b0b" }) }) } as never);
const identity = live.find((r) => r.step === "host-identity-binding")!;
expect(identity.status).toBe("pass");
expect(identity.detail).not.toContain("now resolve to");
const route = live.find((r) => r.step === "reply-route")!;
expect(route.status).toBe("unknown");
expect(route.detail).toContain("go to 'old-box-b', not 'box-b'");
expect(route.fix).toContain("rig host doctor old-box-b");
// The entry doctor names is the one rig send's own resolver picks.
const sent = resolveCrossHostTarget("pm@some-rig@host-0b0b0b0b", undefined, () => ({ ok: true as const, registry }), undefined, () => loadHostBindings(p));
expect(sent).toMatchObject({ ok: true, sugarHost: "old-box-b" });
const dead = await doctorLegs(stale, { ...deps, httpGet: async () => { throw new Error("read ECONNRESET"); } } as never);
expect(dead.find((r) => r.step === "transport-reachability")!.status).toBe("fail");
const deadRoute = dead.find((r) => r.step === "reply-route")!;
expect(deadRoute.status).toBe("fail");
expect(deadRoute.detail).toContain("@host-0b0b0b0b resolve to 'old-box-b'");
expect(deadRoute.fix).toContain("delete 'old-box-b'");
});
it("an unreachable entry that replies don't resolve to adds no route row", async () => {
const { doctorLegs } = await import("../src/commands/host.js");
const rows = await doctorLegs(anonHost, {
...baseDeps,
httpGet: async () => { throw new Error("connect ECONNREFUSED"); },
learnedHostId: () => "host-84c37990",
replyRoute: () => ({ id: "other-host", transport: "http", url: "http://y:7433" }),
} as never);
expect(rows.map((r) => r.step)).toEqual(["transport-reachability"]);
});
it("an unreachable ssh entry whose declared hostId replies resolve to gets the route row too", async () => {
const { doctorLegs } = await import("../src/commands/host.js");
const sshHost = { id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig", hostId: "host-84c37990" } as const;
const rows = await doctorLegs(sshHost, {
...baseDeps,
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "connect refused" }),
httpGet: async () => ({ status: 200, body: "" }),
replyRoute: (id: string) => (id === "host-84c37990" ? sshHost : null),
} as never);
expect(rows.find((r) => r.step === "reply-route")).toMatchObject({ status: "fail" });
});
});
describe("F2 — concurrency guard: conflict records survive concurrent writers", () => {
+12 -4
View File
@@ -69,12 +69,20 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
return prog.parseAsync(["node", "rig", "host", ...argv]);
}
it("approved walk: registry entry with bearer_file + a 0600 token file; code printed", async () => {
it.each([false, true])("approved walk: registry entry with bearer_file + a 0600 token file (json=%s)", async (json) => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "approved", token: "issued-bearer" } }] });
const { out, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
const { out, err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10", ...(json ? ["--json"] : [])]));
expect(exitCode).toBeUndefined();
expect(out.join("\n")).toContain("Pairing code: 424242");
expect(out.join("\n")).toContain("Paired. Host 'vps-a' registered");
expect(err).toEqual([]);
if (json) {
expect(JSON.parse(out.join("\n"))).toMatchObject({ ok: true, entry: { id: "vps-a", transport: "http" } });
} else {
expect(out.join("\n")).toContain("Pairing code: 424242");
expect(out.join("\n")).toContain("Paired. Host 'vps-a' registered");
expect(out.join("\n")).toContain("rig host doctor vps-a");
expect(out.join("\n")).toContain("rig config set feed.subscriptions.vps-a.enabled true");
}
expect(fs.existsSync(path.join(dir, "config.json"))).toBe(false);
const tokenPath = path.join(dir, "secrets", "host-vps-a.token");
expect(fs.readFileSync(tokenPath, "utf8")).toBe("issued-bearer\n");
+58 -2
View File
@@ -6,6 +6,8 @@ import { Command } from "commander";
import { hostCommand, doctorLegs, postureCheck, type DoctorDeps, type CheckRow } from "../src/commands/host.js";
import type { HostEntry } from "../src/host-registry.js";
import type { CrossHostResult } from "../src/cross-host-executor.js";
import { ConfigStore } from "../src/config-store.js";
import { configCommand } from "../src/commands/config.js";
// OPR.0.4.4.13 FR-1 — rig host add / list. Registry path is steered via
// OPENRIG_HOME so tests never touch the operator's real ~/.openrig.
@@ -50,6 +52,38 @@ describe("rig host add/list", () => {
expect(exitCode).toBeUndefined();
expect(out.join("\n")).toContain("Added host 'vps-1' (ssh)");
expect(out.join("\n")).toContain("rig host doctor vps-1");
expect(out.join("\n")).toContain("Remote FEED requires an HTTP host entry");
expect(out.join("\n")).not.toContain("rig config set feed.subscriptions.");
});
it.each(["laptop", "laptop.prod-1"])("HTTP add advertises a working opt-in command for %s", async (id) => {
const configPath = path.join(dir, "config.json");
const store = new ConfigStore(configPath);
store.set("feed.subscriptions.progress", "false");
const before = fs.readFileSync(configPath, "utf8");
const { out, exitCode } = await capture(() => run(["add", "--id", id, "--transport", "http", "--url", "http://laptop:7433"]));
expect(exitCode).toBeUndefined();
expect(fs.readFileSync(configPath, "utf8")).toBe(before);
const nextStep = out.map((line) => line.trim()).find((line) => line.startsWith("rig config set "));
expect(nextStep).toBeDefined();
const program = new Command().addCommand(configCommand(configPath));
const applied = await capture(() => program.parseAsync(["node", ...nextStep!.split(/\s+/)]));
expect(applied.exitCode).toBeUndefined();
expect(applied.err).toEqual([]);
const saved = new ConfigStore(configPath);
expect(saved.get(`feed.subscriptions.${id}.enabled`)).toBe(true);
expect(saved.get("feed.subscriptions.progress")).toBe(false);
expect(saved.get("feed.subscriptions.other-host.enabled")).toBe(false);
});
it("an HTTP host with a reserved feed name stays registered without an unusable command", async () => {
const { out, exitCode } = await capture(() => run(["add", "--id", "progress", "--transport", "http", "--url", "http://laptop:7433"]));
expect(exitCode).toBeUndefined();
expect(fs.readFileSync(path.join(dir, "hosts.yaml"), "utf8")).toContain("id: progress");
expect(out.join("\n")).toContain("reserved by feed settings");
expect(out.join("\n")).not.toContain("rig config set feed.subscriptions.");
expect(fs.existsSync(path.join(dir, "config.json"))).toBe(false);
});
it("add surfaces the loader's own validation error at add-time (both bearers)", async () => {
@@ -60,7 +94,9 @@ describe("rig host add/list", () => {
it("list renders pointers and NEVER a resolved secret value (qa1 hygiene guard)", async () => {
vi.stubEnv("FACTORY_TOKEN", "super-secret-bearer-value");
await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN"]));
const added = await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN", "--json"]));
expect(JSON.parse(added.out.join("\n"))).toMatchObject({ ok: true, entry: { id: "f1" } });
expect(added.err).toEqual([]);
const { out } = await capture(() => run(["list"]));
const text = out.join("\n");
expect(text).toContain("env:FACTORY_TOKEN");
@@ -235,7 +271,7 @@ describe("rig host doctor — stepwise distinct errors + three-valued posture",
if (cmd.includes("ss -tln")) return okRun(`LISTEN 0 511 ${addr}:7433 0.0.0.0:*`);
return okRun("");
});
const inRange = await postureCheck(sshHost, mk("100.103.199.127"));
const inRange = await postureCheck(sshHost, mk("100.88.0.21"));
expect(inRange.find((r) => r.step === "daemon-bind-not-public")!.status).toBe("pass");
const outOfRange = await postureCheck(sshHost, mk("100.1.2.3"));
expect(outOfRange.find((r) => r.step === "daemon-bind-not-public")!.status).toBe("fail");
@@ -340,6 +376,26 @@ describe("rig host doctor — stepwise distinct errors + three-valued posture",
expect(err.join("\n")).toContain("unknown host id 'nope'");
});
it("doctor's reply route reads the same hosts.yaml and host-bindings.json rig send does", async () => {
const { replyRouteFromDisk } = await import("../src/commands/host.js");
const { resolveCrossHostTarget } = await import("../src/cross-host-target.js");
fs.writeFileSync(path.join(dir, "hosts.yaml"), [
"hosts:",
" - id: old-box-b",
" transport: http",
" url: http://10.0.0.9:7433",
" - id: box-b",
" transport: http",
" url: http://10.0.0.5:7433",
].join("\n") + "\n");
// Both aliases learned one identity; hosts.yaml order, not sidecar order, picks the entry.
const learned = { hostId: "host-0b0b0b0b", firstObservedAt: "x", lastObservedAt: "x" };
fs.writeFileSync(path.join(dir, "host-bindings.json"), JSON.stringify({ version: 1, bindings: { "box-b": learned, "old-box-b": learned } }));
expect(replyRouteFromDisk("host-0b0b0b0b")?.id).toBe("old-box-b");
expect(resolveCrossHostTarget("pm@some-rig@host-0b0b0b0b", undefined)).toMatchObject({ ok: true, sugarHost: "old-box-b" });
expect(replyRouteFromDisk("host-unknown")).toBeNull();
});
// seed a registry for the doctor CLI test above
async function capturePrelude(): Promise<void> {
const prog = new Command();
+151
View File
@@ -0,0 +1,151 @@
// MCP clients on protocol 2026-07-28 reach the same durable work loop as 2025 clients: discovery, then a task created by
// one seat and claimed by another through the daemon's real queue routes, over Streamable HTTP and over stdio. A client
// pinned to 2026-07-28 never falls back to the 2025 initialize handshake, so it fails against a server that speaks only
// 2025. The 2025 suites (mcp-http-daemon, mcp-queue-daemon and the rest) keep proving the older clients.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import { serve, type ServerType } from "@hono/node-server";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import type Database from "better-sqlite3";
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
import { addGrant, startMcpHttpServer } from "../src/mcp-http.js";
const DESK = "desk@team";
const IMPL = "impl@team";
const PINNED = { versionNegotiation: { mode: { pin: "2026-07-28" } } } as const;
type Call = (name: string, args?: Record<string, unknown>) => Promise<{ isError: boolean; structured: Record<string, any>; text: string }>;
describe("MCP 2026-07-28 clients on the daemon's queue routes", () => {
let db: Database.Database;
let home: string;
let repo: QueueRepository;
let daemon: ServerType;
let daemonUrl: string;
const closers: Array<() => Promise<void>> = [];
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
beforeEach(async () => {
db = createDb(":memory:");
migrate(db, ALL_MIGRATIONS);
home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-2026-"));
process.env.OPENRIG_HOME = home;
delete process.env.OPENRIG_SESSION_NAME;
const inert = async () => ({ ok: false, error: "inert fixture" });
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
repo.attachOutbox(new OutboxHandler(db));
const { app } = createTestApp(db, {
appDeps: {
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
queueRepo: repo,
},
});
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
});
afterEach(async () => {
for (const close of closers.splice(0).reverse()) await close();
await new Promise<void>((resolve) => daemon.close(() => resolve()));
db.close();
fs.rmSync(home, { recursive: true, force: true });
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
});
async function connected(client: Client, transport: StreamableHTTPClientTransport | StdioClientTransport): Promise<Call> {
await client.connect(transport);
closers.push(() => client.close());
return async (name, args = {}) => {
const result = await client.callTool({ name, arguments: args });
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
};
}
/** Creates a task from DESK to IMPL as `asDesk`, and claims it as `asImpl`; returns the task's id. */
async function createAndClaim(asDesk: Call, asImpl: Call): Promise<string> {
const created = await asDesk("rig_queue_create", { destination: IMPL, body: "build the thing" });
expect(created.isError, created.text).toBe(false);
const qitemId = created.structured.qitemId as string;
const claimed = await asImpl("rig_queue_claim", { qitemId });
expect(claimed.isError, claimed.text).toBe(false);
expect(repo.getById(qitemId)).toMatchObject({ sourceSession: DESK, destinationSession: IMPL, state: "in-progress" });
return qitemId;
}
it("over Streamable HTTP: a pinned or auto client is served 2026-07-28, a default client 2025, each as its grant's seat", async () => {
const tokenFile = path.join(home, "mcp-http-tokens.json");
const deskToken = await addGrant(tokenFile, { name: "desk", seat: DESK, rigs: ["team"], roots: [], workspace: false, owner: false });
const implToken = await addGrant(tokenFile, { name: "impl", seat: IMPL, rigs: ["team"], roots: [], workspace: false, owner: false });
const served = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile });
closers.push(() => served.close());
const over = (token: string) => new StreamableHTTPClientTransport(new URL(served.url), { requestInit: { headers: { Authorization: `Bearer ${token}` } } });
for (const [mode, options, era] of [
["pinned", PINNED, "modern"],
["auto", { versionNegotiation: { mode: "auto" } }, "modern"],
["default", {}, "legacy"],
] as const) {
const deskClient = new Client({ name: `desk-${mode}`, version: "1.0.0" }, options);
const asDesk = await connected(deskClient, over(deskToken));
const implClient = new Client({ name: `impl-${mode}`, version: "1.0.0" }, options);
const asImpl = await connected(implClient, over(implToken));
expect([mode, deskClient.getProtocolEra(), implClient.getProtocolEra()]).toEqual([mode, era, era]);
const tools = (await deskClient.listTools()).tools.map((tool) => tool.name);
expect(tools).toEqual(expect.arrayContaining(["rig_queue_create", "rig_queue_claim", "rig_queue_list", "rig_queue_show"]));
await createAndClaim(asDesk, asImpl);
}
});
it("over HTTP, a bad credential is still refused for a pinned client, and loopback without auth still refuses a foreign Host", async () => {
const tokenFile = path.join(home, "mcp-http-tokens.json");
await addGrant(tokenFile, { name: "desk", seat: DESK, rigs: ["team"], roots: [], workspace: false, owner: false });
const authed = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile });
closers.push(() => authed.close());
const stranger = new Client({ name: "stranger", version: "1.0.0" }, PINNED);
await expect(stranger.connect(new StreamableHTTPClientTransport(new URL(authed.url), { requestInit: { headers: { Authorization: "Bearer not-a-grant" } } })))
.rejects.toThrow(/401/);
const open = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "none.json") });
closers.push(() => open.close());
const { port } = new URL(open.url);
const answer = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port: Number(port), path: "/mcp", method: "POST",
headers: { Host: `rebound.example:${port}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" } }, (res) => {
let body = ""; res.on("data", (chunk) => { body += chunk; }); res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
});
req.on("error", reject);
req.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }));
});
expect(answer.status).toBe(403);
expect(JSON.parse(answer.body)).toEqual({ jsonrpc: "2.0", error: { code: -32000, message: `Invalid Host header: rebound.example:${port}` }, id: null });
});
it("over stdio: a pinned client is served 2026-07-28 by rig mcp serve, as the env's seat", async () => {
const source = new URL("../src/commands/mcp.ts", import.meta.url).href;
const script = `import { mcpCommand } from ${JSON.stringify(source)}; await mcpCommand().parseAsync(["serve", "--port", ${JSON.stringify(new URL(daemonUrl).port)}], { from: "user" });`;
const spawnAs = (seat: string) => new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", "--input-type=module", "-e", script],
env: { ...process.env as Record<string, string>, OPENRIG_HOME: home, OPENRIG_SESSION_NAME: seat },
stderr: "pipe",
});
const deskClient = new Client({ name: "desk-stdio", version: "1.0.0" }, PINNED);
const asDesk = await connected(deskClient, spawnAs(DESK));
const implClient = new Client({ name: "impl-stdio", version: "1.0.0" }, PINNED);
const asImpl = await connected(implClient, spawnAs(IMPL));
expect([deskClient.getProtocolEra(), implClient.getProtocolEra()]).toEqual(["modern", "modern"]);
await createAndClaim(asDesk, asImpl);
}, 30_000);
});
+160 -1
View File
@@ -19,8 +19,14 @@ import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
import { MissionControlWriteContract } from "../../daemon/src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlActionLog } from "../../daemon/src/domain/mission-control/mission-control-action-log.js";
import { loadHumanRegistry, type HumanFragment } from "../../daemon/src/domain/gateway/human-registry.js";
import { makeQueuePorts } from "../../daemon/src/domain/gateway/slack/queue-access.js";
import { buildOutboundMessage } from "../../daemon/src/domain/gateway/slack/message.js";
import { mcpCommand } from "../src/commands/mcp.js";
import { addGrant, McpHttpConfigError, revokeGrant, startMcpHttpServer, withGrantsLock } from "../src/mcp-http.js";
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
const INITIALIZE = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "probe", version: "1" } } };
@@ -29,6 +35,9 @@ describe("rig mcp serve --http on the daemon's routes", () => {
let home: string;
let shared: string;
let repo: QueueRepository;
let bus: EventBus;
/** Registered people a test sets; null reads the instance's registry, as the daemon does. */
let people: HumanFragment[] | null;
let daemon: ServerType;
let daemonUrl: string;
let tokenFile: string;
@@ -47,7 +56,10 @@ describe("rig mcp serve --http on the daemon's routes", () => {
// The env seat a local caller is declared as; an authenticated caller must never act as it.
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
tokenFile = path.join(home, "mcp-http-tokens.json");
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
bus = new EventBus(db);
people = null;
repo = new QueueRepository(db, bus, { validateRig: () => true,
loadHumanRegistry: () => (people ? { ok: true as const, entities: people } : loadHumanRegistry()) });
// The wake store attached, as the daemon attaches it at startup: a handoff makes its successor's wake durable there.
repo.attachOutbox(new OutboxHandler(db));
const inert = async () => ({ ok: false, error: "inert fixture" });
@@ -150,6 +162,34 @@ describe("rig mcp serve --http on the daemon's routes", () => {
expect(who.text).toContain("\"principal\":\"bob\"");
});
it("tells the daemon which grant's caller acted: concurrent callers' rows each carry their own principal, never one a caller sends", async () => {
// This host's own daemon, provably: the id it minted (read from its database) is the id its /healthz answers with.
const identity = path.join(home, "identity.sqlite");
const idDb = createDb(identity);
idDb.exec("CREATE TABLE self_host_identity (singleton INTEGER PRIMARY KEY, host_id TEXT)");
idDb.prepare("INSERT INTO self_host_identity VALUES (1, 'test-host')").run();
idDb.close();
vi.stubEnv("OPENRIG_DB", identity);
setSelfHostId("test-host");
closers.push(async () => { setSelfHostId(null); vi.unstubAllEnvs(); });
const alice = await grant("alice");
const bob = await grant("bob");
const served = await listen();
// A caller's own X-OpenRig-Principal goes no further than this server: the daemon hears the grant's principal.
const forged = { "X-OpenRig-Principal": JSON.stringify({ source: "grant-service", id: "mallory", grant: "g", version: 1 }) };
const caller = async (token: string) => {
const client = new Client({ name: "principal-test", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL(served.url), { requestInit: { headers: { Authorization: `Bearer ${token}`, ...forged } } }));
closers.push(() => client.close());
return async (body: string) => ((await client.callTool({ name: "rig_queue_create", arguments: { destination: "impl@team", body } })).structuredContent as { qitemId: string }).qitemId;
};
const [asAlice, asBob] = await Promise.all([caller(alice), caller(bob)]);
const ids = await Promise.all([asAlice("one"), asBob("two"), asAlice("three"), asBob("four")]);
const stamp = (id: string) => db.prepare("SELECT identity_provenance AS p, identity_principal AS who FROM queue_transitions WHERE qitem_id = ?").get(id);
const as = (id: string) => ({ p: "principal:v1", who: JSON.stringify({ source: "file", id }) });
expect(ids.map(stamp)).toEqual([as("alice"), as("bob"), as("alice"), as("bob")]);
});
it("reaches only its scope: an out-of-scope write creates nothing, and items, successors, artifacts and owner tools stay out of reach", async () => {
const carol = await grant("carol");
const served = await listen();
@@ -198,6 +238,125 @@ describe("rig mcp serve --http on the daemon's routes", () => {
expect(viaItem.text).not.toContain("ARTIFACT body");
});
it("a hosted member works its own rows as its grant's seat, shows on the team with activity unknown, and keeps its record after revoke", async () => {
// The team has a lead with a node. The hosted member (a tool outside any terminal) has a roster entry and a grant, no node.
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-http-workspace-"));
const savedWorkspace = process.env.OPENRIG_WORKSPACE_ROOT;
process.env.OPENRIG_WORKSPACE_ROOT = workspace;
setSelfHostId("host-a");
try {
seat("team", "lead");
fs.mkdirSync(path.join(workspace, "rosters"));
fs.writeFileSync(path.join(workspace, "rosters", "team.json"), JSON.stringify({
version: 1, id: "team", name: "Team", purpose: "Ship the release", curator: { seat: "lead@team", host: "host-a" }, updated_at: "2026-10-26",
members: [{ seat: "grok@team", host: "host-a", capabilities: ["web research"], engagement: ["delegate"],
use_when: "A question needs current web sources", why: "Searches the web and cites it", caveat: "No terminal or files: answers in text only", hosted: true }],
}));
const token = await addGrant(tokenFile, { name: "grok", seat: "grok@team", rigs: ["team"], roots: [], workspace: false, owner: false });
const served = await listen();
// Presence is recorded only for an authenticated call, beside the token file and never in it.
const presenceFile = path.join(path.dirname(tokenFile), "mcp-http-presence.json");
expect((await post(served.url, {})).status).toBe(401);
expect(fs.existsSync(presenceFile)).toBe(false);
const asGrok = await connect(served.url, token);
const task = await repo.create({ sourceSession: "lead@team", destinationSession: "grok@team", summary: "When did 0.7.2 ship?", body: "Find the publish date, with a source.", nudge: false } as never);
const listed = await asGrok("rig_queue_list", { owned: true });
expect(listed.isError).toBe(false);
expect(listed.structured.items.map((row: { qitemId: string }) => row.qitemId)).toEqual([task.qitemId]);
expect(repo.getById(task.qitemId)?.state, "listing it doesn't take it").toBe("pending");
expect((await asGrok("rig_queue_claim", { qitemId: task.qitemId })).isError).toBe(false);
const claim = repo.listTransitions(task.qitemId).find((t) => t.state === "in-progress");
expect(claim?.actorSession).toMatch(/^grok@team/);
const answered = await asGrok("rig_queue_update", { qitemId: task.qitemId, state: "done", closureReason: "no-follow-on", resolution: "It published on 2026-10-25 (npm)." });
expect(answered.isError).toBe(false);
expect(repo.getById(task.qitemId)).toMatchObject({ state: "done", resolution: "It published on 2026-10-25 (npm)." });
// Re-entry: a new connection with the same grant reaches the same work.
const again = await connect(served.url, token);
expect((await again("rig_queue_show", { qitemId: task.qitemId })).text).toContain("It published on 2026-10-25");
// The team shows it: a hosted member with its authored capabilities and limits, no pane, and activity unknown, never idle.
const team = await again("rig_team", { rigId: "team" });
expect(team.structured.seats.map((s: { session: string }) => s.session)).toEqual(["lead@team"]);
expect(team.structured.hostedMembers).toEqual([{ session: "grok@team", roster: "team", capabilities: ["web research"],
useWhen: "A question needs current web sources", caveat: "No terminal or files: answers in text only", pane: null, activity: "unknown",
// Its last call is recorded; no ring was rung (nudge: false), so its last wake is unknown, never idle.
presence: { lastCallAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT/), lastWake: null, lastWakeScope: "the latest ring among its 50 most recent rows" } }]);
expect(JSON.parse(fs.readFileSync(presenceFile, "utf8")).seats["grok@team"]).toMatchObject({ grant: "grok" });
expect(fs.statSync(presenceFile).mode & 0o777).toBe(0o600);
expect(fs.readFileSync(tokenFile, "utf8")).not.toContain("lastCallAt");
// Revoked: its next request is refused, and the work and its answer stay.
expect(await revokeGrant(tokenFile, "grok")).toBe(true);
expect((await post(served.url, { Authorization: `Bearer ${token}` })).status).toBe(401);
expect(repo.getById(task.qitemId)).toMatchObject({ state: "done", resolution: "It published on 2026-10-25 (npm)." });
} finally {
setSelfHostId(null);
if (savedWorkspace === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT; else process.env.OPENRIG_WORKSPACE_ROOT = savedWorkspace;
fs.rmSync(workspace, { recursive: true, force: true });
}
});
it("a hosted member learns its seat, asks a person or its requester while keeping the item, and finishes the same item", async () => {
// The member has a grant and no node. Ana is a registered person; the lead is a team seat.
const ANA = "human-ana@external";
people = [{ entityId: "human-ana", class: "human", displayName: "Ana", address: ANA,
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: "unused", role: "primary", handle: "UANA" }], prefs: { deliveryClass: "A" } }];
const token = await addGrant(tokenFile, { name: "grok", seat: "grok@team", rigs: ["team"], roots: [], workspace: false, owner: false });
const asGrok = await connect((await listen()).url, token);
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
// whoami has no node or session for it, and the member still learns the seat it acts as.
const me = await asGrok("rig_whoami");
expect(me.isError).toBe(false);
expect(me.structured).toMatchObject({ ok: true, seat: "grok@team", rig: "team", session: null, caller: { authenticated: true, principal: "grok" } });
expect(me.structured.message).toContain("no session here");
// From a person: the member parks the item on her with the question and its draft as the summary, she's asked with
// that draft, and her answer returns it in progress.
const fromAna = await repo.create({ sourceSession: ANA, destinationSession: "grok@team", summary: "Announce the release", body: "Post the 0.7.3 note publicly.", nudge: false } as never);
expect((await asGrok("rig_queue_claim", { qitemId: fromAna.qitemId })).isError).toBe(false);
const draft = "May I post this publicly? \"0.7.3 is out: connected assistants can now ask before they act.\"";
const asked = await asGrok("rig_queue_update", { qitemId: fromAna.qitemId, state: "blocked", blockedOn: ANA, summary: draft, note: "Asked Ana before posting." });
expect(asked.isError).toBe(false);
expect(repo.getById(fromAna.qitemId)).toMatchObject({ state: "blocked", blockedOn: ANA });
expect(repo.listTransitions(fromAna.qitemId).at(-1)).toMatchObject({ state: "blocked", ownerNotificationKind: "human-required" });
// The ask she gets, through the projection and render the Slack post uses, carries the exact draft.
const ports = makeQueuePorts(repo, { loadHumanRegistry: () => ({ ok: true as const, entities: people ?? [] }), sharedChannels: () => [] });
const ask = (await ports.listHumanAlerts({ minimumLevel: "NOTICE" })).find((item) => item.qitemId === fromAna.qitemId);
expect(ask).toMatchObject({ ownerNotificationKind: "human-required", summary: draft });
const rendered = buildOutboundMessage({ qitemId: ask!.qitemId, ownerNotificationKind: ask!.ownerNotificationKind, summary: ask!.summary,
body: ask!.body, state: ask!.state, tags: ask!.tags, evidenceRef: ask!.evidenceRef }, { sourceLabel: "team" });
expect(rendered.text).toContain("0.7.3 is out: connected assistants can now ask before they act.");
expect(rendered.text).not.toContain("Asked Ana before posting.");
await contract.act({ verb: "resolve", qitemId: fromAna.qitemId, actorSession: ANA, decision: "Yes, post it.", notify: true } as never);
expect(repo.getById(fromAna.qitemId)).toMatchObject({ state: "in-progress", blockedOn: null, destinationSession: "grok@team" });
expect((await asGrok("rig_queue_show", { qitemId: fromAna.qitemId })).text).toContain("Yes, post it.");
expect((await asGrok("rig_queue_update", { qitemId: fromAna.qitemId, state: "done", closureReason: "no-follow-on", resolution: "Posted the 0.7.3 note." })).isError).toBe(false);
expect(repo.getById(fromAna.qitemId)).toMatchObject({ state: "done", resolution: "Posted the 0.7.3 note." });
// From a seat: the member asks the lead as a new item and parks its own on it; the lead's answer returns it pending.
const fromLead = await repo.create({ sourceSession: "lead@team", destinationSession: "grok@team", summary: "Draft the changelog", body: "Draft the 0.7.3 changelog.", nudge: false } as never);
expect((await asGrok("rig_queue_claim", { qitemId: fromLead.qitemId })).isError).toBe(false);
const question = await asGrok("rig_queue_create", { destination: "lead@team", summary: "Which date format?", body: "ISO or long-form dates in the changelog? Draft attached below." });
expect(question.isError).toBe(false);
const questionId = question.structured.qitemId as string;
expect(repo.getById(questionId)).toMatchObject({ sourceSession: "grok@team", destinationSession: "lead@team" });
expect((await asGrok("rig_queue_update", { qitemId: fromLead.qitemId, state: "blocked", blockedOn: questionId })).isError).toBe(false);
expect(repo.getById(fromLead.qitemId)).toMatchObject({ state: "blocked", blockedOn: questionId });
repo.update({ qitemId: questionId, actorSession: "lead@team", state: "done", closureReason: "no-follow-on", resolution: "ISO dates." } as never);
expect(repo.getById(fromLead.qitemId)).toMatchObject({ state: "pending", blockedOn: null });
expect((await asGrok("rig_queue_list", { owned: true })).structured.items.map((row: { qitemId: string }) => row.qitemId)).toContain(fromLead.qitemId);
expect((await asGrok("rig_queue_show", { qitemId: questionId })).text).toContain("ISO dates.");
expect((await asGrok("rig_queue_claim", { qitemId: fromLead.qitemId })).isError).toBe(false);
expect((await asGrok("rig_queue_update", { qitemId: fromLead.qitemId, state: "done", closureReason: "no-follow-on", resolution: "Changelog drafted with ISO dates." })).isError).toBe(false);
expect(repo.getById(fromLead.qitemId)).toMatchObject({ state: "done", resolution: "Changelog drafted with ISO dates." });
// The same two tasks, one question: nothing was reopened as a new task.
expect(rows()).toBe(3);
});
it("stops a revoked grant at its next request and keeps its records", async () => {
const alice = await grant("alice");
const served = await listen();
@@ -0,0 +1,647 @@
// rig mcp serve --http --grant-service against the daemon's real routes and a fake grant service speaking
// openrig.grant/v1: each request's key is checked with the grant service for this daemon's self id, every answer but
// active is refused with no fallback, and an external caller reaches only its own items and the team's questions to it.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import { serve, type ServerType } from "@hono/node-server";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type Database from "better-sqlite3";
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
import { addHumanFragment, loadHumanRegistry } from "../../daemon/src/domain/gateway/human-registry.js";
import { makeQueuePorts } from "../../daemon/src/domain/gateway/slack/queue-access.js";
import { buildOutboundMessage } from "../../daemon/src/domain/gateway/slack/message.js";
import { createHash } from "node:crypto";
import { addGrant, McpHttpConfigError, readGrantServiceHeader, startMcpHttpServer } from "../src/mcp-http.js";
import { mcpCommand } from "../src/commands/mcp.js";
const INITIALIZE = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "probe", version: "1" } } };
const SCHEMA = "openrig.grant/v1";
const KEY = `${"8977f2dc0000aaaa1111bbbb"}.cus_VVMAexample01.${"ab".repeat(24)}`;
const CALLER = "cus-vvmaexample01-caller@team-a";
const INSTANCE = "core-test-01";
/** The grant service's active answer for KEY, as grantV1 builds it. Core reads only principal.id of the principal. */
const active = (extra: Record<string, unknown> = {}) => ({
schema: SCHEMA, status: "active",
principal: { id: "cus_VVMAexample01", kind: "external_caller", label: "External caller …mple01" },
grant: { id: "8977f2dc0000aaaa1111bbbb", version: 6, updated_at: "2026-10-26T10:30:00.000Z", expires_at: null, cache_seconds: 0, revocation: "per_request" },
environment: { id: "team-a", core_instance: INSTANCE, studio: "https://studio.example", lead: "lead@team-a" },
seat: CALLER, rigs: ["team-a"], artifact_roots: ["proof"], access_class: "work",
objects: { queue: ["create", "show", "list"], artifact: ["read"] }, team: { id: "software-factory", version: "1" },
...extra,
});
describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
let db: Database.Database;
let home: string;
let shared: string;
let repo: QueueRepository;
let daemon: ServerType;
let daemonUrl: string;
let setup: ReturnType<typeof createTestApp>;
let service: http.Server;
let serviceUrl: string;
/** What the fake authority answers next: [HTTP status, body or raw text]. */
let answer: () => [number, unknown];
/** A daemon route made to fail: its answer, or undefined to serve the request as usual. */
let broken: ((req: Request) => Response | undefined) | undefined;
let asked: Array<{ headers: http.IncomingHttpHeaders; body: string }>;
const closers: Array<() => Promise<void>> = [];
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
beforeEach(async () => {
db = createDb(":memory:");
migrate(db, ALL_MIGRATIONS);
home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-grant-"));
shared = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "mcp-grant-shared-")));
process.env.OPENRIG_HOME = home;
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
setup = createTestApp(db, { appDeps: { queueRepo: repo, filesAllowlist: [{ name: "proof", canonicalPath: shared }] } });
broken = undefined;
const fetchRoute = (...args: Parameters<typeof setup.app.fetch>) => broken?.(args[0]) ?? setup.app.fetch(...args);
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: fetchRoute, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
setSelfHostId(INSTANCE);
asked = [];
answer = () => [200, active()];
service = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
asked.push({ headers: req.headers, body });
const [status, reply] = answer();
res.writeHead(status, { "Content-Type": "application/json" }).end(typeof reply === "string" ? reply : JSON.stringify(reply));
});
});
await new Promise<void>((resolve) => service.listen(0, "127.0.0.1", resolve));
serviceUrl = `http://127.0.0.1:${(service.address() as AddressInfo).port}/api/connector/v1/grant`;
seat("team-a", "cus-vvmaexample01-caller");
});
afterEach(async () => {
for (const close of closers.splice(0).reverse()) await close();
await new Promise<void>((resolve) => service.close(() => resolve()));
await new Promise<void>((resolve) => daemon.close(() => resolve()));
setSelfHostId(null);
db.close();
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(shared, { recursive: true, force: true });
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
});
/** A managed seat the daemon's whoami can answer for. */
function seat(rigName: string, member: string) {
const rig = setup.rigRepo.listRigs?.().find((r: { name: string }) => r.name === rigName) ?? setup.rigRepo.createRig(rigName);
const node = setup.rigRepo.addNode(rig.id, `pod.${member}`, { role: "worker" });
setup.sessionRegistry.registerSession(node.id, `${member}@${rigName}`);
}
async function listen(header?: { name: string; value: string }) {
const served = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "mcp-http-tokens.json"),
grantService: { url: serviceUrl, ...(header ? { header } : {}), timeoutMs: 1_000 } });
closers.push(() => served.close());
return served;
}
async function connect(url: string, token = KEY) {
const client = new Client({ name: "grant-service-test", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }));
closers.push(() => client.close());
const call = async (name: string, args: Record<string, unknown> = {}) => {
const result = await client.callTool({ name, arguments: args });
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
};
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name) });
}
const post = (url: string, headers: Record<string, string> = {}) => fetch(url, {
method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...headers }, body: JSON.stringify(INITIALIZE),
});
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
const create = (sourceSession: string, destinationSession: string, body: string) =>
repo.create({ sourceSession, destinationSession, body, nudge: false } as never);
it("asks the grant service on every request with the raw key and this daemon's self id, acts as the grant's seat, and never reads the token file", async () => {
const fileToken = await addGrant(path.join(home, "mcp-http-tokens.json"), { name: "local", seat: "local@team-a", rigs: ["team-a"], roots: [], workspace: false, owner: true });
const served = await listen({ name: "x-example-bypass", value: "preview-secret" });
expect(served.authenticated).toBe(true);
const asExternal = await connect(served.url);
expect((await asExternal.tools()).sort()).toEqual(["rig_artifact_read", "rig_queue_create", "rig_queue_list", "rig_queue_show"]);
const created = await asExternal("rig_queue_create", { destination: "lead@team-a", body: "please build it" });
expect(created.isError).toBe(false);
expect(repo.getById(created.structured.qitemId)?.sourceSession).toBe(CALLER);
expect(asked.length).toBeGreaterThanOrEqual(3);
for (const request of asked) {
expect(request.headers.authorization).toBe(`Bearer ${KEY}`);
expect(request.headers["content-type"]).toBe("application/json");
expect(request.headers["x-example-bypass"]).toBe("preview-secret");
expect(JSON.parse(request.body)).toEqual({ instance: INSTANCE });
}
// A token-file credential, even an owner's, is just an unknown key here, refused before any call to the grant service.
const before = asked.length;
expect((await post(served.url, { Authorization: `Bearer ${fileToken}` })).status).toBe(401);
expect(asked.length).toBe(before);
});
/** This host's own daemon, provably: the id it minted (read from its database) is the id its /healthz answers with. */
function thisHostsDaemon() {
const identity = path.join(home, "identity.sqlite");
const idDb = createDb(identity);
idDb.exec("CREATE TABLE self_host_identity (singleton INTEGER PRIMARY KEY, host_id TEXT)");
idDb.prepare("INSERT INTO self_host_identity VALUES (1, ?)").run(INSTANCE);
idDb.close();
vi.stubEnv("OPENRIG_DB", identity);
closers.push(async () => { vi.unstubAllEnvs(); });
}
it("records the grant service's principal, with its grant id and version, on the rows its caller writes", async () => {
thisHostsDaemon();
const asExternal = await connect((await listen()).url);
const created = await asExternal("rig_queue_create", { destination: "lead@team-a", body: "attributed to the external caller" });
expect(created.isError).toBe(false);
expect(db.prepare("SELECT identity_provenance AS p, identity_principal AS who FROM queue_transitions WHERE qitem_id = ?").get(created.structured.qitemId))
.toEqual({ p: "principal:v1", who: JSON.stringify({ source: "grant-service", id: "cus_VVMAexample01", grant: "8977f2dc0000aaaa1111bbbb", version: 6 }) });
});
it("refuses every answer but active, a timeout, an unbound instance and a missing seat, with no fallback and nothing written", async () => {
const quiet = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const served = await listen();
const cases: Array<[() => [number, unknown], number, string]> = [
[() => [401, { schema: SCHEMA, status: "unknown" }], 401, "unauthenticated"],
[() => [401, { schema: SCHEMA, status: "revoked" }], 401, "unauthenticated"],
[() => [401, { schema: SCHEMA, status: "expired" }], 401, "unauthenticated"],
[() => [403, { schema: SCHEMA, status: "wrong_instance" }], 401, "unauthenticated"],
[() => [400, { schema: SCHEMA, status: "malformed" }], 503, "grant_authority_unavailable"],
[() => [503, { schema: SCHEMA, status: "unavailable" }], 503, "grant_authority_unavailable"],
[() => [200, active({ schema: "openrig.grant/v2" })], 503, "grant_authority_unavailable"],
// No other name stands in for the schema, a retired one included.
[() => [200, active({ schema: "openrig.legacy.grant/v1" })], 503, "grant_authority_unavailable"],
[() => [200, "<html>preview login</html>"], 503, "grant_authority_unavailable"],
[() => [200, active({ environment: { id: "team-a", core_instance: "another-core" } })], 503, "grant_authority_unavailable"],
[() => [200, active({ rigs: ["team-b"] })], 503, "grant_authority_unavailable"],
[() => [200, active({ grant: { ...active().grant, expires_at: "2026-01-01T00:00:00.000Z" } })], 401, "unauthenticated"],
[() => [200, active({ seat: "cus-nobody01-caller@team-a" })], 503, "seat_unavailable"],
];
for (const [next, status, error] of cases) {
answer = next;
const res = await post(served.url, { Authorization: `Bearer ${KEY}` });
expect([res.status, ((await res.json()) as { error: string }).error]).toEqual([status, error]);
}
// No key, or one that isn't a grant key: refused before the grant service is asked.
const before = asked.length;
for (const headers of [{}, { Authorization: "Bearer orm_local-token" }, { Authorization: `Bearer ${KEY}x` }]) {
expect((await post(served.url, headers)).status).toBe(401);
}
expect(asked.length).toBe(before);
// A daemon with no self id can't bind a key to an instance: refused, and the grant service isn't asked.
setSelfHostId(null);
const unbound = await listen();
answer = () => [200, active()];
expect((await post(unbound.url, { Authorization: `Bearer ${KEY}` })).status).toBe(503);
expect(asked.length).toBe(before);
// The grant service gone: unavailable, never served as anyone.
setSelfHostId(INSTANCE);
await new Promise<void>((resolve) => service.close(() => resolve()));
service = http.createServer();
expect((await post(served.url, { Authorization: `Bearer ${KEY}` })).status).toBe(503);
expect(rows()).toBe(0);
} finally {
quiet.mockRestore();
}
});
/** Seats created as `rig import --materialize-only` creates them, through the daemon's own route: nodes with no session
* and no occupant. */
async function unlaunched(rigName: string, logicalIds: string[]) {
const pods = new Map<string, string[]>();
for (const id of logicalIds) {
const [pod, member] = id.split(".") as [string, string];
pods.set(pod, [...(pods.get(pod) ?? []), member]);
}
const spec = [`version: "0.2"`, `name: ${rigName}`, "pods:", ...[...pods].flatMap(([pod, members]) => [` - id: ${pod}`, ` label: ${pod}`, " members:",
...members.flatMap((member) => [` - id: ${member}`, ' agent_ref: "builtin:terminal"', " profile: none", " runtime: terminal", " cwd: /tmp"]),
" edges: []"]), "edges: []", ""].join("\n");
const res = await fetch(`${daemonUrl}/api/rigs/import/materialize`, { method: "POST", headers: { "Content-Type": "text/plain", "X-Rig-Root": "/tmp" }, body: spec });
expect(res.status).toBe(201);
}
/** A new rig of that name whose seats have no session, as a materialized rig's seats don't. */
function sessionless(rigName: string, ...logicalIds: string[]) {
const rig = setup.rigRepo.createRig(rigName);
for (const id of logicalIds) setup.rigRepo.addNode(rig.id, id, { role: "worker" });
return rig.id;
}
/** The grant service's active answer naming a seat in its own rig. */
const grantForSeat = (seat: string) => active({ seat, rigs: [seat.split("@")[1]] });
const count = (table: "sessions" | "nodes") => (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
it("serves a seat that was created but never launched as the grant's seat, through a create and show, and creates or launches nothing", async () => {
thisHostsDaemon();
await unlaunched("cust-team", ["cus-vvmaexample02.caller", "workshop.lead"]);
const seat = "cus-vvmaexample02-caller@cust-team";
answer = () => [200, grantForSeat(seat)];
const before = { sessions: count("sessions"), nodes: count("nodes") };
const served = await listen();
expect((await post(served.url, { Authorization: `Bearer ${KEY}` })).status).toBe(200);
const asExternal = await connect(served.url);
expect((await asExternal.tools()).sort()).toEqual(["rig_artifact_read", "rig_queue_create", "rig_queue_list", "rig_queue_show"]);
const created = await asExternal("rig_queue_create", { destination: "workshop-lead@cust-team", body: "please build it" });
expect(created.isError).toBe(false);
const shown = await asExternal("rig_queue_show", { qitemId: created.structured.qitemId });
expect(shown.structured.item).toMatchObject({ qitemId: created.structured.qitemId, sourceSession: seat, destinationSession: "workshop-lead@cust-team" });
expect(db.prepare("SELECT identity_provenance AS p, identity_principal AS who FROM queue_transitions WHERE qitem_id = ?").get(created.structured.qitemId))
.toEqual({ p: "principal:v1", who: JSON.stringify({ source: "grant-service", id: "cus_VVMAexample01", grant: "8977f2dc0000aaaa1111bbbb", version: 6 }) });
expect({ sessions: count("sessions"), nodes: count("nodes") }).toEqual(before);
});
it("a seat whoami doesn't know is here only as exactly one node of the one active rig of that name, and an unreadable registry is never the seat", async () => {
const quiet = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const served = await listen();
const outcome = async (seat: string) => {
answer = () => [200, grantForSeat(seat)];
const res = await post(served.url, { Authorization: `Bearer ${KEY}` });
return [res.status, res.status === 200 ? "served" : ((await res.json()) as { error: string }).error];
};
const before = { sessions: count("sessions"), rows: rows() };
// No such node in the rig, no rig of that name, or only an archived rig's node: not here.
const rigOne = sessionless("rig-one", "cus-one.caller");
expect(await outcome("cus-one-other@rig-one")).toEqual([503, "seat_unavailable"]);
expect(await outcome("cus-one-caller@rig-none")).toEqual([503, "seat_unavailable"]);
setup.rigRepo.archiveRig(sessionless("rig-old", "cus-old.caller"));
expect(await outcome("cus-old-caller@rig-old")).toEqual([503, "seat_unavailable"]);
// An archived rig of the same name doesn't make the active one ambiguous.
sessionless("rig-old", "cus-old.caller");
expect(await outcome("cus-old-caller@rig-old")).toEqual([200, "served"]);
// Two active rigs of that name, or two nodes whose names derive alike: ambiguous, and neither is chosen.
sessionless("rig-two", "cus-two.caller");
sessionless("rig-two", "cus-two.caller");
expect(await outcome("cus-two-caller@rig-two")).toEqual([503, "grant_authority_unavailable"]);
sessionless("rig-dup", "cus-dup.caller", "cus.dup-caller");
expect(await outcome("cus-dup-caller@rig-dup")).toEqual([503, "grant_authority_unavailable"]);
// A rig list or node list that fails or reads malformed, or whoami failing, is refused: never the seat.
const failures: Array<[RegExp, () => Response]> = [
[/^\/api\/rigs\/summary$/, () => Response.json({ error: "down" }, { status: 500 })],
[/^\/api\/rigs\/summary$/, () => Response.json({ rigs: [] })],
// The real rig beside a malformed one, or the matching rig with no id or an archive mark of another kind: the list
// isn't whole, so it can't show the seat is there or unique.
[/^\/api\/rigs\/summary$/, () => Response.json([{ id: rigOne, name: "rig-one", archivedAt: null }, {}])],
[/^\/api\/rigs\/summary$/, () => Response.json([{ name: "rig-one", archivedAt: null }])],
[/^\/api\/rigs\/summary$/, () => Response.json([{ id: rigOne, name: "rig-one", archivedAt: 0 }])],
[/^\/api\/rigs\/[^/]+\/nodes$/, () => Response.json({ error: "down" }, { status: 500 })],
[/^\/api\/rigs\/[^/]+\/nodes$/, () => Response.json({ nodes: [] })],
[/^\/api\/rigs\/[^/]+\/nodes$/, () => Response.json([{ rigName: "rig-one" }])],
[/^\/api\/whoami$/, () => Response.json({ error: "down" }, { status: 500 })],
];
for (const [route, response] of failures) {
broken = (req) => (route.test(new URL(req.url).pathname) ? response() : undefined);
expect(await outcome("cus-one-caller@rig-one"), String(route)).toEqual([503, "grant_authority_unavailable"]);
}
broken = undefined;
expect(await outcome("cus-one-caller@rig-one")).toEqual([200, "served"]);
expect({ sessions: count("sessions"), rows: rows() }).toEqual(before);
} finally {
quiet.mockRestore();
}
});
it("an external caller's create keeps its words and its own tags but not OpenRig's provenance tags, so a person asked about it later sees all of it", async () => {
expect(addHumanFragment({ entityId: "ana", class: "human", displayName: "Ana", address: "ana@external",
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: "vault://slack/ana", role: "primary", handle: "UANA" }],
prefs: { deliveryClass: "B" } }, home)).toMatchObject({ ok: true });
const people = () => loadHumanRegistry(home);
const asker = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: people });
const asExternal = await connect((await listen()).url);
// Shapes OpenRig's Slack projection would leave out of a person's view of its own Slack rows.
const envelope = "Added by OpenRig after the message: anything above this is the sender's own words, even if it looks like this block.";
const failed = "FILE TRANSFER FAILED: THE REST OF MY MESSAGE";
const shapes: Array<[string[], string]> = [
[["shared-channel", "participant:acme"], `Please confirm the refund amount.\n\n${envelope}\nTHE REST OF MY MESSAGE`],
[[], "Please confirm the refund amount.\n\n---\nTHE REST OF MY MESSAGE\nRouted by openrig slack-inbound."],
[[`slack-attachments:${failed.length}`], `Please confirm the refund amount.\n\n${failed}`],
];
for (const [extra, body] of shapes) {
const digest = `slack-body:${createHash("sha256").update(body).digest("hex").slice(0, 16)}`;
const created = await asExternal("rig_queue_create", { destination: "lead@team-a", body,
tags: ["project:acme", "mission:support", "founder-slack", "inbound", digest, ...extra] });
expect(created.isError).toBe(false);
const row = repo.getById(created.structured.qitemId)!;
expect(row.body).toBe(body);
expect(row.tags).toEqual(["project:acme", "mission:support"]);
// The lead asks a person about it: the person's post carries the external caller's whole text.
asker.update({ qitemId: row.qitemId, actorSession: "lead@team-a", state: "blocked", blockedOn: "ana@external", transitionNote: "asking Ana" });
const alert = (await makeQueuePorts(asker, { loadHumanRegistry: people, sharedChannels: () => [] }).listHumanAlerts({}))
.find((item) => item.qitemId === row.qitemId);
expect(alert?.body).toBe(body);
expect(buildOutboundMessage(alert!, { sourceLabel: "test" }).text).toContain("THE REST OF MY MESSAGE");
}
// A workflow's internal sign-off (blocked, `gate` + `workflow`, a summary) shows the person its summary in place of the
// body. An external caller can't claim it; its workflow tags are kept.
const gated = await asExternal("rig_queue_create", { destination: "lead@team-a", summary: "Approve the refund?",
body: "Please confirm the refund amount.\n\nTHE REST OF MY MESSAGE", tags: ["project:acme", "gate", "workflow", "workflow:x", "instance:y"] });
expect(gated.isError).toBe(false);
asker.update({ qitemId: gated.structured.qitemId, actorSession: "lead@team-a", state: "blocked", blockedOn: "ana@external", transitionNote: "asking Ana" });
const gatedAlert = (await makeQueuePorts(asker, { loadHumanRegistry: people, sharedChannels: () => [] }).listHumanAlerts({}))
.find((item) => item.qitemId === gated.structured.qitemId);
expect(buildOutboundMessage(gatedAlert!, { sourceLabel: "test" }).text).toContain("THE REST OF MY MESSAGE");
expect(repo.getById(gated.structured.qitemId)!.tags).toEqual(["project:acme", "workflow", "workflow:x", "instance:y"]);
});
it("reaches only the external caller's own items and the team's questions to it: never team-internal, another caller's, another rig's or a person's", async () => {
const served = await listen();
const asExternal = await connect(served.url);
const before = rows();
for (const destination of ["cus-otherone01-caller@team-a", CALLER, "lead@team-b", "alex@external", "lead@team-a@elsewhere"]) {
expect((await asExternal("rig_queue_create", { destination, body: "not allowed" })).structured).toMatchObject({ ok: false, code: "out_of_scope" });
}
expect(rows()).toBe(before);
const own = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "my task" })).structured.qitemId as string;
const question = await create("lead@team-a", CALLER, "a question for the external caller");
const internal = await create("lead@team-a", "impl@team-a", "SECRET team-internal work");
const otherCaller = await create("cus-otherone01-caller@team-a", CALLER, "SECRET from another external caller");
const otherRig = await create("lead@team-b", "impl@team-b", "SECRET other rig");
expect((await asExternal("rig_queue_show", { qitemId: own })).isError).toBe(false);
expect((await asExternal("rig_queue_show", { qitemId: question.qitemId })).isError).toBe(false);
for (const hidden of [internal, otherCaller, otherRig]) {
const shown = await asExternal("rig_queue_show", { qitemId: hidden.qitemId });
expect(shown.structured).toMatchObject({ code: "out_of_scope" });
expect(shown.text).not.toContain("SECRET");
}
for (const args of [{ all: true }, { all: true, destination: "impl@team-a" }, { all: true, source: "lead@team-a" }]) {
const listed = await asExternal("rig_queue_list", args);
expect(JSON.stringify(listed.structured)).not.toContain("SECRET");
}
const listed = JSON.stringify((await asExternal("rig_queue_list", { all: true })).structured);
expect(listed).toContain(own);
expect(listed).toContain(question.qitemId);
// Its task handed on inside the team: the successor stays hidden. Handed back to it: named.
const handOn = (id: string, to: string) => fetch(`${daemonUrl}/api/queue/${id}/handoff`, { method: "POST",
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "lead@team-a" }, body: JSON.stringify({ toSession: to, nudge: false }) });
expect((await handOn(own, "impl@team-a")).status).toBe(201);
expect((await asExternal("rig_queue_show", { qitemId: own })).structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
const second = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "another task" })).structured.qitemId as string;
expect((await handOn(second, CALLER)).status).toBe(201);
expect((await asExternal("rig_queue_show", { qitemId: second })).structured.successor).toMatchObject({ destinationSession: CALLER });
// Artifacts: its own item's, under its named root; never by root and path alone, never a question's.
fs.writeFileSync(path.join(shared, "result.md"), "RESULT body");
for (const id of [own, question.qitemId]) db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run(path.join(shared, "result.md"), id);
expect((await asExternal("rig_artifact_read", { qitemId: own })).text).toContain("RESULT body");
expect((await asExternal("rig_artifact_read", { qitemId: question.qitemId })).structured).toMatchObject({ code: "out_of_scope" });
expect((await asExternal("rig_artifact_read", { root: "proof", path: "result.md" })).structured).toMatchObject({ code: "out_of_scope" });
});
it("never names a handed-on item's out-of-scope owner: not in show, list, a cross-host successor or a sibling successor", async () => {
const served = await listen();
const asExternal = await connect(served.url);
const row = (id: string, source: string, destination: string, extra: Record<string, string | null> = {}) => {
const fields = { qitem_id: id, ts_created: "2026-10-26T00:00:00.000Z", ts_updated: "2026-10-26T00:00:00.000Z", source_session: source,
destination_session: destination, state: "pending", priority: "routine", body: "x", handed_off_from: null, ...extra };
db.prepare(`INSERT INTO queue_items (${Object.keys(fields).join(", ")}) VALUES (${Object.keys(fields).map(() => "?").join(", ")})`).run(...Object.values(fields));
};
const handOn = (id: string, to: string) => fetch(`${daemonUrl}/api/queue/${id}/handoff`, { method: "POST",
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "lead@team-a" }, body: JSON.stringify({ toSession: to, nudge: false }) });
// Handed on inside the team: the fact stays, the new owner appears nowhere in show or list.
const own = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "my task" })).structured.qitemId as string;
expect((await handOn(own, "impl@team-a")).status).toBe(201);
const shown = await asExternal("rig_queue_show", { qitemId: own });
expect(shown.text).not.toContain("impl@team-a");
expect(shown.structured.item).toMatchObject({ state: "handed-off", closureReason: "handed_off_to", handedOffTo: null, closureTarget: null,
closingTransition: { closureReason: "handed_off_to", closureTarget: null, transitionNote: "handed on (out of scope)" } });
expect(shown.structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
expect(JSON.stringify((await asExternal("rig_queue_list", { all: true })).structured)).not.toContain("impl@team-a");
// A cross-host successor's parties can't be checked here: its id and host stay hidden.
const remote = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "goes far" })).structured.qitemId as string;
db.prepare("UPDATE queue_items SET state = 'handed-off', closure_reason = 'handed_off_to', closure_target = 'qitem-far01@other-host', handed_off_to = 'impl@team-a@other-host' WHERE qitem_id = ?").run(remote);
const far = await asExternal("rig_queue_show", { qitemId: remote });
expect(far.structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
expect(far.text).not.toContain("other-host");
// A successor addressed to the caller but sent by another caller-shaped seat isn't the team's; nor is a sibling the
// caller can't see named among otherSuccessors.
const forged = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "x" })).structured.qitemId as string;
db.prepare("UPDATE queue_items SET state = 'handed-off', closure_reason = 'handed_off_to', closure_target = ?, handed_off_to = ? WHERE qitem_id = ?").run(CALLER, CALLER, forged);
row("qitem-forged-next", "cus-otherone01-caller@team-a", CALLER, { handed_off_from: forged });
expect((await asExternal("rig_queue_show", { qitemId: forged })).structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
const split = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "x" })).structured.qitemId as string;
db.prepare("UPDATE queue_items SET state = 'handed-off', closure_reason = 'handed_off_to', closure_target = ?, handed_off_to = ? WHERE qitem_id = ?").run(CALLER, CALLER, split);
row("qitem-split-hidden", "lead@team-a", "impl@team-a", { handed_off_from: split, ts_created: "2026-10-26T00:00:01.000Z" });
row("qitem-split-back", "lead@team-a", CALLER, { handed_off_from: split, ts_created: "2026-10-26T00:00:02.000Z" });
const back = await asExternal("rig_queue_show", { qitemId: split });
expect(back.structured.successor).toMatchObject({ qitemId: "qitem-split-back", destinationSession: CALLER });
expect(back.text).not.toContain("qitem-split-hidden");
});
it("an id-reuse conflict never names another item's parties, and still names the external caller's own", async () => {
const served = await listen();
const asExternal = await connect(served.url);
const own = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "my task" })).structured.qitemId as string;
// Reusing the id of an item it can't see: taken, nothing more. Its own item's conflict still names its own parties.
const internal = await create("lead@team-a", "impl@team-a", "SECRET team work");
const reused = await asExternal("rig_queue_create", { destination: "lead@team-a", body: "x", qitemId: internal.qitemId });
expect(reused.structured).toMatchObject({ ok: false, code: "qitem_id_reuse" });
expect(reused.text).not.toContain("impl@team-a");
const mine = await asExternal("rig_queue_create", { destination: "qa@team-a", body: "x", qitemId: own });
expect(mine.structured).toMatchObject({ code: "qitem_id_reuse" });
expect(mine.text).toContain(CALLER);
});
/** An external caller's connection, the team lead's daemon writes, and its own task creator. */
async function externalAndLead() {
const served = await listen();
const asExternal = await connect(served.url);
const asLead = (path: string, body: unknown) => fetch(`${daemonUrl}/api/queue/${path}`, { method: "POST",
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "lead@team-a" }, body: JSON.stringify(body) });
const mine = async (body: string) => (await asExternal("rig_queue_create", { destination: "lead@team-a", body })).structured.qitemId as string;
return { asExternal, asLead, mine };
}
it("a create retry after a handoff returns the stored row through the same projection, creating nothing again", async () => {
const { asExternal, asLead, mine } = await externalAndLead();
const count = (sql: string, id: string) => (db.prepare(sql).get(id) as { n: number }).n;
const retried = await mine("retry me");
expect((await asLead(`${retried}/handoff`, { toSession: "impl@team-a", nudge: false })).status).toBe(201);
for (const body of ["retry me", "retry me, changed"]) {
const again = await asExternal("rig_queue_create", { destination: "lead@team-a", body, qitemId: retried });
expect(again.isError).toBe(false);
expect(again.text).not.toContain("impl@team-a");
}
expect(count("SELECT COUNT(*) AS n FROM queue_items WHERE qitem_id = ?", retried)).toBe(1);
expect(count("SELECT COUNT(*) AS n FROM queue_items WHERE handed_off_from = ?", retried)).toBe(1);
const far = await mine("far");
db.prepare("UPDATE queue_items SET state = 'handed-off', closure_reason = 'handed_off_to', closure_target = 'qitem-far02@other-host', handed_off_to = 'impl@team-a@other-host' WHERE qitem_id = ?").run(far);
expect((await asExternal("rig_queue_create", { destination: "lead@team-a", body: "far", qitemId: far })).text).not.toContain("other-host");
});
it("a task parked on an internal child reads blocked, with no child id, owner or state anywhere", async () => {
const { asExternal, asLead, mine } = await externalAndLead();
const child = await create("lead@team-a", "qa@team-b", "SECRET child");
const parked = await mine("park me");
expect((await asLead(`${parked}/update`, { state: "blocked", blockedOn: child.qitemId, transitionNote: `waiting on ${child.qitemId} (qa@team-b)` })).status).toBe(200);
const shown = await asExternal("rig_queue_show", { qitemId: parked });
expect(shown.structured.item).toMatchObject({ state: "blocked", blockedOn: null });
for (const text of [shown.text, (await asExternal("rig_queue_list", { all: true })).text]) {
expect(text).not.toContain(child.qitemId);
expect(text).not.toContain("qa@team-b");
}
});
it("an escalated task keeps its closure, without the escalation target or the note naming it", async () => {
const { asExternal, asLead, mine } = await externalAndLead();
const escalated = await mine("escalate me");
expect((await asLead(`${escalated}/update`, { state: "done", closureReason: "escalation", closureTarget: "alex@external", transitionNote: "escalated to alex@external" })).status).toBe(200);
const shown = await asExternal("rig_queue_show", { qitemId: escalated });
expect(shown.structured.item).toMatchObject({ closureReason: "escalation", closureTarget: null,
closingTransition: { closureTarget: null, transitionNote: "escalated (out of scope)" } });
expect(shown.text).not.toContain("alex@external");
});
it("a superseded task keeps its closure without the replacement's id, while a denial's reason stays readable", async () => {
const { asExternal, asLead, mine } = await externalAndLead();
const replacement = await create("lead@team-a", "impl@team-a", "the replacement");
const superseded = await mine("supersede me");
expect((await asLead(`${superseded}/update`, { state: "done", closureReason: "superseded", closureTarget: replacement.qitemId })).status).toBe(200);
const shown = await asExternal("rig_queue_show", { qitemId: superseded });
expect(shown.structured.item).toMatchObject({ closureReason: "superseded", closureTarget: null });
expect(shown.text).not.toContain(replacement.qitemId);
// A denial's reason is the team's own text, not a party.
const denied = await mine("deny me");
expect((await asLead(`${denied}/update`, { state: "done", closureReason: "denied", closureTarget: "outside what this team does" })).status).toBe(200);
expect((await asExternal("rig_queue_show", { qitemId: denied })).structured.item).toMatchObject({ closureReason: "denied", closureTarget: "outside what this team does" });
});
/** One request with exactly these headers (a proxy's own Host included), read whole. */
function raw(url: string, method: string, headers: Record<string, string> = {}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const target = new URL(url);
const body = method === "POST" ? JSON.stringify(INITIALIZE) : undefined;
const req = http.request({ host: target.hostname, port: target.port, path: target.pathname, method,
headers: { ...(body ? { "Content-Type": "application/json", Accept: "application/json, text/event-stream" } : {}), ...headers } }, (res) => {
let text = "";
res.on("data", (chunk) => { text += chunk; });
res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: text }));
});
req.on("error", reject);
req.end(body);
});
}
it("with OAuth discovery set, serves only the configured resource's metadata, without a credential or a grant-service call, and challenges a missing credential apart from a refused one", async () => {
const quiet = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const resource = "https://mcp.example/team/mcp";
const issuer = "https://account.example";
const metadataUrl = "https://mcp.example/.well-known/oauth-protected-resource/team/mcp";
const metadataPath = "/.well-known/oauth-protected-resource/team/mcp";
// Without it: no metadata, and the challenge is the realm alone.
const plain = await listen();
const plainRoot = plain.url.replace(/\/mcp$/, "");
expect((await raw(`${plainRoot}${metadataPath}`, "GET")).status).toBe(404);
expect((await raw(plain.url, "POST")).headers["www-authenticate"]).toBe('Bearer realm="openrig"');
const served = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "mcp-http-tokens.json"),
grantService: { url: serviceUrl, timeoutMs: 1_000 }, oauth: { resource, issuer } });
closers.push(() => served.close());
const root = served.url.replace(/\/mcp$/, "");
const before = asked.length;
// The configured values exactly, whatever Host the proxy sends, and the grant service isn't asked.
for (const headers of [{}, { Host: "evil.example" }]) {
const metadata = await raw(`${root}${metadataPath}`, "GET", headers);
expect(metadata.status).toBe(200);
expect(JSON.parse(metadata.body)).toEqual({ resource, authorization_servers: [issuer], scopes_supported: ["openrig.work"], bearer_methods_supported: ["header"] });
}
// Only that path: no root document, no local /mcp alias, and GET only.
for (const [pathname, method] of [["/.well-known/oauth-protected-resource", "GET"], ["/.well-known/oauth-protected-resource/mcp", "GET"], [metadataPath, "POST"]] as const) {
expect((await raw(`${root}${pathname}`, method)).status, `${method} ${pathname}`).toBe(404);
}
expect(asked.length).toBe(before);
// No credential: pointed at the metadata, with no error. A refused one: also invalid_token. Whatever the Host.
const missing = await raw(served.url, "POST", { Host: "evil.example" });
expect([missing.status, missing.headers["www-authenticate"]]).toEqual([401, `Bearer realm="openrig", resource_metadata="${metadataUrl}"`]);
const refused = `Bearer realm="openrig", resource_metadata="${metadataUrl}", error="invalid_token"`;
expect((await raw(served.url, "POST", { Authorization: "Bearer orm_not-a-grant-key" })).headers["www-authenticate"]).toBe(refused);
for (const status of ["unknown", "revoked", "expired", "wrong_instance"]) {
answer = () => [401, { schema: SCHEMA, status }];
const res = await raw(served.url, "POST", { Authorization: `Bearer ${KEY}` });
expect([res.status, res.headers["www-authenticate"]], status).toEqual([401, refused]);
}
// The grant service or the seat unavailable stays a 503 with no challenge.
answer = () => [503, { schema: SCHEMA, status: "unavailable" }];
const outage = await raw(served.url, "POST", { Authorization: `Bearer ${KEY}` });
expect([outage.status, outage.headers["www-authenticate"]]).toEqual([503, undefined]);
answer = () => [200, active({ seat: "cus-nobody01-caller@team-a" })];
const noSeat = await raw(served.url, "POST", { Authorization: `Bearer ${KEY}` });
expect([noSeat.status, noSeat.headers["www-authenticate"]]).toEqual([503, undefined]);
// A valid key is served as before.
answer = () => [200, active()];
expect((await raw(served.url, "POST", { Authorization: `Bearer ${KEY}` })).status).toBe(200);
} finally {
quiet.mockRestore();
}
});
it("refuses OAuth discovery at startup without a grant service, or for a resource or issuer of any other shape", async () => {
const start = (oauth: { resource: string; issuer: string }, withService = true) => startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0,
tokenFile: path.join(home, "mcp-http-tokens.json"), ...(withService ? { grantService: { url: serviceUrl } } : {}), oauth });
const issuer = "https://account.example";
await expect(start({ resource: "https://mcp.example/team/mcp", issuer }, false)).rejects.toThrow(McpHttpConfigError);
for (const resource of ["http://mcp.example/team/mcp", "https://mcp.example/team/mcp?x", "https://mcp.example/team/mcp#y",
"https://[email protected]/team/mcp", "https://mcp.example/team", "https://mcp.example/team/mcp/", "https://MCP.example/team/mcp",
"https://mcp.example:443/team/mcp", "not a url"]) {
await expect(start({ resource, issuer }), resource).rejects.toThrow(McpHttpConfigError);
}
for (const bad of ["http://account.example", "https://account.example?x", "https://account.example#y", "https://[email protected]", "not a url"]) {
await expect(start({ resource: "https://mcp.example/team/mcp", issuer: bad }), bad).rejects.toThrow(McpHttpConfigError);
}
});
it("rig mcp serve takes --oauth-resource and --oauth-issuer together, and only with --http --grant-service", async () => {
const lines: string[] = [];
const quiet = vi.spyOn(console, "error").mockImplementation((line) => { lines.push(String(line)); });
const exitCode = process.exitCode;
try {
for (const args of [
["--http", "--grant-service", serviceUrl, "--oauth-resource", "https://mcp.example/team/mcp"],
["--http", "--grant-service", serviceUrl, "--oauth-issuer", "https://account.example"],
["--http", "--oauth-resource", "https://mcp.example/team/mcp", "--oauth-issuer", "https://account.example"],
]) {
process.exitCode = undefined;
await mcpCommand().parseAsync(["serve", "--port", "1", ...args], { from: "user" });
expect(process.exitCode, args.join(" ")).toBe(1);
}
expect(lines.filter((line) => line.includes("--oauth-resource and --oauth-issuer"))).toHaveLength(3);
} finally {
process.exitCode = exitCode;
quiet.mockRestore();
}
});
it("reads the grant request's header from a one-line secret file, and refuses one that would replace the request's own", () => {
const file = path.join(home, "header");
fs.writeFileSync(file, "x-example-bypass: preview-secret\n");
expect(readGrantServiceHeader(file)).toEqual({ name: "x-example-bypass", value: "preview-secret" });
for (const bad of ["Authorization: Bearer other", "no colon here", "a: b\nc: d"]) {
fs.writeFileSync(file, bad);
expect(() => readGrantServiceHeader(file)).toThrow();
}
});
});
@@ -0,0 +1,123 @@
// An external caller's seat created the way an operator creates one without a launch (`rig import --materialize-only`),
// served by the built `rig mcp serve --http --grant-service` on a real private daemon: initialize, tools/list and a
// create/show round trip as that seat, recorded with the grant's principal. The seat check only reads: no session, no
// occupant.
import { afterEach, expect, it } from "vitest";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { AddressInfo } from "node:net";
import Database from "better-sqlite3";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { prepareHermeticEnv } from "../../daemon/test/helpers/hermetic-env.js";
import { findFreePort, runRig, spawnScenarioDaemon } from "../../daemon/test/helpers/scenario-daemon.js";
const rigBin = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../dist/bin-wrapper.js");
const KEY = `${"8977f2dc0000aaaa1111bbbb"}.cus_VVMAexample01.${"ab".repeat(24)}`;
const RIG = "cust-team";
const CALLER = `cus-vvmaexample01-caller@${RIG}`;
const LEAD = `workshop-lead@${RIG}`;
const INITIALIZE = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "probe", version: "1" } } };
const closers: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const close of closers.splice(0).reverse()) await close().catch(() => {});
});
it("serves a materialized, never-launched caller seat over a grant service on a real daemon, and the check starts nothing", async () => {
const scaffold = prepareHermeticEnv({ baseEnv: { PATH: process.env.PATH, TERM: "xterm-256color" } });
const daemon = await spawnScenarioDaemon(scaffold, { rigBin });
closers.push(() => daemon.stop());
const dbPath = path.join(scaffold.stateDir, "scenario.db");
const spec = path.join(scaffold.root, `${RIG}.yaml`);
const member = (id: string) => [` - id: ${id}`, ' agent_ref: "builtin:terminal"', " profile: none", " runtime: terminal", ` cwd: ${scaffold.root}`];
fs.writeFileSync(spec, [`version: "0.2"`, `name: ${RIG}`, "pods:",
" - id: cus-vvmaexample01", " label: Caller", " members:", ...member("caller"), " edges: []",
" - id: workshop", " label: Workshop", " members:", ...member("lead"), " edges: []", "edges: []", ""].join("\n"));
const imported = await runRig(["import", spec, "--materialize-only"], daemon.readEnv, rigBin);
expect(imported.code, imported.stdout + imported.stderr).toBe(0);
const census = () => {
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
return {
nodes: (db.prepare("SELECT COUNT(*) AS n FROM nodes").get() as { n: number }).n,
sessions: (db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n,
};
} finally { db.close(); }
};
const before = census();
expect(before).toEqual({ nodes: 2, sessions: 0 });
// A grant authority answering for this daemon's own instance id.
const instance = ((await (await fetch(`${daemon.baseUrl}/healthz`)).json()) as { selfHostId: string }).selfHostId;
let seat = CALLER;
const service = http.createServer((req, res) => {
req.resume().on("end", () => res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
schema: "openrig.grant/v1", status: "active",
principal: { id: "cus_VVMAexample01", kind: "external_caller" },
grant: { id: "8977f2dc0000aaaa1111bbbb", version: 6, expires_at: null, revocation: "per_request" },
environment: { id: RIG, core_instance: instance, lead: LEAD },
seat, rigs: [RIG], artifact_roots: [], access_class: "work", objects: { queue: ["create", "show", "list"] },
})));
});
await new Promise<void>((resolve) => service.listen(0, "127.0.0.1", resolve));
closers.push(() => new Promise<void>((resolve) => service.close(() => resolve())));
// The built server, as an operator starts it, beside this daemon (its database proves the daemon is this host's).
const port = await findFreePort();
const env: Record<string, string> = {};
for (const [name, value] of Object.entries({ ...daemon.readEnv, OPENRIG_DB: dbPath })) if (value !== undefined) env[name] = value;
let log = "";
const server: ChildProcess = spawn(process.execPath, [rigBin, "mcp", "serve", "--http", "--bind", `127.0.0.1:${port}`,
"--grant-service", `http://127.0.0.1:${(service.address() as AddressInfo).port}/api/connector/v1/grant`], { env, stdio: ["ignore", "pipe", "pipe"] });
server.stdout?.on("data", (chunk) => { log += chunk; });
server.stderr?.on("data", (chunk) => { log += chunk; });
closers.push(async () => { server.kill("SIGTERM"); });
const url = `http://127.0.0.1:${port}/mcp`;
const initialize = () => fetch(url, { method: "POST", body: JSON.stringify(INITIALIZE),
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", Authorization: `Bearer ${KEY}` } });
let first: Response | undefined;
for (const deadline = Date.now() + 30_000; !first && Date.now() < deadline;) {
first = await initialize().catch(() => undefined);
if (!first) await new Promise((resolve) => setTimeout(resolve, 200));
}
expect(first, log).toBeDefined();
expect([first!.status, first!.status === 200 ? "served" : await first!.text()], log).toEqual([200, "served"]);
const client = new Client({ name: "grant-service-caller", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${KEY}` } } }));
closers.push(() => client.close());
const call = async (name: string, args: Record<string, unknown>) => {
const result = await client.callTool({ name, arguments: args });
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
return result.structuredContent as Record<string, any>;
};
// The tools the grant's objects name.
expect((await client.listTools()).tools.map((tool) => tool.name).sort()).toEqual(["rig_queue_create", "rig_queue_list", "rig_queue_show"]);
const created = await call("rig_queue_create", { destination: LEAD, body: "please build it" });
const shown = await call("rig_queue_show", { qitemId: created.qitemId });
expect(shown.item).toMatchObject({ qitemId: created.qitemId, sourceSession: CALLER, destinationSession: LEAD });
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
expect(db.prepare("SELECT source_session AS source, destination_session AS destination FROM queue_items WHERE qitem_id = ?").get(created.qitemId))
.toEqual({ source: CALLER, destination: LEAD });
expect(db.prepare("SELECT identity_provenance AS p, identity_principal AS who FROM queue_transitions WHERE qitem_id = ?").get(created.qitemId))
.toEqual({ p: "principal:v1", who: JSON.stringify({ source: "grant-service", id: "cus_VVMAexample01", grant: "8977f2dc0000aaaa1111bbbb", version: 6 }) });
} finally { db.close(); }
// Read only: still the two created nodes, and no session for either.
expect(census()).toEqual(before);
const listed = await runRig(["ps", "--nodes", "--rig", RIG, "--json"], daemon.readEnv, rigBin);
expect(listed.code, listed.stdout + listed.stderr).toBe(0);
expect(JSON.parse(listed.stdout).map((node: { logicalId: string; canonicalSessionName: string | null }) => [node.logicalId, node.canonicalSessionName ?? null]).sort())
.toEqual([["cus-vvmaexample01.caller", null], ["workshop.lead", null]]);
// A seat this daemon doesn't have is still not here.
seat = `cus-nobody01-caller@${RIG}`;
const missing = await initialize();
expect([missing.status, ((await missing.json()) as { error: string }).error]).toEqual([503, "seat_unavailable"]);
}, 120_000);
+47
View File
@@ -0,0 +1,47 @@
// A hosted member's last call, as `rig mcp serve --http` records it: once per seat per interval, by atomic rename, at mode
// 0600, beside the token file. A missing or unreadable record is unknown (an empty map), and a write that fails never
// reaches the caller.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { mcpPresenceFilePath, presenceRecorder, readPresence } from "../src/mcp-presence.js";
describe("a hosted member's last call", () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-presence-")); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it("is recorded once per seat per interval, each seat on its own, at mode 0600", () => {
const file = mcpPresenceFilePath(dir);
expect(file).toBe(path.join(dir, "mcp-http-presence.json"));
let now = Date.parse("2026-10-26T12:00:00.000Z");
const record = presenceRecorder(file, 60_000, () => now);
record("grok@lab", "grok");
now += 30_000;
record("grok@lab", "grok"); // inside the interval: not written
record("chatgpt@lab", "chatgpt");
expect(readPresence(file)).toEqual({
"grok@lab": { grant: "grok", lastCallAt: "2026-10-26T12:00:00.000Z" },
"chatgpt@lab": { grant: "chatgpt", lastCallAt: "2026-10-26T12:00:30.000Z" },
});
now += 31_000;
record("grok@lab", "grok");
expect(readPresence(file)["grok@lab"]!.lastCallAt).toBe("2026-10-26T12:01:01.000Z");
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
expect(fs.readdirSync(dir)).toEqual(["mcp-http-presence.json"]);
});
it("reads as unknown when nothing was recorded or the file can't be read, and a failed write never throws", () => {
expect(readPresence(path.join(dir, "absent.json"))).toEqual({});
fs.writeFileSync(path.join(dir, "broken.json"), "{ not json");
expect(readPresence(path.join(dir, "broken.json"))).toEqual({});
fs.writeFileSync(path.join(dir, "odd.json"), JSON.stringify({ version: 1, seats: { "grok@lab": { lastCallAt: 7 } } }));
expect(readPresence(path.join(dir, "odd.json"))).toEqual({});
const log = vi.spyOn(console, "error").mockImplementation(() => {});
try {
expect(() => presenceRecorder(path.join(dir, "missing-dir", "presence.json"))("grok@lab", "grok")).not.toThrow();
expect(log.mock.calls.flat().join(" ")).toContain("couldn't record grok@lab's call");
} finally { log.mockRestore(); }
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { mcpCommand } from "../src/commands/mcp.js";
import { DaemonClient } from "../src/client.js";
import * as lifecycle from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
vi.mock("@modelcontextprotocol/server/stdio", () => ({
serveStdio: vi.fn(() => {
queueMicrotask(() => process.stdin.emit("end"));
return { close: vi.fn(async () => {}) };
}),
}));
function deps(): { deps: StatusDeps; clientFactory: ReturnType<typeof vi.fn> } {
const clientFactory = vi.fn((url: string) => new DaemonClient(url));
return { deps: { lifecycleDeps: { exists: () => false } as never, clientFactory }, clientFactory };
}
describe("rig mcp serve --port", () => {
beforeEach(() => { vi.clearAllMocks(); });
afterEach(() => { process.exitCode = undefined; vi.restoreAllMocks(); });
it("refuses a non-numeric port before starting the server", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const { deps: d, clientFactory } = deps();
await mcpCommand(d).parseAsync(["serve", "--port", "abc"], { from: "user" });
expect(process.exitCode).toBe(1);
expect(err).toHaveBeenCalledWith(expect.stringContaining("Invalid port number"));
expect(clientFactory).not.toHaveBeenCalled();
expect(serveStdio).not.toHaveBeenCalled();
});
it.each(["0", "-1", "65536", "99999"])("warns once and starts with port %s unchanged", async (port) => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const { deps: d, clientFactory } = deps();
await mcpCommand(d).parseAsync(["serve", "--port", port], { from: "user" });
expect(process.exitCode).toBeUndefined();
expect(err).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(`Warning: daemon port ${port}`));
expect(clientFactory).toHaveBeenCalledExactlyOnceWith(`http://127.0.0.1:${port}`);
expect(serveStdio).toHaveBeenCalledOnce();
});
it.each(["1", "7433", "65535", "7433suffix"])("keeps the existing parseInt behavior for %s", async (port) => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const { deps: d, clientFactory } = deps();
await mcpCommand(d).parseAsync(["serve", "--port", port], { from: "user" });
expect(process.exitCode).toBeUndefined();
expect(err).not.toHaveBeenCalled();
expect(clientFactory).toHaveBeenCalledExactlyOnceWith(`http://127.0.0.1:${parseInt(port, 10)}`);
});
it.each([{ args: [] }, { args: ["--port", ""] }])("uses the daemon endpoint for empty or omitted port ($args)", async ({ args }) => {
const status = vi.spyOn(lifecycle, "getDaemonStatus").mockResolvedValue({ state: "running", host: "::1", port: 8123 });
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const { deps: d, clientFactory } = deps();
await mcpCommand(d).parseAsync(["serve", ...args], { from: "user" });
expect(status).toHaveBeenCalledOnce();
expect(clientFactory).toHaveBeenCalledExactlyOnceWith("http://[::1]:8123");
expect(err).not.toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
});
});
+167 -3
View File
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMcpServer } from "../src/mcp-server.js";
import { createMcpServer, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess } from "../src/mcp-server.js";
import { DaemonClient, DaemonConnectionError, type DaemonResponse } from "../src/client.js";
function mockClient(overrides?: {
@@ -359,7 +362,7 @@ describe("MCP Server", () => {
it("rig_queue_show names where a handed-on item went, and says when it can't tell", async () => {
const lists: string[] = [];
const getFn = vi.fn(async (path: string) => {
if (path === "/api/queue/qitem-remote") return { status: 200, data: { qitemId: "qitem-remote", state: "handed-off", closureReason: "handed_off_to", closureTarget: "qitem-20261023-abc@mm2-host" } };
if (path === "/api/queue/qitem-remote") return { status: 200, data: { qitemId: "qitem-remote", state: "handed-off", closureReason: "handed_off_to", closureTarget: "qitem-remote-successor@mm2-host" } };
if (path === "/api/queue/qitem-done") return { status: 200, data: { qitemId: "qitem-done", state: "done", closureReason: "handed_off_to", closureTarget: "qa@team" } };
if (path === "/api/queue/qitem-old") return { status: 200, data: { qitemId: "qitem-old", state: "handed-off", closureReason: "handed_off_to", closureTarget: "qa@team" } };
if (path.endsWith("/transitions")) return { status: 200, data: [] };
@@ -378,7 +381,7 @@ describe("MCP Server", () => {
const successor = async (qitemId: string) => ((await mcpClient.callTool({ name: "rig_queue_show", arguments: { qitemId } })).structuredContent as { successor: unknown }).successor;
// A cross-host handoff is named from its closure target, not fetched.
expect(await successor("qitem-remote")).toEqual({ qitemId: "qitem-20261023-abc", host: "mm2-host" });
expect(await successor("qitem-remote")).toEqual({ qitemId: "qitem-remote-successor", host: "mm2-host" });
expect(lists).toEqual([]);
// A handoff-and-complete closes the source done, and it still has a successor: the newest, with the others named.
expect(await successor("qitem-done")).toEqual({ qitemId: "qitem-next", destinationSession: "qa@team", state: "pending", otherSuccessors: ["qitem-earlier"] });
@@ -415,6 +418,77 @@ describe("MCP Server", () => {
await cleanup();
});
it("rig_team adds the rig's hosted members (marked hosted, on this host, not a node), and says when it can't tell", async () => {
const folder = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-rosters-"));
const member = (seat: string, host: string, extra: Record<string, unknown> = {}) =>
({ seat, host, capabilities: ["web research"], engagement: ["delegate"], use_when: "Current sources", why: "Searches the web", hosted: true, ...extra });
fs.writeFileSync(path.join(folder, "a.json"), JSON.stringify({ version: 1, id: "research", name: "Research", purpose: "Find things",
curator: { seat: "dev-impl@my-rig", host: "host-a" }, updated_at: "2026-10-26",
members: [
member("grok@my-rig", "host-a", { caveat: "Text only" }),
member("dev-impl@my-rig", "host-a"), // a node with a session: a seat
member("dev-qa@my-rig", "host-a"), // a node not launched yet (no session): still a seat
member("old@my-rig", "host-a", { hosted: undefined }), // a stale entry not marked hosted
member("bot@my-rig", "host-b"), // another host's
member("grok@other-rig", "host-a"), // another rig's
] }));
fs.writeFileSync(path.join(folder, "b.json"), "{ not json");
let hostSelfId: string | null = "host-a";
const getFn = vi.fn(async (p: string) => {
if (p.startsWith("/api/rigs/summary")) return { status: 200, data: [{ id: "rig-1", name: "my-rig" }] };
if (p === "/api/rigs/rig-1") return { status: 200, data: { rig: { id: "rig-1", name: "my-rig" }, nodes: [{ id: "n1", logicalId: "dev.impl" }, { id: "n2", logicalId: "dev.qa" }], edges: [], representative: null } };
if (p === "/api/rigs/rig-1/nodes") return { status: 200, data: [
{ logicalId: "dev.impl", canonicalSessionName: "dev-impl@my-rig", hostSelfId, agentActivity: { state: "idle" }, activityState: null },
{ logicalId: "dev.qa", canonicalSessionName: null, hostSelfId, activityState: null },
] };
if (p.startsWith("/api/queue/list?") && new URLSearchParams(p.split("?")[1]).get("destinationSession") === "grok@my-rig") return { status: 200, data: [
{ qitemId: "q-old", destinationSession: "grok@my-rig", lastNudgeAttempt: "2026-10-26T10:00:00.000Z", lastNudgeWireResult: "failed:webhook (GROK_WAKE_URL) answered 500" },
{ qitemId: "q-new", destinationSession: "grok@my-rig", lastNudgeAttempt: "2026-10-26T11:00:00.000Z", lastNudgeWireResult: "wake-accepted:webhook (GROK_WAKE_URL) answered 202" },
{ qitemId: "q-unrung", destinationSession: "grok@my-rig", lastNudgeAttempt: null, lastNudgeWireResult: null },
// A daemon that ignored the filter: another seat's ring doesn't count.
{ qitemId: "q-other", destinationSession: "lead@my-rig", lastNudgeAttempt: "2026-10-26T12:00:00.000Z", lastNudgeWireResult: "verified" },
] };
return { status: 404, data: {} };
});
const log = vi.spyOn(console, "error").mockImplementation(() => {});
const presence = path.join(folder, "presence.json");
fs.writeFileSync(presence, JSON.stringify({ version: 1, seats: { "grok@my-rig": { grant: "grok", lastCallAt: "2026-10-26T11:05:00.000Z" } } }));
const connectTo = async (rostersFolder: string) => {
const server = createMcpServer(mockClient({ get: getFn }), { rostersFolder: () => rostersFolder, presenceFile: () => presence });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.connect(serverTransport);
await client.connect(clientTransport);
return { team: async () => (await client.callTool({ name: "rig_team", arguments: { rigId: "my-rig" } })).structuredContent as { hostedMembers: unknown[]; hostedMembersNote?: string },
close: async () => { await client.close(); await server.close(); } };
};
const blocked = path.join(folder, "a-file");
fs.writeFileSync(blocked, "not a folder");
const [here, unreachable] = [await connectTo(folder), await connectTo(path.join(blocked, "rosters"))];
try {
const found = await here.team();
expect(found.hostedMembers).toEqual([{ session: "grok@my-rig", roster: "research", capabilities: ["web research"], useWhen: "Current sources",
caveat: "Text only", pane: null, activity: "unknown",
presence: { lastCallAt: "2026-10-26T11:05:00.000Z", lastWakeScope: "the latest ring among its 50 most recent rows",
lastWake: { at: "2026-10-26T11:00:00.000Z", result: "wake-accepted:webhook (GROK_WAKE_URL) answered 202", qitemId: "q-new" } } }]);
// A scoped caller gets a generic note; the file names and errors go to this server's log.
expect(found.hostedMembersNote).toBe("Some rosters couldn't be read, so hosted members may be missing.");
expect(log.mock.calls.flat().join(" ")).toContain("b.json");
// A folder that can't be inspected isn't "no rosters".
expect(await unreachable.team()).toMatchObject({ hostedMembers: [], hostedMembersNote: "Some rosters couldn't be read, so hosted members may be missing." });
// With this daemon's host id unknown (none on the nodes, none from health), it says so instead of listing none.
hostSelfId = null;
const unknown = await here.team();
expect(unknown.hostedMembers).toEqual([]);
expect(unknown.hostedMembersNote).toContain("host id isn't known");
} finally {
await here.close();
await unreachable.close();
log.mockRestore();
fs.rmSync(folder, { recursive: true, force: true });
}
});
it("the context reads return bounded summaries, cut long text and say so, and keep a route's hint as the message", async () => {
const slice = (name: string, missionId: string) => ({ name, missionId, displayName: name, status: "active", description: null, qitemCount: 2, lastActivityAt: null, slicePath: `/abs/${name}`, readiness: { big: true } });
const getFn = vi.fn(async (path: string) => {
@@ -456,6 +530,48 @@ describe("MCP Server", () => {
await cleanup();
});
it("carries the member workflow as one versioned text, quoted word for word in the hosted-members doc", async () => {
await setup();
const instructions = mcpClient.getInstructions() ?? "";
expect(MEMBER_WORKFLOW).toContain(`(${MEMBER_WORKFLOW_VERSION})`);
expect(instructions).toContain(MEMBER_WORKFLOW);
// The inbox, the claim before work, and keeping an item blocked while a person is asked.
for (const part of ["rig_queue_list with owned set", "rig_queue_claim", "blockedOn that person", "blockedOn that new qitemId"]) expect(MEMBER_WORKFLOW).toContain(part);
// v2: the closure reason a finished item takes, and scope described from the grant, not the tool list.
expect(MEMBER_WORKFLOW_VERSION).toBe("member-workflow/v2");
for (const part of ["closure reason no-follow-on", "a listed tool isn't access", "names the shared roots you may read"]) expect(MEMBER_WORKFLOW).toContain(part);
const doc = fs.readFileSync(new URL("../../../docs/reference/hosted-members.md", import.meta.url), "utf8");
expect(doc).toContain(`> ${MEMBER_WORKFLOW}`);
expect(doc).not.toContain("member-workflow/v1");
await cleanup();
});
it("names the closure reasons rig_queue_update takes, finished work first", async () => {
await setup();
const { tools } = await mcpClient.listTools();
const update = tools.find((tool) => tool.name === "rig_queue_update");
const reason = (update?.inputSchema as { properties?: Record<string, { description?: string }> } | undefined)?.properties?.closureReason?.description ?? "";
expect(reason).toContain("no-follow-on for finished work");
for (const value of ["handed_off_to", "denied", "canceled", "escalation", "superseded"]) expect(reason).toContain(value);
expect(reason).not.toContain("completed");
await cleanup();
});
it("doesn't give an external caller's connection the member workflow or its authority, only the shared lines", async () => {
const server = createMcpServer(mockClient(), { seat: "cus-1-caller@team",
access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false, external: { objects: { queue: ["create", "show", "list"] } } } });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const external = new Client({ name: "external-client", version: "1.0.0" });
await server.connect(serverTransport);
await external.connect(clientTransport);
const instructions = external.getInstructions() ?? "";
expect(instructions).toContain("reserved for a person");
expect(instructions).not.toContain(MEMBER_WORKFLOW_VERSION);
expect(instructions).not.toContain("basically authorized");
await external.close();
await server.close();
});
it("rig_down calls POST /api/down with correct params", async () => {
const postFn = vi.fn(async () => ({
status: 200,
@@ -927,6 +1043,54 @@ describe("MCP Server", () => {
await cleanup();
});
it("rig_queue_create drops OpenRig's provenance tags only from an external caller's create and revision; other callers' pass as sent", async () => {
const sent = ["mission:m", "project:p", "custom", "workflow", "founder-slack", "inbound", "shared-channel", "participant:acme", "slack-body:0123456789abcdef", "slack-attachments:39", "gate"];
const tagsSent = async (access?: McpAccess, revises?: string) => {
const postFn = vi.fn(async () => ({ status: 201, data: { qitemId: "qitem-1" } }));
const getFn = vi.fn(async () => ({ status: 200, data: { qitemId: "qitem-0", sourceSession: "cus-a1-caller@team", destinationSession: "lead@team" } }));
const server = createMcpServer(mockClient({ post: postFn, get: getFn }), { seat: "cus-a1-caller@team", access });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({ name: "rig_queue_create", arguments: { destination: "lead@team", body: "Do X", tags: sent, ...(revises ? { revises } : {}) } });
await client.close();
await server.close();
expect(result.isError).toBeUndefined();
return (postFn.mock.calls[0] as unknown as [string, Record<string, unknown>])[1].tags;
};
const work: McpAccess = { principal: "grok", owner: false, rigs: ["team"], roots: [], workspace: false };
const external: McpAccess = { ...work, principal: "cus_A1", external: { objects: { queue: ["create", "show", "list"] } } };
expect(await tagsSent()).toEqual(sent);
expect(await tagsSent(work)).toEqual(sent);
expect(await tagsSent(external)).toEqual(["mission:m", "project:p", "custom", "workflow"]);
expect(await tagsSent(external, "qitem-0")).toEqual(["mission:m", "project:p", "custom", "workflow"]);
});
it("rig_queue_create: an external caller hands tasks only to its team, never to a caller-shaped seat (itself included); a self-hosted grant is unchanged", async () => {
const created = async (access: McpAccess, destination: string, revises?: string) => {
const postFn = vi.fn(async () => ({ status: 201, data: { qitemId: "qitem-1" } }));
const getFn = vi.fn(async () => ({ status: 200, data: { qitemId: "qitem-0", sourceSession: "cus-a1-caller@team", destinationSession: "lead@team" } }));
const server = createMcpServer(mockClient({ post: postFn, get: getFn }), { seat: "cus-a1-caller@team", access });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({ name: "rig_queue_create", arguments: { destination, body: "Do X", ...(revises ? { revises } : {}) } });
await client.close();
await server.close();
return { posted: postFn.mock.calls.length, refused: (result.structuredContent as { code?: string } | undefined)?.code === "out_of_scope" };
};
const work: McpAccess = { principal: "grok", owner: false, rigs: ["team"], roots: [], workspace: false };
const external: McpAccess = { ...work, principal: "cus_A1", external: { objects: { queue: ["create", "show", "list"] } } };
for (const destination of ["cus-a1-caller@team", "cus-b2-caller@team"]) {
expect(await created(external, destination)).toEqual({ posted: 0, refused: true });
expect(await created(external, destination, "qitem-0")).toEqual({ posted: 0, refused: true });
expect(await created(work, destination)).toEqual({ posted: 1, refused: false });
}
expect(await created(external, "lead@team")).toEqual({ posted: 1, refused: false });
});
it("rig_queue_create sends humanAlert only when alert is set, so an MCP-only agent can still raise a sign-in", async () => {
const postFn = vi.fn(async () => ({ status: 201, data: { qitemId: "qitem-1" } }));
await setup({ post: postFn });
@@ -1,6 +1,41 @@
import { expect, it } from "vitest";
import { spawn } from "node:child_process";
it.each(["0", "99999"])("initializes and lists tools over stdio despite daemon port %s", async (port) => {
const source = new URL("../src/commands/mcp.ts", import.meta.url).href;
const script = `import { mcpCommand } from ${JSON.stringify(source)}; await mcpCommand().parseAsync(["serve", "--port", ${JSON.stringify(port)}], { from: "user" });`;
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { stdio: ["pipe", "pipe", "pipe"] });
let err = ""; let buffer = ""; let initialized = false; let tools: unknown; let timedOut = false;
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 10_000);
child.stderr.on("data", (chunk) => { err += chunk; });
const send = (message: unknown) => child.stdin.write(JSON.stringify(message) + "\n");
child.stdout.on("data", (chunk) => {
buffer += chunk;
let end: number;
while ((end = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
if (!line.trim()) continue;
const reply = JSON.parse(line);
if (reply.id === 1 && reply.result) {
initialized = true;
send({ jsonrpc: "2.0", method: "notifications/initialized" });
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
} else if (reply.id === 2) {
tools = reply.result?.tools;
child.stdin.end();
}
}
});
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "port-startup-test", version: "1" } } });
const code = await new Promise<number | null>((resolve, reject) => { child.once("close", resolve); child.once("error", reject); });
clearTimeout(timer);
expect({ code, timedOut, initialized }, err).toEqual({ code: 0, timedOut: false, initialized: true });
expect(tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: "rig_status" })]));
expect(err.split("\n").filter((line) => line.startsWith("Warning:"))).toEqual([
`Warning: daemon port ${port} is outside [1, 65535]; MCP will start, but daemon tool calls may fail. Check --port.`,
]);
}, 15_000);
it.each(["eof", "sigterm"])("finishes rig mcp serve after native %s", async (mode) => {
const source = new URL("../src/commands/mcp.ts", import.meta.url).href;
const script = `import { mcpCommand } from ${JSON.stringify(source)}; console.error("module-ready"); await mcpCommand().parseAsync(["serve", "--port", "7433"], { from: "user" }); console.error("command-closed");`;
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DaemonClient } from "../src/client.js";
import { getDaemonStatus, stopDaemon, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { normalizeOpenRigUrl } from "../src/openrig-compat.js";
import { resolveStartupProofEndpoint } from "../src/commands/startup-proof.js";
import { crashCartEvidenceUrl } from "../src/commands/crash-cart.js";
// OPENRIG_URL=http://host:port/ (trailing slash) addresses the same daemon as
// http://host:port; the CLI must not request //healthz or //api/... from it.
describe("OPENRIG_URL with a trailing slash", () => {
afterEach(() => vi.unstubAllEnvs());
function probeDeps(ok: (url: string) => boolean) {
const urls: string[] = [];
const fetch = vi.fn(async (url: string) => {
urls.push(url);
return { ok: ok(url) };
});
const exists = vi.fn(() => false);
const readFile = vi.fn(() => null);
return { urls, exists, readFile, deps: { fetch, exists, readFile } as unknown as LifecycleDeps };
}
it.each([
["http://127.0.0.1:7433/", "http://127.0.0.1:7433"],
["http://127.0.0.1:7433//", "http://127.0.0.1:7433"],
["http://h:7433/p/", "http://h:7433/p"],
["https://h/", "https://h"],
["http://h:7433/?a=b/", "http://h:7433/?a=b/"],
["http://h:7433/p/?x", "http://h:7433/p/?x"],
["http://h:7433/p/#x/", "http://h:7433/p/#x/"],
["http://h:7433", "http://h:7433"],
["/", "/"],
["//", "//"],
["http://", "http://"],
])("normalizeOpenRigUrl(%s) is %s (a query or fragment URL stays as written)", (raw, normalized) => {
expect(normalizeOpenRigUrl(raw)).toBe(normalized);
});
it("getDaemonStatus probes <origin>/healthz, not <origin>//healthz", async () => {
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7555/");
const { urls, deps } = probeDeps((url) => url === "http://127.0.0.1:7555/healthz");
const status = await getDaemonStatus(deps);
expect(urls).toEqual(["http://127.0.0.1:7555/healthz"]);
expect(status).toMatchObject({ state: "running", healthy: true, port: 7555, host: "127.0.0.1" });
});
// Commands reach OPENRIG_URL through getDaemonUrl's http://host:port rebuild, so the probe
// only changes for a plain http origin; a prefix or https URL is probed exactly as before.
it.each([
["http://127.0.0.1:7555/p/", "http://127.0.0.1:7555/p//healthz"],
["https://127.0.0.1:7555/", "https://127.0.0.1:7555//healthz"],
["http://127.0.0.1:7555/?x=1", "http://127.0.0.1:7555/?x=1/healthz"],
// No explicit port (or :80, which URL reports as none): status would hand commands 7433,
// so the probe must not succeed on port 80 for them.
["http://127.0.0.1/", "http://127.0.0.1//healthz"],
["http://127.0.0.1:80/", "http://127.0.0.1:80//healthz"],
])("getDaemonStatus probes %s as main does (%s)", async (url, probed) => {
vi.stubEnv("OPENRIG_URL", url);
const { urls, deps } = probeDeps(() => false);
await getDaemonStatus(deps);
expect(urls).toEqual([probed]);
});
it("a slash-only OPENRIG_URL stays an explicit target and never selects the local daemon", async () => {
vi.stubEnv("OPENRIG_URL", "/");
const { urls, exists, readFile, deps } = probeDeps(() => true);
await getDaemonStatus(deps);
expect(urls).toEqual(["//healthz"]);
expect(exists).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(new DaemonClient().baseUrl).toBe("/");
});
it("a default DaemonClient requests <url>/api/..., not <url>//api/...", async () => {
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7555//");
const urls: string[] = [];
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
urls.push(String(url));
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
}) as unknown as typeof fetch;
const client = new DaemonClient(undefined, { fetchImpl });
await client.get("/api/ps");
expect(urls).toContain("http://127.0.0.1:7555/api/ps");
expect(urls.some((url) => url.includes("7555//"))).toBe(false);
});
it("a default DaemonClient keeps a path prefix and scheme", () => {
vi.stubEnv("OPENRIG_URL", "https://h:7433/p/");
expect(new DaemonClient().baseUrl).toBe("https://h:7433/p");
});
it("the startup-proof endpoint reads OPENRIG_URL through the same normalization", () => {
const endpoint = resolveStartupProofEndpoint({
env: { OPENRIG_URL: "http://127.0.0.1:7555/", OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
readFile: () => { throw new Error("no endpoint file"); },
});
expect(endpoint).toEqual({ baseUrl: "http://127.0.0.1:7555", token: "tok" });
});
it.each([
["http://127.0.0.1:7555/p/?x=1", "http://127.0.0.1:7555/p/?x=1/api/ps"],
["http://127.0.0.1:7555/p/#frag", "http://127.0.0.1:7555/p/#frag/api/ps"],
])("a default DaemonClient keeps main's base for %s", async (url, requested) => {
vi.stubEnv("OPENRIG_URL", url);
const urls: string[] = [];
const fetchImpl = vi.fn(async (u: string | URL | Request) => {
urls.push(String(u));
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
}) as unknown as typeof fetch;
const client = new DaemonClient(undefined, { fetchImpl });
expect(client.baseUrl).toBe(url);
await client.get("/api/ps");
expect(urls).toContain(requested);
});
it("the startup-proof endpoint picks RIGGED_URL like every other reader when OPENRIG_URL is empty", () => {
const endpoint = resolveStartupProofEndpoint({
env: { OPENRIG_URL: "", RIGGED_URL: "http://127.0.0.1:7556/", OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
readFile: () => { throw new Error("no endpoint file"); },
});
expect(endpoint).toEqual({ baseUrl: "http://127.0.0.1:7556", token: "tok" });
});
it.each([
[{ OPENRIG_URL: " ", RIGGED_URL: "http://127.0.0.1:7556" }, "http://127.0.0.1:7556"],
[{ OPENRIG_URL: " http://127.0.0.1:7555/ " }, "http://127.0.0.1:7555"],
])("the startup-proof endpoint trims and skips blank URLs as before (%o)", (urls, baseUrl) => {
const endpoint = resolveStartupProofEndpoint({
env: { ...urls, OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
readFile: () => { throw new Error("no endpoint file"); },
});
expect(endpoint).toEqual({ baseUrl, token: "tok" });
});
// The shared reader leaves a query or fragment URL as written, so these two callers keep main's own trim before
// /healthz: rig daemon stop trims every trailing slash, and crash-cart's evidence probe one, as its detector does.
it.each([
["http://127.0.0.1:7555/?x=1/", "http://127.0.0.1:7555/?x=1/healthz"],
["http://127.0.0.1:7555/?x=1///", "http://127.0.0.1:7555/?x=1/healthz"],
["http://127.0.0.1:7555/#frag/", "http://127.0.0.1:7555/#frag/healthz"],
])("rig daemon stop with OPENRIG_URL=%s checks %s", async (url, check) => {
vi.stubEnv("OPENRIG_URL", url);
const { urls, deps } = probeDeps(() => true);
await expect(stopDaemon(deps)).rejects.toThrow(`Checked ${check};`);
expect(urls).toEqual([check]);
});
it.each([
["http://127.0.0.1:7555/?x=1/", "http://127.0.0.1:7555/?x=1/healthz"],
["http://127.0.0.1:7555/?x=1//", "http://127.0.0.1:7555/?x=1//healthz"],
["http://127.0.0.1:7555/#frag/", "http://127.0.0.1:7555/#frag/healthz"],
])("crash-cart's evidence probe for OPENRIG_URL=%s is %s", (url, probe) => {
expect(crashCartEvidenceUrl(normalizeOpenRigUrl(url), undefined)).toBe(probe);
});
});
@@ -103,6 +103,96 @@ describe("a queue create that got no answer is read back once by its ID", () =>
const posts = (calls: Call[]) => calls.filter((c) => c.method === "post");
const readBacks = (calls: Call[]) => calls.filter((c) => c.method === "get" && c.path === `/api/queue/${ID}`);
it.each([
["an explicit host", "dev@rig-b", ["--host", "vps-b"]],
["a host-qualified destination", "dev@rig-b@vps-b", []],
])("create --verify reads the remote receipt with %s and its bearer, never the local row", async (_case, destination, extra) => {
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n bearer_env: XH_AUTH\n`);
vi.stubEnv("XH_AUTH", AUTH_VALUE);
const { calls, error } = await run(destination, [{ status: 201, data: REMOTE_ROW }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${LOCAL}/api/queue/${ID}`]: { status: 404, data: { error: "qitem_not_found" } },
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
}, ["--verify", ...extra]);
expect(error).toBeNull();
expect(posts(calls)).toEqual([expect.objectContaining({ base: LOCAL, path: "/api/queue/create" })]);
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: REMOTE,
options: { timeoutMs: 10_000, headers: { Authorization: `Bearer ${AUTH_VALUE}` } } })]);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ qitemId: ID, persisted: true,
delivery: { receiptSource: "terminal", outcome: "verified", wakeResult: "verified" } });
expect(stdout.join("\n") + stderr.join("\n")).not.toContain(AUTH_VALUE);
});
it.each([
["missing", { status: 404, data: { error: "qitem_not_found" } }],
["unreachable", new DaemonTimeoutError("remote receipt timed out")],
["wrong id", { status: 200, data: { ...REMOTE_ROW, qitemId: "another-id", lastNudgeResult: "verified" } }],
["wrong destination", { status: 200, data: { ...REMOTE_ROW, destinationSession: "other@rig-b", lastNudgeResult: "verified" } }],
["wrong source", { status: 200, data: { ...REMOTE_ROW, sourceSession: "other@rig-a", lastNudgeResult: "verified" } }],
["wrong body", { status: 200, data: { ...REMOTE_ROW, body: "different", lastNudgeResult: "verified" } }],
] satisfies Array<[string, Reply]>)("create --verify keeps a %s remote receipt indeterminate without resending", async (_case, receipt) => {
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 201, data: REMOTE_ROW }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
[`${REMOTE}/api/queue/${ID}`]: receipt,
}, ["--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: REMOTE })]);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
});
it("create --verify does not guess a receipt host missing from this CLI's registry", async () => {
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 201, data: REMOTE_ROW }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
}, ["--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toHaveLength(0);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
});
it("does not verify an older stored body returned by a successful colliding create", async () => {
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
const oldRow = { ...REMOTE_ROW, body: "older body", lastNudgeResult: "verified" };
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 200, data: { ...oldRow,
createWarning: { code: "qitem_body_not_saved", message: "The supplied body was not saved." } } }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: oldRow },
}, ["--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toHaveLength(1);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
expect(stderr.join("\n")).toContain("not saved");
});
it("create --verify reads locally when --host names this daemon, without requiring a registry entry", async () => {
const { calls, error } = await run("dev@rig-b", [{ status: 201, data: ROW }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...ROW, lastNudgeResult: "verified" } },
}, ["--host", "host-a", "--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: LOCAL })]);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "verified" } });
});
it("a reconciled remote create still verifies its remote receipt without another write", async () => {
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
const { calls, error } = await run("dev@rig-b@vps-b", [FORWARD_UNKNOWN], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
}, ["--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toHaveLength(2);
expect(readBacks(calls).every(c => c.base === REMOTE)).toBe(true);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "verified" } });
});
it("a local create that committed after the CLI stopped waiting reads back as created, and isn't sent again", async () => {
const { calls, error } = await run("dev@rig-b", [new DaemonTimeoutError(`Request to ${LOCAL}/api/queue/create timed out after 5000ms`)],
{ [`${LOCAL}/api/queue/${ID}`]: { status: 200, data: ROW } });
+19
View File
@@ -20,6 +20,25 @@ it("reads v1 extensions without importing authored live facts", () => {
expect(value.members).toEqual([member]);
expect(() => parseRoster({ ...authored, version: 2 })).toThrow("version 1");
});
it("shows a hosted member's last call only for a roster row on this host; another host's same-named seat is unknown", () => {
const home = mkdtempSync(join(tmpdir(), "roster-presence-"));
folders.push(home);
vi.stubEnv("OPENRIG_HOME", home);
writeFileSync(join(home, "mcp-http-presence.json"), JSON.stringify({ version: 1, seats: { "bot@team": { grant: "bot", lastCallAt: "2026-10-26T18:00:00.000Z" } } }));
const local = { ...member, seat: "bot@team", host: "host-a", hosted: true };
const here = { ...inventory([]), localHostId: "host-a" };
expect(enrichRosterMember(local, here).live).toMatchObject({ lastCallAt: "2026-10-26T18:00:00.000Z" });
expect(enrichRosterMember({ ...local, host: "host-b" }, here).live).toMatchObject({ lastCallAt: null });
expect(enrichRosterMember(local, { ...here, localHostId: null }).live).toMatchObject({ lastCallAt: null });
vi.unstubAllEnvs();
});
it("keeps an authored hosted flag, refuses one that isn't a boolean, and says a hosted member has no pane", () => {
const hosted = { ...member, seat: "grok@studio", hosted: true };
expect(parseRoster({ ...authored, members: [hosted, { ...member, hosted: false }] }).members).toEqual([hosted, member]);
expect(() => parseRoster({ ...authored, members: [{ ...member, hosted: "yes" }] })).toThrow("hosted: true or false");
expect(enrichRosterMember(hosted, inventory()).live).toMatchObject({ status: "unknown", reason: expect.stringContaining("hosted member: it has no pane") });
});
it("joins exact host self-id and seat, never the caller alias", () => {
expect(enrichRosterMember(member, inventory()).live).toMatchObject({ status: "reported", runtime: "claude-code", configuredModel: "configured-a", observedModel: null, agentActivity: { sampledAt: node.agentActivity.sampledAt } });
expect(enrichRosterMember(member, inventory([{ ...node, hostSelfId: "different", hostId: member.host } as typeof node])).live).toMatchObject({ status: "unknown", runtime: null, configuredModel: null });
+37
View File
@@ -356,6 +356,43 @@ describe("findSlice + resolution variants", () => {
expect(slice.missionName).toBe("release-0.3.2");
});
it("finds the owning mission when the missions root ends in a separator", () => {
const withSlash = missionsRoot + path.sep;
expect(findSlice(withSlash, "07-target", "release-0.3.2").missionName).toBe("release-0.3.2");
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
expect(findSlice(withSlash, abs).missionName).toBe("release-0.3.2");
});
it("finds the owning mission for a relative missions root with a bare name and --mission, or a mission path", () => {
const prev = process.cwd();
process.chdir(root);
try {
const hinted = findSlice("missions", "07-target", "release-0.3.2");
expect(hinted.missionName).toBe("release-0.3.2");
expect(hinted.absPath).toBe(path.join(fs.realpathSync(root), "missions", "release-0.3.2", "slices", "07-target"));
expect(findSlice("missions/", "07-target", "release-0.3.2").missionName).toBe("release-0.3.2");
expect(findSlice("missions", "release-0.3.2/slices/07-target").missionName).toBe("release-0.3.2");
} finally {
process.chdir(prev);
}
});
it.each(["/.", "/sub/.."])("resolves an absolute slice path ending in %s to the slice itself", (suffix) => {
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
fs.mkdirSync(path.join(abs, "sub"));
const slice = findSlice(missionsRoot, abs + suffix.replace(/\//g, path.sep));
expect(slice.absPath).toBe(abs);
expect(slice.name).toBe("07-target");
expect(slice.missionName).toBe("release-0.3.2");
});
it("keeps an absolute slice path that leaves the missions root pointing at the requested folder", () => {
const outside = path.join(root, "other", "m1", "slices", "x");
fs.mkdirSync(outside, { recursive: true });
const slice = findSlice(missionsRoot, [missionsRoot, "..", "other", "m1", "slices", "x"].join(path.sep));
expect(slice.absPath).toBe(outside);
});
it.each(["workspace", "missions", "declared mission"])(
"resolves a hinted bare slice past an unrelated %s directory",
(collision) => {
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { seatCommand } from "../src/commands/seat.js";
import type { DaemonClient } from "../src/client.js";
import { STATE_FILE, getDaemonStatus, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
import { DAEMON_SHUTDOWN_RECEIPT } from "@openrig/daemon/daemon-shutdown";
import { dirname, join } from "node:path";
const rigId = "01M5FH6C2XEGA4B56J40RF9Q2G";
const nodeId = "01M5FH6ZV4C0S3JW1AGM5YNRQ4";
const snapshotId = "01M5FH6F8XV2FEEQDHCGKF3TFA";
const historySessionId = "01M5FH6EW20ZB9HFA3QWQT577B";
function lifecycleDeps(): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "fixture.sqlite", startedAt: "2026-10-21T00:00:00Z" }) : null),
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE),
mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true),
};
}
describe("rig seat history", () => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); process.exitCode = 0; });
async function run(args: string[], client: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn> }, lifecycle = lifecycleDeps()) {
vi.stubEnv("OPENRIG_TERMINAL_BEARER_TOKEN", "test-terminal-token");
const deps: StatusDeps = { lifecycleDeps: lifecycle, clientFactory: () => client as unknown as DaemonClient };
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => { lines.push(parts.join(" ")); });
vi.spyOn(console, "error").mockImplementation(() => {});
const program = new Command(); program.exitOverride(); program.addCommand(seatCommand(deps));
await program.parseAsync(["node", "rig", "seat", "history", "--rig", rigId, "--node-id", nodeId, "--json", ...args]);
return lines;
}
it("reads the server list with terminal auth and no local filtering", async () => {
const body = { ok: true, snapshotId, histories: [{ snapshotId, historySessionId, resumeType: "codex_id", tokenState: "unverified" }] };
const client = { get: vi.fn(async () => ({ status: 200, data: body })), post: vi.fn() };
const lines = await run([], client);
expect(client.get).toHaveBeenCalledWith(`/api/startup/${rigId}/nodes/${nodeId}/history`, { headers: { Authorization: "Bearer test-terminal-token" } });
expect(client.post).not.toHaveBeenCalled();
expect(JSON.parse(lines.join("\n"))).toEqual(body);
});
it("sends both exact references back for server validation", async () => {
const body = { ok: true, snapshotId, history: { historySessionId }, eligibleForAttempt: true, mutated: false };
const client = { get: vi.fn(), post: vi.fn(async () => ({ status: 200, data: body })) };
const lines = await run(["--snapshot-id", snapshotId, "--history-session-id", historySessionId], client);
expect(client.post).toHaveBeenCalledWith(`/api/startup/${rigId}/nodes/${nodeId}/history/validate`,
{ snapshotId, historySessionId }, { headers: { Authorization: "Bearer test-terminal-token" } });
expect(client.get).not.toHaveBeenCalled();
expect(JSON.parse(lines.join("\n"))).toEqual(body);
});
it("refuses an incomplete reference without a request", async () => {
const client = { get: vi.fn(), post: vi.fn() };
await run(["--snapshot-id", snapshotId], client);
expect(client.get).not.toHaveBeenCalled(); expect(client.post).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it("inspection preserves a stale daemon state even with a clean shutdown receipt", async () => {
const lifecycle = lifecycleDeps();
const receipt = join(dirname(STATE_FILE), DAEMON_SHUTDOWN_RECEIPT);
lifecycle.isProcessAlive = vi.fn(() => false);
lifecycle.exists = vi.fn(path => path === STATE_FILE || path === receipt);
lifecycle.readFile = vi.fn(path => path === STATE_FILE
? JSON.stringify({ pid: 123, port: 7433, db: "fixture.sqlite", startedAt: "2026-10-20T00:00:00Z" })
: path === receipt ? JSON.stringify({ schema: "openrig.daemon-shutdown/v1", pid: 123,
startedAt: "2026-10-20T00:00:00Z", completedAt: "2026-10-20T00:00:01Z",
phase: "complete", outcome: "clean", failures: [] }) : null);
const client = { get: vi.fn(), post: vi.fn() };
await run([], client, lifecycle);
expect(lifecycle.removeFile).not.toHaveBeenCalled();
expect(client.get).not.toHaveBeenCalled();
expect((await getDaemonStatus(lifecycle)).state).toBe("stale");
expect(lifecycle.removeFile).toHaveBeenCalledWith(STATE_FILE);
});
});
+21
View File
@@ -733,6 +733,27 @@ describe("rig seat status", () => {
expect(output).not.toContain("No conversation continuity, startup context delivery,");
});
it("handover fresh human output says an unconfirmed restore packet was sent, not that nothing was delivered", async () => {
const reason = "the input box could not be observed";
const deps = makeDeps({ status: 200, data: {
...HANDOVER_RESULT_FRESH,
restorePacket: { status: "unconfirmed", reason },
sideEffects: { ...HANDOVER_RESULT_FRESH.sideEffects, startupContextDelivered: false },
} }, []);
const { logs, exitCode } = await captureLogs(async () => {
await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "fresh", "--reason", "context-wall"]);
});
const output = logs.join("\n");
expect(exitCode).toBeUndefined(); // the handover still succeeded
expect(output).toContain("Seat handover complete: dev-impl@seat-rig");
expect(output).toContain(`its submission is unconfirmed: ${reason}.`);
expect(output).toContain("Check the successor's screen before sending the context again");
expect(output).not.toContain("No conversation continuity, startup context delivery,");
expect(output).not.toContain("restore packet) was delivered");
});
it("handover surfaces a loud step-named failure (successor_create_failed)", async () => {
const deps = makeDeps({
status: 500,
+46 -4
View File
@@ -158,6 +158,14 @@ describe("Send CLI", () => {
const parsed = JSON.parse(body);
lastSendBody = parsed;
sendBodies.push(parsed);
if (parsed.session === "slow-session") {
// A completed send can answer after the ordinary client's five-second deadline.
setTimeout(() => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, sessionName: parsed.session }));
}, 6_000);
return;
}
if (parsed.session === "lost-response-session") {
// The message was accepted, but no response headers reach the caller.
res.destroy();
@@ -433,6 +441,25 @@ describe("Send CLI", () => {
expect(exitCode).toBe(1);
});
it.each([false, true])("waits for a slow successful send without repeating it (json=%s)", { timeout: 15_000 }, async (json) => {
const { stdout, stderr, exitCode } = await captureChannels(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "slow-session", "hello", ...(json ? ["--json"] : [])]);
});
expect(sendBodies).toHaveLength(1);
expect(exitCode).toBeUndefined();
expect(stderr).toEqual([]);
if (json) expect(JSON.parse(stdout[0])).toMatchObject({ ok: true, sessionName: "slow-session" });
else expect(stdout).toContain("Sent to slow-session");
});
it("gives a local fan-out the send response budget", async () => {
const post = vi.fn().mockResolvedValue({ status: 200, data: { total: 1, sent: 1, failed: 0, results: [] } });
await captureLogs(() => makeCmd(runningDeps(port, () => ({ post } as unknown as DaemonClient)))
.parseAsync(["node", "rig", "send", "--to", "a@r", "hello"]).then(() => {}));
expect(post).toHaveBeenCalledTimes(1);
expect(post.mock.calls[0][2]).toMatchObject({ timeoutMs: 60_000 });
});
it.each([false, true])("losing the response after acceptance is unconfirmed (json=%s), with no resend", async (json) => {
const { stdout, stderr, exitCode } = await captureChannels(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "lost-response-session", "hello", ...(json ? ["--json"] : [])]);
@@ -443,6 +470,8 @@ describe("Send CLI", () => {
const output = json ? JSON.parse(stdout[0]).error : { consequence: stderr[1], action: stderr[2] };
expect(output.consequence).toContain("Delivery UNCONFIRMED");
expect(output.action).toContain("duplicate");
expect(output.action).toContain("off-screen");
expect(output.action).toContain("does not prove non-delivery");
expect([...stdout, ...stderr].join("\n")).not.toMatch(/was not sent|Sent to/);
if (json) { expect(stdout).toHaveLength(1); expect(stderr).toEqual([]); }
else expect(stdout).toEqual([]);
@@ -578,7 +607,7 @@ describe("Send CLI", () => {
text: expect.stringContaining("hello"),
waitForIdleMs: 30000,
}),
{ timeoutMs: 35000 },
{ timeoutMs: 90000 },
);
const sentText = postFn.mock.calls[0]?.[1] as { text: string } | undefined;
expect(sentText?.text).toContain("To: dev-impl@my-rig");
@@ -625,13 +654,13 @@ describe("Send CLI", () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello", "--wait-for-idle", "600"]);
});
expect(postFn.mock.calls.map((call) => [(call[1] as { waitForIdleMs: number }).waitForIdleMs, call[2]])).toEqual([[600000, { timeoutMs: 605000 }]]);
expect(postFn.mock.calls.map((call) => [(call[1] as { waitForIdleMs: number }).waitForIdleMs, call[2]])).toEqual([[600000, { timeoutMs: 660000 }]]);
expect(logs.join("\n")).toContain("when the 600000ms wait ended (8 checks). No text was sent.");
expect(logs.join("\n")).not.toMatch(/did not respond|consecutive/i);
expect(exitCode).toBe(1);
});
it("send without wait-for-idle uses default client timeout path", async () => {
it("send without wait-for-idle uses the 60 s response budget", async () => {
const postFn = vi.fn(async () => ({
status: 200,
data: { ok: true, sessionName: "dev-impl@my-rig" },
@@ -640,7 +669,7 @@ describe("Send CLI", () => {
await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello"]);
});
expect(postFn.mock.calls[0]?.[2]).toBeUndefined();
expect(postFn.mock.calls[0]?.[2]).toEqual({ timeoutMs: 60_000 });
});
// Slice-03 Atom 6b — --context delivery flag.
@@ -1296,6 +1325,19 @@ describe("Send CLI", () => {
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
});
it("single-seat send with a trailing-slash OPENRIG_URL reaches <url>/api/..., not <url>//api/...", async () => {
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}/`);
vi.stubEnv("RIGGED_URL", "");
lastSendBody = null;
const rc = recordingRealClient();
const { logs } = await captureLogs(async () => {
await makeCmd(probeFailNoStateDeps(rc.factory)).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello"]);
});
expect(logs.join("\n")).toContain("Sent to dev-impl@my-rig");
expect(rc.seen()).toBe(`http://127.0.0.1:${port}`);
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
});
it("R2 RED: fan-out --to + OPENRIG_URL + probe-stopped -> actual /broadcast lands with per-recipient results", async () => {
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}`);
vi.stubEnv("RIGGED_URL", "");
@@ -72,6 +72,14 @@ describe("rig start kernel wait evidence", () => {
expect(result.errors).toBe(""); expect(result.exitCode).toBe(0);
expect(result.received).toContain("/api/rigs/summary");
});
it("says a kernel that was already down before this daemon start stays down, and how to bring it back", async () => {
const result = await run("skipped", 200, 200, {}, false,
"kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing");
expect(result.logs).toContain("Kernel auto-boot skipped: kernel rig already managed; it was already down before this daemon start");
expect(result.logs).toContain("rig up kernel --existing");
expect(result.logs).not.toContain("--no-kernel or test mode");
expect(result.errors).toBe(""); expect(result.exitCode).toBe(0);
});
it("names an already-managed kernel and how to bring it back, instead of blaming --no-kernel", async () => {
const result = await run("skipped", 200, 200, {}, false, "kernel rig already managed");
expect(result.logs).not.toContain("--no-kernel or test mode");
+11 -2
View File
@@ -22,6 +22,7 @@ describe("terminal open request budget", () => {
const connections = new Set<net.Socket>();
const timers = new Set<ReturnType<typeof setTimeout>>();
let page = 0;
const panes: Array<{ tab_id: string; label: string }> = [];
const socketServer = net.createServer(socket => {
connections.add(socket);
socket.on("close", () => connections.delete(socket));
@@ -35,8 +36,16 @@ describe("terminal open request budget", () => {
let result: Record<string, unknown> = { type: request.method };
if (request.method === "ping") result = { type: "pong", version: "0.7.1", protocol: 1 };
if (request.method === "workspace.create") result = { type: "workspace_create", workspace: { workspace_id: "owned-workspace" } };
if (request.method === "layout.apply") result = { type: "layout_apply", layout: { tab_id: `owned-tab-${++page}` } };
if (request.method === "pane.list") result = { type: "pane_list", panes: [] };
if (request.method === "layout.apply") {
const tab_id = `owned-tab-${++page}`;
const collect = (node: any): void => {
if (node.type === "pane") panes.push({ tab_id, label: node.label });
else { collect(node.first); collect(node.second); }
};
collect(request.params.root);
result = { type: "layout_apply", layout: { tab_id } };
}
if (request.method === "pane.list") result = { type: "pane_list", panes };
const reply = () => socket.end(`${JSON.stringify({ id: request.id, result })}\n`);
if (request.method === "layout.apply") {
// Each 2.8s response is within the real socket transport's 5s bound.
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more