Compare commits

...
10 Commits
Author SHA1 Message Date
dev-driver c99450f322 docs(release): drop the invented-request gap and correct the Slack line
The invented-request problem is addressed for 0.7.0, so it leaves the
known gaps. A Slack channel-mapping change needs a daemon restart; the
page no longer claims disable then enable does or doesn't reload it.
Enabling marks any alerts waiting to post as already seen
(seedBacklogAsHistory, queue-access.ts:106-109, called at
gateway.ts:292), so open exchanges miss them.
2026-10-23 09:00:55 +00:00
dev-driver 7e55c19a53 docs(codex): state that hook trust checks the files only when granted
A later edit to the cached hook scripts keeps the trust OpenRig granted
on first install, as with any hook trusted in Codex. The what's-new page
and the refocus channel reference now say so.
2026-10-23 08:41:28 +00:00
dev-driver 99aaada64c docs(release): 0.7.0 what's new, and a clearer managed-refresh log line
The what's-new page an agent reads after upgrading still described
0.6.12. It now lists what behaves differently from 0.6.14 and what to do
about it: the migrations and settings, the clean-start environment, the
plugin refresh and its copies, quiet hours and modes, rig names in
place of ids, restore and launch changes, the openrig-core plugin's
return to Codex seats with first-install hook trust, changed output, and
the known gaps.

The managed-refresh log line now says the file's previous contents were
preserved before the refresh, not that custom behaviour stopped: it runs
before any write, and a later copy failure can still skip the refresh.
2026-10-23 08:20:32 +00:00
dev-driver ac496208b0 fix(codex): trust only shipped core hooks on first install 2026-10-23 08:16:11 +00:00
dev-driver b3e03ed75b fix(daemon): preserve managed files before startup refresh 2026-10-23 07:06:21 +00:00
dev-driver a1cd9ebf3b fix(codex): keep fresh starts free of automatic plugin review 2026-10-23 06:29:24 +00:00
v-openrig-build 2fec738282 test: compose 0.7.0 candidate with startup identity framing 2026-10-22 09:11:13 +00:00
dev-driver 5380ca1070 Revert "fix(daemon): read a native install's version label as claude with an OS executable witness (#1345)"
This reverts commit 6c31f02706.
2026-10-22 03:36:20 +00:00
dev-driver a892e73df8 fix(daemon): wait for Claude's input box before a fresh seat's startup text (#1730)
A fresh Claude seat could be sent its first startup text before Claude had drawn its input box, and the text was then left unsent.

Now, before a fresh Claude start's first send (through `rig up`, an added or grown seat, `seat launch --fresh`, a continued fresh start or a managed launch), the startup orchestrator looks at the screen until the input box is drawn, using the startup check's own recognizer (`claudeInputBoxDrawn`, from `composerRegion`): at most 25 looks, 200 ms apart. If no box appears in that time, or the screen can't be read, the text is sent as it is today. That fallback is best effort: it doesn't guarantee the text is never sent early, and it isn't a startup pass. Readiness (`checkReady`, `native-resume-probe.ts`) and its callers are unchanged, and restores and later sends don't get this wait.

After the Enter, on every Claude orchestrator send, restore replay included, a screen with text but no input line is now looked at again within the same bound instead of reading unverified at once. If our exact text then shows staged, only the existing exact-own-text, submit-only retry acts; an empty box reads as submitted; a draft, a permission prompt or anything else stays unverified with no further Enter. There's no repaste.

Limits: handover successors start through their own launch and send path, so neither wait covers them. A fresh Claude start can take up to about 5 seconds longer when no box is drawn, and a send whose screen shows no input line after the Enter up to about 5 seconds more. Nothing is refused. Native launch wasn't run; it remains the acceptance check.

+139/-7 in 5 files: source +35/-5, tests +104/-2 (2.97 test lines per added source line).

(cherry picked from commit 5f64645e29)
2026-10-21 23:52:03 +00:00
dev-driver f3fad85e96 chore(release): prepare 0.7.0 version and installer pins 2026-10-21 20:48:22 +00:00
43 changed files with 707 additions and 924 deletions

No files matched your search

+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.6.12/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.6.12/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/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.6.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:
This runs OpenRig's install script from the `v0.7.0` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.12/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
+2 -2
View File
@@ -181,11 +181,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.6.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.12/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.6.12`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.0`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
+6 -2
View File
@@ -36,8 +36,12 @@ own project folder is the same project and still acts as the seat, as before thi
launched before this change, a manual run) the hooks do what they always did. The plugin's activity relay stays quiet next to
the seat's own `settings.local.json` copy only when that entry is exactly OpenRig's and the script it names is the same
script; an entry from an older install doesn't match, so both report until the seat's next launch refreshes it.
Codex loads the plugin's hooks once the daemon has installed it through Codex's own `codex plugin add` (see the plugin
README for the config it writes and how to opt out), and each hook has passed Codex's exact-hook review. The Codex
OpenRig automatically installs its Codex plugin for seats that select it. On its first installation, the six exact shipped
hook definitions are trusted before launch, after checking the installed hook files against this daemon's shipped copy.
Existing plugin disables and hook choices are retained. An existing untrusted installation stays under native review:
Codex's "continue without trusting" leaves no record, so OpenRig does not assume an absent trust record is permission.
The file check happens only when trust is granted: a later edit to the cached hook scripts keeps that trust, as with any hook trusted in Codex.
The Codex
column above then applies only to a Codex seat whose plugin selection includes openrig-core: every Codex hook command
exits 0 before running anything unless OpenRig gave that seat `OPENRIG_SEAT_PROJECT_DIR` and an explicit
`OPENRIG_NODE`.
+124 -171
View File
@@ -1,7 +1,7 @@
# What changed in OpenRig 0.6.12
# What changed in OpenRig 0.7.0
This note is for an agent on an install that was just upgraded to 0.6.12. It lists only what behaves differently from
0.6.11 and what to do about it. For everything OpenRig can do, read the capability map:
This note is for an agent on an install that was just upgraded to 0.7.0. It lists only what behaves differently from
0.6.14 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
@@ -9,181 +9,134 @@ after an upgrade it can still hold the previous version's note.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Twelve migrations run on the first start,
whatever you use: `100`, `104`, `105`, `107` to `115`. They add columns and tables for the typing guard's modes, a
seat's saved effort, Claude's cost estimate, Pi usage, mode and posture categories, workflow scope, seat environment
values, the Codex `--no-daemon` record and `sessions.agent_app`; build two indexes for recent queue activity (about
10 to 12 MB on a long-running install); and rebuild the Slack thread map. There are no migrations `102` or `106`.
- **Claude seats now run the openrig-core plugin's hooks.** The bundled plugin goes from 0.1.9 to 0.1.13. A Claude
seat whose plugins include `openrig-core` (every shipped agent spec selects it) starts with `CLAUDE_CODE_PLUGIN_DIRS`
pointing at OpenRig's copy, so the activity relay, the compaction save and restore hooks and refocus actually run. It
needs Claude Code 2.1.280 or later, which nothing checks. A running seat picks it up at its next launch, restore or
handover. The refocus hook may take up to 60 seconds, so a hung refocus can hold a prompt that long.
- **Six new settings, all off except one:** `output.whoami_peers` (8, used only while `output.compact` is on),
`bundle.services_enabled`, `provider.subscription_moves`, `usage.pi_cost_facts`, `usage.pi_openrouter_charge` and
`transport.verify_input_box`. See "Off by default" below.
## Output that changed shape
- **Exit codes:** eleven client-fixable `rig queue update` refusals now answer HTTP 400 and exit 1 instead of 500 and
exit 2 (`blocker_not_found`, `blocker_not_live` and `blocker_malformed` still exit 2). `rig ask` exits 1 for an
unknown or ambiguous rig. `rig compact` exits 1 (was 2) for `pane_unknown_protected`, and its route answers 409 (was
502). Over the shared transport, a write that throws without a code makes `rig send` exit 1 with HTTP 409 instead of 2
with 500, and a guarded send whose text was written returns `sent: true` with `inputPhase: "written"`.
- **`rig compact` and `rig compact --state` add a `compact` result:** `submitted_unconfirmed` (shown as "submitted,
unconfirmed"), `uncertain` or `not_sent`. "Submitted" means `/compact` and Enter reached the seat, not that compaction
ran.
- **`rig capture --lines N --json`** adds `activity: "unknown"` and `activityNote` when lines were cut.
- **`rig start --last --json`** carries `keptDown` (always) and `warnings` (`seat_status_unreadable`).
- **`rig queue show <id> --full`** on a closed row adds `closingTransition`; plain `rig queue show` on such a row reads
`readView.complete: false` and lists it in `omittedFields`. `rig queue show` returns `resolution` when a close stored
one.
- **After `rig down`,** `rig ps`, the TUI and the rig summary read the rig `stopped`, not recoverable, and
`hasLiveAgents` can read `false`.
- **`rig seat status`** reads `Typing guard: on (hold)` for hold, and its JSON's `desired` and `effective` mean hold only;
`desiredMode` and `effectiveMode` give the mode.
- **`rig usage top`** measures tokens and rates within one conversation and one window, so figures can drop and more
seats read `insufficient_samples`.
- **The execution view** binds a row to a mission by its exact `mission:` tag first; `q1_lanes[].waiting` and
`folded.merge_report` are new.
- **Smaller:** `rig create --json` carries `detail` on failure; `rig status --json` is new; `rig mcp serve` lists 23 tools
(five queue tools added); a JSON body of `null` gets 400 `invalid_json`; `GET /api/events` takes `?replay=`; a Slack
seat post's sender line ends `on <host id>`; `rig ps --nodes` `resumeCommand` can carry `--no-daemon`.
## Seats and restore
- **`rig start --last` leaves down a rig stopped on purpose** (`rig down` or `rig seat stop`) and prints how to bring it
back: `rig start --rigs <rig>`, or `rig up '<name>' --existing`.
- **An ordinary terminal seat relaunches fresh on restore** and re-runs its startup actions; don't answer `--fresh` for
it. A missing required startup file for such a seat blocks the whole restore before anything launches.
- **Restore stops a seat whose current runtime differs from its snapshot's,** before launching it; `--fresh` doesn't
bypass it. Restore from a snapshot taken with the seat's current runtime.
- **A slow Claude restore ends `attention_required`, not failed:** "Claude resume did not finish within Ns; Claude may
still be starting, so the launch was kept". Look at the pane, and once Claude is up run `rig seat clear-attention`.
Each such seat can add up to about the readiness timeout, one after another, so `rig up` can pass its 120-second
wait while the daemon keeps working: check `rig ps` before running it again.
- **"Returned to a shell" now means the process tree shows an idle shell.** "The pane shows a shell that is not a proven
idle shell; a launch script may still be starting …" means the seat may still be coming up. A Pi or OMP seat at a
bare shell reads `attention_required`.
- **A Codex fork whose new conversation ID arrives late** stops at `attention_required` with its startup context kept;
let it finish starting, then run `rig seat continue <seat>`. A handover to a Codex fork can complete with `Fork id:
PENDING`.
- **A seat whose startup failed for a recoverable reason** becomes ready after a successful send or capture.
- **`rig launch` takes a rig's name as well as its ID;** a name two active rigs share stops with `rig_ambiguous`.
- **`rig add`, single-seat `rig launch` and `rig launch --seats` wait up to 120 seconds.** A timeout still isn't a
rollback.
- **A runtime `--version` check gives up after 30 seconds.** `rig bootstrap` blocks on a runtime that's installed but
fails its check, reported apart from one that's missing; `rig up`'s preflight fails a Claude, Codex or OMP check that
doesn't answer (a warning for Pi).
- **Claude login detection:** only a current refusal reads `login_required`, not the "Not logged in" footer alone.
- **Model pins:** `fable` now names `claude-fable-5-1`, a trailing `[1m]` is set aside, and pinned Pi seats are checked
(a `:high`-style thinking suffix is accepted).
- **A custom permission policy saved with CRLF line endings** now takes effect at the next launch or restore, instead
of the advisory floor.
- **`rig scope mission create`** writes `status: planned`; set it `active` to count it in `rig proof show --project`.
With `--workspace <project>`, missions go under the project's declared `missions.root`.
## Sends, wakes and the queue
- **A send to a pane in tmux copy or view mode** leaves the mode and delivers; `pane_in_mode` means the pane couldn't
leave its mode. Leave it in that pane, then send again.
- **`rig send --dangerously-interact` types exactly what you give it.** Enter is added only for a complete answer
staged in a recognised Claude Code or Codex input box; anywhere else, put CR in the payload. With `--verify` it reads
unchecked.
- **A Codex seat reads "needs you" only when a real prompt is visible;** a pending permission request reads
`permission_request_pending` first, and an unreadable pane gets `permission_request_unverified` after a grace.
Codex's `»` prompt is recognised.
- **Managed `/compact` is short enough to run and is sent only to an idle seat.** Confirm compaction by effect: the
restore request, or the drop in context.
- **A held-back (`retained`) wake doesn't use up a one-shot park timer;** it's tried again at the interval. You don't
need to re-arm after one.
- **A retired watchdog doesn't deliver a late wake.** A row parked on its owner's own repeating reminder reads
`servedByOwnReminder` in `rig parked` and draws no extra parked-owner wake.
- **`rig queue update <id> --summary "<text>"`** alone corrects a row's subject. Close with **`--resolution
"<outcome>"`** to store the outcome on the row.
- **`rig remove` and `rig shrink`** keep a seat that gets work while stopping (409, `retained: true`) unless
`--fallback` takes it. A stale `rig unclaim` is refused with `session_not_current`.
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Six migrations run on the first start (116, 118,
119, 121, 122 and 123). Two settings are added, `terminal.history_limit` (default 10000) and
`terminal.inherit_tmux_environment` (default false), and `provider.subscription_moves` is removed.
- **The bundled openrig-core plugin goes from 0.1.15 to 0.1.17.** Startup replaces installed copies of managed plugins and
marked global skills. It first copies every file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`,
whether or not anyone edited it, and logs `[plugin-vendor] preserved previous contents of <file> at <copy> before
managed refresh` for each one. A stock upgrade from 0.6.14 copies about 22 files (about 250 KB), and a later start with
no new version copies none. The copies matter only for files someone changed: the copy keeps the bytes, but the change
may no longer be active after the refresh. Keep the log line, because it names the original file and the copy's folder
doesn't. If a copy fails, that plugin or skill isn't refreshed, its files are kept, and the log says so. Copies aren't
cleaned up.
- **If you gave new agents accounts or API keys through tmux's global environment** (`tmux set-environment -g …`), new
agents no longer inherit it (below). Put the values in the seat's `env`, pass `rig up --env KEY=VALUE`, or keep the old
behaviour with `rig config set terminal.inherit_tmux_environment true`. Running agents aren't affected.
- **If you write queue rows to another machine by its self-id** (`member@rig@<self-id>`), run `rig host doctor <alias>`
once per host: bindings learned before 0.7.0 have no address.
## New things you can do
- **Set one seat's reasoning effort:** `rig seat set-effort <seat> --effort <level> --reason "<why>"` (or `--clear`); it
applies at the next managed launch, not to the running process. Check it with `rig seat effort <seat>`.
- **Change a seat's stored permission policy:** `rig policy apply <policy> --seat <seat> --reason "<why>"`. `rig policy
current` and plain `rig ps` don't show it; `rig seat status` does.
- **Give seats environment values:** `env:` on the rig or a member in `rig.yaml`, or `rig up <spec> --env KEY=VALUE` for
one launch. Never put secrets there.
- **Scope a workflow run:** `--project`, `--mission`, `--slice` and `--tags` on `rig workflow instantiate` and `run`;
write a target as `member@{rig}` to follow the bound rig.
- **Read cost:** `rig usage cost [--seat <session>] [--json]`. A missing figure is never $0, and Claude's figure is an
estimate, not an invoice.
- **Hand work over without a shell:** `rig mcp serve` with `OPENRIG_SESSION_NAME` set gives `rig_queue_create`,
`rig_queue_show`, `rig_queue_list`, `rig_queue_claim` and `rig_queue_update`. Nothing wakes that agent.
- **`rig status --json`:** treat `daemon.reachable: null` as unconfirmed, not down.
- OpenRig 0.6.12 adds `rig community-harness list` for installed community-harness presets, showing their maintainer
and declared limits. These harnesses are not tested by OpenRig. Support is launch-only: no resume or fork, no
resource projection or startup-file delivery, and identity remains unconfirmed. A quiet screen is not proof that the
seat is idle. (#1392, #1518)
- **Guidance:** the `rigs` skill offers to join the team before the first operator message, and offers Codex users one
removable rules file; `delegating-work` says to brief a peer by the model `rig ps --nodes --json` shows; the operator
reads `$OPENRIG_HOME/reference/keeping-a-watch.md` when asked to watch or remind, including a low-allowance warning.
- User-visible text says "harness" for the agent software around a model, and "community harness" for one an outside
contributor maintains; `docs/reference/topology-naming.md` defines the terms. (#1506)
- **Hold a person's routine asks while they're away:** `rig gateway human quiet set <id> --daily HH:MM-HH:MM` or
`--until <time>` (`--tz`), and `end`, `clear` or `show`. A held ask posts once, without a mention, when quiet ends.
Asks tagged `escalation` or `wake-escalation` are not held.
- **Apply a person's mode** ("asleep", "out of office"): `rig gateway human mode save <id> <name>` (with
`--availability`, `--expected-back`, `--decision-room "<their words>"`, `--exclude`, `--quiet`), then `apply <id>
<name> [--until …]`, `current`, `end` and `account`. Never tell the person "back to normal" while the account shows an
effect `kept`, `pending` or `failed`.
- **Decide an ask inside a person's decision room:** check `rig gateway human mode room <id>`, then `rig gateway human
mode decide <qitemId> --application <id> --reason "…" --reversible|--irreversible`. It's recorded as delegated, never
as their answer, and only the agent that asked can decide. Permission prompts are refused (`permission-prompt`).
- **Pass a rig's name instead of its id** to `archive`, `unarchive`, `release`, `shrink`, `expand`, `snapshot`,
`export` and `restore --rig`. A name two rigs share is refused and the matching ids are named: with `--json`,
`archive`, `unarchive`, `release`, `shrink`, `expand` and `restore status --rig` return `rig_ambiguous`.
- **Stop a managed compaction's follow-up after `/compact` was sent:** `rig compact --cancel` now also disarms the
restore and audit prompts that would follow. It sends no input and can't undo the `/compact`.
- **See your work candidates:** `rig queue whoami` adds `currentWorkChoice` when current work isn't settled.
- **See why a wake didn't land:** `rig queue show --json` adds `lastNudgeInputBox`.
- **See each seat's configured effort:** the TUI agent table's `CFG EFF` column, and `effort` and `effortSource` in
`rig ps --nodes --full --json`. It's the configured level, not what the model ran.
## Off by default
## Behaviour that changed
- **`output.compact`** now also shortens `rig queue show` and `rig queue transitions` (`--full` for everything) and
bounds `rig whoami --json` peers to `output.whoami_peers` (`--all-peers` lists all).
- **`provider.usage_windows`** now also shows Claude's windows, each seat's configured account and, with
`--all-hosts` or `--hosts`, every registered machine's windows. A Claude account's current availability always reads
`unknown`: don't tell a person an account has room from a seat's reading.
- **`usage.pi_cost_facts`** and **`usage.pi_openrouter_charge`** take effect when a Pi seat next launches or resumes, not
at once; what was recorded stays visible after they're turned off.
- **`provider.subscription_moves`** enables `rig provider moves`. The `openrig-user` skill's section for it mentions apps
that don't ship in this release.
- **`bundle.services_enabled`**, **`transport.verify_input_box`**, the per-seat draft-aware typing guard (`rig seat
set-typing-guard <seat> --mode draft-aware`), `rig bundle create --include-login-files`, `OPENRIG_TMUX_CONTROL=1`
and `OPENRIG_REFOCUS_BUDGET_MS` (4500 to 52000) each act only when chosen.
- **The daemon runs tmux without `/bin/sh`** on Linux and macOS; `OPENRIG_TMUX_SHELL=1` at daemon start keeps the old
path.
- **New agents start from a clean environment, not tmux's global one.** A new seat or handover successor gets:
- the launch environment: `PATH`, `HOME`, `USER`, `LOGNAME`, `CODEX_HOME`, `OPENRIG_HOME`, `OPENRIG_URL`,
`OPENRIG_PORT`, `OPENRIG_HOST`, the seat's own identity values (such as `OPENRIG_SESSION_NAME`) and the allowed
provider sign-in variables. Not `OPENRIG_DB` or `TMUX_TMPDIR` (see Known gaps);
- the daemon's `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME` and
`CLAUDE_CONFIG_DIR`;
- the values named in tmux's `update-environment`;
- its own `env`.
Shell startup still runs. `rig setup` warns when it sees a custom tmux configuration.
- **After a reboot, the daemon restores a kernel whose seats are all detached** (and still bound to their sessions), in
the background, as `rig up kernel --existing` would. `OPENRIG_NO_KERNEL=1` still skips it, and `rig start --json` reports `kernelRestore`.
- **A seat missing a required startup file no longer blocks its rig's restore:** `rig up <rig> --existing` holds that
seat and restores the others (`partially_restored`).
- **Seats added to a running rig** with `rig expand`, `rig grow` or add-member get its culture file and rig-level
startup, for rigs created on 0.7.0 or later. Older rigs get no backfill.
- **A busy daemon gets the real request.** The CLI refuses only when the daemon is stopped or stale, the connection is
refused or blocked, or the daemon answering belongs to another OpenRig home. A slow daemon is no longer read as down.
- **A new seat's first startup message** says it's OpenRig's identity context, not a task. A seat whose startup sends
nothing else is asked to run `rig whoami --json` and say it's ready for an assignment.
- **A new Claude seat's first startup text** waits briefly (about five seconds) for Claude's input box before it's
sent.
- **Codex seats that select openrig-core get the plugin installed into Codex.** While Codex hooks are on
(`runtime.codex.hooks_enabled`, default on), OpenRig installs it with Codex's own marketplace and plugin commands at
daemon start and at a fresh launch, unless the person's Codex configuration disables it.
- **A first install only** (no earlier install or plugin cache): OpenRig checks the installed hook files against its own
copy, then trusts the plugin's six shipped hooks by their exact definitions, so the seat starts without Codex's hook
review.
The file check happens only when trust is granted: a later edit to the cached hook scripts keeps that trust, as with any hook trusted in Codex.
- **An existing install keeps the person's choices.** A plugin or hook they disabled stays disabled. One they left
untrusted stays under Codex's review: Codex records nothing for "continue without trusting", so OpenRig doesn't read a
missing trust record as permission.
- **What it adds:** the four managed inline hooks stay the only activity reporters; the plugin adds refocus and its
skills.
- **Watchdog delivery is recorded as it happened:** `rig watchdog status` outcomes are `sent`, `refused`, `failed`,
`skipped` or `terminal` (0.6.14 had `sent`, `skipped` or `terminal`). Every reminder carries "Delivery may repeat after
an unconfirmed attempt." under its header line, and two refusals in a row at one seat's prompt raise that seat's prompt
alert. A park wake shows the continuation its park recorded (`Recorded continuation: …`), so record one when you park.
- **Slack:** a thread reply answers a parked question only if it was written after the question posted, and a message
Slack rejects as `invalid_blocks` isn't posted again.
- **Claude Code team agents are asked for permission less often.** Housekeeping commands such as `archive`, `attach`,
`bind`, `release`, `reconcile-session`, `config set` and `seat continue` no longer prompt. `seat restart` and `policy
permissions apply` now do.
- **`rig up --plan` waits as long as an apply** (120 seconds). An unanswered plan exits 1 and is safe to run again.
- **`rig setup` keeps 10,000 rows of tmux history**, down from 50,000 (`terminal.history_limit`, 2000 to 1000000).
## Output that changed shape
- **`rig gateway human list`** shows each person's availability (`available`, `focus`, `away` or `off`) where it showed
`away` or `available`, plus a `quiet:` line; JSON adds `availability` and `quiet`.
- **`rig attach`** prints `Sender:` (and `Tmux:` when the names differ), and `rig whoami` adds `identity.senderAddress`.
A clashing member name is refused with `address_conflict`.
- **Send results** carry `managedCompaction`, and the CLI prints a `Notice:` line.
- **`rig ps --rig <unknown>`** exits 1, and `--host H --rig X` filters by rig.
## What to stop doing
- **Stop looking up a rig's ID before `rig launch`;** the name works.
- **Stop expecting `rig start --last` to bring back a rig taken down on purpose;** use `rig start --rigs <rig>` or `rig
up '<name>' --existing`.
- **Stop counting on `--dangerously-interact` to press Enter;** put CR in the payload.
- **Stop taking the preparation prompt's delivery as the compaction's result;** read the `compact` result and confirm by
effect.
- **Stop answering `--fresh` for an ordinary terminal seat on every restore.**
- **Stop branching on exit 2 for a fixable `rig queue update` refusal;** it's exit 1.
- **Stop recreating a seat, or editing the database, to change its permission policy;** use `rig policy apply --seat`.
- **Don't use `rig provider moves`:** it's gone with its setting. Report a subscription move as a queue item to whoever
asked.
- **Don't steer a new seat's account or keys through tmux globals.** Use the seat's `env` or `rig up --env`.
- **Don't close a row `done` with `--closure-reason blocked_on`.** It's refused with `invalid_closure_reason`; use `rig
queue block <id> --on <child>`.
- **Don't treat "daemon did not respond" as down.** Check the effect before any retry or restart.
- **Don't look up a rig id first** for the commands above; pass the name.
- **Don't restart the daemon to stop a managed compaction's follow-up prompts.** Use `rig compact --cancel`.
- **Don't run `rig up kernel --existing` by hand after a reboot** before checking `rig status`.
- **Don't reuse a `deliveryId` to deduplicate a send,** and don't pass `--reason` or `--actor` to `rig slack verify`.
## Known gaps in 0.6.12
## Known gaps in 0.7.0
- **Managed compaction's 600-character budget** rests on one comparison on Claude Code 2.1.282, and nothing confirms
compaction ran.
- **The plugin hooks haven't run through this path in a real session,** and two hook runs for one seat aren't
serialized: a post-compaction record can be lost or a refocus delivered twice.
- **One Slack app across machines was simulated in one process,** never run on two real machines. Relayed events are
trusted from the calling daemon, and `rig slack verify` on the hub doesn't check relayed channels.
- **`env:` values aren't secret,** and members added with `rig grow` can't carry a member-level `env:`.
- **`rig up --env` is not forwarded for flat-node specs or v1 bundles,** although the launch may succeed. Do not rely on
the flag for those formats in this release.
- **The draft-aware guard waits for idle inside the seat's delivery lease,** which can delay other work for that seat.
- **With `bundle.services_enabled` on, `rig up <file>.rigbundle` installs into the current folder** when no `--target` is
given.
- **`rig seat restart --keep-conversation` supports Linux only,** and hasn't been re-run on 0.6.12.
- **The rig status API can call a running rig `blocked`** because of one seat that isn't running: it folds a restore
forecast into current status.
- **A Codex seat can keep reading as at its usage limit** in a constructed screen case not seen in a real session.
- **After `rig scope slice create` says it couldn't verify membership, the slice was kept.** Check `mission.yaml` before
creating it again.
- **A Claude Code seat whose permission request was approved automatically can still read "needs input";** the Codex
case is fixed.
- **Codex team seats aren't asked before lifecycle commands yet.**
- **The send gate trusts the most recently recorded hook;** `rig capture` shows the real screen.
- **A launched team still writes into its working folder;** the operator says what changes before you agree.
- **After a restart, `rig status` shows `Kernel: skipped` whatever the kernel seats' state.** Read them with
`rig ps --nodes --rig kernel --full`.
- **`rig tui --shared` still attaches on the default tmux server,** so use `rig tui` when the daemon runs on a non-default
tmux socket.
- **A new seat doesn't get the daemon's `OPENRIG_DB` or `TMUX_TMPDIR`.** A tool in the seat that reads `OPENRIG_DB`
directly can fall back to the default database. If you rely on either, pass it in the seat's `env` or with `rig up
--env`.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment, and the seat may lack OpenRig's variables. Check `env` in the seat before relying on them.
- **A Slack channel-mapping change needs a daemon restart** (`rig daemon stop`, then `rig daemon start`). Don't use `rig
slack disable` then `enable` for it: enabling marks any alerts waiting to post to Slack as already seen, so open
exchanges miss them.
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
its prompt, press Enter in its pane, or ask the person to.
- **The TUI's terminal button refuses inside a Herdr pane reached over SSH.** Start the TUI with `env -u SSH_CONNECTION
-u SSH_CLIENT -u SSH_TTY rig tui`, or run `rig terminal open '<view>' --provider herdr`.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
acting on it.
- **Ubuntu 22.04's stock tmux 3.2a can crash its whole server** when OpenRig checks a pane that has just closed. Avoid
tmux 3.2a for OpenRig (`tmux -V`).
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.6.12",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.6.12",
"version": "0.7.0",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7068,7 +7068,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.6.12",
"version": "0.7.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7103,7 +7103,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.6.12",
"version": "0.7.0",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7145,7 +7145,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.6.12",
"version": "0.7.0",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.6.12",
"version": "0.7.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.6.12",
"version": "0.7.0",
"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": [
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.16",
"version": "0.1.17",
"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.16",
"version": "0.1.17",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -41,27 +41,15 @@ This plugin ships dual-manifest packaging — both `.claude-plugin/` and `.codex
`CLAUDE_PROJECT_DIR` doesn't follow a `cd`. A Claude started in the seat's own project folder is the same project and
still acts as the seat. A seat launched before this change has no `OPENRIG_SEAT_PROJECT_DIR` until its next launch, restore
or handover, and behaves as before until then.
- **Codex CLI** — Codex loads a plugin only once it's installed through its own marketplace path and enabled. When the
daemon manages Codex seats with `runtime.codex.hooks_enabled` on, it installs this plugin with Codex's own commands
(`codex plugin marketplace add` for a local marketplace beside the vendored copy, then `codex plugin add
openrig-core@openrig`), under the daemon's `CODEX_HOME`.
- **What that writes:** Codex's install writes `[marketplaces.openrig]` and `[plugins."openrig-core@openrig"] enabled = true`
into that `config.toml`. Codex makes the plugin's skills available to every Codex session on that home.
- **Opting out:** set `enabled = false` for the plugin, or `[plugins._default] enabled = false` with no explicit value for
it. OpenRig then never installs or reinstalls it, for both the first install and an upgrade. It reads the system, user
and legacy managed config files; it can't see profile, project, session-flag, MDM or cloud-managed config.
- **Upgrades:** a new vendored version is reinstalled the same way, and only while the plugin isn't disabled.
- **Review:** each of the plugin's hooks still needs Codex's exact-hook review before it runs. OpenRig writes no trust
and no hook state for them.
- **Selected seats only:** every hook in `hooks/codex.json` starts with a shell guard, `test -n "$OPENRIG_SEAT_PROJECT_DIR"
&& test -f "$OPENRIG_NODE" && test -x "$OPENRIG_NODE" || exit 0`, before running anything. OpenRig sets both only for a
Codex seat whose resolved plugin selection includes this plugin (`OPENRIG_NODE` is the daemon's own absolute Node). So
an unselected seat or a personal session is a successful no-op, even with no `node` on its PATH.
- **Disclosed limit:** a Codex started from inside a selected seat's shell inherits those variables and acts as the seat.
- **Activity can be reported twice, never zero times:** on Codex the plugin's activity relay always reports. Whether
Codex also runs the daemon's own managed relay for an event depends on hook state merged from every user layer (a
selected profile included) and session flags, which the plugin can't see. So with both active, each activity event
(SessionStart, UserPromptSubmit, Stop, PermissionRequest) is reported twice on a selected Codex seat.
- **Codex CLI** — OpenRig installs the plugin automatically when a Codex seat selects it and hooks are enabled.
On the first installation, it trusts only the six exact shipped hook definitions, after checking installed hook files
and scripts against the daemon's packaged copy. Existing plugin disables and hook choices remain in place across
restart and upgrade. An existing untrusted plugin stays under native review: Codex does not persist "continue without
trusting", so an absent trust record on an existing installation is not treated as permission.
- **One activity producer:** the four managed inline hooks report Codex activity; the plugin's activity entries are
no-ops. The plugin supplies refocus and skills. Disabling an inline activity hook is not bypassed by the plugin.
- **Seat scope:** the commands require both `OPENRIG_SEAT_PROJECT_DIR` and the executable `OPENRIG_NODE`. Personal and
unselected sessions are no-ops. A nested Codex inheriting those seat variables still acts as that seat.
Generated Claude settings commands for the activity relay and context collector use the daemon's absolute Node
executable when its name is recognized (`node` or `node.exe`); other names keep the legacy bare `node` command and
@@ -277,10 +277,9 @@ function settingsRelayReports(hookEvent, providerPayload, env = process.env) {
function pluginRunIsRedundant(providerPayload, argv = process.argv, env = process.env) {
if (!argv.includes("--from-plugin")) return false;
const hookEvent = firstString(providerPayload.hookEvent, providerPayload.hookEventName, providerPayload.hook_event_name, providerPayload.event, providerPayload.eventName);
// Codex: the plugin's run always reports. Whether Codex runs the daemon's managed relay for this event depends on hook
// state merged from every user layer (a selected profile included) and session flags, which this script can't see, so it
// never assumes so. With both routes active an event is reported twice; it is never lost.
if (firstString(env.OPENRIG_RUNTIME, env.RIGGED_RUNTIME) === "codex") return false;
// Codex activity has one producer: OpenRig's managed inline hooks. The plugin supplies refocus and skills, but its
// activity entries are no-ops. Do not infer merged native hook state here or bypass a person's inline-hook disable.
if (firstString(env.OPENRIG_RUNTIME, env.RIGGED_RUNTIME) === "codex") return true;
return settingsRelayReports(hookEvent, providerPayload, env);
}
@@ -13,6 +13,7 @@ const EVENT_LABELS = {
SessionStart: "session_start",
UserPromptSubmit: "user_prompt_submit",
Stop: "stop",
PostCompact: "post_compact",
PermissionRequest: "permission_request",
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.6.12",
"version": "0.7.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -225,11 +225,17 @@ host in front of you.
process are gone. If the command returns while either remains, stop: inspect
identity and use the host's authorized recovery procedure. Do not blindly
signal a PID.
7. Start the target with `rig daemon start` or the host's known wrapper. Verify
daemon status, process path, listener, version, and database integrity.
8. Run the plugin refresh helper in plan mode. Apply safe writes only after the
three roots and classifications make sense; resolve preserved paths one at a
time when required.
7. Before starting the target, account for its startup refresh of managed plugins
and marked global skills. This runtime preserves differing replaced files under
`$OPENRIG_HOME/backups/managed-refresh/` and reports each recovery path; if the
target lacks that preservation, copy those trees first. Start it with
`rig daemon start` or the host's known wrapper. Verify daemon status, process
path, listener, version, and database integrity. Recovery copies retain bytes;
they do not promise custom behaviour remains active after refresh.
8. Run the plugin refresh helper in plan mode. Its plan governs only the helper's
later writes, not startup writes that already happened. Apply safe writes only
after the three roots and classifications make sense; resolve preserved paths
one at a time when required.
9. Verify protected seats with `rig ps --nodes -A --json`, representative
`rig capture`, and `rig restore-check` where applicable. `-A` is required:
without it the node read is your CURRENT rig only, and a daemon upgrade
@@ -14,7 +14,7 @@ import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { resolveReadinessTimeoutMs } from "../domain/readiness-timeout.js";
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, witnessedClaudeVersionLabel, type ClaudeLaunchedProcess, type NativeProcessLister, type VersionLabelWitnessWait } from "../domain/native-process-lineage.js";
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { mergeManagedBlock, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { shellQuote } from "./shell-quote.js";
@@ -60,7 +60,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
private nodeExecutable: string;
private autoDriveProviderPrompts: boolean;
private listProcesses?: NativeProcessLister;
private resolvePermissionMode?: (nodeId: string) => string | undefined;
private readinessTimeoutMs?: number;
private autoLaunches = new Map<string, { binding: NodeBinding; token: string; freshInPlace?: boolean; executable?: string; scriptLaunch?: boolean; fingerprint?: string; process?: ClaudeLaunchedProcess }>();
readonly claudeManagedLaunch?: ClaudeManagedLaunch;
@@ -82,9 +81,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
nodeExecutable?: string;
autoDriveProviderPrompts?: boolean;
listProcesses?: NativeProcessLister;
/** #86 — the seat's persisted managed permission mode (NativePermissionStore.resolve), undefined when
* unmanaged; throws when it can't be read. Absent → every seat counts as managed for the version-label witness. */
resolvePermissionMode?: (nodeId: string) => string | undefined;
/** Bound on a slow resume's wait, from the launch (default: runtime.readiness_timeout_seconds). */
readinessTimeoutMs?: number;
claudeManagedLaunch?: ClaudeManagedLaunch;
@@ -107,7 +103,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
this.nodeExecutable = deps.nodeExecutable ?? process.execPath;
this.autoDriveProviderPrompts = deps.autoDriveProviderPrompts ?? false;
this.listProcesses = deps.listProcesses;
this.resolvePermissionMode = deps.resolvePermissionMode;
this.readinessTimeoutMs = deps.readinessTimeoutMs;
this.claudeManagedLaunch = deps.claudeManagedLaunch;
this.activityRelayPath = deps.activityRelayPath ?? null;
@@ -408,13 +403,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
// -- Private helpers --
/** #86: only a launch binding carries permissionMode, so the seat's persisted selection decides for every other
* caller. Unmanaged needs both to say so; a missing or unreadable selection counts as managed. */
private isUnmanaged(binding: NodeBinding): boolean {
if (binding.permissionMode !== undefined || !this.resolvePermissionMode) return false;
try { return this.resolvePermissionMode(binding.nodeId) === undefined; } catch { return false; }
}
/** A fresh in-place launch keeps the retiree's scrollback. Qualify a history-based ready result
* with the current frame, only for the exact launch that requested it. Unavailable observations
* retain the old admission (including its ambiguity); visible gates and conflicting identity do not. */
@@ -453,16 +441,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
return withIdentity.status === "resumed" && occupant !== "absent" ? historyProbe : current;
}
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string, options?: ReadinessOptions, witnessWait?: VersionLabelWitnessWait) {
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string, options?: ReadinessOptions) {
const input = { runtime: "claude-code", paneCommand, paneContent };
const probe = assessNativeResumeProbe(input);
// #86: an unmanaged native install's version label reads as `claude` once its OS executable is witnessed. The
// witness runs only when the label would change the result; a managed seat keeps its exact proof below.
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
if (probe.status !== "resumed" && asClaude.status === "resumed" && this.isUnmanaged(binding) && binding.tmuxSession
&& await witnessedClaudeVersionLabel(paneCommand, { target: binding.tmuxSession, tmux: this.tmux, listProcesses: this.listProcesses }, witnessWait)) {
return asClaude;
}
// Headerless managed launches (including bypass mode) use the same readiness
// precedence as resume. This candidate result is returned only after the
// exact launch identity below is proved; screen text alone is insufficient.
@@ -582,13 +563,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const checkEvery = 5;
let attempts = windowAttempts;
let slowStart = false;
// #86: one record per wait; after an actual path-read timeout the rest of this wait skips the witness.
const witnessWait: VersionLabelWitnessWait = { pathReadTimedOut: false };
for (let attempt = 0; attempt < attempts; attempt++) {
const paneCommand = await this.tmux.getPaneCommand(tmuxSession);
const paneContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent, undefined, witnessWait);
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent);
if (probe.code === "no_conversation_found") {
return {
@@ -672,7 +651,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const finalCommand = await this.tmux.getPaneCommand(tmuxSession);
const finalContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
const finalProbe = await this.assessManagedProbe(binding, finalCommand, finalContent, undefined, witnessWait);
const finalProbe = await this.assessManagedProbe(binding, finalCommand, finalContent);
if (finalProbe.status === "resumed") {
return { ok: true };
+13 -18
View File
@@ -6,7 +6,7 @@ import { shellQuote } from "./shell-quote.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { claudePostureFlag, claudeClassicRendererEnvPrefix } from "./yolo-mode.js";
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { verifyClaudePaneProcess, verifyPaneIdleShell, witnessedClaudeVersionLabel, type ClaudeLaunchedProcess, type NativeProcessLister, type VersionLabelWitnessWait } from "../domain/native-process-lineage.js";
import { verifyClaudePaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
@@ -106,7 +106,7 @@ export class ClaudeResumeAdapter {
return { ok: false, code: "resume_failed", message: keyResult.message };
}
const result = await this.verifyResume(tmuxSessionName, resumeToken!, !managed).catch((error): ResumeResult => ({
const result = await this.verifyResume(tmuxSessionName, resumeToken!).catch((error): ResumeResult => ({
ok: false,
code: "attention_required",
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
@@ -114,29 +114,20 @@ export class ClaudeResumeAdapter {
return result.ok ? { ...result, appliedLaunch } : result;
}
/** #86: as the launch adapter's probe, an unmanaged native install's version label reads as `claude` once its OS
* executable is witnessed. The witness runs only when the label would change the result. */
private async assessProbe(target: string, paneCommand: string | null, paneContent: string, unmanaged: boolean, witnessWait: VersionLabelWitnessWait) {
const input = { runtime: "claude-code", paneCommand, paneContent };
const probe = assessNativeResumeProbe(input);
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
if (unmanaged && probe.status !== "resumed" && asClaude.status === "resumed"
&& await witnessedClaudeVersionLabel(paneCommand, { target, tmux: this.tmux, listProcesses: this.options.listProcesses }, witnessWait)) return asClaude;
return probe;
}
private async verifyResume(tmuxSessionName: string, resumeToken: string, unmanaged: boolean): Promise<ResumeResult> {
private async verifyResume(tmuxSessionName: string, resumeToken: string): Promise<ResumeResult> {
const pollMs = this.options.pollMs ?? 200;
const maxWaitMs = this.options.maxWaitMs ?? 5_000;
const sleepFn = this.options.sleep ?? sleep;
const attempts = Math.max(1, Math.floor(maxWaitMs / Math.max(pollMs, 1)) + 1);
// #86: one record per wait; after an actual path-read timeout the rest of this wait skips the witness.
const witnessWait: VersionLabelWitnessWait = { pathReadTimedOut: false };
for (let attempt = 0; attempt < attempts; attempt++) {
const paneCommand = await this.tmux.getPaneCommand(tmuxSessionName);
const paneContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
const probe = await this.assessProbe(tmuxSessionName, paneCommand, paneContent, unmanaged, witnessWait);
const probe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand,
paneContent,
});
if (probe.code === "no_conversation_found") {
return {
@@ -171,7 +162,11 @@ export class ClaudeResumeAdapter {
const finalCommand = await this.tmux.getPaneCommand(tmuxSessionName);
const finalContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
const finalProbe = await this.assessProbe(tmuxSessionName, finalCommand, finalContent, unmanaged, witnessWait);
const finalProbe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand: finalCommand,
paneContent: finalContent,
});
if (finalProbe.code === "no_conversation_found") {
return { ok: false, code: "retry_fresh", message: "Claude resume failed: no conversation found for the requested session" };
@@ -59,6 +59,7 @@ const defaultCodexCli: CodexCliRunner = (args, env) => new Promise((resolve) =>
// Codex's hook trust recipe (the key and hash Codex records for a trusted hook), kept in one script.
const codexHookTrust = createRequire(import.meta.url)("../../assets/plugins/openrig-core/hooks/scripts/codex-hook-trust.cjs") as {
computeCodexHookTrust(event: string, input: CodexHookTrustInput): { key: string; hash: string };
codexHookHash(eventLabel: string, handler: { command: string; timeoutSec: number; matcher?: string | null; statusMessage?: string | null }): string;
};
@@ -306,10 +307,9 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
/**
* Install the vendored openrig-core plugin into Codex through Codex's own commands, so a seat that selected it gets its
* refocus and activity hooks (each still subject to Codex's exact-hook review). It runs only when the person's config
* refocus and skills, trusting only exact shipped hooks on its first install. It runs only when the person's config
* doesn't disable the plugin (codexCorePluginEnablement), and only when the installed version differs from the vendored
* one. It writes nothing into Codex's plugin cache and no hook state or trust; the only file it writes itself is the
* marketplace manifest beside the vendored plugin. Returns what it did, for the caller's report.
* one. Codex owns its plugin cache; OpenRig writes the marketplace manifest and first-install exact-hook trust.
*/
async ensureCodexCorePlugin(): Promise<{ outcome: "installed" | "current" | "skipped"; reason: string }> {
const report = (outcome: "installed" | "current" | "skipped", reason: string) => {
@@ -323,6 +323,23 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
catch { return report("skipped", "the vendored plugin has no readable Codex manifest"); }
if (typeof version !== "string" || !/^[0-9A-Za-z._-]+$/.test(version)) return report("skipped", "the vendored plugin's Codex manifest has no usable version");
const shipped = nodePath.resolve(import.meta.dirname, "../../assets/plugins/openrig-core");
const ownFiles: string[] = [];
const collect = (dir: string, relative = "hooks") => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const child = nodePath.join(relative, entry.name);
if (entry.isDirectory()) collect(nodePath.join(dir, entry.name), child);
else ownFiles.push(child);
}
};
collect(nodePath.join(shipped, "hooks"));
ownFiles.push(".codex-plugin/plugin.json");
const matchesShipped = (dir: string) => ownFiles.every(relative => {
try { return fs.readFileSync(nodePath.join(dir, relative)).equals(fs.readFileSync(nodePath.join(shipped, relative))); }
catch { return false; }
});
if (!matchesShipped(pluginDir)) return report("skipped", "vendored hook files differ from this daemon's shipped plugin; existing choices are left alone");
const codexHome = nodePath.dirname(this.resolveCodexConfigPath());
const layers: CodexConfigLayerRead[] = codexReadableConfigLayers(codexHome, this.codexSystemConfigDir).map((layer) => {
if (!this.fs.exists(layer.path)) return { ...layer, state: "absent" as const };
@@ -345,7 +362,14 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// Current only when Codex would load it: the vendored version is in its store AND the user config still registers it
// (our marketplace, and an explicit enabled = true, which Codex's install writes). Codex loads only configured
// plugins, so a cache left behind after the entry or the marketplace was removed is installed again.
const cached = this.fs.exists(nodePath.join(codexHome, "plugins", "cache", CODEX_CORE_MARKETPLACE, CODEX_CORE_PLUGIN, version));
const cacheRoot = nodePath.join(codexHome, "plugins", "cache", CODEX_CORE_MARKETPLACE, CODEX_CORE_PLUGIN);
const cachedDir = nodePath.join(cacheRoot, version);
const cached = this.fs.exists(cachedDir);
// Codex's "continue without trusting" choice writes no state. An existing install/configuration may therefore be a
// decline even with no hooks.state entry. Only the very first installation earns automatic trust; reinstalls and
// upgrades retain whatever native choices already exist, including an unrecorded decline.
const firstInstall = !this.fs.exists(cacheRoot) && !layers.some(layer => layer.state === "read"
&& Object.hasOwn((parseToml(layer.content).plugins ?? {}) as object, CODEX_CORE_PLUGIN_KEY));
if (cached && recorded !== undefined && userLayerEnablesCorePlugin(userConfig)) {
return report("current", `${CODEX_CORE_PLUGIN_KEY} ${version} is installed and registered`);
}
@@ -362,6 +386,30 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
const installed = await this.runCodexCli(["plugin", "add", CODEX_CORE_PLUGIN_KEY], env);
if (installed.status !== 0) return report("skipped", `codex plugin add failed: ${(installed.stderr || installed.stdout).trim() || `exit ${installed.status}`}`);
// Trust is over normalized definitions, not script contents. Byte equality is an additional pre-grant check;
// Codex remains responsible for later native review if definitions change. No cache files are rewritten here.
if (firstInstall && matchesShipped(cachedDir)) {
let content = this.fs.readFile(this.resolveCodexConfigPath());
const hookManifest = JSON.parse(fs.readFileSync(nodePath.join(shipped, "hooks/codex.json"), "utf8"));
for (const [event, groups] of Object.entries(hookManifest.hooks) as Array<[string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout: number }> }>]> ) {
groups.forEach((group, groupIndex) => group.hooks.forEach((handler, handlerIndex) => {
const { key, hash } = codexHookTrust.computeCodexHookTrust(event, {
...handler, timeoutSec: handler.timeout, matcher: group.matcher,
keySource: `${CODEX_CORE_PLUGIN_KEY}:hooks/codex.json`, groupIndex, handlerIndex,
});
// Any existing choice in any readable layer/profile wins; never overwrite enabled=false or an old hash.
const hasChoice = (doc: unknown): boolean => {
if (!doc || typeof doc !== "object") return false;
const value = doc as { hooks?: { state?: Record<string, unknown> } };
return Object.hasOwn(value.hooks?.state ?? {}, key) || Object.values(doc).some(hasChoice);
};
if (!hasChoice(parseToml(content)) && !layers.some(layer => layer.state === "read" && hasChoice(parseToml(layer.content)))) {
content = upsertCodexHookTrust(content, key, hash);
}
}));
}
this.fs.writeFile(this.resolveCodexConfigPath(), content);
}
return report("installed", `${CODEX_CORE_PLUGIN_KEY} ${version} through Codex's own install (${enablement.reason})`);
}
@@ -1584,7 +1632,7 @@ export function computeCodexHookTrust(
* `[hooks.state."<key>"] trusted_hash = "<hash>"` record. Mirrors upsertCodexProjectTrust:
* find/create the exact table header, splice ONLY its `trusted_hash` line, leave every other
* `[hooks.state]` / `[projects]` entry and the managed hook block byte-identical. Same key+hash
* ⇒ no-op. Only ever called for OUR 4 authored hook keys (never a blanket/wildcard trust).
* ⇒ no-op. Only called for our authored inline or exact first-install plugin keys (never blanket trust).
*/
export function upsertCodexHookTrust(content: string, key: string, hash: string): string {
const header = `[hooks.state.${JSON.stringify(key)}]`;
@@ -297,6 +297,8 @@ export function resolveStubActivityEndpoint(env: NodeJS.ProcessEnv): { baseUrl:
export const STUB_CLOCK_ANNOUNCEMENT = "OPENRIG_TEST_CLOCK_NOW active — timestamps are injected";
export async function runStubRunner(args: StubRunnerArgs): Promise<void> {
// Discard input after the terminal echoes it, so repeated sends cannot fill its unread input queue.
process.stdin.resume();
// PRD §5 (no wall-clock in the stub's OWN behavior): the runner's own stamps honor
// the same A3-R3 injectable clock the compaction assets use — OPENRIG_TEST_CLOCK_NOW
// (an ISO instant) when set, real wall-clock otherwise (absent = production).
+6 -8
View File
@@ -8,7 +8,7 @@ import type { TmuxAdapter } from "../adapters/tmux.js";
import type { TranscriptStore } from "./transcript-store.js";
import { startTmuxTranscriptCapture } from "./transcript-capture.js";
import { deriveResumeToken, type ResumeTokenCaptureDeps } from "./resume-token-capture.js";
import { verifyClaudePaneRuntime, verifyCodexPaneProcess, witnessedClaudeVersionLabel, type NativeProcessLister } from "./native-process-lineage.js";
import { verifyClaudePaneRuntime, verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
import {
observeSolePane,
paneObservationVerdict,
@@ -81,7 +81,7 @@ interface ClaimServiceDeps {
};
/** #421 — start time of the pane's current Claude process; a sidecar sampled earlier is skipped. */
claudeProcessStartedAt?: ResumeTokenCaptureDeps["claudeProcessStartedAt"];
/** Process census for the runtime proof in `reconcileSession` and #86's native version-label witness; defaults to the live process table. */
/** Process census for the runtime proof in `reconcileSession`; defaults to the live process table. */
listProcesses?: NativeProcessLister;
resumeTokenCapturer?: {
captureCodexThreadId(sessionName: string): Promise<string | undefined>;
@@ -527,10 +527,11 @@ export class ClaimService {
const projectionDrift: string[] = [];
const paneObservation = await this.observeBindingPane(sessionName);
if (!paneObservation.ok) projectionDrift.push(paneObservation.detail);
const paneTarget = paneObservation.ok ? paneObservation.pane : sessionName;
let paneCommand: string | null = null;
try {
paneCommand = await this.tmuxAdapter.getPaneCommand(paneTarget);
paneCommand = await this.tmuxAdapter.getPaneCommand(
paneObservation.ok ? paneObservation.pane : sessionName,
);
} catch { paneCommand = null; }
if (nodeRow.runtime) {
const expectation: Record<string, string[]> = {
@@ -542,10 +543,7 @@ export class ClaimService {
if (!paneCommand) {
projectionDrift.push(`runtime unverified: pane command unreadable; node declares runtime "${nodeRow.runtime}"`);
} else if (expected && expected.length > 0 && !expected.includes(paneCommand)
// #86: a native install's version label confirms claude-code only with its OS executable witness.
&& !(nodeRow.runtime === "claude-code" && await witnessedClaudeVersionLabel(paneCommand,
{ target: paneTarget, tmux: this.tmuxAdapter, listProcesses: this.listProcesses }))
&& !await this.runtimeProvenInPane(nodeRow.runtime, paneTarget)) {
&& !await this.runtimeProvenInPane(nodeRow.runtime, paneObservation.ok ? paneObservation.pane : sessionName)) {
// A script-launched runtime reads as its launcher (`sh`); only a pane whose process tree shows no such runtime is drift.
projectionDrift.push(`runtime unverified: pane command "${paneCommand}" does not confirm runtime "${nodeRow.runtime}"`);
}
@@ -18,16 +18,9 @@ function run(argv) {
}));
}`;
/** execFile's own timeout kill (Node sets killed with the kill signal); a maxBuffer overflow or a failed run isn't one. */
function isTimeoutKill(error: unknown): boolean {
const e = error as { killed?: unknown; signal?: unknown } | null;
return e?.killed === true && e.signal === "SIGTERM";
}
/** Optional OS executable witnesses. Unavailable, exited and over-budget PIDs
* have no witness; this never substitutes argv or a version string for a path.
* `onTimeout` hears only an actual read timeout, never any other missing path. */
export async function readNativeExecutablePaths(requested: number[], opts: { onTimeout?: () => void } = {}): Promise<Map<number, string>> {
* have no witness; this never substitutes argv or a version string for a path. */
export async function readNativeExecutablePaths(requested: number[]): Promise<Map<number, string>> {
const pids = [...new Set(requested.filter(pid => Number.isInteger(pid) && pid > 0 && pid <= 2147483647))].slice(0, 1024);
const result = new Map<number, string>();
if (pids.length === 0) return result;
@@ -49,9 +42,6 @@ export async function readNativeExecutablePaths(requested: number[], opts: { onT
if (typeof path === "string" && path.startsWith("/") && !path.includes("\0")
&& !path.split("/").some(part => part === "." || part === "..")) result.set(entry[0], path);
}
} catch (error) {
// A missing OS witness is unknown, not positive identity.
if (isTimeoutKill(error)) opts.onTimeout?.();
}
} catch { /* A missing OS witness is unknown, not positive identity. */ }
return result;
}
@@ -439,28 +439,22 @@ function mayBeClaudeRuntime(row: NativeProcessRow): boolean {
/** The same OS observation serves menu input, restore proof and periodic identity.
* Older callers may carry only pid/ppid/command; that is insufficient positive Codex proof. */
export async function listNativeProcesses(opts: NativeProcessListOptions = {}): Promise<NativeProcessRow[]> {
export async function listNativeProcesses(options: { claudeLaunchPaths?: boolean } = {}): Promise<NativeProcessRow[]> {
try {
// Copies: the table is shared with concurrent readers, and executable paths are added below.
const rows: NativeProcessRow[] = (await runAsyncSite("codex.runtime.list_processes", readProcessTable)).map((row) => ({ ...row }));
// `claudeLaunchPaths`: the caller is proving a launch through a `#!` script, which needs the OS path of the
// foreground path-named Claude too. Every other listing skips that read (on macOS it starts a helper process).
const candidates = rows.filter(row => needsClaudeExecutablePath(row) || (opts.claudeLaunchPaths === true && mayBeClaudeAfterScript(row)));
const candidates = rows.filter(row => needsClaudeExecutablePath(row) || (options.claudeLaunchPaths === true && mayBeClaudeAfterScript(row)));
if (candidates.length > 0) {
const pids = candidates.map(row => row.pid);
const onTimeout = opts?.onPathReadTimeout;
const paths = await (onTimeout ? readNativeExecutablePaths(pids, { onTimeout }) : readNativeExecutablePaths(pids));
const paths = await readNativeExecutablePaths(candidates.map(row => row.pid));
for (const row of candidates) row.executablePath = paths.get(row.pid);
}
return rows;
} catch { return []; }
}
/** `claudeLaunchPaths`: also read the OS path of a foreground path-named Claude (a `#!` script launch).
* `onPathReadTimeout` (#86): told when the OS path read actually timed out. */
export interface NativeProcessListOptions { claudeLaunchPaths?: boolean; onPathReadTimeout?: () => void }
export type NativeProcessLister = (opts?: NativeProcessListOptions) => NativeProcessRow[] | Promise<NativeProcessRow[]>;
export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProcessRow[]>;
/** One OS process, by pid and `ps` start time: a reused pid has another start time. */
export interface ClaudeLaunchedProcess { pid: number; startedAt: string }
@@ -733,30 +727,6 @@ export async function verifyClaudePaneRuntime(input: Parameters<typeof observeCl
return second?.fingerprint === first.fingerprint ? second : null;
}
const NATIVE_VERSION_LABEL = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
/** One wait's record that the optional path witness timed out, so the rest of that wait skips it. */
export interface VersionLabelWitnessWait { pathReadTimedOut: boolean }
/** #86: the native installer names Claude's process, and so the pane label, after its versioned file. Whether that
* label may stand for `claude`: only when the pane's one stable foreground Claude is the version-named process
* itself, witnessed by its OS executable path (`claudeProcess`: argv0 claude, the installer's versions layout, a
* basename equal to the OS name), and the pane's pid is unchanged. The label alone is never identity, and a path
* read that fails or times out is no witness. Like a `claude` label, this says nothing about the conversation.
* Within one `wait`, once a path read actually times out, the witness isn't tried again; a new wait starts fresh. */
export async function witnessedClaudeVersionLabel(paneCommand: string | null,
input: Omit<Parameters<typeof observeClaudePaneRuntime>[0], "selectedExecutable">, wait?: VersionLabelWitnessWait): Promise<boolean> {
const label = paneCommand?.trim() ?? "";
if (!NATIVE_VERSION_LABEL.test(label) || wait?.pathReadTimedOut) return false;
const lister = input.listProcesses ?? listNativeProcesses;
const onPathReadTimeout = () => { if (wait) wait.pathReadTimedOut = true; };
const native = await verifyClaudePaneRuntime({ ...input, listProcesses: () => lister({ onPathReadTimeout }) });
// argv0 `claude` keeps the OS path the deciding evidence: a versioned argv0 matches the OS name without it.
if (!native || executableName(tokens(native.process.command)[0] ?? "") !== "claude"
|| native.process.executableName !== label || !native.process.executablePath) return false;
return await input.tmux.getPanePid(input.target).catch(() => null) === native.panePid;
}
/** The `ps` start time (lstart, local time) of the one stable Claude foreground
* process. This makes no claim about its conversation token. */
export async function observeClaudePaneStartedAt(input: Parameters<typeof observeClaudePaneRuntime>[0]): Promise<string | null> {
@@ -22,11 +22,13 @@
// expected normal-state response until a separate publish authorization.
import nodePath from "node:path";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
export interface PluginVendorFs {
readFile(path: string): string;
writeFile(path: string, content: string): void;
/** Copy raw bytes, failing if the recovery path already exists. */
copyFileExclusive(source: string, target: string): void;
exists(path: string): boolean;
mkdirp(path: string): void;
listFiles(dir: string): string[];
@@ -168,13 +170,13 @@ export class PluginVendorService {
}
}
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
this.fs.mkdirp(targetDir);
const files = this.fs.listFiles(sourceDir);
for (const relPath of files) {
for (const [relPath, content] of updates) {
const srcPath = nodePath.join(sourceDir, relPath);
const destPath = nodePath.join(targetDir, relPath);
const content = this.fs.readFile(srcPath);
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
@@ -187,6 +189,25 @@ export class PluginVendorService {
}
}
/** Preserve every replaced file before touching a target, including its version authority. */
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
try {
for (const [rel, content] of new Map(updates)) {
const dest = nodePath.join(targetDir, rel);
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
const backup = nodePath.join(backupDir, rel);
this.fs.mkdirp(nodePath.dirname(backup));
this.fs.copyFileExclusive(dest, backup);
this.logger(`[plugin-vendor] preserved previous contents of ${dest} at ${backup} before managed refresh`);
}
return true;
} catch (error) {
this.logger(`[plugin-vendor] preservation failed for ${targetDir}: ${(error as Error).message}; refresh skipped, existing files kept`);
return false;
}
}
/**
* Reapply the source file's permission bits to the staged/projected dest. Plain
* readFile+writeFile (writeFileSync) creates the dest with the process default mode,
@@ -255,11 +276,12 @@ export class PluginVendorService {
continue;
}
}
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
this.fs.mkdirp(targetDir);
for (const relPath of files) {
for (const [relPath, content] of updates) {
const srcPath = nodePath.join(sourceDir, relPath);
const destPath = nodePath.join(targetDir, relPath);
const content = this.fs.readFile(srcPath);
if (
this.fs.exists(destPath) &&
hashContent(this.fs.readFile(destPath)) === hashContent(content)
@@ -18,7 +18,7 @@ import { AppliedLaunchObservationStore } from "./applied-launch-observation-stor
import { NativePermissionStore } from "./native-permission-store.js";
import { RigRepository } from "./rig-repository.js";
import { SessionTransport, inspectStartupStagedText } from "./session-transport.js";
import { startupOwnCollapsedPaste, startupSubmissionEvidence, type StartupSubmissionDiagnostic } from "./startup-submission-evidence.js";
import { claudeInputBoxDrawn, startupNoInputLine, startupOwnCollapsedPaste, startupSubmissionEvidence, type StartupSubmissionDiagnostic } from "./startup-submission-evidence.js";
import type { AppliedLaunchObservation } from "./permission-drift.js";
import { resolveReadinessTimeoutMs } from "./readiness-timeout.js";
import { SettingsStore } from "./user-settings/settings-store.js";
@@ -924,7 +924,8 @@ export class StartupOrchestrator {
}
const firstSendTextIndex = postLaunchFiles.findIndex((file) => file.deliveryHint === "send_text");
let prompt = identityAction.value;
const identityContext = `OpenRig-generated identity context (not a project task):\n${identityAction.value}\nEnd of generated identity context.`;
let prompt = identityContext;
let remainingFiles = postLaunchFiles;
if (firstSendTextIndex !== -1) {
@@ -932,7 +933,7 @@ export class StartupOrchestrator {
try {
const content = this.readFile(firstSendText.absolutePath);
if (content.length > 0) {
prompt = `${identityAction.value}\n\n${content}`;
prompt = `${identityContext}\n\n${content}`;
remainingFiles = postLaunchFiles.filter((_, index) => index !== firstSendTextIndex);
}
} catch {
@@ -941,6 +942,10 @@ export class StartupOrchestrator {
}
}
if (firstSendTextIndex === -1 && !input.isRestore && !includeDurableObligations && !challengeBlock) {
prompt += "\n\nFor this identity-only startup, run rig whoami --json and reply with your identity and readiness for an assignment. Repository history, tool descriptions and your own replies are not assignments.";
}
if (includeDurableObligations) prompt += `\n\nThis is a fresh conversation. Before choosing work, derive your identity with rig whoami --json and read durable obligations with rig queue list --destination ${binding.tmuxSession} --state pending,in-progress,blocked --limit 10000 --full --json. Report truncation at the limit; a destination row is not permission to claim unrelated work.`;
// OPR.0.4.3.06 — the per-launch orientation challenge rides along with the
@@ -1030,6 +1035,7 @@ export class StartupOrchestrator {
const tmuxSession = input.binding.tmuxSession!;
input.lastSubmissionConfirmed = false;
input.lastSubmissionUnconfirmedReason = undefined;
if (input.adapter.runtime === "claude-code" && sendOrder === 1 && !input.isRestore) await this.awaitClaudeInputBox(input.binding.tmuxPane);
const textResult = await this.tmuxAdapter.sendText(tmuxSession, text);
if (!textResult.ok) {
return { error: (textResult as { message?: string }).message ?? "unknown" };
@@ -1121,16 +1127,34 @@ export class StartupOrchestrator {
}
/**
* Claude can take the startup Enter after the first look while the composer still shows our paste collapsed.
* Only that transient is looked at again, until the composer reads clear or staged, shows anything else, or
* the looks run out. A draft, ghost text or any other mismatch keeps its first-look verdict. Observation only.
* A fresh launch can read ready before Claude has drawn its input box: on Linux the pane names `claude` the moment
* Claude starts, and startup text pasted then sat unsent. Before a fresh start's first send, look at the visible
* screen until the input box the startup check reads is drawn, at most STARTUP_SUBMIT_SETTLE_LOOKS looks 200 ms
* apart. A screen that never shows one within that, or can't be read, is sent to as before. Observation only.
*/
private async awaitClaudeInputBox(pane: string | null | undefined): Promise<void> {
if (!pane) return;
for (let look = 0; look < STARTUP_SUBMIT_SETTLE_LOOKS; look++) {
let screen: string | null;
try { screen = await this.tmuxAdapter.capturePaneScreen(pane); } catch { return; }
if (screen === null || claudeInputBoxDrawn(screen)) return;
await this.sleep(200);
}
}
/**
* Claude can take the startup Enter after the first look while the composer still shows our paste collapsed, and a
* fresh Claude can still be drawing its screen, with no input line at all, when the first look comes. Only those
* two transients are looked at again, until the composer reads clear or staged, shows anything else, or the looks
* run out. A draft, ghost text, a dialog or any other mismatch keeps its verdict. Observation only.
* Remaining ambiguity: a person who clears a collapsed paste with the same line count inside the window reads
* as submitted.
*/
private async settleOwnPaste(tmuxSession: string, pane: string, text: string, runtime: string | null): Promise<{ pane: string; state: ReturnType<typeof inspectStartupStagedText>; looks: number }> {
let state = inspectStartupStagedText(pane, text, runtime);
let looks = 0;
while (state === "unverified" && looks < STARTUP_SUBMIT_SETTLE_LOOKS && startupOwnCollapsedPaste(pane, text, runtime)) {
while (state === "unverified" && looks < STARTUP_SUBMIT_SETTLE_LOOKS
&& (startupOwnCollapsedPaste(pane, text, runtime) || startupNoInputLine(pane, runtime))) {
await this.sleep(200);
// A failed re-look adds nothing; the last usable observation stands.
const next = await this.tmuxAdapter.capturePaneContent(tmuxSession, STARTUP_SUBMIT_CAPTURE_LINES).catch(() => null);
@@ -46,6 +46,17 @@ export function composerRegion(pane: string | null, runtime: string | null = nul
closingRuleLine: end < 0 ? null : end + 1, capturedLines: pane === null ? 0 : lines.length };
}
/** Whether Claude's input box is drawn: its input line, then a closing rule with the composer footer under it. */
export function claudeInputBoxDrawn(pane: string | null): boolean {
const { markerLine, closingRuleLine } = composerRegion(pane, "claude-code");
return markerLine !== null && closingRuleLine !== null;
}
/** Whether a screen has no input line at all, as while Claude is still drawing it: not a draft and not a dialog. */
export function startupNoInputLine(pane: string | null, runtime: string | null = null): boolean {
return Boolean(pane?.trim()) && composerRegion(pane, runtime).markerLine === null;
}
/** An echoed turn or a partial/opaque composer stays unverified. */
export function inspectStartupStagedText(pane: string | null, expected: string, runtime: string | null = null): "staged" | "clear" | "unverified" {
const { body } = composerRegion(pane, runtime);
+2 -2
View File
@@ -22,7 +22,6 @@ import { migrate } from "./db/migrate.js";
import { ALL_MIGRATIONS } from "./db/all-migrations.js";
import { RigRepository } from "./domain/rig-repository.js";
import { SessionRegistry } from "./domain/session-registry.js";
import { NativePermissionStore } from "./domain/native-permission-store.js";
import { deriveCanonicalSessionName, isHumanSeatSessionRef, parseSessionName } from "./domain/session-name.js";
import { resolveExternal } from "./domain/gateway/external-admission.js";
import { makeTopologyDestinationCheck } from "./domain/queue-destination-check.js";
@@ -834,7 +833,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, readFile: (p: string) => fs.readFileSync(p, "utf-8") });
const runtimeSettings = new ContextPackSettingsStore().resolveConfig();
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, resolvePermissionMode: (nodeId: string) => new NativePermissionStore(db).resolve(nodeId, "claude-code").permissionMode, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const codexAdapter = new CodexRuntimeAdapter({ prepareTeamWorkspace, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
return new SettingsStore().resolveOne("runtime.codex.hooks_enabled").value as boolean;
@@ -890,6 +889,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const realFs = {
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
exists: (p: string) => fs.existsSync(p),
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
listFiles: (dir: string) => {
@@ -1,293 +0,0 @@
// #86 — Claude's native installer names its process, and so tmux's pane label, after its versioned file (2.1.300).
// An unmanaged seat whose label is a version reads as `claude` only when the OS executable path witnesses native
// Claude. Every case runs through existing seams, so the same file runs unchanged on main: the witnessed cases fail
// there (timeout, no verified resume, reconcile drift) and the refusals already hold.
import { describe, it, expect, vi } from "vitest";
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
import type { NativeProcessLister, NativeProcessRow } from "../src/domain/native-process-lineage.js";
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
import { ClaimService } from "../src/domain/claim-service.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import { ContextUsageStore } from "../src/domain/context-usage-store.js";
import { ContextMonitor } from "../src/domain/context-monitor.js";
const VERSION = "2.1.300";
const NATIVE_PATH = `/fixture/.local/share/claude/versions/${VERSION}`;
const startedAt = "Fri Oct 16 01:00:00 2026";
// The staging subshell exec'd Claude, so Claude leads the pane's foreground group under the pane shell.
// Also used as a process lister, which may be handed list options: only a string names a command.
function rows(command?: unknown): NativeProcessRow[] {
if (typeof command !== "string") command = "claude --permission-mode acceptEdits --resume tok --name seat";
return [
{ pid: 10, ppid: 1, pgid: 10, tpgid: 12, executableName: "zsh", command: "-zsh", startedAt },
{ pid: 12, ppid: 10, pgid: 12, tpgid: 12, executableName: VERSION, executablePath: NATIVE_PATH, command: command as string, startedAt },
];
}
function mockTmux(label: string, panePid: () => Promise<number | null> = async () => 10): TmuxAdapter {
return {
sendText: vi.fn(async () => ({ ok: true as const })),
sendKeys: vi.fn(async () => ({ ok: true as const })),
sendShellCommand: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
getPaneCommand: vi.fn(async () => label),
// Claude hasn't drawn its prompt yet: no screen text can prove the resume.
capturePaneContent: vi.fn(async () => ""),
getPanePid: vi.fn(panePid),
listPanes: vi.fn(async () => []),
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
startPipePane: vi.fn(async () => ({ ok: true as const })),
stopPipePane: vi.fn(async () => ({ ok: true as const })),
setSessionOption: vi.fn(async () => ({ ok: true as const })),
getSessionOption: vi.fn(async () => null),
} as unknown as TmuxAdapter;
}
const fsOps = {
exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {}, listFiles: () => [],
} as unknown as ClaudeAdapterFsOps;
function binding(extra: Partial<NodeBinding> = {}): NodeBinding {
return { id: "b1", nodeId: "n1", tmuxSession: "dev-impl@rig", tmuxWindow: null, tmuxPane: null,
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/project", ...extra };
}
// The seat's persisted managed permission mode; by default the seat has no managed selection.
type PermissionModeReader = (nodeId: string) => string | undefined;
const adapter = (tmux: TmuxAdapter, listProcesses: () => NativeProcessRow[] | Promise<NativeProcessRow[]>,
resolvePermissionMode: PermissionModeReader | null = () => undefined) =>
new ClaudeCodeAdapter({ tmux, fsOps, listProcesses, sleep: async () => {}, ...(resolvePermissionMode ? { resolvePermissionMode } : {}) });
describe("#86 unmanaged launch wait", () => {
it.each([
["OpenRig's own resume command", rows()],
["a flag from the user's own claude, which exact proof can't parse", rows("claude --strict-mcp-config --permission-mode acceptEdits --resume tok --name seat")],
])("resumes a witnessed native version label: %s", async (_name, processRows) => {
const result = await adapter(mockTmux(VERSION), async () => processRows)
.launchHarness(binding(), { name: "seat", resumeToken: "tok" });
expect(result).toMatchObject({ ok: true, resumeToken: "tok" });
});
it("checkReady reads a witnessed native version label as Claude", async () => {
expect(await adapter(mockTmux(VERSION), rows).checkReady(binding())).toEqual({ ready: true });
});
});
describe("#86 refusals that stay refused", () => {
const awaiting = { ready: false, code: "awaiting_runtime" };
const cases: [string, NativeProcessRow[] | (() => NativeProcessRow[]), string?, (() => Promise<number | null>)?][] = [
["a path outside the native layout", rows().map(r => r.pid === 12 ? { ...r, executablePath: `/opt/other/${VERSION}` } : r)],
["a basename that isn't the OS name", rows().map(r => r.pid === 12 ? { ...r, executablePath: NATIVE_PATH.replace("300", "299") } : r)],
["argv0 that isn't claude", rows("node /fixture/cli.js --resume tok")],
["a version-only argv0", rows(`${VERSION} --resume tok`)],
["two candidates", [...rows(), { ...rows()[1]!, pid: 13 }]],
// listNativeProcesses leaves the path unset when the OS read fails or times out.
["a path read that failed or timed out", rows().map(r => ({ ...r, executablePath: undefined }))],
["argv0 naming the native path, with no OS path read", rows(`${NATIVE_PATH} --resume tok`).map(r => ({ ...r, executablePath: undefined }))],
["argv0 naming the native path, with an unrelated OS path", rows(`${NATIVE_PATH} --resume tok`).map(r => r.pid === 12 ? { ...r, executablePath: "/opt/other/claude" } : r)],
["a background process", rows().map(r => r.pid === 12 ? { ...r, pgid: 88 } : r)],
["a label naming a different version", rows(), "2.1.301"],
["a pane pid change after the samples", rows(), VERSION, vi.fn<() => Promise<number | null>>()
.mockResolvedValueOnce(10).mockResolvedValueOnce(10).mockResolvedValue(11)],
];
it.each(cases)("%s", async (_name, processRows, label = VERSION, panePid) => {
const list = typeof processRows === "function" ? processRows : () => processRows;
expect(await adapter(mockTmux(label, panePid), list).checkReady(binding())).toMatchObject(awaiting);
});
it("a process that changes between the two samples", async () => {
const list = vi.fn<() => NativeProcessRow[]>().mockReturnValueOnce(rows())
.mockReturnValue(rows().map(r => r.pid === 12 ? { ...r, startedAt: startedAt.replace("01:00:00", "01:00:01") } : r));
expect(await adapter(mockTmux(VERSION), list).checkReady(binding())).toMatchObject(awaiting);
});
});
describe("#86 results that don't change", () => {
it("a managed seat keeps its exact proof; the witness isn't consulted", async () => {
const list = vi.fn(rows);
expect(await adapter(mockTmux(VERSION), list).checkReady(binding({ permissionMode: "auto" })))
.toMatchObject({ ready: false, code: "awaiting_runtime" });
expect(list).not.toHaveBeenCalled();
});
it.each<[string, PermissionModeReader | null]>([
["a persisted managed selection, through a binding without permissionMode", () => "auto"],
["an unreadable selection", () => { throw new Error("Invalid persisted native permission selection; launch refused."); }],
["no selection reader", null],
])("the witness isn't consulted with %s", async (_name, resolvePermissionMode) => {
const list = vi.fn(rows);
expect(await adapter(mockTmux(VERSION), list, resolvePermissionMode).checkReady(binding()))
.toMatchObject({ ready: false, code: "awaiting_runtime" });
expect(list).not.toHaveBeenCalled();
});
it("a shell label over an idle shell stays a return to the shell", async () => {
// Claude has exited: the pane's shell leads the foreground group again, with nothing else in it.
const shellAlone = () => [{ ...rows()[0], tpgid: 10 }];
expect(await adapter(mockTmux("zsh"), shellAlone).checkReady(binding())).toMatchObject({ ready: false, code: "returned_to_shell" });
});
it("a claude label stays ready by name, with no process read", async () => {
const list = vi.fn(rows);
expect(await adapter(mockTmux("claude"), list).checkReady(binding())).toEqual({ ready: true });
expect(list).not.toHaveBeenCalled();
});
});
describe("#86 legacy restore resume", () => {
const resumeAdapter = (tmux: TmuxAdapter, listProcesses: () => NativeProcessRow[], extra = {}) =>
new ClaudeResumeAdapter(tmux, { listProcesses, pollMs: 1, maxWaitMs: 2, sleep: async () => {}, ...extra });
it("an unmanaged resume with a witnessed native version label is verified", async () => {
expect(await resumeAdapter(mockTmux(VERSION), rows).resume("dev-impl@rig", "claude_id", "tok", "/project"))
.toMatchObject({ ok: true });
});
it("an unwitnessed version label stays unverified", async () => {
const list = () => rows().map(r => ({ ...r, executablePath: undefined }));
expect(await resumeAdapter(mockTmux(VERSION), list).resume("dev-impl@rig", "claude_id", "tok", "/project"))
.toMatchObject({ ok: false, code: "attention_required" });
});
it("a managed resume keeps its exact proof; the witness isn't consulted", async () => {
const list = vi.fn(rows);
const claudeManagedLaunch = { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`,
assertCurrent: () => {}, configDir: "/fixture", executable: NATIVE_PATH }) } as unknown as ClaudeManagedLaunch;
const result = await resumeAdapter(mockTmux(VERSION), list, { claudeManagedLaunch })
.resume("dev-impl@rig", "claude_id", "tok", "/project", undefined, undefined, "auto", "n1");
expect(result).toMatchObject({ ok: false, code: "attention_required" });
expect(list).not.toHaveBeenCalled();
});
});
describe("#86 reconcile-session drift", () => {
async function reconcile(listProcesses: () => NativeProcessRow[]) {
const db = createFullTestDb();
const tmux = mockTmux(VERSION);
const setup = createTestApp(db, { tmux });
const deps = { db, rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, discoveryRepo: setup.discoveryRepo,
eventBus: setup.eventBus, tmuxAdapter: tmux, listProcesses };
const claims = new ClaimService(deps as ConstructorParameters<typeof ClaimService>[0]);
const rig = setup.rigRepo.createRig("drift-rig");
const node = setup.rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/work/repo", podId: null });
setup.sessionRegistry.markDetached(setup.sessionRegistry.registerSession(node.id, "dev-impl@drift-rig").id);
setup.sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@drift-rig" });
const result = await claims.reconcileSession({ sessionName: "dev-impl@drift-rig" });
db.close();
if (!result.ok) throw new Error(result.message);
return result.result;
}
it("a witnessed native version label confirms the runtime; continuity stays unverified", async () => {
const result = await reconcile(rows);
expect(result.projectionDrift.join(" ")).not.toContain("runtime unverified");
expect(result.continuity).toBe("unverified");
});
it("an unwitnessed version label keeps the drift line", async () => {
const result = await reconcile(() => rows().map(r => r.pid === 12 ? { ...r, executablePath: `/opt/other/${VERSION}` } : r));
expect(result.projectionDrift.join(" ")).toContain(`runtime unverified: pane command "${VERSION}" does not confirm runtime "claude-code"`);
});
});
describe("#86 recovery readiness (ContextMonitor.pollOnce) follows the seat's persisted selection", () => {
async function poll(opts: { selection?: string; label?: string; rows: () => NativeProcessRow[] }): Promise<string> {
const db = createDb();
migrate(db, ALL_MIGRATIONS);
const rigRepo = new RigRepository(db);
const sessions = new SessionRegistry(db);
const node = rigRepo.addNode(rigRepo.createRig("recovery-rig").id, "dev.impl", { runtime: "claude-code", cwd: "/project" });
const session = sessions.registerSession(node.id, "dev-impl@recovery-rig");
db.prepare("UPDATE sessions SET status = 'running', startup_status = 'attention_required' WHERE id = ?").run(session.id);
const permissions = new NativePermissionStore(db);
if (opts.selection) permissions.write(node.id, { runtime: "claude-code", mode: opts.selection }, "test", "managed seat");
const stateDir = mkdtempSync(join(tmpdir(), "label-witness-"));
const claude = new ClaudeCodeAdapter({ tmux: mockTmux(opts.label ?? VERSION), fsOps, listProcesses: opts.rows, sleep: async () => {},
resolvePermissionMode: (nodeId) => permissions.resolve(nodeId, "claude-code").permissionMode });
const monitor = new ContextMonitor(db, new ContextUsageStore(db, { stateDir, codexHomeDir: stateDir }),
{ ensureContextCollector: () => {}, checkReady: (b) => claude.checkReady(b) });
try {
await monitor.pollOnce();
return (db.prepare("SELECT startup_status FROM sessions WHERE id = ?").get(session.id) as { startup_status: string }).startup_status;
} finally {
monitor.stop();
db.close();
rmSync(stateDir, { recursive: true, force: true });
}
}
it.each<[string, Parameters<typeof poll>[0], string]>([
["managed auto, a version label, the wrong conversation", { selection: "auto", rows: () => rows("claude --permission-mode auto --resume other --name seat") }, "attention_required"],
["managed auto, a version label, no OS path", { selection: "auto", rows: () => rows().map(r => ({ ...r, executablePath: undefined })) }, "attention_required"],
["managed auto, the ordinary claude label, as on main", { selection: "auto", label: "claude", rows }, "ready"],
["unmanaged, a witnessed version label", { rows }, "ready"],
])("%s", async (_name, opts, expected) => {
expect(await poll(opts)).toBe(expected);
});
});
describe("#86 an actual path-read timeout ends the witness for the rest of one wait", () => {
const unwitnessed = () => rows().map(r => ({ ...r, executablePath: undefined }));
// As listNativeProcesses does when the OS read times out: the path stays unset and the caller is told.
const timedOut: NativeProcessLister = (opts) => { opts?.onPathReadTimeout?.(); return unwitnessed(); };
const launch = (claude: ClaudeCodeAdapter) => claude.launchHarness(binding(), { name: "seat", resumeToken: "tok" });
// The witness's own reads carry its timeout listener; main's process checks call the same lister without one.
const witnessReads = (list: ReturnType<typeof vi.fn>) =>
list.mock.calls.filter(([opts]) => typeof opts === "object" && opts !== null && "onPathReadTimeout" in opts).length;
// Main's result for the same process evidence: a managed seat never consults the witness.
const mainResult = () => launch(adapter(mockTmux(VERSION), vi.fn(unwitnessed), () => "auto"));
it.each<[string, () => NativeProcessLister, number]>([
["a first-sample timeout", () => vi.fn(timedOut), 1],
["a second-sample timeout", () => vi.fn<NativeProcessLister>().mockImplementationOnce(() => rows()).mockImplementation(timedOut), 2],
])("the launch wait stops reading after %s and ends with main's result", async (_name, makeList, reads) => {
const list = makeList();
const result = await launch(adapter(mockTmux(VERSION), list));
expect(result).toEqual(await mainResult());
expect(result.ok).toBe(false);
expect(witnessReads(list as ReturnType<typeof vi.fn>)).toBe(reads);
});
it("a missing path without a timeout isn't one: every observation still tries", async () => {
const list = vi.fn(unwitnessed);
const tmux = mockTmux(VERSION);
expect(await launch(adapter(tmux, list))).toMatchObject({ ok: false });
const observations = vi.mocked(tmux.capturePaneContent).mock.calls.length;
expect(observations).toBeGreaterThan(16);
expect(witnessReads(list)).toBe(observations);
});
it("a later, independent launch wait uses a healthy witness", async () => {
let read: NativeProcessLister = timedOut;
const claude = adapter(mockTmux(VERSION), (opts) => read(opts));
expect(await launch(claude)).toMatchObject({ ok: false });
read = () => rows();
expect(await launch(claude)).toMatchObject({ ok: true, resumeToken: "tok" });
});
it("the legacy restore wait reads once after a timeout, and its next wait uses a healthy witness", async () => {
let read: NativeProcessLister = timedOut;
const list = vi.fn<NativeProcessLister>((opts) => read(opts));
const resume = new ClaudeResumeAdapter(mockTmux(VERSION), { listProcesses: list, sleep: async () => {} });
expect(await resume.resume("dev-impl@rig", "claude_id", "tok", "/project")).toMatchObject({ ok: false, code: "attention_required" });
expect(list).toHaveBeenCalledTimes(1);
read = () => rows();
expect(await resume.resume("dev-impl@rig", "claude_id", "tok", "/project")).toMatchObject({ ok: true });
});
});
@@ -16,10 +16,11 @@ function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-on-demand-")); roots.push(root);
const home = path.join(root, "home"), codexHome = path.join(root, "codex-home"), bin = path.join(root, "bin"), cwd = path.join(root, "project");
for (const dir of [home, codexHome, bin, cwd]) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(bin, "codex"), `#!${process.execPath}\nif(process.argv.includes('--help'))console.log('Usage: codex [OPTIONS]\\n --no-daemon');else if(process.argv.includes('--version'))console.log('codex-cli 0.155.1');\n`, { mode: 0o700 });
const calls = path.join(root, "codex-calls.jsonl");
fs.writeFileSync(path.join(bin, "codex"), `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(calls)}, JSON.stringify(process.argv.slice(2)) + "\\n");\nif(process.argv.includes('--help'))console.log('Usage: codex [OPTIONS]\\n --no-daemon');else if(process.argv.includes('--version'))console.log('codex-cli 0.155.1');\n`, { mode: 0o700 });
const config = path.join(codexHome, "config.toml");
const env = { PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, HOME: home, CODEX_HOME: codexHome, OPENRIG_HOME: path.join(root, "instance") };
return { root, home, codexHome, cwd, config, env };
return { root, home, codexHome, cwd, config, env, calls };
}
async function startDaemon(f: ReturnType<typeof fixture>, hooksEnabled = true) {
@@ -84,11 +85,13 @@ it("a daemon with no Codex seat and hooks disabled leaves a config without the b
try { expect(fs.existsSync(g.config)).toBe(false); } finally { other.db.close(); }
}, 60000);
it("a daemon that boots with a Codex seat still writes the hooks block and trust", async () => {
it("a daemon with a selected Codex seat installs its plugin while retaining inline activity hooks", async () => {
const f = fixture(); fs.writeFileSync(f.config, USER_CONFIG);
const first = await startDaemon(f);
first.db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-1', 'r')").run();
first.db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime) VALUES ('node-1', 'rig-1', 'dev.impl', 'codex')").run();
first.db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, '[]', '[]', 'codex')")
.run("node-1", JSON.stringify([{ category: "plugin", absolutePath: path.join(f.env.OPENRIG_HOME, "plugins", "openrig-core") }]));
first.db.close();
expect(fs.readFileSync(f.config, "utf8")).toBe(USER_CONFIG);
const second = await startDaemon(f);
@@ -97,6 +100,9 @@ it("a daemon that boots with a Codex seat still writes the hooks block and trust
expect(text).toContain(USER_CONFIG);
expect(text).toContain(HOOKS_BLOCK);
expect(text).toContain("trusted_hash");
const calls = fs.existsSync(f.calls) ? fs.readFileSync(f.calls, "utf8").trim().split("\n").map((line) => JSON.parse(line) as string[]) : [];
expect(calls.filter((args) => args[0] === "plugin").map(args => args.slice(0,2))).toEqual([["plugin", "marketplace"], ["plugin", "add"]]);
// The stub records native calls only; it does not emulate Codex's cache/config writes.
} finally { second.db.close(); }
}, 60000);
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import { spawnSync } from "node:child_process";
import fs, { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import fs, { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { createFullTestDb } from "./helpers/test-app.js";
@@ -10,21 +10,16 @@ import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import { SEAT_NODE_ENV, SEAT_PROJECT_DIR_ENV } from "../src/domain/claude-core-plugin-env.js";
import { CodexRuntimeAdapter, computeCodexHookTrust, type CodexCliRunner } from "../src/adapters/codex-runtime-adapter.js";
import { codexCorePluginEnablement, type CodexConfigLayerRead } from "../src/domain/codex-core-plugin-install.js";
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
import { parse as parseToml } from "smol-toml";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// Codex seats get the openrig-core plugin through Codex's own install. Its hooks act only in a seat that selected the plugin,
// a person's explicit or default disable is never overridden, and the plugin's relay stays quiet only when Codex would run the
// daemon's managed relay for the same event. Hermetic: temp HOME and CODEX_HOME, a fake `codex` CLI, no real Codex or login.
// Retained plugin guards/relay behavior and private-HOME launch checks. No real Codex or login.
const require = createRequire(import.meta.url);
const PLUGIN = resolve(import.meta.dirname, "../assets/plugins/openrig-core");
const RELAY = join(PLUGIN, "hooks", "scripts", "activity-relay.cjs");
const relay = require(RELAY) as { pluginRunIsRedundant(p: Record<string, unknown>, argv: string[], env: NodeJS.ProcessEnv): boolean };
const managed = require(join(PLUGIN, "hooks", "scripts", "codex-hook-trust.cjs")) as {
computeCodexHookTrust(event: string, input: { keySource: string; command: string; timeoutSec: number }): { key: string; hash: string };
};
const NODE_DIR = dirname(process.execPath);
let root: string;
@@ -138,268 +133,61 @@ describe("the launch signal: selected, unselected and personal", () => {
});
});
describe("read before install: the person's choice is never overridden", () => {
const layer = (name: string, content: string | null): CodexConfigLayerRead => content === null
? { name, path: `/${name}`, state: "absent" } : { name, path: `/${name}`, state: "read", content };
const KEY = '[plugins."openrig-core@openrig"]';
it("decides from the readable layers: explicit values, the _default policy, and unreadable or unparsable layers", () => {
const decide = (system: string | null, user: string | null, managedCfg: string | null = null) =>
codexCorePluginEnablement([layer("system config", system), layer("user config", user), layer("legacy managed config", managedCfg)]).decision;
expect(decide(null, null)).toBe("allow"); // absent
expect(decide(null, `${KEY}\nenabled = true\n`)).toBe("allow"); // explicit true
expect(decide(null, `${KEY}\nenabled = false\n`)).toBe("disabled"); // explicit false
expect(decide(null, "[plugins._default]\nenabled = false\n")).toBe("disabled"); // default off, no entry
expect(decide(null, `[plugins._default]\nenabled = false\n\n${KEY}\n`)).toBe("disabled"); // default off, entry without enabled
expect(decide(null, `[plugins._default]\nenabled = false\n\n${KEY}\nenabled = true\n`)).toBe("allow"); // the person's explicit choice
expect(decide(null, "[plugins._default]\nenabled = true\n")).toBe("allow"); // default on
expect(decide("[plugins._default]\nenabled = false\n", null)).toBe("disabled"); // a layer above the user file
expect(decide(null, `${KEY}\nenabled = true\n`, `${KEY}\nenabled = false\n`)).toBe("disabled"); // the higher layer's explicit value wins
// _default follows Codex's merge: a higher-precedence layer's value wins (system 10 < user 20 < legacy managed 40).
expect(decide("[plugins._default]\nenabled = false\n", "[plugins._default]\nenabled = true\n")).toBe("allow");
expect(decide("[plugins._default]\nenabled = true\n", "[plugins._default]\nenabled = false\n")).toBe("disabled");
expect(decide(null, "[plugins._default]\nenabled = true\n", "[plugins._default]\nenabled = false\n")).toBe("disabled");
expect(decide(null, "[plugins._default]\nenabled = false\n", "[plugins._default]\nenabled = true\n")).toBe("allow");
expect(decide(null, "this is = = not toml")).toBe("unknown");
expect(decide(null, `${KEY}\nenabled = "yes"\n`)).toBe("unknown");
expect(codexCorePluginEnablement([{ name: "user config", path: "/u", state: "unreadable", reason: "EACCES" }]).decision).toBe("unknown");
});
/** A vendored plugin at `version`, a CODEX_HOME with `userConfig`, an /etc/codex stand-in, and a recording fake CLI. */
function setup(version: string, userConfig: string | null, opts: { installed?: string; systemConfig?: string } = {}) {
const pluginsDir = join(root, "plugins"); const dir = join(pluginsDir, "openrig-core");
mkdirSync(join(dir, ".codex-plugin"), { recursive: true });
writeFileSync(join(dir, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "openrig-core", version }));
const codexHome = join(root, "codex-home"); mkdirSync(codexHome, { recursive: true });
if (userConfig !== null) writeFileSync(join(codexHome, "config.toml"), userConfig);
if (opts.installed) mkdirSync(join(codexHome, "plugins", "cache", "openrig", "openrig-core", opts.installed), { recursive: true });
const etc = join(root, "etc-codex"); mkdirSync(etc, { recursive: true });
if (opts.systemConfig) writeFileSync(join(etc, "config.toml"), opts.systemConfig);
const calls: string[][] = [];
const runCodexCli: CodexCliRunner = async (args, env) => { calls.push([...args, `CODEX_HOME=${env.CODEX_HOME}`]); return { status: 0, stdout: "", stderr: "" }; };
const adapter = new CodexRuntimeAdapter({ tmux: {} as TmuxAdapter, fsOps: realFs, codexHome, resolveCorePluginDir: () => dir, runCodexCli, codexSystemConfigDir: etc });
return { adapter, calls, pluginsDir, codexHome };
}
const recorded = (pluginsDir: string) => `[marketplaces.openrig]\nsource_type = "local"\nsource = "${pluginsDir}"\n`;
it("first install: absent runs Codex's own marketplace add and plugin add, with the daemon's CODEX_HOME", async () => {
const { adapter, calls, pluginsDir, codexHome } = setup("0.1.14", null);
expect((await adapter.ensureCodexCorePlugin()).outcome).toBe("installed");
expect(calls).toEqual([["plugin", "marketplace", "add", pluginsDir, `CODEX_HOME=${codexHome}`], ["plugin", "add", "openrig-core@openrig", `CODEX_HOME=${codexHome}`]]);
expect(JSON.parse(readFileSync(join(pluginsDir, ".agents", "plugins", "marketplace.json"), "utf8"))).toEqual({
name: "openrig", interface: { displayName: "OpenRig" }, plugins: [{ name: "openrig-core", source: "./openrig-core" }] });
expect(existsSync(join(codexHome, "config.toml"))).toBe(false); // OpenRig writes nothing into the config itself
});
it("first install: explicit false, default-off and unreadable never call Codex", async () => {
for (const config of ['[plugins."openrig-core@openrig"]\nenabled = false\n', "[plugins._default]\nenabled = false\n", "not = = toml"]) {
const { adapter, calls, codexHome } = setup("0.1.14", config);
expect((await adapter.ensureCodexCorePlugin()).outcome).toBe("skipped");
expect(calls).toEqual([]);
expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(config);
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
}
const { adapter, calls } = setup("0.1.14", null, { systemConfig: "[plugins._default]\nenabled = false\n" });
expect((await adapter.ensureCodexCorePlugin()).outcome).toBe("skipped");
expect(calls).toEqual([]);
});
it("version change: enabled reinstalls with plugin add only; explicit false or default-off keeps the old version untouched", async () => {
let s = setup("0.1.14", null, { installed: "0.1.13" });
writeFileSync(join(s.codexHome, "config.toml"), `${recorded(s.pluginsDir)}\n[plugins."openrig-core@openrig"]\nenabled = true\n`);
expect((await s.adapter.ensureCodexCorePlugin()).outcome).toBe("installed");
expect(s.calls.map((c) => c.slice(0, -1))).toEqual([["plugin", "add", "openrig-core@openrig"]]);
for (const off of ['[plugins."openrig-core@openrig"]\nenabled = false\n', '[plugins._default]\nenabled = false\n\n[plugins."openrig-core@openrig"]\n']) {
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
s = setup("0.1.14", null, { installed: "0.1.13" });
writeFileSync(join(s.codexHome, "config.toml"), `${recorded(s.pluginsDir)}\n${off}`);
expect((await s.adapter.ensureCodexCorePlugin()).outcome).toBe("skipped");
expect(s.calls).toEqual([]);
}
});
it("a cache left behind isn't current: Codex loads only a configured plugin, so a lost registration is restored", async () => {
// The plugin entry is gone, the marketplace remains: plugin add only.
let s = setup("0.1.15", null, { installed: "0.1.15" });
writeFileSync(join(s.codexHome, "config.toml"), recorded(s.pluginsDir));
expect((await s.adapter.ensureCodexCorePlugin()).outcome).toBe("installed");
expect(s.calls.map((c) => c.slice(0, -1))).toEqual([["plugin", "add", "openrig-core@openrig"]]);
// The marketplace is gone, the entry remains: marketplace add, then plugin add.
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
s = setup("0.1.15", '[plugins."openrig-core@openrig"]\nenabled = true\n', { installed: "0.1.15" });
expect((await s.adapter.ensureCodexCorePlugin()).outcome).toBe("installed");
expect(s.calls.map((c) => c.slice(0, 3))).toEqual([["plugin", "marketplace", "add"], ["plugin", "add", "openrig-core@openrig"]]);
// A person's disable still wins over a cache left behind.
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
s = setup("0.1.15", "[plugins._default]\nenabled = false\n", { installed: "0.1.15" });
expect((await s.adapter.ensureCodexCorePlugin()).outcome).toBe("skipped");
expect(s.calls).toEqual([]);
});
it("a marketplace recorded through a symlink to the plugins folder is the same source", async () => {
const s = setup("0.1.15", null, { installed: "0.1.14" });
const alias = join(root, "plugins-alias"); fs.symlinkSync(s.pluginsDir, alias);
writeFileSync(join(s.codexHome, "config.toml"), `${recorded(alias)}\n[plugins."openrig-core@openrig"]\nenabled = true\n`);
expect((await s.adapter.ensureCodexCorePlugin()).outcome).toBe("installed");
expect(s.calls.map((c) => c.slice(0, -1))).toEqual([["plugin", "add", "openrig-core@openrig"]]);
});
it("a codex that can't be started is reported with the reason, not as an exit status", async () => {
const pluginsDir = join(root, "plugins"); const dir = join(pluginsDir, "openrig-core");
mkdirSync(join(dir, ".codex-plugin"), { recursive: true });
writeFileSync(join(dir, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "openrig-core", version: "0.1.15" }));
const codexHome = join(root, "codex-home"); mkdirSync(codexHome, { recursive: true });
const noCodex = join(root, "no-codex-bin"); mkdirSync(noCodex, { recursive: true });
const adapter = new CodexRuntimeAdapter({ tmux: {} as TmuxAdapter, fsOps: realFs, codexHome, launchPath: noCodex,
resolveCorePluginDir: () => dir, codexSystemConfigDir: join(root, "etc") });
const result = await adapter.ensureCodexCorePlugin();
expect(result.outcome).toBe("skipped");
expect(result.reason).toMatch(/codex plugin marketplace add failed: .*ENOENT/);
});
it("the current version is left alone, and another marketplace named openrig is never replaced", async () => {
let s = setup("0.1.14", null, { installed: "0.1.14" });
writeFileSync(join(s.codexHome, "config.toml"), `${recorded(s.pluginsDir)}\n[plugins."openrig-core@openrig"]\nenabled = true\n`);
expect((await s.adapter.ensureCodexCorePlugin()).outcome).toBe("current");
expect(s.calls).toEqual([]);
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
s = setup("0.1.14", '[marketplaces.openrig]\nsource_type = "git"\nsource = "https://example.invalid/other.git"\n');
expect((await s.adapter.ensureCodexCorePlugin()).outcome).toBe("skipped");
expect(s.calls).toEqual([]);
});
it("Claude's plugin activity fallback stays available without Codex config or a trust helper", () => {
expect(relay.pluginRunIsRedundant({ hook_event_name: "Stop", cwd: root }, ["node", RELAY, "--from-plugin"], {})).toBe(false);
});
describe("the plugin relay on Codex always reports: whether the managed relay runs can't be established", () => {
/** A CODEX_HOME whose config the daemon itself provisioned: the managed relay block and its trust. */
function provisioned(): string {
const codexHome = join(root, "codex-home"); mkdirSync(codexHome, { recursive: true });
new CodexRuntimeAdapter({ tmux: {} as TmuxAdapter, fsOps: realFs, codexHome, activityRelayPath: RELAY }).ensureCodexActivityHooks();
return codexHome;
}
const env = (codexHome: string, path = NODE_DIR): NodeJS.ProcessEnv => ({ OPENRIG_RUNTIME: "codex", CODEX_HOME: codexHome, PATH: path });
const quiet = (event: string, e: NodeJS.ProcessEnv) => relay.pluginRunIsRedundant({ hook_event_name: event }, ["node", RELAY, "--from-plugin"], e);
const config = (codexHome: string) => join(codexHome, "config.toml");
const trustKey = (codexHome: string, event: string) => managed.computeCodexHookTrust(event, {
keySource: fs.realpathSync.native(config(codexHome)), command: `node "${RELAY}"`, timeoutSec: 5 }).key;
const edit = (codexHome: string, fn: (s: string) => string) => writeFileSync(config(codexHome), fn(readFileSync(config(codexHome), "utf8")));
it("reports each managed event even with the managed relay trusted in the user file (the stated duplicate)", () => {
// Codex merges hook state from every user layer (a selected profile included) and session flags, which the relay can't
// see, so it never assumes the managed handler ran. With both routes active each event is reported twice, never zero.
const home = provisioned();
for (const event of ["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest"]) expect(quiet(event, env(home))).toBe(false);
// The daemon's trust writer and the plugin share one hash recipe.
expect(computeCodexHookTrust("Stop", { keySource: "/k/config.toml", command: `node "${RELAY}"`, timeoutSec: 5 }))
.toEqual(managed.computeCodexHookTrust("Stop", { keySource: "/k/config.toml", command: `node "${RELAY}"`, timeoutSec: 5 }));
});
it("an edited managed stanza or a disable in another user layer leaves the plugin reporting (never zero)", () => {
// The stanza edited after it was trusted, the original hash kept: Codex sees Modified and skips it.
for (const extra of ['statusMessage = "activity"\n', "async = true\n"]) {
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
const home = provisioned();
edit(home, (s) => s.replace(/(\[\[hooks\.Stop\.hooks\]\]\ntype = "command"\ncommand = '[^']+'\ntimeout = 5\n)/, `$1${extra}`));
expect(quiet("Stop", env(home))).toBe(false);
}
// A selected profile layer disables the managed key while the user file still trusts it.
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
const home = provisioned();
edit(home, (s) => `profile = "work"\n\n${s}`);
writeFileSync(join(home, "work.config.toml"), `[hooks.state.${JSON.stringify(trustKey(home, "Stop"))}]\nenabled = false\n`);
expect(quiet("Stop", env(home))).toBe(false);
});
it("a home with no managed block (the plugin alone) reports", () => {
const home = provisioned();
writeFileSync(config(home), "[features]\nhooks = true\n");
expect(quiet("Stop", env(home))).toBe(false);
});
it("the Claude path is unchanged: without OPENRIG_RUNTIME=codex the Codex config is never consulted", () => {
const home = provisioned();
expect(relay.pluginRunIsRedundant({ hook_event_name: "Stop", cwd: root }, ["node", RELAY, "--from-plugin"], { CODEX_HOME: home, PATH: NODE_DIR })).toBe(false);
// Root reads a mode-000 file anyway, so the unreadable case can't be made there.
if (process.getuid?.() !== 0) {
chmodSync(config(home), 0o000);
try { expect(quiet("Stop", env(home))).toBe(false); } // unreadable: reports
finally { chmodSync(config(home), 0o600); }
}
});
});
describe("the install runs only for a seat that selected the plugin", () => {
/** A Codex adapter that can run a real launchHarness against fixtures, with a vendored plugin and a recording CLI. */
function launchable(selected: (nodeId: string) => boolean) {
describe("Codex starts with the person's existing plugin choices", () => {
function launchable(config = "") {
const home = join(root, "home"), codexHome = join(root, "codex-home"), cwd = join(root, "project"), bin = join(root, "bin");
for (const d of [home, codexHome, cwd, bin]) mkdirSync(d, { recursive: true });
writeFileSync(join(bin, "codex"), `#!${process.execPath}\nif(process.argv.includes('--help'))console.log('Usage: codex [OPTIONS]\\n --no-daemon');else if(process.argv.includes('--version'))console.log('codex-cli 0.163.0');\n`, { mode: 0o700 });
const dir = join(root, "plugins", "openrig-core");
mkdirSync(join(dir, ".codex-plugin"), { recursive: true });
writeFileSync(join(dir, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "openrig-core", version: "9.9.9" }));
const calls: string[][] = [];
const configPath = join(codexHome, "config.toml");
writeFileSync(configPath, config);
const cacheFile = join(codexHome, "plugins", "cache", "user-plugin");
mkdirSync(dirname(cacheFile), { recursive: true }); writeFileSync(cacheFile, "user installed bytes");
const tmux = { getPaneCommand: async () => "bash", getSessionEnv: async () => undefined, sendShellCommand: async () => ({ ok: true }) };
const adapter = new CodexRuntimeAdapter({
tmux: tmux as unknown as TmuxAdapter, fsOps: { ...realFs, homedir: home }, codexHome, launchPath: `${bin}:${NODE_DIR}:/usr/bin:/bin`,
activityRelayPath: RELAY, resolveGitAddDirs: async () => [], resolveManagedConfigSettings: async () => ({ enabled: true, codexVersion: "0.163.0" }),
resolveCorePluginDir: () => dir, codexSystemConfigDir: join(root, "etc"),
runCodexCli: async (args) => { calls.push(args); return { status: 0, stdout: "", stderr: "" }; },
resolveCorePluginSelected: selected,
} as ConstructorParameters<typeof CodexRuntimeAdapter>[0]);
});
const internals = adapter as unknown as Record<string, unknown>;
internals.dismissSkippableCodexUpdatePrompt = async () => undefined;
internals.captureFreshThreadId = async () => "fresh";
internals.verifyResumeLaunch = async () => ({ ok: true });
return { adapter, calls, cwd };
return { adapter, cwd, configPath, cacheFile };
}
it("an unselected Codex seat's launch makes no Codex CLI call; a selected one installs", async () => {
let s = launchable((id) => id === "selected");
expect((await s.adapter.launchHarness({ nodeId: "unselected", tmuxSession: "u@rig", cwd: s.cwd } as never, { name: "u" })).ok).toBe(true);
expect(s.calls).toEqual([]);
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
s = launchable((id) => id === "selected");
expect((await s.adapter.launchHarness({ nodeId: "selected", tmuxSession: "s@rig", cwd: s.cwd } as never, { name: "s" })).ok).toBe(true);
expect(s.calls.map((c) => c.slice(0, 2))).toEqual([["plugin", "marketplace"], ["plugin", "add"]]);
});
it("concurrent launches run the Codex config setup and the install one after the other", async () => {
const s = launchable(() => true);
const events: string[] = [];
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const internals = s.adapter as unknown as Record<string, unknown>;
internals.resolveManagedConfigSettings = async () => { events.push("config"); await wait(20); return { enabled: true, codexVersion: "0.163.0" }; };
internals.runCodexCli = async (args: string[]) => { events.push(`cli:${args[1]}:start`); await wait(30); events.push(`cli:${args[1]}:end`); return { status: 0, stdout: "", stderr: "" }; };
const launch = (id: string) => s.adapter.launchHarness({ nodeId: id, tmuxSession: `${id}@rig`, cwd: s.cwd } as never, { name: id });
const results = await Promise.all([launch("a"), launch("b")]);
expect(results.every((r) => r.ok)).toBe(true);
const section = ["config", "cli:marketplace:start", "cli:marketplace:end", "cli:add:start", "cli:add:end"];
expect(events).toEqual([...section, ...section]);
});
it("a resume never installs: neither a resume launch nor preparing an in-place resume command", async () => {
let s = launchable(() => true);
expect((await s.adapter.launchHarness({ nodeId: "selected", tmuxSession: "s@rig", cwd: s.cwd } as never, { name: "s", resumeToken: "conv-1" })).ok).toBe(true);
expect(s.calls).toEqual([]);
rmSync(root, { recursive: true, force: true }); mkdirSync(root);
s = launchable(() => true);
expect((await s.adapter.buildResumeCommand({ nodeId: "selected", tmuxSession: "s@rig", cwd: s.cwd } as never, { name: "s", resumeToken: "conv-1" })).ok).toBe(true);
expect(s.calls).toEqual([]);
});
it("ensureManagedConfig alone (boot, resume) never installs", async () => {
const s = launchable(() => true);
await s.adapter.ensureManagedConfig();
expect(s.calls).toEqual([]);
it.each([
["fresh home", ""],
["explicit disable", '[plugins."openrig-core@openrig"]\nenabled = false\n'],
["default disable", '[plugins._default]\nenabled = false\n'],
["installed plugin awaiting native review", '[plugins."openrig-core@openrig"]\nenabled = true\n'],
["existing hook choice", '[hooks.state."user-hook"]\nenabled = false\ntrusted_hash = "original"\n'],
])("%s: launch preserves plugin configuration, cached files and existing hook trust", async (_label, config) => {
const s = launchable(config);
const before = parseToml(config);
const binding = { nodeId: "selected", tmuxSession: "s@rig", cwd: s.cwd } as never;
await s.adapter.ensureManagedConfig(); // boot's managed setup
expect((await s.adapter.launchHarness(binding, { name: "s" })).ok).toBe(true);
const after = parseToml(readFileSync(s.configPath, "utf8"));
expect(after.plugins).toEqual(before.plugins);
expect(after.marketplaces).toEqual(before.marketplaces);
const states = (after.hooks as { state: Record<string, unknown> }).state;
expect(states["user-hook"]).toEqual((before.hooks as { state?: Record<string, unknown> } | undefined)?.state?.["user-hook"]);
// Only the existing four inline hooks gain trust; plugin hooks remain under native review.
expect(Object.keys(states)).toHaveLength(config.includes("user-hook") ? 5 : 4);
expect(readFileSync(s.cacheFile, "utf8")).toBe("user installed bytes");
});
});
describe("the relay copied alone, as a Claude seat's settings relay", () => {
it("still loads and runs without its Codex sibling, and a Codex session without the sibling reports", () => {
it("still loads without its Codex sibling and leaves Codex activity to the inline route", () => {
const dir = join(root, "state", "claude-activity-hooks"); mkdirSync(dir, { recursive: true });
const copy = join(dir, "activity-relay.cjs");
writeFileSync(copy, readFileSync(RELAY));
const r = spawnSync(process.execPath, ["-e", `const m = require(${JSON.stringify(copy)}); process.stdout.write(String(m.pluginRunIsRedundant({ hook_event_name: "Stop" }, ["node", "x", "--from-plugin"], { OPENRIG_RUNTIME: "codex", CODEX_HOME: ${JSON.stringify(root)} })))`], { encoding: "utf8" });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toBe("false");
expect(r.stdout).toBe("true");
});
});
@@ -0,0 +1,114 @@
import { afterEach, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import { spawn } from "node:child_process";
import { parse } from "smol-toml";
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const shipped = path.resolve(import.meta.dirname, "../assets/plugins/openrig-core");
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
function fixture(config = "", corruptCache = false) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "owned-codex-hooks-")); roots.push(root);
const plugin = path.join(root, "plugins/openrig-core"), home = path.join(root, "codex");
fs.cpSync(shipped, plugin, { recursive: true }); fs.mkdirSync(home);
const file = path.join(home, "config.toml"); fs.writeFileSync(file, config);
const version = JSON.parse(fs.readFileSync(path.join(plugin, ".codex-plugin/plugin.json"), "utf8")).version;
const cache = path.join(home, "plugins/cache/openrig/openrig-core", version);
const calls: string[][] = [];
const adapter = () => new CodexRuntimeAdapter({ tmux: {} as TmuxAdapter, codexHome: home,
fsOps: { readFile: p => fs.readFileSync(p, "utf8"), writeFile: (p,c) => fs.writeFileSync(p,c), exists: fs.existsSync, mkdirp: p => fs.mkdirSync(p,{recursive:true}) },
resolveCorePluginDir: () => plugin, codexSystemConfigDir: path.join(root, "etc"),
runCodexCli: async args => {
calls.push(args);
if (args[1] === "add") {
fs.cpSync(plugin, cache, {recursive:true});
if (corruptCache) fs.appendFileSync(path.join(cache,"hooks/scripts/refocus.cjs"),"\nmodified");
if (!(parse(fs.readFileSync(file, 'utf8')) as any).plugins?.['openrig-core@openrig']) fs.appendFileSync(file, '\n[plugins."openrig-core@openrig"]\nenabled = true\n');
}
if (args[1] === "marketplace") fs.appendFileSync(file, `\n[marketplaces.openrig]\nsource = ${JSON.stringify(path.dirname(plugin))}\n`);
return {status:0,stdout:"",stderr:""};
},
});
const read = () => parse(fs.readFileSync(file,"utf8")) as any;
return {adapter, read, file, plugin, cache, calls, home};
}
it("a fresh install has all six exact plugin hooks trusted before the first seat starts", async () => {
const f = fixture(); await f.adapter().ensureCodexCorePlugin();
const states = f.read().hooks?.state ?? {};
expect(Object.keys(states)).toHaveLength(6);
// Source-derived Codex0.163 normalized identities, independently calculated with Python sorted JSON/SHA256.
const expected = {
"openrig-core@openrig:hooks/codex.json:session_start:0:0": "sha256:a746b5abb568d50725b08bf39660bbf08d15fa3d3a5961208862e7beaadba43d",
"openrig-core@openrig:hooks/codex.json:user_prompt_submit:0:0": "sha256:f56dfb2a70982d6a7ae4a6c4ace58b943e16dcb08fe3bc87d3c8803477f4a5c1",
"openrig-core@openrig:hooks/codex.json:user_prompt_submit:1:0": "sha256:c648714b3a02675ba4ea029b8e62dbcd77569afc317ad4f1bc3e8cbfbe7b34ee",
"openrig-core@openrig:hooks/codex.json:stop:0:0": "sha256:bb444b151db38aff570d0ceadc2cc84b21afb021f00b15b326aef55a27d87d40",
"openrig-core@openrig:hooks/codex.json:post_compact:0:0": "sha256:f4dd49518599c67ac23c80daef3537bb03b02e23a1e8c1da1210534766afefae",
"openrig-core@openrig:hooks/codex.json:permission_request:0:0": "sha256:75c66b08f505a22f84def73ecf6d61e98dd1b24ed8ecdcc05306eba3013e64d4"
};
expect(Object.fromEntries(Object.entries(states).map(([key,value]:[string,any])=>[key,value.trusted_hash]))).toEqual(expected);
expect(f.calls.map(a=>a.slice(0,2))).toEqual([["plugin","marketplace"],["plugin","add"]]);
});
it.each(['[plugins."openrig-core@openrig"]\nenabled = false\n','[plugins._default]\nenabled = false\n'])("a disabled plugin stays disabled on restart and upgrade: %s", async config => {
const f = fixture(config); await f.adapter().ensureCodexCorePlugin(); await f.adapter().ensureCodexCorePlugin();
expect(fs.readFileSync(f.file,"utf8")).toBe(config); expect(f.calls).toEqual([]);
});
it("an existing untrusted installation stays untrusted after restart or an upgrade", async () => {
const f = fixture('[plugins."openrig-core@openrig"]\nenabled = true\n');
fs.mkdirSync(path.join(f.home,"plugins/cache/openrig/openrig-core/older"), {recursive:true});
await f.adapter().ensureCodexCorePlugin(); await f.adapter().ensureCodexCorePlugin();
expect(f.read().hooks?.state ?? {}).toEqual({});
});
it("a recorded hook decline and outside hook state survive restart and upgrade byte-for-byte", async () => {
const key = "openrig-core@openrig:hooks/codex.json:stop:0:0";
const original = `[hooks.state.${JSON.stringify(key)}]\nenabled = false\n[hooks.state."outside:stop:0:0"]\ntrusted_hash = "person-choice"\n`;
const f = fixture(original); await f.adapter().ensureCodexCorePlugin(); await f.adapter().ensureCodexCorePlugin();
expect(f.read().hooks.state[key]).toEqual({enabled:false});
expect(f.read().hooks.state["outside:stop:0:0"]).toEqual({trusted_hash:"person-choice"});
});
it.each(["hooks/codex.json","hooks/scripts/refocus.cjs"])("modified vendored %s is not automatically installed or trusted", async relative => {
const f=fixture(); fs.appendFileSync(path.join(f.plugin,relative),"\n ");
await f.adapter().ensureCodexCorePlugin(); expect(f.read().hooks?.state ?? {}).toEqual({}); expect(f.calls).toEqual([]);
});
it("native installation with different cached script bytes gains no trust", async () => {
const f=fixture("", true); await f.adapter().ensureCodexCorePlugin();
expect(f.read().hooks?.state ?? {}).toEqual({});
});
it("a person disabling a previously trusted hook keeps that choice through a fresh adapter and cache upgrade", async () => {
const f=fixture(); await f.adapter().ensureCodexCorePlugin();
const key="openrig-core@openrig:hooks/codex.json:stop:0:0";
const before=f.read().hooks.state[key];
fs.writeFileSync(f.file,fs.readFileSync(f.file,"utf8").replace(`[hooks.state.${JSON.stringify(key)}]`, `[hooks.state.${JSON.stringify(key)}]\nenabled = false`));
fs.renameSync(f.cache, path.join(path.dirname(f.cache),"older"));
await f.adapter().ensureCodexCorePlugin(); await f.adapter().ensureCodexCorePlugin();
expect(f.read().hooks.state[key]).toEqual({...before,enabled:false});
});
it("running both actual relay commands posts each Codex activity event once", async () => {
const received: any[]=[];
const server=http.createServer((req,res)=>{ let body="";req.on("data",chunk=>{body+=chunk;});req.on("end",()=>{received.push(JSON.parse(body));res.end("{}");}); });
await new Promise<void>(resolve=>server.listen(0,"127.0.0.1",resolve));
const root=fs.mkdtempSync(path.join(os.tmpdir(),"one-codex-event-"));roots.push(root);
try {
const port=(server.address() as {port:number}).port;
for (const event of ["SessionStart","UserPromptSubmit","Stop","PermissionRequest"]) {
for (const args of [[],["--from-plugin"]]) await new Promise<void>((resolve,reject)=>{
const child=spawn(process.execPath,[path.join(shipped,"hooks/scripts/activity-relay.cjs"),...args],{timeout:5000,env:{
HOME:root,OPENRIG_HOME:root,OPENRIG_RUNTIME:"codex",OPENRIG_NODE_ID:"fixture",OPENRIG_SESSION_NAME:"fixture-seat",
OPENRIG_OCCUPANT_GENERATION:"fixture-generation",OPENRIG_URL:`http://127.0.0.1:${port}`,OPENRIG_ACTIVITY_HOOK_TOKEN:"fixture-token",
}});
child.stdout.resume();child.stderr.resume();child.on("error",reject);child.on("close",code=>code===0?resolve():reject(new Error(`relay exit ${code}`)));
child.stdin.end(JSON.stringify({hook_event_name:event}));
});
}
expect(received.map(p=>[p.hookEvent,p.relayRoute])).toEqual(["SessionStart","UserPromptSubmit","Stop","PermissionRequest"].map(e=>[e,"managed"]));
} finally {await new Promise<void>((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}
});
it("a same-named marketplace owned by someone else is not installed or trusted", async () => {
const config='[marketplaces.openrig]\nsource = "/another/source"\n';
const f=fixture(config);await f.adapter().ensureCodexCorePlugin();
expect(fs.readFileSync(f.file,"utf8")).toBe(config);expect(f.calls).toEqual([]);
});
@@ -34,24 +34,6 @@ describe("OS executable path witness", () => {
else execute.mockResolvedValue({ stdout: mode === "malformed" ? "not json" : JSON.stringify([[12, "relative"], [13, "/a/../b"], [14, "/a\0b"], [999, "/a"]]) });
expect((await readNativeExecutablePaths([12, 13, 14])).size).toBe(0);
});
it("tells a caller only about an actual read timeout (#86)", async () => {
setPlatform("darwin");
const onTimeout = vi.fn();
// Node's own timeout kill: killed by Node with the kill signal.
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM", code: null }));
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
expect(onTimeout).toHaveBeenCalledOnce();
for (const error of [
Object.assign(new Error("denied"), { killed: false, signal: null, code: 1 }),
Object.assign(new Error("too much output"), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }),
]) {
execute.mockRejectedValueOnce(error);
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
}
execute.mockResolvedValueOnce({ stdout: "not json" });
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
expect(onTimeout).toHaveBeenCalledOnce();
});
it("does no work for an empty request or unsupported platform, and caps batch size", async () => {
setPlatform("darwin");
expect((await readNativeExecutablePaths([])).size).toBe(0);
@@ -53,12 +53,4 @@ describe.each(Object.entries(SOURCES))("from %s", (source, serve) => {
expect(paths).toHaveBeenCalledExactlyOnceWith([20, 21]);
expect(rows.find(r => r.pid === 20)?.executablePath).toBe("/nix/store/x-claude-code-2.1.280/bin/.claude-unwrapped");
});
it("forwards a caller's path-read timeout listener only when one is given (#86)", async () => {
serve([row(15, "2.1.289", "claude --resume token")]);
const onPathReadTimeout = vi.fn();
paths.mockImplementation(async (_pids: number[], opts?: { onTimeout?: () => void }) => { opts?.onTimeout?.(); return new Map(); });
await listNativeProcesses({ onPathReadTimeout });
expect(paths).toHaveBeenCalledExactlyOnceWith([15], { onTimeout: onPathReadTimeout });
expect(onPathReadTimeout).toHaveBeenCalledOnce();
});
});
@@ -29,6 +29,7 @@ function realVendorFs(): PluginVendorFs {
w(dir, "");
return r;
},
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
statMode: (p) => fs.statSync(p).mode,
chmod: (p, m) => fs.chmodSync(p, m),
};
@@ -221,3 +222,86 @@ describe("PluginVendorService release-version authority", () => {
}
});
});
describe("managed refresh preserves recoverable edits", () => {
function upgrade(kind: "plugin" | "plugin skill" | "packaged skill", failBackup = false) {
const base = fs.mkdtempSync(nodePath.join(os.tmpdir(), "vendor-preserve-"));
const assets = nodePath.join(base, "assets"); seedAssets(assets);
const userPlugins = nodePath.join(base, "home", "plugins");
const globalRoot = nodePath.join(base, "global");
const plugin = nodePath.join(userPlugins, "openrig-core");
const source = kind === "plugin" ? nodePath.join(assets, "openrig-core") : plugin;
const target = kind === "plugin" ? plugin : nodePath.join(globalRoot, "compaction-restore");
const rel = kind === "plugin" ? "skills/compaction-restore/SKILL.md" : "SKILL.md";
const dest = nodePath.join(target, rel);
const logs: string[] = [];
const ops = realVendorFs();
const svc = new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: userPlugins, fs: ops, httpClient,
logger: (...args) => logs.push(args.map(String).join(" ")) });
const manifest = (dir: string, version: string) => {
for (const harness of [".claude-plugin", ".codex-plugin"]) {
fs.mkdirSync(nodePath.join(dir, harness), { recursive: true });
fs.writeFileSync(nodePath.join(dir, harness, "plugin.json"), JSON.stringify({ name: "openrig-core", version }));
}
};
// Global projection reads a staged plugin; no daemon or native runtime involved.
if (kind !== "plugin") {
fs.mkdirSync(nodePath.join(plugin, "skills/compaction-restore"), { recursive: true });
fs.writeFileSync(nodePath.join(plugin, "skills/compaction-restore/SKILL.md"), "# compaction-restore\n");
manifest(plugin, "0.1.0");
}
fs.mkdirSync(nodePath.dirname(dest), { recursive: true });
const edited = Buffer.from([0xff, 0, ...Buffer.from("my unique edit\r\n")]);
fs.writeFileSync(dest, edited);
if (kind === "plugin") manifest(target, "0.0.1");
else fs.writeFileSync(nodePath.join(target, ".openrig-vendor-version"), "0.0.1\n");
if (failBackup) ops.copyFileExclusive = () => { throw Error("fixture backup denied"); };
const refresh = () => kind === "plugin" ? svc.ensureVendored("openrig-core")
: kind === "plugin skill" ? svc.ensureSkillGlobally("openrig-core", "compaction-restore", [globalRoot])
: svc.ensureSkillDirGlobally(nodePath.join(plugin, "skills/compaction-restore"), "compaction-restore", JSON.parse(fs.readFileSync(nodePath.join(source, ".claude-plugin/plugin.json"), "utf8")).version, [globalRoot]);
const backupFiles = () => {
const dir = nodePath.join(base, "home", "backups", "managed-refresh");
return fs.existsSync(dir) ? ops.listFiles(dir).map(rel => nodePath.join(dir, rel)) : [];
};
return { base, source, target, dest, edited, logs, manifest, refresh, backupFiles, ops };
}
it.each(["plugin", "plugin skill", "packaged skill"] as const)("%s: recovers exact edited bytes after refresh and keeps earlier recovery copies across retries", async kind => {
const f = upgrade(kind);
try {
// Fail the first target write after preservation, then retry the same version with another edit.
const write = f.ops.writeFile;
let failWrite = true;
f.ops.writeFile = (file, content) => {
if (failWrite && file.startsWith(f.target + nodePath.sep)) { failWrite = false; throw Error("fixture interrupted refresh"); }
write(file, content);
};
await expect(Promise.resolve().then(f.refresh)).rejects.toThrow("fixture interrupted refresh");
const saved = f.backupFiles().find(file => fs.readFileSync(file).equals(f.edited));
expect(saved).toBeDefined();
expect(f.logs.join("\n")).toContain(saved);
expect(f.logs.join("\n")).toContain("before managed refresh");
const nextEdit = Buffer.from("a second unique edit\n"); fs.writeFileSync(f.dest, nextEdit);
await f.refresh();
expect(fs.readFileSync(f.dest, "utf8")).toBe("# compaction-restore\n");
expect(fs.readFileSync(saved!)).toEqual(f.edited);
const second = f.backupFiles().find(file => fs.readFileSync(file).equals(nextEdit));
expect(second).toBeDefined();
await f.refresh(); // equal-version retry leaves both recovery copies intact
expect(fs.readFileSync(saved!)).toEqual(f.edited);
expect(fs.readFileSync(second!)).toEqual(nextEdit);
} finally { fs.rmSync(f.base, { recursive: true, force: true }); }
});
it.each(["plugin", "plugin skill", "packaged skill"] as const)("%s: a failed preservation leaves all target files and version authority unchanged and warns", async kind => {
const f = upgrade(kind, true);
try {
const before = Object.fromEntries(realVendorFs().listFiles(f.target).map(rel => [rel, fs.readFileSync(nodePath.join(f.target, rel))]));
await expect(Promise.resolve().then(f.refresh)).resolves.toBeUndefined();
for (const [rel, bytes] of Object.entries(before)) expect(fs.readFileSync(nodePath.join(f.target, rel))).toEqual(bytes);
expect(f.logs.join("\n")).toContain("fixture backup denied");
expect(f.logs.join("\n")).toContain("refresh skipped");
} finally { fs.rmSync(f.base, { recursive: true, force: true }); }
});
});
@@ -25,6 +25,10 @@ function mockFs(initialFiles?: Record<string, string>) {
return {
readFile: (p: string) => { if (p in store) return store[p]!; throw new Error(`Not found: ${p}`); },
writeFile: (p: string, c: string) => { store[p] = c; },
copyFileExclusive: (src: string, dest: string) => {
if (dest in store) throw Error(`Recovery path exists: ${dest}`);
store[dest] = store[src]!;
},
exists: (p: string) => p in store || Object.keys(store).some((k) => k.startsWith(p + "/")),
mkdirp: () => {},
listFiles: (dir: string) => Object.keys(store).filter((k) => k.startsWith(dir + "/")).map((k) => k.slice(dir.length + 1)),
@@ -483,7 +483,7 @@ profiles:
expect(identityCall).toBeDefined();
expect(identityCall?.[0]).toBe("dev-impl@test-rig");
expect(identityCall?.[1]).toMatch(/^dev-impl@test-rig\nOpenRig session identity:/);
expect(identityCall?.[1]).toMatch(/^OpenRig-generated identity context \(not a project task\):\ndev-impl@test-rig\nOpenRig session identity:/);
// Identity fields preserved
expect(identityCall?.[1]).toContain("- rig: test-rig");
expect(identityCall?.[1]).toContain("- pod: dev");
@@ -102,7 +102,7 @@ function fixture() {
const eventBus = new EventBus(db);
const tmux = mockTmux();
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux, sleep: async () => {} });
const claude = new ClaudeCodeAdapter({ tmux, fsOps: realFs(home), sleep: async () => {} });
const resolverFs: AgentResolverFsOps = { readFile: (p) => fs.readFileSync(p, "utf-8"), exists: (p) => fs.existsSync(p) };
const inst = new PodRigInstantiator({
@@ -79,7 +79,7 @@ function wire(db: ReturnType<typeof createDb>, home: string) {
const tmux = mockTmux();
const tmuxAdapter = tmux as unknown as TmuxAdapter;
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter });
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter });
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, sleep: async () => {} });
const claude = new ClaudeCodeAdapter({ tmux: tmuxAdapter, fsOps: realFs(home), sleep: async () => {} });
const resolverFs: AgentResolverFsOps = { readFile: (p) => fs.readFileSync(p, "utf-8"), exists: (p) => fs.existsSync(p) };
const inst = new PodRigInstantiator({
@@ -167,6 +167,7 @@ describe("StartupOrchestrator", () => {
expect(tmux.sendText).toHaveBeenCalledTimes(1);
expect(tmux.sendText).toHaveBeenCalledWith("r01-impl", expect.stringContaining("rig queue list --destination r01-impl --state pending,in-progress,blocked"));
expect(tmux.sendText).toHaveBeenCalledWith("r01-impl", expect.stringContaining(makeIdentityAction().value));
expect(vi.mocked(tmux.sendText).mock.calls[0]?.[1]).not.toContain("readiness for an assignment");
});
it("persists the authored context before a native gate and exposes only the matching continuation", async () => {
@@ -715,6 +716,8 @@ describe("StartupOrchestrator", () => {
expect(firstPrompt).toContain("dev-impl@test-rig");
expect(firstPrompt).toContain("OpenRig session identity:");
expect(firstPrompt).toContain("Role instructions go here.");
expect(firstPrompt).toContain("OpenRig-generated identity context (not a project task):");
expect(firstPrompt).not.toContain("readiness for an assignment");
expect(sendText).toHaveBeenNthCalledWith(2, "r01-impl", "/rename impl");
expect(deliverStartup).toHaveBeenCalledTimes(1);
});
@@ -843,6 +846,7 @@ describe("StartupOrchestrator", () => {
"r01-impl",
expect.stringContaining("dev-impl@test-rig"),
);
expect(sendText.mock.calls[0]?.[1]).not.toContain("readiness for an assignment");
expect(sendText).toHaveBeenNthCalledWith(2, "r01-impl", "/rename impl");
});
@@ -861,6 +865,7 @@ describe("StartupOrchestrator", () => {
expect(deriveOriented(db, seed.nodeId)).toBe("missing");
// The challenge instruction is embedded in the first delivered prompt.
expect(sendText.mock.calls[0]?.[1]).toContain("startup orientation challenge");
expect(sendText.mock.calls[0]?.[1]).not.toContain("readiness for an assignment");
});
// Claude shows the long startup paste as pasted content and won't act on an instruction found only
@@ -933,7 +938,7 @@ describe("StartupOrchestrator", () => {
expect(deriveOriented(db, seed.nodeId)).toBe("n-a");
});
it.each([false, true])("omission delivers authored identity with files=%s and adds no exercise", async (withFiles) => {
it.each([false, true])("identity-only startup asks for readiness with guidance files=%s and adds no proof exercise", async (withFiles) => {
const seed = seedSession();
const adapter = mockAdapter();
const result = await createOrchestrator().startNode(makeInput(seed, {
@@ -944,7 +949,10 @@ describe("StartupOrchestrator", () => {
expect(result).toMatchObject({ ok: true, startupStatus: "ready" });
expect(adapter.project).toHaveBeenCalledOnce();
expect(adapter.checkReady).toHaveBeenCalledTimes(2);
expect(tmux.sendText).toHaveBeenCalledExactlyOnceWith("r01-impl", makeIdentityAction().value);
expect(tmux.sendText).toHaveBeenCalledExactlyOnceWith("r01-impl", expect.stringContaining(makeIdentityAction().value));
const prompt = vi.mocked(tmux.sendText).mock.calls[0]?.[1];
expect(prompt).toContain("OpenRig-generated identity context (not a project task):");
expect(prompt).toContain("run rig whoami --json and reply with your identity and readiness for an assignment");
expect(deriveOriented(db, seed.nodeId)).toBe("n-a");
const row = db.prepare("SELECT payload FROM events WHERE type='node.startup_pending'").get() as { payload: string };
expect(JSON.parse(row.payload).startupProof).toEqual({ mode: "none", source: "default" });
@@ -497,6 +497,108 @@ describe("startup prompt submission", () => {
expect(observations?.[0]).not.toHaveProperty("look");
expect(f.tmux.capturePaneContent).toHaveBeenCalledTimes(2);
});
// On Linux with Claude 2.1.306 the look after the startup Enter found Claude still drawing its screen, with no input line
// at all, so the check stopped at unverified and the startup text sat unsent until a person pressed Enter. A screen with
// no input line is looked at again, like our collapsed paste, within the same bound.
const noInputLine = "user@host:~/project$ ( . '/tmp/openrig-tmux-send-1-fixture.txt' )\n ▐▛███▛█ Claude Code v2.1.306\n▝▜██████▀ Opus 5.5 · Claude Max\n\n 1 more notice hidden\n";
it("submits a startup prompt that shows up staged once Claude draws its input box, with one Enter-only retry", async () => {
const f = fixture(1);
f.tmux.capturePaneContent.mockImplementationOnce(async () => noInputLine);
const result = await f.start();
expect(result).toMatchObject({ ok: true, startupStatus: "ready" });
expect(result.ok && result.submission).toBeUndefined();
expect(f.tmux.sendText).toHaveBeenCalledTimes(1);
expect(f.tmux.sendKeys).toHaveBeenCalledTimes(2);
expect(f.submitted).toEqual([sentPrompt(f)]);
});
it("confirms a startup prompt Claude took while its input box was still being drawn, with no second Enter", async () => {
const f = fixture(0);
f.tmux.capturePaneContent.mockImplementationOnce(async () => noInputLine);
const result = await f.start();
expect(result).toMatchObject({ ok: true, startupStatus: "ready" });
expect(result.ok && result.submission).toBeUndefined();
expect(f.tmux.sendKeys).toHaveBeenCalledTimes(1);
});
it("sends no second Enter when the input box appears holding a person's draft", async () => {
const f = fixture(Infinity);
f.tmux.capturePaneContent.mockResolvedValue("Previous turn\n────────────────────\n❯ a draft typed by the person\n────────────────────\n⏵⏵ accept edits on (shift+tab to cycle)\n");
f.tmux.capturePaneContent.mockImplementationOnce(async () => noInputLine);
expect(await f.start()).toMatchObject({ ok: true, submission: { status: "unverified",
reasons: ["Startup submission is unverified: the current composer does not positively match the complete prompt."] } });
expect(f.tmux.sendKeys).toHaveBeenCalledTimes(1);
});
it("sends no second Enter when a permission prompt appears instead of the input box", async () => {
const f = fixture(Infinity);
f.tmux.capturePaneContent.mockResolvedValue("Bash command\n\n rig whoami --json\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend\n");
f.tmux.capturePaneContent.mockImplementationOnce(async () => noInputLine);
expect(await f.start()).toMatchObject({ ok: true, submission: { status: "unverified",
reasons: ["Startup submission is unverified: the current composer boundary was not recognized."] } });
expect(f.tmux.capturePaneContent).toHaveBeenCalledTimes(2);
expect(f.tmux.sendKeys).toHaveBeenCalledTimes(1);
});
it("keeps a screen that never shows an input box unverified after the bounded wait, with no second Enter", async () => {
const f = fixture(Infinity);
f.tmux.capturePaneContent.mockResolvedValue(noInputLine);
const result = await f.start();
expect(result).toMatchObject({ ok: true, startupStatus: "ready", submission: { status: "unverified",
reasons: ["Startup submission is unverified: the current composer boundary was not recognized."] } });
expect(f.tmux.sendKeys).toHaveBeenCalledTimes(1);
expect(f.tmux.capturePaneContent).toHaveBeenCalledTimes(1 + SETTLE_LOOKS);
expect(f.sleep.mock.calls).toEqual(Array.from({ length: 2 + SETTLE_LOOKS }, () => [200]));
expect(result.ok && result.submission?.diagnostics).toMatchObject([{ retry: "not_run", observations: [
{ phase: "initial", look: 0, reason: "unrecognized_composer_boundary", markerLine: null },
{ phase: "initial", look: SETTLE_LOOKS, reason: "unrecognized_composer_boundary", markerLine: null },
] }]);
});
// A fresh launch can read ready before Claude has drawn its input box, so before a fresh start's first send the
// visible screen is looked at until the input box is drawn, within the same bound.
const inputBoxScreen = "────────────────────\n❯ \n────────────────────\n⏵⏵ accept edits on (shift+tab to cycle)\n";
const showScreens = (f: ReturnType<typeof fixture>, ...screens: Array<string | null>) => {
const capturePaneScreen = vi.fn(async (_pane: string) => screens.length > 1 ? screens.shift()! : screens[0]!);
(f.tmux as unknown as { capturePaneScreen: typeof capturePaneScreen }).capturePaneScreen = capturePaneScreen;
return capturePaneScreen;
};
it("waits for Claude to draw its input box before pasting a fresh seat's startup text", async () => {
const f = fixture(0);
const screen = showScreens(f, noInputLine, noInputLine, inputBoxScreen);
const result = await f.start();
expect(result).toMatchObject({ ok: true, startupStatus: "ready" });
expect(result.ok && result.submission).toBeUndefined();
expect(screen).toHaveBeenCalledTimes(3);
expect(screen).toHaveBeenCalledWith("%1");
expect(screen.mock.invocationCallOrder[2]!).toBeLessThan(f.tmux.sendText.mock.invocationCallOrder[0]!);
expect(f.tmux.sendKeys).toHaveBeenCalledTimes(1);
});
it("pastes a fresh seat's startup text as before when the input box doesn't appear within the bound", async () => {
const f = fixture(0);
const screen = showScreens(f, noInputLine);
expect(await f.start()).toMatchObject({ ok: true, startupStatus: "ready" });
expect(screen).toHaveBeenCalledTimes(SETTLE_LOOKS);
expect(f.tmux.sendText).toHaveBeenCalledTimes(1);
});
it("pastes a fresh seat's startup text at once when the screen can't be read", async () => {
const f = fixture(0);
const screen = showScreens(f, null);
expect(await f.start()).toMatchObject({ ok: true, startupStatus: "ready" });
expect(screen).toHaveBeenCalledTimes(1);
expect(f.tmux.sendText).toHaveBeenCalledTimes(1);
});
it("waits only before a fresh start's first send, not before the proof line that follows it", async () => {
const f = fixture(0, "claude-code", true);
const screen = showScreens(f, inputBoxScreen);
f.tmux.capturePaneContent.mockImplementationOnce(async () => ownPaste(sentPrompt(f)))
.mockResolvedValueOnce(composerCrop("Press up to edit queued messages"));
expect(await f.start({ startupActions: proofActions(false) })).toMatchObject({ ok: true, startupStatus: "ready" });
expect(f.submitted).toHaveLength(2);
expect(screen).toHaveBeenCalledTimes(1);
});
it("doesn't wait before a restore's first send", async () => {
const f = fixture(0, "claude-code", false, true);
const screen = showScreens(f, noInputLine);
expect(await f.start({ isRestore: true, startupActions: [], resolvedStartupFiles: [file("role.md")] })).toMatchObject({ ok: true });
expect(f.tmux.sendText).toHaveBeenCalledTimes(1);
expect(screen).not.toHaveBeenCalled();
});
for (const identity of [false, true]) {
const path = identity ? "identity" : "challenge-only";
for (const observation of ["unavailable", "mismatch"] as const) {
@@ -10,7 +10,7 @@ import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-en
import { runRig, spawnScenarioDaemon, type ScenarioDaemon } from "./helpers/scenario-daemon.js";
// Real CLI/daemon/SQLite/tmux with two scripted agents. These journeys exercise
// ordinary user commands; they do not model a provider prompt or input consumption.
// ordinary user commands; they do not model a provider prompt or agent acknowledgement.
// Reuse one private fixture to keep the PR cost bounded. Each assertion uses its
// own marker/selector. The stream and cwd cases restart this fixture's daemon.
const here = dirname(fileURLToPath(import.meta.url));
@@ -66,7 +66,7 @@ describe("stub CLI journeys", () => {
if (mode === "narrow soft-wrap") expect(JSON.stringify(target)).not.toContain(marker);
expect(JSON.stringify(target).replace(/\\n/g, "")).toContain(marker);
expect(JSON.stringify(sibling).replace(/\\n/g, "")).not.toContain(marker);
// Pane rendering is the contract here. The stub has no consuming input loop.
// Pane rendering is the contract here. The stub discards stdin without acknowledging it.
}, 60_000);
it("keeps the current-rig view exact while explicit scopes reach the other live rig", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.6.12",
"version": "0.7.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -2
View File
@@ -13,7 +13,7 @@
"core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da",
"core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77",
"core/openrig-software-factory/references/worked-example.md": "409680b7e96e1b5725a68fd7e96da0a4c908fec88a77599d39f574d9e64bcc88",
"core/openrig-upgrade/SKILL.md": "0ddbe16d8c93cd093e5fd3bd11a4323479db46a6d49ae9906df940b699f6e297",
"core/openrig-upgrade/SKILL.md": "5653ccb547ef22ecb4afe3d9a376e522997d1ebf44639690a875cdc5d7447951",
"core/openrig-upgrade/scripts/backup-sqlite.mjs": "52fe3015a0399986b586b6603e0664f61136d2210611b1638110a7b6b06e38b4",
"core/openrig-upgrade/scripts/inspect-upgrade.mjs": "0f9239f3f8ad75885e985433576ffba9cf155dd8616801dd73d60bf3e9990a2a",
"core/openrig-upgrade/scripts/migrate-telemetry-state-0.5.9.mjs": "3d7bdb43ce0618cf1d939e08c3f91531d866ac5b6fec5c4c3e4308d61e2bfc9f",
@@ -112,7 +112,7 @@
"core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da",
"core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77",
"core/openrig-software-factory/references/worked-example.md": "409680b7e96e1b5725a68fd7e96da0a4c908fec88a77599d39f574d9e64bcc88",
"core/openrig-upgrade/SKILL.md": "0ddbe16d8c93cd093e5fd3bd11a4323479db46a6d49ae9906df940b699f6e297",
"core/openrig-upgrade/SKILL.md": "5653ccb547ef22ecb4afe3d9a376e522997d1ebf44639690a875cdc5d7447951",
"core/openrig-upgrade/scripts/backup-sqlite.mjs": "52fe3015a0399986b586b6603e0664f61136d2210611b1638110a7b6b06e38b4",
"core/openrig-upgrade/scripts/inspect-upgrade.mjs": "0f9239f3f8ad75885e985433576ffba9cf155dd8616801dd73d60bf3e9990a2a",
"core/openrig-upgrade/scripts/migrate-telemetry-state-0.5.9.mjs": "3d7bdb43ce0618cf1d939e08c3f91531d866ac5b6fec5c4c3e4308d61e2bfc9f",
@@ -225,11 +225,17 @@ host in front of you.
process are gone. If the command returns while either remains, stop: inspect
identity and use the host's authorized recovery procedure. Do not blindly
signal a PID.
7. Start the target with `rig daemon start` or the host's known wrapper. Verify
daemon status, process path, listener, version, and database integrity.
8. Run the plugin refresh helper in plan mode. Apply safe writes only after the
three roots and classifications make sense; resolve preserved paths one at a
time when required.
7. Before starting the target, account for its startup refresh of managed plugins
and marked global skills. This runtime preserves differing replaced files under
`$OPENRIG_HOME/backups/managed-refresh/` and reports each recovery path; if the
target lacks that preservation, copy those trees first. Start it with
`rig daemon start` or the host's known wrapper. Verify daemon status, process
path, listener, version, and database integrity. Recovery copies retain bytes;
they do not promise custom behaviour remains active after refresh.
8. Run the plugin refresh helper in plan mode. Its plan governs only the helper's
later writes, not startup writes that already happened. Apply safe writes only
after the three roots and classifications make sense; resolve preserved paths
one at a time when required.
9. Verify protected seats with `rig ps --nodes -A --json`, representative
`rig capture`, and `rig restore-check` where applicable. `-A` is required:
without it the node read is your CURRENT rig only, and a daemon upgrade