Compare commits

..
Author SHA1 Message Date
Mike Schwarz 02e7fd1083 fix(tui): keep a daemon-only start on its default bind, and point at the way back to the kernel (#3181)
A daemon started from the TUI's crash cart or start-and-return page used to run `rig daemon start --no-kernel --host 127.0.0.1 --port <port>`. `--host` is an explicit bind, so the daemon listened on 127.0.0.1 alone and other machines on the tailnet couldn't reach it until it was restarted. For the default `http://127.0.0.1:<port>` target the TUI now passes only `--port`, so the daemon uses its default bind (127.0.0.1, plus the Tailscale address when one is present). A `localhost` or `[::1]` target still names its host, a configured bind host is honoured as it is by a plain `rig daemon start`, and `--no-kernel`, the port and the local-target check are unchanged.

After a daemon-only start, which leaves every seat as it was, the start-and-return page says no seats were started and points at the kernel, when one is listed, to see its seats and resume one; with no kernel rig it says to choose a rig or press `k`. The crash cart's notice points at that page with `S`; its mention of the kernel isn't checked against the listed rigs.

A configured non-loopback bind host still can't be reached from the TUI's default loopback target, as with a daemon started any other way.
2026-11-13 16:49:49 -08:00
Dery Ferdika 45d390d157 feat(portability): an opt-in --strict exit status when the report finds matches (#3202)
`scripts/portability-report.mjs` gains an opt-in `--strict` flag. The report is produced exactly as before, and the run then exits 1 when it found matches. Without the flag nothing changes: it exits 0 with or without findings, and 2 on an operational error such as a bad argument or a git failure.

Exit 1 means the report found matches, not that a change is unsafe or must be rejected. The checks are heuristic: a missed match is possible, and an empty report is not a safety claim. OpenRig's own portability workflow doesn't pass `--strict` and stays advisory.
2026-11-13 16:42:05 -08:00
Mike Schwarz 0da9a30622 fix(queue): apply a park or close that carries summary or evidence, and store them (#3136)
When an agent parked or closed its row with `--summary` or `--evidence-ref`, every state change other than a park on a person refused the whole update with `summary_evidence_not_persistable`, so finished work could sit unclosed. A state change now stores both fields, through the writes the standalone `--summary` update and a done close already use. A blank summary or evidence reference isn't stored, so an unset variable can't wipe the row's subject or evidence link, and the rest of the update is applied. The response compares each requested value with the row after the write and names any field the stored row doesn't carry, as an advisory in JSON and an `advisory:` line in the receipt; the transition stays applied. On a state change, `queue.updated` carries the summary as stored. An open ask's decision revision now counts its evidence pointer when the row has one, so an answer read under an earlier pointer gets the existing `decision_revision_changed` conflict and nothing is written; a row with no evidence keeps the revision it had. The note-append rules are unchanged.
2026-11-13 15:48:17 -08:00
Mike Schwarz 8dcc06eb2b fix: a reply hint never points at human@host, and three delivery doc lines (#3132)
A handoff notice from `human@host`, the address an answer typed at this machine is recorded under, ended with a hint to reply there, while the same item said nothing is delivered there. The hint now says `human@host` names no person and offers no reply command; a row written there waits, and OpenRig reports it as undeliverable. The CLI's send wrapper and the daemon's pane envelope change together, and named human seats and `@external` addresses keep their hints. In the delivery reference: an ordinary `rig send` types a message that starts with `/` inside its From/To envelope, so the `/` stays literal, while `--raw` and `--dangerously-interact` type the exact text; the override skips the hold check in every mode, `hold` included; and Claude Code 2.1.322 turns a pasted tab into four spaces. Behaviour beyond the hint's text is unchanged.
2026-11-13 15:47:41 -08:00
billbenetti 795dcc8538 fix(daemon): read a seat's input after the Enter's own checks, so a draft typed while they wait isn't submitted (#3076) (#3176)
Before a conditional Enter, `TmuxAdapter.sendKeys` read the seat's input through the delivery guard, then awaited the caller's own check. Text a person typed while that check waited wasn't read again, so the Enter could submit the notice and the person's text together. The caller's checks now run first and the guard's input read runs last, the order `sendText` uses since #3097. A draft typed while the checks wait makes the guard refuse with `draft_input_changed`: no key is sent, and the person's input is left as it is. The read-only client's carriage-return re-check gets the same order.

The input snapshot and the later tmux key or carriage-return write are separate operations. Text typed after the snapshot can still be submitted with OpenRig's text. This moves the caller's awaited checks before the snapshot; it does not make submission atomic or establish a maximum remaining gap. #3076 stays open.
2026-11-13 15:37:38 -08:00
Evgeny Zotov 7539373d5c fix(cli): ps names the daemon it answered from (#3107)
In a setup with more than one daemon (an OPENRIG_URL override or a host registry), `rig ps` didn't say which daemon answered, so an empty fleet read from the wrong daemon looked like a dead fleet. Every human summary line now ends with `· daemon <address>`: the rig and seat rollup, and the "No rigs", "No active rigs · N archived" and "No rigs match --filter" lines. The address is the client's resolved base URL (scheme, host and port), so it covers the default local daemon, OPENRIG_URL and an explicit URL alike. JSON output is unchanged.
2026-11-13 15:28:21 -08:00
Mike Schwarz bd08e0f094 fix(mcp): ask which assistant the person means when more than one could be (#3179)
Asked to "send my Claude assistant a request" on a team with several that fit, an assistant could pick one from its own history without asking. In a scripted test with four Claude seats, it chose a detached one, and the request waited with nothing to wake it. The MCP server's instructions now tell the relaying agent to list the team before sending to another assistant and, when more than one could be meant, to ask which, using names from the list or plain words for addresses that are never read out. External grants that can create work and list the team get those steps. The front door says a request is picked up on its own only for a running team seat, and adds what happens with a connected assistant and after "send it anyway". Members' instructions are unchanged. The instruction version moves to v3.
2026-11-13 14:07:34 -08:00
Mike Schwarz c2815a6139 docs(skills): an installing assistant the person stays with keeps a return address (#3134)
An assistant that installed OpenRig for someone, and that the person keeps talking to, was told to relay the operator's questions by capturing its screen, and could only label its tasks with a --source name that can't claim or answer anything. The rigs skill's setup path, in both copies, now gives it the loop the openrig plugin's working-with-an-openrig-team skill teaches: its own return address (the seat rig attach printed, or an agreed address the queue takes tasks for, set with OPENRIG_SESSION_NAME on each rig queue command), the goal handed to the operator as a task, the team's questions answered with the person's exact words, and the result read back. Its outcome words match the queue's receipts: delivered-ack-pending is sent with its render unconfirmed, and a retained message isn't retried automatically. Coming back later uses rig queue show with its successors and rig queue list --mine -a, and a re-wake goes to the task's destination. Guidance only; no code changes.
2026-11-13 14:05:50 -08:00
Edward Bowie a1dd3333b4 feat(daemon): support per-seat Codex filesystem policies (#3085) (#3108)
Seats that share a Codex account home and working directory couldn't each get their own filesystem access. codex_permissions now has an opt-in filesystem form: a seat selects native read, write and deny rules (with its approval choice, and network access off unless selected) for its own invocation. Managed launch, resume, restore and rebuild carry the current selection without writing a shared policy file, and a handover checks it before the predecessor is stopped. Seats that don't select this form are unchanged.

Before launch, the effective Codex configuration for that seat's home is read. Positive evidence of a concrete conflict stops the launch and names the conflicting choice and a remedy: a different selected profile, a conflicting approval choice, access broader than the authored rules (a literal deny ancestor answers first), a managed allowlist that excludes the selection, or a missing or empty selected policy. An unavailable, timed-out or unrecognised read keeps the authored invocation and reports "policy not verified: <reason>" in the launch result and status. Compatible profiles, managed settings and redundant entries don't refuse, and owner configuration is never edited.

Limits: the command shown by rig ps is labelled as a posture fallback and doesn't carry the filesystem policy; hand-resuming from it doesn't apply the rules (managed resume does). The configuration read and the native start aren't atomic, and a lower-layer grant spelled with ~ can be refused conservatively. Codex enforces the rules: the author's native Linux checks on Codex 0.167.2 ran on an earlier revision, and macOS and Windows enforcement is unverified. Refs #3085.
2026-11-13 13:28:34 -08:00
Mike Schwarz 0a6d575bd8 fix(compaction): a manual compaction waits at a prompt instead of disarming (#3144)
Under the default acceptEdits launch, a manual rig compact whose preparation turn stopped at a permission prompt usually disarmed ("/compact was not confirmed (target_needs_input)"), so an unattended seat could reach its context wall. A manual attempt whose /compact is deferred before any input, other than by a typed draft, now pauses one reading interval (5 s) and returns to its existing bounded wait, sending once the seat is idle. Nothing is answered for the person, and a typed draft still stops the attempt. The attempt keeps only its latest reading of the seat as awaitingInput (since, reason, observedAt, current), which rig compact <seat> --state shows: a prompt sets it, an idle or working reading clears it, and an unreadable state keeps it marked not current. Expiry names the prompt only when the latest reading still shows it (preparation_needs_input, with what to do); otherwise the earlier reasons stand. A request with no usage data proceeds.
2026-11-13 13:09:28 -08:00
Wu Shuwen 94fa158ce2 fix(daemon): clarify edge cycle refusal (#3163)
When rig edge add refuses a launch dependency cycle, its message said that launch and restore both refuse a cycle. Launch does; restore's current plan sort has no cycle check, so that half of the sentence wasn't true. The message now says only what holds, and the edge-add refusal and its no-write behaviour are unchanged. The existing route test checks the corrected text.
2026-11-13 13:06:11 -08:00
Wu Shuwen 315316301a Filter workflow instances by scope (#3170)
rig workflow list could filter only by status, so finding the instances for one mission, project, rig, workflow, slice or tag meant reading the whole history. The CLI and the HTTP list route now take those exact filters, with repeated tags combined using AND. The database applies them before an optional limit and before deadline and frontier inspection, and an instance without the selected scope or rig doesn't match. Without filters, ordering and status behaviour are unchanged; an explicitly empty filter value returns 400. This covers the workflow-list part of #2728; the queue-list part shipped earlier.
2026-11-13 12:39:41 -08:00
97 changed files with 3002 additions and 1984 deletions

No files matched your search

-420
View File
@@ -8,426 +8,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.15]
OpenRig 0.7.15 is a patch for steadier, more truthfully reported teams. A busy
daemon does less repeated synchronous work on a few read paths and names more
of what it was doing when it stalls. `rig daemon start` waits longer for a slow
start, and restore tries a saved conversation when an eligible seat has no
saved startup context. There are fixes across delivery, Slack replies, Codex seats and
connected assistants, many contributed by people using OpenRig. See Known
limits for what this release doesn't fix.
### Before you upgrade
- **Stop the daemon, back up the database, install 0.7.15, then start the
daemon.** Your agents keep running through a daemon restart; don't use
`rig down`, which ends them.
```sh
rig daemon stop
cp "$(rig config get db.path)" "$(rig config get db.path).before-0.7.15"
npm install -g @openrig/cli@0.7.15 # or your existing installation method
rig daemon start
```
- **One migration runs before the daemon listens.** Migration 165 adds four
columns and two indexes to `activity_grants`, binding a runner's activity
credentials to its actual launch. Going back to 0.7.14 after it isn't
established; keep the backup. ([#3060](https://github.com/mvschwarz/openrig/pull/3060))
- **A slow start:** `rig daemon start` now gives the daemon child it started a
180-second readiness budget, instead of 20 s, and prints
"Waiting for daemon child <PID> readiness (…s elapsed; 180s limit)" about
every 20 s. A quick start still returns at once, and a child that exits ends
the wait. Let the wait finish. The budget bounds scheduling: synchronous work
can run past the budget. A child still not ready when that budget is
exhausted is reported pending as before: "Daemon startup is pending after the readiness deadline".
Readiness remains unconfirmed; the child keeps running and the startup
reservation is retained. Inspect the address and log it prints, and don't
start it again until its restart steps are done: stop the named child PID and
wait for it to exit, inspect `daemon-start.lock`, and only when both recorded
launcher and child PIDs are absent, archive the lock and run
`rig daemon start`. `rig daemon stop` cannot stop that unpublished child. ([#3118](https://github.com/mvschwarz/openrig/pull/3118))
- **The plugins:** the `openrig` plugin carries the CLI's version, 0.7.15.
openrig-core moves to 0.1.30, because its skills changed.
### Under load
- The wake ladder reads its prompt-refusal candidates from the open rows,
instead of scanning the whole transition log on every tick. It returns the
same rows. This removes one repeated scan; it isn't a measured fix for every
stall. ([#3098](https://github.com/mvschwarz/openrig/pull/3098))
- The execution view's candidate read steps through the queue in windows and,
on its cooperative path, yields to the event loop between them, so a mission
filter that matches few rows no longer holds the loop for a whole history
walk. One large row, or the history count, can still take time. Inside a
transaction, on a memory or temporary database, or on a non-WAL file, the
candidate read keeps its synchronous fallback. ([#3122](https://github.com/mvschwarz/openrig/pull/3122))
- Snapshot refreshes share process listings where each seat's order and the
2 s freshness allow; each seat still gets its two ordered observations and
its own fallback. The reduction is derived from the source, not measured. ([#3154](https://github.com/mvschwarz/openrig/pull/3154))
- The slow-operation log adds three queue sites and three selected steps of an
ordinary nodes read: `nodes.inventory.build`,
`nodes.inventory.activity_sync` and `nodes.inventory.terminal_work`. These
nodes records are written after a slow call completes; a missing record does
not rule out unfinished work or work outside those steps. This changes no
behaviour. ([#3100](https://github.com/mvschwarz/openrig/pull/3100), [#3138](https://github.com/mvschwarz/openrig/pull/3138))
### Restore and seats
- For an eligible seat in a rig with pods that restore starts in its own pane,
a resume token with no saved startup context now lets restore try the saved
conversation with an empty plan. It records the `startup_context` gap. A
successful resume with that gap makes the rig read `partially_restored`; a
missing conversation or failed start keeps its own status. No saved startup
plan is replayed or saved startup-context row written; the adapter's ordinary
bootstrap and existing skill restoration still run. An external CLI
attachment is kept, no-token seats still wait for a decision, and legacy
seats are unchanged. `restore-check` reads an eligible seat with a token
yellow; for one with no saved context and no eligible token, it points to the
read-only `rig up <rig> --existing --plan` preview. ([#3177](https://github.com/mvschwarz/openrig/pull/3177))
- When `rig seat restart --keep-conversation` refuses a recoverable seat whose
tmux session is gone, it names the way back: `rig launch '<rig>' '<node>'`
(add `--plan` to preview). ([#3135](https://github.com/mvschwarz/openrig/pull/3135))
- `restore-check` and a full restore warn when a snapshot recorded a named
Codex profile that the current seat has cleared; restore uses the current
selection. Nothing is refused. ([#3155](https://github.com/mvschwarz/openrig/pull/3155))
- A context-usage watchdog reads its own seat's current occupant, not an
archived generation's. A context watch whose seat can't be pinned, or has no
recorded tenure, skips that tick with its reason instead of ending. ([#3027](https://github.com/mvschwarz/openrig/pull/3027), [#3139](https://github.com/mvschwarz/openrig/pull/3139))
### Delivery and people
- Right before an automatic paste into a seat with the draft-aware guard,
OpenRig re-checks: it holds the paste after recent client activity, re-reads
the input after a short wait, and holds when tmux can't be read. The message
is held and retained; `rig seat held-messages` shows it. An ordinary guarded
paste waits about 300 ms longer. This narrows [#3076](https://github.com/mvschwarz/openrig/issues/3076)'s window; it doesn't close it. ([#3097](https://github.com/mvschwarz/openrig/pull/3097))
- A terminal seat whose tmux session runs under another name gets the exact
text of a send, and a queued wake no longer types OpenRig's From/To envelope
into its shell. ([#3074](https://github.com/mvschwarz/openrig/pull/3074))
- When the arrival note is recorded before the answer is routed, an answer to
a reply in an older Slack thread posts back into that thread, not as a new
top-level message; see Known limits for when it posts top-level. Only the
conversation's newest thread answers a parked question, as before. ([#3120](https://github.com/mvschwarz/openrig/pull/3120))
- Slack previews on posts to a person are a setting: `rig slack setup --unfurl on|off`,
and `rig slack status` shows which is in effect from the connector's next
rewire. The default is off; see "Behaviour that changed". ([#3157](https://github.com/mvschwarz/openrig/pull/3157))
### Connected assistants
- An assistant that passes its team's display name as `rig_team`'s `rigId` is
told to leave `rigId` out to see its own team, and a hosted member with no
wake trigger is told it picks up work when its person asks, or on a schedule
they set. ([#3104](https://github.com/mvschwarz/openrig/pull/3104))
- An external grant that works its own inbox is told only about the tools it's
offered. ([#3140](https://github.com/mvschwarz/openrig/pull/3140))
- The TUI keeps connected assistants' check-in lines when a rig's seat
inventory can't be read, with the error beside them. ([#3105](https://github.com/mvschwarz/openrig/pull/3105))
- The `openrig` plugin needs Claude Code 2.1.119 or later. On earlier versions,
with the plugin's OpenRig seat setting left blank, Claude Code drops its MCP
server: the skill loads, but the agent has no OpenRig tools. ([#3137](https://github.com/mvschwarz/openrig/pull/3137))
### Codex, runners and harnesses
- **OpenRig renews its own changed Codex hook approvals.** When a new
openrig-core changes one of OpenRig's six Codex hook declarations, the launch
that installs it renews those approvals from OpenRig's own record instead of
stopping at Codex's hook review. This runs at daemon start in the daemon's
Codex home, when hooks are on and a Codex seat selected openrig-core, and at
each such seat's launch in its own Codex home. It renews only when the
installed plugin matches the shipped one and every current approval matches
OpenRig's saved record. A person's own choice in any layer OpenRig reads
keeps the review; profile files passed with `-p` aren't read. No real Codex
seat ran in its tests. ([#3143](https://github.com/mvschwarz/openrig/pull/3143))
- A Codex seat's resume command in the node inventory and detail panel is
built from what a real resume uses, with a label when the recorded launch
differs. Workspace options that aren't recorded per launch can still read as
a match. ([#3043](https://github.com/mvschwarz/openrig/pull/3043))
- Runner activity grants are bound to the actual launch, and the
replacement's activation revokes its predecessor's grant. A grant made before
this release stays tied to the seat's occupant until then. A grant that can't
be bound never blocks a launch or restore. This attributes reports to
the run OpenRig selected; it doesn't prove a runner's identity. ([#3060](https://github.com/mvschwarz/openrig/pull/3060))
- Terminal views find tmux again when its cached path is gone, instead of
tiles exiting 126 until a daemon restart. ([#3129](https://github.com/mvschwarz/openrig/pull/3129))
- A `SKILL.md` with CRLF line endings is discovered and reaches a seat's
managed plugin loadout. ([#3153](https://github.com/mvschwarz/openrig/pull/3153))
- `rig context work-install --mission <missing>` warns that the mission
doesn't exist, and `missionRoot` reads null; the exit code is unchanged. ([#3141](https://github.com/mvschwarz/openrig/pull/3141))
- OpenRig's own transitions (`daemon@kernel` and `system:*`) no longer count
toward ceremony amplification, and the post-restore audit message matches
the restore skill. ([#3124](https://github.com/mvschwarz/openrig/pull/3124), [#3127](https://github.com/mvschwarz/openrig/pull/3127))
### Behaviour that changed
- **Slack previews:** posts to a person no longer show link and media previews
by default. An existing install stops showing them after its next connector
rewire or daemon restart. Run `rig slack setup --unfurl on` to turn them back
on; earlier previews aren't removed. ([#3157](https://github.com/mvschwarz/openrig/pull/3157))
- **Restore of an eligible seat with a resume token and no startup context**
now tries its saved conversation, and a successful resume reads
`partially_restored`. It used to stop with "Harness not started: no startup
context" and read the rig not restorable. ([#3177](https://github.com/mvschwarz/openrig/pull/3177))
### Known limits
- **Daemon start:** the longer readiness budget doesn't identify or cure the
cause of a slow start, synchronous work can run past the budget, and a child
still not ready when that budget is exhausted stays pending. ([#3118](https://github.com/mvschwarz/openrig/pull/3118))
- **Daemon memory:** 0.7.14's comparison under load established neither equal
memory nor a regression against 0.7.13. This release makes no memory claim.
- **Restore's overall result:** a rig can still read `fully_restored` while a
seat restore didn't start (one claimed or added after its roster or snapshot
was recorded), or a seat kept in its external CLI attachment, goes unnamed.
A seat restore left as a bare shell after "Harness not started" still counts
as running in `rig ps`.
- **Draft-aware delivery ([#3076](https://github.com/mvschwarz/openrig/issues/3076)):** typing after the guard's final check and before
the paste can still leave the notice beside the person's draft. In the tested
append-mode schedule Enter was refused and nothing was submitted or deleted;
this is not a guarantee for every interleaving. The prior conditional-send
Enter-window case is unchanged: characters typed during OpenRig's last checks
before Enter submitted with OpenRig's text.
- **Draft-aware delivery with a wide Claude Code footer:** with Claude Code's
full-width auto-update-failure footer on screen (seen at 200x50), a seat in
the opt-in draft-aware typing mode whose input is empty can hold an ordinary
send instead of delivering it. The message is kept, not lost, and
`rig seat held-messages` shows it. The clipped footer at 80x24 doesn't do
this.
- **Draft-aware delivery to a busy Codex seat:** on Codex (seen with 0.167.1
and 0.167.2), a send to a seat in the opt-in draft-aware typing mode while
it's busy can be pasted into its input without Enter. The message isn't
delivered, and it isn't kept for `rig seat held-messages`. A deliberate send
reports failed, and its text stays in the seat's input box. This was seen on
0.7.12 and 0.7.14, and there's no fix for it in this release.
- **Linked Codex config files:** when a Codex seat's `config.toml` or
`hooks.json` is a symbolic link, OpenRig records its hook trust under the
link's target, as in 0.7.14. A change to key it by the file's own name isn't
in this release, and such a seat may be asked to review hooks again. With a
linked `config.toml`, OpenRig can also miss a setting stored for it that
turns OpenRig's hooks off, and set those hooks up anyway when it moves them
into `hooks.json`. ([#3078](https://github.com/mvschwarz/openrig/issues/3078))
- **Slack reply threads:** recording the arrival note is best effort: a failed
write is not retried and that answer posts top-level. Creating the message
and recording the arrival note are separate writes, so a notification can
also be posted before the note is recorded, with the same top-level outcome.
The person's message is kept. ([#3120](https://github.com/mvschwarz/openrig/pull/3120))
- **From 0.7.14:** [its known limits](https://github.com/mvschwarz/openrig/releases/tag/v0.7.14)
still apply, except where this release changes them. [#3105](https://github.com/mvschwarz/openrig/pull/3105) fixes the
TUI tables' dropped check-in line, and [#3097](https://github.com/mvschwarz/openrig/pull/3097) narrows the draft-aware paste
window described above. 0.7.14's other limits weren't re-checked for this
release unless named here.
### Credits
Thanks to @dajiaohuang, @Kayhusk, @billbenetti, @Aummadour, @smota,
@vanducng, @zichen0116 and @Nikhila-Dhevi-R for merged contributions. Thanks
also for the reports behind these fixes: @Aummadour ([#2989](https://github.com/mvschwarz/openrig/issues/2989)), @vanducng
([#3128](https://github.com/mvschwarz/openrig/issues/3128)), @Alejandro-SJ ([#2990](https://github.com/mvschwarz/openrig/issues/2990)), @billbenetti ([#2976](https://github.com/mvschwarz/openrig/issues/2976)) and @Kayhusk
([#3131](https://github.com/mvschwarz/openrig/issues/3131)).
## [0.7.14]
OpenRig 0.7.14 is a patch for steadier circulation on a busy team. A burst of node
reads now takes turns. In the change's own tests, a health check or a queue read
then waited for one step of it instead of the whole burst; a comparison under
load didn't attribute that gain to this change. Bounded queue listings over a
long retained history use indexes instead of scanning it. An assistant connected to
your team can reach a seat by the name its team shows, connected assistants
appear in `rig ps` and the TUI, and setup guidance tells the agent to fit a
first team to your logins. There are fixes across delivery, seats and the TUI,
many contributed by people using OpenRig. See Known limits for what this
release doesn't fix.
### Before you upgrade
- **Stop the daemon, back up the database, install 0.7.14, then start the
daemon.** Your agents keep running through a daemon restart; don't use
`rig down`, which ends them.
```sh
rig daemon stop
cp "$(rig config get db.path)" "$(rig config get db.path).before-0.7.14"
npm install -g @openrig/cli@0.7.14 # or your existing installation method
rig daemon start
```
- **Three migrations run before the daemon listens.** Migration 163 adds the
`network_installations` table and four columns to `network_bindings`.
Migrations 164 and 166 build indexes. The author's measurement of 166 took
3.47 s on 28,976 rows, not a real installation's size, so a large database can
take longer. The write overhead, and going back to 0.7.13 after these three
migrations, aren't established; keep the backup. ([#3007](https://github.com/mvschwarz/openrig/pull/3007), [#3008](https://github.com/mvschwarz/openrig/pull/3008), [#3090](https://github.com/mvschwarz/openrig/pull/3090))
- **If the start is reported pending:** the CLI waits 20 s, then may print
"Daemon startup is pending after the readiness deadline". Readiness remains
unconfirmed. It leaves the child running and the startup reservation
retained, without recording the daemon. Inspect the address and log it
prints. The child may later answer, and status can then report it running,
but that does not finish this start's recording or release its reservation.
Don't start it again until the steps below are done. The message's restart
steps stop that child and clear the reservation: stop the named child PID and
wait for it to exit, inspect `daemon-start.lock`, and only when both recorded
launcher and child PIDs are absent, archive the lock and run
`rig daemon start`. On a large database the new start can be reported pending
again; reaching a recorded start this way hasn't been tested.
`rig daemon stop` cannot stop that unpublished child.
- **The `openrig` plugin carries the CLI's version, 0.7.14; openrig-core stays
0.1.29.**
### Under load
- Node reads (`rig ps --nodes`, the TUI, several agents at once) take turns,
one step at a time. In the change's own tests, health checks and queue reads
were no longer held behind a whole burst. A comparison under load didn't
attribute a gain to this change: health checks and queue reads were faster
for the release as a whole, `rig ps --nodes` showed no change, and sends were
inconclusive. ([#3101](https://github.com/mvschwarz/openrig/pull/3101))
- Bounded queue listings over retained history use indexes for their ordering
and for live and archived owner notifications. ([#3090](https://github.com/mvschwarz/openrig/pull/3090))
- Codex seats re-checked in one readiness poll share process listings where
each seat's observation order and the 2 s freshness allow; each keeps its own
later observation and its own fallback. ([#3061](https://github.com/mvschwarz/openrig/pull/3061))
- New tmux servers raise their soft open-file limit to 8,192 or the hard limit
where they can, keeping an already higher limit. This makes descriptor
exhaustion less likely as panes and viewers add up; servers already running
keep their limits. ([#3070](https://github.com/mvschwarz/openrig/pull/3070))
- A delegation parent gets one standing notice per child seat for unclaimed
rows, not one per row, and held work gets its notice back when its typing
hold clears. ([#3049](https://github.com/mvschwarz/openrig/pull/3049), [#3082](https://github.com/mvschwarz/openrig/pull/3082))
### Your team and the people it works for
- An assistant connected over MCP reaches a seat by its plain name, stored
under the seat's actual session; an unknown name, or one several seats match,
says so. ([#3088](https://github.com/mvschwarz/openrig/pull/3088))
- Connected assistants appear, with their check-in, in `rig ps`, `rig_team` and
the TUI's tables, and their person sees plain tool titles. ([#3057](https://github.com/mvschwarz/openrig/pull/3057), [#3058](https://github.com/mvschwarz/openrig/pull/3058), [#3092](https://github.com/mvschwarz/openrig/pull/3092))
- `rig doctor` and setup say a login is needed when a seat you start runs on
that harness. The operator is taught to check each seat's runtime against
your logins before launch, name any missing sign-in and let you choose.
([#3077](https://github.com/mvschwarz/openrig/pull/3077))
- When the readable person registry holds nobody, `rig daemon start` and
`rig gateway participants` tell the setup agent to offer registration. The
kernel operator is taught to include that offer in its greeting and register
only on your yes. ([#3041](https://github.com/mvschwarz/openrig/pull/3041), [#3071](https://github.com/mvschwarz/openrig/pull/3071))
- A team your agent hands work to knows how to ask you a question, and your
answer wakes it. A click on an ask with several questions records that
choice; the ask stays open until the last one. ([#3059](https://github.com/mvschwarz/openrig/pull/3059), [#3045](https://github.com/mvschwarz/openrig/pull/3045))
- A pairing request's description of itself is quoted wherever it's shown. ([#3067](https://github.com/mvschwarz/openrig/pull/3067))
- `rig queue list` takes exact `--tag`, `--mission` and `--slice` filters, and
several tags must all match. ([#3066](https://github.com/mvschwarz/openrig/pull/3066))
- A skill-only edition of the `openrig` plugin, for Claude's plugin directory,
follows handed-on work and keeps its reply route. ([#3056](https://github.com/mvschwarz/openrig/pull/3056), [#3075](https://github.com/mvschwarz/openrig/pull/3075))
### Fixes
- An ordinary send is refused as needing input while an open Claude Code or
Codex `/model` picker is positively identified; nothing is typed. Close the
picker before trying to deliver the message. This is shown by the change's
own tests; it hasn't yet been checked with the real Claude Code and Codex
programs on this release. ([#3083](https://github.com/mvschwarz/openrig/pull/3083))
- On a resume of a Codex seat's own conversation in a rig with pods, including
`rig up --existing`, OpenRig avoids adding missing guidance blocks to the
repository's tracked `AGENTS.md` when Codex is 0.164.0 or later, no guidance
file or developer instructions are configured, and the configuration and
block sources can be read. ([#3039](https://github.com/mvschwarz/openrig/pull/3039))
- With `recovery.auto_drive_provider_prompts` on, Claude Code's gated-grants
trust check is never answered for you, and the plain trust prompt is answered
"Yes". ([#3073](https://github.com/mvschwarz/openrig/pull/3073))
- `rig terminal open --provider herdr` reuses a live Herdr workspace, and Rex
tiles show the seat's agent and its activity. ([#3068](https://github.com/mvschwarz/openrig/pull/3068), [#2992](https://github.com/mvschwarz/openrig/pull/2992))
- `rig broadcast` prints a held recipient as retained, not sent. ([#3054](https://github.com/mvschwarz/openrig/pull/3054))
- Account erasure also removes OpenRig's own wakes and notices for that
account's seats. ([#3089](https://github.com/mvschwarz/openrig/pull/3089))
- A snapshot without sessions returns its `invalid_snapshot_data` blocker
instead of throwing a TypeError; the restore still stops there. A retried
no-launch import recovers the original rig by its operation ID. ([#3069](https://github.com/mvschwarz/openrig/pull/3069), [#3042](https://github.com/mvschwarz/openrig/pull/3042))
- A rebuilt briefing limits optional superseded recap paths to the newest
three by default when one folder covers that history; the current recap and
all artifact records remain. Other layouts keep the full list, and
`output.rebuild_recaps` set to 0 lists all paths. ([#3055](https://github.com/mvschwarz/openrig/pull/3055))
- `rig compact --state` names the input check that stopped a managed
compaction, and the TUI's graph shows a seat the daemon reads as unknown as
unknown. ([#3053](https://github.com/mvschwarz/openrig/pull/3053), [#3046](https://github.com/mvschwarz/openrig/pull/3046))
- Setting a context-pressure threshold explains when an environment override
inverts the pair, and `rig workflow validate` warns that harness pins aren't
checked against seats. ([#2567](https://github.com/mvschwarz/openrig/pull/2567), [#3102](https://github.com/mvschwarz/openrig/pull/3102))
- The config browser's entries say each setting's type, choices, range, format
and write route. ([#3050](https://github.com/mvschwarz/openrig/pull/3050))
### Experimental
- `rig network` is experimental, unsupported and at your own risk. Its connector
isn't inert: the daemon starts it, sweeps leftover credential folders and
schedules local rounds even with no binding. ([#3007](https://github.com/mvschwarz/openrig/pull/3007), [#3084](https://github.com/mvschwarz/openrig/pull/3084))
- A grant service can hold back only new work under its fair-use limit, and an
app can read what needs a person, and the answers to it, by link. ([#3048](https://github.com/mvschwarz/openrig/pull/3048), [#3008](https://github.com/mvschwarz/openrig/pull/3008))
### Behaviour that changed
- **Node reads:** a nodes response is no longer one snapshot across its steps,
and in a burst each nodes response comes later: about 43 ms against 21 ms
median in a 24-request burst. One very large rig is still one step. ([#3101](https://github.com/mvschwarz/openrig/pull/3101))
- **An assistant naming a seat now hears three errors:** `destination_ambiguous`
(several seats match; the candidates are listed), `destination_not_found` (no
seat it can reach has that name) and `destination_unchecked` (the team
couldn't be read; try again). ([#3088](https://github.com/mvschwarz/openrig/pull/3088))
### Known limits
- **Queue listings:** the new indexes aren't a cure for every stall. Listings of
active work showed no gain, one case was slower, and writes weren't measured. ([#3090](https://github.com/mvschwarz/openrig/pull/3090))
- **Daemon memory:** in a before-and-after comparison under load, the daemon's
resident anonymous memory and its memory when idle afterwards had medians
about 23-25 MiB higher on 0.7.14 than on 0.7.13, but the two versions' runs
overlapped. Neither equal memory nor a regression is established.
- **Draft-aware delivery ([#3076](https://github.com/mvschwarz/openrig/issues/3076)):** an automatic OpenRig notice can still be left beside
a draft started during delivery. In the reproduced paste-window cases the person's draft
stayed intact and no Enter was sent, but OpenRig's text stayed in the input.
Those cases don't cover a later window. On a conditional send, one that names
the conversation it expects, an isolated test with a scripted terminal showed
characters typed while OpenRig ran its last checks before Enter submitted
together with OpenRig's text, and the send reported success. The same test
reproduces it on 0.7.13. It hasn't been tested with a real client or model.
- **The TUI's tables** can drop a connected assistant's check-in line when its
rig's seat inventory can't be read; `rig roster` still prints it. ([#3105](https://github.com/mvschwarz/openrig/pull/3105) isn't in this release)
- **Repository guidance on restore:** rigs without pods, old or unknown Codex
versions, configured guidance and unreadable configuration keep the existing
file path. Fresh, fork and handover launches are outside this resume fix.
Existing managed blocks may still refresh, and the seat-skills line still
belongs to the file writer; check `git status`. A block whose source can't be
read is reported missing.
- **Linked Codex config files:** when a Codex seat's `config.toml` or
`hooks.json` is a symbolic link, OpenRig records its hook trust under the
link's target, as in 0.7.13. A change to key it by the file's own name isn't
in this release, and such a seat may be asked to review hooks again. With a
linked `config.toml`, OpenRig can also miss a setting stored for it that
turns OpenRig's hooks off, and set those hooks up anyway when it moves them
into `hooks.json`. ([#3078](https://github.com/mvschwarz/openrig/issues/3078))
- **From 0.7.13:** [its known limits](https://github.com/mvschwarz/openrig/releases/tag/v0.7.13)
still apply, except where this release fixes them. The runtime fixes cover
adding missing guidance on the eligible Codex resumes described above
([#3039](https://github.com/mvschwarz/openrig/pull/3039)) and the unquoted pairing description ([#3067](https://github.com/mvschwarz/openrig/pull/3067)). The fix for
recognized model pickers ([#3083](https://github.com/mvschwarz/openrig/pull/3083)) is shown by the change's own tests and
hasn't yet been checked with the real programs on this release.
This release also changes the operator-first registration ([#3041](https://github.com/mvschwarz/openrig/pull/3041),
[#3071](https://github.com/mvschwarz/openrig/pull/3071)) and pre-launch login guidance ([#3077](https://github.com/mvschwarz/openrig/pull/3077)). In a few test
runs with real agents, the operator offered registration once in its
greeting, on Claude Code and on Codex (one run each); a no registered nobody,
and a yes registered the default person. The person saw the offer in 3 of 4
runs; in the fourth, the setup agent didn't pass the operator's offer on.
The login guidance worked in both runs tested, once from the setup agent and
once from the operator. 0.7.13's other limits weren't re-checked for this
release unless named here.
### Credits
Thanks to @Kayhusk, @billbenetti, @cavalcanticampos, @Aummadour, @Tankard,
@w4rc0n and @Totopo27 for merged contributions. Thanks also for the reports
behind these fixes: @Kayhusk ([#3035](https://github.com/mvschwarz/openrig/issues/3035)), @Tankard ([#2583](https://github.com/mvschwarz/openrig/issues/2583)),
@dajiaohuang ([#2529](https://github.com/mvschwarz/openrig/issues/2529)), @billbenetti ([#2728](https://github.com/mvschwarz/openrig/issues/2728)), @emilio-gagliardi ([#3087](https://github.com/mvschwarz/openrig/issues/3087)), @korallis
([#161](https://github.com/mvschwarz/openrig/issues/161)) and @w4rc0n ([#3017](https://github.com/mvschwarz/openrig/issues/3017)).
## [0.7.13]
OpenRig 0.7.13 is a patch for people running their own agent teams. When you answer a runner's permission request, you now get a
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.15/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.15/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/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.15` 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.12` 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.15/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.15/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
+1
View File
@@ -232,6 +232,7 @@ With `needs-me.read` supported:
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
- **`GET /api/workflow/list`** accepts exact `project`, `mission`, `slice`, `rig` (the bound rig), and `name` (the workflow name) filters, repeatable `tag` filters on the instance's free-form scope tags, and `limit` from 1 to 10,000. Filters combine with AND and apply before the limit; results keep their existing oldest-first order. Instances without the requested scope or binding do not match. Without these filters, existing list behavior is unchanged.
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
+5 -3
View File
@@ -26,9 +26,11 @@ A person can also bind to a local bridge adapter using a `relay` binding:
`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"`
(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture).
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions. A row addressed to
`human@host` names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To reply to a person, use
their registered address:
`rig queue create --destination <their address> --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting your summary, evidence path and reply. A reply destination doesn't identify
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
`human@host` actor, not the person whose queue is being viewed.
+4 -4
View File
@@ -185,10 +185,10 @@ agent doing the setup. Both use this release's tag:
```sh
# Claude Code
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.15/skills/rigs" --skill rigs --agent claude-code
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs" --skill rigs --agent claude-code
# Codex
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.15/skills/rigs" --skill rigs --agent codex
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs" --skill rigs --agent codex
```
If OpenRig is already installed, use the release tag matching your installed `rig --version` instead: replace the tag
@@ -203,11 +203,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.15/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.15/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check if the installed checker is
present (otherwise it reports the check skipped), then runs `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
+2 -2
View File
@@ -12,9 +12,9 @@ In your project folder, add OpenRig's `rigs` skill to the agent you use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.15/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.15/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent codex
```
These use this release's tag, for a new install. If OpenRig is already installed, replace the tag after `tree/` with
+84
View File
@@ -324,6 +324,90 @@ envelope. An OpenRig older than 0.7.3 doesn't read a recorded envelope, so after
the seat launches without it. The field is also supported on legacy flat Codex nodes; other
runtimes reject it.
For an explicit filesystem policy, use `filesystem` **instead of** `sandbox` and its
workspace-write fields. This is the same per-seat selection and audited setter:
```yaml
cwd: /workspace/shared
env:
CODEX_HOME: /accounts/codex # existing account home; never copied
codex_permissions:
approval: never
filesystem:
":minimal": read
":workspace_roots": write
"/workspace/shared/private": deny
network_access: false
```
The mapping accepts native `read`, `write` and `deny` values at absolute paths,
`~/path`, or `:root`, `:minimal`, `:workspace_roots` (alias `:project_roots`),
`:tmpdir` and `:slash_tmp`.
It must be nonempty; nested subpath maps and arbitrary Codex settings are not part
of this option. Omitted network access becomes `false`. Include the read access
your tools and installed Codex executable need; this custom profile adds no
workspace, temporary, queue-state, seat-folder or Git-directory write grants of
its own. Native Codex decides whether the host can enforce the supplied rules.
Filesystem approval accepts `never` or `on-request`; the retired `untrusted`
approval is rejected before the selection is saved.
OpenRig emits the policy as invocation-local `permissions.openrig-seat` and
`default_permissions` overrides, with the selected approval. It writes no policy
file, so two seats can share both `CODEX_HOME` and `cwd` without overwriting each
other. Clearing the selection changes only that seat's record. Existing
account-scoped `codex_config_fragment` resources and their root-key rejection
stay unchanged.
Before dispatch, OpenRig compares Codex's native configuration with the seat's
selection. Positive evidence of access widening, a conflicting profile or
approval choice, an excluding managed allowlist, or a missing/empty selected
profile refuses launch. Compatible managed defaults and allowlists, harmless
descriptions and provably narrower flat rules are accepted. Legacy sandbox
settings are inert under the invocation's explicit permission-profile selection.
Null fields are treated as absent, including unfamiliar optional fields.
Managed `defaultPermissions` is a fallback, not a forced selection; the
effective profile and managed allowlist determine whether the agent's choice holds.
A timeout, reader failure, unfamiliar representation or unsupported managed
requirement continues with the authored flags and reports `policy not verified:
<reason>` in the launch observation and status. The policy is never dropped or
swapped, and account/project settings are never edited to make the check pass.
Absent selection or approval fields, unfamiliar approval strings or profile formats,
and unclear overlap between native selectors and authored path rules are also unverified.
An authored selector that may cover an extra grant is considered before a literal
ancestor would reject that grant, except that an explicit non-root deny ancestor
always takes precedence over possible selector coverage. A full-root deny, written
as `/` or `:root`, is a default narrower grants may refine: possible authored
selector coverage stays unverified, while a proven outside grant still refuses.
Codex 0.167.2's `:minimal` adds system read defaults, not `/home` or `/Users`
user-data coverage; extra grants there under a root deny or with no root rule still refuse unless
another authored rule may cover them. Other minimal-path coverage stays unexpanded.
Duplicate aliases from a
lower layer leave their own coverage unverified; they do not hide a known widening
at another path.
Inconclusive coverage warnings name the extra rule and access. An excluding managed
allowlist is reported before an effective selection or approval mismatch.
Concrete refusals name the conflicting rule and offer a remedy; handover preserves
that remedy while leaving the predecessor running.
Without an explicit `CODEX_HOME`, OpenRig resolves the normal account home and
pins it for both the read and launch; an unresolvable home is reported as
unverified. A named `codex_config_profile` is retained but unverified because the
native reader cannot load that layer on this path. A `launch_wrapper` is also
retained and unverified: it may change the effective home/configuration, so a
read without that wrapper cannot certify the launch.
The filesystem selection replaces permission-mode and team/kernel defaults,
including ambient YOLO. Fresh launches, both resume paths and restore emit the
same selection. Hand-resuming from the command shown by `rig ps` does not yet
carry this filesystem policy. A rebuild uses the saved filesystem policy ahead of an observed
predecessor sandbox; clearing or replacing the policy after a snapshot takes
effect on restore. Status reports the plan and its limits, not observed native
enforcement. Handover checks the successor's filesystem selection before
signaling the predecessor; a concrete conflict leaves that predecessor running.
Configuration can change between the preflight and native startup;
the read and launch are not atomic. Codex's sandbox governs local command
execution, not every connector, account or external service.
For an existing seat, put the envelope in a JSON file, or change its profile, with the existing
audited permission command:
+7 -1
View File
@@ -24,9 +24,15 @@ a shell prompt holds them. Keep the default for a seat that may fall back to a s
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
`--dangerously-interact --reason` remains available in every mode, `hold` included,
and uses the existing-input check below. Direct human input remains available.
An ordinary `rig send` types the message inside its From/To envelope, whose first
line is `From:`, so a message that starts with `/` stays literal text rather than
running as the agent's slash command. `--raw` and `--dangerously-interact` type the
exact text, with no envelope. Claude Code (2.1.322) turns a pasted tab into four
spaces, so tabs reach a Claude Code seat as spaces.
A message sent to a busy Codex seat joins its running turn and may redirect it.
For separate work, create a queue row instead:
`rig queue create --destination <seat> --summary "<one line>" --body-file <file>`.
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.15",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.15",
"version": "0.7.1",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7298,7 +7298,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.15",
"version": "0.7.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7336,7 +7336,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.15",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7380,7 +7380,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.15",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.15",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/acp",
"version": "0.7.15",
"version": "0.7.1",
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
"type": "module",
"bin": {
@@ -15,7 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@openrig/cli": "0.7.15"
"@openrig/cli": "0.7.1"
},
"license": "Apache-2.0",
"author": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "openrig",
"name": "OpenRig",
"version": "0.7.15",
"version": "0.7.1",
"description": "Send work to your OpenRig team of agents and follow it through to the result",
"repository": "https://github.com/mvschwarz/openrig",
"website": "https://openrig.dev",
@@ -12,7 +12,7 @@
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
"distribution": {
"npx": {
"package": "@openrig/[email protected]5"
"package": "@openrig/[email protected]"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.15",
"version": "0.7.1",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
+9 -4
View File
@@ -1166,14 +1166,19 @@ Exit codes:
}
if (limited.length === 0) {
// The daemon identity rides on every empty-fleet line: with more than
// one daemon in a house (OPENRIG_URL, hosts.yaml), a targetless "No rigs"
// invites reading another daemon's answer as this one's — an empty fleet
// read from the wrong daemon reads as a dead fleet.
const daemonLabel = ` · daemon ${client.baseUrl}`;
if (parsedFilter) {
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}`);
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}${daemonLabel}`);
} else if (archivedCount > 0 && !opts.includeArchived) {
// Proven-empty with the history pointer, never a bare "No rigs"
// while archived history exists.
console.log(`No active rigs · ${archivedCount} archived (rig ps --include-archived)`);
console.log(`No active rigs · ${archivedCount} archived (rig ps --include-archived)${daemonLabel}`);
} else {
console.log("No rigs");
console.log(`No rigs${daemonLabel}`);
}
// A host can have connected assistants and no terminal rigs at all.
await printConnectedAssistants(client, opts.rig);
@@ -1192,7 +1197,7 @@ Exit codes:
// FR-1 display element 1: the host rollup line.
const rollupSeats = tableRows.reduce((n, e) => n + e.nodeCount, 0);
const rollupAttention = tableRows.reduce((n, e) => n + (e.attentionCount ?? 0), 0);
console.log(`${tableRows.length} rig${tableRows.length === 1 ? "" : "s"} · ${rollupSeats} seat${rollupSeats === 1 ? "" : "s"} · ${rollupAttention} need${rollupAttention === 1 ? "s" : ""} attention`);
console.log(`${tableRows.length} rig${tableRows.length === 1 ? "" : "s"} · ${rollupSeats} seat${rollupSeats === 1 ? "" : "s"} · ${rollupAttention} need${rollupAttention === 1 ? "s" : ""} attention · daemon ${client.baseUrl}`);
// Human output: apply default truncation budget unless --full.
const humanList = (opts.full || limit !== null) ? tableRows : tableRows.slice(0, HUMAN_RIG_BUDGET);
+2 -2
View File
@@ -1402,8 +1402,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
.option("--wake-after <duration>", "For state=blocked: atomically arm a timer (for example 90s, 15m, 2h)", wakeDurationSeconds)
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--summary <text>", "Summary persisted onto the item, with or without a state change (a blank one isn't stored)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--note <text>", "Transition note for the audit log")
.addHelpText("after", `
After reading an existing remote successor, record its exact closure target, including the host:
+6
View File
@@ -78,6 +78,10 @@ export function renderShortStamp(stampISO: string): string {
return `${stampISO.slice(5, 7)}-${stampISO.slice(8, 10)} ${stampISO.slice(11, 16)}Z`;
}
/** The address OpenRig records an answer typed at this machine under (the daemon's UNATTRIBUTED_ACTOR). It names nobody,
* and a row there asks no one (it waits on Steering and in the Feed, and OpenRig reports it as undeliverable), so a reply hint never points at it. */
const UNATTRIBUTED_PERSON = "human@host";
export function wrapSendBody(
sender: string | undefined,
recipient: string,
@@ -108,6 +112,8 @@ export function wrapSendBody(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: personAddress === UNATTRIBUTED_PERSON
? `↩ ${senderLabel} names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).`
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: meta?.senderReachable === false
+11 -2
View File
@@ -516,8 +516,15 @@ Examples:
cmd
.command("list")
.description("List workflow instances; optionally filter by status")
.description("List workflow instances with optional exact filters")
.option("--status <s>", "Filter by status (active | waiting | completed | failed | aborted)")
.option("--project <id>", "Match the instance project")
.option("--mission <id>", "Match the instance mission")
.option("--slice <id>", "Match the instance slice")
.option("--rig <name>", "Match the bound rig")
.option("--name <workflow>", "Match the workflow name")
.option("--tag <tag>", "Match a scope tag; repeat to require all tags", (tag: string, previous: string[]) => [...previous, tag], [])
.option("--limit <n>", "Maximum matching instances (1-10000)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -525,10 +532,12 @@ Examples:
$ rig workflow list --status active --json
$ rig workflow list --status waiting | jq -r '.instances[].instanceId'
`)
.action(async (opts: { status?: string; json?: boolean }) => {
.action(async (opts: { status?: string; project?: string; mission?: string; slice?: string; rig?: string; name?: string; tag: string[]; limit?: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.status) params.set("status", opts.status);
for (const field of ["project", "mission", "slice", "rig", "name", "limit"] as const) if (opts[field] !== undefined) params.set(field, opts[field]!);
for (const tag of opts.tag) params.append("tag", tag);
const qs = params.toString();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
+30 -3
View File
@@ -651,11 +651,35 @@ function sharedInstructions(has: (tool: string) => boolean): string[] {
const SHARED_INSTRUCTIONS = sharedInstructions(() => true);
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
/** For a connection that relays a person's request to another assistant (an external caller, or the front door): which one
* they mean is theirs to say when more than one fits, never inferred from the agent's own history. The listing steps need
* rig_team, so a connection without it is told only to ask. Pickup is said only where it's true: another connected
* assistant waits for its person; on the front door, a running team seat is woken, and one sent to anyway waits until it's
* back. A member, which works items, isn't given it. */
function askWhichAssistant(canList: boolean, frontDoor: boolean): string {
if (!canList) {
return "If more than one assistant could be the one your person means, ask which; don't pick from your own history or memory. If your person names one clearly, go ahead.";
}
return [
"Before sending a request to another assistant, list the team's members.",
"If more than one could be the one your person means, ask which, using their names from the list (like \"Sam's Claude\"); if the list shows only addresses, describe each in plain words from what the list says about it (like \"the Claude assistant\"), never reading the address out.",
"Don't pick from your own history or memory.",
"If the list shows the chosen one detached or stopped, or a connected assistant with no check-in yet or overdue, say so before sending and ask whether to send it anyway.",
"If the list says the chosen one's person was removed from the team, don't send; tell your person and ask whether to send it to someone else.",
...(frontDoor ? ["When you send to a running team seat, tell your person that seat will pick it up on its own."] : []),
"When you send to another connected assistant, tell your person it will be picked up the next time that assistant's person opens it, or on a schedule they've set up.",
...(frontDoor ? ["If you sent it anyway, tell your person it waits until that one is back."] : []),
"If your person names one clearly, or says to send it anyway to one that isn't connected, go ahead without asking again.",
"If your person still asks to send to one whose person was removed, tell them it can't be delivered: requests to a removed person's assistant are closed unanswered.",
].join(" ");
}
/** rig mcp serve --front-door: the seat is a person's agent in their own app, the front door to their team, not a member
* that works its inbox. The team's questions to it are the person's to answer. */
export const FRONT_DOOR_WORKFLOW_VERSION = "front-door/v2";
export const FRONT_DOOR_WORKFLOW_VERSION = "front-door/v3";
export const FRONT_DOOR_WORKFLOW = [
`Front door (${FRONT_DOOR_WORKFLOW_VERSION}). This seat is a person's agent in their own app: you hand the team their work and bring back what it produced, and you aren't one of its members.`,
askWhichAssistant(true, true),
"Items addressed to your seat (rig_queue_list with owned set) are usually the team's questions about work you handed it. They're the person's to answer, not yours: show each to the person with the team's draft, and record only what the person tells you.",
"To record an answer, claim the item (rig_queue_claim), then close it done with closure reason no-follow-on and exactly what the person typed in resolution, with no label, quotation marks or words of your own (rig_queue_update). If the person isn't there, leave the item open and tell them it's waiting. Never choose an answer for them, and don't claim an item you can't close with their words.",
"Nothing wakes this conversation when the team finishes: check your work and your items when the person asks or comes back.",
@@ -693,8 +717,11 @@ function externalInboxInstructions(tools: ReadonlySet<string>): string {
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS;
const tools = externalTools(access.external);
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? externalInboxInstructions(tools)
const workflow = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? externalInboxInstructions(tools)
: tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
// Only a grant with team/list may reach another connected assistant's seat, and it lists the team with rig_team.
const instructions = tools.has("rig_queue_create")
? `${workflow}\n${askWhichAssistant(tools.has("rig_team"), false)}` : workflow;
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
}
@@ -1689,7 +1716,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
resolution: z.string().optional().describe("The outcome the item keeps, stored only by the update that closes an open item (as rig queue update --resolution)"),
reopen: z.boolean().optional().describe("Reopen a closed item: give an active state (pending, in-progress or blocked) and a note (as rig queue update --reopen)"),
summary: z.string().optional().describe("Replace the one-line summary"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item when closing with state done or parking on a human seat; other updates refuse it. For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
}),
+42
View File
@@ -19,6 +19,16 @@ function mockClient(overrides?: {
return client;
}
// What a connection that relays a person's request is told when more than one assistant could be the one they mean. With
// rig_team it lists the team first; pickup is said only where it's true for the path.
const ASK_LISTED = "Before sending a request to another assistant, list the team's members. If more than one could be the one your person means, ask which, using their names from the list (like \"Sam's Claude\"); if the list shows only addresses, describe each in plain words from what the list says about it (like \"the Claude assistant\"), never reading the address out. Don't pick from your own history or memory. If the list shows the chosen one detached or stopped, or a connected assistant with no check-in yet or overdue, say so before sending and ask whether to send it anyway. If the list says the chosen one's person was removed from the team, don't send; tell your person and ask whether to send it to someone else.";
const ASK_RUNNING_PICKUP = "When you send to a running team seat, tell your person that seat will pick it up on its own.";
const ASK_CONNECTED_PICKUP = "When you send to another connected assistant, tell your person it will be picked up the next time that assistant's person opens it, or on a schedule they've set up.";
const ASK_SENT_ANYWAY = "If you sent it anyway, tell your person it waits until that one is back.";
const ASK_UNLISTED = "If more than one assistant could be the one your person means, ask which; don't pick from your own history or memory. If your person names one clearly, go ahead.";
const ASK_GO_AHEAD = "If your person names one clearly, or says to send it anyway to one that isn't connected, go ahead without asking again.";
const ASK_REMOVED_FACT = "If your person still asks to send to one whose person was removed, tell them it can't be delivered: requests to a removed person's assistant are closed unanswered.";
describe("MCP Server", () => {
let mcpClient: Client;
let daemonClient: DaemonClient;
@@ -395,6 +405,10 @@ describe("MCP Server", () => {
expect(instructions).not.toContain(MEMBER_WORKFLOW);
expect(instructions).not.toContain("without asking whether you may");
for (const part of ["the person's to answer", "record only what the person tells you", "Never choose an answer for them", "exactly what the person typed in resolution, with no label"]) expect(FRONT_DOOR_WORKFLOW).toContain(part);
// It hands the team the person's work, so which assistant they mean is theirs to say when several fit. Its list shows
// team seats and connected assistants: only a running team seat picks a request up on its own.
expect(FRONT_DOOR_WORKFLOW).toContain(`${ASK_LISTED} ${ASK_RUNNING_PICKUP} ${ASK_CONNECTED_PICKUP} ${ASK_SENT_ANYWAY} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
expect(FRONT_DOOR_WORKFLOW).not.toContain("<name>");
// An agent told to say the words are the person's wrapped them as `The person's answer: "…"`, so the stored answer wasn't what they typed.
expect(FRONT_DOOR_WORKFLOW).not.toContain("saying they're the person's");
const front = await mcpClient.callTool({ name: "rig_whoami", arguments: {} });
@@ -877,11 +891,33 @@ describe("MCP Server", () => {
expect(instructions).not.toContain(MEMBER_WORKFLOW_VERSION);
expect(instructions).not.toContain("basically authorized");
expect(instructions).toContain("You may omit destination");
// Asked to "send my Claude assistant one request" on a team with four Claude seats, a client picked one from its own history.
// Without rig_team it can't list the team, and its destinations aren't other connected assistants: it's told only to ask.
expect(instructions).toContain(ASK_UNLISTED);
expect(instructions).not.toContain("list the team's members");
expect(instructions).not.toContain("removed from the team");
expect(instructions).not.toContain("closed unanswered");
expect(instructions).not.toMatch(/picked up|pick it up/);
expect(instructions).not.toMatch(/rig_whoami|rig_team|rig_queue_handoff|rig_queue_decide/);
await external.close();
await server.close();
});
it("an external request grant with team/list lists the team before asking which assistant, and says when another connected one picks up", 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"], team: ["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(`${ASK_LISTED} ${ASK_CONNECTED_PICKUP} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
expect(instructions).not.toContain(ASK_RUNNING_PICKUP);
expect(instructions).not.toContain(ASK_SENT_ANYWAY);
await external.close();
await server.close();
});
it.each([
["a full inbox grant", { queue: ["create", "show", "list", "claim", "update"], team: ["list"] }],
["a narrow inbox grant", { queue: ["claim", "update"] }],
@@ -899,6 +935,10 @@ describe("MCP Server", () => {
expect(named.filter((tool) => !offered.has(tool))).toEqual([]);
expect(instructions).not.toMatch(/rig_whoami|rig_queue_handoff|rig_queue_decide/);
expect(instructions.includes("rig_team")).toBe(offered.has("rig_team"));
// Only a grant that can hand the team work is told to ask which assistant the person means; with team/list it lists the
// team first and may reach another connected assistant, so it's told when that one picks the request up.
expect(instructions.includes("ask which")).toBe(offered.has("rig_queue_create"));
if (offered.has("rig_queue_create")) expect(instructions).toContain(`${ASK_LISTED} ${ASK_CONNECTED_PICKUP} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
await external.close();
await server.close();
});
@@ -911,6 +951,8 @@ describe("MCP Server", () => {
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
]) expect(instructions).toContain(paragraph);
// A member works items rather than relaying a person's request, so it isn't told to ask which assistant they mean.
expect(instructions).not.toContain("ask which");
});
it("shows a connected assistant's person plain titles and openings, keeping each tool's rules and hints", async () => {
@@ -155,6 +155,6 @@ describe("rig ps: connected assistants and their check-in status", () => {
expect(process.exitCode ?? 0).toBe(0);
}
rigs = [];
expect((await run([])).join("\n")).toContain("No rigs\n\nConnected assistants: check-in unavailable");
expect((await run([])).join("\n")).toContain(`No rigs · daemon http://127.0.0.1:${port}\n\nConnected assistants: check-in unavailable`);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { afterEach, expect, it, vi } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Command } from "commander";
import { psCommand } from "../src/commands/ps.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
import { psRoutes } from "../../daemon/src/routes/ps.js";
import { PsProjectionService } from "../../daemon/src/domain/ps-projection.js";
import { createFullTestDb } from "../../daemon/test/helpers/test-app.js";
let close: (() => Promise<void>) | undefined;
afterEach(async () => { await close?.(); close = undefined; vi.restoreAllMocks(); });
/** Serves /api/ps over a fresh test db. activeRigs=0 yields a daemon whose
* only rig is archived — the "No active rigs" empty-fleet shape. */
async function fixture(activeRigs = 1) {
const db = createFullTestDb();
if (activeRigs > 0) {
db.prepare("INSERT INTO rigs (id, name) VALUES ('r', 'fixture')").run();
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES ('n', 'r', 'builder')").run();
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('s', 'n', 'fixture-builder', 'running')").run();
}
db.prepare("INSERT INTO rigs (id, name, archived_at) VALUES ('archived', 'archived-fixture', datetime('now'))").run();
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES ('archived-n', 'archived', 'builder')").run();
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('archived-s', 'archived-n', 'archived-builder', 'running')").run();
const app = new Hono();
app.use("*", async (c, next) => { c.set("psProjectionService" as never, new PsProjectionService({ db }) as never); await next(); });
app.route("/api/ps", psRoutes);
const server = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" });
await new Promise<void>((resolve) => server.on("listening", resolve));
const port = (server.address() as { port: number }).port;
close = () => new Promise<void>((resolve, reject) => { server.close((err) => { db.close(); err ? reject(err) : resolve(); }); });
const client = new DaemonClient(`http://127.0.0.1:${port}`);
const deps = { clientFactory: () => client, lifecycleDeps: {
exists: (p: string) => p === STATE_FILE,
readFile: () => JSON.stringify({ pid: 123, port, db: "fixture.sqlite", startedAt: new Date().toISOString() }),
fetch: async () => ({ ok: true }), isProcessAlive: () => true,
spawn: vi.fn(), kill: vi.fn(), writeFile: vi.fn(), removeFile: vi.fn(), mkdirp: vi.fn(), openForAppend: vi.fn(),
} };
const command = new Command().addCommand(psCommand(deps as never));
return { baseUrl: `http://127.0.0.1:${port}`, command };
}
it("names the daemon on the rollup line when rigs are listed", async () => {
const { baseUrl, command } = await fixture(1);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await command.parseAsync(["node", "rig", "ps"]);
const rollup = log.mock.calls.flat().find((l) => /needs? attention/.test(String(l)));
expect(rollup).toBeDefined();
expect(String(rollup)).toMatch(/1 rig · 1 seat · 0 need attention · daemon http:\/\/127\.0\.0\.1:\d+$/);
expect(String(rollup)).toContain(`daemon ${baseUrl}`);
});
it("names the daemon on the empty-fleet lines — the wrong-daemon trap case", async () => {
const { baseUrl, command } = await fixture(0);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await command.parseAsync(["node", "rig", "ps"]);
const lines = log.mock.calls.flat().map(String).join("\n");
expect(lines).toContain(`No active rigs · 1 archived (rig ps --include-archived) · daemon ${baseUrl}`);
});
it("names the daemon on the filter-miss line", async () => {
const { baseUrl, command } = await fixture(1);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await command.parseAsync(["node", "rig", "ps", "--filter", "lifecycleState=attention_required"]);
const miss = log.mock.calls.flat().find((l) => String(l).startsWith("No rigs match"));
expect(String(miss)).toContain(`daemon ${baseUrl}`);
});
@@ -257,7 +257,7 @@ describe("PL-012 ps --filter contextUsage.* + CTX field", () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--filter", filter]);
});
expect(result.exitCode).toBeUndefined();
expect(result.logs).toEqual([`No rigs match --filter ${key}=${value}`]);
expect(result.logs).toEqual([`No rigs match --filter ${key}=${value} · daemon http://127.0.0.1:${port}`]);
expect(result.errLogs).toHaveLength(1);
expect(result.errLogs[0]).toContain(key);
expect(result.errLogs[0]).toContain("was not applied");
@@ -165,7 +165,7 @@ describe("queue writes refused for a host token the daemon doesn't know", () =>
["a remote's refusal", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "remote-error", remoteStatus: 400, detail: "HTTP 400" } }],
["an auth failure", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "auth-failed", detail: "401" } }],
["an internal error", { status: 500, data: { error: "internal_error", message: "boom" } }],
["a 400 this list doesn't name", { status: 400, data: { error: "summary_evidence_not_persistable", message: "x" } }],
["a 400 this list doesn't name", { status: 400, data: { error: "closure_fields_not_admitted", message: "x" } }],
];
for (const [label, reply] of UNPROVEN) {
it(`never says it after ${label}`, async () => {
@@ -1,9 +1,7 @@
// 51-06 D2 — REAL CLI acceptance boundary (Guard implementation-verdict correction). A genuine
// `queueCommand` invocation talks over LOOPBACK HTTP to the real candidate queueRoutes +
// QueueRepository, backed by a migration-faithful test DB (incl. 044 summary + 048 evidence_ref).
// No pre-baked response: if the daemon route/repo stopped rejecting (or mutated before responding),
// these tests would fail. Proves normal + --json reject, UNCHANGED item/transition/event state after
// the HTTP call, and the successful human-park control — all end to end.
// 51-06 D2, narrowed — REAL CLI acceptance boundary. A genuine `queueCommand` invocation talks over
// LOOPBACK HTTP to the real candidate queueRoutes + QueueRepository, backed by a migration-faithful test DB
// (incl. 044 summary + 048 evidence_ref). No pre-baked response. A state change that carries --summary or
// --evidence-ref is applied and stores them; a blank --summary is reported as not stored, never dropped silently.
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
@@ -107,27 +105,29 @@ describe("51-06 D2 — real CLI->loopback HTTP->queueRoutes/QueueRepository acce
});
afterEach(() => { vi.unstubAllEnvs(); });
it("normal: --summary on a non-park update -> real 400, nonzero exit, item/transition/event UNCHANGED", async () => {
const beforeTxns = txnCount(id), beforeEvents = eventCount();
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--summary", "DROPME"]);
expect(exit).toBe(1);
expect(out).toContain("summary_evidence_not_persistable");
expect(out).toContain("invalidFields");
// unchanged item/metadata/transition/event state AFTER the real HTTP call
const item = repo.getByIdOrThrow(id);
expect(item.state).toBe("pending");
expect(item.summary).toBeNull();
expect(txnCount(id)).toBe(beforeTxns);
expect(eventCount()).toBe(beforeEvents);
it("normal: --summary and --evidence-ref on a state change are applied and stored (exit 0)", async () => {
const beforeTxns = txnCount(id);
const { exit } = await runCli(["update", id, "--state", "in-progress", "--summary", "KEEP", "--evidence-ref", "/proof/e.md"]);
expect(exit).toBeFalsy();
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "KEEP", evidenceRef: "/proof/e.md" });
expect(txnCount(id)).toBe(beforeTxns + 1);
});
it("--json: same reject as structured JSON with nonzero exit, state UNCHANGED", async () => {
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--evidence-ref", "/e.md", "--json"]);
expect(exit).toBe(1);
const body = JSON.parse(out) as { error: string; invalidFields?: string[] };
expect(body.error).toBe("summary_evidence_not_persistable");
expect(body.invalidFields).toEqual(["evidenceRef"]);
expect(repo.getByIdOrThrow(id).evidenceRef).toBeNull();
it("a finished seat's park on a typed gate with --summary and --evidence-ref parks and stores both (--json)", async () => {
await runCli(["update", id, "--state", "in-progress"]);
const { out, exit } = await runCli(["block", id, "--on", "external:relay-reply", "--summary", "Relayed; waiting on the reply", "--evidence-ref", "/proof/relay.md", "--json"]);
expect(exit).toBeFalsy();
expect(JSON.parse(out)).toMatchObject({ state: "blocked", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "blocked", blockedOn: "external:relay-reply", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
});
it.each([false, true])("a blank --summary is applied without it and reported as not stored (json=%s)", async (json) => {
db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run("Original subject", id);
const { out, exit } = await runCli(["update", id, "--state", "in-progress", "--summary", " ", ...(json ? ["--json"] : [])]);
expect(exit).toBeFalsy();
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject" });
if (json) expect(JSON.parse(out).advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
else expect(out).toContain(`The summary was blank, so it wasn't stored: ${id} keeps its summary`);
});
it("CONTROL: real-CLI human-seat park PERSISTS summary + evidence (exit 0)", async () => {
+9 -1
View File
@@ -11,7 +11,15 @@ describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)"
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
});
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
// An answer typed at this machine is recorded as human@host, which names nobody: a reply there asks no one, waits on
// Steering and in the Feed, and OpenRig reports it as undeliverable, so its reply hint says so and points to the person's registered address.
it.each(["human@host", "human@host@host-origin"])("never points %s's reply at the unattributed address", (sender) => {
const last = wrapSendBody(sender, "driver@rig", "Answered.").split("\n").at(-1);
expect(last).toBe(`↩ ${sender} names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).`);
expect(last).not.toContain("rig queue create");
});
it.each(["human@kernel", "human-alice@kernel", "human-alice@host"])(
"gives %s a queue update reply with the existing human-route fields",
(sender) => {
const body = "Please check this.\nKeep the actor unchanged.";
+9
View File
@@ -311,6 +311,15 @@ describe("rig workflow CLI (PL-004 Phase D)", () => {
expect(calls.find((c) => c.path === "/api/workflow/list?status=active")).toBeDefined();
});
it("passes combined workflow filters and repeated tags to the list API", async () => {
const url = "/api/workflow/list?status=active&project=p&mission=m&slice=s&rig=rig&name=wf&limit=4&tag=one&tag=two";
const { deps, calls } = makeDeps({ routes: { [`GET ${url}`]: { status: 200, data: [] } } });
const program = createProgram({ workflowDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "workflow", "list", "--status", "active", "--project", "p", "--mission", "m", "--slice", "s", "--rig", "rig", "--name", "wf", "--limit", "4", "--tag", "one", "--tag", "two", "--json"]);
expect(calls.find((call) => call.path === url)).toBeDefined();
});
it("show GETs /api/workflow/:instanceId", async () => {
const { deps, calls } = makeDeps({
routes: { "GET /api/workflow/inst-1": { status: 200, data: { instanceId: "inst-1" } } },
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.30",
"version": "0.1.29",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.30",
"version": "0.1.29",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -106,7 +106,10 @@ You are about to lose every connection you have built. Spend this turn making th
3. **Open the map with a summary** of 10 to 20 lines: who you are, what you hold, what mattered in this
window, what is next and who authorizes it, and any hold in force. **Publish that summary as your seat
recap** too: save it, plus a line naming the map's path, to a file and run
`rig context recap-write --file <that file>`, which writes your own seat's recap. If your world profile has a seat
`rig context recap-write --file <that file>`, which writes your own seat's recap. Give the file's literal absolute
path, with no shell variable assignment or command substitution: Claude Code's allow rules don't match past an
assignment (apart from a few known-safe variables), and an ask or deny rule still wins, so such a command can stop at
an approval prompt, and OpenRig won't send `/compact` while it waits. If your world profile has a seat
recap atom, it loads this recap, and an old recap would be served as if it were current.
4. **Then write the connections.** Choose what this seat needs; these are examples, not a template:
- **State:** rows you hold (id, state), branches and PR heads, the hold or release in force and who gave
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.15",
"version": "0.1.0",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.15",
"version": "0.1.0",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.15",
"version": "0.1.0",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"logo": "assets/logo.svg"
}
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "openrig",
"version": "0.7.15",
"version": "0.1.0",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
+78 -25
View File
@@ -127,8 +127,8 @@ person needs in their project or home folder and name that path.
joining as optional, with the effects below. Ask the person yes or no after this short explanation:
- This adds me as a named member of the team, with my name on messages.
- In a plain shell, joining writes no files in your project or personal settings.
- Inside tmux on 0.6.11, my sender and reply address is your existing tmux session name, even a number like `0`.
`rig attach --self` returns that address and `rig whoami` confirms it; `--display-name` does not change it.
- Inside tmux, my sender and reply address is `<member>@<rig>`, even when your tmux session is named `0`;
`rig attach --self` returns it and `rig whoami` confirms it; `--display-name` does not change it.
- Inside tmux, other agents can type into this window, and stopping the team (`rig down`) also closes this tmux
session (both from source, not hand-tested). Transcript recording and, for Claude Code, writing
`.openrig/context-collector.cjs` and `.claude/settings.local.json` for the status line were hand-tested on 0.6.11.
@@ -147,7 +147,8 @@ person needs in their project or home folder and name that path.
`rig queue create --source <their id>@external --destination <seat> --summary "<one line>" --body-file <file>`: the
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
shell above is the route.
shell above is the route. For work whose questions and result must come back to this session, use a return
address ("If the person stays with you" below), not a declared name.
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
name someone else), and don't offer again.
@@ -168,9 +169,10 @@ person needs in their project or home folder and name that path.
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
(never the logical ID or a guessed name) and send:
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
results to relay." Carry their constraints, which logins they have (an installed provider that isn't signed in
doesn't count), and any permission choice they made, chosen or declined, in either handoff.
If the person stays here, hand the goal over as a task from your own address instead, so the operator's questions
and the result come back to you ("If the person stays with you" below). Carry their constraints, which logins they
have (an installed provider that isn't signed in doesn't count), and any permission choice they made, chosen or
declined, in either handoff.
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
@@ -181,11 +183,54 @@ person needs in their project or home folder and name that path.
Check those logins (`claude auth status`, `codex login status`), or go by what the person told you. If one is missing
or you can't tell, say so before launching and let them choose: the operator adapts the team to the logins they have,
or they sign in first.
- **Relay the operator's questions** unless the person declined the Codex offer below and explicitly chose the operator route: the operator asks in its own pane, and the person may not be looking there.
After each step you hand it, read its screen with `rig capture <canonicalSessionName>`. When it asks the person
something (start this team? which folder? which option?), ask them here in the operator's words and send their
answer back with `rig send`, or tell them to answer in the operator's pane. Unlike a prompt or a menu, a question
doesn't show up as a stopped seat, so don't leave it to a background wait.
- **If the person stays with you** (unless they declined the Codex offer below and explicitly chose the operator
route), you're their front door to the team: you hand it the goal, bring its questions to them and bring back what
it produced, through the task queue rather than the operator's screen.
- **Your return address:** if this session joined the team (above), it's the `OPENRIG_SESSION_NAME` that
`rig attach` printed. Otherwise agree one with the person on the kernel's rig, such as `claude-app@kernel` or
`codex-app@kernel`. It's an address the queue takes tasks for; nothing is launched for it and no file is written.
If the person keeps more than one of these conversations, give each its own address (for example
`claude-app-2@kernel`); one address shares its questions.
To tell the team what it's for, the person can add it to a roster as a hosted member
(`docs/reference/hosted-members.md` in the OpenRig package).
Put it in front of every `rig queue` command: `OPENRIG_SESSION_NAME=<address> rig queue ...` (add
`OPENRIG_HOME=<home>` when OpenRig doesn't use the default home). A `--source` name only labels the one task it's
on: claiming and answering the team's questions need the address in `OPENRIG_SESSION_NAME`.
- **Hand over the goal:** write it to a file with what the message above carries (that you're the agent that
installed OpenRig and the person stays with you, the goal, folder, branch, constraints, logins and permission
choices), and end it with this paragraph, filling in your address:
> To ask me anything, create a queue item to `<address>` (`rig queue create --destination <address> ...`), then
> block this item on that new item's id (`rig queue block <this id> --on <question id>`), so my answer wakes you. I
> check it when the person asks. Don't ask in your own terminal: the person isn't there. If you hand this item on,
> copy this paragraph into the new item. When you finish, put the outcome in this item's resolution and any file in
> its evidence ref.
Then run
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <canonicalSessionName> --summary "<the goal in a sentence>" --body-file <file> --verify`.
Tell the person the task ID it prints, and keep the goal, your address and that ID: they're how you find this work
again.
- **The team's questions** come to your address as tasks:
`OPENRIG_SESSION_NAME=<address> rig queue list --owned --state pending,in-progress`. Read each in full
(`rig queue show <id> --full`) and show the person the whole question in the team's words. Record only what they
tell you, a condition included ("yes, but only on a branch"): claim it, then
`OPENRIG_SESSION_NAME=<address> rig queue update <id> --state done --closure-reason no-follow-on --resolution "<their words>"`.
Add no label, quotation marks or notes of your own, and don't turn their words into a yes or a no: the agent that
asked decides what they mean. You speak as yourself, never as the person. If they aren't there to answer, leave
it open and tell them it's waiting; never answer for them.
- **A question only on the operator's screen:** if the operator asks in its pane and nothing reached your address,
read it with `rig capture <canonicalSessionName>`, show the person, and send their words as a new task to the
operator that names your task's ID, not as keys typed into its pane.
- **Delivery isn't the task's state.** A task's state says where the work is: pending (created and readable, not
yet claimed), claimed or in progress (an agent took it), handed off (follow the next item) or done (read its
resolution). Whether its agent was woken is a separate fact, the wake outcome `--verify` reports: `verified` (it
rendered in the seat's pane) or `claimed` (picked up); `delivered-ack-pending` (sent, render unconfirmed: not
proof it was woken); `retained` (the seat's typing guard held the wake, the task is still there to read, and it
isn't retried automatically: wake the task's destination once its input is free with
`rig send <destination> "task <id> is yours"`); `failed` (the wake didn't land, and the task is still
owed: wake it the same way); `indeterminate` or `still-pending` (unknown, neither failed nor delivered: check
before you wake it again). A pending task with no wake isn't lost or done.
- **Nothing wakes this chat** when the team asks or finishes: check your address when the person asks or comes back.
- **If the operator answers with a model error,** such as "… not supported when using Codex with a ChatGPT account", its
Codex sign-in works but that account can't run the model. OpenRig put the operator on Codex because the sign-in
passed. Tell the person which model failed. If Claude Code is signed in, offer to move the operator to it; on their
@@ -216,9 +261,9 @@ person needs in their project or home folder and name that path.
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
Another kind of menu you can answer with
the key that screen shows, or ask the person. Capture it again before sending anything more.
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
`rig queue list --destination <your address>` and `rig queue show <id> --full`, and read an agent's screen with
`rig capture <seat>@<rig>`.
- **Replies:** in a plain shell, other agents can't type into this session. Their questions and work for you arrive
at your return address ("If the person stays with you" above). `rig capture <seat>@<rig>` shows what an agent is
doing; it isn't how answers reach you.
### Codex: one offer, once, after OpenRig starts
@@ -257,21 +302,29 @@ offer, so the person sees the real file, not a placeholder:
Follow these steps by default. Only when the person declined the Codex offer above and explicitly chose the operator route does the operator own this follow-through.
- **Find the team's task:** the operator gives the goal to the team's lead as a queue task: `dev-build@starter`, or
`orch-lead` in `workshop` and `factory` (for starter and factory, `rig specs preview <name> --kind rig` says what
each seat does; for a running team, `rig ps --nodes --rig <rig>`). Find it with
`rig queue list --destination <seat>@<rig> -a`.
- **Find the team's task:** if you gave the operator a task, start from it: when the operator hands it to the team's
lead, `rig queue show <id> --json` lists the next item under `successors`. Otherwise, or if the operator started a
new task for the lead (`dev-build@starter`, or `orch-lead` in `workshop` and `factory`; for starter and factory,
`rig specs preview <name> --kind rig` says what each seat does; for a running team, `rig ps --nodes --rig <rig>`),
find it with `rig queue list --destination <seat>@<rig> -a`.
- **Follow it to the result:** a task that changed hands isn't finished. `rig queue show <id> --full` names the seat it
went to, and that seat's next task is the one whose `handedOffFrom` is that ID
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result, and
use `rig capture <seat>@<rig>` to see what it's doing. Then read what it produced and tell the person what the team
actually did (the branch, commit, review or PR text), not just that you sent it.
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result,
and report what the record says; `rig capture <seat>@<rig>` only shows what an agent is doing right now. Then read
what it produced and tell the person what the team actually did (the branch, commit, review or PR text), not just
that you sent it.
- **Give the team more work:** write the goal to a file: what to change, in which repository and branch, what done
looks like, and that the result comes back to you (the branch or commit, the review, any PR text). Then
`rig queue create --source <your name> --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file>`, and tell that agent with
`rig send <seat>@<rig> "task <id> is yours"`. A message informs, and a queue task is the work someone owns.
`--source` names you when this session hasn't joined the team.
looks like, and that the result comes back to you (the branch or commit, the review, any PR text), ending with the
reply paragraph above. Then
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file> --verify`.
A message informs, and a queue task is the work someone owns. Read its wake outcome as above before you send
anything else.
- **Then ask what's next.** If no team can take the work, fix that or ask the person. Don't quietly do it yourself.
- **Coming back later:** in a new session, use the same return address and the task IDs you gave the person. Read
each with `rig queue show <id> --json` and follow its `successors` to the newest item, as above;
`OPENRIG_SESSION_NAME=<address> rig queue list --mine -a` lists everything this address sent or owns, finished work
included (without `-a` it shows only open items). Follow that work rather than creating it again.
Adding OpenRig's MCP plugin to this app later starts a new session; it doesn't carry this conversation over.
- **Other ways to work with the team:** `rig send <seat>@<rig> "..."` types into an agent's terminal,
`rig capture <seat>@<rig>` reads its screen, and `rig ps --nodes --rig <rig>` shows who's on it.
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
lands, something rhymes, and you go check instead of building.
Ninety-three top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.15`). This marker describes the pack's teaching, not publication or live
`capability-delta-v0.7.13`). This marker describes the pack's teaching, not publication or live
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
What changed in the installed version, and what to do about it, is one read away:
`rig context get reference/whats-new.md`.
@@ -33,10 +33,7 @@ one command away. Your memory of the fleet is a claim about the past.
*including the broken ones*. **The bare form is the question you usually want:** `--active`
filters to running and **hides detached, exited and attention states** — which are exactly the
cells you were scanning for. A seat whose tmux session is confirmed gone reads `detached`
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it. Since 0.7.14 `rig ps` ends with connected
assistants and their check-in line, and node reads take turns through their three synchronous
steps, so other reads and sends aren't held behind a burst of them; a nodes response is no longer
one snapshot across those steps, and in a burst each one comes later.
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it.
A verified exited process in a retained pane reads `pane_exited`, instead of running and ready;
outstanding work uses the existing recovery-finding route. An unknown observation is not an exit.
`rig ps --nodes` also shows the model each seat was last seen using (`modelObservation`, with its
@@ -112,8 +109,7 @@ one command away. Your memory of the fleet is a claim about the past.
Closed work's content and metadata can also be erased on a schedule
(`retention.closed_content_days`, `retention.closed_metadata_days`; `0`, the default, keeps them),
with an optional `retention.erasure_ledger`, for a retention policy or an account deletion. It's
opt-in, and a retried create can't bring erased work back. Since 0.7.14 an account's erasure also
removes OpenRig's own wakes and notices for that account's seats.
opt-in, and a retried create can't bring erased work back.
- **Check the exit code.** Many commands used to print nothing and exit 0 when a daemon read
failed; now they exit 1 or 2. Empty output from a failed read is not empty state.
@@ -128,9 +124,7 @@ unseen — which makes messages the one delivery channel that never gets skipped
consequential delivery gets an effect check at the far end rather than a blind retry.
`--verify` exits 1 when it can't confirm what its Enter did (`submission unconfirmed`, or text
staged but not submitted): the message may still have arrived, so inspect with `rig capture`
before any resend, and don't let a script resend on that exit code alone. Since 0.7.14 an ordinary
send is refused as needing input while an open Claude Code or Codex `/model` picker is
positively identified; nothing is typed. A
before any resend, and don't let a script resend on that exit code alone. A
producer-link advisory reading `no_activity_signal` means activity could not be determined;
confirm by effect, never relaunch on it. A send or wake to a seat whose latest hook is a
question waiting for a person is refused with `target_needs_input`, even when its screen
@@ -181,8 +175,7 @@ unseen — which makes messages the one delivery channel that never gets skipped
and the box was empty after Enter; it only reports.
- **`rig broadcast`** — one fact to everyone at once, rather than relaying it N times and getting
the wording wrong on the fourth. **Blast radius is real** — on a large rig this lands in every
seat's turn. A broadcast that reached no one says so, and a recipient a seat's setting held
reads as retained, not sent.
seat's turn. A broadcast that reached no one says so.
- **`rig capture <seat>`** — read what is on their screen right now. **`--rig` / `--pod`** captures
every seat in one call. `--lines N` is the last N lines (stderr says how many earlier lines were
cut); `--history-plus-pane` gives the older N history lines plus the whole pane. A short capture
@@ -262,7 +255,7 @@ unseen — which makes messages the one delivery channel that never gets skipped
default. **Opening `rig terminal open saved:kernel --window` is the step after install or start**:
say you're opening it, run it on the daemon's desktop, then tell the person where it opened. Don't
wait for a phrase like “show me my agents”; the same command brings their agents back later. No
YAML, herdr when installed or plain tmux. Rex tiles show each seat's agent and its activity. The layout follows the terminal's width: on a narrow
YAML, herdr when installed or plain tmux. The layout follows the terminal's width: on a narrow
one the operator gets the first tab; on a wide one the dashboard and operator sit side by side
and the advisor gets its own tab. A `kernel` view the person saved still wins.
Tiles show their seat's screen without attaching, so whole lines show at any tile width; a
@@ -352,12 +345,7 @@ unseen — which makes messages the one delivery channel that never gets skipped
never posted. By default, an emoji reaction on a message posted for an ask reaches the asking seat
as a task tagged `human-reaction`: a signal to interpret, not an answer, and the ask stays open.
`rig slack setup --reactions off` disables that routing; the default is `route`, and regenerating
the app manifest preserves the setting. Since 0.7.15 link and media previews on posts to a person
are off by default: `rig slack setup --unfurl on` turns them back on from the connector's next
rewire, and `rig slack status` shows the mode. When the arrival note is recorded before the
answer is routed, an answer to a reply in an older thread posts back into that thread; a failed
note write posts it top-level; a notification posted before the note is recorded can also lead
to a top-level answer.
the app manifest preserves the setting.
It needs the `reactions:read` scope and the `reaction_added` event (reinstall the app);
`rig slack verify` warns when the scope is missing. A long ask is posted in numbered thread parts; one that
still can't be posted comes back to the asking seat as a `slack-undeliverable` task. Replies
@@ -492,8 +480,7 @@ relay is a reason to inspect the available routing, not to surrender judgment to
- **`rig workflow validate`** — will this spec instantiate at all, before a run finds out for you. A
step with no allowed exit that can finish it or route onward is reported as `step_cannot_finish`.
An allowed mapped exit is valid; in a dependency graph, `handoff` can finish a sink without a next
step. Since 0.7.14 each step with a `harness:` pin gets a `harness_pin_unchecked` warning:
validation doesn't check pins against seats.
step.
- **`rig workflow compile <mission>`** / **`instantiate-lifecycle`** — derive one executable graph
from `project.yaml` → `mission.yaml` → `slice.yaml`, inspect it without writing, then start an
eligible graph with an opaque replay key. Typed acceptance candidate, verdict, and evidence
@@ -645,9 +632,6 @@ not have to tear one down to change it, and you rarely have to start from nothin
steps): hand it work, follow it, answer the team's questions in their own words, and read the
result. VS Code, Copilot CLI and Cursor load it with no seat: give the app its own
`rig mcp serve --front-door` server with `OPENRIG_SESSION_NAME` set and turn the plugin's off.
A skill-only edition for Claude's plugin directory, in `plugins/claude-directory/`, follows
handed-on work and keeps its reply route. The plugin needs Claude Code 2.1.119 or later: on
earlier versions, with its OpenRig seat setting blank, the agent has no OpenRig tools.
- **A Claude member can bring its own setup** in the rig spec: `agent_file`,
`append_system_prompt_file`, `plugin_dirs` and `advisor` (an Advisor model). They're copied into
that seat's own storage, never the project, so one seat in a shared repository can have its own
@@ -731,8 +715,6 @@ not have to tear one down to change it, and you rarely have to start from nothin
(or, when it names none, the only one Codex has logged since it started); OpenRig can't tell
which conversation Codex is on now. If the conversation was changed inside the running Codex,
the restart may refuse or resume the earlier one, so look at the pane (`rig capture <seat>`) first.
Since 0.7.15, when it refuses a recoverable seat whose tmux session is gone (`session_not_live`),
it names the way back: `rig launch '<rig>' '<node>'` (add `--plan` to preview).
- **`rig add`** / **`rig expand`** — graft one more member, or a whole new pod, onto a rig that is
already running. `rig add`, `rig grow` and `rig remove` take the rig's name or ID; a name two active rigs share is refused
with `rig_ambiguous` and the candidates. `rig grow --new-pod` and `rig expand` are one ingress; choose by input shape.
@@ -800,14 +782,6 @@ version, from checking the environment to a useful report to the OpenRig team.
Stop distinguishes process/listener exit from a clean asynchronous drain: inspect the matching
`daemon-shutdown.json` and log when completion is incomplete or unverified. No target is a
distinct no-op, and a timeout never proves down. Use command help for exact limits and recovery.
Since 0.7.15 start gives the child it started a 180-second readiness budget, with a progress
line about every 20 s: let it finish rather than starting again. Synchronous work can run past
the budget, and it isn't a cure for a slow start.
When the daemon stalls, read `$OPENRIG_HOME/logs/slow-operations.jsonl` before guessing: it names
slow synchronous steps by site, since 0.7.15 including three queue sites and three selected steps
of an ordinary nodes read (`nodes.inventory.build`, `nodes.inventory.activity_sync`,
`nodes.inventory.terminal_work`). These nodes records are written after a slow call completes; a
missing record doesn't rule out unfinished work or work outside those steps.
On Linux, when the daemon runs as a systemd user service, the seats' tmux server starts in its
own user scope, so stopping or restarting the daemon's unit keeps the seats; otherwise it starts
as before, with one log line giving the reason.
@@ -874,12 +848,6 @@ version, from checking the environment to a useful report to the OpenRig team.
Use `rig launch ... --snapshot-id <id>` when selection must be exact, and `rig restore status
<attempt> --rig <rig>` for the derived intended-set receipt after an asynchronous restore.
restore-check's per-seat `seat.<s>.queue-file` check is now `seat.<s>.queue-store`.
Since 0.7.15, for an eligible seat in a rig with pods that it starts in its own pane, restore
tries the saved conversation when the seat has a resume token but no saved startup context, and
records the `startup_context` gap; a successful resume reads `partially_restored`, and a missing
conversation or failed start keeps its own status. restore-check reads that seat yellow. Both warn when a snapshot's named Codex profile was cleared
from the current seat. A rig can still read `fully_restored` past a seat restore didn't start or
one left attached, and a seat left as a bare shell still counts as running in `rig ps`.
- **`rig restore-packet write` / `read` / `validate`** — a seat is about to die or must move
runtimes; capture what it knows into a portable artifact instead of losing it with the process.
- **Upgrading is agent-led** — load the shipped `openrig-upgrade` skill for the bounded inspect,
@@ -978,12 +946,6 @@ version, from checking the environment to a useful report to the OpenRig team.
re-nudges). `--enabled true|false` remains compatible: true selects hold, false selects off.
Known limit: in `draft-aware`, a multi-line message to a Claude seat can stay unsubmitted when the
paste changes Claude's footer hint, and later sends wait behind it; check the seat's composer.
Since 0.7.15 `draft-aware` re-checks right before an automatic paste and holds after recent
client activity or when tmux can't be read; typing after that last check still isn't seen. With
Claude Code's full-width auto-update-failure footer on screen, an empty `draft-aware` seat can
hold an ordinary send; `rig seat held-messages` shows it. On Codex (seen with 0.167.1 and
0.167.2), a send to a busy `draft-aware` seat can be pasted without Enter, neither delivered
nor held; its text stays in the input, and there's no fix for it in this release.
`rig seat status` shows the requested/effective mode; wait for pending activation to finish.
**`rig seat held-messages`** lists what was retained and **`rig seat retire-held-message`**
retires one; changing mode does not replay old held messages. A seat that starts, or a successor
@@ -1000,8 +962,7 @@ version, from checking the environment to a useful report to the OpenRig team.
- **`rig compact-plan`** → **`rig compact`** — who is near the context wall, then act on it.
**Ordering matters: running `compact` without the plan is guessing which seat needed it.**
Managed compaction waits for the seat's restore map before compacting; `rig compact --state`,
`--cancel` and `--skip-map` inspect, stop or skip that wait; since 0.7.14 `--state` also names the
input check behind a `draft_input_unknown` stop (`inputReadFailure`). If preparation stops at an ordinary
`--cancel` and `--skip-map` inspect, stop or skip that wait. If preparation stops at an ordinary
approval, a manual `rig compact` keeps waiting for someone to answer it, within its 180-second
budget; OpenRig never answers it, and the request times out after 240 seconds. The `claude-compaction-restore`
skill covers writing and reading the map; `claude-compact-in-place` no longer ships. After a
@@ -1057,11 +1018,6 @@ version, from checking the environment to a useful report to the OpenRig team.
cached hook keeps that trust, as with any hook trusted in Codex; the trust covers that
`CODEX_HOME`, so your own Codex sessions there load the plugin too, and its hooks do nothing
outside an OpenRig seat; adding your own Codex hook can trigger a re-review of one OpenRig hook.
Since 0.7.15, when a new openrig-core changes one of those six hook declarations, the boot or
launch that installs it renews OpenRig's approvals from its own record, in the daemon's Codex
home at boot and in a seat's own Codex home at its launch, instead of stopping at Codex's review.
A person's own choice in any layer OpenRig reads keeps the review. A linked `config.toml` or
`hooks.json` still has its trust recorded under the link's target.
- **Know what compaction costs before you reach for it.** On some runtimes what comes back has
enough context left to believe it knows everything and not enough to actually know anything — and
**the compacted agent is the only one who knows it happened**, while every other seat keeps
@@ -1142,8 +1098,7 @@ scheme, and nothing downstream can see it.
System World, topology, and Project World plan. Add `--runtime` to see the composed managed skill
loadout, `--apply-skills` to reconcile its owned harness projection, or `--deliver` to emit the
exact extant files in order while marking absent pieces visibly. Without the flags it remains
plan-only. Since 0.7.15 a `--mission` that doesn't exist adds a warning and reads
`missionRoot` null, instead of quietly serving the project's context. Without `--project` it picks the project itself, in order: the only project, the
plan-only. Without `--project` it picks the project itself, in order: the only project, the
project whose `rigs:` lists the seat's rig, the deepest project root containing the working
folder, then the only unclaimed project. It reports `position.selectedBy`. If a step is
ambiguous (two projects list the rig, or two roots tie), it stops there and asks for
@@ -1547,10 +1502,7 @@ your circumstances is configuration, and the ones that are not, another agent ca
0.7.13, `rig daemon start` ends with that offer when the person registry can be read and holds
nobody. The kernel operator's startup is also taught to run `rig gateway participants` before its
first greeting and offer registration when nobody is registered. The agent asks first and
registers only on the person's yes. Since 0.7.14 setup guidance tells the agent to fit a first
team to the person's logins: a login `rig doctor` reports missing matters when a seat you'd start
runs on that harness, so say which one before launch
(`rig specs preview <team> --kind rig --json` shows each seat's runtime).
registers only on the person's yes.
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
reset, or stopped reporting entirely. The last one is a signal, not a gap.
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
@@ -1635,12 +1587,7 @@ your circumstances is configuration, and the ones that are not, another agent ca
Clients on MCP protocol revision 2026-07-28 and on the 2025 revisions share the same endpoint.
With a grant service (experimental and off by default; you serve it to the internet behind
your own tunnel), `--task-destination <member@rig>` names where a caller's `rig_queue_create`
goes when it gives no destination, and a seat's name alone resolves within the grant's team. Since
0.7.14 a plain name finds the seat's actual session (its own name, then its pod-member form, then
that name's slug, among seats the grant can address): an ambiguous name returns
`destination_ambiguous` with the candidates, an unknown one `destination_not_found`, and an
unreadable team `destination_unchecked`. Its person sees each tool's plain title, and under a
grant service's fair-use limit only new work is held back. A
goes when it gives no destination, and a seat's name alone resolves within the grant's team. A
grant that names a person adds `rig_question_list` and `rig_question_answer`, so that person
answers the team's questions from their assistant, recorded as their answer through that
connection.
@@ -1654,10 +1601,7 @@ your circumstances is configuration, and the ones that are not, another agent ca
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
A hosted member's roster entry shows whether it's checking in: its expected cadence and its last
check-in, read as checking in, overdue, not yet checked in or checking in on request, and unknown
when that can't be read. It's a status, never a gate on delivery. Since 0.7.14 `rig ps`,
`rig_team` and the TUI's tables list connected assistants with that check-in line; since 0.7.15
the TUI keeps it when a rig's inventory can't be read. An assistant that passes its team's
display name as `rig_team`'s `rigId` is told to leave `rigId` out to see its own team.
when that can't be read. It's a status, never a gate on delivery.
**Internet-facing hosted members are experimental, unsupported, off by default and at your
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
@@ -1715,8 +1659,7 @@ database. It is one more set of primitives on a machine full of them.
original byte size and the exact expansion command. Read the complete original record with
`rig queue show <qitemId> --full --json`; a preview or a compact list is not the whole body.
Compact `rig queue list` rows carry `summary`; the fields they leave out are absent, not empty,
and `fieldsElided` names them. Since 0.7.14 `--tag` (repeatable; every tag must match),
`--mission` and `--slice` filter exactly, and bounded listings over retained history use indexes.
and `fieldsElided` names them.
Delivery status is separate from content: inspect its receipt before claiming it was consumed.
- **"What am I waiting on?"** — the queue row's `waiting` view derives its owner, exact blocker
and blocker owner, last meaningful change, activity confidence and next backstop from live
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.15",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
+23 -11
View File
@@ -11,8 +11,8 @@ import { execRuntimeProbe } from "./runtime-probe-exec.js";
import { shellQuote } from "./shell-quote.js";
import { codexPostureArg } from "./yolo-mode.js";
import { observeCodexSandbox } from "../domain/permission-drift.js";
import { codexEnvelopeArgs } from "../domain/codex-rebuild-permissions.js";
import { codexMemberEnvelope, observeCodexEnvelope, type CodexMemberPermissions } from "../domain/codex-member-permissions.js";
import { codexMemberLaunch, type CodexMemberPermissions } from "../domain/codex-member-permissions.js";
import { codexPolicyWarning, resolveCodexFilesystemHome, verifyCodexFilesystemPolicy, type CodexFilesystemVerifier } from "../domain/codex-filesystem-preflight.js";
import { unknownDaemonSupportMessage, type CodexDaemonSupportDetector } from "../domain/codex-daemon-support.js";
import { codexNetworkDefaultArg, type CodexNetworkDefaultReader } from "../domain/codex-network-default.js";
@@ -40,6 +40,7 @@ interface CodexResumeOptions {
readNetworkDefault?: CodexNetworkDefaultReader;
/** launch.codex_login_shell: true keeps Codex's own login shells for tool commands. Absent: off. */
codexLoginShell?: () => boolean;
verifyFilesystemPolicy?: CodexFilesystemVerifier;
/** Ensure the managed Codex config (hooks, trust) just before a resume, as a runtime-adapter launch does, in the
* seat's own home when it has one. */
ensureManagedConfig?: (codexHome?: string) => Promise<void>;
@@ -93,7 +94,17 @@ export class CodexResumeAdapter {
if (wrapped && !this.options.seatLaunchEnvironment) {
return { ok: false, code: "resume_failed", message: "launch_wrapper support unavailable; no agent started." };
}
const codexHome = seatCodexHome ?? this.options.codexHome;
const filesystem = codexPermissions && "filesystem" in codexPermissions ? codexPermissions : undefined;
const codexHome = seatCodexHome ?? this.options.codexHome ?? (filesystem ? resolveCodexFilesystemHome() : undefined);
let policyWarning: string | undefined;
if (filesystem) {
try {
const verified = await (this.options.verifyFilesystemPolicy ?? verifyCodexFilesystemPolicy)({ permissions: filesystem, cwd, codexHome, launchPath: this.options.launchPath, launchWrapper,
codexConfigProfile: codexConfigProfile?.trim() || undefined });
if (!verified.ok) return { ok: false, code: "resume_failed", message: verified.error };
if (!verified.verified) policyWarning = codexPolicyWarning(verified.reason);
} catch { policyWarning = codexPolicyWarning("Native Codex configuration reader failed"); }
}
// Only a seat's own home is passed on, so a call for a seat without one is exactly as before.
await (seatCodexHome ? this.options.ensureManagedConfig?.(seatCodexHome) : this.options.ensureManagedConfig?.());
@@ -120,15 +131,16 @@ export class CodexResumeAdapter {
}
const profileArg = codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : "";
const envelope = codexPermissions ? codexMemberEnvelope(codexPermissions) : undefined;
const posture = envelope ? profileArg + codexEnvelopeArgs(envelope) : codexPostureArg(profileArg, process.env, resolvedPosture);
const appliedLaunch = envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture);
const postureArg = posture + operationalLaunchArg("codex", envelope ? { nonInterruptive }
const choice = codexPermissions ? codexMemberLaunch(codexPermissions) : undefined;
const posture = choice ? profileArg + choice.args : codexPostureArg(profileArg, process.env, resolvedPosture);
const appliedLaunch = choice ? choice.observation : observeCodexSandbox(posture);
if (policyWarning) appliedLaunch.reason = policyWarning;
const postureArg = posture + operationalLaunchArg("codex", choice ? { nonInterruptive }
: { kernelAuthority, teamPermissionDefault, nonInterruptive, launchPosture: resolvedPosture });
const readNetworkDefault = this.options.readNetworkDefault;
const networkArg = envelope ? "" : await codexNetworkDefaultArg(seatCodexHome && readNetworkDefault ? ((dir: string) => readNetworkDefault(dir, seatCodexHome)) : readNetworkDefault,
const networkArg = choice ? "" : await codexNetworkDefaultArg(seatCodexHome && readNetworkDefault ? ((dir: string) => readNetworkDefault(dir, seatCodexHome)) : readNetworkDefault,
appliedLaunch, cwd, tmuxSessionName);
const workspaceArg = !envelope && teamPermissionDefault && !codexConfigProfile?.trim()
const workspaceArg = !choice && teamPermissionDefault && !codexConfigProfile?.trim()
&& appliedLaunch.state === "observed" && appliedLaunch.value === "workspace-write"
? codexTeamWorkspaceArg(this.options.prepareTeamWorkspace, tmuxSessionName) : "";
// The seat's own writable roots, as a launch gets them: its git metadata dirs (so it can still commit) and its topology
@@ -152,7 +164,7 @@ export class CodexResumeAdapter {
const launchEnv = [this.options.launchPath ? `PATH=${shellQuote(this.options.launchPath)}` : "", codexHome ? `CODEX_HOME=${shellQuote(codexHome)}` : ""].filter(Boolean);
const staged = this.options.seatLaunchEnvironment
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { codexCwd: cwd, runtime: "codex", ...(seatCodexHome ? { codexHome: seatCodexHome } : {}), ...(wrapped ? { launchWrapper: wrapped } : {}) })
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { codexCwd: cwd, runtime: "codex", ...((filesystem ? codexHome : seatCodexHome) ? { codexHome: filesystem ? codexHome : seatCodexHome } : {}), ...(wrapped ? { launchWrapper: wrapped } : {}) })
: launchEnv.length ? `env ${launchEnv.join(" ")} ${cmd}` : cmd;
if (beforeNativeDispatch && !beforeNativeDispatch()) return { ok: false, code: "attention_required", message: "Selected history changed during Codex resume preparation; no native command was sent." };
const textResult = beforeNativeDispatch
@@ -164,7 +176,7 @@ export class CodexResumeAdapter {
const result = await this.verifyResume(tmuxSessionName);
const launchFacts = daemonSupport ? { ...appliedLaunch, daemonOptOut: daemonSupport.kind === "supported" } : appliedLaunch;
return result.ok ? { ...result, appliedLaunch: launchFacts } : result;
return result.ok ? { ...result, appliedLaunch: launchFacts, ...(policyWarning ? { warnings: [policyWarning] } : {}) } : result;
}
// Mirrors ClaudeResumeAdapter.verifyResume: poll the pane, run the native
@@ -1,5 +1,6 @@
import { codexEnvelopeArgs } from "../domain/codex-rebuild-permissions.js";
import { codexMemberEnvelope, observeCodexEnvelope, readProfileFingerprint, type CodexProfileObservation, type ProfileFingerprint } from "../domain/codex-member-permissions.js";
import { codexMemberLaunch, observeCodexEnvelope, readProfileFingerprint, type CodexProfileObservation, type ProfileFingerprint } from "../domain/codex-member-permissions.js";
import { codexPolicyWarning, resolveCodexFilesystemHome, verifyCodexFilesystemPolicy, type CodexFilesystemDecision, type CodexFilesystemVerifier } from "../domain/codex-filesystem-preflight.js";
import { operationalLaunchArg } from "./kernel-authority.js";
import nodePath from "node:path";
import fs from "node:fs";
@@ -176,6 +177,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// the real probe by default.
private verifyProfilePreflight: (profile: string, cwd: string, home?: string) => Promise<CodexProfileProbeResult>;
private profileFingerprint: (path: string) => Promise<ProfileFingerprint>;
private verifyFilesystemPolicy: CodexFilesystemVerifier;
// A handover checks the exact binding before retiring its predecessor. Consume
// that result once at dispatch, rather than repeat a refusal after cutover.
private filesystemPreflight = new WeakMap<NodeBinding, CodexFilesystemDecision>();
// #69: whether the installed Codex supports --no-daemon. Startup wires the real probe;
// absent (unit tests, other embedders) keeps the existing invocation unchanged.
private detectDaemonSupport?: CodexDaemonSupportDetector;
@@ -238,6 +243,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
verifyProfilePreflight?: (profile: string, cwd?: string, home?: string) => Promise<CodexProfileProbeResult>;
/** The setter's optional profile hash; defaults to a bounded, non-blocking read. */
profileFingerprint?: (path: string) => Promise<ProfileFingerprint>;
verifyFilesystemPolicy?: CodexFilesystemVerifier;
detectDaemonSupport?: CodexDaemonSupportDetector;
readNetworkDefault?: CodexNetworkDefaultReader;
codexLoginShell?: () => boolean;
@@ -275,6 +281,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
this.codexHome = deps.codexHome;
this.seatHome = deps.seatHome;
this.profileFingerprint = deps.profileFingerprint ?? readProfileFingerprint;
this.verifyFilesystemPolicy = deps.verifyFilesystemPolicy ?? verifyCodexFilesystemPolicy;
this.launchPath = deps.launchPath;
this.detectDaemonSupport = deps.detectDaemonSupport;
this.readNetworkDefault = deps.readNetworkDefault;
@@ -309,8 +316,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
/** The home a seat's Codex uses: its own from its member or rig `env:`, else the daemon's explicit CODEX_HOME. Undefined
* keeps the legacy OS/PID home behavior. */
private homeFor(binding: { nodeId?: string; accountHome?: string | null } | undefined): string | undefined {
return this.ownHome(binding) ?? this.codexHome;
private homeFor(binding: { nodeId?: string; accountHome?: string | null; codexPermissions?: NodeBinding["codexPermissions"] } | undefined): string | undefined {
const selected = this.ownHome(binding) ?? this.codexHome;
return binding?.codexPermissions && "filesystem" in binding.codexPermissions
? selected ?? resolveCodexFilesystemHome(undefined, this.fs.homedir) : selected;
}
/** Only the seat's own CODEX_HOME, undefined when it declares none. Probes and readers get it as an extra argument
@@ -319,8 +328,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
return binding?.accountHome ?? (binding?.nodeId ? this.seatHome?.(binding.nodeId, binding.accountHome === null) : undefined);
}
private ownHomeCommandOpt(binding: { nodeId?: string; accountHome?: string | null }): { codexHome?: string } {
const own = this.ownHome(binding);
private ownHomeCommandOpt(binding: NodeBinding): { codexHome?: string } {
const own = binding.codexPermissions && "filesystem" in binding.codexPermissions ? this.homeFor(binding) : this.ownHome(binding);
return own ? { codexHome: own } : {};
}
@@ -675,7 +684,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
return;
}
const command = `node "${relay}"`;
const keySource = canonicalCodexHookSource(hooksPath);
const keySource = codexHookSourcePath(hooksPath);
const stripped = upsertCodexFeaturesHooksEnabled(stripCodexActivityHooks(existing, command));
let before: unknown = null;
let expected: { hooks?: { state?: CodexHookState } } | null = null;
@@ -685,7 +694,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
return;
}
if (!expected) return;
const inlineSource = canonicalCodexHookSource(configPath);
const inlineSource = codexHookSourcePath(configPath);
let config = stripped;
for (const event of OPENRIG_ACTIVITY_HOOK_EVENTS) {
const groups = document.hooks[event] ?? [];
@@ -721,22 +730,13 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
/**
* OPR.0.4.3.33 — compute + splice Codex's `[hooks.state."<key>"] trusted_hash` for our 4
* authored activity hooks into `content`. `key_source` is the canonicalized config path
* (Codex keys trust by `std::fs::canonicalize(config.toml).display()`); we best-effort
* `realpathSync` the config path to match — on a miss (file not yet on real disk, or a mock
* fs in tests) we fall back to the plain absolute path, and any resulting key mismatch just
* degrades to the launch-time trust gate (Layer-2 floor), never a broken run. The command
* authored activity hooks into `content`. Codex 0.167.2 resolves the source folder but
* keeps the file name, including a linked file, as returned by app-server hooks/list. The command
* string is the value Codex deserializes from our TOML literal `'node "<relay>"'` — i.e.
* `node "<relay>"` WITHOUT the outer TOML quote delimiters. Timeout=5, matcher/status None.
*/
private applyCodexActivityHookTrust(content: string, configPath: string, relay: string): string {
let keySource = configPath;
try {
keySource = fs.realpathSync.native(configPath);
} catch {
// config.toml not on the real filesystem (first write / unit-test mock fs) — the plain
// absolute path is the honest best guess; a canonicalization delta is fail-safe (gate reappears).
}
const keySource = codexHookSourcePath(configPath);
const command = `node "${relay}"`;
// upsertCodexActivityHooks has just written our marked block. Codex keys by the
// group's position among this event's groups, not its position inside our block.
@@ -779,7 +779,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
try {
const original = this.fs.readFile(hooksPath);
const document = parseCodexHooksJson(original);
const keySource = canonicalCodexHookSource(hooksPath);
const keySource = codexHookSourcePath(hooksPath);
let config = updated ?? "";
const expected = parseToml(config) as { hooks?: { state?: CodexHookState } };
for (const event of OPENRIG_ACTIVITY_HOOK_EVENTS) {
@@ -907,6 +907,36 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
return { delivered, failed, ...(warnings.length ? { warnings } : {}) };
}
/** No managed config writes, pane input, termination or bindings change here. */
async preflightLaunch(binding: NodeBinding): Promise<{ ok: true; warnings?: string[] } | { ok: false; error: string }> {
const decision = await this.checkFilesystemPolicy(binding);
if (!decision) return { ok: true };
if (!decision.ok) return decision;
const profile = await this.checkProfileLoad(binding);
if (!profile.ok) return profile;
this.filesystemPreflight.set(binding, decision);
return decision.verified ? { ok: true } : { ok: true, warnings: [codexPolicyWarning(decision.reason)] };
}
private async checkFilesystemPolicy(binding: NodeBinding): Promise<CodexFilesystemDecision | undefined> {
if (!binding.codexPermissions || !("filesystem" in binding.codexPermissions)) return undefined;
try {
return await this.verifyFilesystemPolicy({ permissions: binding.codexPermissions, cwd: binding.cwd,
codexHome: this.homeFor(binding), launchPath: this.launchPath, launchWrapper: binding.launchWrapper,
codexConfigProfile: binding.codexConfigProfile?.trim() || undefined });
} catch {
return { ok: true, verified: false, reason: "Native Codex configuration reader failed" };
}
}
private async checkProfileLoad(binding: NodeBinding): Promise<{ ok: true } | { ok: false; error: string }> {
const profile = binding.codexConfigProfile?.trim();
if (!profile) return { ok: true };
const own = this.ownHome(binding);
const probe = await (own ? this.verifyProfilePreflight(profile, binding.cwd, own) : this.verifyProfilePreflight(profile, binding.cwd));
return probe.ok ? { ok: true } : { ok: false, error: `${probe.error}${probe.migrationHint ? `\n Fix: ${probe.migrationHint}` : ""}` };
}
async launchHarness(
binding: NodeBinding,
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; beforeNativeDispatch?: () => boolean },
@@ -930,6 +960,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
const prepared = await this.prepareLaunch(binding, opts.name, undefined, { installCorePlugin: !opts.resumeToken });
if (!prepared.ok) return prepared;
const { modelArg, effortArg, appliedLaunch, postureArg, queueStateDirArg, gitDirArg, seatFolderArg, daemonArg, networkArg, loginShellArg } = prepared;
const policyNotice = appliedLaunch.reason?.startsWith("policy not verified:") ? { warnings: [appliedLaunch.reason] } : {};
// Fork branch: `codex fork <parent_thread_id>`. Captures the NEW thread id
// post-fork. Parent thread id is NOT persisted onto the new seat record
@@ -970,7 +1001,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// For a handover a missed capture is pending metadata, not a failed launch: the fork returns
// without a token and the handover's readiness checks still decide success. Any other fork
// keeps its unsent context pending for `rig seat continue`, which verifies the child first.
if (opts.forkSource.pendingIdAllowed) return { ok: true, appliedLaunch };
if (opts.forkSource.pendingIdAllowed) return { ok: true, appliedLaunch, ...policyNotice };
return {
ok: false,
error: "codex fork: could not capture new post-fork thread id",
@@ -978,7 +1009,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
forkIdentityPending: true,
};
}
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch };
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch, ...policyNotice };
}
// OPR.0.4.8.2: one posture decision (codexPostureArg) for the fresh launch too — YOLO forces
@@ -1005,17 +1036,17 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
if (opts.resumeToken) {
const verification = await this.verifyResumeLaunch(binding.tmuxSession, updatePrompt, { resumeToken: opts.resumeToken, ...this.ownHomeOpt(binding) });
if (!verification.ok) return verification;
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id", appliedLaunch,
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id", appliedLaunch, ...policyNotice,
...(verification.resumedProcess ? { resumedProcess: verification.resumedProcess } : {}) };
}
const threadId = await this.captureFreshThreadId(binding, updatePrompt);
if (updatePrompt.failure) return updatePrompt.failure;
if (threadId) {
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch };
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch, ...policyNotice };
}
return { ok: true, appliedLaunch };
return { ok: true, appliedLaunch, ...policyNotice };
}
/** The command launchHarness would type to resume `resumeToken`, wrapped in the seat launch environment,
@@ -1023,7 +1054,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
async buildResumeCommand(
binding: NodeBinding,
opts: { name: string; resumeToken: string; running?: RunningCodexLaunch; launchShell?: string },
): Promise<{ ok: true; command: string; core: string; appliedLaunch: AppliedLaunchObservation } | { ok: false; error: string }> {
): Promise<{ ok: true; command: string; core: string; appliedLaunch: AppliedLaunchObservation; warnings?: string[] } | { ok: false; error: string }> {
if (binding.launchWrapper?.length && !this.seatLaunchEnvironment) {
return { ok: false, error: "launch_wrapper support unavailable; no agent started." };
}
@@ -1038,7 +1069,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
} catch {
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
}
return { ok: true, command, core, appliedLaunch: prepared.appliedLaunch };
return { ok: true, command, core, appliedLaunch: prepared.appliedLaunch,
...(prepared.appliedLaunch.reason?.startsWith("policy not verified:") ? { warnings: [prepared.appliedLaunch.reason] } : {}) };
}
/** Type a command buildResumeCommand built into the pane's shell, then verify the resume as launchHarness
@@ -1089,6 +1121,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
* prepared parts come from the running Codex instead, and no Codex process is started to decide them. */
private async prepareLaunch(binding: NodeBinding, name: string, running: RunningCodexLaunch | undefined,
opts: { installCorePlugin: boolean }): Promise<PreparedCodexLaunch | { ok: false; error: string }> {
const checkedBeforeCutover = this.filesystemPreflight.get(binding);
const decision = checkedBeforeCutover ?? await this.checkFilesystemPolicy(binding);
this.filesystemPreflight.delete(binding);
if (decision && !decision.ok) return decision;
// The config write and, for a seat that selected openrig-core, its install run as one step under the config lock, so
// concurrent launches never interleave them. An unselected seat's launch never installs.
// A seat with its own CODEX_HOME gets OpenRig's managed config (hooks, feature flag) and plugin in that home.
@@ -1107,15 +1143,17 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
const effortArg = effort ? ` -c ${shellQuote(`model_reasoning_effort="${effort}"`)}` : "";
const profile = binding.codexConfigProfile?.trim();
const profileArg = profile ? ` -p ${shellQuote(profile)}` : "";
// A handover rebuild's observed predecessor envelope, else the seat's own envelope, replaces the posture decision.
const memberEnvelope = binding.codexRebuildPermissions || !binding.codexPermissions ? undefined : codexMemberEnvelope(binding.codexPermissions);
const envelope = binding.codexRebuildPermissions ?? memberEnvelope;
const posture = envelope
const filesystemPermissions = binding.codexPermissions && "filesystem" in binding.codexPermissions ? binding.codexPermissions : undefined;
// An authored filesystem policy always wins over a stale observed legacy envelope. Legacy rebuilds keep their carry behavior.
const memberChoice = binding.codexPermissions && (!binding.codexRebuildPermissions || filesystemPermissions) ? codexMemberLaunch(binding.codexPermissions) : undefined;
const envelope = memberChoice ? undefined : binding.codexRebuildPermissions;
const posture = memberChoice ? profileArg + memberChoice.args : envelope
? profileArg + codexEnvelopeArgs(envelope)
: codexPostureArg(profileArg, process.env, binding.launchPosture);
const appliedLaunch: AppliedLaunchObservation = envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture);
const appliedLaunch: AppliedLaunchObservation = memberChoice?.observation ?? (envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture));
if (decision && !decision.verified) appliedLaunch.reason = codexPolicyWarning(decision.reason);
// The seat's envelope is not full access, so a policy's full_bypass extras don't follow it.
const postureArg = posture + operationalLaunchArg(this.runtime, memberEnvelope ? { ...binding, kernelAuthority: false, launchPosture: undefined } : binding);
const postureArg = posture + operationalLaunchArg(this.runtime, memberChoice ? { ...binding, kernelAuthority: false, launchPosture: undefined } : binding);
if (running) {
// The running Codex already loaded its profile; drift in -p is the caller's to compare.
@@ -1123,10 +1161,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// conversation id, as on every resume, so the resume proof reads the id even when a dir has a space.
return { ok: true, model, modelArg, effort, effortArg, profile, appliedLaunch, postureArg,
queueStateDirArg: "",
gitDirArg: running.addDirs.map((dir) => ` --add-dir ${shellQuote(dir)}`).join(""),
gitDirArg: (filesystemPermissions ? [] : running.addDirs).map((dir) => ` --add-dir ${shellQuote(dir)}`).join(""),
seatFolderArg: "",
daemonOptOut: running.daemonOptOut, daemonArg: running.daemonOptOut ? " --no-daemon" : "",
networkArg: running.networkOverride ? ` -c ${shellQuote(CODEX_NETWORK_DEFAULT_OVERRIDE)}` : "",
networkArg: !filesystemPermissions && running.networkOverride ? ` -c ${shellQuote(CODEX_NETWORK_DEFAULT_OVERRIDE)}` : "",
loginShellArg: running.nonLoginShell ? codexNonLoginShellArg : "" };
}
@@ -1134,19 +1172,14 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// [profiles.<name>] table or invalid TOML must fail BEFORE the opaque
// `codex -p <profile> resume` failure. An absent .config.toml passes
// (Codex default-layers it; advisor Option B).
if (profile) {
const probeResult = await (own ? this.verifyProfilePreflight(profile, binding.cwd, own) : this.verifyProfilePreflight(profile, binding.cwd));
if (!probeResult.ok) {
return {
ok: false,
error: `${probeResult.error}${probeResult.migrationHint ? `\n Fix: ${probeResult.migrationHint}` : ""}`,
};
}
if (profile && !checkedBeforeCutover) {
const loaded = await this.checkProfileLoad(binding);
if (!loaded.ok) return loaded;
}
// Codex rejects writable --add-dir roots under a read-only sandbox. Only
// suppress OpenRig's generated roots when later project layers cannot make it writable.
// A seat envelope states its sandbox: read-only gets no generated roots; workspace-write keeps them.
const suppressAutoAddDirs = memberEnvelope ? memberEnvelope.sandboxPolicy.type === "read-only"
const suppressAutoAddDirs = memberChoice ? memberChoice.suppressAutoAddDirs
: Boolean(!binding.codexRebuildPermissions && profile && posture === profileArg && this.profileRemainsReadOnly(profile, binding.cwd, home));
// Issue #121: fresh, resume and fork get the same git metadata dirs, so a restored or forked seat can still commit.
// A rebuild keeps the writable roots its observed envelope names instead.
@@ -1155,7 +1188,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// The seat's own topology folder, so it can keep its LEARNED.md and recap: the same launches and the same suppression.
const seatFolder = binding.codexRebuildPermissions || suppressAutoAddDirs ? undefined : this.prepareSeatFolder?.(name);
const seatFolderArg = seatFolder ? ` --add-dir ${shellQuote(seatFolder)}` : "";
const teamWorkspaceArg = !envelope && binding.teamPermissionDefault && !profile
const teamWorkspaceArg = !memberChoice && !envelope && binding.teamPermissionDefault && !profile
&& appliedLaunch.state === "observed" && appliedLaunch.value === "workspace-write"
? codexTeamWorkspaceArg(this.prepareTeamWorkspace, name) : "";
const queueStateDirArg = binding.codexRebuildPermissions || suppressAutoAddDirs
@@ -1169,7 +1202,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
const daemonOptOut = daemonSupport?.kind === "supported";
const daemonArg = daemonOptOut ? " --no-daemon" : "";
// #275: on the plain floor, network access unless Codex reports an opt-out or policy. An envelope states its own.
const networkArg = envelope ? ""
const networkArg = memberChoice || envelope ? ""
: await codexNetworkDefaultArg(own && this.readNetworkDefault ? ((cwd: string) => this.readNetworkDefault!(cwd, own)) : this.readNetworkDefault, appliedLaunch, binding.cwd, name);
// The launch facts keep the probed daemon choice, so a printed recovery command can repeat it; no probe, no fact.
@@ -2458,9 +2491,10 @@ function tomlSections(text: string, current?: string): TomlSection[] {
return sections;
}
function canonicalCodexHookSource(path: string): string {
// Codex keys hook trust by the canonical source path; a file not on the real filesystem keeps its plain path.
try { return fs.realpathSync.native(path); } catch { return path; }
function codexHookSourcePath(file: string): string {
// Resolve a linked home folder, but retain a linked config.toml/hooks.json file's own name.
try { return nodePath.join(fs.realpathSync.native(nodePath.dirname(file)), nodePath.basename(file)); }
catch { return file; } // The folder may not exist yet, including with an injected filesystem.
}
/** A hooks.json document as Codex reads it: `hooks` maps each event to matcher groups, each with a `hooks` list. */
+3 -1
View File
@@ -1237,8 +1237,10 @@ export class TmuxAdapter {
const identity = this.freshManaged.get(target)?.nodeId ?? target;
const submit = keys.some(isSubmitKey);
const check = async () => {
if (this.deliveryGuard?.needsInputCheck(identity)) await this.deliveryGuard.beforeInput(identity, submit);
// The caller's checks can wait (a conversation lookup), so they run first and the guard's input read runs
// last, as in sendText (#3076): input typed while they wait is seen, and a key is not sent onto it.
if (options?.beforeKeys) await options.beforeKeys();
if (this.deliveryGuard?.needsInputCheck(identity)) await this.deliveryGuard.beforeInput(identity, submit);
};
await check();
beforeWrite(); beforeInput?.();
@@ -63,6 +63,8 @@ export const POST_COMPACT_RESTORE_COOLDOWN_MS_DEFAULT = 10 * 60_000;
// writing the restore map can take a minute+; the wait returns as soon as the
// seat is idle, so this only bounds a pathological never-idle case.
export const MANUAL_PREP_WAIT_MS_DEFAULT = 180_000;
/** How often a manual attempt reads its seat while it waits (for the map, or after a /compact the seat wasn't idle for). */
export const MANUAL_SEAT_READ_INTERVAL_MS = 5_000;
// Operational ceiling, not a measured optimum. Existing context polls advance auto preparation.
export const AUTO_PREP_WAIT_MS_DEFAULT = 25 * 60_000;
// How long a stopped attempt holds automatic compaction off for its seat before a fresh attempt may begin. Without it one
@@ -224,7 +226,7 @@ export function buildCompactCommand(compactInstruction: string): string {
}
function buildPreCompactPrepPrompt(input: {
usedPercentage: number;
usedPercentage: number | null;
thresholdPercent: number;
preCompactInstruction?: string | null;
/** The policy's compaction instruction when it is too long for the /compact command. */
@@ -239,7 +241,7 @@ function buildPreCompactPrepPrompt(input: {
]
: [
"OpenRig automatic compaction preparation is now required.",
`Current context usage is ${input.usedPercentage}%; configured compaction threshold is ${input.thresholdPercent}%.`,
`Current context usage is ${input.usedPercentage ?? "unknown"}%; configured compaction threshold is ${input.thresholdPercent}%.`,
`The ${AUTO_PREP_WAIT_MS_DEFAULT / 60_000}-minute ceiling starts after preparation delivery returns; completing the restore map and becoming idle must fit within it.`,
];
pieces.push(
@@ -253,6 +255,7 @@ function buildPreCompactPrepPrompt(input: {
pieces.push(
`Write this attempt's complete restore map to ${JSON.stringify(input.preparation.mapPath + ".tmp")}, using your ordinary file-edit tool. Append the exact completion marker below as its LAST line, then finish and close the file. OpenRig will atomically publish it to ${JSON.stringify(input.preparation.mapPath)}; do not run a shell command to rename or publish it. Never write the final file incrementally.`,
`Completion marker: ${input.preparation.marker}`,
"To avoid one cause of approval prompts, which hold /compact until someone answers them: write files with your file-edit tool (acceptEdits covers your working directory, not a preparation root outside it), and if you publish a seat recap, run rig context recap-write --file with the file's literal absolute path, with no shell variable assignment or command substitution, so an allow rule for rig commands can match it.",
"The marker identifies this attempt and occupant; it does not certify the map's quality. Keep the normal ranked restore-map content.",
`Near the top of the map, copy this note word for word; the restore request will have you read it: ${MANAGED_COMPACT_TRUST_NOTE}`,
...(input.overflowCompactInstruction?.trim()
@@ -498,6 +501,10 @@ export interface PreparationState {
mapUsedPercentage?: number;
/** A record too old to resume that this attempt replaced (see `getStrandedCompaction`): its restore may have been owed. */
supersedes?: StrandedCompaction;
/** A manual attempt's latest reading of its seat showed a prompt (a permission request its preparation turn reached,
* say) that holds /compact: since when, and when it was last seen. `current` is false when a later reading couldn't
* establish the seat's state, so the prompt is an earlier observation. Cleared when a reading shows idle or work. */
awaitingInput?: { since: number; reason: string; observedAt: number; current: boolean };
}
/** An open record from before a restart, too old to resume: the seat's restore steps may still be owed. */
export interface StrandedCompaction { attemptId: string; mapPath: string; phase: string; recordedAt: string }
@@ -1409,7 +1416,6 @@ export class ClaudeCompactionEnforcer {
return { triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" };
}
if (input.runtime !== "claude-code") return this.recordManualFailure(input.sessionName, "runtime_filter");
if (input.usedPercentage == null) return this.recordManualFailure(input.sessionName, "no_usage_data");
// The preparation prompt cannot be written to such a seat either, so the whole attempt is refused up front.
if (this.paneUnknownProtected(input.sessionName)) return this.recordManualFailure(input.sessionName, "pane_unknown_protected");
const actor = opts.actor ?? { source: "caller" as const, session: null };
@@ -1429,34 +1435,69 @@ export class ClaudeCompactionEnforcer {
};
this.setManualStage(input.sessionName, "preparing", undefined, opts.operatorInitiated === true);
await this.deliverPreparation(input, attempt);
// Manual preparation can pause for an ordinary approval, before or after the map is complete. OpenRig never answers
// it. It keeps the latest completed reading of the seat on the attempt (awaitingInput), recorded before deadline
// reconciliation, so --state and the expiry reason describe the seat now rather than an earlier prompt.
const observe = (reading: { ok: boolean; reason?: string; activity?: { state?: string } } | null) => {
const state = reading === null ? "unknown" : reading.ok ? "idle"
: reading.reason === "target_needs_input" ? "needs_input" : reading.activity?.state ?? "unknown";
if (state === "needs_input") {
attempt.awaitingInput = { since: attempt.awaitingInput?.since ?? this.now(), reason: "target_needs_input", observedAt: this.now(), current: true };
} else if (state === "unknown") {
if (attempt.awaitingInput) attempt.awaitingInput = { ...attempt.awaitingInput, current: false };
} else delete attempt.awaitingInput;
};
// While the map is written, one bounded reading every MANUAL_SEAT_READ_INTERVAL_MS, never one per map check. The first
// comes an interval after delivery: a reading taken as the preparation turn starts tells nothing.
let nextRead = this.now() + MANUAL_SEAT_READ_INTERVAL_MS;
while (attempt.status === "waiting" && !opts.skipMap && !this.mapReady(attempt)) {
const remaining = attempt.deadlineAt! - this.now();
if (remaining > 0 && this.now() >= nextRead) {
let reading;
try { reading = await this.sessionTransport.waitUntilIdle(input.sessionName, Math.min(remaining, CAPTURE_READABLE_BUDGET_MS), attempt.controller.signal, { singleObservation: true, allowDraft: true }); }
catch { reading = null; }
observe(reading);
nextRead = this.now() + MANUAL_SEAT_READ_INTERVAL_MS;
}
this.reconcilePreparations();
if (attempt.status !== "waiting") break;
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
}
if (attempt.status !== "waiting") return failed(attempt.reason ?? "preparation_incomplete");
// Manual preparation can pause for an ordinary approval after the map is complete.
// Keep the same classifier/deadline and wait outside the input lease; never answer it.
let idle;
do {
const remaining = attempt.deadlineAt! - this.now();
if (remaining <= 0) return failed(this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete");
try { idle = await this.sessionTransport.waitUntilIdle(input.sessionName, remaining, attempt.controller.signal, { allowDraft: true }); }
catch { idle = { ok: false, reason: "transport_unavailable" }; }
this.reconcilePreparations();
if (idle.ok || idle.reason !== "target_needs_input" || attempt.status !== "waiting") break;
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
this.reconcilePreparations();
} while (attempt.status === "waiting");
if (!idle.ok || attempt.status !== "waiting") {
// The transport was given this attempt's remaining budget; name its timeout like preparation expiry.
const reason = attempt.reason ?? (idle.ok ? "preparation_incomplete"
: idle.reason === "wait_for_idle_timeout"
? this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete"
: idle.reason);
return failed(reason);
// The budget ran out: name a prompt the latest reading showed, else as before.
const expired = () => attempt.awaitingInput?.current ? "preparation_needs_input"
: this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete";
let compact: { ok: boolean; reason?: string; deferredBy?: { reason?: string; activity?: { state?: string } } };
for (;;) {
let idle;
do {
const remaining = attempt.deadlineAt! - this.now();
if (remaining <= 0) return failed(expired());
// The wait can run to the deadline. Each reading it completes reaches the attempt as it lands, so a --state read or
// deadline reconciliation during the wait never takes an answered prompt for a current one.
try { idle = await this.sessionTransport.waitUntilIdle(input.sessionName, remaining, attempt.controller.signal,
{ allowDraft: true, onObservation: (activity) => observe({ ok: false, activity }) }); }
catch { idle = { ok: false, reason: "transport_unavailable" }; }
observe(idle);
this.reconcilePreparations();
if (idle.ok || idle.reason !== "target_needs_input" || attempt.status !== "waiting") break;
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
this.reconcilePreparations();
} while (attempt.status === "waiting");
if (!idle.ok || attempt.status !== "waiting") {
// The transport was given this attempt's remaining budget; name its timeout like preparation expiry.
const reason = attempt.reason ?? (idle.ok ? "preparation_incomplete"
: idle.reason === "wait_for_idle_timeout" ? expired() : idle.reason);
return failed(reason);
}
compact = await this.sendPreparedCompact(input, attempt, opts.skipMap === true);
// The seat wasn't idle at the send after all (its preparation turn reached a prompt, or did more work): nothing was
// written, so wait again within this attempt's deadline rather than disarm. The send reads the pane while the idle
// wait can take a hook's idle, so pause one reading interval first: a disagreement can't loop sends without a break.
if (compact.ok || compact.reason !== "waiting_for_idle") break;
observe({ ok: false, ...compact.deferredBy });
await this.sleep(Math.min(MANUAL_SEAT_READ_INTERVAL_MS, Math.max(1, attempt.deadlineAt! - this.now())));
}
const compact = await this.sendPreparedCompact(input, attempt, opts.skipMap === true);
if (!compact.ok) {
const outcome = failed(compact.reason ?? "send_failed");
// A /compact whose result was lost still owes its restore steps, and this request authorizes them.
@@ -1590,7 +1631,7 @@ export class ClaudeCompactionEnforcer {
const composed = composeManagedCompactCommand({ compactInstruction: attempt.compactInstruction, mapPath: attempt.mapPath });
prep = await this.sessionTransport.send(input.sessionName, buildPreCompactPrepPrompt({
overflowCompactInstruction: composed.instructionInCommand ? null : attempt.compactInstruction,
usedPercentage: input.usedPercentage!, thresholdPercent: policy.thresholdPercent,
usedPercentage: input.usedPercentage ?? null, thresholdPercent: policy.thresholdPercent,
preCompactInstruction: policy.preCompactInstruction, preparation: attempt, constructedAt: this.now(),
}), { openrigNotice: true, allowDraftWhileIdle: true, ...idleGate, beforeWrite: () => {
this.reconcilePreparations();
@@ -1634,7 +1675,7 @@ export class ClaudeCompactionEnforcer {
this.reconcilePreparations();
}
private async sendPreparedCompact(input: EnforcerInput, attempt: PreparationAttempt, skipMap: boolean): Promise<{ok: boolean; reason?: string}> {
private async sendPreparedCompact(input: EnforcerInput, attempt: PreparationAttempt, skipMap: boolean): Promise<{ok: boolean; reason?: string; deferredBy?: { reason?: string; activity?: { state?: string } }}> {
const check = () => {
this.reconcilePreparations();
this.checkAttemptPolicy(input, attempt);
@@ -1705,13 +1746,15 @@ export class ClaudeCompactionEnforcer {
if (notSent && typeof windowReason === "string" && HOLD_REASONS.has(windowReason) && this.holdPrepared(input.sessionName, attempt, windowReason)) {
return { ok: false, reason: PREPARED_AWAITING_WINDOW };
}
// Only a known pre-input idle deferral keeps the automatic attempt armed for a later poll; the transport
// marks those `not_reached`. Reconciliation still bounds the idle grace and stops on an occupant change.
// Only a known pre-input idle deferral keeps the attempt armed: an automatic one for a later poll, a manual one for
// the rest of its deadline (its caller waits again). The transport marks those `not_reached`. Reconciliation still
// bounds the idle grace and stops on an occupant change.
// A typing-guard draft hold (`draft_*`) is `not_reached` too, but it is a refusal, not an idle wait: it stops
// as not sent, and a fresh attempt may begin after the retry cooldown.
const draftHold = policyDraftHold || (typeof result.reason === "string" && result.reason.startsWith("draft_"));
if (attempt.mode === "automatic" && attempt.status === "waiting" && notSent && phase === "not_reached" && !draftHold) {
return { ok: false, reason: "waiting_for_idle" };
if (attempt.status === "waiting" && notSent && phase === "not_reached" && !draftHold
&& (attempt.mode === "automatic" || this.now() < attempt.deadlineAt!)) {
return { ok: false, reason: "waiting_for_idle", deferredBy: { reason: result.reason, activity: result.activity } };
}
// A compact may have been sent despite a lost result. Never replay automatically.
attempt.compact = notSent ? "not_sent" : "uncertain";
@@ -1790,7 +1833,9 @@ export class ClaudeCompactionEnforcer {
else if (!attempt.prepared && attempt.deadlineAt !== null && this.now() >= this.compactDeadline(attempt)!) {
const ready = this.mapReady(attempt);
if (!(ready && this.holdPrepared(session, attempt, "map_complete_seat_never_idle"))) {
this.stopPreparation(session, ready ? "map_complete_seat_never_idle" : "preparation_incomplete");
// A manual attempt whose latest reading is a prompt says so (only the manual wait records awaitingInput).
this.stopPreparation(session, attempt.awaitingInput?.current ? "preparation_needs_input"
: ready ? "map_complete_seat_never_idle" : "preparation_incomplete");
}
}
if (enabled) attempt.policyWasEnabled = true;
@@ -0,0 +1,233 @@
import path from "node:path";
import os from "node:os";
import {
CODEX_SEAT_PERMISSION_PROFILE,
codexFilesystemOverrides,
type CodexFilesystemAccess,
type CodexFilesystemPermissions,
} from "./codex-member-permissions.js";
import { readCodexConfiguration } from "./codex-network-default.js";
export type CodexFilesystemDecision =
| { ok: true; verified: true }
| { ok: true; verified: false; reason: string }
| { ok: false; error: string };
const verified = (): CodexFilesystemDecision => ({ ok: true, verified: true });
const refuse = (error: string, remedy: string): CodexFilesystemDecision => ({ ok: false, error: `${error}\nFix: ${remedy}` });
const unknown = (reason: string): CodexFilesystemDecision => ({ ok: true, verified: false, reason });
export const codexPolicyWarning = (reason: string): string => `policy not verified: ${reason}`;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
// Null means absent, including options added by a later native reader.
const present = (record: Record<string, unknown>) => Object.entries(record).filter(([, value]) => value != null);
function combine(...checks: CodexFilesystemDecision[]): CodexFilesystemDecision {
let inconclusive: Extract<CodexFilesystemDecision, { verified: false }> | undefined;
for (const check of checks) {
if (!check.ok) return check;
if (!check.verified) inconclusive ??= check;
}
return inconclusive ?? verified();
}
const accessRank = { deny: 0, read: 1, write: 2 };
const isAccess = (value: unknown): value is CodexFilesystemAccess => value === "read" || value === "write" || value === "deny";
const canonicalKey = (key: string) => key === ":project_roots" ? ":workspace_roots" : key;
function ambiguousKeys(keys: string[]): Set<string> {
const seen = new Set<string>(), ambiguous = new Set<string>();
for (const key of keys.map(canonicalKey)) {
if (seen.has(key)) ambiguous.add(key);
seen.add(key);
}
return ambiguous;
}
const literalAbsolute = (key: string) => path.isAbsolute(key) && path.normalize(key) === key && !/[?*\[\]{}]/.test(key);
const nativeSelectors = new Set([":root", ":minimal", ":workspace_roots", ":tmpdir", ":slash_tmp"]);
const literalHomePath = (key: string) => key.startsWith("~/") && path.normalize(key) === key && !/[?*\[\]{}]/.test(key);
/** Compare only established flat coverage. Native expansion, inheritance and
* unfamiliar syntax stay unknown; this is not a second sandbox implementation. */
function compareFilesystem(actual: unknown, permissions: CodexFilesystemPermissions): CodexFilesystemDecision {
if (actual == null) return refuse("Rule permissions.openrig-seat.filesystem: the agent declares read/write/deny rules, but the effective Codex profile has no mapping.",
"Select a Codex profile that retains the agent's filesystem rules, or deliberately change the agent's codex_permissions.");
if (!isRecord(actual)) return unknown("Codex selected filesystem mapping has an unfamiliar representation");
const entries = present(actual);
if (!entries.length) return refuse("Rule permissions.openrig-seat.filesystem: the agent declares read/write/deny rules, but the effective Codex mapping is empty.",
"Restore the intended filesystem mapping in the effective Codex profile before launching.");
const authored = Object.fromEntries(Object.entries(permissions.filesystem).map(([key, value]) => [canonicalKey(key), value]));
const effective = Object.fromEntries(entries.map(([key, value]) => [canonicalKey(key), value]));
const authoredAmbiguous = ambiguousKeys(Object.keys(permissions.filesystem));
const ambiguous = new Set([...authoredAmbiguous, ...ambiguousKeys(entries.map(([key]) => key))]);
const at = (rules: Record<string, unknown>, key: string, extraAccess?: CodexFilesystemAccess): CodexFilesystemAccess | undefined => {
if (ambiguous.has(key)) return undefined;
if (isAccess(rules[key])) return rules[key];
const fullRoot = rules[":root"] ?? (literalAbsolute(path.sep) ? rules[path.sep] : undefined);
const rootAccess = isAccess(fullRoot) ? fullRoot : "deny";
const ancestors = Object.keys(rules).filter(root => (literalAbsolute(root) || literalHomePath(root)) && key.startsWith(root.endsWith(path.sep) ? root : root + path.sep))
.sort((a, b) => b.length - a.length);
const root = ancestors[0];
const ancestorAccess = root ? rules[root] : rootAccess;
// Native selectors/home paths can overlap authored literal paths or other
// selectors. Do not infer a widening where an existing grant may cover it;
// native expansion remains Codex's job, not another policy engine here.
// Only the agent's own ambiguity can affect coverage of another key;
// duplicate aliases introduced by a lower layer stay local to those aliases.
// Non-root denies are restrictions; either full-root spelling is a
// default that narrower grants may refine under Codex's native rules.
const denyAncestor = root !== undefined && root !== path.sep && rules[root] === "deny";
// Codex 0.167.2's Linux/macOS minimal defaults are system paths, not
// these user-data roots. Other minimal coverage stays unexpanded.
const outsideMinimal = literalAbsolute(key) && /^\/(?:home|Users)(?:\/|$)/i.test(key);
const possibleCoverage = extraAccess !== undefined && !denyAncestor
&& (!isAccess(ancestorAccess) || accessRank[extraAccess] > accessRank[ancestorAccess])
&& (authoredAmbiguous.size > 0 || Object.entries(rules).some(([rule, access]) => rule !== ":root" && rule !== path.sep
&& !(rule === ":minimal" && outsideMinimal)
&& (nativeSelectors.has(key) || literalHomePath(key) || !literalAbsolute(rule))
&& isAccess(access) && accessRank[access] >= accessRank[extraAccess]));
if (nativeSelectors.has(key)) return key !== ":root" && possibleCoverage ? undefined : rootAccess;
if (!literalAbsolute(key) && !literalHomePath(key)) return undefined;
if (key !== path.sep && possibleCoverage) return undefined;
if (root) return isAccess(rules[root]) ? rules[root] : undefined;
if (isAccess(fullRoot)) return fullRoot;
if (literalHomePath(key)) return "deny";
if (Object.keys(rules).some(rule => !literalAbsolute(rule) && rule !== ":minimal")) return undefined;
// :minimal read cannot grant write; its native read coverage is not
// enumerated here, so extra reads outside a literal ancestor stay unknown.
if (rules[":minimal"] != null && !outsideMinimal) return rules[":minimal"] === "read" ? "read" : undefined;
return "deny";
};
const checks: CodexFilesystemDecision[] = [];
const unfamiliar = entries.some(([, value]) => !isAccess(value));
for (const [key, value] of Object.entries(effective)) {
if (!isAccess(value)) { checks.push(unknown("Codex returned an unfamiliar filesystem access value or nested mapping")); continue; }
const expected = at(authored, key, value);
if (expected === undefined) { checks.push(unknown(`Filesystem rule ${JSON.stringify(key)}: the effective configuration grants ${value}; agent coverage could not be compared.`)); continue; }
if (accessRank[value] > accessRank[expected]) return refuse(
`Filesystem rule ${JSON.stringify(key)}: agent coverage is at most ${expected}; a lower Codex layer (account or trusted project) grants ${value}.`,
"Remove the conflicting lower-layer grant, or deliberately update the agent's codex_permissions to the intended access.");
if (!(key in authored) && value === "read" && expected === "read" && !Object.keys(authored).some(root =>
root === ":root" || (literalAbsolute(root) && literalAbsolute(key) && key.startsWith(root.endsWith(path.sep) ? root : root + path.sep)))) {
checks.push(unknown(`Filesystem rule ${JSON.stringify(key)}: native minimal read coverage could not be compared.`));
}
}
for (const [key, access] of Object.entries(authored)) {
if (key in effective) continue;
const remaining = at(effective, key);
if (remaining !== undefined && accessRank[remaining] > accessRank[access] && !unfamiliar) return refuse(
`Filesystem rule ${JSON.stringify(key)}: the agent requires ${access}; the effective Codex policy omits that restriction and grants ${remaining}.`,
"Restore the agent's restriction in the effective Codex profile, or deliberately change the agent's codex_permissions.");
checks.push(unknown("An authored filesystem rule is absent from the effective mapping"));
}
return combine(...checks);
}
const UNRELATED_REQUIREMENTS = new Set([
"modelProvider", "modelProviders", "allowedLoginMethods", "cliAuthCredentialsStore", "chatgptBaseUrl",
"additionalDeveloperInstructions", "allowedWindowsSandboxImplementations", "allowedWebSearchModes",
"allowManagedHooksOnly", "allowBrowserAndComputerUse", "allowAppshots", "allowRemoteControl",
"computerUse", "browserUse", "inAppBrowser", "hooks", "enforceResidency", "models", "sqliteHome",
"logDir", "modelCatalogJson", "checkForUpdateOnStartup", "allowLoginShell", "feedback", "otel",
// Legacy sandbox allowlists do not select a permission profile.
"allowedSandboxModes",
]);
function compareRequirements(read: unknown, permissions: CodexFilesystemPermissions): CodexFilesystemDecision {
if (!isRecord(read) || !("requirements" in read)) return unknown("Codex configRequirements/read returned an unrecognized response");
const checks: CodexFilesystemDecision[] = [];
if (present(read).some(([key]) => key !== "requirements" && key !== "supportsIndependentSpeedModes")) checks.push(unknown("Codex returned an unfamiliar requirements response field"));
if (read.requirements == null) return combine(...checks);
if (!isRecord(read.requirements)) return unknown("Codex managed requirements have an unfamiliar shape");
for (const [key, value] of present(read.requirements)) {
if (UNRELATED_REQUIREMENTS.has(key)) continue;
if (key === "defaultPermissions" && typeof value === "string") {
// A fallback, not a forced choice. The effective selection and allowlists
// below establish whether Codex actually applied the agent's profile.
continue;
}
if (key === "allowedApprovalPolicies" && Array.isArray(value) && value.every(policy => typeof policy === "string")) {
if (!value.includes(permissions.approval)) return refuse("Rule allowedApprovalPolicies: the managed allowlist excludes the agent's approval choice.",
"Ask the managed-policy owner to allow the intended approval, or select an allowed approval for the agent.");
continue;
}
if (key === "allowedPermissionProfiles" && isRecord(value)) {
if (value[CODEX_SEAT_PERMISSION_PROFILE] === false) return refuse("Rule allowedPermissionProfiles: the managed allowlist explicitly excludes the agent's filesystem profile.",
"Ask the managed-policy owner to allow the agent's filesystem profile, or select an allowed policy for the agent.");
const entries = present(value);
if (entries.every(([, allowed]) => typeof allowed === "boolean")) {
if (value[CODEX_SEAT_PERMISSION_PROFILE] !== true) return refuse("Rule allowedPermissionProfiles: the managed allowlist does not include the agent's filesystem profile.",
"Ask the managed-policy owner to allow the agent's filesystem profile, or select an allowed policy for the agent.");
continue;
}
}
checks.push(unknown("Codex returned an unsupported managed requirement"));
}
return combine(...checks);
}
/** Positive conflicts refuse; unavailable evidence never replaces the authored
* invocation or edits managed policy. Unknowns cannot mask a known conflict. */
export function decideCodexFilesystemPolicy(request: {
permissions: CodexFilesystemPermissions; configRead: unknown; requirementsRead: unknown;
}): CodexFilesystemDecision {
const { permissions, configRead, requirementsRead } = request;
const requirements = compareRequirements(requirementsRead, permissions);
if (!requirements.ok) return requirements;
const checks: CodexFilesystemDecision[] = [requirements];
if (!isRecord(configRead) || !isRecord(configRead.config)) return combine(...checks, unknown("Codex config/read did not return a recognized effective configuration"));
const config = configRead.config;
if (config.default_permissions == null || typeof config.default_permissions !== "string") checks.push(unknown("Codex permission selection is absent or has an unfamiliar representation"));
else if (config.default_permissions !== CODEX_SEAT_PERMISSION_PROFILE) return refuse("Rule default_permissions: the agent selects its filesystem profile; the effective Codex configuration selects a different profile.",
"Remove the conflicting account or trusted-project selection, or deliberately change the agent's codex_permissions.");
if (typeof config.approval_policy !== "string" || !["never", "on-request", "untrusted"].includes(config.approval_policy)) checks.push(unknown("Codex approval selection is absent or unfamiliar"));
else if (config.approval_policy !== permissions.approval) return refuse("Rule approval_policy: the effective Codex configuration conflicts with the agent's approval choice.",
"Align the account, trusted-project or managed approval policy with the agent's intended approval choice.");
if (config.permissions == null) return refuse("Rule permissions.openrig-seat: the agent selects a filesystem profile, but the effective Codex configuration has no profile mapping.",
"Restore the selected profile in the effective Codex configuration before launching.");
if (!isRecord(config.permissions)) return combine(...checks, unknown("Codex permission profiles have an unfamiliar representation"));
const selected = config.permissions[CODEX_SEAT_PERMISSION_PROFILE];
if (selected == null) return refuse("Rule permissions.openrig-seat: the agent selects a filesystem profile that is absent from the effective Codex configuration.",
"Restore the selected profile in the effective Codex configuration before launching.");
if (!isRecord(selected)) return combine(...checks, unknown("Codex selected profile has an unfamiliar representation"));
if (!present(selected).length) return refuse("Rule permissions.openrig-seat: the agent selects filesystem read/write/deny rules, but the effective Codex profile is empty.",
"Restore the selected profile's intended filesystem rules before launching.");
const unfamiliarProfile = present(selected).some(([key]) => key !== "filesystem" && key !== "network" && key !== "description");
if (unfamiliarProfile) checks.push(unknown("Codex returned an unfamiliar selected profile option"));
if (!(unfamiliarProfile && selected.filesystem == null)) checks.push(compareFilesystem(selected.filesystem, permissions));
const network = selected.network;
if (!isRecord(network)) checks.push(unknown("Codex selected network policy could not be compared"));
else {
if (network.enabled === true && !permissions.network_access) return refuse("Rule network.enabled: the agent denies network access; the effective Codex profile allows it.",
"Remove the conflicting lower-layer network grant, or deliberately enable network_access in the agent's codex_permissions.");
if (typeof network.enabled !== "boolean") checks.push(unknown("Codex selected network access is unfamiliar"));
if (present(network).some(([key]) => key !== "enabled")) checks.push(unknown("Codex returned unfamiliar network options"));
}
return combine(...checks);
}
/** Pin the normal account home for this opt-in path so the reader and dispatch
* do not accidentally use different pane-shell defaults. */
export function resolveCodexFilesystemHome(home?: string, normalHome?: string): string | undefined {
try {
const resolved = home ?? process.env.CODEX_HOME ?? path.join(normalHome ?? os.homedir(), ".codex");
return resolved && path.isAbsolute(resolved) && !/[\0\r\n]/.test(resolved) ? resolved : undefined;
} catch { return undefined; }
}
export async function verifyCodexFilesystemPolicy(request: {
permissions: CodexFilesystemPermissions; cwd: string; codexHome?: string;
launchPath?: string; launchWrapper?: readonly string[]; codexConfigProfile?: string;
}): Promise<CodexFilesystemDecision> {
if (request.launchWrapper?.length) return unknown("launch_wrapper may change the effective home or configuration; the unwrapped reader cannot certify it");
// app-server does not expose this CLI profile layer on the supported path.
// Keep -p on the actual invocation and leave enforcement to Codex.
if (request.codexConfigProfile?.trim()) return unknown("the native reader cannot load the selected codex_config_profile");
const codexHome = resolveCodexFilesystemHome(request.codexHome);
if (!codexHome) return unknown("the actual Codex account home could not be resolved");
try {
const read = await readCodexConfiguration({ cwd: request.cwd, codexHome, launchPath: request.launchPath,
args: ["-c", `approval_policy=${JSON.stringify(request.permissions.approval)}`,
...codexFilesystemOverrides(request.permissions).flatMap(value => ["-c", value]),
"-c", "features.plugins=false", "-c", "features.apps=false"] });
if (!read.ok) return unknown(read.reason);
return decideCodexFilesystemPolicy({ permissions: request.permissions, configRead: read.configRead, requirementsRead: read.requirementsRead });
} catch { return unknown("Native Codex configuration reader failed"); }
}
export type CodexFilesystemVerifier = typeof verifyCodexFilesystemPolicy;
@@ -4,12 +4,14 @@ import { constants as fsConstants } from "node:fs";
import { open } from "node:fs/promises";
import { isAbsolute } from "node:path";
import type { CodexPermissionEnvelope } from "./codex-rebuild-permissions.js";
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
import { shellQuote } from "../adapters/shell-quote.js";
import type { AppliedLaunchObservation } from "./permission-drift.js";
/** A Codex seat's own sandbox and approval choice, emitted as explicit flags on its managed launches and resumes.
* Full access is not an envelope: it stays the full_bypass mode. Normalized, a workspace-write envelope states every
* field the emitter sets, so nothing falls back to a profile's value for them. */
export interface CodexMemberPermissions {
export interface CodexSandboxPermissions {
sandbox: "workspace-write" | "read-only";
approval: "never" | "on-request" | "untrusted";
network_access?: boolean;
@@ -18,20 +20,60 @@ export interface CodexMemberPermissions {
exclude_slash_tmp?: boolean;
}
export type CodexFilesystemAccess = "read" | "write" | "deny";
/** An explicit native filesystem policy, mutually exclusive with the legacy sandbox fields. */
export interface CodexFilesystemPermissions {
filesystem: Record<string, CodexFilesystemAccess>;
approval: Exclude<CodexSandboxPermissions["approval"], "untrusted">;
network_access?: boolean;
sandbox?: never;
writable_roots?: never;
exclude_tmpdir_env_var?: never;
exclude_slash_tmp?: never;
}
export type CodexMemberPermissions = CodexSandboxPermissions | CodexFilesystemPermissions;
const KEYS = ["sandbox", "approval", "network_access", "writable_roots", "exclude_tmpdir_env_var", "exclude_slash_tmp"];
const WORKSPACE_ONLY = ["network_access", "writable_roots", "exclude_tmpdir_env_var", "exclude_slash_tmp"];
const FILESYSTEM_KEYS = ["filesystem", "approval", "network_access"];
const FILESYSTEM_SPECIAL_PATHS = new Set([":root", ":minimal", ":workspace_roots", ":project_roots", ":tmpdir", ":slash_tmp"]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
export function validateCodexMemberPermissions(value: unknown, label = "codex_permissions"): CodexMemberPermissions {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected a mapping with sandbox and approval`);
const raw = value as Record<string, unknown>;
for (const key of Object.keys(raw)) if (!KEYS.includes(key)) throw new Error(`${label}.${key}: unsupported key; supported keys are ${KEYS.join(", ")}`);
const sandbox = raw.sandbox;
if (!isRecord(value)) throw new Error(`${label}: expected a mapping with sandbox or filesystem, and approval`);
const raw = value;
const keys = "filesystem" in raw ? FILESYSTEM_KEYS : KEYS;
for (const key of Object.keys(raw)) if (!keys.includes(key)) throw new Error(`${label}.${key}: unsupported key; supported keys are ${keys.join(", ")}`);
const approval = raw.approval;
if (sandbox === "danger-full-access") throw new Error(`${label}.sandbox: full access is the full_bypass mode, not an envelope; select it with --mode full_bypass`);
if (sandbox !== "workspace-write" && sandbox !== "read-only") throw new Error(`${label}.sandbox: expected workspace-write or read-only`);
if (approval !== "never" && approval !== "on-request" && approval !== "untrusted") {
throw new Error(`${label}.approval: expected never, on-request or untrusted`);
}
if ("filesystem" in raw) {
if (approval === "untrusted") {
throw new Error(`${label}.approval: untrusted is not supported with filesystem; expected never or on-request`);
}
if (!isRecord(raw.filesystem) || Object.keys(raw.filesystem).length === 0) {
throw new Error(`${label}.filesystem: expected a nonempty mapping of paths to read, write or deny`);
}
const filesystem: CodexFilesystemPermissions["filesystem"] = {};
for (const [path, access] of Object.entries(raw.filesystem)) {
if (/[\0\r\n]/.test(path) || !(isAbsolute(path) || path.startsWith("~/") || FILESYSTEM_SPECIAL_PATHS.has(path))) {
throw new Error(`${label}.filesystem: expected an absolute path, ~/path or a supported native special path; got ${JSON.stringify(path)}`);
}
if (access !== "read" && access !== "write" && access !== "deny") {
throw new Error(`${label}.filesystem[${JSON.stringify(path)}]: expected read, write or deny`);
}
filesystem[path] = access;
}
if (raw.network_access !== undefined && typeof raw.network_access !== "boolean") throw new Error(`${label}.network_access: expected true or false`);
return { approval, filesystem, network_access: raw.network_access === true };
}
const sandbox = raw.sandbox;
if (sandbox === "danger-full-access") throw new Error(`${label}.sandbox: full access is the full_bypass mode, not an envelope; select it with --mode full_bypass`);
if (sandbox !== "workspace-write" && sandbox !== "read-only") throw new Error(`${label}.sandbox: expected workspace-write or read-only`);
if (sandbox === "read-only") {
const extra = WORKSPACE_ONLY.find(key => raw[key] !== undefined);
if (extra) throw new Error(`${label}.${extra}: only valid with sandbox workspace-write`);
@@ -42,26 +84,48 @@ export function validateCodexMemberPermissions(value: unknown, label = "codex_pe
}
const roots = raw.writable_roots ?? [];
if (!Array.isArray(roots)) throw new Error(`${label}.writable_roots: expected a list of absolute paths`);
roots.forEach((root, index) => {
const writableRoots: string[] = [];
roots.forEach((root: unknown, index) => {
if (typeof root !== "string" || !root.trim() || !isAbsolute(root) || /[\0\r\n]/.test(root)) {
throw new Error(`${label}.writable_roots[${index}]: expected an absolute path without newlines`);
}
writableRoots.push(root);
});
return {
sandbox: "workspace-write", approval,
network_access: raw.network_access === true, writable_roots: [...roots] as string[],
network_access: raw.network_access === true, writable_roots: writableRoots,
exclude_tmpdir_env_var: raw.exclude_tmpdir_env_var === true, exclude_slash_tmp: raw.exclude_slash_tmp === true,
};
}
/** The native policy codexEnvelopeArgs emits for this choice. */
export function codexMemberEnvelope(permissions: CodexMemberPermissions): CodexPermissionEnvelope {
export function codexMemberEnvelope(permissions: CodexSandboxPermissions): CodexPermissionEnvelope {
if (permissions.sandbox === "read-only") return { approvalPolicy: permissions.approval, sandboxPolicy: { type: "read-only" } };
return { approvalPolicy: permissions.approval, sandboxPolicy: { type: "workspace-write",
writable_roots: permissions.writable_roots ?? [], network_access: permissions.network_access === true,
exclude_tmpdir_env_var: permissions.exclude_tmpdir_env_var === true, exclude_slash_tmp: permissions.exclude_slash_tmp === true } };
}
/** Invocation-local profile: never written into the account home or the shared project. */
export const CODEX_SEAT_PERMISSION_PROFILE = "openrig-seat";
export function codexFilesystemOverrides(permissions: CodexFilesystemPermissions): string[] {
const entries = Object.entries(permissions.filesystem).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(",");
return [`default_permissions=${JSON.stringify(CODEX_SEAT_PERMISSION_PROFILE)}`,
`permissions={${JSON.stringify(CODEX_SEAT_PERMISSION_PROFILE)}={filesystem={${entries}},network={enabled=${permissions.network_access === true}}}}`];
}
/** The same authored choice reaches launch, both resumes and the status plan. This reports argv, not enforcement. */
export function codexMemberLaunch(permissions: CodexMemberPermissions): { args: string; observation: AppliedLaunchObservation; suppressAutoAddDirs: boolean } {
if ("filesystem" in permissions) {
return { args: ` -a ${permissions.approval}` + codexFilesystemOverrides(permissions).map(value => ` -c ${shellQuote(value)}`).join(""),
observation: { runtime: "codex", axis: "sandbox", state: "observed", value: `permissions:${CODEX_SEAT_PERMISSION_PROFILE}`,
reason: "emitted_launch_arguments", ...(permissions.approval === "never" ? { approvalPolicy: "never" } : {}) }, suppressAutoAddDirs: true };
}
const envelope = codexMemberEnvelope(permissions);
return { args: codexEnvelopeArgs(envelope), observation: observeCodexEnvelope(envelope), suppressAutoAddDirs: permissions.sandbox === "read-only" };
}
/** What an emitted envelope's arguments ask for; the native result still needs its own observation. */
export function observeCodexEnvelope(envelope: CodexPermissionEnvelope): AppliedLaunchObservation {
return { runtime: "codex", axis: "sandbox", state: "observed", value: envelope.sandboxPolicy.type,
@@ -122,57 +122,67 @@ export const CODEX_NETWORK_READER_DEADLINE_MS = 4_000;
/** The production reader: asynchronous, so the daemon keeps serving while Codex starts. */
export function codexNetworkDefaultReader(options: CodexNetworkReaderOptions = {}): CodexNetworkDefaultReader {
const deadlineMs = options.deadlineMs ?? CODEX_NETWORK_READER_DEADLINE_MS;
const graceMs = options.graceMs ?? 500;
const maxOutputBytes = options.maxOutputBytes ?? 4 * 1024 * 1024;
return async (cwd, seatHome) => {
const codexHome = seatHome ?? options.codexHome;
const started = performance.now();
const env = {
...process.env,
...(options.launchPath ? { PATH: options.launchPath } : {}),
...(options.home ? { HOME: options.home } : {}),
...(codexHome ? { CODEX_HOME: codexHome } : {}),
};
// The launch flag `-s workspace-write` is `sandbox_mode` for the server; plugins and apps are
// switched off for the reader only. app-server serves stdio by default.
const args = ["-c", 'sandbox_mode="workspace-write"', "-c", "features.plugins=false", "-c", "features.apps=false", "app-server"];
let child: ReturnType<typeof spawn>;
try {
// Its own process group, so cleanup reaches the reader's descendants and nothing else.
child = spawn("codex", args, { cwd, env, stdio: ["pipe", "pipe", "ignore"], detached: true });
} catch (error) {
return { ...no(`codex app-server could not start: ${firstLine(error)}`), elapsedMs: Math.round(performance.now() - started) };
}
const decision = await converse(child, cwd, deadlineMs, maxOutputBytes);
await stopGroup(child, graceMs);
const result = await readCodexConfiguration({ ...options, cwd, codexHome: seatHome ?? options.codexHome,
args: ["-c", 'sandbox_mode="workspace-write"', "-c", "features.plugins=false", "-c", "features.apps=false"] });
const decision = result.ok ? decideCodexNetworkDefault(result.configRead, result.requirementsRead) : no(result.reason);
return { ...decision, elapsedMs: Math.round(performance.now() - started) };
};
}
function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: number, maxOutputBytes: number): Promise<CodexNetworkDecision> {
export type CodexConfigurationRead = { ok: true; configRead: unknown; requirementsRead: unknown } | { ok: false; reason: string };
const unread = (reason: string): CodexConfigurationRead => ({ ok: false, reason });
/** One native config read with the caller's launch overrides. No thread, turn or persistent server. */
export async function readCodexConfiguration(options: CodexNetworkReaderOptions & { cwd: string; args: readonly string[] }): Promise<CodexConfigurationRead> {
const deadlineMs = options.deadlineMs ?? CODEX_NETWORK_READER_DEADLINE_MS;
const graceMs = options.graceMs ?? 500;
const maxOutputBytes = options.maxOutputBytes ?? 4 * 1024 * 1024;
const { cwd, codexHome } = options;
const env = {
...process.env,
...(options.launchPath ? { PATH: options.launchPath } : {}),
...(options.home ? { HOME: options.home } : {}),
...(codexHome ? { CODEX_HOME: codexHome } : {}),
};
// app-server serves stdio by default. The caller states the same permission overrides as its launch.
const args = [...options.args, "app-server"];
let child: ReturnType<typeof spawn>;
try {
// Its own process group, so cleanup reaches the reader's descendants and nothing else.
child = spawn("codex", args, { cwd, env, stdio: ["pipe", "pipe", "ignore"], detached: true });
} catch (error) {
return unread(`codex app-server could not start: ${firstLine(error)}`);
}
const decision = await converse(child, cwd, deadlineMs, maxOutputBytes);
await stopGroup(child, graceMs);
return decision;
}
function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: number, maxOutputBytes: number): Promise<CodexConfigurationRead> {
return new Promise((resolve) => {
const answers = new Map<number, Record<string, unknown>>();
let buffered = "";
let received = 0;
let settled = false;
const finish = (decision: CodexNetworkDecision) => {
const finish = (decision: CodexConfigurationRead) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
resolve(decision);
};
const deadline = setTimeout(() => finish(no(`codex app-server did not answer within ${deadlineMs} ms`)), deadlineMs);
const deadline = setTimeout(() => finish(unread(`codex app-server did not answer within ${deadlineMs} ms`)), deadlineMs);
const send = (message: Record<string, unknown>) => {
if (child.stdin?.writable) child.stdin.write(`${JSON.stringify(message)}\n`);
};
child.on("error", (error) => finish(no(`codex app-server could not start: ${firstLine(error)}`)));
child.on("exit", () => finish(no("codex app-server exited before answering")));
child.stdin?.on("error", () => finish(no("codex app-server closed its input before answering")));
child.on("error", (error) => finish(unread(`codex app-server could not start: ${firstLine(error)}`)));
child.on("exit", () => finish(unread("codex app-server exited before answering")));
child.stdin?.on("error", () => finish(unread("codex app-server closed its input before answering")));
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
received += Buffer.byteLength(chunk);
if (received > maxOutputBytes) return finish(no("codex app-server answered with more output than expected"));
if (received > maxOutputBytes) return finish(unread("codex app-server answered with more output than expected"));
buffered += chunk;
let newline: number;
while ((newline = buffered.indexOf("\n")) >= 0) {
@@ -183,7 +193,7 @@ function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: numb
try { message = JSON.parse(line); } catch { continue; }
// Only responses to our own requests: notifications and server requests carry a method.
if (!isRecord(message) || typeof message.id !== "number" || "method" in message) continue;
if (message.error !== undefined) return finish(no(`codex app-server returned an error for request ${message.id}`));
if (message.error !== undefined) return finish(unread(`codex app-server returned an error for request ${message.id}`));
if (message.id === 1) {
send({ method: "initialized", params: {} });
send({ id: 2, method: "config/read", params: { includeLayers: true, cwd } });
@@ -192,7 +202,7 @@ function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: numb
}
answers.set(message.id, message);
if (answers.has(2) && answers.has(3)) {
return finish(decideCodexNetworkDefault(answers.get(2)!.result, answers.get(3)!.result));
return finish({ ok: true, configRead: answers.get(2)?.result, requirementsRead: answers.get(3)?.result });
}
}
});
@@ -110,9 +110,12 @@ export function buildCodexResumeDisplay(
const profile = input.codexConfigProfile ?? null;
const profileArg = profile ? ` -p ${shellQuote(profile)}` : "";
const effectivePosture = input.resolvedPosture ?? "floor";
const envelope = input.codexPermissions ? codexMemberEnvelope(input.codexPermissions) : undefined;
const permissions = input.codexPermissions;
const hasFilesystemPolicy = permissions !== undefined && "filesystem" in permissions;
const envelope = permissions && !("filesystem" in permissions) ? codexMemberEnvelope(permissions) : undefined;
// Derive displayed flags from the same three inputs adapters/codex-resume.ts uses (member permission envelope,
// resolved posture with an empty env so ambient OPENRIG_YOLO never leaks, and operationalLaunchArg).
// A filesystem choice currently uses the posture fallback here; label that omission below.
const posture = envelope ? profileArg + codexEnvelopeArgs(envelope) : codexPostureArg(profileArg, {}, effectivePosture);
const postureArg = posture + operationalLaunchArg(
"codex",
@@ -163,6 +166,17 @@ export function buildCodexResumeDisplay(
&& recorded.state === "observed"
&& recorded.value !== null;
if (hasFilesystemPolicy) {
return {
command,
status: hasObservedRecord ? "recorded_uncertain" : "no_recorded_launch",
label: composeCodexResumeLabel(
`shown command uses posture fallback ${postureArg.trim()}; does not carry the agent's filesystem policy (managed resume carries it)`,
hasLaunchWrapper,
),
};
}
if (!envelope && input.emptySavedPosture) {
const fallbackClause = `saved posture empty, showing fallback ${currentFlags} (managed resume posture unknown)`;
if (!hasObservedRecord) {
@@ -4,8 +4,7 @@
import type Database from "better-sqlite3";
import { NativePermissionStore, type StoredNativePermissionSelection } from "./native-permission-store.js";
import { claudePostureFlag, codexPostureArg, planModelAliasNotice, yoloEnabled } from "../adapters/yolo-mode.js";
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
import { codexMemberEnvelope } from "./codex-member-permissions.js";
import { codexMemberLaunch } from "./codex-member-permissions.js";
import { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } from "../adapters/kernel-authority.js";
import { RigRepository } from "./rig-repository.js";
import type { NodeBinding } from "./runtime-adapter.js";
@@ -94,7 +93,7 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
// A Codex seat's envelope replaces the posture decision with its own flags, as the launch does.
const envelope = runtime === "codex" ? planned.codexPermissions : undefined;
const nativeArgument = runtime === "claude-code" ? claudePostureFlag(env, posture, mode)
: envelope ? `${profile ? ` -p ${profile}` : ""}${codexEnvelopeArgs(codexMemberEnvelope(envelope))}`.trim()
: envelope ? `${profile ? ` -p ${profile}` : ""}${codexMemberLaunch(envelope).args}`.trim()
: codexPostureArg(profile ? ` -p ${profile}` : "", env, posture).trim();
const openRigAdds: PermissionAccount["openRigAdds"] = { claudeAllow: [], claudeAsk: [], notes: [] };
const unknown = [NOT_READ, UNVERIFIED,
@@ -119,7 +118,11 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
controls.push(`rig seat set-permissions ${seat} --rules <file.json> --reason "<why>"`);
}
if (envelope) {
openRigAdds.notes.push(envelope.sandbox === "workspace-write"
if ("filesystem" in envelope) openRigAdds.notes.push(
"Invocation-local filesystem policy; OpenRig adds no writable directories or network grant. No shared policy file is written.",
"Each launch reads native configuration and refuses a legacy sandbox or a merged policy mismatch. A named codex_config_profile is unsupported with this choice.",
"This status describes the saved plan; native enforcement is not observed here.");
else openRigAdds.notes.push(envelope.sandbox === "workspace-write"
? "Extra writable roots for OpenRig's queue state, and git metadata folders on a fresh launch, beside the envelope's own roots."
: "No extra writable roots: the envelope is read-only.",
`Network access ${envelope.sandbox === "workspace-write" && envelope.network_access ? "on" : "off"}, as the envelope states; OpenRig adds no network setting of its own.`,
@@ -268,7 +268,8 @@ export function diagnoseRuntimePosture(input: {
const launchArguments = applied?.state === "observed" && applied.runtime === input.runtime
&& (input.runtime === "claude-code" || input.runtime === "codex");
const enforcement = launchArguments
? unknownEnforcement(applied.axis, applied.value, null, "native_permission_effect_unverified")
? unknownEnforcement(applied.axis, applied.value, null,
applied.runtime === "codex" && applied.reason?.startsWith("policy not verified:") ? applied.reason : "native_permission_effect_unverified")
: input.runtime === "claude-code"
? inspectClaude(input.cwd, input.applied, input.fs)
: inspectLaunchBoundRuntime(input.runtime, input.applied);
+34 -53
View File
@@ -520,14 +520,13 @@ export interface QueueUpdateInput {
wakeMessage?: string;
/**
* OPR.0.4.4.19 FR-6 — park-time inputs. summary + evidence_ref are
* updatable AT THE PARK MOMENT (state=blocked with a human-seat blocker),
* not create-only: `rig queue block --summary --evidence-ref` persists
* them onto the EXISTING item so the attention query + Packet 2 read
* them. A done close also persists evidence_ref, so a finished row carries
* its artifact. OPR.0.5.1 slice-51-06 D2: supplying either where it can't
* be stored is REJECTED (QueueRepositoryError "summary_evidence_not_persistable")
* before any mutation — not silently ignored — so a caller never believes
* unpersistable metadata was saved.
* updatable with any state change, not create-only: `rig queue block
* --summary --evidence-ref` persists them onto the EXISTING item so the
* attention query + Packet 2 read them, and a done close leaves its
* artifact on the finished row. A human-seat park still requires them.
* A blank value is not stored (it would erase the row's subject or
* evidence link); the update route reports any value
* the stored row doesn't carry, so a caller never believes it was saved.
*/
summary?: string | null;
evidenceRef?: string | null;
@@ -2579,7 +2578,7 @@ export class QueueRepository {
}
/** The registered person an address names, so an ask back reaches them; null for anyone else, the unattributed
* terminal actor included (it's a person address, but nothing is delivered there). */
* terminal actor included (a person address, but a row there asks no one; it waits on Steering and in the Feed, and OpenRig reports it as undeliverable). */
reachablePerson(address: string | null | undefined): string | null {
if (!address || address === UNATTRIBUTED_ACTOR) return null;
const registry = this.loadHumanRegistryFn();
@@ -3455,6 +3454,7 @@ export class QueueRepository {
}
const hasNote = typeof input.transitionNote === "string" && input.transitionNote.trim().length > 0;
const hasValidSummary = typeof input.summary === "string" && input.summary.trim().length > 0;
const hasValidEvidenceRef = typeof input.evidenceRef === "string" && input.evidenceRef.trim().length > 0;
const isGuardedTerminal = (["done", "canceled", "handed-off"] as const).includes(
qitem.state as "done" | "canceled" | "handed-off",
);
@@ -3664,35 +3664,15 @@ export class QueueRepository {
throw new QueueRepositoryError("invalid_human_notification", "An informational delivery cannot become a human approval park; author a separate decision request.");
}
// A park on a person by any address (a legacy human seat, or a registered person's own address or alias) keeps the
// summary and evidence it names. validateHumanPark still requires them only for the legacy human-seat form.
// summary and evidence it names, or the row's own where it names a blank one. validateHumanPark still requires them
// only for the legacy human-seat form.
const isHumanPark = input.state === "blocked" && !!effectiveBlockedOn && this.isPersonAddress(effectiveBlockedOn);
// A done close can also store the artifact the work produced, so the finished row carries its link.
const isDoneClose = input.state === "done";
// OPR.0.5.1 slice-51-06 D2 refused the whole transition when summary/evidence_ref came with a state it
// didn't store them for, so finished work could stay pending over optional metadata. A state change now
// stores them (below), except a blank value; the route reports what wasn't stored.
// OPR.0.5.1 slice-51-06 D2 — summary/evidence_ref are persist-able ONLY at a human-seat park
// (see the FR-6 note below). Silently ignoring them on any other transition is a data-loss trap
// (the operator believes the metadata was saved). HARD-REJECT before ANY UPDATE/log/event so the
// caller learns immediately and nothing is half-applied. null/undefined = absent (allowed);
// empty string = present (a deliberate value → rejected on a non-park transition).
if (!isHumanPark) {
const invalidFields: Array<"summary" | "evidenceRef"> = [];
if (input.summary != null) invalidFields.push("summary");
if (input.evidenceRef != null && !isDoneClose) invalidFields.push("evidenceRef");
if (invalidFields.length > 0) {
const flags = invalidFields.map((f) => (f === "summary" ? "--summary" : "--evidence-ref")).join(" / ");
const where = invalidFields.map((f) => f === "summary"
? "summary persists on a human-seat park, or on its own (rig queue update <id> --summary … with no --state)"
: "evidenceRef persists on a human-seat park or a done close").join("; ");
throw new QueueRepositoryError(
"summary_evidence_not_persistable",
`${where}; the '${input.state}' transition cannot store ${invalidFields.length > 1 ? "them" : "it"}. Remove ${flags}, or use one of those.`,
{ invalidFields },
);
}
}
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE, beside the reference
// reject above: an incoherent field must never silently persist (worse than a drop —
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE: an incoherent
// field must never silently persist (worse than a drop —
// the COALESCE below would write it). Admits-map, derived from LIVE schema use:
// closure_reason/closure_target → state "done", OR the PARK-RECORD form
// (state "blocked" with closureReason "blocked_on" — the workflow gate/park
@@ -3770,8 +3750,8 @@ export class QueueRepository {
let effectiveSummary = qitem.summary;
let effectiveEvidenceRef = qitem.evidenceRef;
if (isHumanPark) {
effectiveSummary = input.summary ?? qitem.summary;
effectiveEvidenceRef = input.evidenceRef ?? qitem.evidenceRef;
effectiveSummary = hasValidSummary ? input.summary! : qitem.summary;
effectiveEvidenceRef = hasValidEvidenceRef ? input.evidenceRef! : qitem.evidenceRef;
const park = validateHumanPark({
blockedOn: effectiveBlockedOn,
summary: effectiveSummary,
@@ -3899,15 +3879,13 @@ export class QueueRepository {
input.qitemId
);
// FR-6: park-time summary/evidence_ref are PERSISTED onto the existing
// item (not merely validated-then-dropped) — visible to the attention
// query and to Packet 2. A done close writes evidence_ref the same way.
if (isHumanPark) {
this.persistSummary(input.qitemId, input.summary ?? null);
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
} else if (isDoneClose) {
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
}
// FR-6: summary/evidence_ref are PERSISTED onto the existing item (not
// merely validated-then-dropped) — visible to the attention query, to
// Packet 2 and to every reader of the row. Only non-blank values are
// stored, on a park on a person too: a blank one would erase the row's
// subject or evidence link, and the route reports it as not stored.
if (hasValidSummary) this.persistSummary(input.qitemId, input.summary!);
if (hasValidEvidenceRef) this.persistEvidenceRef(input.qitemId, input.evidenceRef!);
const transition = this.transitionLog.append({
qitemId: input.qitemId,
@@ -3991,9 +3969,9 @@ export class QueueRepository {
closureReason: validation.closureReason ?? null,
closureTarget: validation.closureTarget ?? null,
actorSession: input.actorSession,
// FR-1 × FR-6: the event carries the summary as of THIS mutation
// FR-1 × FR-6: the event carries the summary as stored by THIS mutation
// (park-time summary included) so surfaces refresh without a fetch.
summary: effectiveSummary ?? null,
summary: this.getById(input.qitemId)?.summary ?? null,
});
return { persistedEvent, persistedEvents: [...dependentEvents, persistedEvent] };
}
@@ -4560,9 +4538,11 @@ export class QueueRepository {
}
/** A park's current question: its latest human-required transition, and that notification's final post receipt, if any. */
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions) and its decision state
* (open, blocked or terminal; who it's addressed to or parked on; its answers, a decision made for them, the current park,
* and the replies returned). A label, a wake, a claim or a note on a park doesn't change it; a new park does, on any blocker. */
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions, and the evidence it
* points to, when it has any) and its decision state (open, blocked or terminal; who it's addressed to or parked on; its
* answers, a decision made for them, the current park, and the replies returned). A label, a wake, a claim or a note on a
* park doesn't change it; a new park does, on any blocker, and so does a new evidence pointer. A row with no evidence
* keeps the revision it had before evidence counted. */
decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string {
const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state;
const excludeOp =
@@ -4589,7 +4569,8 @@ export class QueueRepository {
if (transitions[i]!.state === "blocked" && transitions[i - 1]?.state !== "blocked") parkEntry = transitions[i]!.transitionId;
}
const decision = [item.body, item.humanIntent ?? null, item.humanQuestions ?? null, phase, item.destinationSession, item.blockedOn,
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies];
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies,
...(item.evidenceRef ? [item.evidenceRef] : [])];
return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`;
}
@@ -12,7 +12,7 @@ export function terminalAnswerMeaning(unattributed: string): string {
* which reaches them where they answer. `asked` is null when no registered person resolves: removed since the ask, an
* unreadable registry, or an ask to the unattributed actor itself. What to do then is this one sentence. */
export function askBackRoute(asked: string | null, unattributed: string, request: string): string {
const nowhere = `Don't write to ${unattributed}: nothing is delivered there.`;
const nowhere = `A row to ${unattributed} names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there.`;
if (asked) {
return `If their answer is unclear or conditional, ask them back with an ask to ${asked} that names ${request}: `
+ `rig queue create --destination ${asked} --summary "<your question>" --body-file <file>. It reaches them where they answer (the Feed, Steering, and Slack when it's connected). ${nowhere}`;
+1 -1
View File
@@ -131,7 +131,7 @@ export function addRigEdge(
if (LAUNCH_DEPENDENCY_KINDS.has(input.kind) && createsLaunchCycle(rig.edges, candidate)) {
return {
ok: false, code: "edge_cycle",
message: `a ${input.kind} edge from ${input.from} to ${input.to} would make launch order cycle, and launch and restore refuse a cycle`,
message: `a ${input.kind} edge from ${input.from} to ${input.to} would make launch order cycle; launch and restore require an acyclic order`,
};
}
if (input.plan) return { ok: true, rigId: input.rigId, outcome: "would_add", edge };
@@ -253,6 +253,9 @@ export interface RuntimeAdapter {
/** Claude's managed capability/launch seam, shared with seat selection. */
readonly claudeManagedLaunch?: import("./claude-managed-launch.js").ClaudeManagedLaunch;
readonly runtime: string;
/** Check launch preconditions before an in-place handover ends the predecessor.
* A concrete conflict refuses; an inconclusive check may allow launch with a warning. */
preflightLaunch?(binding: NodeBinding): Promise<{ ok: true; warnings?: string[] } | { ok: false; error: string }>;
/** The file an adapter's project() writes for a skill when it lives outside
* the Claude project tree, for "already in place" detection. Absent = the
* instantiator's default target. */
@@ -518,7 +518,9 @@ export class SeatHandoverService {
if (permissionOverride.codexPermissions) {
// Saved envelopes retain ordinary launch additions; only an observed predecessor
// policy uses the exact carry path. Describe policy, not an observed native delta.
const directoryPolicy = permissionOverride.codexPermissions.sandbox === "workspace-write"
const directoryPolicy = "filesystem" in permissionOverride.codexPermissions
? "with its explicit filesystem policy and no additional writable directory grants"
: permissionOverride.codexPermissions.sandbox === "workspace-write"
? "with OpenRig's usual Git metadata, seat-folder and pod-state writable roots as on a fresh launch"
: "without additional writable directory grants, as on a read-only fresh launch";
permissionContinuity = { state: "selection_used", detail: `The seat's saved permission envelope was used ${directoryPolicy}. This describes launch policy, not an observed difference. The predecessor policy was not carried; native successor equivalence is unverified.`
@@ -617,7 +619,9 @@ export class SeatHandoverService {
// The registry binding is unchanged (commit never ran). A resolve_pane failure leaves the live
// retiree wholly untouched; a failure after the in-place respawn leaves the seat re-wakeable from
// its provider session file (never destroyed). Inspect tmux/daemon logs and retry.
guidance: "The seat's registry binding is unchanged. If the failure was after the in-place respawn, the seat is re-wakeable from its provider session file. Inspect tmux/daemon logs and retry.",
guidance: launch.code === "successor_preflight_failed"
? `The predecessor is still running and its binding is unchanged. ${launch.message}`
: "The seat's registry binding is unchanged. If the failure was after the in-place respawn, the seat is re-wakeable from its provider session file. Inspect tmux/daemon logs and retry.",
};
}
@@ -779,7 +783,7 @@ export class SeatHandoverService {
sourceOutcome,
launchAdditionNotes,
startedWithoutRequired,
launchWarnings: coreCopyNotes,
launchWarnings: [...coreCopyNotes, ...(launch.warnings ?? [])],
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId),
});
}
@@ -951,7 +951,7 @@ export class SeatLifecycleService {
let persisted: PersistedEvent | null = null;
const result = this.db.transaction(() => {
// Read the current record for the audit; malformed old bytes stay visible as such and are replaced.
const row = this.db.prepare("SELECT codex_permissions_json FROM nodes WHERE id = ? AND runtime = 'codex'").get(seat.nodeId) as { codex_permissions_json: string | null } | undefined;
const row = this.db.prepare("SELECT codex_permissions_json, codex_config_profile FROM nodes WHERE id = ? AND runtime = 'codex'").get(seat.nodeId) as { codex_permissions_json: string | null; codex_config_profile: string | null } | undefined;
if (!row) throw new Error("Seat runtime changed; the envelope was not changed.");
let from: unknown = null;
try { from = readCodexMemberPermissions(this.db, seat.nodeId) ?? null; }
@@ -968,7 +968,7 @@ export class SeatLifecycleService {
nodeId: seat.nodeId, from, to, ...(replaced ? { replaced } : {}), actor, reason, effect: "future_launches_only" });
}
return { ok: true as const, seat, from, to, changed, ...(replaced ? { replaced } : {}),
effect: `The envelope applies at the next separately authorized managed launch or resume, as explicit sandbox and approval flags that a profile or policy posture doesn't override.${replaced ? " It replaces the seat's recorded permission mode." : ""} The current process and history are unchanged. Older readers may ignore it; check rollback compatibility and preserve restrictions before downgrading.` };
effect: `The envelope applies at the next separately authorized managed launch or resume, ${"filesystem" in to ? "as explicit permission and approval flags, with native configuration conflicts refused and inconclusive checks reported as policy not verified." : "as explicit sandbox and approval flags that a profile or policy posture doesn't override."}${replaced ? " It replaces the seat's recorded permission mode." : ""} The current process and history are unchanged. Older readers may ignore it; check rollback compatibility and preserve restrictions before downgrading.` };
})();
if (persisted) this.eventBus.notifySubscribers(persisted);
return result;
@@ -3930,12 +3930,13 @@ export class SessionTransport {
/** Wait on the existing classifier without occupying the input/lifecycle lease. With `singleObservation`, one
* observation within `timeoutMs` and no wait after it: a busy reading returns at once. */
async waitUntilIdle(sessionName: string, timeoutMs: number, signal?: AbortSignal, opts?: { singleObservation?: boolean; allowDraft?: true }) {
async waitUntilIdle(sessionName: string, timeoutMs: number, signal?: AbortSignal, opts?: { singleObservation?: boolean; allowDraft?: true; onObservation?: (activity: AgentActivity) => void }) {
const meta = this.getSessionMeta(sessionName);
return this.waitForIdle({ sessionName, runtime: meta.runtime, attachmentType: meta.attachmentType, timeoutMs, signal,
binding: { sessionName, nodeId: meta.nodeId, occupant: meta.occupant, pane: meta.pane },
...(opts?.allowDraft ? { allowDraft: true } : {}),
...(opts?.singleObservation ? { singleObservation: true } : {}) });
...(opts?.singleObservation ? { singleObservation: true } : {}),
...(opts?.onObservation ? { onObservation: opts.onObservation } : {}) });
}
private async waitForIdle(input: {
@@ -3954,6 +3955,8 @@ export class SessionTransport {
allowUnknown?: boolean;
/** One observation only: a reading that isn't idle ends the wait at once. */
singleObservation?: boolean;
/** Called with each observation that completes in time, so a caller can report the seat's state while it waits. */
onObservation?: (activity: AgentActivity) => void;
// Optional display value for timeout messages. The deadline always comes
// from timeoutMs; when set, expiry reports this value instead, so a
// re-wait running on a leftover budget still names the configured wait.
@@ -4024,6 +4027,7 @@ export class SessionTransport {
return expire();
}
last = activity;
input.onObservation?.(activity);
const waitedMs = input.timeoutMs - (deadline - Date.now());
if (activity.state === "idle" || (input.allowUnknown && activity.state === "unknown") || (input.allowDraft && this.deliversOverDraft(activity))) {
@@ -250,6 +250,21 @@ export class SuccessorSessionLauncher {
};
}
const binding = successorBinding(input.node, departingSession, pane.id, cwd, input.occupantGeneration);
const adapter = input.node.runtime ? this.runtimeAdapters[input.node.runtime] : undefined;
let preflightWarnings: string[] = [];
if (adapter?.preflightLaunch) {
try {
const checked = await adapter.preflightLaunch(binding);
if (!checked.ok) return { ok: false, code: "successor_preflight_failed", step: "create_successor",
message: checked.error, replacementStarted: false };
preflightWarnings = checked.warnings ?? [];
} catch (error) {
return { ok: false, code: "successor_preflight_failed", step: "create_successor",
message: `Successor launch preflight threw: ${error instanceof Error ? error.message : String(error)}`, replacementStarted: false };
}
}
// 2. Terminate the retiree IN PLACE, then respawn the now-dead pane WITHOUT -k so native scrollback
// survives (respawn-pane -k CLEARS the pane history — verified on tmux 3.6a; that would defeat the
// money-proof). The swap: set remain-on-exit so the pane survives the exit → graceful SIGTERM
@@ -304,7 +319,7 @@ export class SuccessorSessionLauncher {
// not repointed. When the harness launch was already sent and only readiness failed, the pane is NOT left as
// a bare shell: the successor may still come up there, unadopted (#2985). The failure says so, so the caller
// can tell the operator that a retry would end it.
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration);
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration, undefined, binding);
if (!started.ok) {
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true,
tmuxPane: pane.id, ...(started.launched ? { successorLaunched: true } : {}) };
@@ -320,6 +335,7 @@ export class SuccessorSessionLauncher {
cwd: input.node.cwd ?? undefined,
});
const warnings = [...preflightWarnings, ...(respawned.warnings ?? [])];
return {
ok: true,
discoveredId: discovered.id,
@@ -330,7 +346,7 @@ export class SuccessorSessionLauncher {
appliedLaunch: started.appliedLaunch,
...(started.activityGrantId ? { activityGrantId: started.activityGrantId } : {}),
retireeExit: terminated.path,
...(respawned.warnings ? { warnings: respawned.warnings } : {}),
...(warnings.length ? { warnings } : {}),
};
}
@@ -397,6 +413,7 @@ export class SuccessorSessionLauncher {
forkSource?: ForkSource,
launchGeneration?: string | null,
staged?: StagedLaunch,
preparedBinding?: NodeBinding,
): Promise<{ ok: true; activityGrantId?: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
if (!adapter) {
@@ -414,39 +431,7 @@ export class SuccessorSessionLauncher {
// edge of the same seat, so its launch must read the seat's spec, else the
// running topology silently drifts from the founder-designed one at every
// handover. (config profile is still a tracked follow-on.)
const binding: NodeBinding = {
id: "",
nodeId: node.id,
attachmentType: "tmux",
tmuxSession,
tmuxWindow: null,
tmuxPane,
cmuxWorkspace: null,
cmuxSurface: null,
updatedAt: "",
cwd: cwd ?? "",
launchGeneration: launchGeneration ?? undefined,
nonInterruptive: node.nonInterruptive,
kernelAuthority: node.kernelAuthority,
teamPermissionDefault: node.teamPermissionDefault,
claudePermissions: node.claudePermissions,
claudeMcp: node.runtime === "claude-code" ? node.claudeMcp : undefined,
codexPermissions: node.codexPermissions,
// Seam B: continuity — the successor launches at the departing seat's posture.
...(node.launchPosture ? { launchPosture: node.launchPosture } : {}),
...(node.permissionMode ? { permissionMode: node.permissionMode } : {}),
// 0.5.2-07: the successor reads the seat's SPEC-pinned model (adapter emits -m/--model).
model: node.model ?? undefined,
// #75: the successor reads the seat's resolved effort (saved choice, else authored).
effort: node.effort ?? undefined,
advisor: node.advisor ?? undefined,
// 0.5.2-07 A4-profile: the successor reads the seat's SPEC-pinned codex config profile (adapter emits -p).
codexConfigProfile: node.codexConfigProfile ?? undefined,
codexRebuildPermissions: node.codexRebuildPermissions,
...(node.launchAdditions ? { launchAdditions: node.launchAdditions } : {}),
...(node.launchWrapper?.length ? { launchWrapper: node.launchWrapper } : {}),
...(staged?.binding ?? {}),
};
const binding = preparedBinding ?? successorBinding(node, tmuxSession, tmuxPane, cwd, launchGeneration, staged?.binding);
// Slice 16: a staged runtime-switch successor gets its target materials before its harness starts.
let notes: string[] | undefined;
@@ -578,6 +563,43 @@ export class SuccessorSessionLauncher {
}
}
function successorBinding(node: SuccessorNode, tmuxSession: string, tmuxPane: string, cwd: string | undefined,
launchGeneration?: string | null, bindingOverrides?: StagedLaunch["binding"]): NodeBinding {
return {
id: "",
nodeId: node.id,
attachmentType: "tmux",
tmuxSession,
tmuxWindow: null,
tmuxPane,
cmuxWorkspace: null,
cmuxSurface: null,
updatedAt: "",
cwd: cwd ?? "",
launchGeneration: launchGeneration ?? undefined,
nonInterruptive: node.nonInterruptive,
kernelAuthority: node.kernelAuthority,
teamPermissionDefault: node.teamPermissionDefault,
claudePermissions: node.claudePermissions,
claudeMcp: node.runtime === "claude-code" ? node.claudeMcp : undefined,
codexPermissions: node.codexPermissions,
// Seam B: continuity — the successor launches at the departing seat's posture.
...(node.launchPosture ? { launchPosture: node.launchPosture } : {}),
...(node.permissionMode ? { permissionMode: node.permissionMode } : {}),
// 0.5.2-07: the successor reads the seat's SPEC-pinned model (adapter emits -m/--model).
model: node.model ?? undefined,
// #75: the successor reads the seat's resolved effort (saved choice, else authored).
effort: node.effort ?? undefined,
advisor: node.advisor ?? undefined,
// 0.5.2-07 A4-profile: the successor reads the seat's SPEC-pinned codex config profile (adapter emits -p).
codexConfigProfile: node.codexConfigProfile ?? undefined,
codexRebuildPermissions: node.codexRebuildPermissions,
...(node.launchAdditions ? { launchAdditions: node.launchAdditions } : {}),
...(node.launchWrapper?.length ? { launchWrapper: node.launchWrapper } : {}),
...(bindingOverrides ?? {}),
};
}
function compactEnv(input: Record<string, string | undefined>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
@@ -353,6 +353,28 @@ export class WorkflowInstanceStore {
return rows.map(rowToInstance);
}
/** Apply selectors before the optional bound and before expensive inspection. */
listFiltered(filters: { status?: WorkflowInstanceStatus; project?: string; mission?: string; slice?: string; rig?: string; name?: string; tags?: string[]; limit?: number }): WorkflowInstance[] {
const clauses: string[] = [];
const args: Array<string | number> = [];
if (filters.status) { clauses.push("status = ?"); args.push(filters.status); }
if (filters.name) { clauses.push("workflow_name = ?"); args.push(filters.name); }
if (filters.rig) { clauses.push(this.hasBoundRigColumn ? "bound_rig = ?" : "0"); if (this.hasBoundRigColumn) args.push(filters.rig); }
for (const field of ["project", "mission", "slice"] as const) {
if (!filters[field]) continue;
clauses.push(this.hasScopeColumn ? `json_extract(scope_json, '$.${field}') = ?` : "0");
if (this.hasScopeColumn) args.push(filters[field]!);
}
for (const tag of filters.tags ?? []) {
clauses.push(this.hasScopeColumn ? "EXISTS (SELECT 1 FROM json_each(workflow_instances.scope_json, '$.tags') WHERE value = ?)" : "0");
if (this.hasScopeColumn) args.push(tag);
}
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
const limit = filters.limit === undefined ? "" : " LIMIT ?";
if (filters.limit !== undefined) args.push(filters.limit);
return (this.db.prepare(`SELECT * FROM workflow_instances${where} ORDER BY created_at ASC${limit}`).all(...args) as InstanceRow[]).map(rowToInstance);
}
/**
* Update frontier + status atomically. Caller is responsible for
* wrapping in a transaction when this needs to compose with other
@@ -1845,8 +1845,9 @@ export class WorkflowRuntime {
/** List instances (optionally filtered) with the deadline verdict attached. */
listInstancesWithDeadline(
status?: "active" | "waiting" | "completed" | "failed" | "aborted",
filters?: Omit<Parameters<WorkflowInstanceStore["listFiltered"]>[0], "status">,
): WorkflowInstanceWithInspection[] {
const rows = status ? this.instanceStore.listByStatus(status) : this.instanceStore.listAll();
const rows = filters ? this.instanceStore.listFiltered({ ...filters, status }) : status ? this.instanceStore.listByStatus(status) : this.instanceStore.listAll();
return rows.map((row) => {
const inspected = this.inspect(row.instanceId);
return {
+6
View File
@@ -100,6 +100,10 @@ export function appendDeliveredSegment(envelope: string, deltaMs: number): strin
return lines.join("\n") + rest;
}
/** The address OpenRig records an answer typed at this machine under (the daemon's UNATTRIBUTED_ACTOR). It names nobody,
* and a row there asks no one (it waits on Steering and in the Feed, and OpenRig reports it as undeliverable), so a reply hint never points at it. */
const UNATTRIBUTED_PERSON = "human@host";
/** Wrap a tmux-pane body with the canonical From/To envelope. The
* recipient pane sees both the sender's identity and a reply hint.
* Cross-host nudges should NOT double-wrap: the remote rig wraps
@@ -134,6 +138,8 @@ export function wrapPaneEnvelope(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: personAddress === UNATTRIBUTED_PERSON
? `↩ ${senderLabel} names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).`
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: isOpenRigSender(senderLabel)
+6 -3
View File
@@ -63,8 +63,8 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
}
// Read the KNOWN context-usage projection BEFORE triggering. When usage is
// absent / stale / unknown we pass null (never invent a value); the enforcer
// returns an honest `no_usage_data` reason for a Claude seat.
// absent / stale / unknown we pass null (never invent a value); an explicit
// manual request doesn't depend on the threshold, so it proceeds without it.
const usage = usageStore.getForNode(row.node_id, sessionName);
const outcome = await enforcer.triggerManualCompact(
{
@@ -170,7 +170,8 @@ function manualOutcomeMessage(sessionName: string, outcome: Extract<ManualCompac
const prep = outcome.preparation;
if (!prep || prep.delivery === "not_sent") return manualReasonMessage(sessionName, outcome.reason);
const effect = prep.delivery === "delivered" ? "Preparation was sent" : "Preparation may have reached the seat (delivery is unconfirmed)";
const detail = (outcome.reason === "preparation_incomplete" || outcome.reason === "map_complete_seat_never_idle")
const detail = (outcome.reason === "preparation_incomplete" || outcome.reason === "map_complete_seat_never_idle"
|| outcome.reason === "preparation_needs_input")
? manualReasonMessage(sessionName, outcome.reason)
: outcome.restoreOwed
? `/compact was not confirmed (${outcome.reason}) and is never sent again. Its restore steps follow in case it ran; the restore request tells the seat to say so and skip if it didn't compact.`
@@ -202,6 +203,8 @@ function manualReasonMessage(sessionName: string, reason: string): string {
return `The occupant generation could not be resolved for '${sessionName}'; no preparation or compact was sent. For the confirmed live seat, run rig reconcile-session ${shellQuote(sessionName)} --no-launch, then explicitly retry rig compact ${shellQuote(sessionName)}. Reconciliation does not restart this attempt.`;
case "map_complete_seat_never_idle":
return `Compaction for '${sessionName}': map complete, seat never idle within the bounded wait; managed compaction is disarmed. Inspect rig compact ${shellQuote(sessionName)} --state before an explicit retry.`;
case "preparation_needs_input":
return `Compaction for '${sessionName}' waited for the seat to answer a prompt (a permission request in its preparation turn, say) and it didn't become idle within the bounded wait; managed compaction is disarmed. Answer the prompt in the seat, then retry explicitly: rig compact ${shellQuote(sessionName)}. Inspect rig compact ${shellQuote(sessionName)} --state first.`;
case "preparation_incomplete":
return `The restore map was not completed in time for '${sessionName}'; managed compaction is disarmed. Inspect rig compact ${sessionName} --state, retry explicitly, or use --skip-map once.`;
case "pane_unknown_protected":
+21 -5
View File
@@ -211,6 +211,24 @@ export function queueRoutes(): Hono {
message: `No wake is set for this park: nothing wakes ${item.destinationSession} on a timer while ${item.qitemId} waits on ${item.blockedOn}. To add one, park it again with --wake-after <duration> (or --wake-watchdog <job-id>). When ${item.blockedOn} clears, rig queue resolve ${item.qitemId} --decision <evidence> unparks it.`,
};
}
/** A summary or evidence ref the update asked for but the stored row doesn't carry (a blank one, which isn't stored
* because it would erase what the row has, or one the repository couldn't write): read from the stored row, so the
* caller is told whatever the reason. */
function unstoredAdvisories(body: { summary?: unknown; evidenceRef?: unknown }, item: QueueItem) {
return ([["summary", "summary", "summary_not_stored"], ["evidenceRef", "evidence ref", "evidence_ref_not_stored"]] as const)
.flatMap(([field, name, code]) => {
const requested = body[field];
const stored = item[field];
if (typeof requested !== "string" || stored === requested) return [];
const why = requested.trim().length === 0 ? `The ${name} was blank, so it wasn't stored` : `The ${name} wasn't stored`;
return [{
code,
qitemId: item.qitemId,
field,
message: `${why}: ${item.qitemId} keeps ${stored ? `its ${name} "${stored}"` : `no ${name}`}. The rest of the update was applied.`,
}];
});
}
function getInbox(c: { get: (key: string) => unknown }): InboxHandler {
return c.get("inboxHandler" as never) as InboxHandler;
}
@@ -312,9 +330,6 @@ export function queueRoutes(): Hono {
: err.code === "reply_to_requires_update" ? 400
: err.code === "reply_to_not_found" ? 400
: err.code === "invalid_human_questions" ? 400
// OPR.0.5.1 slice-51-06 D2: summary/evidence_ref on a non-park transition — a client
// input error surfaced as a structured 400 (the daemon rejects before any mutation).
: err.code === "summary_evidence_not_persistable" ? 400
// OPR.0.4.6.WF3 FR-6: the frontier close-path guard — operator
// misuse of a queue verb on a live workflow packet; structured
// 400 with the what/why/fix message, never a 500.
@@ -922,8 +937,9 @@ export function queueRoutes(): Hono {
evidenceRef: body.evidenceRef,
...resolveRecordedIdentity(c, identity), // P21 §4 era-stamp, plus the MCP adapter's caller on a direct attributed request (principal:v1)
});
const advisory = body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null;
return c.json({ ...item, ...(advisory ? { advisories: [advisory] } : {}) });
const advisories = [body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null, ...unstoredAdvisories(body, item)]
.filter((advisory) => advisory !== null);
return c.json({ ...item, ...(advisories.length > 0 ? { advisories } : {}) });
} catch (err) {
return errorResponse(c, err);
}
+13 -3
View File
@@ -321,10 +321,20 @@ export function workflowRoutes(): Hono {
// runtime.continue()'s enriched instance.
app.get("/list", (c) => {
const status = c.req.query("status");
if (status === "active" || status === "waiting" || status === "completed" || status === "failed" || status === "aborted") {
return c.json(getRuntime(c).listInstancesWithDeadline(status));
const parsedStatus = status === "active" || status === "waiting" || status === "completed" || status === "failed" || status === "aborted" ? status : undefined;
const rawLimit = c.req.query("limit");
if (rawLimit !== undefined && (!/^[1-9]\d*$/.test(rawLimit) || Number(rawLimit) > 10000)) return c.json({ error: "limit_invalid", message: "limit must be an integer from 1 to 10000" }, 400);
const fields = ["project", "mission", "slice", "rig", "name"] as const;
const filters: { project?: string; mission?: string; slice?: string; rig?: string; name?: string; tags?: string[]; limit?: number } = {};
for (const field of fields) {
const value = c.req.query(field);
if (value !== undefined) { if (!value.trim()) return c.json({ error: "filter_invalid", message: `${field} must not be empty` }, 400); filters[field] = value; }
}
return c.json(getRuntime(c).listInstancesWithDeadline());
const tags = c.req.queries("tag");
if (tags?.some((tag) => !tag.trim())) return c.json({ error: "filter_invalid", message: "tag must not be empty" }, 400);
if (tags?.length) filters.tags = tags;
if (rawLimit !== undefined) filters.limit = Number(rawLimit);
return c.json(getRuntime(c).listInstancesWithDeadline(parsedStatus, Object.keys(filters).length ? filters : undefined));
});
// Lists every cached workflow_spec with an `isBuiltIn` flag computed
@@ -4,7 +4,7 @@ import type { Database as DatabaseType } from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
import { observeClaudePermission } from "../src/domain/permission-drift.js";
import { diagnoseRuntimePosture, observeClaudePermission, renderPermissionDriftSummary } from "../src/domain/permission-drift.js";
describe("AppliedLaunchObservationStore", () => {
let db: DatabaseType;
@@ -47,6 +47,23 @@ describe("AppliedLaunchObservationStore", () => {
expect(db.prepare("SELECT COUNT(*) AS n FROM applied_launch_observations").get()).toEqual({ n: 1 });
});
it("preserves an inconclusive Codex policy reason through storage and rendered status", () => {
db.prepare("UPDATE nodes SET runtime = 'codex' WHERE id = 'node-1'").run();
registry.registerSession("node-1", "dev-impl@r1");
const generation = registry.currentOccupantTenure("node-1")!.generationUuid;
const reason = "policy not verified: native read timed out";
expect(store.recordGeneration(generation, { runtime: "codex", axis: "sandbox", state: "observed",
value: "permissions:openrig-seat", reason })).toBe(true);
const applied = store.readCurrent("node-1");
expect(applied?.reason).toBe(reason);
const diagnostic = diagnoseRuntimePosture({ runtime: "codex", cwd: "/work", applied,
fs: { readFile: () => "", cwdReadable: () => true, commandAvailable: () => true, claudePermissionModes: () => null } });
expect(diagnostic.enforcement).toMatchObject({ state: "unknown", reason });
expect(renderPermissionDriftSummary(diagnostic)).toContain(reason);
registry.mintOccupantTenure("node-1", "handover");
expect(store.readCurrent("node-1")).toBeNull();
});
it("never rebinds a delayed launch write onto a successor generation", () => {
registry.registerSession("node-1", "dev-impl@r1");
const launchedGeneration = registry.currentOccupantTenure("node-1")!.generationUuid;
@@ -11,7 +11,7 @@
// usage data short-circuit, send-failure no-dedup-update semantics.
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import { ClaudeCompactionEnforcer as ActualEnforcer, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
import { ClaudeCompactionEnforcer as ActualEnforcer, MANUAL_SEAT_READ_INTERVAL_MS, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
import { CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS, type SessionTransport } from "../src/domain/session-transport.js";
import type { ClaudeCompactionPolicy, SettingsStore } from "../src/domain/user-settings/settings-store.js";
@@ -1199,7 +1199,7 @@ describe("ClaudeCompactionEnforcer", () => {
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("preparing");
// DEGRADED duplicate: usedPercentage null (bad-sidecar projection). The guard
// now PRECEDES the no_usage_data recordManualFailure path, so it must NOT record
// PRECEDES every manual refusal, so it must NOT record
// skipped-or-failed / erase the preparing marker (the bug rev1-r2 caught).
const degraded = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: null });
expect(degraded).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" });
@@ -1383,14 +1383,79 @@ describe("ClaudeCompactionEnforcer", () => {
expect(enforcer.getManualCompactionState("codex@rig")?.stage).toBe("skipped-or-failed");
});
it("no known usage sample → rejected with no_usage_data (never triggers blind)", async () => {
it("no known usage sample: an explicit manual request still prepares and compacts; unknown usage isn't a reason to stop", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000 });
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: null });
expect(outcome).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "no_usage_data" });
expect(send).not.toHaveBeenCalled();
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
const texts = send.mock.calls.map((call) => String(call[1]));
expect(texts[0]).toContain("OpenRig manual compaction was requested for this seat");
expect(texts[0]).not.toContain("null");
expect(texts[1]).toMatch(/^\/compact /);
});
it("the preparation request asks for a turn free of approval prompts: file-edit writes and a literal recap-write path", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000 });
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(String(send.mock.calls[0]![1])).toContain("To avoid one cause of approval prompts, which hold /compact until someone answers them: write files with your file-edit tool (acceptEdits covers your working directory, not a preparation root outside it), and if you publish a seat recap, run rig context recap-write --file with the file's literal absolute path, with no shell variable assignment or command substitution, so an allow rule for rig commands can match it.");
});
it("when the idle wait and the /compact send disagree, each round pauses a reading interval, bounded by the deadline", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
// The idle wait reads idle (a hook), while every /compact finds the pane at a prompt and writes nothing.
send.mockImplementation(async (_s: string, text: string) => text.startsWith("/compact")
? { ok: false, sent: false, inputPhase: "not_reached", reason: "target_needs_input", activity: { state: "needs_input" } }
: { ok: true });
let clock = 1_700_000_000_000;
const sleeps: number[] = [];
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { manualPrepWaitMs: 20_000, now: () => clock,
sleep: async (ms) => { sleeps.push(ms); clock += ms; } });
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(outcome).toMatchObject({ triggered: false, reason: "preparation_needs_input" });
const compacts = send.mock.calls.filter((call) => String(call[1]).startsWith("/compact")).length;
expect(compacts).toBeGreaterThan(1);
expect(compacts).toBeLessThanOrEqual(20_000 / MANUAL_SEAT_READ_INTERVAL_MS + 1);
// Every deferred send is followed by a pause, the last one running into the deadline.
expect(sleeps.filter((ms) => ms === MANUAL_SEAT_READ_INTERVAL_MS).length).toBe(compacts);
});
it("a /compact refused before input because the seat reached a prompt waits again within the deadline, then compacts", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
send.mockImplementationOnce(async () => ({ ok: true })); // the preparation request lands
// The seat read idle, then its preparation turn reached a permission prompt before /compact: refused, nothing written.
send.mockImplementationOnce(async () => ({ ok: false, sent: false, inputPhase: "not_reached", reason: "target_needs_input" }));
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000, sleep: async () => {} });
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
const texts = send.mock.calls.map((call) => String(call[1]));
expect(texts.filter((text) => text.startsWith("/compact"))).toHaveLength(2);
expect(enforcer.getPreparationState(SEAT)).toMatchObject({ status: "compact-sent" });
});
it("a seat still at a prompt when the deadline passes stops naming the prompt, and --state showed what it waited on", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
let clock = 1_700_000_000_000;
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { manualPrepWaitMs: 1000, now: () => clock, sleep: async (ms) => { clock += ms; } });
const seen: unknown[] = [];
transport.waitUntilIdle = async () => {
seen.push(enforcer.getPreparationState(SEAT)?.awaitingInput);
return { ok: false, reason: "target_needs_input", error: "fixture", activity: { state: "needs_input" } as any, waitedMs: 0, attempts: 1 } as any;
};
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(outcome).toMatchObject({ triggered: false, stage: "skipped-or-failed", reason: "preparation_needs_input",
preparation: { delivery: "delivered", compact: "not_sent" } });
expect(seen.slice(1)).toContainEqual({ since: expect.any(Number), reason: "target_needs_input", observedAt: expect.any(Number), current: true });
expect(send.mock.calls.filter((call) => String(call[1]).startsWith("/compact"))).toHaveLength(0);
});
it("bounded to the triggered seat: only that seat is sent to; no other seat gets manual state", async () => {
@@ -0,0 +1,557 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import os from "node:os";
import type { CodexFilesystemPermissions } from "../src/domain/codex-member-permissions.js";
import { decideCodexFilesystemPolicy, verifyCodexFilesystemPolicy } from "../src/domain/codex-filesystem-preflight.js";
import { readCodexConfiguration } from "../src/domain/codex-network-default.js";
vi.mock("../src/domain/codex-network-default.js", () => ({ readCodexConfiguration: vi.fn() }));
afterEach(() => vi.unstubAllEnvs());
const permissions: CodexFilesystemPermissions = {
approval: "never", filesystem: { ":minimal": "read", "/work": "write", "/work/hidden": "deny" }, network_access: false,
};
function config() {
return { config: { approval_policy: "never", default_permissions: "openrig-seat", sandbox_mode: null,
sandbox_workspace_write: null, profile: null, permissions: { "openrig-seat": {
description: null, extends: null, workspace_roots: null,
filesystem: { "/work/hidden": "deny", "/work": "write", ":minimal": "read", glob_scan_max_depth: null },
network: { enabled: false, allow_local_binding: null, allow_upstream_proxy: null,
dangerously_allow_all_unix_sockets: null, dangerously_allow_non_loopback_proxy: null, domains: null,
enable_socks5: null, enable_socks5_udp: null, mitm: null, mode: null, proxy_url: null, socks_url: null, unix_sockets: null },
} } }, origins: {}, layers: [] };
}
function decide(configRead: unknown = config(), requirementsRead: unknown = { requirements: null }) {
return decideCodexFilesystemPolicy({ permissions, configRead, requirementsRead });
}
describe("Codex seat filesystem configuration preflight", () => {
it("accepts the exact authored policy regardless of mapping key order", () => {
expect(decide()).toEqual({ ok: true, verified: true });
});
it("accepts the native nullable managed-policy response without overriding requirements", () => {
expect(decide(config(), { requirements: { allowedApprovalPolicies: null, allowedApprovalsReviewers: null,
allowedSandboxModes: null, allowedPermissionProfiles: null, defaultPermissions: null,
featureRequirements: null, application: null, autoReview: null, network: null,
futureFilesystemPolicy: null } })).toEqual({ ok: true, verified: true });
});
it("allows other permission profiles in the shared home without selecting them", () => {
const read = config();
Object.assign(read.config.permissions, { "other-seat": { filesystem: { ":root": "write" } } });
expect(decide(read)).toEqual({ ok: true, verified: true });
});
it.each([
["lower-layer extra write", { filesystem: { ...permissions.filesystem, "/poison": "write" }, network: { enabled: false } }],
["changed deny", { filesystem: { ...permissions.filesystem, "/work/hidden": "read" }, network: { enabled: false } }],
["missing deny", { filesystem: { ":minimal": "read", "/work": "write" }, network: { enabled: false } }],
["network changed", { filesystem: permissions.filesystem, network: { enabled: true } }],
["empty filesystem", { filesystem: {}, network: { enabled: false } }],
])("refuses %s without disclosing its values", (_label, profile) => {
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": profile });
expect(decide(read)).toMatchObject({ ok: false, error: expect.any(String) });
});
it.each([
["wrong profile selection", { default_permissions: "other" }],
["wrong approval", { approval_policy: "on-request" }],
["missing profile map", { permissions: null }],
])("refuses %s", (_label, patch) => {
const read = config();
Object.assign(read.config, patch);
expect(decide(read)).toMatchObject({ ok: false });
});
it.each([undefined, null, {}, { config: [] }])("reports missing or malformed configuration %#", read => {
expect(decideCodexFilesystemPolicy({ permissions, configRead: read, requirementsRead: { requirements: null } })).toMatchObject({ ok: true, verified: false });
});
it("accepts unrelated managed model settings and an approval allowlist that includes the choice", () => {
expect(decide(config(), { requirements: { modelProvider: "openai", allowedApprovalPolicies: ["on-request", "never"],
allowedPermissionProfiles: null, defaultPermissions: null, network: null, featureRequirements: null } })).toEqual({ ok: true, verified: true });
});
it.each([
{ allowedApprovalPolicies: ["on-request"] },
{ allowedPermissionProfiles: { "other": true } },
{ allowedPermissionProfiles: { "openrig-seat": false } },
])("does not bypass managed policy %#", requirements => {
expect(decide(config(), { requirements })).toMatchObject({ ok: false });
});
it.each([undefined, null, {}, { requirements: [] }, { requirements: null, futurePolicy: true }])("reports unreadable requirements %#", read => {
expect(decideCodexFilesystemPolicy({ permissions, configRead: config(), requirementsRead: read })).toMatchObject({ ok: true, verified: false });
});
});
describe("authored ancestor and selector coverage matrix", () => {
const ancestors = {
"root read": { "/": "read" },
"root write": { "/": "write" },
"root deny": { "/": "deny" },
"native root deny": { ":root": "deny" },
"literal read": { ":root": "read", "/work/hidden": "read" },
"literal write": { ":root": "read", "/work/hidden": "write" },
"literal deny": { ":root": "read", "/work": "write", "/work/hidden": "deny" },
} satisfies Record<string, CodexFilesystemPermissions["filesystem"]>;
type Outcome = "accept" | "not-verified" | "refuse";
// Expected outcomes are authored from the rule, not computed by another
// coverage comparator. Columns: read, write, read+aliases, write+aliases.
// Authored uncertainty warns; lower-layer aliases cannot hide a known
// widening. A non-root deny wins; a root deny is a refinable default.
const matrix = [
["root read", "none", "accept", "refuse", "not-verified", "refuse"],
["root read", "read", "accept", "refuse", "not-verified", "refuse"],
["root read", "write", "accept", "not-verified", "not-verified", "not-verified"],
["root write", "none", "accept", "accept", "not-verified", "not-verified"],
["root write", "read", "accept", "accept", "not-verified", "not-verified"],
["root write", "write", "accept", "accept", "not-verified", "not-verified"],
["root deny", "none", "refuse", "refuse", "refuse", "refuse"],
["root deny", "read", "not-verified", "refuse", "not-verified", "refuse"],
["root deny", "write", "not-verified", "not-verified", "not-verified", "not-verified"],
["native root deny", "none", "refuse", "refuse", "refuse", "refuse"],
["native root deny", "read", "not-verified", "refuse", "not-verified", "refuse"],
["native root deny", "write", "not-verified", "not-verified", "not-verified", "not-verified"],
["literal read", "none", "accept", "refuse", "not-verified", "refuse"],
["literal read", "read", "accept", "refuse", "not-verified", "refuse"],
["literal read", "write", "accept", "not-verified", "not-verified", "not-verified"],
["literal write", "none", "accept", "accept", "not-verified", "not-verified"],
["literal write", "read", "accept", "accept", "not-verified", "not-verified"],
["literal write", "write", "accept", "accept", "not-verified", "not-verified"],
["literal deny", "none", "refuse", "refuse", "refuse", "refuse"],
["literal deny", "read", "refuse", "refuse", "refuse", "refuse"],
["literal deny", "write", "refuse", "refuse", "refuse", "refuse"],
] satisfies [keyof typeof ancestors, "none" | "read" | "write", Outcome, Outcome, Outcome, Outcome][];
const cases = matrix.flatMap(([ancestor, selector, read, write, readAliases, writeAliases]) => [
{ ancestor, selector, grant: "read", aliases: false, outcome: read },
{ ancestor, selector, grant: "write", aliases: false, outcome: write },
{ ancestor, selector, grant: "read", aliases: true, outcome: readAliases },
{ ancestor, selector, grant: "write", aliases: true, outcome: writeAliases },
]);
it.each(cases)("$ancestor; selector=$selector; grant=$grant; aliases=$aliases => $outcome", ({ ancestor, selector, grant, aliases, outcome }) => {
const own: CodexFilesystemPermissions = { ...permissions, filesystem: {
...ancestors[ancestor], ...(selector === "none" ? {} : { ":workspace_roots": selector }),
} };
const aliasAccess = selector === "none" ? "read" : selector;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: {
...own.filesystem, ...(aliases ? { ":workspace_roots": aliasAccess, ":project_roots": aliasAccess } : {}),
"/work/hidden/secret": grant,
}, network: { enabled: false } } });
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
if (outcome === "refuse") expect(result).toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
else if (outcome === "not-verified") expect(result).toMatchObject({ ok: true, verified: false });
else expect(result).toEqual({ ok: true, verified: true });
});
});
describe("root deny refinements", () => {
it.each(["/", ":root"])('keeps selector overlap unverified beneath %s deny', root => {
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":slash_tmp": "write" } };
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: true, verified: false });
});
it.each([
["/", "/Users/x/.ssh/key"], [":root", "/Users/x/.ssh/key"],
["/", "/home/x/.ssh/key"], [":root", "/home/x/.ssh/key"],
])('refuses a user-data read outside minimal defaults beneath %s deny at %s', (root, key) => {
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read", "/work": "write" } };
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, [key]: "read" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: false, error: expect.stringContaining(JSON.stringify(key)) });
});
it.each(["/", ":root"])('does not claim to expand minimal system reads beneath %s deny', root => {
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read" } };
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/usr/bin/tool": "read" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: true, verified: false });
});
it.each(["/", ":root"])('retains other authored coverage for user data beneath %s deny', root => {
const key = "/Users/x/.ssh/key";
for (const [rule, expected] of [[":workspace_roots", "not-verified"], ["/Users/x", "accept"]] as const) {
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read", [rule]: "read" } };
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, [key]: "read" }, network: { enabled: false } } });
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
if (expected === "accept") expect(result).toEqual({ ok: true, verified: true });
else expect(result).toMatchObject({ ok: true, verified: false });
}
});
});
describe("minimal coverage without an authored root rule", () => {
it.each(["/Users/x/.ssh/key", "/users/x/.ssh/key", "/HOME/x/.ssh/key"])("refuses an uncovered user-data read at %s", key => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "read" });
const result = decide(read);
expect(result).toMatchObject({ ok: false, error: expect.stringContaining(JSON.stringify(key)) });
if (!result.ok) expect(result.error).toContain("Fix:");
});
it("keeps an unexpanded system read unverified and names its key", () => {
const key = "/opt/x";
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "read" });
expect(decide(read)).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining(JSON.stringify(key)) });
});
});
describe("Codex seat filesystem verifier reader boundary", () => {
it.each([["env", "CODEX_HOME=/other"], ["custom-wrapper"]])("reports a launch wrapper without certifying a different configuration %#", async launchWrapper => {
vi.mocked(readCodexConfiguration).mockClear();
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", launchWrapper }))
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("launch_wrapper") });
expect(readCodexConfiguration).not.toHaveBeenCalled();
});
it.each(["", "relative/home", "/absolute\nother"])("reports an unestablished account home %# before reading", async codexHome => {
vi.mocked(readCodexConfiguration).mockClear();
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome })).toMatchObject({ ok: true, verified: false });
expect(readCodexConfiguration).not.toHaveBeenCalled();
});
it("uses the seat home and candidate invocation policy, never sandbox or per-command selection overrides", async () => {
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: true, configRead: config(), requirementsRead: { requirements: null } });
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", launchPath: "/bin" })).toEqual({ ok: true, verified: true });
expect(readCodexConfiguration).toHaveBeenLastCalledWith({ cwd: "/work", codexHome: "/account", launchPath: "/bin", args: [
"-c", 'approval_policy="never"', "-c", 'default_permissions="openrig-seat"', "-c",
'permissions={"openrig-seat"={filesystem={":minimal"="read","/work"="write","/work/hidden"="deny"},network={enabled=false}}}',
"-c", "features.plugins=false", "-c", "features.apps=false",
] });
});
it("returns an unavailable read as unverified", async () => {
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: false, reason: "native read timed out" });
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" })).toMatchObject({ ok: true, verified: false });
});
it("names the filesystem rule, access on each side and a remedy without dumping configuration", () => {
const read = config();
read.config.permissions["openrig-seat"].filesystem["/work/hidden"] = "read";
Object.assign(read.config, { privateSetting: "do-not-print-this-value" });
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringMatching(/\/work\/hidden.*agent.*deny.*(?:Codex|layer).*read/i) });
const decision = decide(read);
if (decision.ok) throw new Error("fixture must conflict");
expect(decision.error).toContain("\nFix:");
expect(decision.error).not.toContain("do-not-print-this-value");
read.config.default_permissions = "private-profile-selection";
const selection = decide(read);
if (selection.ok) throw new Error("fixture must conflict");
expect(selection.error).toContain("default_permissions");
expect(selection.error).toContain("\nFix:");
expect(selection.error).not.toContain("private-profile-selection");
});
it("returns a thrown reader failure without exposing arbitrary error contents", async () => {
vi.mocked(readCodexConfiguration).mockRejectedValueOnce(new Error("do not print this"));
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" })).toEqual({
ok: true, verified: false, reason: "Native Codex configuration reader failed",
});
});
it("resolves the normal account home without an explicit CODEX_HOME", async () => {
vi.stubEnv("CODEX_HOME", undefined);
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: true, configRead: config(), requirementsRead: { requirements: null } });
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work" })).toEqual({ ok: true, verified: true });
expect(readCodexConfiguration).toHaveBeenLastCalledWith(expect.objectContaining({ codexHome: `${os.homedir()}/.codex` }));
});
it("keeps a named profile unverified instead of certifying the base configuration", async () => {
vi.mocked(readCodexConfiguration).mockClear();
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", codexConfigProfile: "model-only" }))
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("codex_config_profile") });
expect(readCodexConfiguration).not.toHaveBeenCalled();
});
});
describe("compatible policies and inconclusive reads", () => {
it.each([":root", ":minimal"])(
"refuses a known extra write grant at %s, including beside an unfamiliar representation", key => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "write", future: { access: "future" } });
expect(decide(read)).toMatchObject({ ok: false });
});
it("refuses full-root read beyond minimal read", () => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":root": "read" });
expect(decide(read)).toMatchObject({ ok: false });
});
it.each([":workspace_roots", ":project_roots", ":tmpdir", ":slash_tmp", "~/x"])(
"leaves extra %s coverage unverified when an authored path may already cover it", key => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "write" });
expect(decide(read)).toMatchObject({ ok: true, verified: false });
});
it("does not refuse a potentially redundant slash_tmp grant beside an authored /tmp grant", () => {
const own = { ...permissions, filesystem: { ":root": "read", "/tmp": "write" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":slash_tmp": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: true, verified: false });
});
it("still refuses a selector write when no authored rule could cover write access", () => {
const own = { ...permissions, filesystem: { ":root": "read" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":slash_tmp": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: false });
});
it("does not invent a selector conflict by collapsing ambiguous coverage aliases", () => {
const own = { ...permissions, filesystem: { ":root": "read", ":project_roots": "write", ":workspace_roots": "deny" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":tmpdir": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("effective configuration grants") });
});
it.each([
{ ":workspace_roots": "read", ":project_roots": "read" },
{ ":project_roots": "read", ":workspace_roots": "read" },
])("refuses an outside write despite lower-layer duplicate aliases %#", aliases => {
const own = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ...aliases, "/outside": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: false, error: expect.stringContaining('"/outside"') });
});
it("leaves only the lower-layer duplicate aliases unverified when there is no separate conflict", () => {
const own = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem,
":workspace_roots": "read", ":project_roots": "read" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: true, verified: false });
});
it("leaves an extra absolute rule unverified when a native selector may cover it", () => {
const own = { ...permissions, filesystem: { ":root": "read", ":tmpdir": "write" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: true, verified: false });
const literal = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...literal.filesystem, "/outside": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: literal, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: false });
});
it("checks authored selectors before refusing a write beneath a read-only literal ancestor", () => {
const own = { ...permissions, filesystem: { "/": "read", ":slash_tmp": "write" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
expect(result).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining('"/tmp/child"') });
expect(result).toMatchObject({ reason: expect.stringContaining("write") });
});
it("still refuses a direct literal-root widening beside an authored selector", () => {
const own = { ...permissions, filesystem: { "/": "read", ":slash_tmp": "write" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: false, error: expect.stringContaining('"/"') });
});
it("refuses a read beneath the fixture's authored deny despite minimal read coverage", () => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work/hidden/secret": "read" });
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
});
it("refuses a write beneath an authored deny despite an authored write selector", () => {
const own = { ...permissions, filesystem: { ...permissions.filesystem, ":workspace_roots": "write" } } satisfies CodexFilesystemPermissions;
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/work/hidden/secret": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
});
it("names the extra selector and access in an inconclusive coverage warning", () => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":slash_tmp": "write" });
const result = decide(read);
expect(result).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining('":slash_tmp"') });
expect(result).toMatchObject({ reason: expect.stringContaining("write") });
});
it.each(["never", "on-request"] satisfies CodexFilesystemPermissions["approval"][])(
"reports an unfamiliar approval string as unverified for authored %s", approval => {
const read = config();
read.config.approval_policy = "future-native-approval";
expect(decideCodexFilesystemPolicy({ permissions: { ...permissions, approval }, configRead: read, requirementsRead: { requirements: null } }))
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("approval") });
});
it("still refuses a recognized untrusted approval instead of treating it as a future spelling", () => {
const read = config();
read.config.approval_policy = "untrusted";
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining("approval_policy") });
});
it("does not let an unfamiliar approval hide a known filesystem or network conflict", () => {
const read = config();
read.config.approval_policy = "future-native-approval";
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/outside": "write" });
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining('"/outside"') });
Object.assign(read.config.permissions["openrig-seat"], { filesystem: permissions.filesystem, network: { enabled: true } });
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining("network.enabled") });
});
it.each([
{ allowedApprovalPolicies: ["on-request"] },
{ allowedPermissionProfiles: { "openrig-seat": false } },
])("reports an excluding managed allowlist ahead of effective selection or approval mismatches %#", requirements => {
const read = config();
Object.assign(read.config, { default_permissions: "other", approval_policy: "on-request" });
expect(decide(read, { requirements })).toMatchObject({ ok: false, error: expect.stringContaining("managed allowlist") });
read.config.default_permissions = "openrig-seat";
expect(decide(read, { requirements })).toMatchObject({ ok: false, error: expect.stringContaining("managed allowlist") });
});
it.each(["default_permissions", "approval_policy"])("reports absent or null %s without inventing a conflict", key => {
for (const value of [undefined, null]) {
const read = config();
Object.assign(read.config, { [key]: value });
expect(decide(read)).toMatchObject({ ok: true, verified: false });
}
});
it("reports a new profile representation without a filesystem field but keeps independent conflicts", () => {
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { futureFilesystem: { access: "new-format" }, network: { enabled: false } } });
expect(decide(read)).toMatchObject({ ok: true, verified: false });
read.config.permissions["openrig-seat"].network.enabled = true;
expect(decide(read)).toMatchObject({ ok: false });
});
it("accepts an allowed effective selection even when the managed fallback selects another profile", () => {
expect(decide(config(), { requirements: { defaultPermissions: "private-fallback-value",
allowedPermissionProfiles: { "openrig-seat": true } } })).toEqual({ ok: true, verified: true });
});
it("accepts a known selector grant already covered by an authored full-root grant", () => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":root": "write", ":workspace_roots": "write", "~/x": "read" });
expect(decideCodexFilesystemPolicy({ permissions: { ...permissions, filesystem: { ...permissions.filesystem, ":root": "write" } },
configRead: read, requirementsRead: { requirements: null } })).toEqual({ ok: true, verified: true });
});
it.each(["default_permissions", "approval_policy"])("reports a future %s representation without inventing a conflict", key => {
const read = config();
Object.assign(read.config, { [key]: { selection: "future-native-format" } });
expect(decide(read)).toMatchObject({ ok: true, verified: false });
});
it("refuses an explicitly excluded profile even beside unknown allowlist entries", () => {
expect(decide(config(), { requirements: { allowedPermissionProfiles: { "openrig-seat": false, future: { allowed: true } } } }))
.toMatchObject({ ok: false });
});
it.each([
{ filesystem: permissions.filesystem, network: { enabled: false }, extends: ":workspace" },
{ filesystem: permissions.filesystem, network: { enabled: false }, workspace_roots: { "/extra": true } },
{ filesystem: permissions.filesystem, network: { enabled: false }, futurePolicy: true },
{ filesystem: permissions.filesystem, network: { enabled: false, allow_local_binding: true } },
{ filesystem: { ":workspace_roots": { ".": "write" } }, network: { enabled: false } },
{ filesystem: { "/work": "allow" }, network: { enabled: false } },
])("reports unfamiliar selected policy options %#", profile => {
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": profile });
expect(decide(read)).toMatchObject({ ok: true, verified: false });
});
it.each([
{ allowedApprovalPolicies: "never" }, { featureRequirements: { use_linux_sandbox_bwrap: false } },
{ network: {} }, { futureFilesystemPolicy: true },
])("reports unsupported requirements without refusing %#", requirements => {
expect(decide(config(), { requirements })).toMatchObject({ ok: true, verified: false });
});
it("keeps positive configuration and managed conflicts visible alongside unknowns", () => {
const read = config();
read.config.default_permissions = "other";
expect(decide(read, { requirements: { futureManagedPolicy: true } })).toMatchObject({ ok: false });
expect(decide(config(), { requirements: { futureManagedPolicy: true, allowedApprovalPolicies: ["on-request"] } }))
.toMatchObject({ ok: false });
expect(decideCodexFilesystemPolicy({ permissions, configRead: null, requirementsRead: { requirements: { allowedPermissionProfiles: { "openrig-seat": false } } } }))
.toMatchObject({ ok: false });
});
it("accepts inert legacy settings, a model-only profile and harmless descriptions", () => {
const read = config();
Object.assign(read.config, { sandbox_mode: "read-only", sandbox_workspace_write: { network_access: true }, profile: "model-only" });
Object.assign(read.config.permissions["openrig-seat"], { description: "description only" });
expect(decide(read)).toEqual({ ok: true, verified: true });
});
it("treats every null profile field as absent, including future fields", () => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"], { futureProfile: null });
Object.assign(read.config.permissions["openrig-seat"].filesystem, { futureFilesystem: null });
Object.assign(read.config.permissions["openrig-seat"].network, { futureNetwork: null });
expect(decide(read)).toEqual({ ok: true, verified: true });
});
it("accepts a narrower filesystem policy and redundant subpath grants", () => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work": "read", "/work/child": "read" });
expect(decide(read)).toEqual({ ok: true, verified: true });
});
it("accepts matching managed selection and allowlists", () => {
expect(decide(config(), { requirements: { defaultPermissions: "openrig-seat",
allowedPermissionProfiles: { "openrig-seat": true }, allowedApprovalPolicies: ["on-request", "never"],
allowedSandboxModes: ["read-only"] } })).toEqual({ ok: true, verified: true });
});
it("compares the project_roots alias without silently collapsing conflicting spellings", () => {
const withAlias: CodexFilesystemPermissions = { approval: "never", filesystem: { ":project_roots": "write" } };
const read = config();
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ":workspace_roots": "write" }, network: { enabled: false } } });
expect(decideCodexFilesystemPolicy({ permissions: withAlias, configRead: read, requirementsRead: { requirements: null } }))
.toEqual({ ok: true, verified: true });
expect(decideCodexFilesystemPolicy({ permissions: { ...withAlias, filesystem: { ":workspace_roots": "deny", ":project_roots": "write" } },
configRead: read, requirementsRead: { requirements: null } })).toMatchObject({ ok: true, verified: false });
});
it.each([
{ configRead: null, requirementsRead: { requirements: null } },
{ configRead: config(), requirementsRead: { requirements: [] } },
{ configRead: config(), requirementsRead: { requirements: { futureManagedPolicy: true } } },
])("reports an unreadable shape or unknown requirement without refusing %#", request => {
expect(decideCodexFilesystemPolicy({ permissions, ...request })).toMatchObject({ ok: true, verified: false, reason: expect.any(String) });
});
it.each(["future-access", { read: ["/nested"] }])("reports unfamiliar filesystem representation without refusing %j", access => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work/hidden": access });
expect(decide(read)).toMatchObject({ ok: true, verified: false });
});
it("still refuses a concrete extra write grant alongside an unfamiliar representation", () => {
const read = config();
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/poison": "write", "/future": { read: ["/nested"] } });
expect(decide(read)).toMatchObject({ ok: false });
});
it("reports a reader timeout without dropping or replacing the invocation policy", async () => {
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: false, reason: "native read timed out" });
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" }))
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("timed out") });
});
});
+40 -8
View File
@@ -9,11 +9,12 @@
// values THIS reproduction emits and the perturbation tests prove the identity fields are folded in
// — but the DEFINITIVE correctness check is a byte-for-byte read-back of a real Codex `[hooks.state]`
// after `/hooks`→"Trust all" (the QA VM proof). Until that read-back confirms them, the exact
// key_source (canonicalized config path) and positional indices are PROVISIONAL. A mismatch is
// key_source (resolved folder plus file name) and positional indices are PROVISIONAL. A mismatch is
// fail-safe (gate reappears + Layer-2 keystroke floor), never a false-trusted run.
import { describe, it, expect, vi } from "vitest";
import nodeFs from "node:fs";
import nodePath from "node:path";
import nodeOs from "node:os";
import { parse as parseToml } from "smol-toml";
import {
CodexRuntimeAdapter,
@@ -29,8 +30,8 @@ const COMMAND = `node "${RELAY}"`;
const EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest"] as const;
// PIN TO VM READ-BACK (qa gate) — PROVISIONAL from open-source reproduction (PR #20321 / commits
// 0452dca, ffcc9cc; codex-rs fingerprint.rs version_for_toml). keySource here is the NON-canonical
// mock path `/home/test/.codex/config.toml`; the real key_source is std::fs::canonicalize(config.toml).
// 0452dca, ffcc9cc; codex-rs fingerprint.rs version_for_toml). Codex0.167.2 hooks/list confirms
// the source folder is resolved, while the config file keeps its own name even when it is linked.
const PROVISIONAL_FIXTURE: Record<(typeof EVENTS)[number], { key: string; hash: string }> = {
SessionStart: {
key: `${CONFIG}:session_start:0:0`,
@@ -267,22 +268,53 @@ describe("OPR.0.4.3.33 — provisioning-seam coupling (ensureCodexActivityHooks
expect(fs._store[CONFIG]).toBe(before);
});
it("uses native canonical path casing for Codex hook trust keys", () => {
const canonicalConfigPath = "C:\\Users\\Test\\.codex\\config.toml";
const nativeRealpath = vi.spyOn(nodeFs.realpathSync, "native").mockReturnValue(canonicalConfigPath);
it("resolves the source folder while retaining the config file name", () => {
const canonicalHome = nodePath.join("/resolved", "codex");
const nativeRealpath = vi.spyOn(nodeFs.realpathSync, "native").mockReturnValue(canonicalHome);
try {
const fakeFs = mockCodexFs({ [RELAY]: "// relay" });
makeAdapter(fakeFs).ensureCodexActivityHooks();
const configPath = Object.keys(fakeFs._store).find((path) => path.endsWith("config.toml"))!;
const config = fakeFs._store[configPath]!;
expect(nativeRealpath).toHaveBeenCalledWith(configPath);
expect(config).toContain(`[hooks.state.${JSON.stringify(`${canonicalConfigPath}:session_start:0:0`)}]`);
expect(config).toContain(`[hooks.state.${JSON.stringify(`${nodePath.join(canonicalHome, "config.toml")}:session_start:0:0`)}]`);
expect(config).not.toContain(`[hooks.state.${JSON.stringify(`${configPath}:session_start:0:0`)}]`);
} finally {
nativeRealpath.mockRestore();
}
});
it.skipIf(process.platform === "win32").each([
{ json: false, linkedHome: false }, { json: true, linkedHome: false },
{ json: false, linkedHome: true }, { json: true, linkedHome: true },
])("trusts file names inside the resolved home (JSON: $json, linked home: $linkedHome)", ({ json, linkedHome }) => {
const root = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), "codex-linked-trust-"));
try {
const home = nodePath.join(root, "home"); nodeFs.mkdirSync(home);
const selectedHome = linkedHome ? nodePath.join(root, "linked-home") : home;
if (linkedHome) nodeFs.symlinkSync(home, selectedHome);
const source = nodePath.join(root, "source.toml"); nodeFs.writeFileSync(source, 'model = "fixture"\n');
nodeFs.symlinkSync(source, nodePath.join(home, "config.toml"));
const relay = nodePath.join(root, "relay.cjs"); nodeFs.writeFileSync(relay, "// never executed");
if (json) {
const hooks = nodePath.join(root, "source-hooks.json"); nodeFs.writeFileSync(hooks, '{"hooks":{}}\n');
nodeFs.symlinkSync(hooks, nodePath.join(home, "hooks.json"));
}
const adapter = new CodexRuntimeAdapter({ tmux: mockTmux(), activityRelayPath: relay, fsOps: {
exists: nodeFs.existsSync, readFile: p => nodeFs.readFileSync(p, "utf8"),
writeFile: (p,c) => nodeFs.writeFileSync(p,c), mkdirp: p => { nodeFs.mkdirSync(p,{ recursive:true }); },
} });
adapter.ensureCodexActivityHooks(selectedHome);
const hooks = parseToml(nodeFs.readFileSync(source,"utf8")).hooks;
if (!hooks || typeof hooks !== "object") throw new Error("missing hook table");
const states: unknown = Reflect.get(hooks, "state");
if (!states || typeof states !== "object") throw new Error("missing trust table");
expect(Object.keys(states).sort()).toEqual(["permission_request", "session_start", "stop", "user_prompt_submit"]
.map(event => `${nodePath.join(nodeFs.realpathSync.native(home), json ? "hooks.json" : "config.toml")}:${event}:0:0`).sort());
expect(nodeFs.lstatSync(nodePath.join(home, "config.toml")).isSymbolicLink()).toBe(true);
} finally { nodeFs.rmSync(root,{recursive:true,force:true}); }
});
it("is idempotent — re-running produces byte-identical config (no duplicate trust tables)", () => {
const fs = mockCodexFs({ [RELAY]: "// relay" });
const adapter = makeAdapter(fs);
@@ -82,6 +82,71 @@ describe("the typed Codex envelope", () => {
});
});
describe("a seat's explicit filesystem policy", () => {
it("accepts the native project_roots alias without changing its authored spelling", () => {
expect(validateCodexMemberPermissions({ approval: "never", filesystem: { ":project_roots": "write" } }))
.toEqual({ approval: "never", filesystem: { ":project_roots": "write" }, network_access: false });
});
const policy = { approval: "never", filesystem: {
":minimal": "read", ":workspace_roots": "read", "/lease/a": "write", "/lease/a/private": "deny",
}, network_access: false };
it("accepts native read, write and deny entries without a legacy sandbox selection", () => {
expect(validateCodexMemberPermissions(policy)).toEqual(policy);
expect(validateCodexMemberPermissions({ approval: "on-request", filesystem: { ":minimal": "read" } }))
.toEqual({ approval: "on-request", filesystem: { ":minimal": "read" }, network_access: false });
});
it("keeps two seats' filesystem choices separate and clears only the selected seat", async () => {
const f = fixture();
const other = { approval: "on-request", filesystem: { ":minimal": "read", "/lease/b": "write" }, network_access: false };
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true, to: policy });
expect(await f.service.setPermissions({ ...set(other), seatRef: "dev-b@codex-team" })).toMatchObject({ ok: true, to: other });
const store = new NativePermissionStore(f.db);
expect(store.launchOverride(f.a.id, "codex").codexPermissions).toEqual(policy);
expect(store.launchOverride(f.b.id, "codex").codexPermissions).toEqual(other);
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", mode: "inherit", actor: "operator@codex-team", reason: "Clear only A" }))
.toMatchObject({ ok: true });
expect(readCodexMemberPermissions(f.db, f.a.id)).toBeUndefined();
expect(store.launchOverride(f.b.id, "codex").codexPermissions).toEqual(other);
});
it("allows named-profile combinations in either setter direction and keeps the authored policy", async () => {
const f = fixture();
await f.service.setPermissions(set(reader));
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "named", actor: "operator@codex-team", reason: "Select" }))
.toMatchObject({ ok: true });
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true });
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(policy);
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "inherit", actor: "operator@codex-team", reason: "Clear" }))
.toMatchObject({ ok: true });
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true });
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "named", actor: "operator@codex-team", reason: "Conflict" }))
.toMatchObject({ ok: true });
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(policy);
});
it.each([
{ ...policy, approval: "untrusted" },
{ ...policy, sandbox: "workspace-write" },
{ ...policy, writable_roots: ["/another/root"] },
{ ...policy, exclude_slash_tmp: true },
{ ...policy, filesystem: {} },
{ ...policy, filesystem: { "relative/path": "deny" } },
{ ...policy, filesystem: { ":unknown": "write" } },
{ ...policy, filesystem: { "/a\nb": "deny" } },
{ ...policy, filesystem: { "/private": "none" } },
{ ...policy, filesystem: { "/private": true } },
{ ...policy, network_access: "yes" },
])("rejects incomplete or mixed policy without replacing a saved choice: %j", async (invalid) => {
expect(() => validateCodexMemberPermissions(invalid)).toThrow();
const f = fixture();
expect(await f.service.setPermissions(set(reader))).toMatchObject({ ok: true });
expect(await f.service.setPermissions(set(invalid))).toMatchObject({ ok: false });
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(reader);
});
});
describe("a Codex seat's current envelope (offline, no native enforcement claim)", () => {
it("gives two seats in one folder their own envelopes, as the seat's explicit choice", async () => {
const f = fixture();
@@ -249,7 +314,7 @@ describe("a Codex seat's current profile", () => {
});
describe("both writers: the spec at creation, the setter afterwards, every launch reading the seat's record", () => {
const authored = (profile: string, permissions?: CodexMemberPermissions) => ({ version: "0.2", name: "authored", pods: [{ id: "dev", label: "Dev", members: [
const authored = (profile: string | undefined, permissions?: CodexMemberPermissions) => ({ version: "0.2", name: "authored", pods: [{ id: "dev", label: "Dev", members: [
{ id: "impl", agent_ref: "local:impl.agent.yaml", profile: "default", runtime: "codex", cwd: "/same/project", codex_config_profile: profile,
...(permissions ? { codex_permissions: permissions } : {}) },
], edges: [] }], edges: [] });
@@ -292,6 +357,27 @@ describe("both writers: the spec at creation, the setter afterwards, every launc
expect(RigSpecSchema.validate(wrongRuntime).errors.join(" ")).toContain("codex_permissions");
});
it("materializes and exports the filesystem choice with a named profile", async () => {
const f = fixture();
const policy: CodexMemberPermissions = { approval: "never", filesystem: { ":minimal": "read", "/lease/a": "write", "/lease/a/private": "deny" }, network_access: false };
const raw = authored("model-only", policy);
expect(RigSpecSchema.validate(raw).valid).toBe(true);
const { setup, node, rigId } = await materialize(f.db, raw);
expect(readCodexMemberPermissions(f.db, node.id)).toEqual(policy);
const exported = new RigSpecExporter({ rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, podRepo: new PodRepository(f.db) }).exportRig(rigId);
const decoded = RigSpecSchema.normalize(RigSpecCodec.parse(RigSpecCodec.serialize(exported as import("../src/domain/types.js").RigSpec)));
expect(decoded.pods[0]!.members[0]!.codexPermissions).toEqual(policy);
expect(decoded.pods[0]!.members[0]!.codexConfigProfile).toBe("model-only");
setup.sessionRegistry.registerSession(node.id, "dev-impl@authored");
const service = new SeatLifecycleService({ db: f.db, rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, eventBus: setup.eventBus,
tmuxAdapter: {} as TmuxAdapter });
expect(await service.setPermissions({ seatRef: "dev-impl@authored", codexProfile: "other-model", actor: "operator@authored", reason: "Model selection" }))
.toMatchObject({ ok: true });
expect(await service.setPermissions({ seatRef: "dev-impl@authored", codexPermissions: policy, actor: "operator@authored", reason: "Keep policy" }))
.toMatchObject({ ok: true });
expect(readCodexMemberPermissions(f.db, node.id)).toEqual(policy);
});
it("restores with the seat's current profile, so an older snapshot can't bring back a replaced one", async () => {
const f = fixture();
const setup = createTestApp(f.db);
@@ -322,6 +408,8 @@ describe("legacy flat specs carry the envelope too", () => {
const exported = new RigSpecExporter({ rigRepo: f.rigRepo, sessionRegistry: new SessionRegistry(f.db) }).exportRig(f.a.rigId);
const parsed = LegacyRigSpecCodec.parse(LegacyRigSpecCodec.serialize(exported as import("../src/domain/types.js").LegacyRigSpec));
expect(LegacyRigSpecSchema.normalize(parsed).nodes.find(n => n.codexPermissions)!.codexPermissions).toEqual(lease);
expect(LegacyRigSpecSchema.validate({ ...raw, nodes: [{ ...raw.nodes[0], codex_config_profile: "model-only",
codex_permissions: { approval: "never", filesystem: { ":project_roots": "write" } } }] }).errors).toEqual([]);
});
});
@@ -138,6 +138,28 @@ function makeValidFlagPolicy(posture: "floor" | "full_bypass"): string {
}
describe("Issue #2989 — native-resume-probe Codex show-and-label builder", () => {
describe.each(["floor", "full_bypass", "auto"] as const)("filesystem policy with %s posture fallback", (resolvedPosture) => {
it.each([false, true])("labels the omitted policy with recorded launch=%s, including wrappers", (hasRecord) => {
const display = buildCodexResumeDisplay("tok-filesystem", {
codexPermissions: { filesystem: { ":root": "read", "/work": "write" }, approval: "never" },
resolvedPosture,
recordedLaunch: hasRecord ? {
runtime: "codex", axis: "sandbox", state: "observed", value: "permissions:openrig-seat",
approvalPolicy: "never", reason: "emitted_launch_arguments",
} : null,
launchWrapper: ["env", "POLICY_WRAPPER=1"],
});
const fallback = buildCodexResumeDisplay("tok-filesystem", { resolvedPosture });
expect(display.command).toBe(fallback.command);
expect(display.command).not.toContain("default_permissions");
expect(display.label).toContain("shown command uses posture fallback");
expect(display.label).toContain("does not carry the agent's filesystem policy");
expect(display.label).toContain("omits launch_wrapper");
expect(display.label).not.toContain("a resume now uses");
expect(display.status).toBe(hasRecord ? "recorded_uncertain" : "no_recorded_launch");
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
@@ -7,6 +7,9 @@ import Database from "better-sqlite3";
import { parse as parseToml } from "smol-toml";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
import type { CodexFilesystemVerifier } from "../src/domain/codex-filesystem-preflight.js";
import { ObservationPass } from "../src/domain/process-observation-pass.js";
import { codexNonLoginShellArg } from "../src/domain/native-resume-probe.js";
import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
@@ -2842,6 +2845,203 @@ describe("a seat's own permission envelope (argv only, not native enforcement)",
});
});
describe("a seat's explicit filesystem policy (argv and native-read preflight, not enforcement)", () => {
const filesystemChoice = { approval: "never", filesystem: { ":minimal": "read", "/same": "write", "/same/private": "deny" }, network_access: false } satisfies import("../src/domain/codex-member-permissions.js").CodexFilesystemPermissions;
it.each(["fresh", "resume", "codex_id", "codex_last"])("keeps a wrapped filesystem launch and reports unverified policy on %s", async kind => {
const tmux = mockTmux();
const environment = new SeatLaunchEnvironment(tmux, { PATH: "/bin" }, "/same");
const command = vi.spyOn(environment, "command").mockImplementation(async (_session, core) => core);
const verifyFilesystemPolicy: CodexFilesystemVerifier = async request => request.launchWrapper?.length
? { ok: true, verified: false, reason: "launch_wrapper can change the checked configuration" } : { ok: true, verified: true };
const launchWrapper = ["env", "CODEX_HOME=/other"];
const options = { codexHome: "/shared-home", verifyFilesystemPolicy, seatLaunchEnvironment: environment,
sleep: async () => {} };
if (kind === "fresh" || kind === "resume") {
const adapter = new CodexRuntimeAdapter({ ...options, tmux, fsOps: mockFs() });
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice, launchWrapper };
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
expect(result).toMatchObject({ ok: true, appliedLaunch: { reason: expect.stringContaining("policy not verified: launch_wrapper") } });
} else {
const restore = new CodexResumeAdapter(tmux, { ...options, maxWaitMs: 0 });
expect(await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "floor", undefined, undefined,
undefined, undefined, undefined, undefined, filesystemChoice, undefined, launchWrapper))
.toMatchObject({ ok: true, appliedLaunch: { reason: expect.stringContaining("policy not verified: launch_wrapper") } });
}
expect(command).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('default_permissions="openrig-seat"'), expect.objectContaining({ launchWrapper }));
});
it.each(["fresh", "resume"])("emits the selected policy on %s without extra writable roots or shared policy files", async path => {
const tmux = mockTmux(), filesystem = mockFs();
const writes = vi.spyOn(filesystem, "writeFile");
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: true }));
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: filesystem, sleep: async () => {}, verifyFilesystemPolicy,
codexHome: "/shared-home", resolveGitAddDirs: async () => ["/same/.git"], prepareSeatFolder: () => "/seat-state" });
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice, launchPosture: "full_bypass" as const };
const result = path === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
expect(result).toMatchObject({ ok: true });
const command = path === "fresh" ? vi.mocked(tmux.sendText).mock.calls[0]![1] : "core" in result ? result.core : "";
expect(command).toContain('default_permissions="openrig-seat"');
expect(command).toContain('"/same/private"="deny"');
expect(command).not.toMatch(/--add-dir|danger-full-access|sandbox_workspace_write| -s /);
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ permissions: filesystemChoice, cwd: "/same", codexHome: "/shared-home" }));
expect(writes.mock.calls.every(([path]) => !path.endsWith(".codex/config.toml"))).toBe(true);
});
it("refuses a native-read mismatch before dispatching any command", async () => {
const tmux = mockTmux();
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy: async () => ({ ok: false, error: "lower profile adds a writable root" }) });
expect(await adapter.launchHarness({ ...makeBinding("/same"), codexPermissions: filesystemChoice }, { name: "dev-a@test-rig" }))
.toMatchObject({ ok: false, error: expect.stringContaining("lower profile") });
expect(tmux.sendText).not.toHaveBeenCalled();
});
it.each(["fresh", "resume"])("pins the normal account home on the native read and %s command", async kind => {
const previous = process.env.CODEX_HOME;
delete process.env.CODEX_HOME;
try {
const tmux = mockTmux();
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: false, reason: "native read timed out" }));
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: { ...mockFs(), homedir: "/normal-account" }, sleep: async () => {}, verifyFilesystemPolicy });
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice };
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
expect(result).toMatchObject({ ok: true, appliedLaunch: { reason: "policy not verified: native read timed out" } });
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ codexHome: "/normal-account/.codex" }));
const command = kind === "fresh" ? vi.mocked(tmux.sendText).mock.calls[0]?.[1] : "command" in result ? result.command : "";
expect(command).toContain("CODEX_HOME='/normal-account/.codex'");
expect(command).toContain('default_permissions="openrig-seat"');
expect(command).toContain('"/same/private"="deny"');
} finally {
if (previous === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previous;
}
});
it("reports a thrown native reader as unverified before handover without changing config or sending input", async () => {
const tmux = mockTmux(), filesystem = mockFs();
const write = vi.spyOn(filesystem, "writeFile");
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: filesystem,
verifyFilesystemPolicy: async () => { throw new Error("private error contents"); } });
expect(await adapter.preflightLaunch({ ...makeBinding(), codexPermissions: filesystemChoice }))
.toEqual({ ok: true, warnings: ["policy not verified: Native Codex configuration reader failed"] });
expect(write).not.toHaveBeenCalled();
expect(tmux.sendText).not.toHaveBeenCalled();
});
it("carries a pre-cutover check into one launch and checks again for a later launch", async () => {
const tmux = mockTmux();
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>()
.mockResolvedValueOnce({ ok: true, verified: false, reason: "native read timed out" })
.mockResolvedValueOnce({ ok: false, error: "later policy conflict" });
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {}, verifyFilesystemPolicy });
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice };
expect(await adapter.preflightLaunch(binding)).toMatchObject({ ok: true });
expect(tmux.sendText).not.toHaveBeenCalled();
expect(await adapter.launchHarness(binding, { name: "dev-a@test-rig" }))
.toMatchObject({ ok: true, warnings: ["policy not verified: native read timed out"],
appliedLaunch: { reason: "policy not verified: native read timed out" } });
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(1);
vi.mocked(tmux.sendText).mockClear();
expect(await adapter.launchHarness(binding, { name: "dev-a@test-rig" })).toMatchObject({ ok: false, error: "later policy conflict" });
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(2);
expect(tmux.sendText).not.toHaveBeenCalled();
});
it.each([
{ homes: ["/shared-home", "/shared-home"], cwds: ["/same", "/same"] },
{ homes: ["/shared-home", "/shared-home"], cwds: ["/project-a", "/project-b"] },
{ homes: ["/home-a", "/home-b"], cwds: ["/same", "/same"] },
] satisfies { homes: [string, string]; cwds: [string, string] }[])("keeps A/B/A policies separate with home/cwd sharing: %j", async ({ homes, cwds }) => {
const tmux = mockTmux();
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: true }));
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {}, verifyFilesystemPolicy,
seatHome: id => id === "A" ? homes[0] : homes[1] });
const other: import("../src/domain/codex-member-permissions.js").CodexFilesystemPermissions = {
approval: "on-request", filesystem: { ":minimal": "read", "/lease/b": "write" }, network_access: false,
};
for (const { id, cwd, permissions } of [
{ id: "A", cwd: cwds[0], permissions: filesystemChoice },
{ id: "B", cwd: cwds[1], permissions: other },
{ id: "A", cwd: cwds[0], permissions: filesystemChoice },
]) {
expect(await adapter.launchHarness({ ...makeBinding(cwd), nodeId: id,
codexPermissions: permissions }, { name: `dev-${id}@test-rig` })).toMatchObject({ ok: true });
}
const commands = vi.mocked(tmux.sendText).mock.calls.map(call => call[1]);
expect(commands).toHaveLength(3);
expect(commands[0]).toContain('"/same/private"="deny"');
expect(commands[0]).not.toContain('"/lease/b"');
expect(commands[1]).toContain('"/lease/b"="write"');
expect(commands[1]).not.toContain('"/same/private"');
expect(commands[2]).toEqual(commands[0]);
expect(verifyFilesystemPolicy.mock.calls.map(call => call[0])).toEqual([
expect.objectContaining({ codexHome: homes[0], cwd: cwds[0], permissions: filesystemChoice }),
expect.objectContaining({ codexHome: homes[1], cwd: cwds[1], permissions: other }),
expect.objectContaining({ codexHome: homes[0], cwd: cwds[0], permissions: filesystemChoice }),
]);
});
it("keeps a named configuration profile and the authored filesystem policy on resume", async () => {
const tmux = mockTmux();
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: false, reason: "profile reader unavailable" }));
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy,
verifyProfilePreflight: async profile => ({ ok: true, profile }) });
expect(await adapter.buildResumeCommand({ ...makeBinding("/same"), codexPermissions: filesystemChoice, codexConfigProfile: "named" },
{ name: "dev-a@test-rig", resumeToken: "thread-A" })).toMatchObject({ ok: true,
core: expect.stringMatching(/-p 'named' .*default_permissions="openrig-seat"/), appliedLaunch: { reason: "policy not verified: profile reader unavailable" } });
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ codexConfigProfile: "named" }));
expect(tmux.sendText).not.toHaveBeenCalled();
});
it.each(["codex_id", "codex_last"])("legacy restore %s carries the same policy and refuses a failed native read", async kind => {
const tmux = mockTmux();
const restore = new CodexResumeAdapter(tmux, { codexHome: "/shared-home", maxWaitMs: 0, sleep: async () => {},
verifyFilesystemPolicy: async () => ({ ok: true, verified: true }), resolveGitAddDirs: async () => ["/same/.git"] });
await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "full_bypass", undefined, undefined,
undefined, undefined, undefined, undefined, filesystemChoice);
const command = vi.mocked(tmux.sendText).mock.calls[0]![1];
expect(command).toContain('default_permissions="openrig-seat"');
expect(command).toContain('"/same/private"="deny"');
expect(command).not.toMatch(/--add-dir|danger-full-access|sandbox_workspace_write| -s /);
vi.mocked(tmux.sendText).mockClear();
const refusing = new CodexResumeAdapter(tmux, { verifyFilesystemPolicy: async () => ({ ok: false, error: "policy mismatch" }) });
expect(await refusing.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "floor", undefined, undefined,
undefined, undefined, undefined, undefined, filesystemChoice)).toMatchObject({ ok: false, message: "policy mismatch" });
expect(tmux.sendText).not.toHaveBeenCalled();
});
it.each(["fresh", "resume", "codex_id", "codex_last"])("checks a filesystem seat's named profile load before dispatch on %s", async kind => {
const tmux = mockTmux();
const verifyFilesystemPolicy: CodexFilesystemVerifier = async () => ({ ok: true, verified: false, reason: "profile configuration cannot be compared" });
if (kind === "fresh" || kind === "resume") {
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy,
verifyProfilePreflight: async profile => ({ ok: false, profile, error: "profile failed to load" }) });
const binding = { ...makeBinding(), codexPermissions: filesystemChoice, codexConfigProfile: "broken" };
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
expect(result).toMatchObject({ ok: false, error: expect.stringContaining("profile failed to load") });
} else {
const restore = new CodexResumeAdapter(tmux, { verifyFilesystemPolicy, exec: async () => { throw new Error("profile failed to load"); } });
expect(await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", "broken", "floor", undefined, undefined,
undefined, undefined, undefined, undefined, filesystemChoice)).toMatchObject({ ok: false, message: expect.stringContaining("profile failed to load") });
}
expect(tmux.sendText).not.toHaveBeenCalled();
});
it("refuses a filesystem seat's broken named profile during pre-cutover checks", async () => {
const tmux = mockTmux();
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(),
verifyFilesystemPolicy: async () => ({ ok: true, verified: false, reason: "profile configuration cannot be compared" }),
verifyProfilePreflight: async profile => ({ ok: false, profile, error: "profile failed to load" }) });
expect(await adapter.preflightLaunch({ ...makeBinding(), codexPermissions: filesystemChoice, codexConfigProfile: "broken" }))
.toMatchObject({ ok: false, error: expect.stringContaining("profile failed to load") });
expect(tmux.sendText).not.toHaveBeenCalled();
});
});
// #875 Codex: on a fresh launch of a supported Codex, OpenRig's managed guidance goes in as the launch's developer
// instructions (`-c developer_instructions`, Codex 0.164.0), so the person's tracked repository AGENTS.md and the Codex
// home are left alone. Anything else keeps the repository AGENTS.md, as before.
@@ -5,7 +5,7 @@ import {compactionRoutes} from '../src/routes/compaction.js';
import {mkdtempSync, mkdirSync, writeFileSync, renameSync, rmSync, readFileSync, symlinkSync, lstatSync, statSync, utimesSync, chmodSync, existsSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join, dirname} from 'node:path';
import {ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT} from '../src/domain/claude-compaction-enforcer.js';
import {ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT, MANUAL_SEAT_READ_INTERVAL_MS} from '../src/domain/claude-compaction-enforcer.js';
import {SessionTransport, CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS} from '../src/domain/session-transport.js';
import {SeatDeliveryGuard} from '../src/domain/seat-delivery-guard.js';
import {TmuxAdapter} from '../src/adapters/tmux.js';
@@ -498,7 +498,7 @@ it('manual route preserves a non-timeout idle-wait refusal after map completion'
it('preparation receipt: unresolved permission at deadline discloses the delivered prep and exact attempt',async()=>{
const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('needs_input');});
const res=await routeFixture(f)();const body=await res.json();
expect(res.status).toBe(409);expect(body.reason).toBe('map_complete_seat_never_idle');
expect(res.status).toBe(409);expect(body.reason).toBe('preparation_needs_input');expect(body.error).toContain('Answer the prompt in the seat');
expect(body.preparation).toMatchObject({attemptId:f.e.getPreparationState(seat)!.attemptId,delivery:'delivered'});
expect(body.error).toContain('Preparation was sent');expect(body.error).not.toContain('could not be sent');
expect(body.error).toContain('disarmed');expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1);
@@ -854,10 +854,74 @@ it('manual attention clears within the same attempt with one preparation and one
});
it('manual unresolved attention consumes the deadline and never revives',async()=>{
const f=fixture();const start=f.clock();f.onSleep(async()=>{stageMap(f);f.activity('needs_input');});
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_needs_input'});
expect(f.clock()).toBe(start+1000);expect(compacts(f)).toHaveLength(0);
f.activity('idle');stageMap(f);await f.e.maybeAutoCompact(input);expect(compacts(f)).toHaveLength(0);
});
// The manual wait reads the seat while the map is written too, and the attempt keeps only the latest reading: a prompt
// is reported while it's there and named at expiry only when the latest reading still shows it.
const readsBy=(ms:number)=>Math.floor(ms/MANUAL_SEAT_READ_INTERVAL_MS)+1;
it('manual: a prompt before the map is reported in --state and names the expiry',async()=>{
const f=fixture(20_000);const seen:any[]=[];
f.onSleep(async()=>{f.activity('needs_input');seen.push(f.e.getPreparationState(seat)?.awaitingInput);});
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_needs_input'});
expect(seen).toContainEqual(expect.objectContaining({reason:'target_needs_input',current:true}));
expect(compacts(f)).toHaveLength(0);
});
it('manual: a prompt before the map, answered, then the completed map compacts on the same attempt',async()=>{
const f=fixture(20_000);let n=0;let attempt='';const seen:any[]=[];
f.onSleep(async()=>{n++;attempt||=f.e.getPreparationState(seat)!.attemptId;seen.push(f.e.getPreparationState(seat)?.awaitingInput);
if(n<30)f.activity('needs_input');else{f.activity('idle');stageMap(f);}});
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(true);
expect(seen).toContainEqual(expect.objectContaining({current:true}));
expect(f.e.getPreparationState(seat)).toMatchObject({attemptId:attempt,status:'compact-sent'});
expect(compacts(f)).toHaveLength(1);
});
it.each(['running','unknown'] as const)('manual: a prompt answered into %s is not reported as current at expiry',async(after)=>{
const f=fixture(20_000);let n=0;vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
f.onSleep(async()=>{n++;stageMap(f);f.activity(n<8?'needs_input':after);});
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
const state=f.e.getPreparationState(seat)!;
if(after==='running')expect(state.awaitingInput).toBeUndefined();
else expect(state.awaitingInput).toMatchObject({reason:'target_needs_input',current:false});
expect(compacts(f)).toHaveLength(0);
});
it('manual: prompt, then idle, then busy before the map is done: no stale prompt, the map expiry as before',async()=>{
const f=fixture(20_000);let n=0;
f.onSleep(async()=>{n++;f.activity(n<30?'needs_input':n<50?'idle':'running');});
expect(readsBy(20_000)).toBeGreaterThanOrEqual(4);
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_incomplete'});
expect(f.e.getPreparationState(seat)!.awaitingInput).toBeUndefined();
expect(compacts(f)).toHaveLength(0);
});
// The idle wait after the map can run to the deadline, so readings it completes must reach the attempt as they land: a
// --state read (which also reconciles the deadline) during that wait describes the seat now.
it.each(['running','unknown'] as const)('manual: a prompt answered into %s while the post-map wait runs; --state reads during the wait, and at the deadline, see no current prompt',async(after)=>{
const f=fixture(20_000);vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
let n=0;let prompt:any;let attempt='';
f.onSleep(async()=>{n++;if(n===1){attempt=f.e.getPreparationState(seat)!.attemptId;stageMap(f);f.activity('needs_input');}
else if(n===2){prompt=f.e.getPreparationState(seat)?.awaitingInput;f.activity(after);}});
// After the prompt the seat works on, or can't be read; on the third such reading of one wait, two --state reads land:
// one as it is, one at the deadline, while that wait is still in flight.
const read=(f.transport as any).classifySendReadiness;let later=0;let during:any;let atDeadline:any;
(f.transport as any).classifySendReadiness=async(...args:any[])=>{
if(n>=2&&++later===3){
during=f.e.getPreparationState(seat)?.awaitingInput??null;
f.advance(f.e.getPreparationState(seat)!.deadlineAt!-f.clock());
atDeadline=f.e.getPreparationState(seat);
}
return read(...args);
};
const outcome=await f.e.triggerManualCompact(input,{operatorInitiated:true});
expect(prompt).toMatchObject({reason:'target_needs_input',current:true});
// A working seat clears the prompt; one that can't be read keeps it only as an earlier observation, never a current one.
const earlier=after==='running'?undefined:expect.objectContaining({reason:'target_needs_input',current:false});
expect(during??undefined).toEqual(earlier);
expect(atDeadline).toMatchObject({attemptId:attempt,status:'stopped',reason:'map_complete_seat_never_idle'});
expect(atDeadline.awaitingInput).toEqual(earlier);
expect(outcome).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
expect(compacts(f)).toHaveLength(0);
});
it('manual default accepts a map after120s but before180s',async()=>{
expect(MANUAL_PREP_WAIT_MS_DEFAULT).toBe(180_000);
const f=fixture(null);const start=f.clock();f.onSleep(async()=>{if(f.clock()>=start+130_000)stageMap(f);});
@@ -3,7 +3,7 @@ import { TerminalPolicyStore } from "../src/domain/terminal-policy.js";
//
// Proves: the route resolves the target + reads the EXISTING context-usage
// projection BEFORE calling the enforcer (known usage permits a manual request
// independently of the threshold); non-Claude → 422 runtime_filter; unknown usage → 409 no_usage_data
// independently of the threshold); non-Claude → 422 runtime_filter; unknown usage still prepares and compacts
// (route passes null, never invents a value); ambiguous → 409; missing field →
// 400; enforcer-unwired → 503. Reuses the shipped SessionTransport + the SAME
// ClaudeCompactionEnforcer (no second path).
@@ -277,7 +277,7 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
expect(sentTexts).toHaveLength(0);
});
it("unknown usage (no sample persisted) → 409 no_usage_data (never triggers blind)", async () => {
it("unknown usage (no sample persisted): an explicit manual request doesn't depend on the threshold, so it prepares and compacts", async () => {
seed();
const { app, sentTexts } = buildApp();
const res = await app.request("/api/compaction/trigger", {
@@ -285,9 +285,11 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: "dev-impl@my-rig" }),
});
expect(res.status).toBe(409);
expect((await res.json()).reason).toBe("no_usage_data");
expect(sentTexts).toHaveLength(0);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ ok: true, stage: "compact-sent" });
expect(sentTexts[0]).toContain("OpenRig manual compaction was requested for this seat");
expect(sentTexts[0]).not.toContain("null");
expect(sentTexts.some((text) => text.startsWith("/compact"))).toBe(true);
});
it("ambiguous session across rigs → 409", async () => {
@@ -201,6 +201,29 @@ describe("an opt-in decision revision on the settling verbs (#2446)", () => {
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read })).toMatchObject({ status: 200, body: { closedQitem: { state: "done" } } });
});
it("a new evidence pointer on an open ask is a new decision: an answer read under the earlier pointer conflicts, with nothing written", async () => {
const f = fixture();
const id = (await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Approve the release notes", summary: "Notes?",
humanIntent: "decision", evidenceRef: "/proof/notes-v1.md", nudge: false })).qitemId;
const read = await f.revision(id);
f.repo.update({ qitemId: id, actorSession: ASKER, state: "pending", evidenceRef: "/proof/notes-v2.md" });
expect(f.repo.getById(id)).toMatchObject({ state: "pending", evidenceRef: "/proof/notes-v2.md" });
expect(await f.revision(id)).not.toBe(read);
const before = f.written(id);
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read }))
.toMatchObject({ status: 409, body: { error: "decision_revision_changed", currentRevision: await f.revision(id) } });
expect(f.written(id)).toEqual(before);
});
it("a row with no evidence keeps the revision it had before evidence counted", async () => {
// A fixed id, so the revision (which names the row's park episode) is the same on every run; the value is the one
// this ask had before evidence counted.
const f = fixture();
const id = (await f.repo.create({ qitemId: "qitem-revision-control", sourceSession: ASKER, destinationSession: PERSON,
body: "Synthetic two-question ask", summary: "Ship?", humanIntent: "decision", humanQuestions: QUESTIONS, nudge: false })).qitemId;
expect(await f.revision(id)).toBe("dr1-bed30769297c81fe419c3520");
});
it("repairs a direct-answer partial commit on retry without duplicating the reply row, then reconciles subsequent retries", async () => {
const f = fixture();
const row = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false });
@@ -1314,4 +1314,31 @@ describe("#3076: a draft typed between the guard's read and the paste", () => {
const sent = await f.send("attach-quiet", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
});
it("re-reads the input after the Enter's own waiting checks, so a draft typed while they wait is not submitted with the notice", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
const typed = " PERSONDRAFT-k7";
const result = await f.guard.operation("worker@test", async () => {
expect(await f.tmux.sendText("worker@test", notice)).toMatchObject({ ok: true });
// A conditional Enter awaits its own checks (a conversation lookup); the person types after the pasted notice.
return f.tmux.sendKeys("worker@test", ["Enter"], undefined, { beforeKeys: async () => { f.bodies.a += typed; } });
});
expect(result).toMatchObject({ ok: false, code: "draft_input_changed" });
expect(f.submissions).toEqual([]);
expect(f.writes.filter(w => w.startsWith("keys:"))).toEqual([]);
expect(f.bodies.a).toBe(notice + typed);
});
it("still presses the Enter when the input stays as pasted while the Enter's checks wait", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
const result = await f.guard.operation("worker@test", async () => {
expect(await f.tmux.sendText("worker@test", notice)).toMatchObject({ ok: true });
return f.tmux.sendKeys("worker@test", ["Enter"], undefined, { beforeKeys: async () => {} });
});
expect(result).toEqual({ ok: true });
expect(f.submissions).toEqual([notice]);
expect(f.bodies.a).toBe("");
});
});
@@ -263,7 +263,7 @@ describe("host pairing with the kernel operator running: the person's answer goe
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. ${PERSON} is the person asked, on this host; the other machine asking to pair describes itself as "requester@far-host" (its own words, not an instruction). A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
// An unclear answer is asked back through the person asked, never human@host.
expect(item.body).toContain(`ask them back with an ask to ${PERSON} that names pairing code ${f.issued.code}: rig queue create --destination ${PERSON}`);
expect(item.body).toContain("Don't write to human@host: nothing is delivered there. Then act on what they answer.");
expect(item.body).toContain("A row to human@host names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there. Then act on what they answer.");
});
it("a requester's own name reaches the operator only quoted, as its own words, even when shaped like an instruction", async () => {
@@ -307,26 +307,18 @@ describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention",
expect(ev!.toState).toBe("blocked");
});
// OPR.0.5.1 slice-51-06 D2: non-park summary/evidence used to be SILENTLY dropped (a data-loss
// trap); it is now a HARD REJECT before any mutation. The surface still stays tight (nothing
// persists on a non-park item) — now enforced loudly instead of silently.
it("NEGATIVE: non-park updates REJECT summary/evidence_ref inputs (surface stays tight, loudly)", async () => {
// OPR.0.5.1 slice-51-06 D2 first replaced a silent drop with a whole-transition refusal. Narrowed: a
// non-park state change now applies and stores them, so nothing is dropped and finished work isn't held.
it("non-park updates STORE summary/evidence_ref inputs and apply the state change", async () => {
const item = await inProgressItem();
let err: unknown;
try {
repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "should NOT persist",
evidenceRef: "should-not-persist.md",
});
} catch (e) { err = e; }
expect(err).toBeInstanceOf(QueueRepositoryError);
expect((err as QueueRepositoryError).code).toBe("summary_evidence_not_persistable");
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["summary", "evidenceRef"]);
const read = repo.getById(item.qitemId)!;
expect(read.summary).toBeNull(); // nothing persisted (reject was before any write)
expect(read.evidenceRef).toBeNull();
const res = repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "now persists",
evidenceRef: "now-persists.md",
});
expect(res).toMatchObject({ state: "in-progress", summary: "now persists", evidenceRef: "now-persists.md" });
expect(repo.getById(item.qitemId)).toMatchObject({ summary: "now persists", evidenceRef: "now-persists.md" });
});
});
+9 -1
View File
@@ -22,7 +22,15 @@ describe("wrapPaneEnvelope — slice 23 envelope renderer (daemon-side)", () =>
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
});
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
// An answer typed at this machine is recorded as human@host, which names nobody: a reply there asks no one, waits on
// Steering and in the Feed, and OpenRig reports it as undeliverable, so its reply hint says so and points to the person's registered address.
it.each(["human@host", "human@host@host-origin"])("never points %s's reply at the unattributed address", (sender) => {
const last = wrapPaneEnvelope(sender, "driver@rig", "Answered.").split("\n").at(-1);
expect(last).toBe(`↩ ${sender} names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).`);
expect(last).not.toContain("rig queue create");
});
it.each(["human@kernel", "human-alice@kernel", "human-alice@host"])(
"gives %s a queue update reply with the existing human-route fields",
(sender) => {
const body = "Please check this.\nKeep the actor unchanged.";
@@ -82,7 +82,7 @@ describe("the primary user acts on anyone's ask, unattributed, on that person's
expect(reply.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(reply.body).toContain("don't ask them to confirm it");
expect(reply.body).toContain(`ask them back with an ask to sarah@external that names ${sarahs.qitemId}: rig queue create --destination sarah@external`);
expect(reply.body).toContain("Don't write to human@host: nothing is delivered there.");
expect(reply.body).toContain("A row to human@host names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there.");
expect(humanAnswerText(reply.body)).toBe("Ship it");
});
@@ -118,7 +118,20 @@ describe("queue wake nudge carries the stored summary", () => {
} finally { await settle(); h.db.close(); }
});
it.each(["human@kernel", "human@host"])("a wake from %s offers a reply accepted by the real queue writer", async (sender) => {
it("a wake from human@host says that address names no person, and offers no reply command", async () => {
const h = makeHarness();
try {
const row = await h.repo.create({ sourceSession: "human@host", destinationSession: "dev@rig", body: "Please check the build." });
await vi.waitFor(() => expect(h.calls).toHaveLength(1));
const envelope = h.calls[0]!.text;
expect(envelope).toContain("From: human@host\nTo: dev@rig\n");
expect(envelopeBody(envelope)).toEqual([`Queue handoff: ${row.qitemId} - check your queue.`]);
expect(envelope.split("\n").at(-1)).toBe("↩ human@host names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).");
expect(envelope).not.toContain("rig queue create");
} finally { await settle(); h.db.close(); }
});
it.each(["human@kernel"])("a wake from %s offers a reply accepted by the real queue writer", async (sender) => {
const h = makeHarness();
try {
const row = await h.repo.create({ sourceSession: sender, destinationSession: "dev@rig", body: "Please check the build." });
@@ -1,7 +1,7 @@
// 51-06 atom D2 — non-persistable queue update metadata (summary/evidence_ref) must HARD-REJECT
// BEFORE any mutation, per the Guard-bound design (no post-commit marker; reject-before-write).
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref) so a drop is
// provably the non-park rule, NOT a missing column (the test-suite parity gap this atom flags).
// 51-06 D2, narrowed — summary/evidence_ref given with a state change are STORED and the transition applies:
// optional metadata never holds finished work at pending. A blank summary is the one value not stored (it would
// erase the row's subject); the route reports it, so a caller never believes it was saved.
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import type Database from "better-sqlite3";
@@ -17,11 +17,9 @@ import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_rep
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository, QueueRepositoryError } from "../src/domain/queue-repository.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { queueRoutes } from "../src/routes/queue.js";
const REJECT_CODE = "summary_evidence_not_persistable";
function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
const app = new Hono();
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, queueRepo); await next(); });
@@ -29,7 +27,7 @@ function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
return app;
}
describe("51-06 D2 — non-park queue update metadata reject", () => {
describe("51-06 D2 narrowed — queue update metadata on a state change", () => {
let db: Database.Database;
let bus: EventBus;
let repo: QueueRepository;
@@ -46,52 +44,57 @@ describe("51-06 D2 — non-park queue update metadata reject", () => {
const mkItem = async () => (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item" })).qitemId;
const txnCount = (id: string) => (db.prepare("SELECT count(*) c FROM queue_transitions WHERE qitem_id = ?").get(id) as { c: number }).c;
const eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() as { c: number }).c;
const setSummary = (id: string, summary: string) => db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run(summary, id);
const setEvidence = (id: string, ref: string) => db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run(ref, id);
const post = (path: string, body: Record<string, unknown>, session: string) => app.request(path, {
method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
});
async function expectRejectZeroMutation(input: Record<string, unknown>, invalidFields: string[]) {
it.each([
["summary", { summary: "KEEP" }, { summary: "KEEP", evidenceRef: null }],
["evidence_ref", { evidenceRef: "/proof/x.md" }, { summary: null, evidenceRef: "/proof/x.md" }],
["both", { summary: "S", evidenceRef: "/e" }, { summary: "S", evidenceRef: "/e" }],
])("a state change carrying %s applies and stores it, with one transition", async (_label, input, stored) => {
const id = await mkItem();
const beforeState = repo.getByIdOrThrow(id).state;
const beforeTxns = txnCount(id);
const beforeEvents = eventCount();
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input }); }
catch (e) { err = e; }
expect(err).toBeInstanceOf(QueueRepositoryError);
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(invalidFields);
// zero mutation: no UPDATE (state), no log (transition), no event
expect(repo.getByIdOrThrow(id).state).toBe(beforeState);
expect(txnCount(id)).toBe(beforeTxns);
expect(eventCount()).toBe(beforeEvents);
}
it("rejects --summary on a non-park transition, before any mutation", async () => {
await expectRejectZeroMutation({ summary: "DROPME" }, ["summary"]);
});
it("rejects --evidence-ref on a non-park transition, before any mutation", async () => {
await expectRejectZeroMutation({ evidenceRef: "/proof/x.md" }, ["evidenceRef"]);
});
it("rejects BOTH, naming both invalidFields, before any mutation", async () => {
await expectRejectZeroMutation({ summary: "S", evidenceRef: "/e" }, ["summary", "evidenceRef"]);
});
it("treats empty-string as PRESENT (null=absent, ''=present) -> rejects", async () => {
await expectRejectZeroMutation({ summary: "" }, ["summary"]);
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input });
expect(res).toMatchObject({ state: "in-progress", ...stored });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", ...stored });
expect(txnCount(id)).toBe(2);
});
it("also rejects on a terminal non-park transition (in-progress -> done + closure)", async () => {
it("a finished seat's park on another row stores its summary and evidence, and the event carries the summary", async () => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" }); // claim (metadata-free, ok)
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", summary: "S" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect(repo.getByIdOrThrow(id).state).toBe("in-progress"); // unchanged by the rejected close
const blocker = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const events: Array<Record<string, unknown>> = [];
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(res).toMatchObject({ state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "blocked", summary: "Relayed; waiting on the reply" });
});
it("metadata-free non-park update is UNCHANGED (null=absent, no reject)", async () => {
it.each(["done", "canceled"] as const)("a %s close stores summary and evidence_ref on the closed row", async (state) => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const close = state === "done" ? { closureReason: "no-follow-on" } : {};
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state, ...close, summary: "Finished", evidenceRef: "/proof/result.md" });
expect(res).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
const shown = await app.request(`/api/queue/${id}`);
expect(shown.status).toBe(200);
expect(await shown.json()).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
});
it.each(["", " "])("a blank summary (%j) isn't stored: the state still changes and the row keeps its subject", async (blank) => {
const id = await mkItem();
setSummary(id, "Original subject");
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: blank, evidenceRef: "/e" });
expect(res).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: "/e" });
});
it("metadata-free update is UNCHANGED (null = absent)", async () => {
const id = await mkItem();
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: null, evidenceRef: null });
expect(res.state).toBe("in-progress");
expect(res).toMatchObject({ state: "in-progress", summary: null, evidenceRef: null });
});
it("CONTROL: human-seat park still PERSISTS summary + evidence byte-for-byte", async () => {
@@ -114,43 +117,101 @@ describe("51-06 D2 — non-park queue update metadata reject", () => {
expect(people.update({ qitemId: bare, actorSession: "orch@rig", state: "blocked", blockedOn })).toMatchObject({ state: "blocked", summary: null });
});
it("an unregistered name on another daemon's domain still refuses summary on a park", async () => {
it.each(["", " "])("a blank summary and evidence ref (%j) on a park on a registered person aren't stored: the row keeps its own", async (blank) => {
const person = { entityId: "sarah", class: "human" as const, displayName: "Sarah", address: "sarah@external", connectorBindings: [],
prefs: { deliveryClass: "A" as const } };
const people = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [person] }) });
const id = await mkItem();
setSummary(id, "Which layout?"); setEvidence(id, "/proof/layout.md");
expect(people.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@external", summary: blank, evidenceRef: blank }))
.toMatchObject({ state: "blocked", blockedOn: "sarah@external", summary: "Which layout?", evidenceRef: "/proof/layout.md" });
});
it("a legacy human-seat park given a blank summary and evidence ref parks on the row's own, instead of refusing", async () => {
const id = await mkItem();
setSummary(id, "PARK-KEEP"); setEvidence(id, "/proof/park.md");
expect(repo.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " ", evidenceRef: " " }))
.toMatchObject({ state: "blocked", summary: "PARK-KEEP", evidenceRef: "/proof/park.md" });
});
it("ROUTE: a blank summary on a park on a person -> 200, parked, and the advisory names the summary as not stored", async () => {
const id = await mkItem();
setSummary(id, "Original subject"); setEvidence(id, "/proof/kept.md");
const res = await post(`/api/queue/${id}/update`, { actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " " }, "orch@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
expect(body).toMatchObject({ state: "blocked", summary: "Original subject" });
expect(body.advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
});
it("a park on an unregistered name on another daemon's domain stores its summary too", async () => {
const nobody = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
const id = await mkItem();
let err: unknown;
try { nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect(nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }))
.toMatchObject({ state: "blocked", blockedOn: "sarah@host", summary: "S" });
});
it("a done close stores evidence_ref, and queue show returns it on the closed row", async () => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: "/proof/result.md" });
expect(res).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
const shown = await app.request(`/api/queue/${id}`);
expect(shown.status).toBe(200);
expect(((await shown.json()) as { state: string; evidenceRef?: string | null })).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
});
it("a canceled close still refuses evidence_ref, before any mutation", async () => {
const id = await mkItem();
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "canceled", evidenceRef: "/proof/x.md" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["evidenceRef"]);
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "pending", evidenceRef: null });
});
it("ROUTE: POST /:id/update non-park + summary -> HTTP 400 naming invalidFields", async () => {
// P21 I3: create/update derive the sender from the transport header; header==body claim ⇒ tolerated.
const created = await app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "orch@rig" }, body: JSON.stringify({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }) });
it("ROUTE: POST /:id/update with a state change + summary -> 200, stored, no advisory", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await app.request(`/api/queue/${id}/update`, { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "dev-x@rig" }, body: JSON.stringify({ actorSession: "dev-x@rig", state: "in-progress", summary: "DROPME" }) });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; invalidFields?: string[] };
expect(body.error).toBe(REJECT_CODE);
expect(body.invalidFields).toEqual(["summary"]);
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "KEEP" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
expect(body).toMatchObject({ state: "in-progress", summary: "KEEP" });
expect(body.advisories).toBeUndefined();
});
it("ROUTE: a blank summary with a state change -> 200, state applied, and an advisory names the summary as not stored", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject" });
expect(body.advisories).toEqual([{
code: "summary_not_stored", qitemId: id, field: "summary",
message: `The summary was blank, so it wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.`,
}]);
});
it("a stored evidence pointer survives a blank one on a state change, and a done close keeps it too", async () => {
const id = (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item", evidenceRef: "/proof/kept.md" })).qitemId;
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", evidenceRef: "" })).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: " " }))
.toMatchObject({ state: "done", evidenceRef: "/proof/kept.md" });
});
it("ROUTE: a blank evidence ref with a state change -> 200, the pointer kept, and an advisory names it as not stored", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", evidenceRef: "/proof/kept.md" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", evidenceRef: " " }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
expect(body.advisories).toEqual([{
code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
message: `The evidence ref was blank, so it wasn't stored: ${id} keeps its evidence ref "/proof/kept.md". The rest of the update was applied.`,
}]);
});
it("ROUTE: when the repository can't write either field, the state still changes, the caller is told, and the event agrees with the stored row", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
// A column probe that read false at construction (its PRAGMA read failed), while the columns exist.
Object.assign(repo as unknown as Record<string, boolean>, { hasSummaryColumn: false, hasEvidenceRefColumn: false });
const events: Array<Record<string, unknown>> = [];
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "New subject", evidenceRef: "/proof/new.md" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
expect(body.advisories).toEqual([
{ code: "summary_not_stored", qitemId: id, field: "summary",
message: `The summary wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.` },
{ code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
message: `The evidence ref wasn't stored: ${id} keeps no evidence ref. The rest of the update was applied.` },
]);
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "in-progress", summary: "Original subject" });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
});
});
@@ -5,7 +5,7 @@ describe("what a receiving agent reads to act on a person's answer", () => {
it("names the person to ask back, and never the unattributed terminal actor", () => {
const route = askBackRoute("sarah@external", "human@host", "request q-1");
expect(route).toContain("ask them back with an ask to sarah@external that names request q-1: rig queue create --destination sarah@external");
expect(route).toContain("Don't write to human@host: nothing is delivered there.");
expect(route).toContain("A row to human@host names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there.");
});
it("with no registered person to ask back: says so once, keeps the request and the answer waiting, and names the ways through", () => {
@@ -6064,6 +6064,24 @@ describe("RestoreOrchestrator", () => {
});
describe("launch_wrapper native resume, fail-closed, and snapshot retention", () => {
it.each(["replace", "clear"])("legacy Codex restore reads the live filesystem selection after snapshot (%s)", async change => {
const rig = rigRepo.createRig("fs-restore");
const oldPolicy = { approval: "never", filesystem: { ":minimal": "read", "/old": "write" }, network_access: false } satisfies import("../src/domain/codex-member-permissions.js").CodexFilesystemPermissions;
const currentPolicy = { approval: "never", filesystem: { ":minimal": "read", "/current": "write", "/current/private": "deny" }, network_access: false };
const node = rigRepo.addNode(rig.id, "codex-worker", { runtime: "codex", cwd: "/repo/codex", codexPermissions: oldPolicy });
const session = sessionRegistry.registerSession(node.id, "dev-worker@fs-restore");
db.prepare("UPDATE sessions SET resume_type = 'codex_id', resume_token = 'fs-thread', restore_policy = 'resume_if_possible' WHERE id = ?").run(session.id);
sessionRegistry.updateStatus(session.id, "running");
const snap = snapshotCapture.captureSnapshot(rig.id, "manual");
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(change === "replace" ? JSON.stringify(currentPolicy) : null, node.id);
sessionRegistry.updateStatus(session.id, "exited");
const codexResume = mockCodexResume({ ok: true as const });
expect(await createOrchestrator({ codex: codexResume, listProcesses: nativeLineage("codex", "fs-thread") }).restore(snap.id))
.toMatchObject({ ok: true });
const call = vi.mocked(codexResume.resume).mock.calls[0]!;
expect(call[12]).toEqual(change === "replace" ? currentPolicy : undefined);
});
it("passes current launch_wrapper as the new LAST arg after effort on Pi native resume and after beforeNativeDispatch (with undefined padding) on Codex native resume", async () => {
const rig = rigRepo.createRig("r99");
const piNode = rigRepo.addNode(rig.id, "pi-worker", {
+1 -1
View File
@@ -94,7 +94,7 @@ describe("POST and DELETE /api/rigs/:rigId/edges", () => {
]) {
const refused = await add(body);
expect(refused.status).toBe(400);
expect(await refused.json()).toMatchObject({ ok: false, code: "edge_cycle" });
expect(await refused.json()).toMatchObject({ ok: false, code: "edge_cycle", message: expect.stringContaining("launch and restore require an acyclic order") });
}
expect(state()).toEqual(before);
@@ -694,7 +694,7 @@ describe("an answered request goes to the receiving agent, which decides while t
expect(handed.body).toContain(`- **Who's who:** ${SEAT} is the agent asking to run the tool; ${asked} is the person it asked; human@host answered.`);
expect(handed.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(handed.body).toContain(`ask them back with an ask to ${asked} that names request ${askId}`);
expect(handed.body).toContain("Don't write to human@host: nothing is delivered there.");
expect(handed.body).toContain("A row to human@host names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there.");
expect(handed.body).toContain("Closing this item without deciding permits nothing");
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(f.permits()).toHaveLength(0);
@@ -1293,6 +1293,83 @@ describe("SeatHandoverService", () => {
detail: expect.stringContaining("without additional writable directory grants, as on a read-only fresh launch") } } } });
});
it("rebuild keeps the saved filesystem policy instead of carrying a broader observed predecessor", async () => {
const { node } = seedSeat();
const own = { approval: "never", filesystem: { ":minimal": "read", "/work": "write", "/work/private": "deny" }, network_access: false };
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(JSON.stringify(own), node.id);
currentCodexPermissions.mockResolvedValue({ state: "observed", envelope: { approvalPolicy: "never", sandboxPolicy: { type: "danger-full-access" } },
profile: null, processStartedAt: "Mon Apr 20 12:00:00 2026", observedAt: "2026-04-20T12:01:00Z" });
const result = await service.handover({ seatRef: "dev-impl@seat-rig", source: "rebuild", reason: "context wall" });
expect(result).toMatchObject({ ok: true, result: { sourceOutcome: { permissions: { state: "selection_used",
detail: expect.stringContaining("explicit filesystem policy and no additional writable directory grants") } } } });
const binding = launchHarness.mock.calls[0]![0];
expect(binding.codexRebuildPermissions).toBeUndefined();
expect(binding.codexPermissions).toEqual(own);
});
it.each(["fresh", "fork:dev-impl@seat-rig", "rebuild"])(
"keeps the predecessor running when filesystem preflight refuses before %s handover", async source => {
const { node, sessionId } = seedSeat();
if (!sessionId) throw new Error("fixture requires a current session");
sessionRegistry.updateResumeToken(sessionId, "native-thread", "codex_id");
const own = { approval: "never", filesystem: { ":minimal": "read", "/project": "write" } };
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(JSON.stringify(own), node.id);
const before = sessionRegistry.getBindingForNode(node.id);
const remedy = "Fix: Remove the conflicting lower-layer access before another handover.";
const verifyFilesystemPolicy = vi.fn(async () => ({ ok: false as const, error: `native policy refuses successor\n${remedy}` }));
const adapter = new CodexRuntimeAdapter({ tmux: tmux(), codexHome: "/seat-home", verifyFilesystemPolicy,
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, listFiles: () => [] } });
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
tmuxAdapter: tmux(), runtimeAdapters: { codex: adapter }, sleep: async () => {}, readinessTimeoutMs: 50 });
expect(await handover.handover({ seatRef: "dev-impl@seat-rig", source, reason: "check successor policy" }))
.toMatchObject({ ok: false, code: "successor_create_failed", message: expect.stringContaining("native policy"),
guidance: expect.stringContaining(remedy) });
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ codexHome: "/seat-home", cwd: "/project" }));
expect(signalPaneProcess).not.toHaveBeenCalled();
expect(respawnPane).not.toHaveBeenCalled();
expect(killSession).not.toHaveBeenCalled();
expect(sessionRegistry.getBindingForNode(node.id)).toEqual(before);
});
it("checks the saved filesystem policy before signaling the predecessor and then hands over", async () => {
const { node } = seedSeat();
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?")
.run(JSON.stringify({ approval: "never", filesystem: { ":minimal": "read" } }), node.id);
const verifyFilesystemPolicy = vi.fn(async () => ({ ok: true as const, verified: true as const }));
const adapter = new CodexRuntimeAdapter({ tmux: tmux(), codexHome: "/seat-home", verifyFilesystemPolicy,
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {} } });
vi.spyOn(adapter, "launchHarness").mockImplementation(launchHarness);
vi.spyOn(adapter, "checkReady").mockImplementation(checkReady);
signalPaneProcess.mockImplementation(async () => {
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(1);
return { ok: true };
});
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
tmuxAdapter: tmux(), runtimeAdapters: { codex: adapter }, sleep: async () => {}, readinessTimeoutMs: 50 });
expect(await handover.handover({ seatRef: "dev-impl@seat-rig", source: "fresh", reason: "check successor policy" }))
.toMatchObject({ ok: true });
expect(signalPaneProcess).toHaveBeenCalledTimes(1);
expect(launchHarness).toHaveBeenCalledTimes(1);
});
it("continues handover with the authored policy when native verification is inconclusive", async () => {
const { node } = seedSeat();
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?")
.run(JSON.stringify({ approval: "never", filesystem: { ":minimal": "read" } }), node.id);
const verifyFilesystemPolicy = vi.fn(async () => ({ ok: true as const, verified: false as const, reason: "native read timed out" }));
const adapter = new CodexRuntimeAdapter({ tmux: tmux(), codexHome: "/seat-home", verifyFilesystemPolicy,
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {} } });
vi.spyOn(adapter, "launchHarness").mockImplementation(launchHarness);
vi.spyOn(adapter, "checkReady").mockImplementation(checkReady);
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
tmuxAdapter: tmux(), runtimeAdapters: { codex: adapter }, sleep: async () => {}, readinessTimeoutMs: 50 });
expect(await handover.handover({ seatRef: "dev-impl@seat-rig", source: "rebuild", reason: "keep authored policy" }))
.toMatchObject({ ok: true, result: { warnings: expect.arrayContaining(["policy not verified: native read timed out"]) } });
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(1);
expect(launchHarness.mock.calls[0]?.[0]).toMatchObject({ codexPermissions: { filesystem: { ":minimal": "read" } } });
expect(signalPaneProcess).toHaveBeenCalledTimes(1);
});
it.each(["observed", "unverified"] as const)("rebuild keeps saved-envelope launch additions when predecessor permissions are %s", async state => {
const { node } = seedSeat({ codexConfigProfile: "locked" });
const own = { sandbox: "workspace-write", approval: "on-request", writable_roots: ["/shared/state"],
@@ -64,6 +64,44 @@ describe("SuccessorSessionLauncher", () => {
});
}
it.each([false, true])("leaves the predecessor untouched when launch preflight refuses or throws (throws: %s)", async throws => {
const preflightLaunch = vi.fn(async () => {
if (throws) throw new Error("preflight unavailable");
return { ok: false as const, error: "policy conflicts with the successor" };
});
const runtime = { ...fakeAdapter("codex"), preflightLaunch };
const tmux = { listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead,
getDefaultShell, getPaneCommand } as unknown as TmuxAdapter;
const guarded = new SuccessorSessionLauncher(tmux, discoveryRepo, { runtimeAdapters: { codex: runtime } });
const onReplacementStarted = vi.fn();
const result = await guarded.createSuccessor({ node: { id: "node-1", runtime: "codex", cwd: "/w" },
departingSessionName: "dev-impl@rig", onReplacementStarted });
expect(result).toMatchObject({ ok: false, code: "successor_preflight_failed", replacementStarted: false });
expect(signalPaneProcess).not.toHaveBeenCalled();
expect(setRemainOnExit).not.toHaveBeenCalled();
expect(respawnPane).not.toHaveBeenCalled();
expect(killSession).not.toHaveBeenCalled();
expect(launchHarness).not.toHaveBeenCalled();
expect(onReplacementStarted).not.toHaveBeenCalled();
});
it("preflights the same binding it launches and retains an inconclusive-policy warning", async () => {
const preflightLaunch = vi.fn(async () => ({ ok: true as const, warnings: ["policy not verified: native read timed out"] }));
const runtime = { ...fakeAdapter("codex"), preflightLaunch };
const tmux = { listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead,
getDefaultShell, getPaneCommand } as unknown as TmuxAdapter;
const guarded = new SuccessorSessionLauncher(tmux, discoveryRepo, { runtimeAdapters: { codex: runtime } });
signalPaneProcess.mockImplementation(async () => {
expect(preflightLaunch).toHaveBeenCalledTimes(1);
return { ok: true };
});
const result = await guarded.createSuccessor({ node: { id: "node-1", runtime: "codex", cwd: "/w",
codexPermissions: { approval: "never", filesystem: { ":minimal": "read" } } },
departingSessionName: "dev-impl@rig", occupantGeneration: "successor-generation" });
expect(result).toMatchObject({ ok: true, warnings: ["policy not verified: native read timed out"] });
expect(preflightLaunch.mock.calls[0]?.[0]).toBe(launchHarness.mock.calls[0]?.[0]);
});
it("revokes the staged attempt if its separate readiness probe fails", async () => {
db.prepare("INSERT INTO rigs (id,name) VALUES ('rig-bound','bound')").run();
db.prepare("INSERT INTO nodes (id,rig_id,logical_id,runtime) VALUES ('node-1','rig-bound','dev.impl','codex')").run();
+41
View File
@@ -695,6 +695,47 @@ describe("TmuxAdapter", () => {
expect(tmux.cmds()).toContain("tmux delete-buffer -b 'cr'");
expect(seat.noteSubmitted).not.toHaveBeenCalled();
});
it("input typed while the carriage return's re-check awaits the caller's checks stops it (#3076)", async () => {
const tmux = readOnlyTmux();
const adapter = new TmuxAdapter(tmux.exec, tmux.files);
let composer = "notice";
const seat = guard(async () => {
if (composer !== "notice") throw new DeliveryGuardError("draft_input_changed", "The input changed; Enter was not sent.");
});
adapter.deliveryGuard = seat as unknown as SeatDeliveryGuard;
let calls = 0;
const beforeKeys = vi.fn(async () => { calls += 1; if (calls === 2) composer = "notice PERSONDRAFT-k7"; });
expect(await adapter.sendKeys("dev@rig", ["Enter"], undefined, { beforeKeys }))
.toEqual({ ok: false, code: "draft_input_changed", message: "The input changed; Enter was not sent." });
expect(tmux.cmds().some(cmd => cmd.startsWith("tmux paste-buffer"))).toBe(false);
expect(tmux.cmds()).toContain("tmux delete-buffer -b 'cr'");
expect(seat.noteSubmitted).not.toHaveBeenCalled();
});
});
describe("sendKeys reads the input after the caller's waiting checks (#3076)", () => {
const TARGET = { nodeId: "n1", session: "dev@rig", occupant: "o1", pane: "%1" };
it("a draft typed while beforeKeys awaits stops the Enter, and nothing reaches the pane", async () => {
const exec = vi.fn<ExecFn>(async (cmd: string) => cmd.startsWith("tmux list-panes") ? "%1|0|/w|80|24|1\n" : "");
const adapter = new TmuxAdapter(exec);
let composer = "notice";
const seat = {
maybeTarget: () => TARGET, target: () => TARGET, ownsLifecycle: () => false,
checkInput: vi.fn(), noteSubmitted: vi.fn(), notePasted: async () => {}, attachInputInspection: () => {},
input: (_name: string, fn: () => Promise<unknown>) => fn(), humanInput: (_name: string, fn: () => Promise<unknown>) => fn(),
needsInputCheck: () => true,
beforeInput: vi.fn(async () => {
if (composer !== "notice") throw new DeliveryGuardError("draft_input_changed", "The input changed; Enter was not sent.");
}),
};
adapter.deliveryGuard = seat as unknown as SeatDeliveryGuard;
const result = await adapter.sendKeys("dev@rig", ["Enter"], undefined, { beforeKeys: async () => { composer = "notice PERSONDRAFT-k7"; } });
expect(result).toEqual({ ok: false, code: "draft_input_changed", message: "The input changed; Enter was not sent." });
expect(exec.mock.calls.map(([cmd]) => cmd).some(cmd => cmd.startsWith("tmux send-keys"))).toBe(false);
expect(seat.noteSubmitted).not.toHaveBeenCalled();
});
});
describe("killSession", () => {
@@ -31,6 +31,7 @@ import { workflowInstanceVersionSchema } from "../src/db/migrations/049_workflow
import { workflowSpecJsonSchema } from "../src/db/migrations/050_workflow_spec_json.js";
import { workflowResumeSchema } from "../src/db/migrations/051_workflow_resume.js";
import { workflowInstanceBoundRigSchema } from "../src/db/migrations/052_workflow_instance_bound_rig.js";
import { workflowInstanceScopeSchema } from "../src/db/migrations/110_workflow_instance_scope.js";
const SPEC = `workflow:
id: routes-fixture
@@ -85,6 +86,7 @@ describe("workflow routes (PL-004 Phase D)", () => {
coreSchema, eventsSchema,
queueItemsSchema, queueTransitionsSchema,
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
workflowInstanceBoundRigSchema, workflowInstanceScopeSchema,
]);
bus = new EventBus(db);
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
@@ -308,6 +310,27 @@ describe("workflow routes (PL-004 Phase D)", () => {
expect(body).toHaveLength(0);
});
it("filters instance scope before the limit and combines exact selectors", async () => {
const ids: string[] = [];
for (let i = 0; i < 3; i++) {
const response = await app.request("/api/workflow/instantiate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ specPath, rootObjective: `item ${i}`, createdBySession: "ops@rig" }) });
expect(response.status).toBe(201);
ids.push(((await response.json()) as { instance: { instanceId: string } }).instance.instanceId);
}
const update = db.prepare("UPDATE workflow_instances SET scope_json = ?, bound_rig = ?, created_at = ? WHERE instance_id = ?");
update.run(JSON.stringify({ project: "p", mission: "other", slice: "s", tags: ["review"] }), "rig", "2026-01-01", ids[0]);
update.run(JSON.stringify({ project: "p", mission: "m", slice: "s", tags: ["review", "ready"] }), "rig", "2026-01-02", ids[1]);
update.run(JSON.stringify({ project: "p", mission: "m", slice: "s", tags: ["review"] }), "rig", "2026-01-03", ids[2]);
const result = await app.request("/api/workflow/list?status=active&project=p&mission=m&slice=s&rig=rig&name=routes-fixture&tag=review&tag=ready&limit=1");
expect(result.status).toBe(200);
expect(((await result.json()) as Array<{ instanceId: string }>).map((row) => row.instanceId)).toEqual([ids[1]]);
const allTags = await app.request("/api/workflow/list?status=active&project=p&mission=m&slice=s&rig=rig&name=routes-fixture&tag=review&tag=ready");
expect(allTags.status).toBe(200);
expect(((await allTags.json()) as Array<{ instanceId: string }>).map((row) => row.instanceId)).toEqual([ids[1]]);
expect((await app.request("/api/workflow/list?limit=0")).status).toBe(400);
expect((await app.request("/api/workflow/list?tag=")).status).toBe(400);
});
it("GET /:instance_id returns 404 for unknown id", async () => {
const res = await app.request("/api/workflow/unknown-id");
expect(res.status).toBe(404);
+4 -2
View File
@@ -6,6 +6,8 @@ export function daemonStartArgs(target: string): string[] {
|| url.username || url.password || url.search || url.hash || url.pathname !== "/") {
throw new Error("This target is not a local daemon endpoint. Reconnect after its owner starts it; no local daemon was started.");
}
return ["daemon", "start", "--no-kernel", "--host", url.hostname.replace(/^\[|\]$/g, ""),
"--port", url.port || "80"];
// The daemon's default bind already listens on 127.0.0.1, with Tailscale beside it when present; naming that host would
// make it the only listener. Another loopback spelling isn't in the default bind, so it's still named.
const host = url.hostname.replace(/^\[|\]$/g, "");
return ["daemon", "start", "--no-kernel", ...(host === "127.0.0.1" ? [] : ["--host", host]), "--port", url.port || "80"];
}
+2
View File
@@ -389,6 +389,8 @@ async function run(): Promise<void> {
} else {
void refreshCrashCart();
void live?.refresh();
// Daemon only: no seat was started. Start and return lists the rigs, the kernel first, to bring one back.
if (startup) view.dispatch({ type: "notice", message: "Daemon started; this start didn't start any seats. Press S to choose seats to bring back, the kernel's first." });
}
});
return;
+10
View File
@@ -163,6 +163,8 @@ export class StartupController {
const failure = s.notice;
await this.refresh();
if (!confirmed) { s.notice = failure; s.detail = failure; this.changed(); }
// refresh() moves the page on; read it again rather than through the narrowed `s.page`.
else if (this.state.page === "rigs") { s.notice = daemonOnlyNotice(s.rigs); this.changed(); }
return;
}
if (s.page === "rigs" && key === "k" && !s.rigs.some((r) => r.name === "kernel")) {
@@ -244,6 +246,14 @@ export class StartupController {
}
}
/** After a daemon-only start: this start brought back no seat, and the existing way to bring one back is the rig list
* here, where the kernel comes first. It says nothing about why any seat isn't running. */
export function daemonOnlyNotice(rigs: Array<{ name: string }>): string {
return rigs.some((r) => r.name === "kernel")
? "Daemon started; this start didn't start any seats. The kernel is listed first: choose it to see its seats and resume one."
: "Daemon started; this start didn't start any seats. Choose a rig, or press k to set up a kernel.";
}
export function startupLines(s: StartupState): Array<{ text: string; action?: Action }> {
const button = (text: string, key: string) => ({ text, action: { type: "startup" as const, key } });
const lines: Array<{ text: string; action?: Action }> = [
@@ -6,10 +6,16 @@ import { resolveCrashCartKey } from "../src/crash-cart/keys.js";
describe("local startup target", () => {
it("starts only the intended local daemon, leaving occupant selection to the user", () => {
// The default bind is 127.0.0.1 plus Tailscale when present, so naming 127.0.0.1 would drop the Tailscale listener.
expect(daemonStartArgs("http://127.0.0.1:17433")).toEqual([
"daemon", "start", "--no-kernel", "--host", "127.0.0.1", "--port", "17433",
"daemon", "start", "--no-kernel", "--port", "17433",
]);
});
it.each([["http://localhost:7433", "localhost"], ["http://[::1]:7433", "::1"]])(
"names %s's host, which the default bind doesn't listen on", (target, host) => {
expect(daemonStartArgs(target)).toEqual(["daemon", "start", "--no-kernel", "--host", host, "--port", "7433"]);
},
);
it.each(["https://example.test", "http://10.0.0.2:7433", "http://user:password@localhost:7433", "http://localhost:7433/other"])(
"does not turn %s into local startup authority", (target) => {
expect(() => daemonStartArgs(target)).toThrow("no local daemon was started");
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { DaemonClient, launchNodeNotice } from "../src/daemon-client.js";
import { StartupController, startupLines, type StartupSeat } from "../src/startup.js";
import { StartupController, daemonOnlyNotice, startupLines, type StartupSeat } from "../src/startup.js";
import { createViewState, emptySnapshot } from "../src/state.js";
import { renderScreen } from "../src/render.js";
@@ -118,6 +118,11 @@ describe("TUI startup choices", () => {
expect(f.startDaemon).toHaveBeenCalledTimes(1);
expect(f.posts).toEqual([]);
expect(f.controller.state.page).toBe("rigs");
// It names the existing way back for the kernel, and claims nothing about why it isn't running.
expect(f.controller.state.notice).toBe("Daemon started; this start didn't start any seats. The kernel is listed first: choose it to see its seats and resume one.");
});
it("with no kernel rig, a daemon-only start points at choosing a rig or setting up a kernel", () => {
expect(daemonOnlyNotice([{ name: "work" }])).toBe("Daemon started; this start didn't start any seats. Choose a rig, or press k to set up a kernel.");
});
it("decline and refresh invalidate fresh consent with no launch effect", async () => {
const f = fixture(); await chooseOperator(f);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.7.15",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -1,14 +1,14 @@
{
"release": "0.7.14",
"archiveSha256": "866fb734b32c18c7a938d6f1edbda44c51fce1c87ee4c44839a8a3083d2aba25",
"release": "0.7.12",
"archiveSha256": "e90b2487fb744d7ca69e52e13b2aaef2a204fcfa76db00c373248acc0795eb52",
"plugins": {
"openrig": {
"version": "0.7.14",
"sha256": "42286256af8e2b6153086a95c1eebbcfd40eaf7b70086a88f263a5606d54e591"
"version": "0.1.0",
"sha256": "ee8a1300119576f36889cc6494988c69faa3dfb2c3172cfd856a7ecc3edc0506"
},
"openrig-core": {
"version": "0.1.29",
"sha256": "5e08deffe33feed6f7beff6df39d70402248632f87e3b63f074835b400fff54d"
"version": "0.1.28",
"sha256": "59b2fb34be931a423583283696c22034fa417530ea682268a68156a70730fb5f"
}
}
}
+8 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
// Lists lines added by a change that contain values tied to one machine, network or account, so
// the author can replace them with portable ones or keep them on purpose. Findings never fail it: a finding is a question, not a verdict. It exits 0
// with or without findings and 2 on an operational error (bad argument, git failure).
// the author can replace them with portable ones or keep them on purpose. A finding is a question, not a verdict: the default
// exits 0 with or without findings, --strict exits 1 when findings exist, and 2 is an operational error (bad argument, git failure).
//
// What it looks for:
// 1. Credentials: keys, tokens, passwords, key blocks.
@@ -17,6 +17,7 @@
// node scripts/portability-report.mjs --staged # what is staged for the next commit
// add --out report.md to also write the report to a file
// add --locations-only to list file and line without quoting the matched text (for public CI output)
// add --strict to exit 1 when findings exist (the default stays advisory: exit 0 either way)
import { execFileSync } from "node:child_process";
import { writeFileSync, realpathSync } from "node:fs";
@@ -133,11 +134,12 @@ export function renderReport(findings, label, { locationsOnly = false } = {}) {
}
function parseArguments(argv) {
const options = { staged: false, locationsOnly: false };
const options = { staged: false, locationsOnly: false, strict: false };
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
if (key === "--staged") options.staged = true;
else if (key === "--locations-only") options.locationsOnly = true;
else if (key === "--strict") options.strict = true;
else if (["--from", "--to", "--out", "--repo"].includes(key)) options[key.slice(2)] = argv[++index];
else throw new Error(`Unknown argument: ${key}`);
}
@@ -163,12 +165,13 @@ export function main(argv = process.argv.slice(2)) {
label = `${from.slice(0, 8)}..${to.length > 12 ? to.slice(0, 8) : to}`;
}
const diff = git(repo, ["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--src-prefix=a/", "--dst-prefix=b/", "--diff-filter=ACMR", ...range]);
const report = renderReport(findPortabilityIssues(addedLines(diff)), label, {
const findings = findPortabilityIssues(addedLines(diff));
const report = renderReport(findings, label, {
locationsOnly: options.locationsOnly,
});
process.stdout.write(report);
if (options.out) writeFileSync(options.out, report);
return 0;
return options.strict && findings.length > 0 ? 1 : 0;
}
function invokedDirectly() {
+29
View File
@@ -173,3 +173,32 @@ test("strips Git's separator tab and leaves C-quoted filenames quoted", () => {
}
assert.deepEqual(addedLines("+++ b/ordinary name.md\t\n@@ -0,0 +1 @@\n+see /Users/alice/project\n"), [{ file: "ordinary name.md", line: 1, text: "see /Users/alice/project" }]);
});
test("--strict exits 1 on findings, keeps the advisory default at 0, and exits 0 on a clean stage", async () => {
const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { fileURLToPath } = await import("node:url");
const { execFileSync, spawnSync } = await import("node:child_process");
const repo = mkdtempSync(join(tmpdir(), "openrig-portability-strict-"));
const script = fileURLToPath(new URL("./portability-report.mjs", import.meta.url));
try {
execFileSync("git", ["init", "--quiet", repo]);
execFileSync("git", ["-C", repo, "config", "user.email", "[email protected]"]);
execFileSync("git", ["-C", repo, "config", "user.name", "t"]);
writeFileSync(join(repo, "a.md"), "see /Users/alice/work\n");
execFileSync("git", ["-C", repo, "add", "--", "a.md"]);
const strictWithFindings = spawnSync(process.execPath, [script, "--repo", repo, "--staged", "--strict"], { encoding: "utf8" });
assert.equal(strictWithFindings.status, 1, strictWithFindings.stdout + strictWithFindings.stderr);
const advisoryWithFindings = spawnSync(process.execPath, [script, "--repo", repo, "--staged"], { encoding: "utf8" });
assert.equal(advisoryWithFindings.status, 0, advisoryWithFindings.stdout + advisoryWithFindings.stderr);
execFileSync("git", ["-C", repo, "commit", "--quiet", "-m", "seed"]);
writeFileSync(join(repo, "b.md"), "Nothing machine-specific here.\n");
execFileSync("git", ["-C", repo, "add", "--", "b.md"]);
const strictClean = spawnSync(process.execPath, [script, "--repo", repo, "--staged", "--strict"], { encoding: "utf8" });
assert.equal(strictClean.status, 0, strictClean.stdout + strictClean.stderr);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -69,7 +69,7 @@
"agent-operated-software/SKILL.md": "ec910bf5f631d7f20872786a565a0437a446fa2d5fd8b52b685979978243a6a8",
"agent-operated-workflows/SKILL.md": "24e54cbfa979999f523516112670618b2926ae5d42c7aa0846c94db214857ea5",
"applying-a-permission-policy/SKILL.md": "f7bba8d50b10fc8715f40a57bf82677ca9388c6718c8a162833d9c6daf2afdca",
"claude-compaction-restore/SKILL.md": "cd8369a62647ad389a9b0cc91c6f784a0d93e2d2b874bf9570d47c2134647d1c",
"claude-compaction-restore/SKILL.md": "e1a6ee501ae97d6de0a8df8dae01c088953f56907e76f10089908d7a8c5691a7",
"claude-compaction-restore/reference/SKILL-v1.md": "6af2a889c9c38227fb186897c6e29185f81b215822070b469a6ceeb47734e133",
"claude-compaction-restore/scripts/precompact-hook.mjs": "712e97c8beb02eb6c2815884a960778a225d8f6238dfe04a9b0547c34eaa131d",
"claude-compaction-restore/scripts/restore-from-jsonl.mjs": "5584cff4ab36cd843e14c81bd201e4a2f8e498e995abacc4f81ec6c1fc556b10",
+78 -25
View File
@@ -127,8 +127,8 @@ person needs in their project or home folder and name that path.
joining as optional, with the effects below. Ask the person yes or no after this short explanation:
- This adds me as a named member of the team, with my name on messages.
- In a plain shell, joining writes no files in your project or personal settings.
- Inside tmux on 0.6.11, my sender and reply address is your existing tmux session name, even a number like `0`.
`rig attach --self` returns that address and `rig whoami` confirms it; `--display-name` does not change it.
- Inside tmux, my sender and reply address is `<member>@<rig>`, even when your tmux session is named `0`;
`rig attach --self` returns it and `rig whoami` confirms it; `--display-name` does not change it.
- Inside tmux, other agents can type into this window, and stopping the team (`rig down`) also closes this tmux
session (both from source, not hand-tested). Transcript recording and, for Claude Code, writing
`.openrig/context-collector.cjs` and `.claude/settings.local.json` for the status line were hand-tested on 0.6.11.
@@ -147,7 +147,8 @@ person needs in their project or home folder and name that path.
`rig queue create --source <their id>@external --destination <seat> --summary "<one line>" --body-file <file>`: the
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
shell above is the route.
shell above is the route. For work whose questions and result must come back to this session, use a return
address ("If the person stays with you" below), not a declared name.
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
name someone else), and don't offer again.
@@ -168,9 +169,10 @@ person needs in their project or home folder and name that path.
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
(never the logical ID or a guessed name) and send:
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
results to relay." Carry their constraints, which logins they have (an installed provider that isn't signed in
doesn't count), and any permission choice they made, chosen or declined, in either handoff.
If the person stays here, hand the goal over as a task from your own address instead, so the operator's questions
and the result come back to you ("If the person stays with you" below). Carry their constraints, which logins they
have (an installed provider that isn't signed in doesn't count), and any permission choice they made, chosen or
declined, in either handoff.
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
@@ -181,11 +183,54 @@ person needs in their project or home folder and name that path.
Check those logins (`claude auth status`, `codex login status`), or go by what the person told you. If one is missing
or you can't tell, say so before launching and let them choose: the operator adapts the team to the logins they have,
or they sign in first.
- **Relay the operator's questions** unless the person declined the Codex offer below and explicitly chose the operator route: the operator asks in its own pane, and the person may not be looking there.
After each step you hand it, read its screen with `rig capture <canonicalSessionName>`. When it asks the person
something (start this team? which folder? which option?), ask them here in the operator's words and send their
answer back with `rig send`, or tell them to answer in the operator's pane. Unlike a prompt or a menu, a question
doesn't show up as a stopped seat, so don't leave it to a background wait.
- **If the person stays with you** (unless they declined the Codex offer below and explicitly chose the operator
route), you're their front door to the team: you hand it the goal, bring its questions to them and bring back what
it produced, through the task queue rather than the operator's screen.
- **Your return address:** if this session joined the team (above), it's the `OPENRIG_SESSION_NAME` that
`rig attach` printed. Otherwise agree one with the person on the kernel's rig, such as `claude-app@kernel` or
`codex-app@kernel`. It's an address the queue takes tasks for; nothing is launched for it and no file is written.
If the person keeps more than one of these conversations, give each its own address (for example
`claude-app-2@kernel`); one address shares its questions.
To tell the team what it's for, the person can add it to a roster as a hosted member
(`docs/reference/hosted-members.md` in the OpenRig package).
Put it in front of every `rig queue` command: `OPENRIG_SESSION_NAME=<address> rig queue ...` (add
`OPENRIG_HOME=<home>` when OpenRig doesn't use the default home). A `--source` name only labels the one task it's
on: claiming and answering the team's questions need the address in `OPENRIG_SESSION_NAME`.
- **Hand over the goal:** write it to a file with what the message above carries (that you're the agent that
installed OpenRig and the person stays with you, the goal, folder, branch, constraints, logins and permission
choices), and end it with this paragraph, filling in your address:
> To ask me anything, create a queue item to `<address>` (`rig queue create --destination <address> ...`), then
> block this item on that new item's id (`rig queue block <this id> --on <question id>`), so my answer wakes you. I
> check it when the person asks. Don't ask in your own terminal: the person isn't there. If you hand this item on,
> copy this paragraph into the new item. When you finish, put the outcome in this item's resolution and any file in
> its evidence ref.
Then run
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <canonicalSessionName> --summary "<the goal in a sentence>" --body-file <file> --verify`.
Tell the person the task ID it prints, and keep the goal, your address and that ID: they're how you find this work
again.
- **The team's questions** come to your address as tasks:
`OPENRIG_SESSION_NAME=<address> rig queue list --owned --state pending,in-progress`. Read each in full
(`rig queue show <id> --full`) and show the person the whole question in the team's words. Record only what they
tell you, a condition included ("yes, but only on a branch"): claim it, then
`OPENRIG_SESSION_NAME=<address> rig queue update <id> --state done --closure-reason no-follow-on --resolution "<their words>"`.
Add no label, quotation marks or notes of your own, and don't turn their words into a yes or a no: the agent that
asked decides what they mean. You speak as yourself, never as the person. If they aren't there to answer, leave
it open and tell them it's waiting; never answer for them.
- **A question only on the operator's screen:** if the operator asks in its pane and nothing reached your address,
read it with `rig capture <canonicalSessionName>`, show the person, and send their words as a new task to the
operator that names your task's ID, not as keys typed into its pane.
- **Delivery isn't the task's state.** A task's state says where the work is: pending (created and readable, not
yet claimed), claimed or in progress (an agent took it), handed off (follow the next item) or done (read its
resolution). Whether its agent was woken is a separate fact, the wake outcome `--verify` reports: `verified` (it
rendered in the seat's pane) or `claimed` (picked up); `delivered-ack-pending` (sent, render unconfirmed: not
proof it was woken); `retained` (the seat's typing guard held the wake, the task is still there to read, and it
isn't retried automatically: wake the task's destination once its input is free with
`rig send <destination> "task <id> is yours"`); `failed` (the wake didn't land, and the task is still
owed: wake it the same way); `indeterminate` or `still-pending` (unknown, neither failed nor delivered: check
before you wake it again). A pending task with no wake isn't lost or done.
- **Nothing wakes this chat** when the team asks or finishes: check your address when the person asks or comes back.
- **If the operator answers with a model error,** such as "… not supported when using Codex with a ChatGPT account", its
Codex sign-in works but that account can't run the model. OpenRig put the operator on Codex because the sign-in
passed. Tell the person which model failed. If Claude Code is signed in, offer to move the operator to it; on their
@@ -216,9 +261,9 @@ person needs in their project or home folder and name that path.
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
Another kind of menu you can answer with
the key that screen shows, or ask the person. Capture it again before sending anything more.
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
`rig queue list --destination <your address>` and `rig queue show <id> --full`, and read an agent's screen with
`rig capture <seat>@<rig>`.
- **Replies:** in a plain shell, other agents can't type into this session. Their questions and work for you arrive
at your return address ("If the person stays with you" above). `rig capture <seat>@<rig>` shows what an agent is
doing; it isn't how answers reach you.
### Codex: one offer, once, after OpenRig starts
@@ -257,21 +302,29 @@ offer, so the person sees the real file, not a placeholder:
Follow these steps by default. Only when the person declined the Codex offer above and explicitly chose the operator route does the operator own this follow-through.
- **Find the team's task:** the operator gives the goal to the team's lead as a queue task: `dev-build@starter`, or
`orch-lead` in `workshop` and `factory` (for starter and factory, `rig specs preview <name> --kind rig` says what
each seat does; for a running team, `rig ps --nodes --rig <rig>`). Find it with
`rig queue list --destination <seat>@<rig> -a`.
- **Find the team's task:** if you gave the operator a task, start from it: when the operator hands it to the team's
lead, `rig queue show <id> --json` lists the next item under `successors`. Otherwise, or if the operator started a
new task for the lead (`dev-build@starter`, or `orch-lead` in `workshop` and `factory`; for starter and factory,
`rig specs preview <name> --kind rig` says what each seat does; for a running team, `rig ps --nodes --rig <rig>`),
find it with `rig queue list --destination <seat>@<rig> -a`.
- **Follow it to the result:** a task that changed hands isn't finished. `rig queue show <id> --full` names the seat it
went to, and that seat's next task is the one whose `handedOffFrom` is that ID
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result, and
use `rig capture <seat>@<rig>` to see what it's doing. Then read what it produced and tell the person what the team
actually did (the branch, commit, review or PR text), not just that you sent it.
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result,
and report what the record says; `rig capture <seat>@<rig>` only shows what an agent is doing right now. Then read
what it produced and tell the person what the team actually did (the branch, commit, review or PR text), not just
that you sent it.
- **Give the team more work:** write the goal to a file: what to change, in which repository and branch, what done
looks like, and that the result comes back to you (the branch or commit, the review, any PR text). Then
`rig queue create --source <your name> --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file>`, and tell that agent with
`rig send <seat>@<rig> "task <id> is yours"`. A message informs, and a queue task is the work someone owns.
`--source` names you when this session hasn't joined the team.
looks like, and that the result comes back to you (the branch or commit, the review, any PR text), ending with the
reply paragraph above. Then
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file> --verify`.
A message informs, and a queue task is the work someone owns. Read its wake outcome as above before you send
anything else.
- **Then ask what's next.** If no team can take the work, fix that or ask the person. Don't quietly do it yourself.
- **Coming back later:** in a new session, use the same return address and the task IDs you gave the person. Read
each with `rig queue show <id> --json` and follow its `successors` to the newest item, as above;
`OPENRIG_SESSION_NAME=<address> rig queue list --mine -a` lists everything this address sent or owns, finished work
included (without `-a` it shows only open items). Follow that work rather than creating it again.
Adding OpenRig's MCP plugin to this app later starts a new session; it doesn't carry this conversation over.
- **Other ways to work with the team:** `rig send <seat>@<rig> "..."` types into an agent's terminal,
`rig capture <seat>@<rig>` reads its screen, and `rig ps --nodes --rig <rig>` shows who's on it.