Compare commits

...
Author SHA1 Message Date
OpenRig contributors 7d7970fd50 fix(daemon): keep binary files byte-identical in rig bundles
Bundle create read every vendored agent-package file, and the declared
culture, docs and startup files, as UTF-8 text and wrote it back as UTF-8.
Any byte sequence that is not valid UTF-8 became U+FFFD, and the integrity
manifest was hashed from the changed copy, so inspect and install verified
the corrupted bytes as intact.

Copy those files as bytes: the pod assembler reads them with readFileBuffer
and writes the same bytes, and the install-side skills and workflow-spec
routers copy with fs.copyFileSync. Rewriting rig.yaml and agent.yaml import
refs stays a text operation.

Refs #245
2026-10-01 01:14:37 +00:00
Mike SchwarzandOpenRig contributors afde814f5b fix(daemon): keep a blocked row's park timer through a seat handover (#242)
* fix(daemon): keep a blocked row's park timer through a seat handover

At a seat swap, the occupant invalidator stops every watchdog job the retiring
generation registered. A park timer (rig queue block --wake-after, with or without
--wake-max) is registered by the parking seat, so a handover stopped it too. Nothing
re-armed it, and the row stayed blocked with no timer, which is the strand reported
in korallis/agent-stack#61.

The rig's parked-owner consumer eventually noticed the dead wake and sent one generic
wake once the new occupant was idle. The recorded wake time, message and backoff
cadence were still lost.

A blocked row's current queue-generated timer wakes the seat that owns the row, and
the successor inherits that row, so the swap now keeps it. The queue lists those
timers: each is still the row's current armed wake, is still active, and still
targets the row's destination. The watchdog drop leaves them running.

Every other job the retiring generation registered still stops: operator-attached
watchdogs, superseded or stale timers, timers of rows rerouted to another seat, and
occupant-only jobs. Re-parking still retires the kept timer, so no duplicate is
created. A failed handover never reaches the invalidator, as before.

* fix(daemon): retire a park timer when its row is rerouted

routeToFallback moved a blocked row to a new owner but left the row's park timer
active and aimed at the old owner, so it could fire to the old owner. The
pre-delivery check only refuses timers whose rows are all terminal. That was already
reachable on main (park, reroute, fire), and keeping park timers through a handover
also let it happen after a swap (review-r2).

The reroute now retires the row's current park-generated timer through the existing
retireParkGeneratedTimer path, reason park_rerouted, the same one re-park and exit
use. The new owner has no dedicated wake from a reroute, as before; the retired timer
leaves the row's wake dead, so the parked-owner backstop covers it.

* fix(daemon): leave a repeating queue wait running when its row is rerouted

The previous commit retired every park timer on reroute. A repeating queue wait
already delivers to the row's current owner (evaluateQueueWait sends to view.owner),
so it woke the new owner correctly, and retiring it took that wake away. Only a
periodic park timer is aimed at the old owner, so only that is retired now.

Tests now fire through the engine with resolveQueueWait wired, as startup.ts wires
it. After a periodic reroute the row shows no live park timer, and the new owner's
parked diagnosis sees it as unhealthy, so the backstop covers the new owner. A
repeating wait survives the reroute, with or without swaps, and delivers only to the
new owner.

---------

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 17:19:00 -07:00
Mike SchwarzandOpenRig contributors cc2c37dc2f fix(daemon): keep UTF-8 intact across herdr socket chunks (#241)
createHerdrSocketRpc decoded each socket chunk on its own, so a
multi-byte character split across two chunks became U+FFFD while the
JSON line still parsed. Setting the socket encoding to UTF-8 uses Node's
streaming decoder, which carries an incomplete character into the next
chunk. Newline framing, id matching and error, end and timeout handling
are unchanged.

The new test drives the real RPC over a private unix socket and splits
the reply inside 2-, 3- and 4-byte characters, plus a case where
another id's line arrives first.

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 16:11:27 -07:00
Rudy Mizrahi Celekli cc02aef23f fix(daemon): preserve executable mode during atomic file edits (#235)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 16:04:49 -07:00
Rudy Mizrahi CelekliandOpenRig contributors 657bc01368 fix(cli): preserve local rsync directory content operands (#231)
Signed-off-by: Rudy Celekli <[email protected]>
Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 16:04:20 -07:00
Rudy Mizrahi Celekli 9b9cf9ec4c fix(daemon): release SSE subscriptions aborted during replay (#233)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 16:03:50 -07:00
Rudy Mizrahi Celekli 7810d5120a fix(cli): disclose stale environment service receipts (#232)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 15:57:21 -07:00
Rudy Mizrahi Celekli 6678cef5a1 fix(daemon): preserve discovery claims during async rescans (#237)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 15:56:39 -07:00
Rudy Mizrahi Celekli 3dca5ee54b fix(cli): decode SSH and rsync output across UTF-8 chunks (#230)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 15:49:34 -07:00
Mike Schwarzanddev-driver e6bfdce242 fix(transport): distinguish unverified runtime from stopped agent (#240)
Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-30 15:24:11 -07:00
d3ad0d1995 fix(daemon): observe native processes independently of the inherited locale (#239)
Both native process listers run `ps` with an `lstart` column and parse it with
an English date pattern. `ps` formats that column through the locale, so a
daemon that inherited a non-English locale (for example LANG=de_DE.UTF-8,
LC_TIME=fr_FR.UTF-8 or LC_ALL=ja_JP.UTF-8) saw every row fail the pattern:
process detection and resume metadata silently came back empty.

Each `ps` call now runs with a child-only `LC_ALL=C`, spread over the existing
environment. The parent environment, the column sets, the parsers and every
identity requirement are unchanged.

On macOS, the C locale also makes `ps` print non-ASCII bytes in the command
column as escapes (for example `\240`) instead of raw UTF-8. Token boundaries
and every ASCII identity token (executable name, session and resume ids) are
unchanged.


Claude-Session: https://claude.ai/code/session_01V8RzdrMhGkYLV4rn96kNMN

Co-authored-by: v-openrig-build <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-09-30 15:14:59 -07:00
Toan NguyenandOpenRig contributors 56a3efeaa1 fix(tmux): finish the printable-separator migration for client and cursor reads (#12) (#179)
Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 15:10:40 -07:00
Rudy Mizrahi Celekli f45c64f642 fix(daemon): clamp oversized suffix ranges to asset bounds (#234)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 15:04:25 -07:00
Rudy Mizrahi Celekli 8a8a3633ca fix(daemon): keep fenced example headings out of context addresses (#236)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 15:00:29 -07:00
Rudy Mizrahi Celekli c149053c5e fix: Keep CLI request deadlines active through response bodies (#201)
* fix(cli): retain request deadlines through response bodies

Signed-off-by: Rudy Celekli <[email protected]>

* fix(cli): preserve unknown outcomes for unreadable responses

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 14:59:53 -07:00
Rudy Mizrahi Celekli 87fc4fa2d5 fix(attach): accept runtime guards for existing nodes (#228)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 14:59:15 -07:00
f056f06a34 fix(daemon): keep ps rows whose command name contains spaces (#82)
listNativeProcesses reads `ps -Ao pid,ppid,pgid,tpgid,ucomm,lstart,command`
and matched ucomm with \S+. ucomm can contain spaces on both platforms:
macOS app helpers ("Slack Helper", "QuickTime Player"), and on Linux task
names that prctl(PR_SET_NAME) and process.title rewrite ("tmux: server",
"node (vitest 1)"). Every such row was silently dropped from the process
table, which is why the real-ps tests could not find their own (renamed)
vitest worker on Linux. lstart always begins with a weekday word and runs to
the year, and ucomm (16 bytes at most) is too short to contain such a date,
so matching ucomm lazily up to the first date is exact.

Co-authored-by: Martin <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 14:58:44 -07:00
Thomas Bray da2297e13a fix(paths): make module URLs and transcript containment portable (#168)
* Fix module URL conversion and transcript containment across platforms

* Refresh generated integrity digest for portable precompact hook

* fix(transcripts): contain unsafe fallback paths

* test(transcripts): cover unsafe fallback separators
2026-09-30 14:44:47 -07:00
Thomas BrayandMTG-Thomas 812e1e555f refactor(tmux): add shell-free argv execution seam (#167)
* refactor: argv-exec seam in TmuxAdapter (Windows/psmux path)

Every multiplexer invocation is now built as an argv array first.
A new optional ArgvExecFn dep runs it WITHOUT a shell
(execFile-style: argv[0] is the binary, rest verbatim, no quoting
layer) — the Windows/psmux path, where POSIX shells, single-quote
quoting and /dev/null redirections do not exist.

Default behavior is byte-identical: without argvExec every method
renders the exact legacy shell string (the existing suite pins all
of them). startServer (-D detach/redirection) and signalPaneProcess
(Unix kill, not a mux command) stay on the string path deliberately.

New tmux-argv-exec suite pins exact argv per op; live smoke
(/tmp, throwaway) drove real tmux 3.5a 7/7 through execFile with
the string path rigged to throw.

* Wire shell-free tmux execution without changing runtime adapters

---------

Co-authored-by: MTG-Thomas <[email protected]>
2026-09-30 14:44:12 -07:00
Guillaume FossierandClaude Sonnet 5.5 b2679174e5 fix(cli): skip ps for daemon process liveness on Windows (#173)
readProcessState shelled out to `ps -o state= -p <pid>` to reject zombies.
Windows has no ps and no zombie state, so isProcessAlive returned false for a
live daemon child and startup failed with "liveness could not be confirmed".
On win32 the process.kill(pid, 0) probe now decides on its own. POSIX behavior
is unchanged.

Refs #172

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-09-30 14:43:40 -07:00
c0a322950c test(cli): skip release-surface tag diffs when v0.3.1/v0.3.2 are absent (#152)
* test(cli): skip release-surface tag diffs when v0.3.1/v0.3.2 are absent

The worked-example (AC-2) block called generateSurfaceDiff in the describe
body, so in a fork or a shallow/--no-tags clone the throw during collection
failed the whole file, including the six unit tests that need no tags.

Check for the two tags up front and skip only the AC-2 and AC-4 blocks when
they are missing, with a hint to fetch tags. Move the AC-2 diff into
beforeAll so a skipped block does no git work. Under CI (which clones with
full history) a missing tag still fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(cli): only treat CI=true as CI in the release-surface tag guard

A truthiness check read CI=false as CI, so a tagless local run with
CI=false failed instead of skipping. Match scripts/ci-macos-process-probe.mjs
and GitHub Actions, which set CI=true.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-09-30 14:43:08 -07:00
JUSHUANGHUI LI 4658c48842 fix(cli): report unknown seat launch outcome on timeout (#191) 2026-09-30 14:39:26 -07:00
Qiaochu Huandhobostay c849d54f5f fix(daemon): leave user config content untouched when stripping codex activity hooks (#186)
stripCodexActivityHooks applied a global /\n{3,}/ collapse (and a
leading-newline trim) to the entire ~/.codex/config.toml document, not
just to the excision seam. Disabling the managed activity-hooks block
silently rewrote unrelated user content, including blank lines inside
TOML multiline strings, changing parsed values in a file the function
documents as touched only at the managed block.

The \n* prefix in the excision pattern already absorbs the blank-line
separator the block was appended with, so replacing the match with a
single newline restores the seam exactly. Drop the document-wide
collapse and the leading trim, and add regression tests that pin
byte-exact preservation of newline runs between user sections and
blank lines inside multiline strings.

Co-authored-by: hobostay <[email protected]>
2026-09-30 14:38:54 -07:00
Qiaochu Huandhobostay b3b9bdc09d fix(daemon): project file-shaped entries instead of failing them on ENOTDIR (#185)
projectEntry probes whether a source is directory- or file-shaped with
listFiles(entry.absolutePath). In production, startup.ts wires listFiles
to a recursive fs.readdirSync walk, which throws ENOTDIR for file-shaped
entries such as subagents (.claude/agents/<name>.yaml). The throw was
caught by project()'s per-entry handler, so every file-shaped entry
silently landed in ProjectionResult.failed and was never installed; the
single-file copy branch was unreachable outside tests. Unit tests missed
this because their mock listFiles returns [] for file paths instead of
throwing, diverging from the production wiring.

Treat a listFiles throw as not-a-directory in all four runtime adapters
(claude, codex, pi, stub) so file-shaped entries fall through to the
existing file copy branch, and add regression tests that drive a
file-shaped subagent through project() with readdirSync-backed fsOps.

Co-authored-by: hobostay <[email protected]>
2026-09-30 14:38:24 -07:00
Toan Nguyen 757b204348 fix(kernel): give kernel Claude seats the shared activity hooks (#160) (#178) 2026-09-30 14:37:53 -07:00
Toan Nguyen ed0f323092 fix(daemon): derive terminal pickup for closed qitems (#164) (#176) 2026-09-30 14:37:22 -07:00
Mike SchwarzandOpenRig contributors bcc0010de9 fix: replace stopped same-name rig generations on YAML import (#196)
* fix(daemon): rig remove never kills another rig's live seat; seat refs skip archived rigs (#174)

An archived duplicate of a live rig shares its name and its seats'
canonical session names.

- `rig remove <archived-rigId> <node>` killed the tmux session by name,
  so it could end the live rig's seat. Removal now asks whether another
  node owns that session name, using the handover's live-ownership check,
  now shared as session-owner.ts. If another node owns it, removal
  leaves the session running, doesn't block on or reroute the queue
  work addressed to it, and reports the owner as `sessionKeptFor`. The
  node itself is still removed.
- Seat refs (`member@rig`) for seat launch, stop, model and handover
  resolved rigs by name including archived ones, so they failed with
  "matched multiple nodes". They now resolve unarchived rigs only, as
  default reads do since migration 042. Other findRigsByName callers
  are unchanged.

* fix(daemon): archived rigs don't claim a live seat's session or input target (#174)

Review follow-up. Archiving a rig keeps its bindings, so the archived twin
still names the live seat's session:

- Removing the live node found the archived twin as the "other owner",
  kept the session and removed the node's open work. Removal now ignores
  owners in archived rigs, so the live node kills its own session and
  still refuses open work without --fallback. The handover check is
  unchanged.
- The delivery guard needs exactly one match for a target, and the twin's
  binding made it two, so delivery to the live seat failed. Unarchived
  matches now win, and archived ones count only when nothing else
  matches. Exactly one match is still required, so a truly ambiguous
  live target still refuses.
- `rig remove` and `rig shrink` human output now says when a session was
  kept, and for whom.

* fix(daemon): a YAML import archives the stopped same-name rig it replaces (#141)

After the seats' tmux server died, `rig up <same rig.yaml>` created a
second, unarchived rig with the same name. Every seat name then matched
two nodes, so launches and kills were refused.

When an explicit YAML import (PodRigInstantiator.instantiate) finds an
unarchived rig with the same name, it first confirms that rig is stopped:
- no running session rows (the existing running-name guard); and
- for every session name it used, this daemon's tmux reports positive
  absence or "no server running".

It then archives that rig in the same database transaction that creates
the replacement. The earlier rig's records are kept, and the output names
it with `rig unarchive <id>`. If the rig can't be confirmed stopped, the
import creates nothing and says why.

If creating the replacement fails, the service hook refuses it, or every
replacement node fails and the replacement is removed, the earlier rig
is un-archived. An adapter that cannot probe keeps today's behavior.

* fix(daemon): one YAML import per rig name while it may archive a generation (#141)

Review follow-up to the stopped-generation archive.

- Concurrency. The stopped check awaits tmux, and a second same-name
  import could run during that wait. It could replace the same old rig
  too, or archive the first import's in-progress replacement, which has
  no running sessions yet. The YAML import now takes a per-database,
  per-name in-flight claim, and a same-name import arriving meanwhile is
  refused (generation_unconfirmed). Unrelated names are unaffected. The
  create transaction also rechecks that the same-name set is the one it
  checked, and that each archive changed its row, so a rollback restores
  only archives this import made.
- Responses. generation_unconfirmed is now a 409 conflict with its
  message on POST /api/up and POST /api/rigs/import, not a 500. The
  archive notice also rides the attention_required outcome, where the
  replacement is kept.

* fix(cli): print the #141 archive notice on the attention 409 and the generation_unconfirmed message

rig up returned from its status >= 400 block before either warnings loop, so the
archive notice (old rig id and its unarchive command) on an attention 409 reached
--json only. Print the response warnings after the attention nodes; that path returns
early, so nothing prints twice. Render generation_unconfirmed verbatim like
rig_name_running instead of the validate-your-spec fallback. JSON output and exit
codes are unchanged.

* fix(daemon): archived generations don't block the replacement's handover or pod shrink

A YAML re-import archives the stopped earlier generation, which keeps its bindings
to the session names the live replacement now uses.

Handover: a fresh, fork or rebuild successor reuses the seat's own session name,
so the archived binding made finalize return successor_already_managed after the
successor had replaced the process. The owner check skips archived rigs for
composer-launched successors, as removeNode does. A discovered successor is still
blocked by any other owner.

Pod shrink: shrinkPod counted and, with --fallback, rerouted queue work addressed
to member session names that another unarchived node owns. It now applies
removeNode's ownership rule first, so shrinking the archived pod neither stops on
nor moves the replacement's work.

* fix(daemon): keep a shrink target's own shared-name work in the up-front check

C3's shrinkPod ownership filter excluded only the node being checked, so when two
members of the pod being shrunk shared a session name, each counted the other as its
owner. Their open queue work then dropped out of the up-front check. Without
--fallback the shrink removed one member and failed on the last. With --fallback the
work wasn't rerouted.

findOtherSessionOwner gains an optional excludeNodeIds, applied in both the binding
and the session-row lookups before the first match, and its default is unchanged.
shrinkPod passes the whole target, so only an unarchived owner outside the target
takes a name's work out of the check. Before C3, shrinking such a pod refused up front
or rerouted with --fallback, and that behavior is restored.

---------

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 14:08:23 -07:00
5177ec8913 fix: provider-aware Codex readiness for non-OpenAI providers (#222)
* fix: provider-aware Codex readiness for non-OpenAI providers (#194)

`rig setup` and the kernel runtime probe treated `codex login status` as the
only Codex authentication check. A user whose Codex selects a custom provider
with `requires_openai_auth = false` and an `env_key` (the reported Amazon
Bedrock bearer-token provider) failed setup and was never offered a Codex
kernel, even with the provider's credential set.

Both checks now read the provider selected in `$CODEX_HOME/config.toml`. For
an explicit provider entry with `requires_openai_auth = false` and an
`env_key`, a nonblank variable counts as local credential availability; the
kernel probe still requires the Codex executable. Every other case keeps the
OpenAI login check, including a missing file, a parse error, a legacy
top-level `profile`, and built-in or undeclared providers, so an unresolved
config never earns readiness. The setup message says this is a local
credential check, not remote authentication or inference.

`AWS_BEARER_TOKEN_BEDROCK` joins the known provider-auth names, so an operator
who names it in `recovery.provider_auth_env_allowlist` can forward it to
managed seats. Nothing is forwarded without that opt-in.

Other Codex config layers (project, system or managed config, requirements,
`-c`, `--profile`) are not resolved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01V8RzdrMhGkYLV4rn96kNMN

* docs: narrow the #194 unresolved-config wording

An unresolved Codex config does not take the new env-key shortcut; readiness
still follows the existing login result. The comment and one test title now say
that, instead of implying OpenRig validates every configuration. No behavior
change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01V8RzdrMhGkYLV4rn96kNMN

---------

Co-authored-by: v-openrig-build <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-09-30 13:37:36 -07:00
Mike Schwarzanddev-driver 5b344e0578 fix(claude): preserve unset native config selection (#225)
Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-30 12:02:47 -07:00
Mike Schwarzandv-openrig-build b43498d7da docs(contributing): ask security and integration PRs for practical scenarios (#224)
Adds a 'Before a security or integration PR' section to CONTRIBUTING.md, a
security question block to the PR template, and a trust-model bullet to
SECURITY.md's scope notes. Additive only; existing scope text is unchanged.

Co-authored-by: v-openrig-build <[email protected]>
2026-09-30 11:57:13 -07:00
Mike Schwarzanddev-qa 8b5e948807 chore(release): prepare 0.6.3 regression repair notes (#223)
Co-authored-by: dev-qa <dev-qa@openrig-build>
2026-09-30 11:25:15 -07:00
Mike Schwarzanddev-qa 4dc4047cdb fix(ci): serialize script tests sharing build outputs (#221)
Co-authored-by: dev-qa <dev-qa@openrig-build>
2026-09-30 11:03:08 -07:00
ammann 700af77ae0 fix(cli): protect proof media from artifact writes (#177) 2026-09-30 11:02:23 -07:00
Mike SchwarzandOpenRig contributors 3b1b335dbe fix(daemon): rig remove never kills another rig's live seat; seat refs skip archived rigs (#174) (#181)
* fix(daemon): rig remove never kills another rig's live seat; seat refs skip archived rigs (#174)

An archived duplicate of a live rig shares its name and its seats'
canonical session names.

- `rig remove <archived-rigId> <node>` killed the tmux session by name,
  so it could end the live rig's seat. Removal now asks whether another
  node owns that session name, using the handover's live-ownership check,
  now shared as session-owner.ts. If another node owns it, removal
  leaves the session running, doesn't block on or reroute the queue
  work addressed to it, and reports the owner as `sessionKeptFor`. The
  node itself is still removed.
- Seat refs (`member@rig`) for seat launch, stop, model and handover
  resolved rigs by name including archived ones, so they failed with
  "matched multiple nodes". They now resolve unarchived rigs only, as
  default reads do since migration 042. Other findRigsByName callers
  are unchanged.

* fix(daemon): archived rigs don't claim a live seat's session or input target (#174)

Review follow-up. Archiving a rig keeps its bindings, so the archived twin
still names the live seat's session:

- Removing the live node found the archived twin as the "other owner",
  kept the session and removed the node's open work. Removal now ignores
  owners in archived rigs, so the live node kills its own session and
  still refuses open work without --fallback. The handover check is
  unchanged.
- The delivery guard needs exactly one match for a target, and the twin's
  binding made it two, so delivery to the live seat failed. Unarchived
  matches now win, and archived ones count only when nothing else
  matches. Exactly one match is still required, so a truly ambiguous
  live target still refuses.
- `rig remove` and `rig shrink` human output now says when a session was
  kept, and for whom.

---------

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 10:23:18 -07:00
Mike SchwarzandOpenRig contributors e3ebd4ac2c fix(daemon): clean up unusable snapshot refresh helpers (#189)
* fix(daemon): a snapshot refresh probe never leaves its tmux session behind (#188)

`rig down` probes Claude resumability in a private `rigged-refresh-*`
tmux session. Two paths left it running, and every retry added another:

- After a seats' tmux server restart, the new server reuses pane ids,
  so a stale seat binding could name the probe's brand-new pane. The
  probe's kill then asked the guard to resolve the helper by name, which
  threw, and the unawaited write let the throw escape: the snapshot
  failed ("Cannot establish managed input target rigged-refresh-...")
  and the helper was never killed.
- When the new pane could not be proven, the probe returned failure
  without removing the session it had just created.

createProbeSession now refuses to use a probe whose new pane is named by
a managed record, or whose pane it cannot prove. It removes that helper
immediately, by the name this call just created. The probe write is
awaited, so a later refusal returns as a result instead of throwing.
Managed or changed targets are still never killed.

* fix(daemon): roll back an unusable probe only by the id its create returned (#188)

Review follow-up. The allocation rollback killed the helper by name after
awaiting creation and pane observation, which could kill a session that a
managed record had adopted meanwhile, or a different session that had taken
the name.

The probe is now created with `new-session -P -F '#{session_id}'`, and the
rollback kills only that session id. It leaves the helper in place, and
says so, when the id is unknown or a managed record now claims the helper's
name. A binding that names only the reused pane id under another session
name is stale and does not block the rollback. A cleanup failure is
reported in the result. The awaited probe write is unchanged.

* fix(daemon): check the helper's identity in the same tmux command that removes it (#188)

Review follow-up. A tmux session id is unique only for one server's
lifetime. If the server restarted between creating the refresh helper and
rolling it back, `kill-session -t $N` could remove an unrelated session
that the new server gave the same id.

The helper's create now returns its id and creation time. The rollback
runs `if-shell -F -t $N` with a check on the session's name and creation
time, and kills only when both match, in the same tmux command. A
mismatch, or an id that no longer resolves, leaves everything in place
and says so in the result.

---------

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 09:31:52 -07:00
Mike Schwarzanddev-driver 100be9d646 fix: recognize managed Claude seats behind launch wrappers (#220)
* fix: recognize managed Claude behind shell wrappers

* fix: normalize observed Claude executable name

---------

Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-30 09:31:13 -07:00
Mike Schwarzanddev-driver f8f3aff68b fix(transport): recognize proven Codex behind shell wrappers (#171)
Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-29 17:03:50 -07:00
Mike Schwarzanddev-driver 1618bab05f fix(daemon): bound overlapping identity and transcript polls (#169)
Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-29 15:38:24 -07:00
Mike Schwarzanddev-driver b68571318d fix(codex): project shared skills to the Codex target (#162)
* fix(codex): project shared skills to the Codex target

* test(projection): allow runtime-aware resolver wiring

---------

Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-29 15:24:58 -07:00
Mike Schwarzanddev-driver 6ab706d435 chore(release): prepare 0.6.2 (#153)
* chore(release): prepare 0.6.2

* docs(release): clarify the default bundle target directory

---------

Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-29 13:04:56 -07:00
Mike Schwarzanddev50-guard a745475b07 fix(daemon): preserve managed launch target across pane reuse (#151)
Co-authored-by: dev50-guard <dev50-guard@openrig-build>
2026-09-29 09:46:38 -07:00
Mike SchwarzandOpenRig contributors 18201ac659 fix(transport): don't type into a bare shell where an agent runtime should run (#142) (#150)
When a seat's agent runtime has failed or exited, its pane shows a bare
shell, and any text typed there runs as shell commands. The
parked-owner watchdog wake was delivered anyway (`zsh: bad pattern:
[OpenRig`).

The transport now refuses a send when the pane's foreground is a shell
and the seat's runtime is an agent runtime, before anything is written.
It uses the shared shell classifier. A terminal node's shell is its
runtime and still receives text. An unknown or unreadable pane stays
advisory, as before.

The watchdog records the refusal as a failed delivery on the open row
and sends no second wake that episode. A running runtime still gets its
wake.

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-29 09:30:28 -07:00
Mike Schwarzanddev-driver aa7bc4407f feat: guide first-project permissions and provider choice (#147)
Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-29 09:30:11 -07:00
6b8bee36b1 fix(bundle): install v2 bundles into --target and resolve paths on the client (#146)
* fix(bundle): resolve bundle paths on the client and launch v2 installs from --target

`rig bundle create/inspect/install` sent spec, output, bundle and target paths
unresolved, so the daemon resolved them against its own cwd. The CLI now
resolves them against the operator's cwd (files are still read on the daemon's
host; nothing is uploaded).

A schema-version-2 bundle apply launched from the temp extraction dir, which is
removed right after instantiate, and ignored targetRoot: members with cwd "."
and the spec/agent refs pointed at a deleted directory. Apply with a targetRoot
now copies the verified extraction into the target and launches from there.
A target with different content at any bundle path is refused with
target_conflict and nothing written; identical files are accepted so a
reinstall works. Plan mode, explicit cwdOverride, legacy v1 bundles and
applies without a targetRoot are unchanged.

Docs: rig-bundle.md install/inspect/--plan/--target/path behavior.

* fix(cli): resolve local rig up --target and align its help with the v2 target contract

On the local route, `rig up <bundle> --target <rel>` now resolves the
target against the client's cwd before sending it, matching `rig bundle
install`. The --target help now states the accepted contract: a v2 bundle
is materialized into the target (default: current directory), relative
member cwds resolve against it, and --cwd still overrides the launch cwd.
Remote --host and topology routing are unchanged: --host sends --target
as given, and it must exist on that host.

Docs: drop the "pass an absolute --target" workaround for the local route.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: v-openrig-build <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-09-29 09:29:33 -07:00
Mike SchwarzandOpenRig contributors 705cda2f0e test(daemon): give successful launch fixtures a startup context and adapter (#148)
#107 refuses to report a fresh pod-aware launch as fresh-primed without the
node's startup context and a runtime adapter. Two fixtures still expected a
successful launch without either, and failed on main:

- launch-node-subset: the single-node launch now seeds the target's startup
  context and passes a ready claude-code adapter, and asserts the harness
  was launched. The non-target assertions are unchanged.
- sessions-routes: the launch-subset success case seeds both nodes' startup
  contexts and opts into route runtime adapters, as startup wires them.

createTestApp gains an opt-in wireRuntimeAdapters option. It is off by
default, so every other route test keeps no route adapters. No production
change.

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-29 09:01:59 -07:00
Sunil 5b7e07c906 refactor: reuse shared constantTimeEqual in terminal-ws (#145)
terminal-ws.ts had a local constantTimeEqual that returned early on
length mismatch. The shared helper in auth-bearer-token.ts already
handles unequal lengths with a fixed-cost dummy compare. Replace the
local copy with an import of the shared helper for consistency.
2026-09-29 08:35:15 -07:00
9523b9de02 test(daemon): pin the #131 sender canonicalizer's edges and scope (#144)
Carries the tests and scope note from #137, which was folded into #135:
- round trip with the stampSelfHostSuffix producer;
- 4-segment names, @local, a near-miss host id, legacy flat names and
  virtual refs pass through unchanged;
- the origin-unknown marker and the P18 wire-over-body rule hold after
  the strip, for both sender helpers.

The comment records that only the sender is canonicalized: the CLI
cannot fix this at the source, and self-suffixed destinations keep the
C4 refusal. No behavior change.

Co-authored-by: OpenRig contributors <[email protected]>
Co-authored-by: Yi-111-a <[email protected]>
2026-09-29 07:48:56 -07:00
Wu Shuwen dc14d71793 fix(restore): surface missing startup context (#107) 2026-09-29 07:48:37 -07:00
Wu Shuwen 636a4790f9 feat(herdr): set agent hint from seat runtime (#106) 2026-09-29 07:22:37 -07:00
Wu Shuwen e4070e4169 fix(tui): preserve unassessed health in focused views (#102) 2026-09-29 07:22:14 -07:00
Wu Shuwen 1fffd9c988 Fix Claude rate limit reset times (#108) 2026-09-29 07:19:43 -07:00
FenjuFu cb29c1f7ce feat(daemon): allow forwarding ANTHROPIC_BASE_URL to seats (#143)
Claude Code seats can already receive ANTHROPIC_API_KEY and
ANTHROPIC_AUTH_TOKEN through recovery.provider_auth_env_allowlist, and
Codex seats can receive OPENAI_BASE_URL, but ANTHROPIC_BASE_URL was not a
known name and was dropped silently. A key issued by an
Anthropic-compatible endpoint (a local Ollama server, a hosted gateway)
then reached the seat without its endpoint, so Claude Code sent it to
api.anthropic.com.

Refs #140

Signed-off-by: FenjuFu <[email protected]>
2026-09-29 07:19:21 -07:00
Johnny Bae e8e8585be9 fix(restore): honor the configured shared-docs root (#139) 2026-09-29 07:19:03 -07:00
Wu Shuwen f6e5df150b fix(tmux): use printable separators for windows (#101)
* fix(tmux): use printable separator for windows

* fix(tmux): preserve separators in window names
2026-09-29 07:06:10 -07:00
Wu Shuwen 0e416ab4ce fix(codex): canonicalize hook trust paths natively (#103) 2026-09-29 07:05:31 -07:00
Shravan Sumanthanan b3f2b085cd fix(daemon,ui): fix timezone flake, macOS realpath in test, and dumb term runner editing (#123)
* fix(daemon,ui): fix timezone flake, macOS realpath in test, and dumb term runner editing

- packages/ui/test/artifacts-navigator.test.tsx: use noon UTC for fixture mtime to ensure consistent local date formatting across all timezones
- packages/daemon/test/s03-bound-launch.test.ts: canonicalize mkdtempSync root via realpathSync to match macOS /private/var path resolution
- packages/daemon/src/adapters/pi-runner.ts: preserve VT100 line editing in createRunnerInput when TERM=dumb by deleting dumb-mode override

* test(daemon,ui): add focused TERM=dumb runner test and compute local mtime dynamically

- packages/daemon/test/pi-runner.test.ts: add focused unit test asserting line editing (type, backspace, retype) under TERM=dumb while verifying normal terminal and piped input still behave as before
- packages/ui/test/artifacts-navigator.test.tsx: compute expected date dynamically from fixture mtime in local time, ensuring 100% timezone invariance across UTC+12..+14
2026-09-29 07:05:03 -07:00
KorallisandClaude Opus 5.5 a09388aabd fix(daemon): canonicalize a self-host-suffixed sender to the local seat (#131) (#135)
Under load the CLI's loopback locality probe can time out, and the client
then host-qualifies its sender with this daemon's own self-host id
(member@rig@<selfHostId>). Every strict actor compare then refused the seat's
own work; for example, `rig queue claim` returned claim_destination_mismatch.

The transport identity now passes through one canonicalizer
(canonicalSenderSession / transportSenderSession) before any actor compare or
record. A trailing @<selfHostId> resolves to the bare local seat. Foreign host
qualifiers, unqualified names and pre-boot (null self id) requests are
unchanged. Covered call sites: requireSenderIdentity and
resolveActorWithDeferral (queue create/claim/unclaim/update/handoff/inbox/
outbox and the other attributed routes), transport send/broadcast, the seat
preference audit, and the activity caller scope.

Fixes #131

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-29 07:04:36 -07:00
179 changed files with 6850 additions and 537 deletions

No files matched your search

+4
View File
@@ -12,6 +12,10 @@
<!-- Design choices, edge cases you did not cover, places a reviewer should look hardest. Empty is a fine answer. -->
## If this is security-related
<!-- Exploitable? Report it privately first (SECURITY.md). Otherwise: how does this go wrong for someone using OpenRig as designed (your own machine or a trusted private network), with evidence? Who causes it, and how do they reach the install? What does the change cost everyone else? See CONTRIBUTING.md. -->
- [ ] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [ ] Tests added or updated where the change is testable
- [ ] I listed the checks I ran, their results, and any checks I could not run
+56
View File
@@ -8,6 +8,62 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.6.3]
- Recognize running managed Claude Code seats behind OpenRig's shell wrappers
using pane lineage, foreground process and native session identity checks.
This repairs the 0.6.2 messaging refusal reported by
[@dmelo](https://github.com/dmelo) in
[#197](https://github.com/mvschwarz/openrig/issues/197)
([#220](https://github.com/mvschwarz/openrig/pull/220)).
- Clean up unusable snapshot helpers when their ownership is proven, instead
of accumulating them on repeated attempts. Thanks to
[@z4cc](https://github.com/z4cc) for
[#188](https://github.com/mvschwarz/openrig/issues/188)
([#189](https://github.com/mvschwarz/openrig/pull/189)).
- Keep archived duplicate rigs out of seat-reference resolution and preserve
another live seat's session and queue work when removing a stale node.
Thanks to [@Farkinell](https://github.com/Farkinell) for
[#174](https://github.com/mvschwarz/openrig/issues/174)
([#181](https://github.com/mvschwarz/openrig/pull/181)).
- Protect proof media from artifact writes: reject binary artifact bodies and
require an explicit `--replace` to overwrite an existing Markdown artifact
([#177](https://github.com/mvschwarz/openrig/pull/177)). Repository script tests
also run serially to prevent interference from shared build outputs
([#221](https://github.com/mvschwarz/openrig/pull/221)).
See [0.6.3 release notes](docs/releases/v0.6.3.md) for the verification limits.
Source regression coverage does not establish installed Claude/Fedora delivery.
## [0.6.2]
- Start with two Claude Code agents, two Codex agents, or a Claude owner and
Codex checker, using the same first-project task and review path. Agent-guided
setup recommends a scoped `rig` command allowance after your explicit choice;
broader permissions remain separate
([#147](https://github.com/mvschwarz/openrig/pull/147)).
- Install schema-version-2 bundles into `--target` and launch from those retained
files. Local CLI bundle paths resolve from your working directory; conflicting
target files are preserved ([#146](https://github.com/mvschwarz/openrig/pull/146)).
Without `--target`, `rig up <file>.rigbundle` installs into your current directory, so run it from the project folder you want.
- Refuse message delivery into a bare shell where an agent runtime should be
running ([#150](https://github.com/mvschwarz/openrig/pull/150), fixes
[#142](https://github.com/mvschwarz/openrig/issues/142)).
- Preserve the named target during managed launches after a tmux/host restart,
instead of confusing it with an old bare pane binding; existing identity checks
remain. Thanks to [@diaztunjano](https://github.com/diaztunjano) for reporting
[#141](https://github.com/mvschwarz/openrig/issues/141), addressed by
[#151](https://github.com/mvschwarz/openrig/pull/151).
- Repair self-host sender identity, tmux window parsing, Codex hook-path
canonicalization, Pi input editing, TUI health display and restore diagnostics;
carry runtime hints to Herdr and allow configured Anthropic endpoint forwarding.
See the [community fixes and credits](docs/releases/v0.6.2.md#community-fixes).
See [0.6.2 release notes](docs/releases/v0.6.2.md) for usage and compatibility.
Installed first-use and reboot/power-loss verification remain pending; these
source changes do not establish those outcomes.
## [0.6.1]
- One version-matched agent help guide: `rig context get help`, also available
+21
View File
@@ -17,6 +17,27 @@ change in with the least friction on both sides.
- **Questions:** use [Discussions › Q&A](https://github.com/mvschwarz/openrig/discussions/categories/q-a),
not an issue.
## Before a security or integration PR
OpenRig connects your coding agents to each other: shared context, messaging, coordination and long-running seats.
It's designed for your own machine or a trusted private network, for you and people you trust, not for the open
internet. It doesn't try to act for you in the outside world; your agents already have tools for that.
**Security-related PRs.** If you've found something exploitable, report it privately first (see
[SECURITY.md](SECURITY.md)) rather than in a public PR. For hardening changes, tell us in the PR description:
- the scenario: how this goes wrong for someone using OpenRig as it's designed to be used, and the evidence you have
- who or what causes it, and how they reach the install
- what the change costs everyone else: a refusal, an extra step, a new setting
We look at the finding and the fix separately. We may agree with a finding and fix it differently, or decide the fix
costs more than it protects. If the answers are missing we'll ask once, and close the PR with thanks if they don't
come.
**Integrations with other tools or projects.** Open an issue or an Ideas Discussion and wait for a maintainer's yes on
scope before you build it; an issue on its own isn't a yes. Often the best home for an integration is your own
repository, and we're happy to link to it.
## Setting up
Node `^22 || ^24` and a working `tmux` are required. Then:
+27 -17
View File
@@ -27,34 +27,43 @@ rig setup --dry-run
To install with Bun instead, run `bun add -g @openrig/cli`. OpenRig still runs on Node.js, so install Node.js 22 as well. Bun may block this package's postinstall script, in which case the Node.js and SQLite check described under [what OpenRig changes on your machine](#what-openrig-changes-on-your-machine) does not run at install time.
Review setup's plan before applying `rig setup`: it checks both native harnesses and cmux. This starter requires tmux and authenticated Codex; the other harness and terminal provider are optional for its repository task.
Choose the working account you already have: **Claude Code, Codex, or both**. Reuse an explicit choice; no second subscription is required. `rig setup --dry-run` previews the broader setup, but applying `rig setup` checks both harnesses and cmux. It is optional for the [selected-provider path](docs/reference/getting-started.md#choose-your-providers).
Before launching, ask your agent to [configure your chosen permissions](docs/reference/getting-started.md#have-your-agent-configure-permissions): keep prompts, remember selected commands, or deliberately choose broader access. The agent handles setup and verification; OpenRig's shipped defaults stay unchanged.
Before launching, your agent asks once: **“Allow your agents to run OpenRig commands without repeated permission prompts?” Yes — recommended / No — keep prompts.** This covers every `rig` command, including starting/stopping agents and configuration, at personal project scope unless you explicitly choose user-wide sessions. It is not global YOLO or permission to invent work. On Yes, the agent [adds and verifies native rules](docs/reference/getting-started.md#have-your-agent-configure-permissions); No or no answer leaves settings unchanged. An existing explicit choice is reused. Say “Undo the OpenRig command allowances added by this setup” to remove only its additions.
Check prerequisites in your launch shell:
Check `tmux -V` and only your selected CLI/login: `claude --version` plus
`claude auth status`, or `codex --version` plus `codex login status`. If needed,
sign in once with `claude auth login` or `codex login`; do not install or log in
to an unused provider.
```bash
tmux -V
codex --version
codex login status
```
| Team | Starter | Models |
| --- | --- | --- |
| Two Codex agents | `first-project` | Both `gpt-6-astra` (unchanged) |
| Two Claude agents | `first-project-claude` | Configured native Claude default |
| Claude owner + Codex checker | `first-project-mixed` | Claude default + `gpt-6-astra` |
Resolve missing tools or login before continuing. From your repository, inspect the plan before launching the two Codex seats, an owner and a checker:
All three use the same owner/checker roles and task. Show the selected runtime,
configured model and command before launch; confirm the account supports the
model instead of silently falling back. The kernel starts automatically and
selects from available authenticated providers independently of these two project
agents. A missing unused provider is not a setup requirement.
```bash
cd /path/to/your/repository
rig up first-project --cwd . --plan
rig up first-project --cwd .
starter=first-project # or first-project-claude or first-project-mixed
rig specs preview "$starter" --kind rig
rig up "$starter" --cwd . --plan
rig up "$starter" --cwd .
rig tui --shared
```
The kernel provides separate operational support and the shared dashboard. To detach without stopping the dashboard, press Ctrl-b then d; `rig tui --shared` returns to that view. Plain `rig tui` opens an independent view. Closing a viewing terminal does not mean you should relaunch the team.
Check project-seat readiness with `rig ps --nodes --rig first-project` and resolve any authentication, trust or permission prompt before assigning work. Then give the owner one bounded outcome from your repository:
Check project-seat readiness with `rig ps --nodes --rig "$starter"` and resolve any authentication, trust or permission prompt before assigning work. Then give the owner one bounded outcome from your repository:
```bash
rig send dev-owner@first-project 'Implement <one useful change>. Track the task in the queue and return its ID. Keep it local, verify the behavior, ask dev-check@first-project to check the exact candidate, and record the result and how I can try it.'
rig queue list --destination dev-owner@first-project --limit 1000
rig send "dev-owner@$starter" 'Implement <one useful change>. Track the task in the queue and return its ID. Keep it local, verify the behavior, ask dev-check in this rig to check the exact candidate, and record the result and how I can try it.'
rig queue list --destination "dev-owner@$starter" --limit 1000
```
Sending a message does not itself create a queue item; the owner records the task. Read the final artifact and the review of its exact candidate, then return to the same owner for the next change. [The guided first-use path](docs/reference/getting-started.md) covers readiness, a useful task, a reviewed result, Herdr/cmux terminals and recovery.
@@ -84,7 +93,7 @@ using a published package, since repository guidance can be ahead of npm.
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. On macOS it can install cmux and enable its automation socket control in `~/.config/cmux/settings.json`. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. With `runtime.codex.hooks_enabled` enabled (the default), writes Codex hook configuration and trust records as described below—even before a rig launches. |
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. |
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules. Ask your agent to [apply your chosen project or user scope](docs/reference/getting-started.md#have-your-agent-configure-permissions); existing rules remain relevant. Broader access is a separate choice. |
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules. Agent-guided setup recommends Yes and requires your actual answer before the agent [adds rules at your chosen scope](docs/reference/getting-started.md#have-your-agent-configure-permissions). No/no answer preserves settings; existing choices and stricter rules remain relevant. Broader access is separate. |
The provider files are separate from instance state. Here `~` means the daemon
user's home; changing `OPENRIG_HOME` alone does not isolate provider configuration.
@@ -167,7 +176,8 @@ Every agent runs in a tmux session you can attach to, inspect, and work with dir
## Starter Rigs
Use `first-project` for the focused first-use path. `product-team` is an optional
Use `first-project`, `first-project-claude`, or `first-project-mixed` for the
same focused first-use path on your selected providers. `product-team` is an optional
larger product-development example:
```bash
@@ -343,7 +353,7 @@ Before setup or managed launch, review [what OpenRig changes on your machine](#w
Already-running adopted sessions may need restart before they pick up newly written runtime config.
**For agents:** Ask the user whether they want core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) before choosing the invocation. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice. Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
## Comparison with Claude Managed Agents
+4
View File
@@ -39,6 +39,10 @@ could do. A proof of concept is welcome; a working exploit against a third party
## Scope notes
- OpenRig is designed for your own machine or a trusted private network, for you and people you trust, not for the
open internet. We assess reports by their practical impact on a user in that setup. Exposing an install to the open
internet isn't a supported deployment. OpenRig's own gateway integrations with outside services, such as Slack, and
anything that lets an untrusted party reach the daemon or an agent are in scope.
- OpenRig assumes the machine and the accounts it runs under are trusted by their owner. Reports
that require a hostile local user with the same account are still welcome but are unlikely to be
treated as high severity.
+99 -47
View File
@@ -1,9 +1,9 @@
# Getting started: one useful change in your repository
Start with a repository and one bounded change you can exercise. The shipped
`first-project` starter provides two native Codex seats: an outcome owner and
an independent checker. It uses your installed Codex executable and login;
terminal-provider support does not change the harness or account being used.
first-project recipes provide the same two seats: an outcome owner and an
independent checker. Choose Claude Code, Codex, or one of each using the accounts
you already have. Terminal-provider support does not change the harness or login.
You need Node.js 22 or 24 and tmux, on macOS or Linux. On a Mac with Apple
silicon, use Node.js 22 (see the [compatibility
@@ -11,14 +11,17 @@ history](../releases/v0.5.15.md#known-compatibility-limitation)). Native
Windows is not supported yet, and WSL2 has not been tested. Node 20 is no longer
supported; Node 26 and other versions are untested.
**Choose permissions before starting the team.** The unchanged starter launches
Codex with `-s workspace-write`; it leaves approval policy to your native Codex
configuration. Network access is normally off in that sandbox, including access
to the local OpenRig daemon. Its `profile: default` selects OpenRig resources,
not a Codex permission profile. Ordinary permission prompts are therefore expected.
You can keep those prompts, remember selected commands, or choose broader access.
[Ask your agent to configure that choice](#have-your-agent-configure-permissions);
OpenRig does not choose permissive operation for everyone.
**Choose permissions before starting the team.** Ordinary OpenRig launches use
Codex's `-s workspace-write`, with approval policy from your native configuration,
or Claude Code's `acceptEdits`, which still leaves commands subject to native
rules and prompts. Codex's sandbox normally blocks network access, including the
local OpenRig daemon. A command allowance does not change general sandbox/network
settings. The starter's `profile: default` selects OpenRig resources, not a native
permission profile.
Agent-guided setup [asks once](#have-your-agent-configure-permissions): “Allow
your agents to run OpenRig commands without repeated permission prompts?”
**Yes — recommended** / **No — keep prompts**. An existing explicit choice is
reused; No or no answer leaves settings unchanged. Broader access is separate.
> Everything below reports **what is currently true**, never a guarantee that
> downstream work will succeed. "Daemon up" does not mean every agent is healthy;
@@ -57,33 +60,69 @@ to return (tmux defaults to Ctrl-b, then d). Decide native trust/auth prompts
there. If a fresh start paused before context delivery, **c** finishes that
delivery to the same occupant. **r** reads actual state again; **d** expands details.
Install OpenRig and inspect `rig setup --dry-run` before applying machine
changes. Check `tmux -V`, `codex --version` and `codex login status` in your
launch shell; install missing prerequisites and complete `codex login` when
needed. This starter needs tmux and Codex, without a Claude login or Herdr
plugin. The kernel selects its available native runtime variant separately.
### Choose your providers
`rig setup` currently installs/checks both harnesses and cmux. Use it when you
want that full environment. Its overall failure can include an optional
component for this starter: read the individual result and verify the three
prerequisites above rather than treating a missing Claude login as broken
Codex. A missing Codex login remains a real launch blocker.
Ask: **“Which working account do you want this team to use: Claude Code, Codex,
or both?”** Reuse an explicit choice already made. Recommend the account the
user already has working; a second subscription is not a prerequisite.
| Choice | Starter name | Owner / checker runtime and model |
| --- | --- | --- |
| Two Codex agents (existing route) | `first-project` | Both `codex`, pinned `gpt-6-astra` |
| Two Claude agents | `first-project-claude` | Both `claude-code`, configured native default model |
| One of each | `first-project-mixed` | Claude owner (native default); Codex checker (`gpt-6-astra`) |
Claude uses the same unpinned model convention as the existing Claude kernel and
implementation-pair recipes: OpenRig does not pass a model override. Read the
selected harness's configured model and show it alongside the recipe and launch
command before proceeding. Confirm account access to any pin; if unavailable,
ask for a supported model choice rather than silently substituting a model or
provider. After launch, confirm the actual native model before assigning work.
Install OpenRig and check `tmux -V`. Check **only the selected providers**:
- Claude Code: `claude --version` and `claude auth status`. If sign-in is missing,
ask once: “Please run `claude auth login` in your launch environment.”
- Codex: `codex --version` and `codex login status`. If sign-in is missing,
ask once: “Please run `codex login` in your launch environment.”
Install a missing selected CLI using its provider's installation instructions.
The other provider's CLI/login and Herdr/cmux are optional. Do not copy credentials
or start repeated sign-in attempts. Recheck the selected login after the user
completes it. `rig setup --dry-run` previews the broader setup; applying
`rig setup` installs/checks **both** harnesses and cmux, so it is optional for
this selected-provider path, not a requirement to fix an unused provider.
### Kernel startup stays automatic
On a fresh instance, ordinary daemon startup chooses the kernel variant from
successful native auth probes: Claude alone, Codex alone, or both. Thus an absent
unused provider is fine. The project recipe does **not** constrain that probe:
if both accounts are authenticated, automatic kernel startup uses both, even
when the two project agents use just one provider. No manual kernel setup is
needed for this journey. The starter choice is not an instance-wide provider
restriction; a request to use only one provider for everything is separate.
Preserve any existing managed kernel and working rigs.
### Launch the two project seats
Set `starter` to the chosen name from the table; the existing Codex route remains:
```sh
cd <your-repository>
rig specs preview first-project
rig up first-project --cwd . --plan
rig up first-project --cwd .
starter=first-project # or first-project-claude or first-project-mixed
rig specs preview "$starter" --kind rig
rig up "$starter" --cwd . --plan
rig up "$starter" --cwd .
rig status
rig ps --nodes --rig first-project
rig ps --nodes --rig "$starter"
```
Preview the starter's seats and resources; plan checks resolution and
preflight for the selected working directory. Launch starts the daemon if needed; the kernel boots in the
background. Read readiness for the project seats, not only daemon health. If a
seat has an authentication, trust or permission prompt, resolve the named
prompt before assigning it work. A model pin is configuration; the native
harness must report the intended model before consequential work.
Preview the selected seats, models and resources. Plan checks resolution and
preflight for the working directory. Launch starts the daemon if needed, with
the kernel behavior above. Read readiness for the project seats, not only daemon
health. Resolve a named authentication, trust or permission prompt before giving
that seat work. No new team is needed when returning to an existing project.
When a seat pauses, open its existing terminal with **o** in the startup view.
Read the proposed command, working directory and target instance. For an intended
@@ -100,7 +139,7 @@ already have taken effect. A delivered message or disappearing prompt alone is
not progress. If startup is still waiting for context delivery, use **c** for the
same occupant, then **r** to refresh. Do not start another seat to clear a prompt.
`first-project` is a deliberately small starting point, not a universal team.
These starters are deliberately small starting points, not universal teams.
For a different installed runtime or team shape, inspect `rig specs ls --kind
rig` and `rig specs preview <name>` before selecting it. A seven-seat showcase
is optional and consumes more concurrent capacity.
@@ -110,7 +149,7 @@ is optional and consumes more concurrent capacity.
For example, in a project that imports CSV files:
```sh
rig send dev-owner@first-project 'Improve the CSV import error when a required column is missing: name the column and leave the existing data unchanged. Add a regression check, ask dev-check for an independent check of the exact candidate, and record the result and how I can try it. Keep the change local; do not publish.'
rig send "dev-owner@$starter" 'Improve the CSV import error when a required column is missing: name the column and leave the existing data unchanged. Add a regression check, ask dev-check for an independent check of the exact candidate, and record the result and how I can try it. Keep the change local; do not publish.'
```
Replace the example with a real problem in your repository. Include what the
@@ -120,11 +159,12 @@ independent check. You should not have to relay the review between terminals.
`rig send` is the initial conversation; the queue and repository artifacts
retain the work. An unbound shell does not need to impersonate a queue owner.
From an actual `first-project` seat, follow the work with
Use the chosen rig name in its seat addresses (for example,
`dev-owner@first-project-claude`). From an actual project seat, follow the work with
`rig queue list --limit 1000`: its default scope is the caller's current rig.
`queue list` has no `--rig` option. From an observer shell or another rig, use
`rig queue list --destination dev-owner@first-project --limit 1000` and the same
command for `dev-check@first-project`, after verifying those live addresses.
`rig queue list --destination "dev-owner@$starter" --limit 1000` and the same
command for `"dev-check@$starter"`, after verifying those live addresses.
These show each destination's obligations, not a whole-rig view. An unbound shell
must not pretend to be a seat to change scope; use `--all-rigs` only when that
broader view is intended. Then read `rig queue show <id> --full` and
@@ -150,7 +190,7 @@ start or replace a terminal, so a missing binding is reported with recovery
guidance rather than creating a second kernel.
Herdr users follow the same launch and task path. To place the managed team in
Herdr, use `rig terminal open first-project --provider herdr`; for the shared
Herdr, use `rig terminal open "$starter" --provider herdr`; for the shared
dashboard, use `rig terminal open kernel --provider herdr`. The equivalent
cmux provider is also available. Read the opened/absent/degraded result: a
partial terminal view is not a healthy team. Repeated terminal-open calls can
@@ -244,18 +284,30 @@ shows a small reviewed example and how to retain custody through a genuine wait.
## Have your agent configure permissions
You choose the scope; the agent inspects the target harness and applies it.
For example:
Before team launch, your agent asks once, unless you already made an explicit
choice for these harnesses and this scope:
> Configure persistent permission for OpenRig commands in this project. Explain
> what the whole `rig` family allows and offer narrower verbs if appropriate.
> Preserve existing deny/ask rules and unrelated settings, back up touched files,
> apply my choice, then verify repeated ordinary reads without extra approvals.
> Allow your agents to run OpenRig commands without repeated permission prompts?
> **Yes — recommended** / **No — keep prompts**
Allowing all `rig` commands includes lifecycle, topology and configuration
operations, not only reads. Keeping prompts or selecting broader permissive
operation are also valid choices. An existing explicit choice authorizes the
routine setup; the agent need not ask you to approve each file edit again.
This covers the entire `rig` family, including starting/stopping agents,
configuration and launching processes. It is not global YOLO or permission to
invent work. The scope is your personal settings for this project unless you
explicitly choose user-wide sessions, which can affect your other projects.
On an actual **Yes**, the agent backs up the relevant files, adds the existing
native rules without duplicates, and preserves stricter rules and unrelated
settings. It checks bare and actual absolute-path invocations, rule loading and
repeated harmless reads in the target conversation. No or no answer leaves
settings alone and continues with existing prompts. Unsupported scope or a
managed restriction is reported; it is not permission to grant broader access.
The agent remembers an explicit choice, scope and exact additions in existing
onboarding context, so setup does not ask again. You do not need to approve each
routine edit separately. To undo, say **“Undo the OpenRig command allowances
added by this setup; keep my other rules.”** The agent removes only its recorded
additions, preserves earlier rules and later edits, and verifies reloading.
Other pre-existing allowances may still permit commands after this undo.
Use the maintained **Applying a permission policy** procedure:
+24 -8
View File
@@ -1,7 +1,7 @@
# RigBundle Reference
Version: 2 (pod-aware)
Last validated against code: 2026-04-11
Last validated against code: 2026-09-29 (install paths and `--target` only; other sections last checked 2026-04-11)
Source of truth: `packages/daemon/src/domain/bundle-types.ts`, `packages/daemon/src/domain/bundle-archive.ts`, `packages/daemon/src/domain/pod-bundle-assembler.ts`
A `.rigbundle` is a self-contained distributable archive that packages a rig spec, all referenced agent specs, their resources (skills, guidance, startup files), culture file, documentation, and an integrity manifest into a single file. The recipient can install and launch the rig without needing the original source tree.
@@ -180,6 +180,13 @@ If any check fails, extraction is aborted and an error is thrown.
## CLI Surface
### Paths
`rig bundle create`, `inspect` and `install` resolve every path you give them (`<spec-path>`, `-o`, `--rig-root`,
`<bundle-path>`, `--target`) to an absolute path against **your** current directory before sending the request. The
daemon then reads and writes those paths on **its own host**: nothing is uploaded, so the files must already exist
where the daemon runs.
### Create a bundle
```bash
@@ -211,7 +218,7 @@ The create command:
rig bundle inspect <bundle-path> [--json]
```
Shows the manifest, digest validity, and integrity verification result. Inspect extracts the archive into a temporary directory for safe validation, then cleans that directory up. It does not install the bundle.
Shows the manifest, digest validity, and integrity verification result. Inspect extracts the archive into a temporary directory for safe validation, then cleans that directory up. It does not install or launch anything.
### Install a bundle
@@ -222,12 +229,20 @@ rig bundle install <bundle-path> [--plan] [--yes] [--target <root>] [--json]
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `<bundle-path>` | yes | — | Path to the `.rigbundle` file. |
| `--plan` | no | `false` | Preview the bootstrap plan without installing or launching. |
| `--plan` | no | `false` | Preview without installing or launching. Not side-effect free: it runs the preflight, which executes the members' runtime `--version` probes, and it records a bootstrap run. It writes nothing to the target. |
| `--yes` | no | `false` | Auto-approve trusted actions during apply mode. |
| `--target <root>` | yes in apply mode | — | Target root directory for package installation. Required unless `--plan` is used. |
| `--target <root>` | yes in apply mode | — | Directory the bundle is installed into and launched from. Required unless `--plan` is used. |
| `--json` | no | `false` | Emit machine-readable JSON. |
Extracts the bundle, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` unless you are running with `--plan`.
Install **launches the rig**; it does not just unpack it. It extracts the bundle to a temporary directory, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` unless you are running with `--plan`.
For a pod-aware (schema version 2) bundle, apply copies the extracted contents (`bundle.yaml`, `rig.yaml`, `agents/`, culture and docs files) into the target and launches from there, then removes the temporary extraction. So:
- the target becomes the rig root: `agent_ref` paths resolve inside it, and a member with `cwd: "."` (or no `cwd`) starts in the target;
- an absolute member `cwd` stays as authored, and `rig up --cwd <dir>` still overrides every member's cwd;
- if the target already has a file with **different** content at any bundle path (for example its own `rig.yaml`), install refuses with `target_conflict` and writes nothing. Identical files are accepted, so reinstalling the same bundle into the same target works. Use an empty or dedicated directory as the target.
Legacy (schema version 1) bundles keep their old behavior: `--target` is only where packages are installed.
### Launch directly
@@ -237,8 +252,9 @@ rig up <bundle-path> [--target <root>] [--cwd <dir>]
`rig up` auto-detects `.rigbundle` files and routes them through the bundle bootstrap path.
- `--target <root>` controls where packaged files are installed
- if `--target` is omitted for a `.rigbundle`, the CLI defaults the install target to the current working directory
- `--target <root>` is the install target described above (for a schema-version-2 bundle, the directory the bundle is copied into and launched from)
- if `--target` is omitted for a `.rigbundle`, the CLI defaults the install target to the current working directory, so the bundle's files are written there
- `rig up` resolves a relative `--target` against your current directory before sending it, like `rig bundle install`; with `--host`, `--target` is sent as given and must be a path that exists on that host
- `--cwd <dir>` does **not** change the install target; it only overrides the launched members' working directory for that run
---
@@ -364,4 +380,4 @@ Equivalent explicit form:
rig up /path/to/my-team.rigbundle --target ~/projects/my-project
```
The bundle is extracted to a temporary directory, integrity is verified, packaged files are installed into the target root, and the rig is bootstrapped with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
The bundle is extracted to a temporary directory and its integrity is verified. A schema-version-2 bundle is then copied into the target root (refused if the target holds different files at the same paths) and the rig is launched from there, with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
+114
View File
@@ -0,0 +1,114 @@
# OpenRig 0.6.2 — Start with the team you already have accounts for
Choose your providers, give an owner one useful change in your repository, and
have a second agent check the result. OpenRig 0.6.2 adds first-project choices,
clearer permission guidance and repairs for bundle installation and agent delivery.
## A small team and one useful result
The [first-use guide](../reference/getting-started.md) offers the same owner and
checker workflow with three recipes:
| Team | Recipe |
| --- | --- |
| Two Codex agents | `first-project` |
| Two Claude Code agents | `first-project-claude` |
| Claude owner and Codex checker | `first-project-mixed` |
Use the accounts you already have; an unused provider's installation or login is
not a prerequisite. Codex seats retain `gpt-6-astra`; Claude seats use the native
configured default. Check model access before launching. Automatic kernel startup
continues to select independently from available authenticated providers.
Agent-guided setup asks once whether to allow OpenRig commands without repeated
permission prompts, with **Yes recommended**. An explicit Yes authorizes native
`rig` command rules at personal project scope unless you choose user-wide scope.
No or no answer leaves settings unchanged. The allowance includes lifecycle and
configuration commands, but does not grant general shell access or change sandbox
and network settings. The guide covers applying, checking and undoing the rules;
bootstrap does not silently apply them
([#147](https://github.com/mvschwarz/openrig/pull/147)).
## Bundle files stay where you install them
Schema-version-2 bundles now copy their verified contents into `--target` and
launch from there, keeping relative working directories and agent references
available after temporary extraction is removed. Different files at a bundle
path cause `target_conflict` before copying; identical files can be reused.
Without `--target`, `rig up <file>.rigbundle` installs into your current directory, so run it from the project folder you want.
`rig bundle create|inspect|install` and local `rig up --target` resolve relative
paths against your current directory. Files still live on the daemon's host;
this is not an upload. Install launches the rig. Legacy version-1 behavior and
the separate `--cwd` override are unchanged. See the
[bundle guide](../reference/rig-bundle.md#cli-surface)
([#146](https://github.com/mvschwarz/openrig/pull/146)).
## Delivery and restore diagnostics
Managed launches after a tmux/host restart preserve the named launch target
instead of confusing it with an old bare pane binding. The existing identity
checks still apply to each write. Thanks to
[@diaztunjano](https://github.com/diaztunjano) for reporting
[#141](https://github.com/mvschwarz/openrig/issues/141), addressed by
[#151](https://github.com/mvschwarz/openrig/pull/151). Actual reboot and power-loss
recovery remain subject to the verification limits below.
Message delivery now refuses a known bare-shell foreground when an agent runtime
should be running, so the message is not entered as shell commands. Terminal-only
seats still receive text; unreadable or unknown foregrounds retain the existing
advisory behavior ([#150](https://github.com/mvschwarz/openrig/pull/150), fixes
[#142](https://github.com/mvschwarz/openrig/issues/142)).
## Community fixes
- **Local sender identity:** recognize a sender qualified with this daemon's own
host ID as its local seat, avoiding false ownership refusals. Foreign host
qualifiers and destination handling stay unchanged. Thanks to
[@korallis](https://github.com/korallis) for
[#135](https://github.com/mvschwarz/openrig/pull/135), and
[@Yi-111-a](https://github.com/Yi-111-a) for the additional cases from
[#137](https://github.com/mvschwarz/openrig/pull/137), included through
[#144](https://github.com/mvschwarz/openrig/pull/144).
- **Terminal and status details:** parse tmux window fields with printable
separators ([#101](https://github.com/mvschwarz/openrig/pull/101)); distinguish
health that was not requested from a failed read
([#102](https://github.com/mvschwarz/openrig/pull/102)); use native realpath for
Codex hook trust ([#103](https://github.com/mvschwarz/openrig/pull/103)); pass
known Claude/Codex runtime hints to Herdr
([#106](https://github.com/mvschwarz/openrig/pull/106)); report missing fresh
startup context or adapters as needing attention
([#107](https://github.com/mvschwarz/openrig/pull/107)); accept numeric Claude
rate-limit reset timestamps ([#108](https://github.com/mvschwarz/openrig/pull/108)).
Thanks to [@dajiaohuang](https://github.com/dajiaohuang).
- **Pi input and portable checks:** retain line editing with `TERM=dumb`, and
correct macOS path and timezone assumptions in tests. Thanks to
[@shravansumanthanan](https://github.com/shravansumanthanan) for
[#123](https://github.com/mvschwarz/openrig/pull/123).
- **Configured restore paths:** restore-check uses the configured shared-docs
root. This does not repair every issue in
[#130](https://github.com/mvschwarz/openrig/issues/130). Thanks to
[@jbaehova](https://github.com/jbaehova) for
[#139](https://github.com/mvschwarz/openrig/pull/139).
- **Anthropic gateways:** permit `ANTHROPIC_BASE_URL` in configured provider-auth
environment forwarding. Thanks to [@FenjuFu](https://github.com/FenjuFu) for
[#143](https://github.com/mvschwarz/openrig/pull/143).
- **Terminal authentication maintenance:** reuse the shared token-comparison
helper. Thanks to [@Sunil56224972](https://github.com/Sunil56224972) for
[#145](https://github.com/mvschwarz/openrig/pull/145).
## Upgrade and verification limits
Node.js 22 and 24 remain supported; Node 20 is unsupported. This maintenance
change adds no dependency, engine or database-migration change. Follow the
[existing upgrade procedure](../../skills/_canonical/core/openrig-upgrade/SKILL.md).
Installed first-use and reboot/power-loss verification remain pending. Source
and regression checks do not establish native permission enforcement or recovery
of an authenticated team after a reboot.
Slack app manifest/setup assistance and Rig Stream classification remain
experimental, with no new real-app or provider-run validation here. The existing
Slack connector is not experimental. See the
[retained limitations](v0.6.0.md#known-limitations).
+62
View File
@@ -0,0 +1,62 @@
# OpenRig 0.6.3 — Managed Claude messaging repair
This patch repairs a 0.6.2 regression that refused messages to running Claude
Code seats launched with an explicit permission mode. It also fixes snapshot
helper cleanup and interference from archived rigs with duplicate names.
## Managed Claude seats can receive messages
OpenRig's managed launch can leave a shell wrapper in front of Claude Code.
The delivery check previously treated that wrapper as a bare shell and refused
`rig send`, even though Claude was running beneath it.
The check now recognizes a stable, foreground Claude process descended from the
bound pane, with the expected native session identity. A bare shell alone still
does not qualify. Fork arguments that identify the parent rather than the
current occupant remain insufficient evidence.
Thanks to [@dmelo](https://github.com/dmelo) for the detailed
[#197 report](https://github.com/mvschwarz/openrig/issues/197), addressed by
[#220](https://github.com/mvschwarz/openrig/pull/220).
## Snapshot helpers and archived rigs
- Clean up a newly created snapshot helper when its input target cannot be
established and its ownership can still be proven. This prevents unusable
`rigged-refresh-*` helpers accumulating on repeated attempts. If ownership
cannot be established, preserve the session and report the limitation.
Thanks to [@z4cc](https://github.com/z4cc) for
[#188](https://github.com/mvschwarz/openrig/issues/188), addressed by
[#189](https://github.com/mvschwarz/openrig/pull/189).
- Exclude archived rigs from seat-reference resolution, prefer unarchived
delivery targets, and preserve another live seat's session and queue work
when removing a stale duplicate node. Removal reports whose session it kept.
Thanks to [@Farkinell](https://github.com/Farkinell) for
[#174](https://github.com/mvschwarz/openrig/issues/174), addressed by
[#181](https://github.com/mvschwarz/openrig/pull/181).
## Proof media and repository checks
`rig proof add` rejects binary files supplied as artifact text, requires a
Markdown artifact name, and requires `--replace` before overwriting an existing
artifact. Attach screenshots and other binary evidence with `--media`. This
prevents a proof write from replacing media with a Markdown body
([#177](https://github.com/mvschwarz/openrig/pull/177)).
Repository script tests now run serially because packaging tests rebuild daemon
output that other tests import. This removes the reproduced shared-output race
without weakening those tests
([#221](https://github.com/mvschwarz/openrig/pull/221)).
## Upgrade and verification scope
Node.js 22 and 24 remain supported. This patch changes no dependencies, engine
requirements or database migrations. Use the
[existing upgrade procedure](../../skills/_canonical/core/openrig-upgrade/SKILL.md).
The fixes have source reviews and focused regression coverage. The Claude
process checks use synthetic process metadata; an installed Claude/Fedora send
has not been demonstrated by that evidence. These checks do not establish
native receipt, automatic recovery, or protection against a process exiting
between verification and input. Final package and publication verification
remain separate from this source preparation.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.6.1",
"version": "0.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.6.1",
"version": "0.6.3",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7013,7 +7013,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.6.1",
"version": "0.6.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7047,7 +7047,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.6.1",
"version": "0.6.3",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7088,7 +7088,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.6.1",
"version": "0.6.3",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.6.1",
"version": "0.6.3",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -13,7 +13,7 @@
"scripts": {
"build": "npm run build --workspaces",
"test": "npm run test:repo && npm run test:workspaces",
"test:repo": "npm run build -w packages/daemon && node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check && node scripts/generate-context-packs.mjs --check",
"test:repo": "npm run build -w packages/daemon && node --test --test-concurrency=1 scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check && node scripts/generate-context-packs.mjs --check",
"test:workspaces": "npm run test -w packages/daemon -w packages/cli -w packages/tui",
"test:ui": "npm run test -w packages/ui",
"typecheck:prep": "npm run build -w packages/daemon",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.6.1",
"version": "0.6.3",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"keywords": [
+14 -6
View File
@@ -77,7 +77,7 @@ export class DaemonTimeoutError extends DaemonConnectionError {
}
/**
* Bad-response: the daemon replied, but the body could not be read as JSON
* Bad-response: the daemon replied, but the body could not be read or parsed as JSON
* (truncated / unparseable / non-JSON — a real symptom under daemon saturation).
* DISTINCT from a stopped or unreachable daemon: the request WAS delivered and
* the outcome is unknown, so this must never render as daemon-not-running.
@@ -216,7 +216,7 @@ export class DaemonClient {
}, options);
}
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions, consumeResponse?: (response: Response) => Promise<void>): Promise<Response> {
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
if (options?.headers) {
init = { ...init, headers: { ...(init.headers as Record<string, string> ?? {}), ...options.headers } };
@@ -226,6 +226,7 @@ export class DaemonClient {
// Known remote or unproved direct endpoints carry origin; proven local requests remain bare.
this.identity ??= this.identityHeaders();
init = { ...init, headers: { ...(init.headers as Record<string, string> ?? {}), ...await this.identity } };
let responseStatus: number | undefined;
try {
const response = await fetchWithTimeout(
this.fetchImpl,
@@ -233,6 +234,10 @@ export class DaemonClient {
init,
{
timeoutMs,
consumeResponse: consumeResponse ? async (response) => {
responseStatus = response.status;
await consumeResponse(response);
} : undefined,
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${timeoutMs}ms`,
},
);
@@ -249,18 +254,21 @@ export class DaemonClient {
if (err instanceof FetchTimeoutError) {
throw new DaemonTimeoutError(`The OpenRig daemon at ${this.baseUrl} did not respond in time: ${msg}`);
}
// Headers prove that the daemon received the request, but not that a write
// finished. A dropped body must preserve unknown-outcome guidance.
if (responseStatus !== undefined) throw new DaemonResponseError(responseStatus, "");
throw new DaemonConnectionError(`Cannot connect to the OpenRig daemon at ${this.baseUrl}: ${msg}`);
}
}
private async requestJson<T>(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
const res = await this.fetch(path, init, options);
let text = "";
const res = await this.fetch(path, init, options, async (response) => { text = await response.text(); });
// Read the raw body once, THEN parse — so a truncated / unparseable response
// (a real symptom under daemon saturation) surfaces as a typed
// DaemonResponseError carrying the status + a bounded snippet, instead of a
// raw SyntaxError bubbling to a cryptic (json) or silent (human) CLI exit.
// A well-formed non-2xx body still parses and returns {status,data}; 204 has none.
const text = await res.text();
if (res.status === 204) return { status: res.status, data: undefined as T };
try {
return { status: res.status, data: JSON.parse(text) as T };
@@ -270,8 +278,8 @@ export class DaemonClient {
}
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
const res = await this.fetch(path, init, options);
const data = await res.text();
let data = "";
const res = await this.fetch(path, init, options, async (response) => { data = await response.text(); });
return { status: res.status, data };
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
}
const hasNode = !!opts.node;
const hasPodFields = !!opts.pod || !!opts.member || !!opts.runtime;
const hasPodFields = !!opts.pod || !!opts.member;
if (hasNode && hasPodFields) {
console.error("Specify either --node or --pod + --member + --runtime, not both.");
process.exitCode = 1;
+6 -3
View File
@@ -85,8 +85,10 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
// bundle sizes; if a bundle author hits this, the daemon-side
// operation is async-completable in a future /bundles/jobs/<id>
// surface (not in slice-05 scope).
// Paths are resolved here, against the operator's cwd: the daemon would otherwise resolve
// them against ITS cwd. Nothing is uploaded — the files must exist on the daemon's host.
const res = await client.post<Record<string, unknown>>("/api/bundles/create", {
specPath: spec, bundleName: opts.name, bundleVersion: opts.bundleVersion, outputPath: opts.output,
specPath: nodePath.resolve(spec), bundleName: opts.name, bundleVersion: opts.bundleVersion, outputPath: nodePath.resolve(opts.output),
includePackages: opts.includePackages,
rigRoot: opts.rigRoot ? nodePath.resolve(opts.rigRoot) : undefined,
provenance: buildClientProvenance(opts.notes),
@@ -117,7 +119,7 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
const res = await client.post<Record<string, unknown>>("/api/bundles/inspect", { bundlePath });
const res = await client.post<Record<string, unknown>>("/api/bundles/inspect", { bundlePath: nodePath.resolve(bundlePath) });
// Check for structured failures (200 with error or failed integrity)
const hasError = typeof res.data["error"] === "string";
@@ -166,7 +168,8 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
// daemon completed the mutating install — operator-unsafe retry
// path (rig_name_collision on second attempt). Bumped to 120s.
const res = await client.post<Record<string, unknown>>("/api/bundles/install", {
bundlePath, plan: opts.plan ?? false, autoApprove: opts.yes ?? false, targetRoot: opts.target,
bundlePath: nodePath.resolve(bundlePath), plan: opts.plan ?? false, autoApprove: opts.yes ?? false,
targetRoot: opts.target ? nodePath.resolve(opts.target) : undefined,
// Item 2 / slice-05 Checkpoint 3.3: send CLI version + skip flag for the
// daemon-side install-time compatibility check. CLI version read at call
// time (no module-level constant) via the existing getCliVersion helper.
+17 -7
View File
@@ -32,6 +32,22 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
};
}
type ExecFile = (file: string, args: string[], options: { encoding: "utf-8" }) => string;
// Windows has no ps(1) and no zombie state, so the signal probe alone decides liveness there.
export function readProcessState(
pid: number,
platform: NodeJS.Platform = process.platform,
run: ExecFile = execFileSync,
): string | null {
if (platform === "win32") return "R";
try {
return run("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf-8" });
} catch {
return null;
}
}
export function realDeps(): LifecycleDeps {
const isProcessAlive = createIsProcessAlive({
signalCheck: (pid) => {
@@ -42,13 +58,7 @@ export function realDeps(): LifecycleDeps {
return false;
}
},
readProcessState: (pid) => {
try {
return execFileSync("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf-8" });
} catch {
return null;
}
},
readProcessState: (pid) => readProcessState(pid),
});
return {
+6
View File
@@ -58,6 +58,12 @@ Examples:
}
const receipt = res.data["receipt"] as Record<string, unknown> | null;
const probeStatus = res.data["probeStatus"];
if (probeStatus === "stale" || probeStatus === "no_orchestrator") {
const probeError = res.data["probeError"];
console.error(`Service status is ${probeStatus === "stale" ? "stale" : "unverified"} (probe: ${probeStatus}); ${receipt ? "showing the saved receipt" : "no saved receipt is available"}.${typeof probeError === "string" && probeError ? ` ${probeError}` : ""}`);
}
if (!receipt) {
console.log("Services configured but no receipt available yet.");
return;
+43 -4
View File
@@ -35,6 +35,11 @@ export const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEA
/** Video extensions for the C8 UX advisory (screencast evidence). */
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv"]);
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".pdf",
".mp3", ".wav", ".ogg", ".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv",
".zip", ".gz", ".7z", ".exe",
]);
export interface C1Header {
slice: string;
@@ -183,7 +188,8 @@ checkboxes do not accept an item under the selected proof policy.
.option("--slice-id <dot-id>", "C1 slice dot-ID (defaults to the slice frontmatter id)")
.option("--file <path>", "Artifact body from a file (mutually exclusive with --body)")
.option("--body <text>", "Artifact body inline (mutually exclusive with --file)")
.option("--name <filename>", "Artifact filename in proof/ (defaults to the --file basename, else <artifact-type>-<verdict>-<UTC>.md)")
.option("--name <filename>", "Markdown artifact filename in proof/ (defaults to the --file stem plus .md, else <artifact-type>-<verdict>-<UTC>.md)")
.option("--replace", "Explicitly replace an existing Markdown artifact")
.option("--evidences <refs>", "D2 attestation: comma-separated proof-contract item refs this artifact covers (item text or 1-based index)")
.option("--self-check <text>", "D2 attestation: the agent's assertion that it LOOKED at the evidence and confirmed it shows the claim")
.option("--media <refs>", "Corrective §3.4: comma-separated media refs (relative to the slice proof/ dir) this drop stands behind — appended to the artifact body as markdown refs so the composer curates them into delivered.items[].proof")
@@ -198,6 +204,7 @@ checkboxes do not accept an item under the selected proof policy.
file?: string;
body?: string;
name?: string;
replace?: boolean;
evidences?: string;
selfCheck?: string;
media?: string;
@@ -228,7 +235,23 @@ checkboxes do not accept an item under the selected proof policy.
action: "Point --file at the evidence file, or use --body.",
});
}
body = fs.readFileSync(opts.file, "utf8");
const input = fs.readFileSync(opts.file);
if (BINARY_EXTENSIONS.has(path.extname(opts.file).toLowerCase()) || input.includes(0)) {
throw new ScopeCliError({
fact: `--file ${opts.file} is binary, not an artifact body.`,
consequence: "The artifact was NOT dropped and the source file was not changed.",
action: "Attach screenshots and other binary evidence with --media instead.",
});
}
try {
body = new TextDecoder("utf-8", { fatal: true }).decode(input);
} catch {
throw new ScopeCliError({
fact: `--file ${opts.file} is not valid UTF-8 text.`,
consequence: "The artifact was NOT dropped and the source file was not changed.",
action: "Use a UTF-8 text file for --file, or attach binary evidence with --media.",
});
}
} else if (opts.body) {
body = opts.body;
}
@@ -402,7 +425,7 @@ checkboxes do not accept an item under the selected proof policy.
// Write the artifact: YAML frontmatter + body into proof/.
const proofDir = path.join(slice.absPath, "proof");
const defaultName = `${opts.artifactType}-${opts.verdict}-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
const fileName = opts.name ?? (opts.file ? path.basename(opts.file) : defaultName);
const fileName = opts.name ?? (opts.file ? `${path.parse(opts.file).name}.md` : defaultName);
// rev1-r2 BLOCKING fix (a7dedd93 review): --name is a FILENAME, never
// a path. Reject separators / dot-dot / absolute shapes BEFORE any
// filesystem effect, so the drop can only land inside proof/ (the
@@ -415,6 +438,13 @@ checkboxes do not accept an item under the selected proof policy.
action: "Pass a bare filename like qa-clear.md; the drop path owns the directory.",
});
}
if (!fileName.toLowerCase().endsWith(".md")) {
throw new ScopeCliError({
fact: `--name '${fileName}' is not a Markdown artifact filename.`,
consequence: "The artifact was NOT dropped; proof media cannot be replaced by a Markdown body.",
action: "Use a .md name for the artifact and attach binary files with --media.",
});
}
const target = path.resolve(proofDir, fileName);
// Defense-in-depth: even a name that slips the shape check must
// resolve INSIDE proof/ (same containment discipline as
@@ -428,7 +458,16 @@ checkboxes do not accept an item under the selected proof policy.
}
fs.mkdirSync(proofDir, { recursive: true });
const frontmatter = YAML.stringify(header).trimEnd();
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, "utf8");
try {
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, { encoding: "utf8", flag: opts.replace ? "w" : "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
throw new ScopeCliError({
fact: `Proof artifact '${fileName}' already exists.`,
consequence: "The existing artifact was not changed.",
action: "Choose another --name, or pass --replace to deliberately update this Markdown artifact.",
});
}
// Echo the parsed header — the seat sees what the composer will see.
const echo = {
+3
View File
@@ -51,6 +51,9 @@ export function removeCommand(depsOverride?: StatusDeps): Command {
}
console.log(`Removed node ${res.data["logicalId"]} from rig ${res.data["rigId"]} (${res.data["sessionsKilled"]} session killed)`);
if (typeof res.data["sessionKeptFor"] === "string") {
console.log(`Session kept: owned by ${res.data["sessionKeptFor"]}`);
}
});
return cmd;
+21 -5
View File
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { DaemonClient, terminalAuthHeaders } from "../client.js";
import { DaemonClient, DaemonTimeoutError, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -477,10 +477,26 @@ Examples:
const daemon = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(daemon)) return;
const client = deps.clientFactory(getDaemonUrl(daemon));
const res = await client.post<Record<string, unknown>>(
`/api/seat/${path}/${encodeURIComponent(seat)}`,
body,
);
let res;
try {
const route = `/api/seat/${path}/${encodeURIComponent(seat)}`;
res = path === "launch"
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 120_000 })
: await client.post<Record<string, unknown>>(route, body);
} catch (err) {
if (path !== "launch" || !(err instanceof DaemonTimeoutError)) throw err;
const error = {
ok: false as const,
code: "launch_outcome_unknown",
status: "unknown",
message: "The CLI timed out waiting for the daemon; the launch may still be in progress.",
guidance: `Check the outcome before retrying: rig seat status ${seat}`,
};
if (opts.json) console.log(JSON.stringify(error, null, 2));
else printSeatError(error, error.message);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
+87 -16
View File
@@ -2,6 +2,7 @@ import { Command } from "commander";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, accessSync, constants, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseDocument } from "yaml";
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
@@ -14,6 +15,8 @@ import {
upsertCmuxSocketControlMode,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
import { parse as parseToml } from "smol-toml";
import { resolveCodexHome } from "../lib/codex-auth.js";
export interface SetupStep {
id: string;
@@ -56,6 +59,43 @@ export interface SetupDeps {
exists: (path: string) => boolean;
mkdirp?: (path: string) => void;
platform?: NodeJS.Platform;
/** Environment for CODEX_HOME and provider credential checks (default process.env). */
env?: NodeJS.ProcessEnv;
}
/** Issue #194 — how the Codex provider selected in `$CODEX_HOME/config.toml`
* authenticates. Only an explicit provider entry with
* `requires_openai_auth = false` and an `env_key` uses its credential
* variable; every unresolved case keeps the OpenAI login check. Other Codex
* config layers are not resolved here. The daemon's kernel probe
* (`selectCodexProviderAuth` in kernel-boot.ts) carries the same rule. */
export type CodexProviderAuth =
| { kind: "openai-login"; unresolved?: string }
| { kind: "env-key"; providerId: string; envKey: string };
export function selectCodexProviderAuth(configToml: string | null): CodexProviderAuth {
if (configToml === null) return { kind: "openai-login" };
let config: Record<string, unknown>;
try {
config = parseToml(configToml) as Record<string, unknown>;
} catch {
return { kind: "openai-login", unresolved: "config.toml could not be parsed" };
}
if (Object.hasOwn(config, "profile")) {
return { kind: "openai-login", unresolved: "config.toml selects a legacy profile, which is not resolved here" };
}
const providerId = config["model_provider"];
const providers = config["model_providers"];
if (typeof providerId !== "string" || !providers || typeof providers !== "object" || !Object.hasOwn(providers, providerId)) {
return { kind: "openai-login" };
}
const entry = (providers as Record<string, unknown>)[providerId];
if (!entry || typeof entry !== "object") return { kind: "openai-login" };
const { requires_openai_auth: requiresOpenAiAuth, env_key: envKey } = entry as Record<string, unknown>;
if (requiresOpenAiAuth !== false || typeof envKey !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envKey)) {
return { kind: "openai-login" };
}
return { kind: "env-key", providerId, envKey };
}
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
@@ -562,17 +602,38 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
}
if (codexInstalled) {
try {
deps.exec("codex login status");
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
} catch (err) {
steps.push({
id: "codex_auth",
status: "fail",
message: `Codex is installed but not ready to launch: ${(err as Error).message}`,
reason: "Codex seats cannot launch until the Codex CLI is logged in and usable.",
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
});
const env = deps.env ?? process.env;
const codexAuth = selectCodexProviderAuth(deps.readFile(path.join(resolveCodexHome(env).codexHome, "config.toml")));
if (codexAuth.kind === "env-key") {
if (env[codexAuth.envKey]?.trim()) {
steps.push({
id: "codex_auth",
status: "pass",
message: `Codex provider "${codexAuth.providerId}" does not use an OpenAI login, and its credential variable ${codexAuth.envKey} is set. This confirms a local credential is available, not that the provider accepts it or that managed seats receive it.`,
});
} else {
steps.push({
id: "codex_auth",
status: "fail",
message: `Codex provider "${codexAuth.providerId}" needs ${codexAuth.envKey}, which is not set in this environment.`,
reason: "Codex seats using this provider cannot authenticate without that variable.",
fixHint: `Export ${codexAuth.envKey} in the environment that runs rig setup and the OpenRig daemon, then rerun \`rig setup\`.`,
});
}
} else {
try {
deps.exec("codex login status");
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
} catch (err) {
const unresolved = codexAuth.unresolved ? ` (${codexAuth.unresolved}, so the OpenAI login was checked)` : "";
steps.push({
id: "codex_auth",
status: "fail",
message: `Codex is installed but not ready to launch${unresolved}: ${(err as Error).message}`,
reason: "Codex seats cannot launch until the Codex CLI is logged in and usable.",
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
});
}
}
} else {
steps.push({
@@ -677,7 +738,7 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
const platform = setupDeps.platform ?? process.platform;
const baseDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
const baseDir = path.dirname(path.dirname(fileURLToPath(new URL(import.meta.url))));
return {
exists: setupDeps.exists,
baseDir,
@@ -710,11 +771,12 @@ export function goldenPathNextSteps(): string[] {
return [
"Next steps (the guided path; full reference: docs/reference/getting-started.md):",
" 1. cd <your-repository> Choose the code the team will work on",
" 2. rig up first-project --cwd . Launch an owner + checker (Codex); auto-starts daemon and kernel",
" 3. rig status Check daemon/kernel readiness; rig ps --nodes --rig first-project checks the team",
" 4. rig send dev-owner@first-project '<one useful change, boundaries, and how to check it>'",
" 2. Choose first-project (two Codex), first-project-claude (two Claude), or first-project-mixed; check only selected logins",
" Preview rig up <starter> --cwd . --plan, then rig up <starter> --cwd .; daemon and kernel start automatically",
" 3. rig status Check daemon/kernel readiness; rig ps --nodes --rig <starter> checks the team",
" 4. rig send dev-owner@<starter> '<one useful change, boundaries, and how to check it>'",
" 5. rig tui --shared Join the kernel dashboard; plain rig tui opens your own view",
" Next: rig queue list --rig first-project; rig workspace doctor; rig scope ...; rig workflow specs",
" Next: rig queue list --destination dev-owner@<starter>; rig workspace doctor; rig scope ...; rig workflow specs",
];
}
@@ -729,6 +791,15 @@ export function goldenPathNextSteps(): string[] {
*/
export function permissionPolicyMenuLines(): string[] {
return [
"Before team launch, your agent asks once (reuse an existing explicit choice):",
" Allow your agents to run OpenRig commands without repeated permission prompts?",
" Yes — recommended / No — keep prompts. No answer leaves settings unchanged too.",
" Includes all rig verbs, lifecycle/config changes and launching processes; not global YOLO or authority to invent work.",
" Personal project scope unless you explicitly choose user-wide sessions. On Yes, the agent adds native rules, preserving stricter rules.",
" Procedure: rig context get skills/applying-a-permission-policy/SKILL.md",
" Undo: ask your agent to remove only the OpenRig command allowances added by this setup.",
" This printed guidance collects no answer and writes no native rules; --policy below is a separate spec choice.",
"",
"Permission policy (optional — recording is a thought, not a gate; `rig up` always works without one):",
" Policy Mode:",
" Locked The most restrictive built-in policy.",
+6 -1
View File
@@ -19,6 +19,7 @@ type ShrinkResponse = {
nodeId: string;
status: "removed" | "failed";
sessionsKilled: number;
sessionKeptFor?: string;
error?: string;
}>;
error?: string;
@@ -74,7 +75,8 @@ export function shrinkCommand(depsOverride?: StatusDeps): Command {
for (const node of res.data.nodes ?? []) {
const icon = node.status === "removed" ? "OK" : "FAIL";
const error = node.error ? ` — ${node.error}` : "";
console.log(` [${icon}] ${node.logicalId}${error}`);
const kept = node.sessionKeptFor ? ` (session kept: owned by ${node.sessionKeptFor})` : "";
console.log(` [${icon}] ${node.logicalId}${kept}${error}`);
}
process.exitCode = 1;
return;
@@ -83,6 +85,9 @@ export function shrinkCommand(depsOverride?: StatusDeps): Command {
console.log(
`Removed pod ${res.data.namespace} from rig ${res.data.rigId} (${res.data.removedLogicalIds?.length ?? 0} node(s), ${res.data.sessionsKilled} session killed)`
);
for (const node of res.data.nodes ?? []) {
if (node.sessionKeptFor) console.log(` ${node.logicalId}: session kept, owned by ${node.sessionKeptFor}`);
}
});
return cmd;
+9 -4
View File
@@ -80,7 +80,7 @@ Examples:
.option("--plan", "Plan mode — preview without executing")
.option("--yes", "Auto-approve trusted actions")
.option("--cwd <path>", "Override launch working directory for all members for this run only")
.option("--target <root>", "Target root directory for package installation (.rigbundle only; does not change agent cwd)")
.option("--target <root>", "Install target for a .rigbundle (default: current directory). A v2 bundle is materialized there and relative member cwds resolve against it; --cwd still overrides launch cwd")
.option("--existing", "Treat <source> as an existing rig name; bypass library-spec name resolution")
.option("--fresh <seats...>", "Deliberately fresh-prime the named seats (logical ids) instead of resuming their original sessions (operation B; reported as fresh-primed)")
.option("--json", "JSON output for agents")
@@ -339,7 +339,7 @@ Examples:
}
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
const targetRoot = opts.target ?? (isRigBundle ? process.cwd() : undefined);
const targetRoot = opts.target ? nodePath.resolve(opts.target) : (isRigBundle ? process.cwd() : undefined);
// OPR.0.3.2.22 Bug 3 — extend the bare `rig up <builtin>` default-cwd
// treatment to path-form. Builtin starter specs declare member-level
@@ -451,6 +451,10 @@ Examples:
for (const node of nodes ?? []) {
console.error(` ${node.logicalId}${node.sessionName ? ` (${node.sessionName})` : ""}: ${node.reason}`);
}
// #141: the rig is kept on this path, so its warnings (e.g. the archived earlier generation) still apply.
for (const w of (res.data["warnings"] as string[]) ?? []) {
console.error(` warning: ${w}`);
}
} else if (code === "cycle_error") {
console.error("Cycle detected in rig topology. Check edge definitions for circular dependencies.");
} else if (code === "validation_failed") {
@@ -464,11 +468,12 @@ Examples:
} else if (code === "invalid_topology_manifest") {
const errors = (res.data["errors"] as string[]) ?? [];
console.error(`Topology manifest invalid:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: the manifest key set is CLOSED — rigs[]{source, host?} plus optional concurrency.`);
} else if (code === "rig_name_running") {
} else if (code === "rig_name_running" || code === "generation_unconfirmed") {
// S5b final-fix F1 (OPR.0.5.4.11): the guard's teaching refusal is
// self-describing (running rig identity, what was checked,
// nothing-created, alternatives) — render it verbatim, never the
// generic unknown-error/validate-your-spec fallback.
// generic unknown-error/validate-your-spec fallback. #141's
// generation_unconfirmed refusal is self-describing the same way.
const teaching = String(res.data["error"] ?? ((res.data["errors"] as string[]) ?? [])[0] ?? "A rig with this name is already running.");
console.error(teaching);
} else {
+7 -2
View File
@@ -1,4 +1,5 @@
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import type { HostEntry } from "./host-registry.js";
/**
@@ -114,14 +115,16 @@ export async function runCrossHostCommand(
let stdout = "";
let stderr = "";
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
if (child.stdout) {
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
stdout += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk);
});
}
if (child.stderr) {
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk);
});
}
@@ -133,6 +136,8 @@ export async function runCrossHostCommand(
});
});
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
return classifyResult(exitCode, stdout, stderr);
}
+5 -1
View File
@@ -8,6 +8,8 @@ export class FetchTimeoutError extends Error {
export interface FetchWithTimeoutOptions {
timeoutMs: number;
timeoutMessage: string;
/** Consume finite response bodies before releasing the request deadline. */
consumeResponse?: (response: Response) => Promise<void>;
}
export async function fetchWithTimeout(
@@ -38,7 +40,9 @@ export async function fetchWithTimeout(
}
try {
return await fetchImpl(url, { ...init, signal: controller.signal });
const response = await fetchImpl(url, { ...init, signal: controller.signal });
await options.consumeResponse?.(response);
return response;
} catch (err) {
if (err instanceof FetchTimeoutError) throw err;
if (err instanceof Error && err.name === "AbortError") {
+11 -3
View File
@@ -39,6 +39,7 @@
import os from "node:os";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
import { spawn as nodeSpawn } from "node:child_process";
import { loadHostRegistry, resolveHost, type SshHostEntry } from "./../host-registry.js";
import { looksLikePermissionGate } from "./../cross-host-executor.js";
@@ -131,7 +132,10 @@ export function checkLocalPath(raw: string): PathCheck {
error: `refused: '${raw}' resolves into the active OPENRIG_HOME (${activeOpenRigHome}) — live OpenRig state is not a copy source/target in v0 (crash-safety). This is the FR-4 default-deny wall (a short closed list; extension requires a ruling).`,
};
}
return { ok: true, normalizedPath: resolved };
// rsync distinguishes a directory from its contents by the source's trailing separator.
const normalizedPath = raw.endsWith(path.sep) && !resolved.endsWith(path.sep)
? resolved + path.sep : resolved;
return { ok: true, normalizedPath };
}
/** Remote side: posix-normalize, then the wall — absolute-only (arch Q5),
@@ -328,12 +332,14 @@ export async function runFileCopy(plan: CopyPlan, deps: { spawn?: SpawnFn } = {}
let stdout = "";
let stderr = "";
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let spawnFailed: NodeJS.ErrnoException | null = null;
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
stdout += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk);
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk);
});
const exitCode: number | null = await new Promise((resolve) => {
@@ -344,6 +350,8 @@ export async function runFileCopy(plan: CopyPlan, deps: { spawn?: SpawnFn } = {}
child.on("close", (code: number | null) => resolve(code));
});
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
if (spawnFailed !== null && (spawnFailed as NodeJS.ErrnoException).code === "ENOENT") {
return {
ok: false,
+20
View File
@@ -60,6 +60,7 @@ function runningDeps(port: number): StatusDeps {
describe("Attach CLI", () => {
let server: http.Server;
let port: number;
let receivedBody: Record<string, unknown>;
beforeAll(async () => {
server = http.createServer(async (req, res) => {
@@ -68,6 +69,7 @@ describe("Attach CLI", () => {
if (req.url === "/api/rigs/rig-1/attach-self" && req.method === "POST") {
const parsed = body ? JSON.parse(body) : {};
receivedBody = parsed;
const logicalId = parsed.logicalId ?? `${parsed.podNamespace}.${parsed.memberName}`;
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({
@@ -119,6 +121,16 @@ describe("Attach CLI", () => {
expect(output).toContain("inbound tmux transport unavailable");
});
it("attach --self --node accepts the advertised runtime guard", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "attach", "--self", "--rig", "rig-1", "--node", "orch1.lead", "--runtime", "codex"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("Attached this shell to node orch1.lead");
expect(receivedBody).toMatchObject({ logicalId: "orch1.lead", runtime: "codex" });
});
it("attach --self --print-env prints shell exports only", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "attach", "--self", "--rig", "rig-1", "--node", "orch1.lead", "--print-env"]);
@@ -140,6 +152,14 @@ describe("Attach CLI", () => {
expect(logs).toEqual([]);
});
it.each(["--pod", "--member"])("attach rejects node mode combined with %s", async (flag) => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "attach", "--self", "--rig", "rig-1", "--node", "orch1.lead", "--runtime", "codex", flag, "orch1"]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("Specify either");
});
it("attach rejects incomplete pod mode before calling the daemon", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "attach", "--self", "--rig", "rig-1", "--pod", "orch1", "--member", "lead"]);
+42
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import nodePath from "node:path";
import { Command } from "commander";
import { bundleCommand } from "../src/commands/bundle.js";
import { DaemonClient } from "../src/client.js";
@@ -60,6 +61,7 @@ function runningDeps(port: number): StatusDeps {
let capturedCreateBodies: Record<string, unknown>[] = [];
// Captured install bodies for assertion (Item 2 Checkpoint 3.3)
let capturedInstallBodies: Record<string, unknown>[] = [];
let capturedInspectBodies: Record<string, unknown>[] = [];
describe("Bundle CLI", () => {
let server: http.Server;
@@ -97,6 +99,7 @@ describe("Bundle CLI", () => {
}));
} else if (req.url === "/api/bundles/inspect" && req.method === "POST") {
const parsed = JSON.parse(body || "{}");
capturedInspectBodies.push(parsed);
if (String(parsed.bundlePath ?? "").includes("bad")) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Inspect failed" }));
@@ -272,6 +275,45 @@ describe("Bundle CLI", () => {
expect(exitCode).toBe(1);
});
// The daemon resolves any relative path against ITS OWN cwd, so every path the CLI sends must
// already be absolute (resolved against the operator's cwd). Files are not transported.
it("bundle create/inspect/install send spec, output, bundle, and target as client-absolute paths", async () => {
capturedCreateBodies = [];
capturedInspectBodies = [];
capturedInstallBodies = [];
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "bundle", "create", "rel/rig.yaml", "-o", "out/rel.rigbundle"]);
await makeCmd().parseAsync(["node", "rig", "bundle", "inspect", "out/rel.rigbundle"]);
await makeCmd().parseAsync(["node", "rig", "bundle", "install", "out/rel.rigbundle", "--yes", "--target", "proj"]);
});
const sent = {
specPath: capturedCreateBodies.at(-1)?.["specPath"],
outputPath: capturedCreateBodies.at(-1)?.["outputPath"],
inspectBundlePath: capturedInspectBodies.at(-1)?.["bundlePath"],
installBundlePath: capturedInstallBodies.at(-1)?.["bundlePath"],
targetRoot: capturedInstallBodies.at(-1)?.["targetRoot"],
};
console.log(`[client-paths] cwd=${process.cwd()} sent=${JSON.stringify(sent)}`);
expect(sent).toEqual({
specPath: nodePath.resolve("rel/rig.yaml"),
outputPath: nodePath.resolve("out/rel.rigbundle"),
inspectBundlePath: nodePath.resolve("out/rel.rigbundle"),
installBundlePath: nodePath.resolve("out/rel.rigbundle"),
targetRoot: nodePath.resolve("proj"),
});
});
it("control: bundle install --plan without --target still sends no targetRoot", async () => {
capturedInstallBodies = [];
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "bundle", "install", "/tmp/test.rigbundle", "--plan"]);
});
const body = capturedInstallBodies.at(-1)!;
expect(body["plan"]).toBe(true);
expect(body["targetRoot"]).toBeUndefined();
expect(body["bundlePath"]).toBe("/tmp/test.rigbundle");
});
// T6: bundle create --rig-root passes rigRoot in request body
it("bundle create --rig-root passes rigRoot in request body", async () => {
capturedCreateBodies = [];
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { Command } from "commander";
import { runProgram } from "../src/cli-error.js";
import { DaemonClient, DaemonTimeoutError } from "../src/client.js";
let server: http.Server | undefined;
afterEach(async () => {
if (!server) return;
server.closeAllConnections();
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
});
async function slowBodyServer(delayMs: number) {
let requests = 0;
let closedBeforeEnd = 0;
let response: http.ServerResponse | undefined;
let markHeadersWritten: () => void;
const headersWritten = new Promise<void>((resolve) => { markHeadersWritten = resolve; });
server = http.createServer(async (req, res) => {
for await (const _chunk of req) { /* consume the actual request body */ }
requests++;
response = res;
res.writeHead(200, { "Content-Type": "application/json" });
res.write('{"ok":'); // fetch resolves headers, while body remains incomplete
markHeadersWritten();
const timer = setTimeout(() => res.end("true}"), delayMs);
res.on("close", () => {
if (!res.writableEnded) closedBeforeEnd++;
clearTimeout(timer);
});
});
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
requests: () => requests, closedBeforeEnd: () => closedBeforeEnd,
headersWritten, dropBody: () => response!.destroy() };
}
function observedFetch() {
let markHeadersReceived: () => void;
const headersReceived = new Promise<void>((resolve) => { markHeadersReceived = resolve; });
const fetchImpl: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
markHeadersReceived();
return response;
};
return { fetchImpl, headersReceived };
}
describe("DaemonClient response body deadline", () => {
it.each(["json", "text", "write"])("bounds %s body reads after headers arrive", async (kind) => {
const endpoint = await slowBodyServer(4_000);
const observed = observedFetch();
const client = new DaemonClient(endpoint.url, { timeoutMs: 1_000, fetchImpl: observed.fetchImpl });
const request = kind === "json" ? client.get("/read") : kind === "text" ? client.getText("/read") : client.post("/write", { action: "once" });
const result = request.then(() => null, (error: unknown) => error);
await expect(Promise.race([endpoint.headersWritten.then(() => "headers written"), result])).resolves.toBe("headers written");
await expect(Promise.race([observed.headersReceived.then(() => "headers received"), result])).resolves.toBe("headers received");
expect(await result).toBeInstanceOf(DaemonTimeoutError);
expect(endpoint.requests()).toBe(1); // no automatic retry of an accepted write
await expect.poll(endpoint.closedBeforeEnd).toBe(1); // cancel the body/socket, not just the caller promise
});
it.each([false, true])("reports an unknown POST outcome after the response body drops (json=%s)", async (json) => {
const endpoint = await slowBodyServer(4_000);
const observed = observedFetch();
const client = new DaemonClient(endpoint.url, { timeoutMs: 2_000, fetchImpl: observed.fetchImpl });
const program = new Command().option("--json");
program.command("write").action(async () => { await client.post("/write", { action: "once" }); });
const out: string[] = [];
const err: string[] = [];
const exitCodes: number[] = [];
const run = runProgram(program, ["node", "rig", "write", ...(json ? ["--json"] : [])], {
out: (line) => out.push(line), err: (line) => err.push(line), exit: (code) => exitCodes.push(code),
});
await expect(Promise.race([endpoint.headersWritten.then(() => "headers written"), run])).resolves.toBe("headers written");
await expect(Promise.race([observed.headersReceived.then(() => "headers received"), run])).resolves.toBe("headers received");
endpoint.dropBody();
expect(await run).toBe(1);
expect(exitCodes).toEqual([1]);
const guidance = (json ? out : err).join("\n");
expect(guidance).toMatch(/unreadable response/i);
expect(guidance).toMatch(/outcome.*UNKNOWN/i);
expect(guidance).toMatch(/re-check current state/i);
expect(guidance).not.toMatch(/not delivered|cannot connect|rig up|rig daemon start/i);
if (json) expect(JSON.parse(guidance).error.consequence).toMatch(/UNKNOWN/);
expect(endpoint.requests()).toBe(1);
await expect.poll(endpoint.closedBeforeEnd).toBe(1);
});
it("uses the per-request deadline through the complete body", async () => {
const endpoint = await slowBodyServer(100);
const client = new DaemonClient(endpoint.url, { timeoutMs: 20 });
await expect(client.get("/read", { timeoutMs: 1_000 })).resolves.toEqual({ status: 200, data: { ok: true } });
expect(endpoint.requests()).toBe(1);
expect(endpoint.closedBeforeEnd()).toBe(0);
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import { spawn as spawnChild } from "node:child_process";
import {
runCrossHostCommand,
classifyResult,
@@ -341,3 +342,52 @@ describe("D13 — remote rig resolution + loud 127", () => {
expect(d13Classify(255, "", "Permission denied (publickey)").failedStep).toBe("permission-gate");
});
});
describe("remote output byte boundaries", () => {
it("preserves UTF-8 from real child stdout and stderr pipes", async () => {
const expected = "界🙂café";
const spawn: SpawnFn = () => spawnChild(process.execPath, ["-e", `
const bytes = Buffer.from(${JSON.stringify(expected)});
let i = 0;
const timer = setInterval(() => {
process.stdout.write(bytes.subarray(i, i + 1));
process.stderr.write(bytes.subarray(i, i + 1));
if (++i === bytes.length) clearInterval(timer);
}, 20);
`]);
const result = await runCrossHostCommand(HOST, ["rig", "capture", "dev@rig"], { spawn });
expect(result).toMatchObject({ ok: true, stdout: expected, stderr: expected });
});
it("flushes an incomplete final UTF-8 character from both pipes on close", async () => {
const spawn: SpawnFn = () => {
const child = makeMockChild();
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from([0xe7]));
child.stderr.emit("data", Buffer.from([0xf0, 0x9f]));
child.emit("close", 0);
});
return child as never;
};
const result = await runCrossHostCommand(HOST, ["rig", "capture", "dev@rig"], { spawn });
expect(result).toMatchObject({ ok: true, stdout: "\ufffd", stderr: "\ufffd" });
});
it.each([0, 1])("preserves split UTF-8 on both pipes for exit %s", async (exitCode) => {
const stdout = '{"content":"界🙂café"}\n';
const stderr = "diagnostic: naïve 日本語\n";
const spawn: SpawnFn = () => {
const child = makeMockChild();
queueMicrotask(() => {
for (const byte of Buffer.from(stdout)) child.stdout.emit("data", Buffer.from([byte]));
for (const byte of Buffer.from(stderr)) child.stderr.emit("data", Buffer.from([byte]));
child.emit("close", exitCode);
});
return child as never;
};
const result = await runCrossHostCommand(HOST, ["rig", "capture", "dev@rig", "--json"], { spawn });
expect(result).toMatchObject({ stdout, stderr });
expect(result.failedStep).toBe(exitCode === 0 ? "none" : "remote-command-failed");
});
});
@@ -1012,6 +1012,41 @@ describe("Daemon Lifecycle", () => {
expect(isAlive(123)).toBe(true);
});
it("readProcessState reads the ps state column on POSIX", async () => {
const { readProcessState } = await import("../src/commands/daemon.js");
const calls: Array<{ file: string; args: string[] }> = [];
const state = readProcessState(123, "linux", (file, args) => {
calls.push({ file, args });
return "S\n";
});
expect(state).toBe("S\n");
expect(calls).toEqual([{ file: "ps", args: ["-o", "state=", "-p", "123"] }]);
});
it("readProcessState returns null on POSIX when ps fails", async () => {
const { readProcessState } = await import("../src/commands/daemon.js");
const state = readProcessState(123, "darwin", () => {
throw new Error("no such process");
});
expect(state).toBeNull();
});
it("readProcessState does not shell out to ps on Windows", async () => {
const { readProcessState, createIsProcessAlive } = await import("../src/commands/daemon.js");
const run = () => {
throw new Error("ps must not run on win32");
};
expect(readProcessState(123, "win32", run)).toBe("R");
const isAlive = createIsProcessAlive({
signalCheck: () => true,
readProcessState: (pid) => readProcessState(pid, "win32", run),
});
expect(isAlive(123)).toBe(true);
});
});
describe("resolveDaemonPath", () => {
+33
View File
@@ -44,6 +44,8 @@ function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCod
describe("rig env", () => {
let server: http.Server;
let port: number;
let probeStatus = "fresh";
let probeError: string | undefined;
const rigSummary = [{ id: "rig-1", name: "my-rig", nodeCount: 2 }];
@@ -76,6 +78,8 @@ describe("rig env", () => {
res.end(JSON.stringify({
ok: true,
hasServices: true,
probeStatus,
...(probeError ? { probeError } : {}),
kind: "compose",
projectName: "my-rig",
receipt: {
@@ -151,6 +155,35 @@ describe("rig env", () => {
expect(parsed.receipt.services).toHaveLength(1);
});
it.each(["stale", "no_orchestrator"])("env status discloses a saved receipt when probe status is %s", async (status) => {
probeStatus = status;
probeError = status === "stale" ? "Docker daemon connection refused" : undefined;
try {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig"]);
});
const output = logs.join("\n");
expect(output).toContain("saved receipt");
expect(output).toContain(status);
if (probeError) expect(output).toContain(probeError);
expect(output).toContain("vault");
expect(output).toContain("healthy");
expect(exitCode).toBeUndefined();
} finally { probeStatus = "fresh"; probeError = undefined; }
});
it("env status --json preserves stale probe metadata without extra output", async () => {
probeStatus = "stale";
probeError = "Docker daemon connection refused";
try {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig", "--json"]);
});
expect(JSON.parse(logs.join(""))).toMatchObject({ probeStatus, probeError, receipt: { services: [{ health: "healthy" }] } });
expect(exitCode).toBeUndefined();
} finally { probeStatus = "fresh"; probeError = undefined; }
});
it("env logs returns service logs", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "logs", "my-rig"]);
+33 -1
View File
@@ -1,7 +1,39 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { fetchWithTimeout, FetchTimeoutError } from "../src/fetch-with-timeout.js";
describe("fetchWithTimeout", () => {
it("shares one deadline across waiting for headers and reading the body", async () => {
vi.useFakeTimers();
try {
let signal: AbortSignal | undefined;
let headersReceived = false;
const fetchImpl: typeof fetch = async (_url, init) => {
signal = init?.signal ?? undefined;
await new Promise((resolve) => setTimeout(resolve, 600));
headersReceived = true;
return new Response("body");
};
const result = fetchWithTimeout(fetchImpl, "http://localhost:7433/read", {}, {
timeoutMs: 1_000,
timeoutMessage: "complete request timed out",
consumeResponse: async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(resolve, 600);
signal!.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
});
},
}).then(() => null, (error: unknown) => error);
await vi.advanceTimersByTimeAsync(600);
expect(headersReceived).toBe(true);
expect(signal!.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(400);
expect(signal!.aborted).toBe(true);
expect(await result).toBeInstanceOf(FetchTimeoutError);
} finally {
vi.useRealTimers();
}
});
it("rejects with FetchTimeoutError when fetch blackholes", async () => {
const fetchImpl: typeof fetch = ((_url: string | URL | Request, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
+65
View File
@@ -7,6 +7,9 @@
import { describe, it, expect } from "vitest";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
import { EventEmitter } from "node:events";
import {
parseFilePathArg,
checkLocalPath,
@@ -312,3 +315,65 @@ describe("classifyRsyncResult / runFileCopy", () => {
expect(res.hint).toContain("brew install rsync");
});
});
describe("file copy output byte boundaries", () => {
it("preserves split UTF-8 on both rsync output pipes", async () => {
const text = "copied café/日本語.md 🙂\n";
const spawn = (() => {
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
child.stdout = new EventEmitter(); child.stderr = new EventEmitter();
queueMicrotask(() => {
for (const byte of Buffer.from(text)) {
child.stdout.emit("data", Buffer.from([byte]));
child.stderr.emit("data", Buffer.from([byte]));
}
child.emit("close", 0);
});
return child;
}) as unknown as NonNullable<Parameters<typeof runFileCopy>[1]>["spawn"];
const result = await runFileCopy({ src: local("/tmp/src"), dst: local("/tmp/dst"), dryRun: true }, { spawn });
expect(result).toMatchObject({ ok: true, stdout: text, stderr: text });
});
});
let rsyncAvailable = false;
try { execFileSync("rsync", ["--version"], { stdio: "ignore" }); rsyncAvailable = true; }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
describe("local directory copy semantics", () => {
it("preserves the trailing slash in the normalized local source and rsync argv", () => {
const source = "." + path.sep + "copy-source" + path.sep;
const planned = planFileCopy(source, "." + path.sep + "copy-destination");
expect(planned.ok).toBe(true);
if (!planned.ok) return;
const expectedSource = path.resolve("copy-source") + path.sep;
expect(planned.plan.src.path).toBe(expectedSource);
expect(buildRsyncArgv(planned.plan).slice(-2)[0]).toBe(expectedSource);
});
it.skipIf(!rsyncAvailable).each([false, true])("native rsync preserves source directory trailing slash=%s (requires installed rsync)", async (contentsOnly) => {
const root = mkdtempSync(path.join(process.cwd(), "openrig-copy-contents-"));
try {
const src = path.join(root, "source");
const dst = path.join(root, "destination");
mkdirSync(src); mkdirSync(dst);
writeFileSync(path.join(src, "artifact.txt"), "owned test artifact");
const localSrc = "." + path.sep + path.relative(process.cwd(), src);
const localDst = "." + path.sep + path.relative(process.cwd(), dst);
const planned = planFileCopy(localSrc + (contentsOnly ? path.sep : ""), localDst);
expect(planned.ok).toBe(true);
if (!planned.ok) return;
const result = await runFileCopy(planned.plan);
expect(result.ok, result.stderr).toBe(true);
const expected = path.join(dst, ...(contentsOnly ? [] : ["source"]), "artifact.txt");
expect(existsSync(expected), `rsync destination should be ${expected}`).toBe(true);
expect(readFileSync(expected, "utf8")).toBe("owned test artifact");
if (contentsOnly) expect(existsSync(path.join(dst, "source"))).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+46
View File
@@ -145,6 +145,52 @@ describe("rig proof add (fs-level, temp workspace)", () => {
expect(process.exitCode).toBeUndefined();
});
it("rejects a screenshot passed as --file without changing its bytes", async () => {
const proofDir = path.join(sliceDir, "proof");
fs.mkdirSync(proofDir);
const screenshot = path.join(proofDir, "shot.png");
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]);
fs.writeFileSync(screenshot, bytes);
await run([
"add", "19-signal-layer", "--mission", "release-x",
"--artifact-type", "qa", "--verdict", "CLEAR",
"--candidate-sha", "abc1234", "--money-evidence", "m",
"--file", screenshot,
]);
expect(process.exitCode).toBe(1);
expect(fs.readFileSync(screenshot)).toEqual(bytes);
expect(fs.readdirSync(proofDir)).toEqual(["shot.png"]);
expect(errs.join("\n")).toContain("--media");
process.exitCode = undefined;
await run(baseArgs(["--name", "shot.png"]));
expect(process.exitCode).toBe(1);
expect(fs.readFileSync(screenshot)).toEqual(bytes);
});
it("uses a Markdown name for a text file and requires --replace to overwrite it", async () => {
const source = path.join(workRoot, "notes.txt");
fs.writeFileSync(source, "first proof");
const fromFile = [
"add", "19-signal-layer", "--mission", "release-x",
"--artifact-type", "qa", "--verdict", "CLEAR",
"--candidate-sha", "abc1234", "--money-evidence", "m",
"--file", source,
];
await run(fromFile);
const target = path.join(sliceDir, "proof", "notes.md");
expect(process.exitCode).toBeUndefined();
expect(fs.readFileSync(target, "utf8")).toContain("first proof");
fs.writeFileSync(source, "revised proof");
await run(fromFile);
expect(process.exitCode).toBe(1);
expect(fs.readFileSync(target, "utf8")).not.toContain("revised proof");
process.exitCode = undefined;
await run([...fromFile, "--replace"]);
expect(process.exitCode).toBeUndefined();
expect(fs.readFileSync(target, "utf8")).toContain("revised proof");
});
it("out-of-set verdict is rejected naming the allowed values; nothing written; exit 1", async () => {
await run([
"add", "19-signal-layer", "--mission", "release-x",
+27 -4
View File
@@ -1,5 +1,6 @@
// OPR.0.3.3.13.1 - CLI surface-detection parser POC tests (AC-1..AC-5).
import { describe, it, expect } from "vitest";
import { beforeAll, describe, it, expect } from "vitest";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
@@ -30,6 +31,25 @@ function readSource(rel: string): { name: string; text: string } {
const FROM = "v0.3.1";
const TO = "v0.3.2";
function hasRef(ref: string): boolean {
try {
execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: here, stdio: "ignore" });
return true;
} catch {
return false;
}
}
// The worked example and determinism checks diff two real release tags. Forks and shallow or
// --no-tags clones lack them, and a throw while collecting a describe fails the whole file,
// unit tests included. Skip those two blocks locally when the tags are absent; CI clones with
// full history, so there a missing tag still fails loudly.
const hasReleaseTags = hasRef(FROM) && hasRef(TO);
const skipTagTests = !hasReleaseTags && process.env.CI !== "true";
if (skipTagTests) {
console.warn(`release-surface: tags ${FROM}/${TO} not found; skipping tag-diff tests (git fetch --tags to run them)`);
}
describe("release-surface parser - extract (unit)", () => {
it("AC-2 crux: emits the registration name `mode`, never the filename `rig-mode` (verb renamed from `policy`, B7)", () => {
const surface = extractSurfaceFromSources([readSource("src/commands/rig-mode.ts")]);
@@ -95,8 +115,11 @@ describe("release-surface parser - diff (AC-1)", () => {
});
});
describe("release-surface parser - v0.3.1..v0.3.2 worked example (AC-2)", () => {
const diff = generateSurfaceDiff({ from: FROM, to: TO, cwd: here });
describe.skipIf(skipTagTests)("release-surface parser - v0.3.1..v0.3.2 worked example (AC-2)", () => {
let diff: SurfaceDiff;
beforeAll(() => {
diff = generateSurfaceDiff({ from: FROM, to: TO, cwd: here });
});
it("emits exactly `policy` and `scope` as new top-level commands (no false positives, no filename)", () => {
const names = diff.added_commands.map((c) => c.name).sort();
@@ -147,7 +170,7 @@ describe("release-surface parser - v0.3.1..v0.3.2 worked example (AC-2)", () =>
});
});
describe("release-surface parser - determinism (AC-4)", () => {
describe.skipIf(skipTagTests)("release-surface parser - determinism (AC-4)", () => {
it("produces byte-identical YAML across two runs on the same refs", () => {
const a = diffToYaml(generateSurfaceDiff({ from: FROM, to: TO, cwd: here }));
const b = diffToYaml(generateSurfaceDiff({ from: FROM, to: TO, cwd: here }));
@@ -0,0 +1,98 @@
// #174: when removal keeps a session that belongs to another rig's live seat, the human output of
// `rig remove` and `rig shrink` says so (JSON already carries `sessionKeptFor`).
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js";
import { removeCommand } from "../src/commands/remove.js";
import { shrinkCommand } from "../src/commands/shrink.js";
import type { StatusDeps } from "../src/commands/status.js";
const RESPONSES: Record<string, unknown> = {
"DELETE /api/rigs/rig-1/nodes/lead.planner": {
ok: true, rigId: "rig-1", nodeId: "node-9", logicalId: "lead.planner",
sessionsKilled: 0, sessionKeptFor: "lead.planner@live-rig", reroutedQitemIds: [],
},
"DELETE /api/rigs/rig-1/nodes/dev.impl": {
ok: true, rigId: "rig-1", nodeId: "node-1", logicalId: "dev.impl", sessionsKilled: 1, reroutedQitemIds: [],
},
"DELETE /api/rigs/rig-1/pods/kept": {
ok: true, status: "ok", rigId: "rig-1", podId: "pod-9", namespace: "kept",
removedLogicalIds: ["kept.lead"], sessionsKilled: 0, reroutedQitemIds: [],
nodes: [{ logicalId: "kept.lead", nodeId: "node-9", status: "removed", sessionsKilled: 0, sessionKeptFor: "kept.lead@live-rig" }],
},
};
describe("#174 remove/shrink human output names a kept session", () => {
let server: http.Server;
let port: number;
beforeAll(async () => {
server = http.createServer((req, res) => {
const body = RESPONSES[`${req.method} ${req.url}`];
res.writeHead(body ? 200 : 404, { "Content-Type": "application/json" });
res.end(JSON.stringify(body ?? { error: "not found" }));
});
await new Promise<void>((resolve) => { server.listen(0, "127.0.0.1", resolve); });
port = (server.address() as { port: number }).port;
});
afterAll(() => { server.close(); });
function deps(): StatusDeps {
return {
lifecycleDeps: {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn((p: string) => p === STATE_FILE
? JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-09-30T00:00:00Z" } as DaemonState)
: null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn((p: string) => p === STATE_FILE),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
},
clientFactory: () => new DaemonClient(`http://127.0.0.1:${port}`),
};
}
async function run(command: Command, args: string[]): Promise<{ output: string; exitCode: number | undefined }> {
const logs: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
const err = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
process.exitCode = undefined;
const program = new Command();
program.exitOverride();
program.addCommand(command);
try {
await program.parseAsync(["node", "rig", ...args]);
return { output: logs.join("\n"), exitCode: process.exitCode };
} finally {
log.mockRestore();
err.mockRestore();
process.exitCode = undefined;
}
}
it("remove says the session was kept and for whom", async () => {
const { output, exitCode } = await run(removeCommand(deps()), ["remove", "rig-1", "lead.planner"]);
expect(exitCode).toBeUndefined();
expect(output).toContain("Removed node lead.planner from rig rig-1 (0 session killed)");
expect(output).toContain("Session kept: owned by lead.planner@live-rig");
});
it("control: remove prints no kept line when it killed its own session", async () => {
const { output } = await run(removeCommand(deps()), ["remove", "rig-1", "dev.impl"]);
expect(output).toContain("Removed node dev.impl from rig rig-1 (1 session killed)");
expect(output).not.toContain("Session kept");
});
it("shrink names each node whose session was kept", async () => {
const { output, exitCode } = await run(shrinkCommand(deps()), ["shrink", "rig-1", "kept"]);
expect(exitCode).toBeUndefined();
expect(output).toContain("kept.lead: session kept, owned by kept.lead@live-rig");
});
});
+59
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { Command } from "commander";
import { DaemonClient } from "../src/client.js";
import { seatCommand, handoverCommand } from "../src/commands/seat.js";
import { STATE_FILE, type DaemonState, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
@@ -208,6 +209,7 @@ const FRESH_LAUNCH_RESULT = {
describe("rig seat status", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -339,6 +341,63 @@ describe("rig seat status", () => {
expect(output).toContain("No continuity source was used; siblings and durable work were preserved.");
});
it("launch waits for a ready response beyond the default five-second timeout", async () => {
vi.useFakeTimers();
const fetchImpl = vi.fn((_url: unknown, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => resolve(Response.json(FRESH_LAUNCH_RESULT)), 6_000);
init!.signal!.addEventListener("abort", () => {
clearTimeout(timer);
reject(init!.signal!.reason);
}, { once: true });
}));
const deps = makeDeps({ status: 200, data: FRESH_LAUNCH_RESULT }, []);
deps.clientFactory = url => new DaemonClient(url, { fetchImpl });
const result = captureLogs(() => makeCommand(deps).parseAsync([
"node", "rig", "seat", "launch", "dev-impl@seat-rig",
"--fresh", "--stop", "--reason", "deliberate blank restart", "--json",
]).then(() => undefined)).catch(error => ({ error }));
await vi.advanceTimersByTimeAsync(6_000);
const output = await result;
expect(output).toMatchObject({ errors: [], exitCode: undefined });
if (!("logs" in output)) throw output.error;
expect(JSON.parse(output.logs.join("\n"))).toMatchObject({ status: "ready", generation: "gen-fresh" });
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it.each([false, true])("launch reports an unknown outcome without retrying on timeout (json=%s)", async json => {
vi.useFakeTimers();
const fetchImpl = vi.fn((_url: unknown, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true });
}));
const deps = makeDeps({ status: 200, data: FRESH_LAUNCH_RESULT }, []);
deps.clientFactory = url => new DaemonClient(url, { fetchImpl });
const result = captureLogs(() => makeCommand(deps).parseAsync([
"node", "rig", "seat", "launch", "dev-impl@seat-rig",
"--fresh", "--stop", "--reason", "deliberate blank restart", ...(json ? ["--json"] : []),
]).then(() => undefined)).catch(error => ({ error }));
await vi.advanceTimersByTimeAsync(120_000);
const output = await result;
expect(output).toMatchObject({ exitCode: 1 });
if (!("logs" in output)) throw output.error;
if (json) {
expect(output.errors).toEqual([]);
expect(JSON.parse(output.logs.join("\n"))).toMatchObject({
status: "unknown",
code: "launch_outcome_unknown",
guidance: expect.stringContaining("rig seat status dev-impl@seat-rig"),
});
} else {
expect(output.logs).toEqual([]);
expect(output.errors.join("\n")).toContain("may still be in progress");
expect(output.errors.join("\n")).toContain("rig seat status dev-impl@seat-rig");
}
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("handover --dry-run --json prints the stable planner shape", async () => {
const paths: string[] = [];
const deps = makeDeps({ status: 200, data: HANDOVER_PLAN }, paths);
+118 -2
View File
@@ -219,8 +219,12 @@ describe("rig setup", () => {
expect(wsIdx).toBeGreaterThan(statusIdx);
expect(wfIdx).toBeGreaterThan(wsIdx);
expect(scopeIdx).toBeGreaterThan(wsIdx);
expect(out).toContain("rig up first-project --cwd .");
expect(out).toContain("rig send dev-owner@first-project");
expect(out).toContain("rig up <starter> --cwd . --plan");
for (const name of ["first-project", "first-project-claude", "first-project-mixed"]) expect(out).toContain(name);
expect(out).toContain("check only selected logins");
expect(out).toContain("rig send dev-owner@<starter>");
expect(out).toContain("rig queue list --destination dev-owner@<starter>");
expect(out).not.toContain("rig queue list --rig");
expect(out).toContain("rig tui --shared");
expect(out).toContain("docs/reference/getting-started.md");
// no magic mega-command - the path is existing verbs only
@@ -946,3 +950,115 @@ describe("rig setup permission-policy menu copy (frozen)", () => {
}
});
});
// Issue #194 — a Codex provider that does not use an OpenAI login.
describe("codex_auth — provider-aware Codex readiness (#194)", () => {
const CODEX_HOME = "/synthetic/codex-cli-home";
const BEDROCK_CONFIG = [
'model_provider = "bedrock-runtime-us"',
"",
"[model_providers.bedrock-runtime-us]",
'name = "Amazon Bedrock Runtime"',
'env_key = "AWS_BEARER_TOKEN_BEDROCK"',
'wire_api = "responses"',
"requires_openai_auth = false",
"",
].join("\n");
const TOKEN = "synthetic-bedrock-token-value";
function providerDeps(opts: {
config?: string | null;
env?: NodeJS.ProcessEnv;
loggedIn?: boolean;
codexInstalled?: boolean;
}) {
const commands: string[] = [];
const reads: string[] = [];
const base = makeDeps();
const deps = makeDeps({
env: opts.env ?? { HOME: "/synthetic/home", CODEX_HOME },
readFile: (p: string) => {
reads.push(p);
return p === `${CODEX_HOME}/config.toml` ? (opts.config ?? null) : null;
},
exec: (cmd: string) => {
commands.push(cmd);
if (cmd === "codex --version" && opts.codexInstalled === false) throw new Error("codex: command not found");
if (cmd === "npm install -g @openai/codex" && opts.codexInstalled === false) throw new Error("install refused in test");
if (cmd === "codex login status" && !opts.loggedIn) throw new Error("Not logged in");
return base.exec(cmd);
},
});
return { deps, commands, reads };
}
const codexAuthStep = (result: SetupResult) => result.steps.find((s) => s.id === "codex_auth");
it("passes the explicit Bedrock provider from the selected CODEX_HOME without an OpenAI login", async () => {
const { deps, commands, reads } = providerDeps({
config: BEDROCK_CONFIG,
env: { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN },
loggedIn: false,
});
const result = await runSetup(deps, {});
const step = codexAuthStep(result);
expect(step?.status).toBe("pass");
expect(step?.message).toContain("AWS_BEARER_TOKEN_BEDROCK");
expect(step?.message).toContain("not that the provider accepts it");
expect(JSON.stringify(result)).not.toContain(TOKEN);
expect(commands).not.toContain("codex login status");
expect(reads).toContain(`${CODEX_HOME}/config.toml`);
expect(reads).not.toContain("/synthetic/home/.codex/config.toml");
});
it("fails with the provider's variable named, never its value, when the token is missing or blank", async () => {
for (const env of [
{ HOME: "/synthetic/home", CODEX_HOME },
{ HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: " " },
]) {
const { deps, commands } = providerDeps({ config: BEDROCK_CONFIG, env, loggedIn: true });
const result = await runSetup(deps, {});
const step = codexAuthStep(result);
expect(step?.status).toBe("fail");
expect(step?.message).toContain("AWS_BEARER_TOKEN_BEDROCK");
expect(step?.fixHint).toContain("Export AWS_BEARER_TOKEN_BEDROCK");
expect(step?.fixHint).not.toContain("codex login");
expect(result.ready).toBe(false);
expect(commands).not.toContain("codex login status");
}
});
it("keeps the OpenAI login check for the default provider and for providers that require it", async () => {
const requiresLogin = BEDROCK_CONFIG.replace("requires_openai_auth = false", "requires_openai_auth = true");
for (const config of [null, 'model_provider = "openai"\n', requiresLogin]) {
const env = { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN };
const ok = providerDeps({ config, env, loggedIn: true });
expect(codexAuthStep(await runSetup(ok.deps, {}))?.status).toBe("pass");
expect(ok.commands).toContain("codex login status");
const notLoggedIn = providerDeps({ config, env, loggedIn: false });
const step = codexAuthStep(await runSetup(notLoggedIn.deps, {}));
expect(step?.status).toBe("fail");
expect(step?.fixHint).toContain("codex login");
}
});
it("reports a malformed-config explanation when the fallback OpenAI login fails", async () => {
const env = { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN };
const { deps } = providerDeps({ config: "model_provider = [broken", env, loggedIn: false });
const step = codexAuthStep(await runSetup(deps, {}));
expect(step?.status).toBe("fail");
expect(step?.message).toContain("config.toml could not be parsed");
});
it("skips the provider check when the Codex executable is missing", async () => {
const { deps } = providerDeps({
config: BEDROCK_CONFIG,
env: { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN },
codexInstalled: false,
});
const result = await runSetup(deps, {});
expect(result.steps.find((s) => s.id === "codex_install")?.status).toBe("fail");
expect(codexAuthStep(result)?.status).toBe("skipped");
});
});
@@ -0,0 +1,74 @@
// #141: `rig up` terminal output for the stopped-generation archive. The archive notice must reach the user
// on the attention 409 too (the replacement is kept there), and a generation_unconfirmed refusal prints its
// own actionable message. Actual command renderer and daemon attention builder; the client is stubbed.
import { describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { upCommand } from "../src/commands/up.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import type { DaemonClient } from "../src/client.js";
import { buildAttentionResponse } from "../../daemon/src/routes/up.js";
const OLD = "01OLDGENERATION00000000000";
const NOTICE = `Archived the stopped earlier "first-project" rig ${OLD}; its records are kept. Restore it with: rig unarchive ${OLD}`;
async function render(data: Record<string, unknown>, status: number, json = false) {
const oldExit = process.exitCode;
const out: string[] = [], err: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...s) => { out.push(s.join(" ")); });
const error = vi.spyOn(console, "error").mockImplementation((...s) => { err.push(s.join(" ")); });
const forbidden = () => { throw new Error("unexpected lifecycle operation"); };
const lifecycleDeps = {
spawn: forbidden, kill: forbidden, writeFile: forbidden, removeFile: forbidden,
mkdirp: forbidden, openForAppend: forbidden,
fetch: async () => ({ ok: true }), isProcessAlive: () => true,
exists: (p: string) => p === STATE_FILE,
readFile: (p: string) => p === STATE_FILE ? JSON.stringify({ pid: 123, port: 12345, db: "mock", startedAt: "2000-01-01" }) : null,
} as LifecycleDeps;
const client = { get: vi.fn(async () => ({ status: 200, data: [] })), post: vi.fn(async () => ({ status, data })) };
process.exitCode = undefined;
try {
const cmd = new Command().exitOverride().addCommand(upCommand({
lifecycleDeps, clientFactory: () => client as unknown as DaemonClient, preflightExec: forbidden,
}));
await cmd.parseAsync(["node", "rig", "up", "first-project", ...(json ? ["--json"] : [])]);
return { out: out.join("\n"), err: err.join("\n"), exit: process.exitCode };
} finally { process.exitCode = oldExit; log.mockRestore(); error.mockRestore(); }
}
function attentionWithNotice() {
const attentionNodes = [{ logicalId: "dev.owner", sessionName: "dev-owner@first-project", reason: "trust_gate", evidence: "trust" }];
const result = { rigId: "NEWRIG", warnings: [NOTICE], stages: [{ stage: "import_rig", status: "blocked",
detail: { code: "attention_required", message: "1 member needs attention.", attentionNodes } }] };
return { ...result, ...buildAttentionResponse(result)! };
}
describe("#141 rig up output for the stopped-generation archive", () => {
it("attention 409: prints the archive notice with the old rig id and its unarchive instruction", async () => {
const r = await render(attentionWithNotice(), 409);
expect(r.err).toContain(`rig unarchive ${OLD}`);
expect(r.err).toContain("dev.owner");
expect(r.exit).toBe(1);
});
it("attention 409 --json: the JSON body is unchanged and nothing else is printed", async () => {
const data = attentionWithNotice();
const r = await render(data, 409, true);
expect(r.out).toBe(JSON.stringify(data));
expect(r.err).toBe("");
expect(r.exit).toBe(1);
});
it("generation_unconfirmed 409: prints its actionable message, not the validate-your-spec fallback", async () => {
const message = `A rig named "first-project" already exists (${OLD}), and OpenRig could not confirm it is stopped: tmux session x still exists. Nothing was created. Restore it with 'rig up first-project', or archive it first with 'rig archive ${OLD}'.`;
const r = await render({ code: "generation_unconfirmed", error: message, errors: [message],
stages: [{ stage: "import_rig", status: "failed", detail: { code: "generation_unconfirmed" } }] }, 409);
expect(r.err).toContain(message);
expect(r.err).not.toMatch(/validate your spec|Check daemon logs/);
expect(r.exit).toBe(1);
});
it("control: on success a warning is printed exactly once", async () => {
const r = await render({ status: "completed", rigId: "NEWRIG", stages: [], warnings: [NOTICE] }, 201);
expect(r.err.split(`rig unarchive ${OLD}`).length - 1).toBe(1);
});
});
+53 -2
View File
@@ -103,8 +103,10 @@ describe("Up CLI", () => {
cmd.outputHelp();
const help = logs.join("");
expect(help).toContain("Launch a rig or managed app from a spec, library entry, or bundle");
expect(help).toContain("Target root directory for package installation");
expect(help).toContain("does not change agent cwd");
const flat = help.replace(/\s+/g, " ");
expect(flat).toContain("Install target for a .rigbundle (default: current directory)");
expect(flat).toContain("relative member cwds resolve against it; --cwd still overrides launch cwd");
expect(flat).not.toContain("does not change agent cwd");
expect(help).toContain("--cwd <path>");
expect(help).toContain("rig up secrets-manager");
});
@@ -295,6 +297,55 @@ describe("Up CLI", () => {
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from .rigbundle resolves a relative --target against the client cwd on the local route", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle", "--target", "rel/target-root"]);
});
expect(lastBody.targetRoot).toBe(`${process.cwd()}/rel/target-root`);
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from .rigbundle with --cwd and no --target keeps the default target and sends --cwd only as cwdOverride", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle", "--cwd", "rel/launch-dir"]);
});
expect(lastBody.targetRoot).toBe(process.cwd());
expect(lastBody.cwdOverride).toBe(`${process.cwd()}/rel/launch-dir`);
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from a library name defaults cwdOverride to the caller working directory", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
@@ -95,13 +95,22 @@ function normalizeRateLimits(value) {
for (const key of ["five_hour", "seven_day"]) {
const window = value[key];
if (!window || typeof window !== "object" || Array.isArray(window)) continue;
const resetsAt = typeof window.resets_at === "number"
? unixSecondsToIso(window.resets_at)
: window.resets_at;
if (typeof window.used_percentage !== "number" || !Number.isFinite(window.used_percentage)
|| typeof window.resets_at !== "string") continue;
result[key] = { usedPercent: window.used_percentage, resetsAt: window.resets_at };
|| typeof resetsAt !== "string") continue;
result[key] = { usedPercent: window.used_percentage, resetsAt };
}
return result.five_hour || result.seven_day ? result : null;
}
function unixSecondsToIso(value) {
if (!Number.isFinite(value)) return null;
const date = new Date(value * 1000);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function writeJsonAtomic(outputPath, value) {
const tmpPath = outputPath + ".tmp";
const dir = path.dirname(outputPath);
@@ -1,12 +1,13 @@
---
name: applying-a-permission-policy
description: >-
Use when a user asks an agent to configure OpenRig command permissions, reduce
repeated native approval prompts, or apply a selected rig/seat permission policy.
Use before launching a team during agent-guided setup, or when a user asks to
configure OpenRig command permissions, reduce repeated native approval prompts,
or apply a selected rig/seat permission policy.
metadata:
openrig:
stage: established
docs_checked: "2026-09-25"
docs_checked: "2026-09-29"
verification_status: "Verify the installed harness version and effective settings; a rule-parser result is not a native permission test."
---
@@ -17,10 +18,40 @@ OpenRig operating posture, native command rules, sandbox access and launch flags
are separate controls. A command rule grants execution capability, not authority
to invent tasks, publish work or change another host.
## Ask once before team launch
Reuse an existing explicit choice for these harnesses and this scope from the
user's onboarding context; do not ask again. Existing rules are configuration,
not evidence of consent to expand their scope. Preserve them without expansion.
If the choice is unknown, briefly explain:
> This allows the whole `rig` command family, including starting/stopping agents,
> changing OpenRig configuration and launching processes. It is not global YOLO
> or permission to invent work. The scope is your personal settings for this
> project unless you explicitly choose your user-wide sessions instead.
>
> Allow your agents to run OpenRig commands without repeated permission prompts?
> **Yes — recommended** / **No — keep prompts**
- **Yes:** require an actual affirmative answer, then apply the procedure below
at that scope for the selected harnesses. Never substitute user-wide rules
when project rules are unsupported. Native restrictions still apply.
- **No:** leave permission settings unchanged and continue with existing prompts.
- **No answer:** make no settings change, infer no consent and continue with
existing prompts. Do not keep asking during this setup.
Remember an explicit Yes or No in the existing onboarding/project context the
agent already reads: choice, harnesses and scope. For Yes, record the exact files
and entries added, any pre-existing equivalents, and the loading/verification
result. Do not add a preference service or native configuration key. No answer
is not a remembered No. Offer broader permissive operation only as a separate
explicit opt-in; the setup question does not select a builtin policy or mode.
## Choose the intended scope
Use an existing explicit choice; otherwise explain these options and ask which
the user wants. Do not ask again for routine steps already authorized.
Outside the setup question above, use an existing explicit choice; otherwise
explain these options and ask which the user wants. Do not reopen the menu after
an answered setup question or ask again for routine steps already authorized.
| Choice | What the agent configures |
| --- | --- |
@@ -47,7 +78,14 @@ execution, an entire interpreter or a generic shell wrapper.
hooks, auth, MCP, model settings and unrelated values. An allow must not erase
a stricter rule or managed requirement. Report a real conflict instead of
silently bypassing it.
Check both bare and actual absolute command spellings: a restriction on one
may not match the other. If a new allowance would evade a stricter restriction,
leave that addition unapplied and report the conflict; do not switch spellings
to bypass it.
4. Back up touched files and merge only authorized additions, avoiding duplicates.
Recognize equivalent existing entries (including Claude's legacy `Bash(rig:*)`);
leave their markers untouched. Reapplying the same choice must be a no-op when
the required entries already exist, including no timestamp-only rewrite.
The agent performs these edits; hand-editing is an option, not a required user
chore. Apply the selected scope without another conversational permission round.
Native enforcement still applies.
@@ -60,8 +98,15 @@ execution, an entire interpreter or a generic shell wrapper.
not destructive probes. Report effective settings and remaining prompts.
A parser match alone does not establish native behavior.
For rollback, remove only this setup's additions, preserving later unrelated
edits. Builtin policy specs remain read-only; customize in user space.
Give the user this short undo: **“Undo the OpenRig command allowances added by
this setup; keep my other rules.”** The agent removes only the recorded entries
from their exact files, preserving pre-existing rules and subsequent edits.
For Codex remove those `prefix_rule` entries; for Claude remove those
`permissions.allow` entries. Delete a newly created rules file only if it still
contains solely this setup's additions. Never restore the entire backup over
later changes. Record the changed choice in the same context and verify native
reloading/revocation; other pre-existing allowances may still permit `rig`.
Builtin policy specs remain read-only; customize in user space.
## Codex command rules
@@ -103,8 +148,10 @@ codex execpolicy check --pretty --rules /absolute/path/to/openrig.rules -- print
```
Inspect matches, not only exit status; repeat `--rules` for other effective
files. A matching `prompt` or `forbidden` overrides allow. Verify rule loading
in the target conversation after the required reload. For project-only scope,
files. A matching `prompt` or `forbidden` overrides allow. The official procedure
loads `.rules` at session startup; a file edit alone does not reload this turn.
Preserve the conversation and use an authorized supported resume when needed,
then verify loading in the target conversation. For project-only scope,
confirm the layer is active there and absent from an unrelated project's active
layers. An evaluator given an explicit `--rules` file proves matching, not that
scope or automatic loading. Existing user-wide rules may already permit the
@@ -134,7 +181,9 @@ Current syntax uses ` *` for a command family; `Bash(rig:*)` is also supported.
Narrower examples are `Bash(rig ps *)` and `Bash(rig queue list *)`.
Preserve `deny`/`ask` entries and `defaultMode`; do not add `Bash(*)` or switch
to bypass to resolve a mismatch. Inspect native `/permissions` and verify the
actual command spelling.
actual command spelling. A bare rule does not cover every absolute invocation:
derive the actual executable and, after the stricter-rule check above, add its
exact `Bash(/actual/path/to/rig *)` spelling if needed. Do not use a path wildcard.
Choose scope using the [settings reference](https://code.claude.com/docs/en/settings):
`.claude/settings.local.json` for personal project settings,
@@ -143,6 +192,9 @@ Choose scope using the [settings reference](https://code.claude.com/docs/en/sett
Confirm the effective project root, especially for worktrees. Keep personal
settings out of commits. Managed restrictions and sandbox/network controls
still apply; a Bash allow is not a general network policy.
Current Claude settings documentation describes live reload of permission
edits. Confirm the rule's source in `/permissions` and repeated harmless calls
in the target conversation; do not claim prompt behavior from JSON validity.
## Existing policies and broader modes
@@ -154,9 +206,10 @@ is unavailable, explain the remaining choice instead of selecting broader access
For explicitly chosen broader operation, inspect
`rig policy current --spec <user-owned-rig.yaml>` and the compatible getting-started guide's **Opt-in permissive
operation** section. OpenRig's Codex `builtin:yolo` supplies `-s danger-full-access`;
it does not itself select `approval_policy` and replaces a named
`codex_config_profile` argument. Claude's corresponding launch flag is
operation** section. OpenRig's Codex `builtin:yolo` supplies
`-s danger-full-access -a never` and replaces a named `codex_config_profile`
argument. The legacy environment-only YOLO path selects only the sandbox.
Claude's corresponding launch flag is
`--dangerously-skip-permissions`. Neither a resource `profile: default` nor OpenRig
operating posture is a native permission policy. Do not change shipped defaults
or assume editing a launch spec changes an existing seat.
@@ -3,8 +3,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const skillRoot = path.resolve(new URL("..", import.meta.url).pathname);
const skillRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const restoreScript = path.join(skillRoot, "scripts", "restore-from-jsonl.mjs");
// P6(C) injectable output-root: the packet base defaults to the shared
// /tmp/claude-compaction-restore (production), but becomes per-run isolated when the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.6.1",
"version": "0.6.3",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -37,6 +37,31 @@ read [references/worked-example.md](references/worked-example.md). Installed cop
`rig context get skills/core/openrig-software-factory/references/worked-example.md`.
A roadmap, YAML file or wake does not execute work or authorize a new outcome.
## Choose the first project's providers
Ask which working account(s) the user wants: Claude Code, Codex, or both. Reuse
an explicit choice and recommend the account they already have working. Use
`first-project-claude` (two Claude), `first-project` (two Codex), or
`first-project-mixed` (Claude owner, Codex checker). All share the same task and
owner/checker culture. Check only selected CLIs/logins; request `claude auth login`
or `codex login` once when that selected login is missing. No credential copying,
unused provider prerequisite or silent model/provider fallback.
Read the compatible getting-started guide's **Choose your providers** and
**Kernel startup stays automatic** sections before launch. The choice selects
the two project agents. Kernel auto-boot independently uses available
authenticated accounts, so it may use both even when the project uses one.
Do not add an unused-provider login gate or manual kernel setup to this path.
An instance-wide provider restriction is a separate request. Preserve an
existing kernel and working user rigs.
Show the chosen recipe, resolved runtimes/models and exact
`rig up <starter> --cwd . --plan` / `rig up <starter> --cwd .` commands. Codex seats
retain `gpt-6-astra`; Claude seats use the configured native default without an
OpenRig model override. Confirm that model with the user and its availability;
verify the native session's actual model before consequential work. Use the
chosen rig name in owner/checker addresses throughout the same task and return.
## Establish the working agreement
Read the repository instructions, current work and desired user-visible result.
@@ -51,12 +76,18 @@ model turns, but delivered wakes and resumed work **can spend tokens**. Prefer
an event-driven wait to frequent empty reminders. Wakes cannot answer a user
question, clear a permission prompt or guarantee progress.
**Choose permissions before launching or assigning work.** Offer ordinary prompts,
persistent rules for selected commands, or deliberately broader access. Follow
[Applying a permission policy](#find-the-compatible-permission-guide) to perform
the user's choice and verify it in the target conversation. Preserve restrictions
and unrelated settings. A whole `rig` allowance covers every verb, not just reads;
it does not grant new product authority or change everyone else's defaults.
**Ask once before launching or assigning work:** “Allow your agents to run
OpenRig commands without repeated permission prompts?” **Yes — recommended** /
**No — keep prompts**. Reuse an existing explicit choice for this scope. Explain
that this covers all `rig` verbs, including starting/stopping agents and changing
configuration, at personal project scope unless the user explicitly chooses
user-wide sessions. It is not global YOLO or permission to invent work.
On an actual Yes, follow [Applying a permission policy](#find-the-compatible-permission-guide)
to add existing native rules, preserve stricter/unrelated settings, and verify
the target conversation. No or no answer leaves settings alone and continues
with existing prompts. Remember the explicit choice and exact additions in the
existing onboarding context; “Undo the OpenRig command allowances added by this
setup” removes only those additions. Broader access remains a separate opt-in.
Keep purpose, acceptance, decisions and evidence in existing project files.
Deliver selected context and obtain each seat's scope reaction; retrieval alone
@@ -0,0 +1,39 @@
# First project
Work in the repository selected at launch. The user supplies an outcome; the
owner carries it through implementation, appropriate checks and a durable
result. Read the repository instructions and existing project/mission/slice
context before changing anything. If there is no selected workflow, keep the
path light. Do not install a release process for a first task.
## Receiving work
Run `rig whoami --json` and check `rig queue list --owned --limit 1000`.
Claim an assigned row before working. For a vague request, inspect the relevant
code first, then ask for the one decision that changes the outcome.
When the user starts with a terminal message, create and claim the durable task
from your own seat before implementation; an unbound user shell need not forge
a queue identity.
For the first meaningful code change, ask dev-check for an independent check
of the exact diff and the behavior it promises. Subsequent checks should match
the consequence of the work. Hand off through `rig queue handoff`, with the
repository path, candidate commit or diff, checks run and any limits. Consult
`--help` for current syntax. Do not use chat text as the work record.
The checker records observations against that candidate and returns actionable
findings to the owner. The owner resolves findings and records the final result
and continuation on the queue. Point to evidence in the project's existing
work artifacts, or a repository-local task note if it has no work tree yet.
Do not mark a change accepted because another row closed.
## Boundaries and continuation
Keep local edits and commits within the assigned change. Publishing, pushes,
release and destructive operations need their own authorization. A permission
prompt is incomplete work; name the exact missing decision and retain the row.
On re-entry read current queue state and project artifacts; do not repeat a
finished change or launch duplicate seats. Explain the result in terms the
user can exercise, state what was not checked, and name the next useful action.
Leave the project ready for another outcome at the same owner address.
@@ -0,0 +1,30 @@
version: "0.2"
name: first-project-claude
summary: >
Two Claude Code seats in your repository: an outcome owner and an
independent checker. Give the owner one bounded change; follow the durable result and
continue at the same address. See docs/reference/getting-started.md.
# Culture mirrors first-project/CULTURE.md; equality is checked in first-project-providers.test.ts.
culture_file: CULTURE.md
pods:
- id: dev
label: Project
members:
- id: owner
agent_ref: "local:../../../agents/development/implementer"
runtime: claude-code
profile: default
cwd: "."
- id: check
agent_ref: "local:../../../agents/development/qa"
runtime: claude-code
profile: default
cwd: "."
edges:
- kind: delegates_to
from: owner
to: check
edges: []
@@ -0,0 +1,39 @@
# First project
Work in the repository selected at launch. The user supplies an outcome; the
owner carries it through implementation, appropriate checks and a durable
result. Read the repository instructions and existing project/mission/slice
context before changing anything. If there is no selected workflow, keep the
path light. Do not install a release process for a first task.
## Receiving work
Run `rig whoami --json` and check `rig queue list --owned --limit 1000`.
Claim an assigned row before working. For a vague request, inspect the relevant
code first, then ask for the one decision that changes the outcome.
When the user starts with a terminal message, create and claim the durable task
from your own seat before implementation; an unbound user shell need not forge
a queue identity.
For the first meaningful code change, ask dev-check for an independent check
of the exact diff and the behavior it promises. Subsequent checks should match
the consequence of the work. Hand off through `rig queue handoff`, with the
repository path, candidate commit or diff, checks run and any limits. Consult
`--help` for current syntax. Do not use chat text as the work record.
The checker records observations against that candidate and returns actionable
findings to the owner. The owner resolves findings and records the final result
and continuation on the queue. Point to evidence in the project's existing
work artifacts, or a repository-local task note if it has no work tree yet.
Do not mark a change accepted because another row closed.
## Boundaries and continuation
Keep local edits and commits within the assigned change. Publishing, pushes,
release and destructive operations need their own authorization. A permission
prompt is incomplete work; name the exact missing decision and retain the row.
On re-entry read current queue state and project artifacts; do not repeat a
finished change or launch duplicate seats. Explain the result in terms the
user can exercise, state what was not checked, and name the next useful action.
Leave the project ready for another outcome at the same owner address.
@@ -0,0 +1,31 @@
version: "0.2"
name: first-project-mixed
summary: >
A Claude Code owner and a Codex checker in your repository: an outcome owner and an
independent checker. Give the owner one bounded change; follow the durable result and
continue at the same address. See docs/reference/getting-started.md.
# Culture mirrors first-project/CULTURE.md; equality is checked in first-project-providers.test.ts.
culture_file: CULTURE.md
pods:
- id: dev
label: Project
members:
- id: owner
agent_ref: "local:../../../agents/development/implementer"
runtime: claude-code
profile: default
cwd: "."
- id: check
agent_ref: "local:../../../agents/development/qa"
runtime: codex
model: gpt-6-astra
profile: default
cwd: "."
edges:
- kind: delegates_to
from: owner
to: check
edges: []
@@ -15,7 +15,7 @@ profiles:
guidance: [role]
subagents: []
plugins: [shared:openrig-core]
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config, shared:claude-activity-hooks]
resources:
guidance:
@@ -15,7 +15,7 @@ profiles:
guidance: [role]
subagents: []
plugins: [shared:openrig-core]
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config, shared:claude-activity-hooks]
resources:
guidance:
@@ -15,7 +15,7 @@ profiles:
guidance: [role]
subagents: []
plugins: [shared:openrig-core]
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config, shared:claude-activity-hooks]
resources:
guidance:
@@ -464,7 +464,16 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
if (!targetDir) return true;
this.fs.mkdirp(targetDir);
const isDir = this.fs.listFiles ? this.fs.listFiles(entry.absolutePath).length > 0 : false;
let isDir = false;
if (this.fs.listFiles) {
try {
isDir = this.fs.listFiles(entry.absolutePath).length > 0;
} catch {
// File-shaped sources make the production listFiles (recursive
// fs.readdirSync walk) throw ENOTDIR — treat the entry as file-shaped.
isDir = false;
}
}
if (isDir && this.fs.listFiles) {
// Directory-shaped: recursive copy
@@ -204,7 +204,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
private applyCodexActivityHookTrust(content: string, configPath: string, relay: string): string {
let keySource = configPath;
try {
keySource = fs.realpathSync(configPath);
keySource = fs.realpathSync.native(configPath);
} catch {
// config.toml not on the real filesystem (first write / unit-test mock fs) — the plain
// absolute path is the honest best guess; a canonicalization delta is fail-safe (gate reappears).
@@ -563,7 +563,16 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
if (!targetDir) return true;
this.fs.mkdirp(targetDir);
const isDir = this.fs.listFiles ? this.fs.listFiles(entry.absolutePath).length > 0 : false;
let isDir = false;
if (this.fs.listFiles) {
try {
isDir = this.fs.listFiles(entry.absolutePath).length > 0;
} catch {
// File-shaped sources make the production listFiles (recursive
// fs.readdirSync walk) throw ENOTDIR — treat the entry as file-shaped.
isDir = false;
}
}
if (isDir && this.fs.listFiles) {
for (const file of this.fs.listFiles(entry.absolutePath)) {
@@ -1194,7 +1203,7 @@ function stripCodexActivityHooks(content: string): string {
"m"
);
if (!pattern.test(content)) return content;
return content.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
return content.replace(pattern, "\n");
}
/** Ensure `[features].hooks = true` (canonical key; not the deprecated codex_hooks alias). */
@@ -58,6 +58,11 @@ export function createRunnerInput(
const editor = readline.createInterface({
input: keys, output, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
});
// Node forces dumb-terminal mode without editing if TERM=dumb. When terminal: true
// was requested, remove the dumb-mode override so VT100 line editing is preserved.
if (Object.prototype.hasOwnProperty.call(editor, "_ttyWrite")) {
delete (editor as any)._ttyWrite;
}
const decoder = new StringDecoder("utf8");
let pending = "";
let paste: string | null = null;
@@ -388,7 +388,16 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession);
const targetDir = nodePath.join(agentDir, "skills", entry.effectiveId);
this.fs.mkdirp(targetDir);
const isDir = this.fs.listFiles ? this.fs.listFiles(entry.absolutePath).length > 0 : false;
let isDir = false;
if (this.fs.listFiles) {
try {
isDir = this.fs.listFiles(entry.absolutePath).length > 0;
} catch {
// File-shaped sources make the production listFiles (recursive
// fs.readdirSync walk) throw ENOTDIR — treat the entry as file-shaped.
isDir = false;
}
}
if (isDir && this.fs.listFiles) {
for (const file of this.fs.listFiles(entry.absolutePath)) {
const dest = nodePath.join(targetDir, file);
@@ -293,7 +293,16 @@ export class StubRuntimeAdapter implements RuntimeAdapter {
if (entry.category === "skill") {
const targetDir = nodePath.join(binding.cwd, ".openrig", "stub", "skills", entry.effectiveId);
this.fsOps.mkdirp(targetDir);
const isDir = this.fsOps.listFiles ? this.fsOps.listFiles(entry.absolutePath).length > 0 : false;
let isDir = false;
if (this.fsOps.listFiles) {
try {
isDir = this.fsOps.listFiles(entry.absolutePath).length > 0;
} catch {
// File-shaped sources make the production listFiles (recursive
// fs.readdirSync walk) throw ENOTDIR — treat the entry as file-shaped.
isDir = false;
}
}
if (isDir && this.fsOps.listFiles) {
for (const file of this.fsOps.listFiles(entry.absolutePath)) {
const dest = nodePath.join(targetDir, file);
+24 -2
View File
@@ -1,8 +1,9 @@
import { exec } from "node:child_process";
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
import type { ExecFn } from "./tmux.js";
import type { ArgvExecFn, ExecFn } from "./tmux.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
function extractExecOutput(err: unknown): string {
if (!err || typeof err !== "object") return "";
@@ -31,3 +32,24 @@ export const execCommand: ExecFn = async (cmd: string): Promise<string> => {
throw err;
}
};
/**
* Production ArgvExecFn for TmuxAdapter (the Windows/psmux path).
* Runs argv[0] directly via execFile: no shell, no quoting layer. argv[0]
* stays the literal "tmux" token — psmux ships a `tmux` alias on PATH, so
* binary resolution is identical to the tmux case.
*/
export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<string> => {
const [bin, ...args] = argv;
if (!bin) throw new Error("execArgvCommand: empty argv");
try {
const { stdout } = await execFileAsync(bin, args, { windowsHide: true });
return stdout;
} catch (err) {
const output = extractExecOutput(err);
if (err instanceof Error && output && !err.message.includes(output)) {
throw new Error(`${err.message}\n${output}`);
}
throw err;
}
};
+210 -81
View File
@@ -6,6 +6,15 @@ import { randomUUID } from "node:crypto";
export type ExecFn = (cmd: string) => Promise<string>;
/**
* Argv exec: run the multiplexer WITHOUT a shell (execFile-style — the
* binary is argv[0], the rest are verbatim arguments, no quoting layer).
* This is the Windows/psmux path: POSIX shells, single-quote quoting and
* `/dev/null` redirections do not exist there. Opt-in via the constructor;
* when absent every method uses the legacy shell-string path unchanged.
*/
export type ArgvExecFn = (argv: string[]) => Promise<string>;
/**
* Injectable file/buffer operations for `sendText`.
* Split out so tests can observe temp-file writes and unique-name generation
@@ -98,10 +107,16 @@ const SESSION_FORMAT = [
"#{session_created}",
"#{session_attached}",
].join(TMUX_FIELD_SEPARATOR);
const WINDOW_FORMAT = "#{window_index}\t#{window_name}\t#{window_panes}\t#{window_active}";
// tmux 3.6 sanitizes literal control characters in -F output to underscores,
// so tab-delimited session and pane rows become unparseable. Use a printable
// delimiter for these adapter-owned formats instead.
const WINDOW_FORMAT = [
"#{window_index}",
"#{window_name}",
"#{window_panes}",
"#{window_active}",
].join(TMUX_FIELD_SEPARATOR);
// tmux 3.6 sanitizes literal control characters in -F output to underscores, and
// tmux 3.3a replaces tabs with underscores, so tab-delimited rows become
// unparseable on those versions. EVERY adapter-owned format uses the printable
// delimiter instead.
const PANE_FORMAT = [
"#{pane_id}",
"#{pane_index}",
@@ -110,7 +125,16 @@ const PANE_FORMAT = [
"#{pane_height}",
"#{pane_active}",
].join(TMUX_FIELD_SEPARATOR);
const CLIENT_FORMAT = "#{client_name}\t#{client_session}";
const CLIENT_FORMAT = [
"#{client_name}",
"#{client_session}",
].join(TMUX_FIELD_SEPARATOR);
const CURSOR_FORMAT = [
"#{cursor_x}",
"#{cursor_y}",
"#{pane_width}",
"#{pane_height}",
].join(TMUX_FIELD_SEPARATOR);
function isNoServerError(err: unknown): boolean {
return err instanceof Error && err.message.includes("no server running");
@@ -177,6 +201,18 @@ function shellQuote(s: string): string {
return "'" + s.replace(/'/g, "'\"'\"'") + "'";
}
/** Elements safe to leave bare in a POSIX shell word list. */
const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
/** Join argv for a POSIX shell — the legacy string form. Only used when
* no ArgvExecFn is wired. Flags and safe words stay bare (matching the
* historical template strings); anything else is single-quoted. The argv
* arrays stay the single source of truth for what is EXECUTED on either
* path — this join only renders them for a shell. */
function posixJoinArgv(argv: string[]): string {
return argv.map((a) => (SHELL_SAFE.test(a) ? a : shellQuote(a))).join(" ");
}
function parseSessionLine(line: string): TmuxSession | null {
const parts = line.split(TMUX_FIELD_SEPARATOR);
if (parts.length < 4) return null;
@@ -191,27 +227,31 @@ function parseSessionLine(line: string): TmuxSession | null {
}
function parseClientLine(line: string): TmuxClient | null {
const parts = line.split("\t");
if (parts.length < 2) return null;
const name = parts[0]!;
if (name === "") return null;
// First-separator split keeps any separator inside the session name intact.
const separator = line.indexOf(TMUX_FIELD_SEPARATOR);
if (separator <= 0) return null;
const session = line.slice(separator + 1);
if (session === "") return null;
return {
name,
session: parts[1]!,
name: line.slice(0, separator),
session,
};
}
function parseWindowLine(line: string): TmuxWindow | null {
const parts = line.split("\t");
if (parts.length < 4) return null;
const index = parseInt(parts[0]!, 10);
const panes = parseInt(parts[2]!, 10);
const firstSeparator = line.indexOf(TMUX_FIELD_SEPARATOR);
const lastSeparator = line.lastIndexOf(TMUX_FIELD_SEPARATOR);
const panesSeparator = line.lastIndexOf(TMUX_FIELD_SEPARATOR, lastSeparator - 1);
if (firstSeparator < 0 || panesSeparator <= firstSeparator || lastSeparator <= panesSeparator) return null;
const index = parseInt(line.slice(0, firstSeparator), 10);
const panes = parseInt(line.slice(panesSeparator + 1, lastSeparator), 10);
if (isNaN(index) || isNaN(panes)) return null;
return {
index,
name: parts[1]!,
name: line.slice(firstSeparator + 1, panesSeparator),
panes,
active: parts[3] === "1",
active: line.slice(lastSeparator + 1) === "1",
};
}
@@ -249,13 +289,49 @@ export class TmuxAdapter {
* never authority over a pre-existing or registry-managed target. */
async createProbeSession(name: string, cwd?: string): Promise<TmuxResult> {
if (this.deliveryGuard?.maybeTarget(name)) return { ok: false, code: "guard_target_managed", message: "A probe cannot reuse a managed seat." };
const created = await this.createSessionUnchecked(name, cwd);
if (!created.ok) return created;
// #188: the create itself reports the new session's id and creation time, the only identity a rollback
// uses. An id alone is unique only for one server's lifetime.
let created: { id: string; at: string } | null;
try {
const out = await this.exec(`tmux new-session -d -P -F ${shellQuote("#{session_id} #{session_created}")} -s ${shellQuote(name)}${cwd != null ? ` -c ${shellQuote(cwd)}` : ""}`);
const match = /^(\$\d+) (\d+)$/.exec(out.trim());
created = match ? { id: match[1]!, at: match[2]! } : null;
} catch (err) {
return classifyWriteError(err);
}
let message = "New probe pane could not be established; no input written.";
let pane: string | null = null;
try {
const panes = await this.listPanes(name);
if (panes.length === 1) { this.freshProbes.set(name, panes[0]!.id); this.freshProbes.set(panes[0]!.id, panes[0]!.id); return created; }
pane = panes.length === 1 ? panes[0]!.id : null;
// #188: a fresh tmux server reuses pane ids, so a stale seat binding can name the new pane. That
// probe could not be cleaned up safely later, so it is not used.
if (pane && this.deliveryGuard?.maybeTarget(pane)) message = "New probe pane is named by a managed record; no input written.";
else if (pane) { this.freshProbes.set(name, pane); this.freshProbes.set(pane, pane); return { ok: true }; }
} catch { /* no target proof, no input */ }
return { ok: false, code: "guard_target_unknown", message: "New probe pane could not be established; no input written." };
// #188: remove the unusable helper by the id its own create returned, unless a managed record has
// since claimed its name (a binding naming only the reused pane id, under another session name, is
// stale: that pane belongs to this session). A name reused by another session has a different id.
const byPane = pane ? this.deliveryGuard?.maybeTarget(pane) : null;
if (!created || this.deliveryGuard?.maybeTarget(name) || byPane?.session === name) {
return { ok: false, code: "guard_target_unknown", message: `${message} The helper session was left in place: its ownership could not be proven.` };
}
const removed = await this.removeCreatedSession(name, created);
if (!removed.ok && removed.code !== "session_not_found") message += ` The helper session was not removed: ${removed.message}`;
return { ok: false, code: "guard_target_unknown", message };
}
/** #188: kill the session this adapter created only while its id still names that session. tmux checks
* the name and creation time and kills in one command, so a later server that reuses the id is safe. */
private async removeCreatedSession(name: string, created: { id: string; at: string }): Promise<TmuxResult> {
const same = `#{&&:#{==:#{session_name},${name}},#{==:#{session_created},${created.at}}}`;
try {
const out = await this.exec(`tmux if-shell -F -t ${shellQuote(created.id)} ${shellQuote(same)} ${shellQuote(`kill-session -t '${created.id}'`)} ${shellQuote("display-message -p kept")}`);
if (out.includes("kept")) return { ok: false, code: "guard_target_changed", message: `its id ${created.id} no longer names it (it may already be gone); nothing was killed.` };
return { ok: true };
} catch (err) {
return classifyWriteError(err);
}
}
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void) => Promise<TmuxResult>, allowAbsent = false): Promise<TmuxResult> {
@@ -266,7 +342,8 @@ export class TmuxAdapter {
if (probePane && !guard.maybeTarget(target)) {
const panes = await this.listPanes(target);
if (panes.length !== 1 || panes[0]!.id !== probePane) throw new Error("Private probe target changed; no input written.");
return write(probePane, () => {});
// Awaited so a refusal inside the write (#188: the probe kill) returns as a result, not a throw.
return await write(probePane, () => {});
}
const created = this.freshManaged.get(target);
const identity = created?.nodeId ?? target;
@@ -305,14 +382,35 @@ export class TmuxAdapter {
return this.deliveryGuard ? this.deliveryGuard.operation(target, fn) : fn();
}
constructor(private exec: ExecFn, private fileOps: TmuxFileOps = defaultTmuxFileOps()) {}
constructor(
private exec: ExecFn,
private fileOps: TmuxFileOps = defaultTmuxFileOps(),
private argvExec?: ArgvExecFn,
) {}
/**
* Run a multiplexer command. `argv` is what is EXECUTED on both paths:
* elements are verbatim (no quoting layer) when argvExec is wired;
* otherwise they are POSIX-joined — byte-identical to the legacy
* template strings for all current call sites (the suite pins them).
* `legacy` overrides the joined form only where history quoted
* differently (the `-F "..."` double-quoted formats); pass it ONLY to
* preserve an exact legacy string, never to smuggle new semantics.
*/
private async run(argv: string[], legacy?: string): Promise<string> {
if (this.argvExec) return this.argvExec(argv);
return this.exec(legacy ?? posixJoinArgv(argv));
}
/** Start an empty native terminal server, without inventing a seat/session. */
async startServer(): Promise<TmuxResult> {
const probeName = `openrig-startup-${randomUUID()}`;
try {
if ((await this.probeSession(probeName)).state !== "transport_unavailable") return { ok: true };
// tmux -D keeps an empty server alive. Native socket ownership arbitrates
// tmux -D keeps an empty server alive. Stays on the shell-string path
// deliberately: detach + stdio redirection have no argv equivalent,
// and server bootstrap is POSIX-specific (psmux auto-starts its own).
// Native socket ownership arbitrates
// concurrent starts; the readback below, not shell exit, proves availability.
await this.exec("tmux -D </dev/null >/dev/null 2>&1 &");
for (let attempt = 0; attempt < 20; attempt++) {
@@ -327,7 +425,8 @@ export class TmuxAdapter {
async listSessions(): Promise<TmuxSession[]> {
try {
const output = await this.exec(`tmux list-sessions -F "${SESSION_FORMAT}"`);
const output = await this.run(["tmux", "list-sessions", "-F", SESSION_FORMAT],
`tmux list-sessions -F "${SESSION_FORMAT}"`);
return parseLines(output, parseSessionLine);
} catch (err) {
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
@@ -337,7 +436,8 @@ export class TmuxAdapter {
async listWindows(sessionName: string): Promise<TmuxWindow[]> {
try {
const output = await this.exec(`tmux list-windows -t ${shellQuote(sessionName)} -F "${WINDOW_FORMAT}"`);
const output = await this.run(["tmux", "list-windows", "-t", sessionName, "-F", WINDOW_FORMAT],
`tmux list-windows -t ${shellQuote(sessionName)} -F "${WINDOW_FORMAT}"`);
return parseLines(output, parseWindowLine);
} catch (err) {
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
@@ -347,7 +447,8 @@ export class TmuxAdapter {
async listPanes(target: string): Promise<TmuxPane[]> {
try {
const output = await this.exec(`tmux list-panes -t ${shellQuote(target)} -F "${PANE_FORMAT}"`);
const output = await this.run(["tmux", "list-panes", "-t", target, "-F", PANE_FORMAT],
`tmux list-panes -t ${shellQuote(target)} -F "${PANE_FORMAT}"`);
return parseLines(output, parsePaneLine);
} catch (err) {
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
@@ -370,7 +471,8 @@ export class TmuxAdapter {
// Use `tmux has-session` directly for reliable existence check — avoids
// parsing format-string output from `list-sessions` which can fail when
// tab delimiters are malformed across tmux versions.
await this.exec(`tmux has-session -t ${shellQuote(name)}`);
await this.run(["tmux", "has-session", "-t", name],
`tmux has-session -t ${shellQuote(name)}`);
return { state: "present" }; // exit 0 = session exists
} catch (err) {
if (isSessionAbsenceError(err)) {
@@ -412,13 +514,14 @@ export class TmuxAdapter {
finishLaunchBinding(session: string): void { this.freshManaged.delete(session); }
private async createSessionUnchecked(name: string, cwd?: string, env?: Record<string, string>): Promise<TmuxResult> {
const cwdFlag = cwd != null ? ` -c ${shellQuote(cwd)}` : "";
const envFlags = env
? Object.entries(env).map(([k, v]) => ` -e ${shellQuote(`${k}=${v}`)}`).join("")
: "";
const cmd = `tmux new-session -d -s ${shellQuote(name)}${cwdFlag}${envFlags}`;
const argv = ["tmux", "new-session", "-d", "-s", name];
if (cwd != null) argv.push("-c", cwd);
if (env) for (const [k, v] of Object.entries(env)) argv.push("-e", `${k}=${v}`);
const legacyParts = ["tmux", "new-session", "-d", "-s", shellQuote(name)];
if (cwd != null) legacyParts.push("-c", shellQuote(cwd));
if (env) for (const [k, v] of Object.entries(env)) legacyParts.push("-e", shellQuote(`${k}=${v}`));
try {
await this.exec(cmd);
await this.run(argv, legacyParts.join(" "));
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -450,17 +553,20 @@ export class TmuxAdapter {
let bufferLoaded = false;
try {
await this.fileOps.writeFile(path, text);
await this.exec(`tmux load-buffer -b ${shellQuote(buffer)} ${shellQuote(path)}`);
await this.run(["tmux", "load-buffer", "-b", buffer, path],
`tmux load-buffer -b ${shellQuote(buffer)} ${shellQuote(path)}`);
bufferLoaded = true;
beforeWrite();
await this.exec(`tmux paste-buffer -t ${shellQuote(target)} -b ${shellQuote(buffer)} -d -r -p`);
await this.run(["tmux", "paste-buffer", "-t", target, "-b", buffer, "-d", "-r", "-p"],
`tmux paste-buffer -t ${shellQuote(target)} -b ${shellQuote(buffer)} -d -r -p`);
return { ok: true };
} catch (err) {
if (bufferLoaded) {
// paste failed after load - `-d` never ran, so the buffer is still
// resident. Best-effort delete to avoid leaking it.
try {
await this.exec(`tmux delete-buffer -b ${shellQuote(buffer)}`);
await this.run(["tmux", "delete-buffer", "-b", buffer],
`tmux delete-buffer -b ${shellQuote(buffer)}`);
} catch { /* best-effort cleanup */ }
}
return classifyWriteError(err);
@@ -480,7 +586,11 @@ export class TmuxAdapter {
* A shell that never consumes the invocation leaves the file for diagnosis.
*/
async sendShellCommand(target: string, command: string, beforeInput?: () => void): Promise<TmuxResult> {
return this.guardedInput(target, pane => this.sendShellCommandUnchecked(pane, command, beforeInput));
// Keep the managed selector for nested paste/Enter checks. A resolved pane
// ID can also occur in detached bindings after a tmux restart; resolving it
// again would lose the unambiguous session/node and its existing lease.
// Each nested write still validates the lease and targets its observed pane.
return this.guardedInput(target, () => this.sendShellCommandUnchecked(target, command, beforeInput));
}
private async sendShellCommandUnchecked(target: string, command: string, beforeInput?: () => void): Promise<TmuxResult> {
@@ -519,9 +629,9 @@ export class TmuxAdapter {
}
private async sendKeysUnchecked(target: string, keys: string[]): Promise<TmuxResult> {
const cmd = `tmux send-keys -t ${shellQuote(target)} ${keys.map(shellQuote).join(" ")}`;
try {
await this.exec(cmd);
await this.run(["tmux", "send-keys", "-t", target, ...keys],
`tmux send-keys -t ${shellQuote(target)} ${keys.map(shellQuote).join(" ")}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -529,9 +639,9 @@ export class TmuxAdapter {
}
async setWindowOption(target: string, option: string, value: string): Promise<TmuxResult> {
const cmd = `tmux set-option -w -t ${shellQuote(target)} ${shellQuote(option)} ${shellQuote(value)}`;
try {
await this.exec(cmd);
await this.run(["tmux", "set-option", "-w", "-t", target, option, value],
`tmux set-option -w -t ${shellQuote(target)} ${shellQuote(option)} ${shellQuote(value)}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -545,9 +655,8 @@ export class TmuxAdapter {
if (!Number.isFinite(rows) || !Number.isInteger(rows) || rows < 1) {
return { ok: false, code: "validation_error", message: `resizeWindow: rows must be a positive integer, got ${rows}` };
}
const cmd = `tmux resize-window -t ${shellQuote(target)} -x ${cols} -y ${rows}`;
try {
await this.exec(cmd);
await this.run(["tmux", "resize-window", "-t", target, "-x", String(cols), "-y", String(rows)]);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -557,7 +666,7 @@ export class TmuxAdapter {
async killSession(name: string): Promise<TmuxResult> {
if (this.deliveryGuard) {
return this.guardedInput(name, async pane => {
const stdout = await this.exec(`tmux display-message -p -t ${shellQuote(pane)} '#{session_id}'`);
const stdout = await this.run(["tmux", "display-message", "-p", "-t", pane, "#{session_id}"], `tmux display-message -p -t ${shellQuote(pane)} '#{session_id}'`);
const sessionId = stdout.trim();
if (!/^\$\d+$/.test(sessionId)) return { ok: false, code: "guard_target_unknown", message: "Cannot establish immutable session identity; no session killed." };
const kill = async () => {
@@ -573,9 +682,9 @@ export class TmuxAdapter {
}
private async killSessionUnchecked(name: string): Promise<TmuxResult> {
const cmd = `tmux kill-session -t ${shellQuote(name)}`;
try {
await this.exec(cmd);
await this.run(["tmux", "kill-session", "-t", name],
`tmux kill-session -t ${shellQuote(name)}`);
const pane = this.freshProbes.get(name);
this.freshProbes.delete(name);
if (pane) this.freshProbes.delete(pane);
@@ -612,14 +721,17 @@ export class TmuxAdapter {
}
private async respawnPaneUnchecked(paneTarget: string, command?: string, opts?: { cwd?: string; env?: Record<string, string> }): Promise<TmuxResult> {
const cwdFlag = opts?.cwd != null ? ` -c ${shellQuote(opts.cwd)}` : "";
const envFlags = opts?.env
? Object.entries(opts.env).map(([k, v]) => ` -e ${shellQuote(`${k}=${v}`)}`).join("")
: "";
const commandArg = command != null && command.length > 0 ? ` ${shellQuote(command)}` : "";
const cmd = `tmux respawn-pane -t ${shellQuote(paneTarget)}${cwdFlag}${envFlags}${commandArg}`;
const argv = ["tmux", "respawn-pane", "-t", paneTarget];
if (opts?.cwd != null) argv.push("-c", opts.cwd);
if (opts?.env) for (const [k, v] of Object.entries(opts.env)) argv.push("-e", `${k}=${v}`);
// The respawn command is ONE argv unit: tmux runs it via the shell.
if (command != null && command.length > 0) argv.push(command);
const legacyRespawn = ["tmux", "respawn-pane", "-t", shellQuote(paneTarget)];
if (opts?.cwd != null) legacyRespawn.push("-c", shellQuote(opts.cwd));
if (opts?.env) for (const [k, v] of Object.entries(opts.env)) legacyRespawn.push("-e", shellQuote(`${k}=${v}`));
if (command != null && command.length > 0) legacyRespawn.push(shellQuote(command));
try {
await this.exec(cmd);
await this.run(argv, legacyRespawn.join(" "));
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -631,9 +743,9 @@ export class TmuxAdapter {
* Set to `on` BEFORE the retiree is signalled to exit (else the pane is destroyed on exit and there
* is nothing to respawn into). */
async setRemainOnExit(paneTarget: string, on: boolean): Promise<TmuxResult> {
const cmd = `tmux set-option -p -t ${shellQuote(paneTarget)} remain-on-exit ${on ? "on" : "off"}`;
try {
await this.exec(cmd);
await this.run(["tmux", "set-option", "-p", "-t", paneTarget, "remain-on-exit", on ? "on" : "off"],
`tmux set-option -p -t ${shellQuote(paneTarget)} remain-on-exit ${on ? "on" : "off"}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -644,7 +756,8 @@ export class TmuxAdapter {
* remain-on-exit)? A known-missing pane also proves physical cutover; unknown probe errors stay false. */
async isPaneDead(paneId: string): Promise<boolean> {
try {
const output = await this.exec(`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_dead}"`);
const output = await this.run(["tmux", "display-message", "-p", "-t", paneId, "#{pane_dead}"],
`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_dead}"`);
return output.trim() === "1";
} catch (error) {
return isNoServerError(error) || isPaneAbsenceError(error);
@@ -653,7 +766,9 @@ export class TmuxAdapter {
/** Seat-handover cutover: signal the pane's foreground process (the retiree) — `TERM` for the graceful
* exit-in-place, `KILL` for the bounded-timeout force fallback. Resolves the pane pid then `kill`s it;
* an unresolvable pid is a structured, non-throwing failure. */
* an unresolvable pid is a structured, non-throwing failure.
* Stays on the shell-string path deliberately: this is a Unix `kill`,
* not a multiplexer command (Windows needs taskkill — named follow-up). */
async signalPaneProcess(paneId: string, signal: "TERM" | "KILL"): Promise<TmuxResult> {
return this.guardedInput(paneId, (pane, beforeWrite) => this.signalPaneProcessUnchecked(pane, signal, beforeWrite));
}
@@ -675,7 +790,8 @@ export class TmuxAdapter {
/** Get the PID of the foreground process in a pane. Returns null if unavailable. */
async getPanePid(paneId: string): Promise<number | null> {
try {
const output = await this.exec(`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_pid}"`);
const output = await this.run(["tmux", "display-message", "-p", "-t", paneId, "#{pane_pid}"],
`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_pid}"`);
const trimmed = output.trim();
const parsed = parseInt(trimmed, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
@@ -689,7 +805,7 @@ export class TmuxAdapter {
* command the pane was created with. Returns null if unavailable (caller falls back). */
async getDefaultShell(): Promise<string | null> {
try {
const output = await this.exec(`tmux show-options -gv default-shell`);
const output = await this.run(["tmux", "show-options", "-gv", "default-shell"]);
const trimmed = output.trim();
return trimmed || null;
} catch {
@@ -700,7 +816,8 @@ export class TmuxAdapter {
/** Get the current foreground command in a pane. Returns null if unavailable. */
async getPaneCommand(paneId: string): Promise<string | null> {
try {
const output = await this.exec(`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_current_command}"`);
const output = await this.run(["tmux", "display-message", "-p", "-t", paneId, "#{pane_current_command}"],
`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_current_command}"`);
const trimmed = output.trim();
return trimmed || null;
} catch {
@@ -715,7 +832,8 @@ export class TmuxAdapter {
* nonzero lookup exit. */
async hasSessionEnv(sessionName: string, varName: string): Promise<boolean | null> {
try {
const output = await this.exec(`tmux show-environment -t ${shellQuote(sessionName)}`);
const output = await this.run(["tmux", "show-environment", "-t", sessionName],
`tmux show-environment -t ${shellQuote(sessionName)}`);
const prefix = `${varName}=`;
return output.split(/\r?\n/).some(
(line) => line.startsWith(prefix) && line.slice(prefix.length).trim().length > 0,
@@ -730,9 +848,13 @@ export class TmuxAdapter {
// Shell-quote the path for safe injection into the pipe-pane command.
// The entire pipe command is passed as a single argument to tmux,
// which executes it via sh -c. We use shellQuote on the path.
const cmd = `tmux pipe-pane -t ${shellQuote(sessionName)} ${shellQuote("cat >> " + shellQuote(outputPath))}`;
// The sink travels as ONE argv unit: the multiplexer runs it via a
// shell, so its inner quoting stays POSIX here (psmux documents the same
// `cat >` server-side form for its own path).
const sink = "cat >> " + shellQuote(outputPath);
try {
await this.exec(cmd);
await this.run(["tmux", "pipe-pane", "-t", sessionName, sink],
`tmux pipe-pane -t ${shellQuote(sessionName)} ${shellQuote(sink)}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -741,9 +863,9 @@ export class TmuxAdapter {
/** Stop pipe-pane on a session. */
async stopPipePane(sessionName: string): Promise<TmuxResult> {
const cmd = `tmux pipe-pane -t ${shellQuote(sessionName)}`;
try {
await this.exec(cmd);
await this.run(["tmux", "pipe-pane", "-t", sessionName],
`tmux pipe-pane -t ${shellQuote(sessionName)}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -753,7 +875,8 @@ export class TmuxAdapter {
/** Capture pane content (last N lines). Returns null if unavailable. */
async capturePaneContent(paneId: string, lines: number = 20): Promise<string | null> {
try {
const output = await this.exec(`tmux capture-pane -p -t ${shellQuote(paneId)} -S -${lines}`);
const output = await this.run(["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
`tmux capture-pane -p -t ${shellQuote(paneId)} -S -${lines}`);
return output || null;
} catch {
return null;
@@ -768,7 +891,8 @@ export class TmuxAdapter {
*/
async capturePaneScreen(paneId: string): Promise<string | null> {
try {
const output = await this.exec(`tmux capture-pane -p -t ${shellQuote(paneId)}`);
const output = await this.run(["tmux", "capture-pane", "-p", "-t", paneId],
`tmux capture-pane -p -t ${shellQuote(paneId)}`);
return output || null;
} catch {
return null;
@@ -783,10 +907,11 @@ export class TmuxAdapter {
*/
async getPaneCursorPosition(paneId: string): Promise<TmuxCursorPosition | null> {
try {
const output = await this.exec(
`tmux display-message -p -t ${shellQuote(paneId)} "#{cursor_x}\t#{cursor_y}\t#{pane_width}\t#{pane_height}"`,
const output = await this.run(
["tmux", "display-message", "-p", "-t", paneId, CURSOR_FORMAT],
`tmux display-message -p -t ${shellQuote(paneId)} "${CURSOR_FORMAT}"`,
);
const [xRaw, yRaw, widthRaw, heightRaw] = output.trim().split("\t");
const [xRaw, yRaw, widthRaw, heightRaw] = output.trim().split(TMUX_FIELD_SEPARATOR);
const x = Number.parseInt(xRaw ?? "", 10);
const y = Number.parseInt(yRaw ?? "", 10);
const width = Number.parseInt(widthRaw ?? "", 10);
@@ -808,9 +933,9 @@ export class TmuxAdapter {
* scopes are never crossed (guard b2).
*/
async setSessionOption(sessionName: string, key: string, value: string): Promise<TmuxResult> {
const cmd = `tmux set-option -t ${shellQuote(sessionName)} ${shellQuote(key)} ${shellQuote(value)}`;
try {
await this.exec(cmd);
await this.run(["tmux", "set-option", "-t", sessionName, key, value],
`tmux set-option -t ${shellQuote(sessionName)} ${shellQuote(key)} ${shellQuote(value)}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -825,9 +950,9 @@ export class TmuxAdapter {
* Used for `set-clipboard` / `copy-command`.
*/
async setServerOption(option: string, value: string): Promise<TmuxResult> {
const cmd = `tmux set-option -s ${shellQuote(option)} ${shellQuote(value)}`;
try {
await this.exec(cmd);
await this.run(["tmux", "set-option", "-s", option, value],
`tmux set-option -s ${shellQuote(option)} ${shellQuote(value)}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -841,7 +966,8 @@ export class TmuxAdapter {
*/
async showServerOption(option: string): Promise<string | null> {
try {
const output = await this.exec(`tmux show-options -sv ${shellQuote(option)}`);
const output = await this.run(["tmux", "show-options", "-sv", option],
`tmux show-options -sv ${shellQuote(option)}`);
const v = output.trim();
return v.length > 0 ? v : null;
} catch {
@@ -852,7 +978,8 @@ export class TmuxAdapter {
/** Get a session-scoped user option value. Returns null if not set or error. */
async getSessionOption(sessionName: string, key: string): Promise<string | null> {
try {
const output = await this.exec(`tmux show-option -v -t ${shellQuote(sessionName)} ${shellQuote(key)}`);
const output = await this.run(["tmux", "show-option", "-v", "-t", sessionName, key],
`tmux show-option -v -t ${shellQuote(sessionName)} ${shellQuote(key)}`);
return output.trim() || null;
} catch {
return null;
@@ -882,7 +1009,8 @@ export class TmuxAdapter {
*/
async readPaneLastActivity(paneId: string): Promise<number | null> {
try {
const output = await this.exec(
const output = await this.run(
["tmux", "display-message", "-p", "-t", paneId, "#{window_activity}"],
`tmux display-message -p -t ${shellQuote(paneId)} '#{window_activity}'`,
);
const trimmed = output.trim();
@@ -905,7 +1033,8 @@ export class TmuxAdapter {
*/
async listClients(): Promise<TmuxClient[]> {
try {
const output = await this.exec(`tmux list-clients -F "${CLIENT_FORMAT}"`);
const output = await this.run(["tmux", "list-clients", "-F", CLIENT_FORMAT],
`tmux list-clients -F "${CLIENT_FORMAT}"`);
return parseLines(output, parseClientLine);
} catch (err) {
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
@@ -920,9 +1049,9 @@ export class TmuxAdapter {
* kills, or rebinds a session and never touches OpenRig routing/identity.
*/
async switchClient(client: string, target: string): Promise<TmuxResult> {
const cmd = `tmux switch-client -c ${shellQuote(client)} -t ${shellQuote(target)}`;
try {
await this.exec(cmd);
await this.run(["tmux", "switch-client", "-c", client, "-t", target],
`tmux switch-client -c ${shellQuote(client)} -t ${shellQuote(target)}`);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
@@ -15,7 +15,7 @@ import { resolvePackage, type ResolveResult } from "./package-resolve-helper.js"
import type { BootstrapStatus } from "./bootstrap-types.js";
// TODO: AS-T12 — migrate to pod-aware bundle source resolver
import type { LegacyBundleSourceResolver as BundleSourceResolver, BundleResolvedSource } from "./bundle-source-resolver.js";
import type { PodBundleSourceResolver } from "./bundle-source-resolver.js";
import { materializePodBundle, type PodBundleSourceResolver } from "./bundle-source-resolver.js";
import { unpack } from "./bundle-archive.js";
import { parsePodBundleManifest } from "./bundle-types.js";
import os from "node:os";
@@ -171,6 +171,24 @@ export class BootstrapOrchestrator {
}
} catch { /* YAML parse failure — let handlePodAwareSpec deal with it */ }
// Apply with an explicit target: launch from a copy of the bundle in the
// target, not from the temp extraction removed in the finally below —
// otherwise `cwd: "."`, the spec dir and agent refs point at a deleted dir.
if (opts.mode === "apply" && opts.targetRoot) {
const targetRoot = nodePath.resolve(opts.targetRoot);
const materialized = materializePodBundle(podSource.tempDir, targetRoot);
if (!materialized.ok) {
const shown = materialized.conflicts.slice(0, 10).join(", ");
const more = materialized.conflicts.length > 10 ? ` (and ${materialized.conflicts.length - 10} more)` : "";
const msg = `Install target ${targetRoot} already has different content at bundle path(s): ${shown}${more}. Nothing was written. Choose an empty or dedicated --target directory.`;
stages.push({ stage: "resolve_spec", status: "failed", detail: { code: "target_conflict", error: msg, conflicts: materialized.conflicts } });
errors.push(msg);
this.deps.bootstrapRepo.updateRunStatus(run.id, "failed");
return { runId: run.id, status: "failed" as BootstrapStatus, stages, errors, warnings };
}
specDir = nodePath.dirname(nodePath.join(targetRoot, podSource.manifest.rigSpec));
}
return await this.handlePodAwareSpec(opts, run, rawYaml, specDir, stages, errors, warnings);
} finally {
if (podBundleTempDir) this.deps.podBundleSourceResolver.cleanup(podBundleTempDir);
@@ -662,7 +680,7 @@ export class BootstrapOrchestrator {
stages,
rigId: (outcome as { rigId: string }).rigId,
errors: [attentionMsg],
warnings,
warnings: [...warnings, ...((outcome as { warnings?: string[] }).warnings ?? [])],
};
}
const outErrors = outcome.code === "validation_failed" || outcome.code === "preflight_failed"
@@ -22,8 +22,8 @@ import nodePath from "node:path";
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
export interface SkillsRouterFsOps {
exists: (path: string) => boolean;
readFile: (path: string) => string;
writeFile: (path: string, content: string) => void;
/** Byte-for-byte copy; routed files are never decoded. */
copyFile: (src: string, dest: string) => void;
mkdirp: (path: string) => void;
}
@@ -125,8 +125,7 @@ export function routeSkills(input: RouteSkillsInput, fs: SkillsRouterFsOps): Rou
continue;
}
fs.mkdirp(nodePath.dirname(targetAbs));
const content = fs.readFile(sourceAbs);
fs.writeFile(targetAbs, content);
fs.copyFile(sourceAbs, targetAbs);
records.push({
declaredPath: declared,
status: "routed",
@@ -194,3 +194,53 @@ export class PodBundleSourceResolver {
}
}
}
/**
* Copy an extracted (already verified) pod bundle into a stable target root so
* the rig launched from it keeps a live spec dir, agent refs and `cwd: "."`
* after the temp extraction is removed.
*
* Refuses — writing nothing — when any bundle file would land on a path in the
* target that holds different content (or a directory). Identical files are
* left as they are, so re-installing the same bundle into the same target works.
*/
export function materializePodBundle(
extractedDir: string,
targetRoot: string,
): { ok: true } | { ok: false; conflicts: string[] } {
const files: string[] = [];
(function walk(dir: string, prefix: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? nodePath.join(prefix, entry.name) : entry.name;
if (entry.isDirectory()) walk(nodePath.join(dir, entry.name), rel);
else if (entry.isFile()) files.push(rel);
}
})(extractedDir, "");
const conflicts = new Set<string>();
for (const rel of files) {
const dest = nodePath.join(targetRoot, rel);
const stat = fs.statSync(dest, { throwIfNoEntry: false });
if (!stat) {
// A missing file is fine unless the nearest existing parent is not a directory.
let parent = nodePath.dirname(dest);
while (parent.length > targetRoot.length && !fs.existsSync(parent)) parent = nodePath.dirname(parent);
if (parent.length > targetRoot.length && !fs.statSync(parent).isDirectory()) {
conflicts.add(nodePath.relative(targetRoot, parent));
}
continue;
}
if (!stat.isFile() || !fs.readFileSync(dest).equals(fs.readFileSync(nodePath.join(extractedDir, rel)))) {
conflicts.add(rel);
}
}
if (conflicts.size > 0) return { ok: false, conflicts: [...conflicts] };
for (const rel of files) {
const dest = nodePath.join(targetRoot, rel);
if (fs.existsSync(dest)) continue;
fs.mkdirSync(nodePath.dirname(dest), { recursive: true });
fs.copyFileSync(nodePath.join(extractedDir, rel), dest);
}
return { ok: true };
}
@@ -31,8 +31,8 @@ import nodePath from "node:path";
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
export interface WorkflowSpecsRouterFsOps {
exists: (path: string) => boolean;
readFile: (path: string) => string;
writeFile: (path: string, content: string) => void;
/** Byte-for-byte copy; routed files are never decoded. */
copyFile: (src: string, dest: string) => void;
mkdirp: (path: string) => void;
}
@@ -195,8 +195,7 @@ export function routeWorkflowSpecs(
});
continue;
}
const content = fs.readFile(sourceAbs);
fs.writeFile(targetAbs, content);
fs.copyFile(sourceAbs, targetAbs);
routedBasenames.add(basename);
records.push({
declaredPath: declared,
@@ -49,8 +49,11 @@ export class ClaudeManagedLaunch {
// Relative/empty PATH entries are interpreted at the intended seat cwd,
// including for /usr/bin/env shebangs inside the selected executable.
const search = PATH.split(path.delimiter).map(p => path.resolve(cwd, p));
const env: Record<string, string> = { PATH: search.join(path.delimiter), HOME,
CLAUDE_CONFIG_DIR: path.resolve(cwd, CLAUDE_CONFIG_DIR ?? path.join(HOME, ".claude")) };
const configDir = path.resolve(cwd, CLAUDE_CONFIG_DIR ?? path.join(HOME, ".claude"));
const env: Record<string, string> = { PATH: search.join(path.delimiter), HOME };
// Session storage needs a directory, but exporting the default changes
// Claude's global config selection. Preserve an unset native selection.
if (CLAUDE_CONFIG_DIR !== undefined) env.CLAUDE_CONFIG_DIR = configDir;
if (claudeClassicRendererEnvPrefix(this.rendererEnv)) env.CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN = "1";
let executable: string | undefined;
for (const dir of search) {
@@ -64,7 +67,7 @@ export class ClaudeManagedLaunch {
const s = statSync(file);
return [realpathSync(file), s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs];
};
return Object.freeze({ env: Object.freeze(env), executable,
return Object.freeze({ env: Object.freeze(env), configDir, executable,
fileIdentity: Object.freeze(identity(executable)), cwdIdentity: Object.freeze(identity(cwd).slice(0, 3)) });
}
@@ -108,7 +111,7 @@ export class ClaudeManagedLaunch {
...(generation ? { OPENRIG_OCCUPANT_GENERATION: generation } : {}) };
const assignments = Object.entries({ ...context.env, ...identity }).map(([key, value]) => shellQuote(`${key}=${value}`));
const forwarded = inherited.filter(key => !(key in identity)).map(key => `"${key}=\${${key}-}"`);
return Object.freeze({ assertCurrent, configDir: context.env.CLAUDE_CONFIG_DIR!, command: (args: readonly string[]) => {
return Object.freeze({ assertCurrent, configDir: context.configDir, command: (args: readonly string[]) => {
assertCurrent();
return `cd ${shellQuote(cwd)} && /usr/bin/env -i ${[...assignments, ...forwarded, shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}`;
} });
@@ -51,7 +51,8 @@ export class DiscoveryRepository {
`UPDATE discovered_sessions SET
tmux_window = ?, pid = ?, cwd = ?, active_command = ?,
runtime_hint = ?, confidence = ?, evidence_json = ?, config_json = ?,
last_seen_at = datetime('now'), status = 'active'
last_seen_at = datetime('now'),
status = CASE WHEN status = 'claimed' THEN 'claimed' ELSE 'active' END
WHERE id = ?`
).run(
data.tmuxWindow ?? null, data.pid ?? null, data.cwd ?? null, data.activeCommand ?? null,
@@ -146,6 +146,8 @@ export class FileWriteService {
try {
tmpFd = fs.openSync(tmpPath, "w");
fs.writeFileSync(tmpFd, req.content);
// Replacing the inode must preserve the existing file's ordinary permissions.
fs.fchmodSync(tmpFd, prevStat.mode & 0o777);
fs.fsyncSync(tmpFd);
} catch (err) {
// Clean up temp file if it was partially created.
+62 -2
View File
@@ -17,7 +17,9 @@
// rather than "daemon failed to start".
import nodePath from "node:path";
import { existsSync } from "node:fs";
import os from "node:os";
import { existsSync, readFileSync } from "node:fs";
import { parse as parseToml } from "smol-toml";
import type { RigRepository } from "./rig-repository.js";
import type { BootstrapOrchestrator } from "./bootstrap-orchestrator.js";
import type { EventBus } from "./event-bus.js";
@@ -168,14 +170,72 @@ export async function defaultProbeRuntimes(): Promise<RuntimeProbeResult> {
}
}
const codexHome = process.env.CODEX_HOME || nodePath.join(os.homedir(), ".codex");
const [claudeCode, codex] = await Promise.all([
tryProbe("claude auth status"),
tryProbe("codex login status"),
probeCodexReadiness({
run: tryProbe,
readConfig: () => {
try { return readFileSync(nodePath.join(codexHome, "config.toml"), "utf-8"); } catch { return null; }
},
env: process.env,
}),
]);
return { claudeCode, codex };
}
/** Issue #194 — how the Codex provider selected in `$CODEX_HOME/config.toml`
* authenticates. Only an explicit provider entry with
* `requires_openai_auth = false` and an `env_key` uses its credential
* variable. Everything else (no file, a parse error, a legacy top-level
* `profile`, a built-in or undeclared provider) keeps the OpenAI login check,
* so an unresolved config does not take the env-key shortcut; readiness still follows the login result. Other Codex config layers
* (project, system or managed config, requirements, `-c`, `--profile`) are not
* resolved here. The CLI's `rig setup` carries the same rule. */
export type CodexProviderAuth =
| { kind: "openai-login"; unresolved?: string }
| { kind: "env-key"; providerId: string; envKey: string };
export function selectCodexProviderAuth(configToml: string | null): CodexProviderAuth {
if (configToml === null) return { kind: "openai-login" };
let config: Record<string, unknown>;
try {
config = parseToml(configToml) as Record<string, unknown>;
} catch {
return { kind: "openai-login", unresolved: "config.toml could not be parsed" };
}
if (Object.hasOwn(config, "profile")) {
return { kind: "openai-login", unresolved: "config.toml selects a legacy profile, which is not resolved here" };
}
const providerId = config["model_provider"];
const providers = config["model_providers"];
if (typeof providerId !== "string" || !providers || typeof providers !== "object" || !Object.hasOwn(providers, providerId)) {
return { kind: "openai-login" };
}
const entry = (providers as Record<string, unknown>)[providerId];
if (!entry || typeof entry !== "object") return { kind: "openai-login" };
const { requires_openai_auth: requiresOpenAiAuth, env_key: envKey } = entry as Record<string, unknown>;
if (requiresOpenAiAuth !== false || typeof envKey !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envKey)) {
return { kind: "openai-login" };
}
return { kind: "env-key", providerId, envKey };
}
/** Codex half of the kernel runtime probe. An env-key provider is ready when
* its variable is nonblank and the executable answers; this is local
* credential availability, not a remote authentication or inference check. */
export async function probeCodexReadiness(deps: {
run: (cmd: string) => Promise<RuntimeAuthStatus>;
readConfig: () => string | null;
env: Record<string, string | undefined>;
}): Promise<RuntimeAuthStatus> {
const auth = selectCodexProviderAuth(deps.readConfig());
if (auth.kind === "openai-login") return deps.run("codex login status");
if (!deps.env[auth.envKey]?.trim()) return "unavailable";
return deps.run("codex --version");
}
/** Honest 3-part-error message for the auth-block path. Per IMPL-PRD
* §6.3 + the building-agent-software skill discipline. */
export function authBlockMessage(): string {
@@ -116,7 +116,8 @@ function scanHeaders(lines: string[]): HeaderScan {
const length = fenceMatch[1]!.length;
if (!fence) {
fence = { marker, length, line: i };
} else if (fence.marker === marker && length >= fence.length) {
} else if (fence.marker === marker && length >= fence.length
&& /^[ \t\r]*$/.test(line.slice(fenceMatch[0].length))) {
fence = null;
}
continue;
@@ -68,6 +68,24 @@ function codexResumeToken(args: string[]): string | null | undefined {
return null;
}
// Managed fresh/resume launches name the current Claude identity explicitly.
// A fork's --resume names its parent, so it cannot prove the new occupant.
function claudeSessionToken(args: string[]): string | null {
let token: string | null = null;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (index === 0 && /^\(\d+\.\d+\.\d+[^)]*\)$/.test(arg)) continue;
if (["--permission-mode", "--model", "--name"].includes(arg)) { index += 1; continue; }
if (/^--(?:permission-mode|model|name)=/.test(arg) || arg === "--dangerously-skip-permissions") continue;
const identity = arg.match(/^--(?:session-id|resume)(?:=(.*))?$/);
if (!identity) return null; // Unknown argv is not positive identity proof.
const value = identity[1] ?? args[++index];
if (token !== null || !value || value.startsWith("-")) return null;
token = value;
}
return token;
}
/** Require a live process in the pane's own lineage whose argv names both the
* declared runtime and the exact native resume identity. */
export function findExactNativeResumeProcess(
@@ -76,7 +94,7 @@ export function findExactNativeResumeProcess(
runtime: string | null,
expectedToken: string,
): NativeProcessRow | null {
if (runtime === "codex") return selectCodexProcess(processes, panePid, expectedToken, true)?.process ?? null;
if (runtime === "codex") return selectNativeProcess(processes, panePid, expectedToken, true)?.process ?? null;
if (runtime !== "claude-code") return null;
const byParent = new Map<number, NativeProcessRow[]>();
for (const process of processes) {
@@ -103,26 +121,35 @@ export function findExactNativeResumeProcess(
export async function listNativeProcesses(): Promise<NativeProcessRow[]> {
try {
const output = await runAsyncSite("codex.runtime.list_processes", async () => {
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,pgid,tpgid,ucomm,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 });
// lstart is locale-formatted; the child-only C locale keeps the English date the parser expects.
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,pgid,tpgid,ucomm,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } });
return stdout;
});
return output.split("\n").slice(1).flatMap((line) => {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/);
// ucomm may contain spaces on every platform: macOS app helpers (`Slack Helper`), and on
// Linux task names set by prctl(PR_SET_NAME) or process.title (`tmux: server`,
// `node (vitest 1)`). lstart always begins with a weekday word and runs to the year, and
// ucomm (16 bytes at most) is too short to contain such a date, so matching ucomm lazily
// up to the first date is exact.
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(.+?)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/);
return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), tpgid: Number(match[4]), executableName: match[5]!, startedAt: match[6]!, command: match[7]! }] : [];
});
} catch { return []; }
}
export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProcessRow[]>;
export type CodexProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string };
type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string };
export type CodexProcessObservation = NativeProcessObservation;
function selectCodexProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false): CodexProcessObservation | null {
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex"): NativeProcessObservation | null {
const byPid = new Map(rows.map((row) => [row.pid, row]));
const root = byPid.get(panePid);
if (byPid.size !== rows.length || !root?.startedAt || !root.tpgid || root.tpgid <= 0) return null;
const matches: { process: NativeProcessRow; chain: NativeProcessRow[] }[] = [];
const executable = runtime === "claude-code" ? "claude" : "codex";
for (const row of rows) {
if (row.executableName !== "codex" || executableName(tokens(row.command)[0] ?? "") !== "codex"
const osExecutable = runtime === "claude-code" ? executableName(row.executableName ?? "") : row.executableName;
if (osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable
|| row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
const chain: NativeProcessRow[] = [];
const visited = new Set<number>();
@@ -136,31 +163,46 @@ function selectCodexProcess(rows: NativeProcessRow[], panePid: number, expectedT
}
if (matches.length !== 1) return null;
const { process, chain } = matches[0]!;
const resumeToken = codexResumeToken(tokens(process.command).slice(1));
if (requireResume && !expectedToken) return null;
if ((requireResume || (expectedToken !== undefined && resumeToken !== undefined))
&& (!expectedToken || resumeToken !== expectedToken)) return null;
if (runtime === "claude-code") {
if (!expectedToken || claudeSessionToken(tokens(process.command).slice(1)) !== expectedToken) return null;
} else {
const resumeToken = codexResumeToken(tokens(process.command).slice(1));
if (requireResume && !expectedToken) return null;
if ((requireResume || (expectedToken !== undefined && resumeToken !== undefined))
&& (!expectedToken || resumeToken !== expectedToken)) return null;
}
return { panePid, process, fingerprint: JSON.stringify(chain.map((row) => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command])) };
}
export async function observeCodexPaneProcess(input: {
async function observeNativePaneProcess(input: {
target: string;
tmux: { getPanePid(target: string): Promise<number | null> };
listProcesses?: NativeProcessLister;
expectedToken?: string | null;
requireResume?: boolean;
}): Promise<CodexProcessObservation | null> {
}, runtime: NativeRuntime): Promise<NativeProcessObservation | null> {
try {
const pid = await input.tmux.getPanePid(input.target);
if (!pid) return null;
const rows = await (input.listProcesses ?? listNativeProcesses)();
return selectCodexProcess(rows, pid, input.expectedToken, input.requireResume);
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime);
} catch { return null; }
}
export async function observeCodexPaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<CodexProcessObservation | null> {
return observeNativePaneProcess(input, "codex");
}
export async function verifyCodexPaneProcess(input: Parameters<typeof observeCodexPaneProcess>[0]): Promise<CodexProcessObservation | null> {
const first = await observeCodexPaneProcess(input);
if (!first) return null;
const second = await observeCodexPaneProcess(input);
return second?.fingerprint === first.fingerprint ? second : null;
}
export async function verifyClaudePaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<NativeProcessObservation | null> {
const first = await observeNativePaneProcess(input, "claude-code");
if (!first) return null;
const second = await observeNativePaneProcess(input, "claude-code");
return second?.fingerprint === first.fingerprint ? second : null;
}
@@ -30,10 +30,11 @@ export interface OccupantInvalidatorDeps {
/** (e/Class-B) durable watchdog_jobs store — armed jobs registered by the retiring generation are
* stopped at swap (a stale wake into the successor's context is the ghost). Optional: absent ⇒ the
* watchdog branch is skipped (never a name-scoped fallback). */
watchdog?: { dropArmedByRegisteringGeneration(generationUuid: string): number };
watchdog?: { dropArmedByRegisteringGeneration(generationUuid: string, keepJobIds?: readonly string[]): number };
/** (e/Class-B) durable queue_items store — in-progress items claimed by the retiring generation are
* RELEASED to pending (never dropped: the role work is durable, the successor re-claims). Optional. */
queue?: { releaseClaimsByGeneration(generationUuid: string): number };
* RELEASED to pending (never dropped: the role work is durable, the successor re-claims). Its current
* park timers (blocked rows' wakes) are role work too, so the watchdog drop keeps them. Optional. */
queue?: { releaseClaimsByGeneration(generationUuid: string): number; currentParkTimerIds?(): string[] };
log?: (msg: string) => void;
}
@@ -65,8 +66,10 @@ export class DefaultOccupantInvalidator implements OccupantInvalidator {
// atom-B present → Class-B gen-scoped invalidation.
// Watchdog (3b): stop every ARMED job registered by the retiring generation — a stale wake firing
// into the successor's context is the specimen; the successor re-arms its own. Gen-scoped so the
// successor's OWN armed jobs (same name, live gen) are untouched.
const stopped = this.deps.watchdog?.dropArmedByRegisteringGeneration(retiringGeneration) ?? 0;
// successor's OWN armed jobs (same name, live gen) are untouched. A blocked row's current park timer
// is kept: it wakes the seat that owns the row, and nothing re-arms it if it stops.
const keepJobIds = this.deps.queue?.currentParkTimerIds?.() ?? [];
const stopped = this.deps.watchdog?.dropArmedByRegisteringGeneration(retiringGeneration, keepJobIds) ?? 0;
if (stopped > 0) {
log(
`[occupant-invalidator] Class-B: stopped ${stopped} armed watchdog job(s) registered by retired ` +
@@ -7,8 +7,10 @@ import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgent
import type { RigSpec, StartupBlock } from "./types.js";
export interface PodAssemblerFsOps extends AgentResolverFsOps {
/** Raw bytes, for files the bundle copies verbatim (agent packages, culture, docs, startup files). */
readFileBuffer(path: string): Uint8Array;
mkdirp(path: string): void;
writeFile(path: string, content: string): void;
writeFile(path: string, content: string | Uint8Array): void;
copyDir(src: string, dest: string): void;
listFiles(dirPath: string): string[];
}
@@ -214,7 +216,7 @@ export class PodBundleAssembler {
throw new Error(`Path traversal detected: "${relPath}" escapes rig root`);
}
if (!this.fs.exists(absPath)) return; // optional files may not exist
const content = this.fs.readFile(absPath);
const content = this.fs.readFileBuffer(absPath);
assertShippableSubstance([{ path: relPath, bytes: content }]);
this.fs.mkdirp(nodePath.dirname(nodePath.join(outputDir, relPath)));
this.fs.writeFile(nodePath.join(outputDir, relPath), content);
@@ -232,7 +234,7 @@ export class PodBundleAssembler {
const files = this.fs.listFiles(srcDir);
const sources = files.map((file) => ({
file,
content: this.fs.readFile(nodePath.join(srcDir, file)),
content: this.fs.readFileBuffer(nodePath.join(srcDir, file)),
}));
assertShippableSubstance(sources.map(({ file, content }) => ({
path: nodePath.join(relPrefix, file),
+15 -3
View File
@@ -9,7 +9,10 @@
// Otherwise the grace follows the latest meaningful queue change; an old note
// cannot keep the row working forever. Stalled-after-claim names this evidence
// without inferring idle/dead from age. Blocked remains parked; wake health is
// derived separately. Legacy callers without timestamps retain count semantics.
// derived separately. A CLOSED row is terminal: the stall timer answers "is a
// claimant holding this obligation", and no obligation survives closure — a
// done item must never contradict its own waiting.state with stalled-after-claim.
// Legacy callers without timestamps retain count semantics.
// Queue-row last_heartbeat is formally superseded (2026-08-30, S24 F-14); readers remain
// null-tolerant. Wiring reopens only for the 0.5.7 mechanized-pull turn-end hook that knows the in-flight row,
// the first honest row-scoped writer. daemon-lifecycle-store.recordHeartbeat remains live and distinct.
@@ -20,11 +23,17 @@ export const PICKUP_STALL_THRESHOLD_KEY = "queue.pickup_stall_threshold_minutes"
export const DEFAULT_PICKUP_STALL_THRESHOLD_MINUTES = 3;
export interface PickupReceipt {
state: "unclaimed" | "working" | "stalled-after-claim" | "parked";
/** Present iff stalled: the named evidence replacing the manual cross-surface join. */
state: "unclaimed" | "working" | "stalled-after-claim" | "parked" | "terminal";
/** Present iff stalled or terminal: the named evidence replacing the manual cross-surface join. */
evidence?: string;
}
/** The closed states. The waiting view already reports every state outside
* pending/in-progress/blocked as "none (terminal obligation)"; the pickup
* receipt must not contradict it with a stall timer. `blocked` is NOT closed
* (it reads parked, and its wake health is derived separately). */
const CLOSED_PICKUP_STATES = ["done", "failed", "denied", "canceled", "handed-off"] as const;
/** Threshold, FRESH-READ per call (the terminal.status_bar precedent: a config flip applies
* to the next read, no restart). Fail-open to the default on any resolution error. */
export function resolvePickupThresholdMinutes(): number {
@@ -54,6 +63,9 @@ export interface PickupFacts {
* S02 finding input) calls this same function, so the rule cannot drift between surfaces. */
export function derivePickup(facts: PickupFacts): PickupReceipt {
if (facts.state === "blocked") return { state: "parked" };
if ((CLOSED_PICKUP_STATES as readonly string[]).includes(facts.state)) {
return { state: "terminal", evidence: `closed (${facts.state})` };
}
if (!facts.claimedAt) return { state: "unclaimed" };
const now = facts.now ?? new Date();
const claimedMs = Date.parse(facts.claimedAt);
+16 -3
View File
@@ -25,7 +25,7 @@ import {
type ParkWakeStatus,
} from "./queue-wake-repository.js";
import { WatchdogJobsRepository } from "./watchdog-jobs-repository.js";
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait } from "./queue-wait-backoff.js";
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait, isQueueWait } from "./queue-wait-backoff.js";
export const QUEUE_STATES = [
"pending",
@@ -130,8 +130,9 @@ export interface QueueItem {
deliveryFailureDetail?: string;
tags: string[] | null;
blockedOn: string | null;
/** S04 — the DERIVED pickup receipt (unclaimed/working/stalled-after-claim/parked). Never
* stored: computed at projection time from claimed_at + the transition log + heartbeat. */
/** S04 — the DERIVED pickup receipt (unclaimed/working/stalled-after-claim/parked/terminal).
* Never stored: computed at projection time from state + claimed_at + the transition log
* + heartbeat; a closed row is terminal, never stalled. */
pickup?: PickupReceipt;
waiting?: WaitingView;
handedOffTo: string | null;
@@ -2848,6 +2849,12 @@ export class QueueRepository {
return this.wakeRepo.getStatus(qitemId);
}
/** Park timers a seat swap keeps: each is still its blocked row's current wake and still targets the
* row's owner (see QueueWakeRepository.currentParkTimerIds). */
currentParkTimerIds(): string[] {
return this.wakeRepo.currentParkTimerIds();
}
/** Refuse a legacy park-generated timer only when every row bound to it is
* terminal. Current exits retire these timers transactionally; this is the
* delivery-seam backstop for residue persisted by an older daemon. A timer
@@ -3384,6 +3391,12 @@ export class QueueRepository {
WHERE qitem_id = ?`
)
.run(fallbackDestination, ts, newChain, `fallback: ${reason}`, qitemId);
// A park timer targets the owner that parked the row. Once the row belongs to someone else it must not keep
// waking the old owner, so it ends here like any other exit from the park. A repeating wait is left running:
// it resolves its recipient from the row's current owner at delivery (evaluateQueueWait).
const armed = this.wakeRepo.getStatus(qitemId);
const armedJob = armed?.kind === "timer" ? (this.watchdogJobsRepo ?? new WatchdogJobsRepository(this.db)).getById(armed.ref) : null;
if (!armedJob || !isQueueWait(armedJob.specYaml)) this.retireParkGeneratedTimer(qitemId, "park_rerouted");
this.transitionLog.append({
qitemId,
@@ -174,6 +174,25 @@ export class QueueWakeRepository {
});
}
/** Active park-generated timers that are still their blocked row's current wake and still target the
* row's owner. These wake the seat that owns the row, not a particular occupant, so a seat swap keeps
* them; any other job the retiring occupant registered still stops. */
currentParkTimerIds(): string[] {
if (!this.available) return [];
return this.db.prepare(
`SELECT w.wake_ref
FROM queue_transition_wakes w
JOIN queue_items q ON q.qitem_id = w.qitem_id
JOIN watchdog_jobs j ON j.job_id = w.wake_ref
WHERE w.phase = 'armed' AND w.wake_kind = 'timer'
AND q.state = 'blocked' AND j.state = 'active' AND j.target_session = q.destination_session
AND w.transition_id = (
SELECT MAX(a.transition_id) FROM queue_transition_wakes a
WHERE a.qitem_id = w.qitem_id AND a.phase = 'armed'
)`,
).all().map((row) => (row as { wake_ref: string }).wake_ref);
}
private isLive(kind: ParkWakeKind, ref: string): boolean {
if (kind === "blocker") {
const row = this.db.prepare("SELECT state FROM queue_items WHERE qitem_id = ?").get(ref) as
@@ -1275,10 +1275,29 @@ export class RestoreOrchestrator {
// phase, where its durability primitives live. Deliberate fresh-primed
// launches are new histories and keep their replay.
const replayContained = resumeRequested && !!resumeToken;
const startupCtx = data.nodeStartupContext?.[node.id] ?? null;
const startupRuntime = startupCtx?.runtime ?? node.runtime ?? null;
const startupAdapter = startupRuntime ? opts?.adapters?.[startupRuntime] : undefined;
// A new pod-aware agent needs a startup context to run StartupOrchestrator.
// Do not report a healthy fresh-primed result when that context or adapter
// is missing; the pane would contain only a shell with no runtime process.
if (
isPodAware
&& launchResult
&& baseStatus !== "resumed"
&& startupRuntime !== null
&& startupRuntime !== "terminal"
&& (!startupCtx || !startupAdapter)
) {
const cause = startupCtx ? `no ${startupRuntime} runtime adapter` : "no startup context";
const error = `Harness not started: ${cause} for ${node.logicalId}.`;
warnings?.push(`Restore: ${error}`);
return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error };
}
// Attempt restore-safe startup replay if context available
if (data.nodeStartupContext && opts?.adapters && launchResult) {
const startupCtx = data.nodeStartupContext[node.id];
if (startupCtx) {
const adapter = opts.adapters[startupCtx.runtime];
if (adapter) {
@@ -331,7 +331,8 @@ export async function defaultListProcessesStrict(): Promise<Array<{ pid: number;
// lstart = the process START TIME — the identity half of pid+start-time
// (r1's pid-reuse remedy): a reused pid changes lstart, so a consumer
// holding last cycle's identity can invalidate without any extra spawn.
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 });
// lstart is locale-formatted; the child-only C locale keeps the English date the parser expects.
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } });
return stdout;
});
return output
@@ -5,6 +5,7 @@ import type { DiscoveryRepository } from "./discovery-repository.js";
import type { EventBus } from "./event-bus.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { QueueRepository } from "./queue-repository.js";
import { findOtherSessionOwner } from "./session-owner.js";
type ClaimedSessionRow = {
session_id: string;
@@ -63,6 +64,8 @@ export type RemoveNodeResult =
nodeId: string;
logicalId: string;
sessionsKilled: number;
/** #174: the session name belonged to another node's seat (logical id @ rig), so it was kept. */
sessionKeptFor?: string;
fallbackDestination?: string;
reroutedQitemIds: string[];
}
@@ -384,8 +387,15 @@ export class RigLifecycleService {
if (invalidFallback) return invalidFallback;
}
// #174: a session name can be reused by another rig's live seat (for example an archived duplicate
// of a live rig). When another unarchived node owns the name, that session and the queue work
// addressed to it are the other seat's: removal neither kills it nor routes its work. Owners in
// archived rigs don't count, since archiving keeps their stale bindings.
const sessionOwner = node.latest_session_name
? findOtherSessionOwner(this.db, node.latest_session_name, node.node_id, { ignoreArchived: true })
: null;
const activeQitemIds = this.activeQitemIdsForSessionNames(
node.latest_session_name ? [node.latest_session_name] : [],
node.latest_session_name && !sessionOwner ? [node.latest_session_name] : [],
);
if (activeQitemIds.length > 0 && fallbackDestination === undefined) {
return {
@@ -407,9 +417,10 @@ export class RigLifecycleService {
}
const preserveDetachedClaimedSession = node.latest_session_origin === "claimed" && node.latest_session_status === "detached";
const keepSession = preserveDetachedClaimedSession || sessionOwner !== null;
let sessionsKilled = 0;
if (node.latest_session_name && !preserveDetachedClaimedSession) {
if (node.latest_session_name && !keepSession) {
const kill = await this.tmuxAdapter?.killSession(node.latest_session_name);
if (kill && !kill.ok && kill.code !== "session_not_found") {
return {
@@ -426,7 +437,7 @@ export class RigLifecycleService {
const persisted: Array<{ type: "session.detached" | "node.removed"; seq: number; createdAt: string }> = [];
let rosterEvent: ReturnType<EventBus["persistWithinTransaction"]> | null = null;
const tx = this.db.transaction(() => {
if (node.latest_session_name && !preserveDetachedClaimedSession) {
if (node.latest_session_name && !keepSession) {
const detached = this.eventBus.persistWithinTransaction({
type: "session.detached",
rigId,
@@ -480,6 +491,7 @@ export class RigLifecycleService {
nodeId: node.node_id,
logicalId: node.logical_id,
sessionsKilled,
...(sessionOwner ? { sessionKeptFor: `${sessionOwner.logical_id}@${sessionOwner.rig_name}` } : {}),
...(fallbackDestination !== undefined ? { fallbackDestination } : {}),
reroutedQitemIds: activeQitemIds,
};
@@ -521,8 +533,14 @@ export class RigLifecycleService {
if (invalidFallback) return invalidFallback;
}
// #141: the same ownership rule as removeNode, applied to the whole shrink target. A member's session name
// that an unarchived node OUTSIDE the target owns (for example the live replacement of an archived
// generation) addresses that node's work, which this shrink neither counts nor reroutes. Another member of
// the target sharing the name doesn't take the name's work out of this check.
const activeQitemIds = this.activeQitemIdsForSessionNames(
nodes.flatMap((node) => node.latest_session_name ? [node.latest_session_name] : []),
nodes.flatMap((node) => node.latest_session_name
&& !findOtherSessionOwner(this.db, node.latest_session_name, node.id, { ignoreArchived: true, excludeNodeIds: ids })
? [node.latest_session_name] : []),
);
if (activeQitemIds.length > 0 && fallbackDestination === undefined) {
return {
@@ -549,6 +567,7 @@ export class RigLifecycleService {
logicalId: string;
status: "removed" | "failed";
sessionsKilled: number;
sessionKeptFor?: string;
error?: string;
}> = [];
for (const node of nodes) {
@@ -613,6 +632,7 @@ export class RigLifecycleService {
logicalId: removed.logicalId,
status: "removed",
sessionsKilled: removed.sessionsKilled,
...(removed.sessionKeptFor ? { sessionKeptFor: removed.sessionKeptFor } : {}),
});
}
@@ -517,6 +517,15 @@ export class RigRepository {
return rows.map((r) => this.rowToRig(r));
}
/** #174: seat-ref resolution sees only unarchived rigs, as default reads do since migration 042. */
findUnarchivedRigsByName(name: string): Rig[] {
const archived = this.hasRigColumn("archived_at") ? " AND archived_at IS NULL" : "";
const rows = this.db
.prepare(`SELECT * FROM rigs WHERE name = ?${archived} ORDER BY created_at`)
.all(name) as RigRow[];
return rows.map((r) => this.rowToRig(r));
}
getRigSummaries(filter?: RigArchiveFilter): Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null; hasServices: boolean; archivedAt: string | null }> {
const cond = archiveWhereClause("r.archived_at", filter);
const where = cond ? `WHERE ${cond}` : "";
@@ -6,7 +6,7 @@ import {
type ResolvedPolicyAttachment,
} from "./permission-policy/policy-ref.js";
import type { RigRepository } from "./rig-repository.js";
import { checkRunningNameGuard, makeRunningSessionCounter } from "./running-name-guard.js";
import { checkRunningNameGuard, confirmStoppedGenerations, makeRunningSessionCounter } from "./running-name-guard.js";
import type { SessionRegistry } from "./session-registry.js";
import type { EventBus } from "./event-bus.js";
import type { NodeLauncher } from "./node-launcher.js";
@@ -460,6 +460,16 @@ export type AddMemberOutcome =
* Pod-aware rig instantiator. Creates pods, nodes, edges, and runs
* startup orchestration per node with resolved agent specs.
*/
/** #141: rig names with a YAML import in progress, per daemon database. */
const importsInFlight = new WeakMap<Database.Database, Set<string>>();
/** #141: the same-name rigs changed between the stopped check and the create transaction. */
class GenerationChanged extends Error {
constructor(name: string) {
super(`The rigs named "${name}" changed while this import was checking them. Nothing was created; retry the import.`);
}
}
export class PodRigInstantiator {
readonly db: Database.Database;
private deps: PodInstantiatorDeps;
@@ -1220,6 +1230,32 @@ export class PodRigInstantiator {
}
async instantiate(rigSpecYaml: string, rigRoot: string, opts?: { cwdOverride?: string; force?: boolean; prelaunchHook?: (rigId: string) => Promise<{ ok: true } | { ok: false; code: string; message: string }> }): Promise<InstantiateOutcome> {
// #141: while an import may archive a stopped same-name generation, allow one import per rig name at
// a time on this daemon. Otherwise two imports could each replace it, or one could archive the other's
// in-progress replacement. Unrelated names are unaffected; an adapter that cannot probe keeps today's
// behavior.
let name: string | null = null;
if (this.deps.tmuxAdapter?.probeSession) {
try {
const parsed = PodRigSpecCodec.parse(rigSpecYaml) as { name?: unknown };
name = typeof parsed?.name === "string" ? parsed.name : null;
} catch { /* the import below reports the parse error */ }
}
if (!name) return this.instantiateOnce(rigSpecYaml, rigRoot, opts);
let inFlight = importsInFlight.get(this.db);
if (!inFlight) importsInFlight.set(this.db, (inFlight = new Set()));
if (inFlight.has(name)) {
return { ok: false, code: "generation_unconfirmed", message: `Another import of rig "${name}" is in progress. Nothing was created; retry once it finishes.` };
}
inFlight.add(name);
try {
return await this.instantiateOnce(rigSpecYaml, rigRoot, opts);
} finally {
inFlight.delete(name);
}
}
private async instantiateOnce(rigSpecYaml: string, rigRoot: string, opts?: { cwdOverride?: string; force?: boolean; prelaunchHook?: (rigId: string) => Promise<{ ok: true } | { ok: false; code: string; message: string }> }): Promise<InstantiateOutcome> {
// 1. Parse + validate
let rigSpec: PodRigSpec;
try {
@@ -1264,10 +1300,46 @@ export class PodRigInstantiator {
}
// 4. Create rig (after cycle check passes)
// #141: an explicit YAML import replacing a same-name rig archives the prior generations once they
// are confirmed stopped, atomically with creating the replacement, so they stop claiming its seat
// names. A same-name generation that can't be confirmed stopped is kept, and nothing is created.
let archivedGenerations: string[] = [];
const tmuxProbe = this.deps.tmuxAdapter?.probeSession?.bind(this.deps.tmuxAdapter);
const priorGenerations = tmuxProbe ? this.deps.rigRepo.findUnarchivedRigsByName(rigSpec.name).map((rig) => rig.id) : [];
if (priorGenerations.length > 0) {
const stopped = await confirmStoppedGenerations(this.db, priorGenerations, tmuxProbe!);
if (!stopped.ok) {
return {
ok: false,
code: "generation_unconfirmed",
message:
`A rig named "${rigSpec.name}" already exists (${stopped.rigId}), and OpenRig could not confirm it is stopped: ` +
`${stopped.reason}. Nothing was created. Restore it with 'rig up ${rigSpec.name}', ` +
`or archive it first with 'rig archive ${stopped.rigId}'.`,
};
}
archivedGenerations = stopped.rigIds;
}
const restoreArchived = () => { for (const id of archivedGenerations) this.deps.rigRepo.unarchiveRig(id); };
let rigId: string;
let createdRigId: string | null = null;
try {
const rig = this.deps.rigRepo.createRig(rigSpec.name);
const create = this.db.transaction(() => {
if (tmuxProbe) {
// #141: apply the stopped decision only to the same-name rigs it was made for, and archive
// only rows this import changes, so a rollback restores exactly what it archived.
const current = this.deps.rigRepo.findUnarchivedRigsByName(rigSpec.name).map((rig) => rig.id).sort();
if (current.join(",") !== [...priorGenerations].sort().join(",")) throw new GenerationChanged(rigSpec.name);
for (const id of archivedGenerations) {
if (makeRunningSessionCounter(this.db)(id) > 0 || !this.deps.rigRepo.archiveRig(id)) throw new GenerationChanged(rigSpec.name);
}
}
return this.deps.rigRepo.createRig(rigSpec.name);
});
const rig = create();
rigId = rig.id;
createdRigId = rig.id;
// PL-007: persist typed workspace block (when declared) on the rig
// record. Whoami / node-inventory read it via getRigWorkspace().
if (rigSpec.workspace) {
@@ -1293,6 +1365,9 @@ export class PodRigInstantiator {
});
}
} catch (err) {
if (err instanceof GenerationChanged) return { ok: false, code: "generation_unconfirmed", message: err.message };
// #141: a replacement that failed to finish creating must not leave the prior generation hidden.
if (createdRigId && archivedGenerations.length > 0) { this.deps.rigRepo.deleteRig(createdRigId); restoreArchived(); }
return { ok: false, code: "instantiate_error", message: (err as Error).message };
}
@@ -1301,6 +1376,9 @@ export class PodRigInstantiator {
const nodeIdMap: Record<string, string> = {}; // "pod.member" -> node DB id
const launchedSessionNames: string[] = []; // Track for orphan cleanup on total failure
const podInstantiateWarnings = preflight.warnings;
for (const id of archivedGenerations) {
podInstantiateWarnings.push(`Archived the stopped earlier "${rigSpec.name}" rig ${id}; its records are kept. Restore it with: rig unarchive ${id}`);
}
// OPR.0.5.3.6 (r2-B1) — instantiate() is the REAL rig-up door (/api/up →
// bootstrap → here), and like the rig-persist sites above, BOTH
@@ -1363,6 +1441,7 @@ export class PodRigInstantiator {
const hookResult = await opts.prelaunchHook(rigId);
if (!hookResult.ok) {
this.deps.rigRepo.deleteRig(rigId);
restoreArchived();
return { ok: false, code: "service_boot_failed", message: hookResult.message };
}
}
@@ -1537,6 +1616,7 @@ export class PodRigInstantiator {
}
}
this.deps.rigRepo.deleteRig(rigId);
restoreArchived();
};
const guard = this.deps.tmuxAdapter?.deliveryGuard;
if (guard) await guard.lifecycle(Object.values(nodeIdMap), cleanup);
@@ -1575,6 +1655,8 @@ export class PodRigInstantiator {
message: `${attentionNodes.length} node${attentionNodes.length === 1 ? " requires" : "s require"} attention before becoming interactive. Inspect the affected sessions and reasons before choosing recovery.`,
rigId,
attentionNodes,
// #141: the replacement is kept on this path, so its archive notice must reach the user too.
...(archivedGenerations.length > 0 ? { warnings: podInstantiateWarnings } : {}),
};
}
@@ -1967,10 +2049,13 @@ export class PodRigInstantiator {
// classified safe_projection and divergent targets overwrote silently.
// #25: a Claude seat's guidance conflict target is the rig's selected file (rig row,
// the same source startNode binds for the write).
resolveTargetPath: (category, effectiveId, cwd, sourcePath) => claudeConflictTargetPath(
category, effectiveId, cwd, sourcePath,
input.member.runtime === "claude-code" ? this.deps.rigRepo.getRigClaudeManagedBlockFile(input.rigId) ?? undefined : undefined,
),
resolveTargetPath: (category, effectiveId, cwd, sourcePath) =>
input.member.runtime === "codex" && category === "skill"
? nodePath.join(cwd, ".agents", "skills", effectiveId, "SKILL.md")
: claudeConflictTargetPath(
category, effectiveId, cwd, sourcePath,
input.member.runtime === "claude-code" ? this.deps.rigRepo.getRigClaudeManagedBlockFile(input.rigId) ?? undefined : undefined,
),
lastHashLookup: (targetPath) => projectionManifest.lastHash(targetPath),
});
if (!planResult.ok) {
@@ -1980,6 +2065,14 @@ export class PodRigInstantiator {
// instantiate warnings surface with the file, reason, and consequence.
(launchResult.warnings ??= []).push(...projectionConflictWarnings(planResult.plan));
// Codex project() writes plan entries before startup-file delivery. Protect
// edited skills there too; filtering only startup files is insufficient.
if (input.member.runtime === "codex" && !input.force) {
planResult.plan.entries = planResult.plan.entries.filter(
entry => entry.category !== "skill" || entry.classification !== "operator_conflict",
);
}
const resolvedFiles = this.buildResolvedStartupFiles(
resolveResult.resolved.spec,
resolveResult.resolved.sourcePath,
@@ -72,3 +72,48 @@ export function checkRunningNameGuard(deps: RunningNameGuardDeps, name: string):
}
return { ok: true };
}
export type StoppedGenerationsVerdict =
| { ok: true; rigIds: string[] }
| { ok: false; rigId: string; reason: string };
/**
* #141: before an explicit YAML import creates a replacement generation, find the unarchived same-name
* generations it may archive. Each must have no running sessions (the guard above) and, for every
* session name it has used, this daemon's tmux must report positive absence or, on the same socket the
* launch uses, "no server running". Any other probe result is uncertainty, not absence. This is a
* generation-management rule for the daemon's own tmux namespace, not proof that no process is alive.
* `probe` is undefined when the adapter cannot probe; the caller then keeps its existing behavior.
*/
export async function confirmStoppedGenerations(
db: Database.Database,
unarchivedRigIds: string[],
probe: (sessionName: string) => Promise<{ state: string; cause?: string }>,
): Promise<StoppedGenerationsVerdict> {
const running = makeRunningSessionCounter(db);
for (const rigId of unarchivedRigIds) {
if (running(rigId) > 0) return { ok: false, rigId, reason: "it has a running session" };
const names = (db.prepare(`
SELECT b.tmux_session AS name FROM bindings b JOIN nodes n ON n.id = b.node_id WHERE n.rig_id = ? AND b.tmux_session IS NOT NULL
UNION SELECT s.session_name FROM sessions s JOIN nodes n ON n.id = s.node_id WHERE n.rig_id = ?
`).all(rigId, rigId) as Array<{ name: string }>).map((row) => row.name);
for (const name of names) {
let result: { state: string; cause?: string };
try {
result = await probe(name);
} catch (err) {
return { ok: false, rigId, reason: `tmux could not check session ${name}: ${(err as Error).message}` };
}
if (result.state === "absent") continue;
if (result.state === "transport_unavailable" && result.cause?.includes("no server running")) continue;
return {
ok: false,
rigId,
reason: result.state === "present"
? `tmux session ${name} still exists`
: `tmux could not check session ${name}${result.cause ? `: ${result.cause}` : ""}`,
};
}
}
return { ok: true, rigIds: unarchivedRigIds };
}
@@ -215,15 +215,23 @@ export class SeatDeliveryGuard {
}
/** Current binding, never a latest historical session-name guess. Unbound seats
* resolve by node/canonical address for preferences and lifecycle preflight. */
* resolve by node/canonical address for preferences and lifecycle preflight.
* #174: an archived rig can keep a binding to the same session name as a live
* seat, so unarchived matches win; archived ones count only when nothing else
* matches. Exactly one match is still required. */
export function resolveGuardTarget(db: Database.Database, name: string): GuardTarget | null {
const rows = db.prepare(`SELECT n.id AS nodeId,
coalesce(b.tmux_session, replace(n.logical_id,'.','-') || '@' || r.name) AS session,
b.tmux_pane AS pane,
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant,
r.archived_at AS archivedAt
FROM nodes n JOIN rigs r ON r.id=n.rig_id LEFT JOIN bindings b ON b.node_id=n.id
WHERE n.id=? OR b.tmux_session=? OR b.tmux_pane=? OR n.logical_id=?
OR (b.tmux_session IS NULL AND replace(n.logical_id,'.','-') || '@' || r.name=?)`)
.all(name, name, name, name, name) as GuardTarget[];
return rows.length === 1 ? rows[0]! : null;
.all(name, name, name, name, name) as Array<GuardTarget & { archivedAt: string | null }>;
const unarchived = rows.filter((row) => row.archivedAt === null);
const pool = unarchived.length > 0 ? unarchived : rows;
if (pool.length !== 1) return null;
const { archivedAt: _archivedAt, ...target } = pool[0]!;
return target;
}
@@ -22,6 +22,7 @@ import type { JsonlExchange } from "./session-jsonl.js";
import type { PersistedEvent } from "./types.js";
import type { AppliedLaunchObservation } from "./permission-drift.js";
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
import { findOtherSessionOwner } from "./session-owner.js";
/** A bounded labeled-from-record recap of the predecessor's last exchanges + the record path,
* resolved from the predecessor's provider transcript (claude transcript_path / codex rollout_path).
@@ -654,7 +655,10 @@ export class SeatHandoverService {
const runtimeMismatch = this.checkRuntimeMismatch(input.node.runtime, discovered.runtimeHint);
if (runtimeMismatch) return fail(runtimeMismatch);
const managedOwner = this.lookupManagedOwner(discovered.tmuxSession, input.node.id);
// #141: a composer-launched successor reuses the seat's own session name, which an archived earlier
// generation still names in its kept binding. Owners in archived rigs are skipped there, as in removeNode
// (#174). A discovered successor is a separate session, so any other owner still blocks it.
const managedOwner = this.lookupManagedOwner(discovered.tmuxSession, input.node.id, input.reportedSource.mode !== "discovered");
if (managedOwner) {
return fail({
ok: false,
@@ -890,25 +894,8 @@ export class SeatHandoverService {
).get(nodeId) as SessionRow | undefined ?? null;
}
private lookupManagedOwner(tmuxSession: string, targetNodeId: string): BindingOwnerRow | null {
const bindingOwner = this.db.prepare(`
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
FROM bindings b
JOIN nodes n ON n.id = b.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE b.tmux_session = ? AND n.id != ?
LIMIT 1
`).get(tmuxSession, targetNodeId) as BindingOwnerRow | undefined;
if (bindingOwner) return bindingOwner;
return this.db.prepare(`
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
FROM sessions s
JOIN nodes n ON n.id = s.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE s.session_name = ? AND n.id != ? AND s.status NOT IN ('superseded', 'detached', 'exited')
LIMIT 1
`).get(tmuxSession, targetNodeId) as BindingOwnerRow | undefined ?? null;
private lookupManagedOwner(tmuxSession: string, targetNodeId: string, ignoreArchived: boolean): BindingOwnerRow | null {
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId, { ignoreArchived });
}
private commit(input: {
@@ -96,6 +96,8 @@ export class SeatIdentityReconciler {
private readonly store: SeatIdentityStore;
private readonly listProcesses: NativeProcessLister;
private timer: ReturnType<typeof setInterval> | null = null;
private reconciling = false;
private generation = 0;
constructor(deps: SeatIdentityReconcilerDeps) {
this.db = deps.db;
@@ -121,6 +123,18 @@ export class SeatIdentityReconciler {
/** Reconcile every running tmux-bound seat once and persist the verdicts. */
async reconcileAll(): Promise<void> {
// Skip ticks while actual reads are pending; never release on a deadline
// that could leave subprocesses alive. Normal polling cost is unchanged.
if (this.reconciling) return;
this.reconciling = true;
try {
await this.reconcileSweep(this.generation);
} finally {
this.reconciling = false;
}
}
private async reconcileSweep(generation: number): Promise<void> {
const seats = this.runningSeats();
// Prune verdicts for nodes no longer running (keep the table bounded).
this.store.pruneExcept(seats.map((s) => s.node_id));
@@ -139,6 +153,7 @@ export class SeatIdentityReconciler {
} catch {
liveSessions = null;
}
if (generation !== this.generation) return;
if (liveSessions === null || liveSessions.size === 0) {
for (const seat of seats) {
this.store.upsert(this.tmuxUnavailableVerdict(seat, observedAt));
@@ -157,13 +172,18 @@ export class SeatIdentityReconciler {
})));
};
const first = await sample();
if (generation !== this.generation) return;
const second = await sample();
if (generation !== this.generation) return;
const codexProofs = new Map(codexSeats.map((seat, index) => [seat.node_id,
first[index] && first[index]?.fingerprint === second[index]?.fingerprint ? second[index]! : null]));
for (const seat of seats) {
try {
this.store.upsert(await this.computeVerdict(seat, liveSessions, observedAt, codexProofs.get(seat.node_id) ?? null));
const verdict = await this.computeVerdict(seat, liveSessions, observedAt, codexProofs.get(seat.node_id) ?? null);
if (generation !== this.generation) return;
this.store.upsert(verdict);
} catch {
if (generation !== this.generation) return;
// A single seat's tmux failure must not crash the loop; record it as
// unavailable observation (non-green for Codex).
this.store.upsert(this.tmuxUnavailableVerdict(seat, observedAt));
@@ -273,6 +293,8 @@ export class SeatIdentityReconciler {
/** Stop the scheduler. Safe to call before start or multiple times. */
stop(): void {
// Fence old observations without releasing their flight before settlement.
this.generation++;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
@@ -1100,7 +1100,7 @@ export class SeatLifecycleService {
const parsed = parseSessionName(ref);
if (parsed.kind === "canonical") {
const localRef = parsed.member;
const rigs = this.rigRepo.findRigsByName(parsed.rig);
const rigs = this.rigRepo.findUnarchivedRigsByName(parsed.rig);
return rigs.flatMap((rig) => getNodeInventory(this.db, rig.id).filter((entry) =>
entry.canonicalSessionName === ref
|| deriveCanonicalFromEntry(entry) === ref
@@ -82,7 +82,7 @@ export class SeatStatusService {
if (parsed.kind === "canonical") {
const localRef = parsed.member;
const rigName = parsed.rig;
const rigs = this.rigRepo.findRigsByName(rigName);
const rigs = this.rigRepo.findUnarchivedRigsByName(rigName);
return rigs.flatMap((rig) => this.entriesForRig(rig.id).filter((entry) =>
entry.canonicalSessionName === ref || entry.logicalId === localRef
).map((entry) => ({ entry })));
@@ -0,0 +1,45 @@
import type Database from "better-sqlite3";
export interface SessionOwnerRow {
node_id: string;
logical_id: string;
rig_name: string;
}
/**
* The OTHER managed node that currently owns a tmux session name, if any: a node whose binding names
* it, or a node with a live (not superseded, detached or exited) session row under it. A session name
* can be shared across rigs, for example by an archived duplicate of a live rig, so the name alone
* never proves which node a session belongs to. `ignoreArchived` skips owners in archived rigs: an
* archived rig keeps its bindings, so its node would otherwise claim the live seat's session.
* `excludeNodeIds` skips further nodes, such as the rest of a group being removed together; it is applied
* in both lookups before the first match is taken, so an excluded node can't hide another owner.
*/
export function findOtherSessionOwner(
db: Database.Database,
tmuxSession: string,
nodeId: string,
opts?: { ignoreArchived?: boolean; excludeNodeIds?: readonly string[] },
): SessionOwnerRow | null {
const live = opts?.ignoreArchived ? " AND r.archived_at IS NULL" : "";
const excluded = [nodeId, ...(opts?.excludeNodeIds ?? [])];
const notExcluded = `n.id NOT IN (${excluded.map(() => "?").join(", ")})`;
const bindingOwner = db.prepare(`
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
FROM bindings b
JOIN nodes n ON n.id = b.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE b.tmux_session = ? AND ${notExcluded}${live}
LIMIT 1
`).get(tmuxSession, ...excluded) as SessionOwnerRow | undefined;
if (bindingOwner) return bindingOwner;
return db.prepare(`
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
FROM sessions s
JOIN nodes n ON n.id = s.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE s.session_name = ? AND ${notExcluded} AND s.status NOT IN ('superseded', 'detached', 'exited')${live}
LIMIT 1
`).get(tmuxSession, ...excluded) as SessionOwnerRow | undefined ?? null;
}
@@ -10,6 +10,8 @@ import type { AgentActivity } from "./types.js";
import { wrapPaneEnvelope, appendDeliveredSegment, type EnvelopeScope } from "../lib/pane-envelope.js";
import { getSelfHostId } from "./hosts/fanout-contract.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { isShellForeground } from "./shell-classifier.js";
import { verifyClaudePaneProcess, verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
@@ -542,11 +544,12 @@ interface SessionTransportDeps {
activityEndpointFile?: () => { baseUrl: string; token: string } | null;
/** S01/S02 P2: optional read-only capture observer. Absent by default (no activation). */
captureObserver?: CaptureObserverSink;
listProcesses?: NativeProcessLister;
}
interface SessionRow { node_id: string; session_name: string; }
interface NodeRow { rig_id: string; logical_id: string; }
interface SessionMetaRow { runtime: string | null; attachment_type: string | null; node_id: string | null; binding_session: string | null; pane: string | null; occupant: string | null; }
interface SessionMetaRow { runtime: string | null; attachment_type: string | null; node_id: string | null; binding_session: string | null; pane: string | null; occupant: string | null; resume_token: string | null; }
interface ResolvedTarget { sessionName: string; rigName: string; nodeLogicalId: string; }
export class SessionTransport {
@@ -563,6 +566,7 @@ export class SessionTransport {
private slowOpRecorder?: SlowOperationInstrumentation;
private activityEndpointFile: () => { baseUrl: string; token: string } | null;
private captureObserver?: CaptureObserverSink;
private listProcesses?: NativeProcessLister;
constructor(deps: SessionTransportDeps) {
this.db = deps.db;
@@ -578,6 +582,7 @@ export class SessionTransport {
this.slowOpRecorder = deps.slowOpRecorder;
this.activityEndpointFile = deps.activityEndpointFile ?? (() => null);
this.captureObserver = deps.captureObserver;
this.listProcesses = deps.listProcesses;
}
/**
@@ -625,7 +630,7 @@ export class SessionTransport {
}
private getSessionMeta(sessionName: string): {
runtime: string | null; attachmentType: string | null; nodeId: string | null; pane: string | null; occupant: string | null;
runtime: string | null; attachmentType: string | null; nodeId: string | null; pane: string | null; occupant: string | null; resumeToken: string | null;
} {
// One existing statement; P2 reads the binding columns it already joins plus the
// same current-occupant subselect the delivery guard uses. No extra query.
@@ -636,6 +641,7 @@ export class SessionTransport {
n.id AS node_id,
b.tmux_session AS binding_session,
b.tmux_pane AS pane,
s.resume_token AS resume_token,
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id = n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant
FROM sessions s
JOIN nodes n ON s.node_id = n.id
@@ -653,6 +659,7 @@ export class SessionTransport {
// binding whose session IS this name labels pane/occupant; otherwise unknown.
pane: row?.binding_session === sessionName ? row?.pane ?? null : null,
occupant: row?.binding_session === sessionName ? row?.occupant ?? null : null,
resumeToken: row?.resume_token ?? null,
};
}
@@ -985,6 +992,21 @@ export class SessionTransport {
};
}
// #142 — a shell label may be an idle shell or a managed launch wrapper.
// Only positive native process proof clears the refusal, but missing proof
// does not establish that the runtime stopped. Terminal/unreadable behavior is unchanged.
const unverifiedShell = runtime && runtime !== "terminal"
? await this.unverifiedShellForeground(sessionName, runtime, sessionMeta.pane, sessionMeta.resumeToken) : null;
if (unverifiedShell) {
return observe({
ok: false,
sessionName,
sent: false,
reason: "target_runtime_unverified",
error: `Refused: '${sessionName}' reports ${unverifiedShell} as the foreground command, but OpenRig could not verify its expected ${runtime} agent in the bound pane. The agent may still be running behind a wrapper. No text was sent.`,
});
}
if (waitForIdleMs !== undefined) {
if (opts?.force) {
return {
@@ -1419,6 +1441,28 @@ export class SessionTransport {
}
}
/** Shell label without positive native proof; not proof of an idle shell or stopped agent.
* Null when no shell label is observed, or the expected native process is verified. */
private async unverifiedShellForeground(sessionName: string, runtime: string, pane: string | null, resumeToken: string | null): Promise<string | null> {
let paneCommand: string | null;
try {
paneCommand = await this.tmuxAdapter.getPaneCommand(sessionName);
} catch {
return null;
}
if (!paneCommand || !isShellForeground(paneCommand)) return null;
if ((runtime === "codex" || runtime === "claude-code") && pane) {
// Reuse stable, foreground, pane-descendant proof. Claude fresh/resume and
// Codex resume must name this session's token. Stale UI, a Node
// launcher alone, missing observations or a native process elsewhere cannot clear it.
const verify = runtime === "codex" ? verifyCodexPaneProcess : verifyClaudePaneProcess;
const native = await verify({ target: sessionName, tmux: this.tmuxAdapter,
listProcesses: this.listProcesses, expectedToken: resumeToken });
if (native && await this.tmuxAdapter.getPanePid(pane).catch(() => null) === native.panePid) return null;
}
return paneCommand.replace(/^-/, "");
}
private async classifySendReadiness(input: {
sessionName: string;
runtime: string | null;
@@ -28,8 +28,8 @@
// to shell anyway).
// - The composer's `paneCommand` is a SHELL string (`tmux attach -r -t 's'`,
// `ssh 'dest' tmux attach …`); the pane node's `command` is an ARGV array —
// so it is carried as `["sh", "-c", paneCommand]`, preserving the composed
// quoting byte-for-byte without the adapter re-parsing shell.
// it is carried through `sh -c` with Herdr's fixed Claude/Codex detection
// hint when the live runtime is known, without re-parsing shell quoting.
// - Liveness/availability = the socket `ping` (is the multiplexer's OWN
// control socket answering — NOT a daemon server ping; HERDR-FINDINGS #3's
// intent, carried to the socket transport). No "layout command" probe: the
@@ -115,6 +115,11 @@ function blankPane(): HerdrPaneNode {
return { type: "pane", label: "", command: ["sh"] };
}
function herdrPaneCommand(pane: ComposedPane): string {
const agent = pane.runtime === "claude-code" ? "claude" : pane.runtime === "codex" ? "codex" : null;
return agent ? `env HERDR_AGENT=${agent} ${pane.paneCommand}` : pane.paneCommand;
}
/**
* Build the EQUAL auto-grid layout tree for one page of panes. PURE. The grid
* shape matches the UI TerminalLauncher `suggestLayout` exactly —
@@ -123,7 +128,8 @@ function blankPane(): HerdrPaneNode {
* every cell is the same size; blanks are layout filler only — they are never
* reported as opened seats. Each real leaf runs the composer's shell
* `paneCommand` via `["sh","-c",…]` so the composed quoting (read-only `-r`,
* ssh-wrap) is preserved untouched. Rows are built as equal `right` strips,
* ssh-wrap) is preserved; a known Claude/Codex runtime adds Herdr's detection
* hint. Rows are built as equal `right` strips,
* then combined with equal `down` strips.
*/
export function buildGridRoot(panes: ComposedPane[]): { root: HerdrLayoutNode; blanks: number; columns: number; rows: number } {
@@ -133,7 +139,7 @@ export function buildGridRoot(panes: ComposedPane[]): { root: HerdrLayoutNode; b
const leaves: HerdrLayoutNode[] = panes.map((pane) => ({
type: "pane",
label: pane.label,
command: ["sh", "-c", pane.paneCommand],
command: ["sh", "-c", herdrPaneCommand(pane)],
}));
for (let i = 0; i < blanks; i++) leaves.push(blankPane());
const rowStrips: HerdrLayoutNode[] = [];
@@ -101,6 +101,7 @@ export function createHerdrSocketRpc(
return (req) =>
new Promise<HerdrResult>((resolve, reject) => {
const conn = net.createConnection({ path: socketPath });
conn.setEncoding("utf8"); // decode across chunks, so a split multi-byte character stays intact
let buf = "";
let settled = false;
const finish = (fn: () => void) => {
@@ -115,8 +116,8 @@ export function createHerdrSocketRpc(
timeoutMs,
);
conn.on("connect", () => conn.write(`${JSON.stringify(req)}\n`));
conn.on("data", (chunk: Buffer) => {
buf += chunk.toString("utf8");
conn.on("data", (chunk: string) => {
buf += chunk;
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
@@ -17,14 +17,17 @@
/**
* A single provider-neutral pane, fully composed. `paneCommand` is the exact
* shell command the provider runs inside the pane (e.g. `tmux attach -t 's'`
* or `ssh host tmux attach -r -t 's'`); the provider does not modify it.
* or `ssh host tmux attach -r -t 's'`). Providers may use the optional runtime
* metadata when rendering their surface.
*/
export interface ComposedPane {
/** Canonical session name of the seat this pane attaches to. */
seat: string;
/** Runtime recorded for a live seat, when available. */
runtime?: string;
/** Human pane label — `<agent> · <slice>` per AC-7. */
label: string;
/** The provider-neutral shell command the pane runs. Composed upstream. */
/** Provider-neutral shell command; a provider may wrap it using runtime metadata. */
paneCommand: string;
/** True when the attach is view-only (`tmux attach -r`) — cross-rig / saved read-only. */
readOnly: boolean;
@@ -151,6 +151,8 @@ export class TerminalViewsStore {
*/
export interface LiveSeatRow {
canonicalSessionName: string | null;
/** Runtime identity from the live node inventory (for provider-specific agent hints). */
runtime?: string | null;
/** "tmux" for tmux-backed seats; other kinds have no attachable pane. */
attachmentType: string | null;
/** The tmux session name (canonical session name for tmux-backed seats). */
@@ -190,6 +192,7 @@ export function deriveViewMembers(
members.push({
seat,
label,
...(row.runtime ? { runtime: row.runtime } : {}),
tmuxSession,
host: opts.host ?? null,
readOnly: opts.readOnly === true,
@@ -41,6 +41,8 @@ export const PANES_PER_PAGE = 9;
export interface ViewMemberInput {
/** Canonical session name of the seat. */
seat: string;
/** Runtime recorded for a live seat; absent for saved members without inventory data. */
runtime?: string | null;
/** Pane label — `<agent> · <slice>` (AC-7). */
label: string;
/** The tmux session to attach to (may be null if the seat has no tmux binding). */
@@ -151,6 +153,7 @@ export function composeView(
}
opened.push({
seat: m.seat,
...(m.runtime ? { runtime: m.runtime } : {}),
label: m.label,
paneCommand: `ssh ${shellQuote(dest)} tmux attach ${attachFlag}-t ${shellQuote(m.tmuxSession)}`,
readOnly: m.readOnly,
@@ -177,6 +180,7 @@ export function composeView(
}
opened.push({
seat: m.seat,
...(m.runtime ? { runtime: m.runtime } : {}),
label: m.label,
paneCommand: `tmux attach ${attachFlag}-t ${shellQuote(m.tmuxSession)}`,
readOnly: m.readOnly,
@@ -47,6 +47,9 @@ function parsePositiveInt(raw: string | undefined, fallback: number): number {
}
const activeTimers = new Map<string, NodeJS.Timeout>();
// Shared across replacing starts: a stopped capture can still own a child.
// ponytail: bound overlap per session, leaving normal non-overlapping cost unchanged.
const capturingSessions = new Set<string>();
// Liveness decoupled from the file mtime. A COMPLETED HEALTHY tick records a
// timestamp here — on the unchanged-content early return (the file already holds
@@ -72,8 +75,8 @@ export function getLastCaptureAt(sessionName: string): number | undefined {
/** Start a per-session capture-pane rotation timer. Idempotent: a
* second start for the same session replaces the first timer. The
* first tick fires immediately so the transcript file is populated
* before the first poll interval elapses. */
* first tick fires immediately unless an old capture for this session is
* still pending; replacement then waits for a scheduled tick after it settles. */
export function startTranscriptRotation(
tmuxAdapter: TmuxAdapter,
sessionName: string,
@@ -90,8 +93,9 @@ export function startTranscriptRotation(
const isCurrent = (): boolean => activeGeneration.get(sessionName) === myGeneration;
const tick = async (): Promise<void> => {
if (!isCurrent() || capturingSessions.has(sessionName)) return;
capturingSessions.add(sessionName);
try {
if (!isCurrent()) return;
const content = await tmuxAdapter.capturePaneContent(sessionName, opts.lines);
// Re-check AFTER the async capture: stop()/replacement may have run while we
// awaited. A dead session (null) is deliberately not recorded either way,
@@ -146,6 +150,8 @@ export function startTranscriptRotation(
// Best-effort capture: target session may have died, output path
// may be unwritable, etc. The next tick retries; failure here
// does not bubble up to the daemon's launch / lifecycle paths.
} finally {
capturingSessions.delete(sessionName);
}
};
+14 -6
View File
@@ -1,5 +1,5 @@
import { mkdirSync, appendFileSync, existsSync, openSync, readSync, closeSync, statSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { join, dirname, relative, isAbsolute, sep } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { getCompatibleOpenRigPath } from "../openrig-compat.js";
import { getLastCaptureAt } from "./transcript-rotation.js";
@@ -195,9 +195,14 @@ export class TranscriptStore {
getTranscriptPath(rigName: string, sessionName: string): string {
const resolved = join(this.root, rigName, `${sessionName}.log`);
// Guard against path traversal from rig/session names containing ".."
if (!resolved.startsWith(this.root + "/") && resolved !== this.root) {
return join(this.root, "_unsafe", `${sessionName}.log`);
// Guard against path traversal from rig/session names containing "..".
// Separator-agnostic: `join()` yields backslashes on Windows, so a
// `startsWith(root + "/")` check rejected every valid path there and
// routed all transcripts to the _unsafe fallback (issue #1).
const rel = relative(this.root, resolved);
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
const safeSessionName = sessionName.replace(/[\\/]/g, "_");
return join(this.root, "_unsafe", `${safeSessionName}.log`);
}
return resolved;
}
@@ -235,8 +240,11 @@ export class TranscriptStore {
if (!this._enabled) return false;
try {
const dir = join(this.root, rigName);
// Guard against path traversal
if (!dir.startsWith(this.root + "/") && dir !== this.root) {
// Guard against path traversal. Separator-agnostic: `join()` yields
// backslashes on Windows, so a `startsWith(root + "/")` check can never
// pass there (it rejected every rig dir and transcripts never started).
const rel = relative(this.root, dir);
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
return false;
}
mkdirSync(dir, { recursive: true });
+5
View File
@@ -1280,6 +1280,9 @@ export type InstantiateOutcome =
// teaches the running rig's identity and the supported alternatives.
// (Additive variant per orch-lead territory ruling on row r054-s5b-build.)
| { ok: false; code: "rig_name_running"; message: string; runningRig: { id: string; name: string; runningSessionCount: number } }
// #141: an unarchived same-name rig exists but could not be confirmed stopped, so the YAML import
// neither archives it nor creates another same-name generation beside it.
| { ok: false; code: "generation_unconfirmed"; message: string }
// OPR.0.3.2.CT (conveyor-trust-minimal-fix):
// When every launched node reaches a recoverable attention_required
// state (e.g., workspace-trust prompt), do NOT tear the rig down.
@@ -1293,6 +1296,8 @@ export type InstantiateOutcome =
message: string;
rigId: string;
attentionNodes: AttentionNode[];
/** #141: notices that must reach the user even though the import stopped for attention. */
warnings?: string[];
};
export interface AttentionNode {
@@ -593,16 +593,18 @@ export class WatchdogJobsRepository {
* successor re-arms its own. Gen-scoped, NEVER name-scoped (the successor shares the seat name, so a
* name-scoped stop would kill the successor's own jobs). A NULL/empty generation never matches
* (UNKNOWN ≠ retired — note-2). Returns the count stopped. Auditable (terminal_reason), not a hard
* delete — the row stays for forensics. Pre-063 dbs no-op.
* delete — the row stays for forensics. Pre-063 dbs no-op. `keepJobIds` are left running: the queue's
* current park timers wake the seat that owns a still-blocked row, which the successor inherits.
*/
dropArmedByRegisteringGeneration(generationUuid: string): number {
dropArmedByRegisteringGeneration(generationUuid: string, keepJobIds: readonly string[] = []): number {
if (!this.hasGenColumn || !generationUuid) return 0;
const keep = keepJobIds.length > 0 ? ` AND job_id NOT IN (${keepJobIds.map(() => "?").join(", ")})` : "";
const res = this.db
.prepare(
`UPDATE watchdog_jobs SET state = 'stopped', terminal_reason = 'registering generation retired (seat handover)'
WHERE state = 'active' AND registered_by_generation_uuid = ?`,
WHERE state = 'active' AND registered_by_generation_uuid = ?${keep}`,
)
.run(generationUuid);
.run(generationUuid, ...keepJobIds);
return res.changes;
}
}
@@ -24,6 +24,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import {
WorkflowSpecError,
parseWorkflowSpec,
@@ -154,7 +155,7 @@ export function loadStarterWorkflowSpecs(opts: StarterSpecLoaderOpts): StarterSp
* root.
*/
export function defaultBuiltinSpecsDir(): string {
const here = path.dirname(new URL(import.meta.url).pathname);
const here = path.dirname(fileURLToPath(new URL(import.meta.url)));
// here = .../{src|dist}/domain/workflow
// package src/dist root = .../{src|dist}
return path.resolve(here, "..", "..", "builtins", "workflow-specs");
+5 -1
View File
@@ -1,5 +1,6 @@
import { serve, type ServerType } from "@hono/node-server";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createDaemonShutdown, DAEMON_SHUTDOWN_RECEIPT } from "./daemon-shutdown.js";
import { readOpenRigEnv, OPENRIG_HOME } from "./openrig-compat.js";
import { makeOperatorDeliveryEngine } from "./domain/gateway/operator-delivery-engine.js";
@@ -408,9 +409,12 @@ export async function startServer(port?: number) {
}
// Only start the server when this file is executed directly (not imported).
// pathToFileURL normalizes argv[1] (backslash paths on Windows) into the same
// shape as import.meta.url — a plain `file://${argv[1]}` never matches on win32,
// which made the daemon exit silently with code 0.
const isDirectRun =
process.argv[1] &&
import.meta.url === `file://${process.argv[1]}`;
import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
startServer();
+2 -1
View File
@@ -9,6 +9,7 @@ import type { AgentActivity } from "../domain/types.js";
import * as parkedQuery from "../domain/parked-query.js";
import { runtimeRungInventory } from "../domain/activity-taxonomy.js";
import { validateResumeToken } from "../domain/resume-token-validation.js";
import { transportSenderSession } from "./require-sender-identity.js";
// ── S19 A4 — the ingest half of the adapter seam: hook events reach the ONE oracle ──
// (SeatActivityService) through this translation, so AgentActivityStore is reduced to a
@@ -324,7 +325,7 @@ activityRoutes.get("/parked", (c) => {
// refusal, never a silent fold of every rig on the daemon.
const seatParam = c.req.query("seat") || undefined;
const rigParam = c.req.query("rig") || undefined;
const callerSession = c.req.header("x-openrig-session") || undefined;
const callerSession = transportSenderSession(c);
let scope: { rig: string; resolvedFrom: "seat-coordinate" | "query-param" | "caller-session" } | null = null;
if (seatParam?.includes("@")) {
scope = { rig: seatParam.split("@")[1]!, resolvedFrom: "seat-coordinate" };
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more