mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99450f322 | ||
|
|
7e55c19a53 | ||
|
|
99aaada64c | ||
|
|
ac496208b0 | ||
|
|
b3e03ed75b | ||
|
|
a1cd9ebf3b | ||
|
|
2fec738282 | ||
|
|
5380ca1070 | ||
|
|
a892e73df8 | ||
|
|
f3fad85e96 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Generated
+5
-5
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.12",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.6.12",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user