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
107 changed files with 3472 additions and 296 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
+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:
+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.
+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;
+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;
+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;
+73 -12
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,
+7 -2
View File
@@ -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"]);
@@ -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 });
}
});
});
+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 }));
+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);
+112
View File
@@ -950,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);
});
});
@@ -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
@@ -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
@@ -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). */
@@ -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;
}
};
+148 -68
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
@@ -104,9 +113,10 @@ const WINDOW_FORMAT = [
"#{window_panes}",
"#{window_active}",
].join(TMUX_FIELD_SEPARATOR);
// 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.
// 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}",
@@ -115,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");
@@ -182,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;
@@ -196,13 +227,14 @@ 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,
};
}
@@ -350,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++) {
@@ -372,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 [];
@@ -382,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 [];
@@ -392,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 [];
@@ -415,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)) {
@@ -457,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);
@@ -495,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);
@@ -568,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);
@@ -578,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);
@@ -594,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);
@@ -606,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 () => {
@@ -622,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);
@@ -661,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);
@@ -680,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);
@@ -693,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);
@@ -702,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));
}
@@ -724,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;
@@ -738,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 {
@@ -749,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 {
@@ -764,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,
@@ -779,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);
@@ -790,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);
@@ -802,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;
@@ -817,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;
@@ -832,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);
@@ -857,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);
@@ -874,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);
@@ -890,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 {
@@ -901,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;
@@ -931,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();
@@ -954,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 [];
@@ -969,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);
@@ -680,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",
@@ -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;
@@ -121,11 +121,17 @@ 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 []; }
@@ -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
@@ -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
@@ -533,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 {
@@ -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 } : {}),
};
}
@@ -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 };
}
@@ -655,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,
@@ -891,8 +894,8 @@ export class SeatHandoverService {
).get(nodeId) as SessionRow | undefined ?? null;
}
private lookupManagedOwner(tmuxSession: string, targetNodeId: string): BindingOwnerRow | null {
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId);
private lookupManagedOwner(tmuxSession: string, targetNodeId: string, ignoreArchived: boolean): BindingOwnerRow | null {
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId, { ignoreArchived });
}
private commit(input: {
+9 -5
View File
@@ -12,22 +12,26 @@ export interface SessionOwnerRow {
* 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 },
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 n.id != ?${live}
WHERE b.tmux_session = ? AND ${notExcluded}${live}
LIMIT 1
`).get(tmuxSession, nodeId) as SessionOwnerRow | undefined;
`).get(tmuxSession, ...excluded) as SessionOwnerRow | undefined;
if (bindingOwner) return bindingOwner;
return db.prepare(`
@@ -35,7 +39,7 @@ export function findOtherSessionOwner(
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')${live}
WHERE s.session_name = ? AND ${notExcluded} AND s.status NOT IN ('superseded', 'detached', 'exited')${live}
LIMIT 1
`).get(tmuxSession, nodeId) as SessionOwnerRow | undefined ?? null;
`).get(tmuxSession, ...excluded) as SessionOwnerRow | undefined ?? null;
}
+11 -10
View File
@@ -992,18 +992,18 @@ export class SessionTransport {
};
}
// #142 — an agent seat whose runtime is not running shows a bare shell, and text typed there runs as
// shell commands. A managed launch wrapper can have the same label: only positive native
// process proof clears that refusal. A terminal's shell is its runtime; unreadable stays advisory.
const bareShell = runtime && runtime !== "terminal"
? await this.bareShellForeground(sessionName, runtime, sessionMeta.pane, sessionMeta.resumeToken) : null;
if (bareShell) {
// #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_not_running",
error: `Refused: '${sessionName}' shows a bare ${bareShell} shell, so its ${runtime} runtime is not running. Text sent there would run as shell commands. Relaunch the seat first. No text was sent.`,
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.`,
});
}
@@ -1441,8 +1441,9 @@ export class SessionTransport {
}
}
/** The shell name when the pane's foreground is a bare shell; null when it is not, or unknown. */
private async bareShellForeground(sessionName: string, runtime: string, pane: string | null, resumeToken: string | null): Promise<string | null> {
/** 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);
@@ -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();
+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();
+4 -4
View File
@@ -346,6 +346,8 @@ function podAssemblerFsOps(): PodAssemblerFsOps {
return {
...assemblerFsOps(),
readFile: (p) => fs.readFileSync(p, "utf-8"),
readFileBuffer: (p) => fs.readFileSync(p),
writeFile: (p, c) => fs.writeFileSync(p, c),
exists: (p) => fs.existsSync(p),
listFiles: (dir) => realFsOps().listFiles!(dir),
};
@@ -365,8 +367,7 @@ function pluginsRouterFsOps(): PluginsRouterFsOps {
function workflowSpecsRouterFsOps(): WorkflowSpecsRouterFsOps {
return {
exists: (p) => fs.existsSync(p),
readFile: (p) => fs.readFileSync(p, "utf-8"),
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
copyFile: (s, d) => fs.copyFileSync(s, d),
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
};
}
@@ -592,8 +593,7 @@ async function routePluginsAfterBootstrap(bundlePath: string): Promise<RoutePlug
function skillsRouterFsOps(): SkillsRouterFsOps {
return {
exists: (p) => fs.existsSync(p),
readFile: (p) => fs.readFileSync(p, "utf-8"),
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
copyFile: (s, d) => fs.copyFileSync(s, d),
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
};
}
+20 -18
View File
@@ -97,26 +97,28 @@ export function chatRoutes(): Hono {
}
});
// Send initial batch
const initial = chatRepo.latest(rigId, 20);
const sentIds = new Set<string>();
for (const msg of initial) {
await stream.writeSSE({ id: msg.id, data: JSON.stringify(msg) });
sentIds.add(msg.id);
}
// Flush any messages received during initial batch, dedup by ID
initialDone = true;
for (const pending of pendingMessages) {
if (!sentIds.has(pending.id)) {
await stream.writeSSE(pending);
}
}
const aborted = new Promise<void>((resolve) => {
stream.onAbort(() => resolve());
});
try {
await new Promise<void>((resolve) => {
stream.onAbort(() => resolve());
});
// Send initial batch
const initial = chatRepo.latest(rigId, 20);
const sentIds = new Set<string>();
for (const msg of initial) {
await stream.writeSSE({ id: msg.id, data: JSON.stringify(msg) });
sentIds.add(msg.id);
}
// Flush any messages received during initial batch, dedup by ID
initialDone = true;
for (const pending of pendingMessages) {
if (!sentIds.has(pending.id)) {
await stream.writeSSE(pending);
}
}
await aborted;
} finally {
unsubscribe();
}
+5 -3
View File
@@ -40,6 +40,10 @@ eventsRoute.get("/", (c) => {
}
});
const aborted = new Promise<void>((resolve) => {
stream.onAbort(() => resolve());
});
try {
// 2. Replay missed events from DB
const missed = rigId
@@ -66,9 +70,7 @@ eventsRoute.get("/", (c) => {
replaying = false;
// 4. Keep stream alive until client disconnects
await new Promise<void>((resolve) => {
stream.onAbort(() => resolve());
});
await aborted;
} finally {
unsubscribe();
}
+1 -1
View File
@@ -162,7 +162,7 @@ export function filesRoutes(): Hono {
const rangeHeader = c.req.header("Range");
if (rangeHeader) {
const m = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? size - Number(m[2]) : NaN;
const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? Math.max(0, size - Number(m[2])) : NaN;
const end = m && m[1] !== "" && m[2] !== "" ? Number(m[2]) : size - 1;
if (!m || Number.isNaN(start) || start < 0 || start >= size || end < start) {
return new Response(null, {
+1 -1
View File
@@ -575,7 +575,7 @@ rigsRoutes.post("/:id/attach-self", async (c) => {
const tmuxPane = typeof body["tmuxPane"] === "string" ? body["tmuxPane"].trim() : "";
const hasNodeTarget = logicalId.length > 0;
const hasPodFields = podNamespace.length > 0 || memberName.length > 0 || runtime.length > 0;
const hasPodFields = podNamespace.length > 0 || memberName.length > 0;
if (hasNodeTarget && hasPodFields) {
return c.json({ error: "Specify either logicalId or podNamespace + memberName + runtime" }, 400);
+3 -1
View File
@@ -93,8 +93,10 @@ rigspecImportRoutes.post("/", async (c) => {
// S5b final-fix F1: the running-name guard refusal is a conflict on the
// direct instantiation route too — never a 500 (map consistency).
: outcome.code === "rig_name_running" ? 409
// #141: an import refused because a same-name rig could not be confirmed stopped.
: outcome.code === "generation_unconfirmed" ? 409
: 500;
const body = outcome.code === "rig_name_running"
const body = outcome.code === "rig_name_running" || outcome.code === "generation_unconfirmed"
? { ...outcome, error: outcome.message }
: outcome;
return c.json(body, status);
+1 -1
View File
@@ -236,7 +236,7 @@ function fileAssetResponse(absPath: string, contentType: string, rangeHeader?: s
if (rangeHeader) {
const m = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? size - Number(m[2]) : NaN;
const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? Math.max(0, size - Number(m[2])) : NaN;
const end = m && m[1] !== "" && m[2] !== "" ? Number(m[2]) : size - 1;
if (!m || Number.isNaN(start) || start < 0 || start >= size || end < start) {
return new Response(null, {
+16 -14
View File
@@ -167,22 +167,24 @@ export function streamRoutes(): Hono {
}
});
const initial = store.list({ limit: 50 });
const sentIds = new Set<string>();
for (const item of initial) {
await stream.writeSSE({ id: item.streamItemId, data: JSON.stringify(item) });
sentIds.add(item.streamItemId);
}
initialDone.value = true;
for (const p of pending) {
if (!sentIds.has(p.id)) await stream.writeSSE(p);
}
const aborted = new Promise<void>((resolve) => {
stream.onAbort(() => resolve());
});
try {
await new Promise<void>((resolve) => {
stream.onAbort(() => resolve());
});
const initial = store.list({ limit: 50 });
const sentIds = new Set<string>();
for (const item of initial) {
await stream.writeSSE({ id: item.streamItemId, data: JSON.stringify(item) });
sentIds.add(item.streamItemId);
}
initialDone.value = true;
for (const p of pending) {
if (!sentIds.has(p.id)) await stream.writeSSE(p);
}
await aborted;
} finally {
unsubscribe();
}
+3 -2
View File
@@ -443,8 +443,9 @@ upRoutes.post("/", async (c) => {
const hasConflict = result.stages.some((s) => {
if (s.status !== "failed" || s.stage !== "import_rig") return false;
const detail = s.detail as { code?: string; message?: string } | undefined;
if (detail?.code !== "rig_name_running") return false;
topLevelCode ??= "rig_name_running";
// #141: an import refused because a same-name rig could not be confirmed stopped is a conflict too.
if (detail?.code !== "rig_name_running" && detail?.code !== "generation_unconfirmed") return false;
topLevelCode ??= detail.code;
conflictError ??= detail.message ?? result.errors[0];
return true;
});
+14 -5
View File
@@ -1,3 +1,4 @@
import { fileURLToPath } from "node:url";
import { configureShadowCapture } from "./domain/shadow-capture.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "./domain/seat-delivery-guard.js";
import { queueRecoveryOwnsWake } from "./domain/queue-wake-ladder.js";
@@ -8,7 +9,7 @@ import { HealthDiagnosisService } from "./domain/health-diagnosis.js";
import { readHealthArtifact, healthAuthority, healthHumanReadiness } from "./domain/health-context.js";
import type { Hono } from "hono";
import type Database from "better-sqlite3";
import type { ExecFn } from "./adapters/tmux.js";
import type { ArgvExecFn, ExecFn } from "./adapters/tmux.js";
import type { CmuxTransportFactory } from "./adapters/cmux.js";
import { createDb } from "./db/connection.js";
import { migrate } from "./db/migrate.js";
@@ -25,7 +26,7 @@ import { NodeLauncher } from "./domain/node-launcher.js";
import { TmuxOptionDefaultsApplier } from "./domain/tmux-option-defaults.js";
import { TmuxAdapter } from "./adapters/tmux.js";
import { CmuxAdapter } from "./adapters/cmux.js";
import { execCommand } from "./adapters/tmux-exec.js";
import { execArgvCommand, execCommand } from "./adapters/tmux-exec.js";
import { createCmuxCliTransport } from "./adapters/cmux-transport.js";
import { SnapshotRepository } from "./domain/snapshot-repository.js";
import { CheckpointStore } from "./domain/checkpoint-store.js";
@@ -172,6 +173,8 @@ interface DaemonOptions {
* listeners by binding evidence. Absent (tests/legacy) = healthz body unchanged. */
bindPlan?: import("./domain/bind-plan.js").BindPlan;
tmuxExec?: ExecFn;
/** Optional shell-free tmux executor; selected by default on Windows. */
argvExec?: ArgvExecFn;
cmuxExec?: ExecFn;
cmuxFactory?: CmuxTransportFactory;
cmuxTimeoutMs?: number;
@@ -226,6 +229,10 @@ const KNOWN_PROVIDER_AUTH_ENV = new Set([
"OPENROUTER_API_KEY",
"ZAI_API_KEY",
"KIMI_API_KEY",
// Issue #194: the bearer token of a Codex Amazon Bedrock provider
// (`env_key = "AWS_BEARER_TOKEN_BEDROCK"`). Still forwarded only when the
// operator names it in recovery.provider_auth_env_allowlist.
"AWS_BEARER_TOKEN_BEDROCK",
]);
export function collectAllowlistedProviderAuthEnv(
@@ -396,7 +403,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
watchdogAutoRegistration.assertLiveSeatCoverage();
const watchdogHistoryLogInstance = new WatchdogHistoryLog(db);
const tmuxAdapter = new TmuxAdapter(opts?.tmuxExec ?? execCommand);
const argvExec = opts?.argvExec
?? (opts?.tmuxExec ? undefined : process.platform === "win32" ? execArgvCommand : undefined);
const tmuxAdapter = new TmuxAdapter(opts?.tmuxExec ?? execCommand, undefined, argvExec);
const deliveryGuard = new SeatDeliveryGuard(db, target => resolveGuardTarget(db, target));
deliveryGuard.recoverActivation();
tmuxAdapter.deliveryGuard = deliveryGuard;
@@ -920,7 +929,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
sessionRegistry,
eventBus,
bootstrapOrchestrator,
specsDir: nodePath.resolve(nodePath.dirname(new URL(import.meta.url).pathname), "..", "specs"),
specsDir: nodePath.resolve(nodePath.dirname(fileURLToPath(new URL(import.meta.url))), "..", "specs"),
// V0.3.1 slice 05 — kernel members run against the operator's
// workspace, not the daemon installation tree. Without this
// cwdOverride, BootstrapOrchestrator refuses with
@@ -1790,7 +1799,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// the same daemon endpoint.
deps.skillLibraryDiscoveryService = new SkillLibraryDiscoveryService({
sharedSkillsDir: nodePath.resolve(
nodePath.dirname(new URL(import.meta.url).pathname),
nodePath.dirname(fileURLToPath(new URL(import.meta.url))),
"..",
"specs",
"agents",
@@ -0,0 +1,186 @@
// #141: a YAML re-import archives the stopped earlier generation, whose pod members keep the session names the
// live replacement now owns. Shrinking the archived pod must neither be blocked by, nor reroute, the replacement's
// queue work, and must not kill its session. The owning pod's own refusal and --fallback stay. Fake tmux only.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { PodRepository } from "../src/domain/pod-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
import { RigLifecycleService } from "../src/domain/rig-lifecycle-service.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const RIG = "first-project";
const SEAT = `crew-lead@${RIG}`;
const FALLBACK = "orch-lead@ops-rig";
describe("#141 shrinking an archived generation's pod vs the live replacement's work", () => {
let db: Database.Database;
let rigRepo: RigRepository;
let sessionRegistry: SessionRegistry;
let queueRepo: QueueRepository;
let killSession: ReturnType<typeof vi.fn>;
let lifecycle: RigLifecycleService;
let stale: { rigId: string; podId: string };
let live: { rigId: string; podId: string; sessionId: string };
function generation(status: "running" | "exited") {
const rig = rigRepo.createRig(RIG);
const pod = new PodRepository(db).createPod(rig.id, "crew", "Crew");
const node = rigRepo.addNode(rig.id, "crew.lead", { runtime: "claude-code", podId: pod.id });
const session = sessionRegistry.registerSession(node.id, SEAT);
sessionRegistry.updateStatus(session.id, status);
sessionRegistry.updateBinding(node.id, { tmuxSession: SEAT });
return { rigId: rig.id, podId: pod.id, sessionId: session.id };
}
const openWork = () => queueRepo.create({ sourceSession: FALLBACK, destinationSession: SEAT, body: "replacement's work" });
const liveStatus = () => (db.prepare("SELECT status FROM sessions WHERE id = ?").get(live.sessionId) as { status: string }).status;
beforeEach(() => {
db = createFullTestDb();
rigRepo = new RigRepository(db);
sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
killSession = vi.fn(async () => ({ ok: true as const }));
lifecycle = new RigLifecycleService({
db, rigRepo, sessionRegistry, discoveryRepo: new DiscoveryRepository(db), eventBus, queueRepo,
// hasSession answers only the --fallback destination check; the fallback seat is the one "live" session.
tmuxAdapter: { killSession, hasSession: vi.fn(async (name: string) => name === FALLBACK) } as unknown as TmuxAdapter,
});
// The P1 shape: the stopped generation was archived and the replacement re-created the same seat.
stale = generation("exited");
rigRepo.archiveRig(stale.rigId);
live = generation("running");
// A running seat elsewhere that can take explicitly rerouted work.
const ops = rigRepo.createRig("ops-rig");
const orch = rigRepo.addNode(ops.id, "orch.lead", { runtime: "claude-code" });
sessionRegistry.updateStatus(sessionRegistry.registerSession(orch.id, FALLBACK).id, "running");
});
afterEach(() => db.close());
it("shrinking the archived pod is not blocked by the replacement's open work and keeps its session", async () => {
const work = await openWork();
const result = await lifecycle.shrinkPod(stale.rigId, stale.podId);
expect(result).toMatchObject({ ok: true, status: "ok", sessionsKilled: 0, reroutedQitemIds: [] });
expect(result.ok && result.nodes[0]).toMatchObject({ status: "removed", sessionKeptFor: `crew.lead@${RIG}` });
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
expect(killSession).not.toHaveBeenCalled();
expect(liveStatus()).toBe("running");
});
it("with --fallback, the replacement's open work does not move", async () => {
const work = await openWork();
const result = await lifecycle.shrinkPod(stale.rigId, stale.podId, { fallbackDestination: FALLBACK });
expect(result).toMatchObject({ ok: true, status: "ok", reroutedQitemIds: [] });
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
expect(killSession).not.toHaveBeenCalled();
expect(liveStatus()).toBe("running");
});
it("control: shrinking the live pod still refuses its own open work without --fallback", async () => {
const work = await openWork();
const result = await lifecycle.shrinkPod(live.rigId, live.podId);
expect(result).toMatchObject({ ok: false, code: "active_qitems", activeQitemIds: [work.qitemId] });
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
expect(killSession).not.toHaveBeenCalled();
});
it("control: shrinking the live pod with --fallback reroutes its own work and kills its session once", async () => {
const work = await openWork();
const result = await lifecycle.shrinkPod(live.rigId, live.podId, { fallbackDestination: FALLBACK });
expect(result).toMatchObject({ ok: true, status: "ok", sessionsKilled: 1, reroutedQitemIds: [work.qitemId] });
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: FALLBACK });
expect(killSession).toHaveBeenCalledExactlyOnceWith(SEAT);
});
});
// Members of the shrink target can share one session name. Another target member doesn't take that name's work out of
// the up-front check: the shrink still refuses without --fallback and reroutes once with it. Only an unarchived owner
// OUTSIDE the target does, and it must be found even when a target member's binding would come first.
describe("#141 shrink target members sharing a session name", () => {
const SHARED = "shared@shared-rig";
let db: Database.Database;
let rigRepo: RigRepository;
let sessionRegistry: SessionRegistry;
let queueRepo: QueueRepository;
let killSession: ReturnType<typeof vi.fn>;
let lifecycle: RigLifecycleService;
let target: { rigId: string; podId: string };
beforeEach(() => {
db = createFullTestDb();
rigRepo = new RigRepository(db);
sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
killSession = vi.fn(async () => ({ ok: true as const }));
lifecycle = new RigLifecycleService({
db, rigRepo, sessionRegistry, discoveryRepo: new DiscoveryRepository(db), eventBus, queueRepo,
tmuxAdapter: { killSession, hasSession: vi.fn(async (name: string) => name === FALLBACK) } as unknown as TmuxAdapter,
});
const rig = rigRepo.createRig("shared-rig");
const pod = new PodRepository(db).createPod(rig.id, "crew", "Crew");
for (const id of ["crew.a", "crew.b"]) {
const node = rigRepo.addNode(rig.id, id, { runtime: "claude-code", podId: pod.id });
sessionRegistry.updateStatus(sessionRegistry.registerSession(node.id, SHARED).id, "running");
sessionRegistry.updateBinding(node.id, { tmuxSession: SHARED });
}
target = { rigId: rig.id, podId: pod.id };
const ops = rigRepo.createRig("ops-rig");
const orch = rigRepo.addNode(ops.id, "orch.lead", { runtime: "claude-code" });
sessionRegistry.updateStatus(sessionRegistry.registerSession(orch.id, FALLBACK).id, "running");
});
afterEach(() => db.close());
const openWork = () => queueRepo.create({ sourceSession: FALLBACK, destinationSession: SHARED, body: "target's work" });
const membersLeft = () => rigRepo.getRig(target.rigId)!.nodes.length;
it("without --fallback the shrink refuses up front; every member and the work stay", async () => {
const work = await openWork();
const result = await lifecycle.shrinkPod(target.rigId, target.podId);
expect(result).toMatchObject({ ok: false, code: "active_qitems", activeQitemIds: [work.qitemId] });
expect(membersLeft()).toBe(2);
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SHARED, state: "pending" });
expect(killSession).not.toHaveBeenCalled();
});
it("with --fallback the work is rerouted once and the shrink completes", async () => {
const work = await openWork();
const result = await lifecycle.shrinkPod(target.rigId, target.podId, { fallbackDestination: FALLBACK });
expect(result).toMatchObject({ ok: true, status: "ok", reroutedQitemIds: [work.qitemId] });
expect(membersLeft()).toBe(0);
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: FALLBACK });
expect(killSession).toHaveBeenCalledExactlyOnceWith(SHARED);
});
it.each(["binding", "session row"])("an unarchived owner outside the target (by %s) still keeps its work out of the check", async (via) => {
// The target members' bindings exist first, so a lookup that returned one of them before excluding it would miss this owner.
const other = rigRepo.addNode(rigRepo.createRig("other-rig").id, "dev.other", { runtime: "claude-code" });
if (via === "binding") sessionRegistry.updateBinding(other.id, { tmuxSession: SHARED });
else sessionRegistry.updateStatus(sessionRegistry.registerSession(other.id, SHARED).id, "running");
const work = await openWork();
const result = await lifecycle.shrinkPod(target.rigId, target.podId);
expect(result).toMatchObject({ ok: true, status: "ok", sessionsKilled: 0, reroutedQitemIds: [] });
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SHARED, state: "pending" });
expect(killSession).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createHash } from "node:crypto";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { createTestApp } from "./helpers/test-app.js";
import { unpack } from "../src/domain/bundle-archive.js";
import { materializePodBundle } from "../src/domain/bundle-source-resolver.js";
// A pod bundle copies agent packages and declared rig files verbatim. Every byte must survive
// create -> archive -> install, whether or not the file is UTF-8 text.
const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
function pseudoRandom(n: number, seed: number): Buffer {
const out = Buffer.alloc(n);
let x = seed >>> 0;
for (let i = 0; i < n; i++) {
x = (Math.imul(x, 1664525) + 1013904223) >>> 0;
out[i] = x >>> 24;
}
return out;
}
const PAYLOADS: Record<string, Buffer> = {
"agents/impl/assets/random.bin": pseudoRandom(4096, 7),
"agents/impl/assets/logo.png": Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), Buffer.from(Array.from({ length: 128 }, (_, i) => 128 + i))]),
"agents/impl/assets/nul.dat": Buffer.from([0x61, 0x00, 0x62, 0x00, 0x00, 0x63]),
"agents/impl/assets/invalid-utf8.txt": Buffer.from([0x6f, 0x6b, 0x20, 0xc3, 0x28, 0x20, 0xa0, 0xa1, 0x20, 0xff]),
"agents/impl/assets/empty.txt": Buffer.alloc(0),
"agents/impl/skills/greet/SKILL.md": Buffer.from("Greet ü 漢 😀\r\nCRLF line\r\nno trailing newline", "utf8"),
"startup/blob.bin": Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x80, 0x00, 0xc0]),
};
const AGENT_YAML = [
'name: impl',
'version: "1.0.0"',
"resources:",
" skills:",
" - id: greet",
" path: skills/greet",
"profiles:",
" default:",
" uses:",
" skills: [greet]",
].join("\n");
const RIG_YAML = [
'version: "0.2"',
"name: bytes-rig",
"pods:",
" - id: dev",
" label: Dev",
" members:",
" - id: impl",
' agent_ref: "local:agents/impl"',
" profile: default",
" runtime: claude-code",
" cwd: .",
" startup:",
" files:",
" - path: startup/blob.bin",
" actions: []",
" edges: []",
"edges: []",
].join("\n");
describe("pod bundle byte preservation (create -> archive -> install)", () => {
let db: Database.Database;
let app: ReturnType<typeof createTestApp>["app"];
let tmpDir: string;
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-bytes-"));
app = createTestApp(db).app;
});
afterEach(() => {
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("keeps binary, non-UTF-8, NUL, empty and multibyte files byte-identical, with truthful integrity", async () => {
const src = path.join(tmpDir, "src");
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
fs.mkdirSync(path.dirname(path.join(src, rel)), { recursive: true });
fs.writeFileSync(path.join(src, rel), bytes);
}
fs.writeFileSync(path.join(src, "agents/impl/agent.yaml"), AGENT_YAML);
fs.writeFileSync(path.join(src, "rig.yaml"), RIG_YAML);
const sourceBefore = Object.fromEntries(Object.keys(PAYLOADS).map((rel) => [rel, sha(fs.readFileSync(path.join(src, rel)))]));
const outputPath = path.join(tmpDir, "bytes.rigbundle");
const created = await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath: path.join(src, "rig.yaml"), rigRoot: src, bundleName: "bytes", bundleVersion: "0.1.0", outputPath }),
});
expect(created.status, await created.clone().text()).toBe(201);
expect((await created.json()).schemaVersion).toBe(2);
// Source assets stay untouched.
for (const rel of Object.keys(PAYLOADS)) {
expect(sha(fs.readFileSync(path.join(src, rel))), rel).toBe(sourceBefore[rel]);
}
// Archive: every copied file equals its source, and the manifest hashes the source bytes.
const extracted = path.join(tmpDir, "extracted");
fs.mkdirSync(extracted);
await unpack(outputPath, extracted);
const manifest = fs.readFileSync(path.join(extracted, "bundle.yaml"), "utf8");
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
const archived = fs.readFileSync(path.join(extracted, rel));
expect(archived.equals(bytes), `${rel} in archive`).toBe(true);
expect(manifest, `${rel} integrity hash`).toContain(`${rel}: ${sha(bytes)}`);
}
const inspected = await app.request("/api/bundles/inspect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath: outputPath }),
});
const view = await inspected.json();
expect(view.digestValid).toBe(true);
expect(view.integrityResult.passed).toBe(true);
// Install: the materialized target holds the source bytes.
const target = path.join(tmpDir, "target");
fs.mkdirSync(target);
expect(materializePodBundle(extracted, target)).toEqual({ ok: true });
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
expect(fs.readFileSync(path.join(target, rel)).equals(bytes), `${rel} installed`).toBe(true);
}
});
});
@@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import nodePath from "node:path";
import realFs from "node:fs";
import os from "node:os";
import { routeSkills, type SkillsRouterFsOps, type RouteSkillsInput } from "../src/domain/bundle-skills-router.js";
// Item 6 / slice-05 Checkpoint 7.2: bundle-skills-router pure-function tests.
@@ -11,12 +13,11 @@ function mockFs(initialFiles: Record<string, string> = {}): SkillsRouterFsOps &
_written: written,
_mkdirpCalls: mkdirpCalls,
exists: (p: string) => written.has(p),
readFile: (p: string) => {
const v = written.get(p);
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
return v;
copyFile: (src: string, dest: string) => {
const v = written.get(src);
if (v === undefined) throw new Error(`File not found in mock: ${src}`);
written.set(dest, v);
},
writeFile: (p: string, c: string) => { written.set(p, c); },
mkdirp: (p: string) => { mkdirpCalls.push(p); },
};
}
@@ -173,3 +174,25 @@ describe("routeSkills", () => {
expect(fs._written.get(`${packageRoot}/test-pkg/skills/DUAL.md`)).toBe("dual");
});
});
describe("routeSkills copies bytes through the real filesystem", () => {
it("a non-UTF-8 file arrives byte-identical", () => {
const root = realFs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-skills-router-bytes-"));
try {
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff, 0xfe]);
const bundleRoot = nodePath.join(root, "bundle");
realFs.mkdirSync(nodePath.join(bundleRoot, "skills/blob"), { recursive: true });
realFs.writeFileSync(nodePath.join(bundleRoot, "skills/blob", "logo.png"), bytes);
const target = nodePath.join(root, "target");
const result = routeSkills(
{ ...makeInput(), bundleRoot, declaredSkills: ["skills/blob/logo.png"], targetSkillsDir: target },
{ exists: realFs.existsSync, mkdirp: (p) => realFs.mkdirSync(p, { recursive: true }), copyFile: realFs.copyFileSync },
);
expect(result.routedCount).toBe(1);
const installedAt = result.records[0]!.installedAt!;
expect(realFs.readFileSync(installedAt).equals(bytes)).toBe(true);
} finally {
realFs.rmSync(root, { recursive: true, force: true });
}
});
});
@@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import nodePath from "node:path";
import realFs from "node:fs";
import os from "node:os";
import { routeWorkflowSpecs, type WorkflowSpecsRouterFsOps, type RouteWorkflowSpecsInput } from "../src/domain/bundle-workflow-specs-router.js";
// Item 6 / slice-05 Checkpoint 7.3e step 2: bundle-workflow-specs-router
@@ -13,12 +15,11 @@ function mockFs(initialFiles: Record<string, string> = {}): WorkflowSpecsRouterF
_written: written,
_mkdirpCalls: mkdirpCalls,
exists: (p: string) => written.has(p),
readFile: (p: string) => {
const v = written.get(p);
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
return v;
copyFile: (src: string, dest: string) => {
const v = written.get(src);
if (v === undefined) throw new Error(`File not found in mock: ${src}`);
written.set(dest, v);
},
writeFile: (p: string, c: string) => { written.set(p, c); },
mkdirp: (p: string) => { mkdirpCalls.push(p); },
};
}
@@ -230,3 +231,25 @@ describe("routeWorkflowSpecs", () => {
expect(r2.records[0]!.status).toBe("routed");
});
});
describe("routeWorkflowSpecs copies bytes through the real filesystem", () => {
it("a non-UTF-8 file arrives byte-identical", () => {
const root = realFs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-workflow-specs-router-bytes-"));
try {
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff, 0xfe]);
const bundleRoot = nodePath.join(root, "bundle");
realFs.mkdirSync(nodePath.join(bundleRoot, "workflows"), { recursive: true });
realFs.writeFileSync(nodePath.join(bundleRoot, "workflows", "latin1.yaml"), bytes);
const target = nodePath.join(root, "target");
const result = routeWorkflowSpecs(
{ ...makeInput(), bundleRoot, declaredWorkflowSpecs: ["workflows/latin1.yaml"], targetWorkflowSpecsDir: target },
{ exists: realFs.existsSync, mkdirp: (p) => realFs.mkdirSync(p, { recursive: true }), copyFile: realFs.copyFileSync },
);
expect(result.routedCount).toBe(1);
const installedAt = result.records[0]!.installedAt!;
expect(realFs.readFileSync(installedAt).equals(bytes)).toBe(true);
} finally {
realFs.rmSync(root, { recursive: true, force: true });
}
});
});
+10 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Hono } from "hono";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
@@ -52,6 +52,15 @@ describe("chat routes", () => {
db.close();
});
it("watch disconnect during history releases the subscription", async () => {
chatRepo.send(rigId, "alice", "existing history");
const before = eventBus.subscriberCount;
const res = await app.request(`/api/rigs/${rigId}/chat/watch`);
expect(eventBus.subscriberCount).toBe(before + 1);
await res.body?.cancel();
await vi.waitFor(() => expect(eventBus.subscriberCount).toBe(before), { timeout: 1000 });
});
it("POST /send persists + returns", async () => {
const res = await app.request(`/api/rigs/${rigId}/chat/send`, {
method: "POST",
@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import { describe, it, expect, vi, afterEach } from "vitest";
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
@@ -336,6 +339,43 @@ describe("Claude Code runtime adapter", () => {
expect(store["/project/.claude/agents/reviewer.yaml"]).toBe("name: reviewer");
});
// T9c: file-shaped subagent under PRODUCTION listFiles semantics. The in-memory
// mockFs above returns [] for a file path, but production wires listFiles to a
// recursive fs.readdirSync walk (startup.ts), which throws ENOTDIR on a file —
// an unguarded probe lands the entry in ProjectionResult.failed and it is never
// projected. This drives project() through a real-fs-backed listFiles.
it("projects file-shaped subagent when listFiles throws ENOTDIR (production readdirSync semantics)", async () => {
const tempRoot = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-claude-projection-"));
const srcFile = nodePath.join(tempRoot, "agents", "base", "subagents", "reviewer.yaml");
fs.mkdirSync(nodePath.dirname(srcFile), { recursive: true });
fs.writeFileSync(srcFile, "name: reviewer");
const cwd = nodePath.join(tempRoot, "project");
fs.mkdirSync(cwd, { recursive: true });
const adapter = new ClaudeCodeAdapter({
tmux: mockTmux(),
fsOps: {
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
exists: (p: string) => fs.existsSync(p),
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
copyFile: (src: string, dest: string) => fs.copyFileSync(src, dest),
listFiles: (dir: string) => fs.readdirSync(dir),
},
});
const plan: ProjectionPlan = {
runtime: "claude-code", cwd,
entries: [makeEntry({ category: "subagent", effectiveId: "reviewer", absolutePath: srcFile, resourcePath: "subagents/reviewer.yaml" })],
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
};
const result = await adapter.project(plan, makeBinding(cwd));
expect(result.failed).toEqual([]);
expect(result.projected).toEqual(["reviewer"]);
expect(fs.readFileSync(nodePath.join(cwd, ".claude", "agents", "reviewer.yaml"), "utf-8")).toBe("name: reviewer");
});
it("applies claude_settings_fragment runtime resources to project-local Claude settings", async () => {
const fs = mockFs({
"/agents/base/runtime/claude-settings.json": JSON.stringify({
@@ -221,4 +221,26 @@ describe("OPR.0.4.1.10 B3 — durable disable via removeCodexActivityHooks", ()
makeAdapter(fs2).removeCodexActivityHooks();
expect(fs2._store[CONFIG]).toBe('[features]\nhooks = true\n'); // untouched
});
// Regression: the strip must excise ONLY the managed block (plus the blank-line separator it was
// appended with). A global \n{3,} collapse on the whole document silently mutated user content.
const MANAGED_BLOCK =
"# BEGIN OPENRIG MANAGED ACTIVITY HOOKS\n" +
"[[hooks.Stop]]\n" +
`command = 'node "${RELAY}"'\n` +
"# END OPENRIG MANAGED ACTIVITY HOOKS\n";
it("preserves runs of 3+ newlines between unrelated user sections when stripping the block", () => {
const userContent = '[features]\nhooks = true\n\n\n\n[projects."/x"]\ntrust_level = "trusted"\n';
const fs = mockCodexFs({ [RELAY]: "// relay", [CONFIG]: `${userContent}\n${MANAGED_BLOCK}` });
makeAdapter(fs).removeCodexActivityHooks();
expect(fs._store[CONFIG]).toBe(userContent); // the 3-blank-line gap survives byte-for-byte
});
it("preserves blank lines inside TOML multiline strings when stripping the block", () => {
const userContent = 'instructions = """line1\n\n\nline2"""\n';
const fs = mockCodexFs({ [RELAY]: "// relay", [CONFIG]: `${userContent}\n${MANAGED_BLOCK}` });
makeAdapter(fs).removeCodexActivityHooks();
expect(fs._store[CONFIG]).toBe(userContent); // the parsed value of `instructions` is unchanged
});
});
@@ -19,6 +19,9 @@ it("reads foreground identity through the real async executable and parser path"
"102 101 102 102 node Sat Jan 1 12:00:00 2000 node /opt/bin/codex -C /project",
"103 102 102 102 codex Sat Jan 1 12:00:00 2000 /opt/vendor/bin/codex -C /project",
"104 1 104 -1 node Sat Jan 1 12:00:00 2000 node /tmp/other.js --label codex",
// Linux task names may contain spaces (prctl PR_SET_NAME / process.title).
"106 1 106 -1 tmux: server Sat Jan 1 12:00:00 2000 tmux new-session -d",
"107 1 107 -1 node (vitest 1) Sat Jan 1 12:00:00 2000 node (vitest 1)",
"broken row",
"105 1 105 0",
];
@@ -34,6 +37,8 @@ it("reads foreground identity through the real async executable and parser path"
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/bin/codex -C /project" },
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project" },
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /tmp/other.js --label codex" },
{ pid: 106, ppid: 1, pgid: 106, tpgid: -1, executableName: "tmux: server", startedAt: "Sat Jan 1 12:00:00 2000", command: "tmux new-session -d" },
{ pid: 107, ppid: 1, pgid: 107, tpgid: -1, executableName: "node (vitest 1)", startedAt: "Sat Jan 1 12:00:00 2000", command: "node (vitest 1)" },
]);
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
expect(await defaultListProcesses()).toEqual([]);
@@ -1731,4 +1731,40 @@ describe("Codex runtime adapter", () => {
);
logSpy.mockRestore();
});
// File-shaped subagent under PRODUCTION listFiles semantics: the in-memory
// mockFs returns [] for a file path, but production wires listFiles to a
// recursive fs.readdirSync walk (startup.ts), which throws ENOTDIR on a file —
// an unguarded probe lands the entry in ProjectionResult.failed and it is never
// projected. This drives project() through a real-fs-backed listFiles.
it("projects file-shaped subagent when listFiles throws ENOTDIR (production readdirSync semantics)", async () => {
const tempRoot = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-codex-projection-"));
const srcFile = nodePath.join(tempRoot, "agents", "base", "subagents", "reviewer.yaml");
fs.mkdirSync(nodePath.dirname(srcFile), { recursive: true });
fs.writeFileSync(srcFile, "name: reviewer");
const cwd = nodePath.join(tempRoot, "project");
fs.mkdirSync(cwd, { recursive: true });
const adapter = new CodexRuntimeAdapter({
tmux: mockTmux(),
fsOps: {
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
exists: (p: string) => fs.existsSync(p),
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
listFiles: (dir: string) => fs.readdirSync(dir),
},
});
const plan: ProjectionPlan = {
runtime: "codex", cwd,
entries: [makeEntry({ category: "subagent", effectiveId: "reviewer", absolutePath: srcFile, resourcePath: "subagents/reviewer.yaml" })],
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
};
const result = await adapter.project(plan, makeBinding(cwd));
expect(result.failed).toEqual([]);
expect(result.projected).toEqual(["reviewer"]);
expect(fs.readFileSync(nodePath.join(cwd, ".agents", "reviewer.yaml"), "utf-8")).toBe("name: reviewer");
});
});
@@ -3,6 +3,8 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { DiscoveryCoordinator } from "../src/domain/discovery-coordinator.js";
import { ClaimService } from "../src/domain/claim-service.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
@@ -186,6 +188,69 @@ describe("DiscoveryCoordinator", () => {
expect(second).toHaveLength(0);
});
it("preserves a claim accepted while a rescan is fingerprinting", async () => {
const pane = makePane({ activeCommand: "sh" });
let releaseFingerprint!: () => void;
let fingerprintEntered!: () => void;
const entered = new Promise<void>((resolve) => { fingerprintEntered = resolve; });
const paused = new Promise<void>((resolve) => { releaseFingerprint = resolve; });
const fingerprinter = mockFingerprinter("unknown");
(fingerprinter.fingerprint as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fingerprintEntered();
await paused;
return { runtimeHint: "unknown", confidence: "low", evidence: { layerUsed: 1 } };
});
const { coordinator, discoveryRepo, sessionRegistry, eventBus } = buildCoordinator({
scanner: mockScanner([pane]), fingerprinter,
});
const discovered = discoveryRepo.upsertDiscoveredSession({
...pane, cwd: "/before", runtimeHint: "unknown", confidence: "low",
});
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-1", "r01");
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)").run("n-1", "rig-1", "first");
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)").run("n-2", "rig-1", "second");
const claims = new ClaimService({
db, discoveryRepo, sessionRegistry, eventBus, rigRepo: new RigRepository(db),
});
const scan = coordinator.scanOnce();
await entered;
try {
expect(await claims.bind({ discoveredId: discovered.id, rigId: "rig-1", logicalId: "first" }))
.toMatchObject({ ok: true, nodeId: "n-1" });
} finally {
releaseFingerprint();
}
const results = await scan;
const duplicate = await claims.bind({ discoveredId: discovered.id, rigId: "rig-1", logicalId: "second" });
expect(duplicate).toMatchObject({ ok: false, code: "not_active" });
expect(discoveryRepo.getDiscoveredSession(discovered.id)).toMatchObject({
status: "claimed", claimedNodeId: "n-1", cwd: pane.cwd,
});
expect(results).toHaveLength(0);
expect(db.prepare("SELECT node_id, tmux_session FROM bindings").all()).toEqual([
{ node_id: "n-1", tmux_session: pane.tmuxSession },
]);
});
it("reactivates vanished discoveries and allows explicitly released claims to be rescanned", () => {
const repo = new DiscoveryRepository(db);
const data = { tmuxSession: "organic", tmuxPane: "%0", runtimeHint: "unknown" as const, confidence: "low" as const };
const first = repo.upsertDiscoveredSession(data);
repo.markVanished([first.id]);
expect(repo.upsertDiscoveredSession(data)).toMatchObject({
id: first.id, firstSeenAt: first.firstSeenAt, status: "active", claimedNodeId: null,
});
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-1", "r01");
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)").run("n-1", "rig-1", "dev");
repo.markClaimed(first.id, "n-1");
repo.releaseClaimByNodeId("n-1");
expect(repo.upsertDiscoveredSession({ ...data, cwd: "/updated" })).toMatchObject({
id: first.id, status: "active", claimedNodeId: null, cwd: "/updated",
});
});
// T7: scanOnce returns active sessions
it("scanOnce returns active discovered sessions", async () => {
const panes = [makePane({ tmuxPane: "%0" }), makePane({ tmuxPane: "%1" })];
+11 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type Database from "better-sqlite3";
import type { Hono } from "hono";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
@@ -146,6 +146,16 @@ describe("SSE events route", () => {
expect(events.length).toBe(2);
});
it("client disconnect during replay releases the subscription", async () => {
const rig = rigRepo.createRig("r01");
eventBus.emit({ type: "rig.created", rigId: rig.id });
const countBefore = eventBus.subscriberCount;
const res = await app.request(`/api/events?rigId=${rig.id}`);
expect(eventBus.subscriberCount).toBe(countBefore + 1);
await res.body?.cancel();
await vi.waitFor(() => expect(eventBus.subscriberCount).toBe(countBefore), { timeout: 1000 });
});
it("client disconnect -> subscriber cleaned up (subscriberCount drops)", async () => {
const rig = rigRepo.createRig("r01");
const countBefore = eventBus.subscriberCount;
@@ -63,6 +63,15 @@ describe("GET /api/files/asset — Range + render opt-in", () => {
expect(suffix.headers.get("Content-Range")).toBe("bytes 950-999/1000");
});
it.each(["bytes=-1000", "bytes=-2000"])("serves the whole representation for a satisfiable long suffix %s", async (range) => {
const res = await app.request(url("media/clip.mp4"), { headers: { Range: range } });
expect(res.status).toBe(206);
expect(res.headers.get("Content-Range")).toBe("bytes 0-999/1000");
expect(res.headers.get("Content-Length")).toBe("1000");
const body = new Uint8Array(await res.arrayBuffer());
expect(body).toEqual(Uint8Array.from({ length: 1000 }, (_, i) => i % 251));
});
it("clamps an over-long end and rejects unsatisfiable ranges with 416", async () => {
const clamped = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=900-5000" } });
expect(clamped.status).toBe(206);
@@ -72,6 +81,10 @@ describe("GET /api/files/asset — Range + render opt-in", () => {
expect(past.status).toBe(416);
expect(past.headers.get("Content-Range")).toBe("bytes */1000");
const zeroSuffix = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=-0" } });
expect(zeroSuffix.status).toBe(416);
expect(zeroSuffix.headers.get("Content-Range")).toBe("bytes */1000");
const garbage = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=zz" } });
expect(garbage.status).toBe(416);
});
+21 -1
View File
@@ -13,7 +13,8 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { createHash } from "node:crypto";
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { filesRoutes } from "../src/routes/files.js";
@@ -209,6 +210,25 @@ describe("UI Enhancement Pack v0 — /api/files routes", () => {
expect((row as { identity_provenance: string | null }).identity_provenance).toBe("claimed:v1");
});
it.skipIf(process.platform === "win32")("preserves executable permissions when replacing a workspace script", async () => {
const target = join(tempDir, "workspace", "check.sh");
writeFileSync(target, "#!/bin/sh\nprintf old\n");
chmodSync(target, 0o755);
const expectedMtime = statSync(target).mtime.toISOString();
const expectedContentHash = sha256(readFileSync(target));
const res = await app.request("/api/files/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
root: "workspace", path: "check.sh", content: "#!/bin/sh\nprintf updated\n",
expectedMtime, expectedContentHash, actor: "test@r",
}),
});
expect(res.status).toBe(200);
expect(statSync(target).mode & 0o777).toBe(0o755);
expect(execFileSync(target, { encoding: "utf8" })).toBe("updated");
});
// P21 I5 — files write is a founder-visible surface; resolveActorWithDeferral splits the two paths:
// header present (CLI/DaemonClient) => derive + transport:v1 + 409-on-mismatch; header absent
// (browser UI) => claimed-era (NULL provenance), never-break (the named deferral, owner=dev50).
@@ -0,0 +1,99 @@
// #141: a YAML import refused because a same-name rig could not be confirmed stopped is a 409 conflict with
// its actionable message on both import routes, not a 500. Same stubbed seam as the running-name route tests.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type Database from "better-sqlite3";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { createFullTestDb, createTestApp, mockTmuxAdapter } from "./helpers/test-app.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const POD_YAML = (name: string) => [
'version: "0.2"',
`name: ${name}`,
"pods:",
" - id: crew",
" label: Crew",
" members:",
" - id: a",
' agent_ref: "builtin:terminal"',
' profile: "none"',
" runtime: terminal",
" cwd: /",
" edges: []",
"edges: []",
].join("\n");
describe("#141 generation_unconfirmed crosses both import routes as a 409", () => {
let db: Database.Database;
let setup: ReturnType<typeof createTestApp>;
let tmpDir: string;
beforeEach(() => {
db = createFullTestDb();
// The earlier rig's tmux session is reported present, so it can't be confirmed stopped.
const tmux = Object.assign(mockTmuxAdapter(), { probeSession: vi.fn(async () => ({ state: "present" })) }) as unknown as TmuxAdapter;
setup = createTestApp(db, {
tmux,
upRouterFsOps: {
exists: (p: string) => fs.existsSync(p),
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
readHead: (p: string, n: number) => {
const fd = fs.openSync(p, "r");
try { const buf = Buffer.alloc(n); return buf.subarray(0, fs.readSync(fd, buf, 0, n, 0)); } finally { fs.closeSync(fd); }
},
},
});
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gen-unconfirmed-"));
});
afterEach(() => {
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function seedStoppedRig(name: string) {
const rig = setup.rigRepo.createRig(name);
const node = setup.rigRepo.addNode(rig.id, "crew.a", { runtime: "claude-code", cwd: "/" });
const session = setup.sessionRegistry.registerSession(node.id, `crew-a@${name}`);
setup.sessionRegistry.updateStatus(session.id, "running");
setup.sessionRegistry.updateBinding(node.id, { tmuxSession: `crew-a@${name}` });
setup.sessionRegistry.updateStatus(session.id, "detached");
return rig;
}
const rigCount = (name: string) => (db.prepare("SELECT COUNT(*) AS c FROM rigs WHERE name = ?").get(name) as { c: number }).c;
it("POST /api/rigs/import: 409, top-level code, actionable error naming the earlier rig, nothing created", async () => {
const rig = seedStoppedRig("gen-import");
const res = await setup.app.request("/api/rigs/import", {
method: "POST",
headers: { "Content-Type": "text/yaml", "X-Rig-Root": "/tmp" },
body: POD_YAML("gen-import"),
});
const body = await res.json() as Record<string, unknown>;
expect(res.status).toBe(409);
expect(body["code"]).toBe("generation_unconfirmed");
expect(String(body["error"])).toContain(rig.id);
expect(String(body["error"])).toMatch(/nothing was created/i);
expect(rigCount("gen-import")).toBe(1);
});
it("POST /api/up: 409, top-level code, actionable error naming the earlier rig, nothing created", async () => {
const rig = seedStoppedRig("gen-up");
const specPath = path.join(tmpDir, "rig.yaml");
fs.writeFileSync(specPath, POD_YAML("gen-up"));
const res = await setup.app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: specPath }),
});
const body = await res.json() as Record<string, unknown>;
expect(res.status).toBe(409);
expect(body["code"]).toBe("generation_unconfirmed");
expect(String(body["error"])).toContain(rig.id);
expect(rigCount("gen-up")).toBe(1);
});
});
@@ -0,0 +1,209 @@
// A seat swap stops the watchdog jobs the retiring occupant registered, so a stale wake doesn't reach the successor.
// A blocked row's current park timer is different: it wakes the seat that still owns the row, and the successor
// inherits that row. It survives the swap; every other job the retiring generation registered still stops.
// Real queue, watchdog and session repositories on a full test DB, wired as startup.ts wires them.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js";
import { DefaultOccupantInvalidator } from "../src/domain/occupant-invalidator.js";
import { diagnoseSeatParked } from "../src/domain/parked-query.js";
import { WatchdogPolicyEngine } from "../src/domain/watchdog-policy-engine.js";
import { WatchdogHistoryLog } from "../src/domain/watchdog-history-log.js";
import { watchdogHistorySchema } from "../src/db/migrations/032_watchdog_history.js";
const SEAT = "dev-impl@seat-rig";
const OTHER = "dev-qa@seat-rig";
describe("#handover-wake: a blocked row's park timer across an occupant swap", () => {
let db: Database.Database;
let queue: QueueRepository;
let jobs: WatchdogJobsRepository;
let invalidator: DefaultOccupantInvalidator;
let gen: (session: string) => string | null;
let seatNodeId: string;
let otherNodeId: string;
let sessionRegistry: SessionRegistry;
let bus: EventBus;
beforeEach(() => {
db = createFullTestDb();
db.exec(watchdogHistorySchema.sql); // the engine audits each evaluation; the full test DB does not include this table
const rigRepo = new RigRepository(db);
sessionRegistry = new SessionRegistry(db);
const rig = rigRepo.createRig("seat-rig");
for (const [logicalId, session] of [["dev.impl", SEAT], ["dev.qa", OTHER]] as const) {
const node = rigRepo.addNode(rig.id, logicalId, { runtime: "codex" });
sessionRegistry.updateStatus(sessionRegistry.registerSession(node.id, session).id, "running");
if (session === SEAT) seatNodeId = node.id; else otherNodeId = node.id;
}
gen = (session) => sessionRegistry.currentOccupantGenerationForSession(session);
bus = new EventBus(db);
queue = new QueueRepository(db, bus, { validateRig: () => true, resolveOccupantGeneration: gen });
jobs = new WatchdogJobsRepository(db, undefined, gen);
queue.attachWatchdogJobsRepository(jobs);
invalidator = new DefaultOccupantInvalidator({
enforcer: { invalidateOccupant() {} }, contextUsage: { invalidateOccupantSidecar() {} }, watchdog: jobs, queue,
});
});
afterEach(() => db.close());
async function park(opts: { destination?: string; actor?: string; repeating?: boolean; watchdogId?: string } = {}) {
const row = await queue.create({ sourceSession: "orch-lead@seat-rig", destinationSession: opts.destination ?? SEAT, body: "work" } as never);
queue.update({
qitemId: row.qitemId, actorSession: opts.actor ?? SEAT, state: "blocked", blockedOn: "external:ci",
transitionNote: "continuation: check CI",
...(opts.watchdogId ? { wakeWatchdogId: opts.watchdogId } : { wakeAfterSeconds: 1800, ...(opts.repeating ? { wakeMaxSeconds: 7200 } : {}) }),
} as never);
return { qitemId: row.qitemId, wakeRef: (queue.getParkWakeStatus(row.qitemId) as { ref: string }).ref };
}
const swap = () => invalidator.invalidateRetiringOccupant({ retiringSessionName: SEAT, successorSessionName: SEAT, retiringGeneration: gen(SEAT)! });
const jobState = (jobId: string) => (db.prepare("SELECT state FROM watchdog_jobs WHERE job_id = ?").get(jobId) as { state: string }).state;
const activeJobsFor = (session: string) => (db.prepare("SELECT COUNT(*) AS n FROM watchdog_jobs WHERE state = 'active' AND target_session = ?").get(session) as { n: number }).n;
const wakeLive = (qitemId: string) => (queue.getParkWakeStatus(qitemId) as { live: boolean }).live;
const parkedWhenIdle = (session = SEAT) => diagnoseSeatParked({
getSeatState: () => ({ activity: "idle-at-prompt", needsInput: { count: 0, reason: null }, decidedBy: "test" }) as never,
listOpenObligations: (destinationSession, limit) => ({
rows: queue.list({ destinationSession, state: ["pending", "in-progress", "blocked"], limit })
.map((r) => ({ qitemId: r.qitemId, state: r.state as "pending" | "in-progress" | "blocked", summary: r.summary ?? null })),
limit,
}),
getParkWake: (qitemId) => queue.getParkWakeStatus(qitemId),
}, { seatNodeId: session === SEAT ? seatNodeId : otherNodeId, sessionName: session });
it.each([false, true])("the still-owned blocked row keeps its one live timer (repeating=%s)", async (repeating) => {
const { qitemId, wakeRef } = await park({ repeating });
swap();
expect(jobState(wakeRef)).toBe("active");
expect(wakeLive(qitemId)).toBe(true);
expect(activeJobsFor(SEAT)).toBe(1);
expect(parkedWhenIdle().obligations.unhealthyHeldCount).toBe(0);
});
it("a successor re-park still supersedes the kept timer: one live timer, not two", async () => {
const { qitemId, wakeRef } = await park();
swap();
queue.update({ qitemId, actorSession: SEAT, state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: re-parked", wakeAfterSeconds: 600 } as never);
expect(jobState(wakeRef)).not.toBe("active");
expect(activeJobsFor(SEAT)).toBe(1);
});
it("another seat's blocked row parked by the retiring occupant keeps its live timer", async () => {
const { qitemId, wakeRef } = await park({ destination: OTHER, actor: SEAT });
swap();
expect(jobState(wakeRef)).toBe("active");
expect(wakeLive(qitemId)).toBe(true);
});
it("control: an occupant-only job the retiring generation registered still stops", () => {
const own = jobs.register({
policy: "periodic-reminder", targetSession: SEAT, intervalSeconds: 600, registeredBySession: SEAT,
specYaml: ["policy: periodic-reminder", "target:", ` session: ${JSON.stringify(SEAT)}`, 'message: "own reminder"', ""].join("\n"),
});
swap();
expect(jobState(own.jobId)).toBe("stopped");
});
it("control: an operator watchdog attached to the blocked row still stops (custom job, not queue-generated)", async () => {
const attached = jobs.register({
policy: "periodic-reminder", targetSession: SEAT, intervalSeconds: 600, registeredBySession: SEAT,
specYaml: ["policy: periodic-reminder", "target:", ` session: ${JSON.stringify(SEAT)}`, 'message: "operator watchdog"', ""].join("\n"),
});
await park({ watchdogId: attached.jobId });
swap();
expect(jobState(attached.jobId)).toBe("stopped");
});
it("control: a row rerouted to another seat does not keep the old seat's timer", async () => {
const { qitemId, wakeRef } = await park();
queue.routeToFallback(qitemId, OTHER, "test transfer");
swap();
expect(jobState(wakeRef)).not.toBe("active");
});
// review-r2 (PR #242): a rerouted row's park timer must not keep waking the OLD owner. The scheduler evaluates
// active jobs; this evaluates every active job, due or not, through the real engine and pre-delivery check.
async function fireAllActive() {
const deliveries: Array<{ targetSession: string; message: string }> = [];
const engine = new WatchdogPolicyEngine({
jobsRepo: jobs, historyLog: new WatchdogHistoryLog(db), eventBus: bus,
deliver: async (request) => { deliveries.push(request); return { status: "ok" }; },
resolvePreDeliveryTerminalReason: ({ jobId }: { jobId: string }) => queue.resolveWatchdogPreDeliveryTerminalReason(jobId),
resolveQueueWait: (input: { jobId: string }) => queue.evaluateWaitReminder(input),
onWakeAttempt: ({ jobId, deliveryStatus }) => queue.recordWatchdogWakeAttempt(jobId, deliveryStatus),
});
for (const job of jobs.listActive()) await engine.evaluate(job);
return deliveries;
}
it("park, reroute, fire: nothing reaches the old owner (no handover)", async () => {
const { qitemId, wakeRef } = await park();
queue.routeToFallback(qitemId, OTHER, "test reroute");
expect(jobState(wakeRef)).not.toBe("active");
expect(wakeLive(qitemId)).toBe(false);
expect(parkedWhenIdle(OTHER).obligations.unhealthyHeldCount).toBe(1);
expect((await fireAllActive()).filter((d) => d.targetSession === SEAT)).toEqual([]);
});
it("park, swap (timer kept), reroute, swap, fire: nothing reaches the old owner", async () => {
const { qitemId, wakeRef } = await park();
swap();
expect(jobState(wakeRef)).toBe("active");
queue.routeToFallback(qitemId, OTHER, "test reroute");
const firstGeneration = gen(SEAT);
sessionRegistry.updateStatus(sessionRegistry.registerSession(seatNodeId, SEAT).id, "running");
expect(gen(SEAT)).not.toBe(firstGeneration);
swap();
expect(jobState(wakeRef)).not.toBe("active");
expect(wakeLive(qitemId)).toBe(false);
expect(parkedWhenIdle(OTHER).obligations.unhealthyHeldCount).toBe(1);
expect((await fireAllActive()).filter((d) => d.targetSession === SEAT)).toEqual([]);
});
it.each([false, true])("a repeating wait survives the reroute and wakes only the new owner (swaps=%s)", async (withSwaps) => {
const { qitemId, wakeRef } = await park({ repeating: true });
if (withSwaps) swap();
queue.routeToFallback(qitemId, OTHER, "test reroute");
if (withSwaps) {
sessionRegistry.updateStatus(sessionRegistry.registerSession(seatNodeId, SEAT).id, "running");
swap();
}
expect(jobState(wakeRef)).toBe("active");
expect(wakeLive(qitemId)).toBe(true);
const deliveries = await fireAllActive();
expect(deliveries.map((d) => d.targetSession)).toEqual([OTHER]);
});
it("control: a stale timer that is no longer the row's current wake still stops", async () => {
const { qitemId, wakeRef: first } = await park();
queue.update({ qitemId, actorSession: SEAT, state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: re-parked", wakeAfterSeconds: 600 } as never);
// Legacy residue: a superseded timer left active by a path that did not retire it.
db.prepare("UPDATE watchdog_jobs SET state = 'active', terminal_reason = NULL WHERE job_id = ?").run(first);
swap();
expect(jobState(first)).toBe("stopped");
expect(wakeLive(qitemId)).toBe(true);
});
});
@@ -0,0 +1,74 @@
// The real createHerdrSocketRpc over a private unix socket. A UTF-8 reply split inside a
// multi-byte character must still parse to the exact original text.
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { createHerdrSocketRpc } from "../src/domain/terminal/herdr-transport.js";
const TEXT = "café 漢 😀";
const servers: net.Server[] = [];
const dirs: string[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) await new Promise((resolve) => server.close(resolve));
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
/** Serve one request, writing each reply part as a separate chunk. */
async function serve(reply: (req: { id: string }) => Buffer[]): Promise<string> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-"));
dirs.push(dir);
const socketPath = path.join(dir, "h.sock");
const server = net.createServer((conn) => {
let buf = "";
conn.on("data", async (data) => {
buf += data.toString("utf8");
const nl = buf.indexOf("\n");
if (nl < 0) return;
for (const part of reply(JSON.parse(buf.slice(0, nl)))) {
conn.write(part);
await new Promise((resolve) => setTimeout(resolve, 20));
}
});
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(socketPath, resolve));
return socketPath;
}
const line = (id: string) => Buffer.from(`${JSON.stringify({ id, result: { type: "pane_output", text: TEXT } })}\n`);
const splitInside = (bytes: Buffer, char: string, into: number) => {
const at = bytes.indexOf(Buffer.from(char)) + into;
return [bytes.subarray(0, at), bytes.subarray(at)];
};
describe("herdr socket RPC keeps UTF-8 across chunk boundaries", () => {
it.each([
["é", 2, 1],
["漢", 3, 1],
["漢", 3, 2],
["😀", 4, 1],
["😀", 4, 2],
["😀", 4, 3],
] as const)("a chunk boundary inside %s (%i bytes) at byte %i", async (char, _bytes, into) => {
const socketPath = await serve((req) => splitInside(line(req.id), char, into));
const rpc = createHerdrSocketRpc(socketPath, 2000);
await expect(rpc({ id: "openrig-1", method: "pane.read", params: {} }))
.resolves.toEqual({ type: "pane_output", text: TEXT });
});
it("still skips another id's line and matches this request's split reply", async () => {
const socketPath = await serve((req) => {
const other = Buffer.from(`${JSON.stringify({ id: "other", result: { type: "x", text: "no" } })}\n`);
const [head, tail] = splitInside(line(req.id), "😀", 2);
return [Buffer.concat([other, head!]), tail!];
});
const rpc = createHerdrSocketRpc(socketPath, 2000);
await expect(rpc({ id: "openrig-1", method: "pane.read", params: {} }))
.resolves.toEqual({ type: "pane_output", text: TEXT });
});
});
+86
View File
@@ -16,7 +16,10 @@ import {
selectVariant,
kernelAlreadyManaged,
authBlockMessage,
probeCodexReadiness,
selectCodexProviderAuth,
type KernelBootDeps,
type RuntimeAuthStatus,
} from "../src/domain/kernel-boot.js";
import type { RigRepository } from "../src/domain/rig-repository.js";
import type { BootstrapOrchestrator } from "../src/domain/bootstrap-orchestrator.js";
@@ -244,3 +247,86 @@ describe("bootKernelIfNeeded — fire-and-forget bootstrap", () => {
tracker.stop();
});
});
// Issue #194 — a Codex provider that does not use an OpenAI login.
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");
function codexProbe(opts: { config: string | null; env?: Record<string, string>; loggedIn?: boolean; installed?: boolean }) {
const ran: string[] = [];
const run = async (cmd: string): Promise<RuntimeAuthStatus> => {
ran.push(cmd);
if (opts.installed === false) return "unavailable";
if (cmd === "codex login status") return opts.loggedIn ? "ok" : "unavailable";
return "ok";
};
return { ran, deps: { run, readConfig: () => opts.config, env: opts.env ?? {} } };
}
describe("selectCodexProviderAuth — provider-aware Codex readiness (#194)", () => {
it("uses the credential variable only for an explicit non-OpenAI provider with env_key", () => {
expect(selectCodexProviderAuth(BEDROCK_CONFIG)).toEqual({
kind: "env-key", providerId: "bedrock-runtime-us", envKey: "AWS_BEARER_TOKEN_BEDROCK",
});
});
it("keeps the OpenAI login check for every unresolved or OpenAI-auth case", () => {
expect(selectCodexProviderAuth(null)).toEqual({ kind: "openai-login" });
expect(selectCodexProviderAuth('model_provider = "openai"\n')).toEqual({ kind: "openai-login" });
expect(selectCodexProviderAuth(BEDROCK_CONFIG.replace("requires_openai_auth = false", "requires_openai_auth = true")).kind).toBe("openai-login");
expect(selectCodexProviderAuth(BEDROCK_CONFIG.replace("requires_openai_auth = false", "")).kind).toBe("openai-login");
expect(selectCodexProviderAuth(BEDROCK_CONFIG.replace('env_key = "AWS_BEARER_TOKEN_BEDROCK"', "")).kind).toBe("openai-login");
expect(selectCodexProviderAuth(BEDROCK_CONFIG.replace('model_provider = "bedrock-runtime-us"', 'model_provider = "missing"')).kind).toBe("openai-login");
expect(selectCodexProviderAuth('model_provider = "__proto__"\n')).toEqual({ kind: "openai-login" });
expect(selectCodexProviderAuth("model_provider = [broken")).toEqual({ kind: "openai-login", unresolved: "config.toml could not be parsed" });
expect(selectCodexProviderAuth(`profile = "work"\n${BEDROCK_CONFIG}`).kind).toBe("openai-login");
});
});
describe("probeCodexReadiness — the kernel's Codex probe (#194)", () => {
it("reports ok for the Bedrock provider with its token set and no OpenAI login, without running codex login status", async () => {
const probe = codexProbe({ config: BEDROCK_CONFIG, env: { AWS_BEARER_TOKEN_BEDROCK: "synthetic-token" }, loggedIn: false });
expect(await probeCodexReadiness(probe.deps)).toBe("ok");
expect(probe.ran).toEqual(["codex --version"]);
});
it("reports unavailable when the provider's variable is missing or blank", async () => {
for (const env of [{}, { AWS_BEARER_TOKEN_BEDROCK: " " }]) {
const probe = codexProbe({ config: BEDROCK_CONFIG, env, loggedIn: true });
expect(await probeCodexReadiness(probe.deps)).toBe("unavailable");
expect(probe.ran).not.toContain("codex login status");
}
});
it("reports unavailable when the Codex executable is missing even with the token set", async () => {
const probe = codexProbe({ config: BEDROCK_CONFIG, env: { AWS_BEARER_TOKEN_BEDROCK: "synthetic-token" }, installed: false });
expect(await probeCodexReadiness(probe.deps)).toBe("unavailable");
});
it("keeps the OpenAI login result for default, OpenAI-auth and malformed configurations", async () => {
for (const config of [null, 'model_provider = "openai"\n', "model_provider = [broken"]) {
for (const loggedIn of [true, false]) {
const probe = codexProbe({ config, env: { AWS_BEARER_TOKEN_BEDROCK: "synthetic-token" }, loggedIn });
expect(await probeCodexReadiness(probe.deps)).toBe(loggedIn ? "ok" : "unavailable");
expect(probe.ran).toEqual(["codex login status"]);
}
}
});
it("lets the kernel select the Codex variant for a Bedrock-only machine", async () => {
const probe = codexProbe({ config: BEDROCK_CONFIG, env: { AWS_BEARER_TOKEN_BEDROCK: "synthetic-token" }, loggedIn: false });
const tracker = await bootKernelIfNeeded(makeBaseDeps({
probeRuntimes: async () => ({ claudeCode: "unavailable", codex: await probeCodexReadiness(probe.deps) }),
}, tmpSpecsDir));
expect(tracker.getStatus().variant).toBe("rig-codex-only.yaml");
tracker.stop();
});
});
@@ -115,6 +115,17 @@ describe("kernel agents — profile.uses references resolve against shared pool"
}
});
function usedRuntimeResources(yamlPath: string): { profile: string; runtimeResources: string[] }[] {
const doc = parseYaml(readFileSync(yamlPath, "utf-8")) as {
profiles?: Record<string, { uses?: { runtime_resources?: string[] } }>;
};
const out: { profile: string; runtimeResources: string[] }[] = [];
for (const [profile, p] of Object.entries(doc.profiles ?? {})) {
out.push({ profile, runtimeResources: p?.uses?.runtime_resources ?? [] });
}
return out;
}
for (const agentPath of KERNEL_AGENT_PATHS) {
const label = agentPath
.replace(KERNEL_AGENTS_DIR + "/", "")
@@ -136,6 +147,26 @@ describe("kernel agents — profile.uses references resolve against shared pool"
}
});
}
// #160 — a kernel seat running the Claude runtime must report activity out of
// the box. Every profile that opts into the shared Claude defaults must also
// opt into the shared activity hooks; without the relay the daemon's freshness
// gate records every hookless Claude kernel seat as `unknown`.
for (const agentPath of KERNEL_AGENT_PATHS) {
const label = agentPath
.replace(KERNEL_AGENTS_DIR + "/", "")
.replace("/agent.yaml", "")
.replace("/", ".");
it(`${label}: claude-default profiles include shared:claude-activity-hooks`, () => {
expect(poolIds(SHARED_AGENT_YAML, "runtime_resources").has("claude-activity-hooks")).toBe(true);
const claudeProfiles = usedRuntimeResources(agentPath)
.filter(({ runtimeResources }) => runtimeResources.includes("shared:claude-default-settings"));
expect(claudeProfiles.length, `${label} declares no claude-default profile`).toBeGreaterThan(0);
for (const { profile, runtimeResources } of claudeProfiles) {
expect(runtimeResources, `${label} profile=${profile}`).toContain("shared:claude-activity-hooks");
}
});
}
});
describe("kernel agents — nested AgentSpec validation", () => {
@@ -85,6 +85,29 @@ describe("parseMarkdownSections — code-fence-protected header tree", () => {
"reference/commands-flags",
]);
});
it.each(["```", "~~~"])("keeps a %s fence open when a marker line has trailing text", (marker) => {
const text = [
"## Actual", `${marker}text`, `${marker} not a closing fence`,
"## Example heading", marker, "## Next", "real prose",
].join("\n");
expect(parseMarkdownSections(text).map((section) => section.headerPath)).toEqual([["actual"], ["next"]]);
expect(resolveAddress(text, ["actual"]).text).toContain("## Example heading");
expect(() => resolveAddress(text, ["example-heading"])).toThrow(AddressResolutionError);
expect(resolveAddress(text, ["next"]).text).toContain("real prose");
expect(validateMarkdownAddressability(text)).toEqual([]);
});
it.each(["```", "~~~"])("preserves %s info strings, marker matching, length and CRLF whitespace closers", (marker) => {
const other = marker === "```" ? "~~~" : "```";
const text = [
"## Actual", `${marker}${marker[0]}language`, marker, "## Too short", other,
"## Different marker", `${marker}${marker[0]}${marker[0]} \t`, "## Next", "prose",
].join("\r\n");
expect(parseMarkdownSections(text).map((section) => section.headerPath)).toEqual([["actual"], ["next"]]);
expect(resolveAddress(text, ["actual"]).text).toContain("## Different marker");
expect(validateMarkdownAddressability(text)).toEqual([]);
});
it("spans terminate at the next SAME-OR-HIGHER header (Q1), own text at ANY header", () => {
const sections = parseMarkdownSections(CORPUS);
const gettingStarted = sections.find((s) => s.headerPath.join("/") === "getting-started")!;
@@ -0,0 +1,82 @@
// Native process observation must not depend on the daemon's inherited locale.
// `ps` formats lstart through LC_TIME, so a non-English locale used to make every
// row fail the parser and both listers returned []. This drives the REAL listers
// (real execFile, real child environment) against a hermetic `ps` that formats
// its date the way the effective locale would: LC_ALL, then LC_TIME, then LANG.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { listNativeProcesses } from "../src/domain/native-process-lineage.js";
import { defaultListProcessesStrict } from "../src/domain/resume-metadata-refresher.js";
const ENGLISH_START = "Wed Sep 30 21:44:48 2026";
const MARKER = "synthetic-unrelated-marker";
let scratch: string | undefined;
afterEach(() => {
vi.unstubAllEnvs();
if (scratch) fs.rmSync(scratch, { recursive: true, force: true });
scratch = undefined;
});
function installLocaleAwarePs(): string {
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "native-ps-locale-"));
const envLog = path.join(scratch, "child-env.txt");
fs.writeFileSync(path.join(scratch, "ps"), [
"#!/bin/sh",
`/usr/bin/env > '${envLog}'`,
'effective="${LC_ALL:-${LC_TIME:-$LANG}}"',
'case "$effective" in',
` ""|C|C.*|POSIX|en_*) started="${ENGLISH_START}" ;;`,
' *) started="Mi 30 Sep 21:44:48 2026" ;;',
"esac",
'case "$2" in',
' *ucomm*) printf \'%s\\n\' "PID PPID PGID TPGID UCOMM STARTED COMMAND" "101 1 101 101 codex $started codex resume tok-1 $OPENRIG_PS_MARKER" "102 1 102 102 zsh $started -zsh" ;;',
' *) printf \'%s\\n\' "PID PPID STARTED COMMAND" "101 1 $started codex resume tok-1 $OPENRIG_PS_MARKER" "102 1 $started -zsh" ;;',
"esac",
"",
].join("\n"), { mode: 0o755 });
vi.stubEnv("PATH", scratch);
vi.stubEnv("OPENRIG_PS_MARKER", MARKER);
return envLog;
}
const INHERITED_LOCALES: Array<{ name: string; LC_ALL?: string; LC_TIME?: string; LANG?: string }> = [
{ name: "LANG=de_DE.UTF-8", LANG: "de_DE.UTF-8" },
{ name: "LC_TIME=fr_FR.UTF-8 over LANG=en_US.UTF-8", LC_TIME: "fr_FR.UTF-8", LANG: "en_US.UTF-8" },
{ name: "LC_ALL=ja_JP.UTF-8", LC_ALL: "ja_JP.UTF-8" },
];
const LISTERS = [
["listNativeProcesses", listNativeProcesses],
["defaultListProcessesStrict", defaultListProcessesStrict],
] as const;
describe.each(LISTERS)("%s under an inherited non-English locale", (_name, list) => {
for (const locale of INHERITED_LOCALES) {
it(`keeps every row under ${locale.name}, with a child-only C locale`, async () => {
const envLog = installLocaleAwarePs();
vi.stubEnv("LC_ALL", locale.LC_ALL);
vi.stubEnv("LC_TIME", locale.LC_TIME);
vi.stubEnv("LANG", locale.LANG);
const rows = await list();
expect(rows.map((row) => row.pid)).toEqual([101, 102]);
expect(rows.every((row) => row.startedAt === ENGLISH_START)).toBe(true);
expect(rows[0]!.command).toBe(`codex resume tok-1 ${MARKER}`);
const childEnv = fs.readFileSync(envLog, "utf8").split("\n");
expect(childEnv).toContain("LC_ALL=C");
expect(childEnv).toContain(`OPENRIG_PS_MARKER=${MARKER}`);
expect(childEnv).toContain(`PATH=${scratch}`);
// The override is child-only: the daemon's own environment is untouched.
expect(process.env.LC_ALL).toBe(locale.LC_ALL);
expect(process.env.LC_TIME).toBe(locale.LC_TIME);
expect(process.env.LANG).toBe(locale.LANG);
});
}
});
@@ -48,7 +48,8 @@ describe("GHOST-STAGE (e) DefaultOccupantInvalidator", () => {
watchdog: { dropArmedByRegisteringGeneration },
log: (m) => logs.push(m),
}).invalidateRetiringOccupant({ retiringSessionName: "seat@rig", successorSessionName: "seat@rig", retiringGeneration: "gen-retired" });
expect(dropArmedByRegisteringGeneration).toHaveBeenCalledWith("gen-retired");
// No queue dep wired, so no park timers are kept.
expect(dropArmedByRegisteringGeneration).toHaveBeenCalledWith("gen-retired", []);
expect(logs.some((l) => /stopped 2 armed watchdog/.test(l))).toBe(true);
});
@@ -7,19 +7,27 @@ import type { RigSpec } from "../src/domain/types.js";
// -- Mock filesystem --
function mockFs(files: Record<string, string>): PodAssemblerFsOps {
const written: Record<string, string> = {};
function mockFs(files: Record<string, string | Uint8Array>): PodAssemblerFsOps {
const written: Record<string, string | Uint8Array> = {};
const dirs = new Set<string>();
const read = (p: string): string | Uint8Array => {
if (p in files) return files[p]!;
if (p in written) return written[p]!;
throw new Error(`File not found: ${p}`);
};
return {
readFile: (p: string) => {
if (p in files) return files[p]!;
if (p in written) return written[p]!;
throw new Error(`File not found: ${p}`);
const v = read(p);
return typeof v === "string" ? v : Buffer.from(v).toString("utf8");
},
readFileBuffer: (p: string) => {
const v = read(p);
return typeof v === "string" ? Buffer.from(v, "utf8") : v;
},
exists: (p: string) => p in files || p in written,
mkdirp: (p: string) => { dirs.add(p); },
writeFile: (p: string, content: string) => { written[p] = content; },
writeFile: (p: string, content: string | Uint8Array) => { written[p] = content; },
copyDir: () => {},
listFiles: (dirPath: string) => {
const result: string[] = [];
@@ -31,7 +39,7 @@ function mockFs(files: Record<string, string>): PodAssemblerFsOps {
return result;
},
_written: written, // for test inspection
} as PodAssemblerFsOps & { _written: Record<string, string> };
} as PodAssemblerFsOps & { _written: Record<string, string | Uint8Array> };
}
// -- Helpers --
@@ -463,6 +471,33 @@ describe("PodBundleAssembler", () => {
});
// T11: integration: assemble -> verify manifest + file contents
it("copies agent-package and rig files as bytes, never decoding them", () => {
const spec = makeRigSpec({
cultureFile: "culture.md",
startup: { files: [{ path: "startup/blob.bin", deliveryHint: "auto", required: true, appliesOn: ["fresh_start", "restore"] }], actions: [] },
});
const binary = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff]);
const culture = Uint8Array.from([0x23, 0x20, 0xa0, 0xa1, 0x0a]);
const startup = Uint8Array.from([0xfe, 0xed, 0x00, 0xc0]);
const files: Record<string, string | Uint8Array> = {
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
[`${RIG_ROOT}/culture.md`]: culture,
[`${RIG_ROOT}/startup/blob.bin`]: startup,
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
[`${RIG_ROOT}/agents/impl/assets/logo.png`]: binary,
};
const fs = mockFs(files);
new PodBundleAssembler({ fsOps: fs }).assemble({
rigRoot: RIG_ROOT, rigSpecPath: `${RIG_ROOT}/rig.yaml`,
outputDir: "/tmp/staging", bundleName: "bytes", bundleVersion: "1.0.0",
});
const written = (fs as unknown as { _written: Record<string, string | Uint8Array> })._written;
expect(Buffer.from(written["/tmp/staging/agents/impl/assets/logo.png"]!).equals(Buffer.from(binary))).toBe(true);
expect(Buffer.from(written["/tmp/staging/culture.md"]!).equals(Buffer.from(culture))).toBe(true);
expect(Buffer.from(written["/tmp/staging/startup/blob.bin"]!).equals(Buffer.from(startup))).toBe(true);
});
it("integration: assembled bundle has correct manifest and files", () => {
const spec = makeRigSpec({
cultureFile: "culture.md",
@@ -492,7 +527,7 @@ describe("PodBundleAssembler", () => {
// Verify written files exist
const written = (fs as unknown as { _written: Record<string, string> })._written;
expect(written["/tmp/staging/rig.yaml"]).toBeDefined();
expect(written["/tmp/staging/culture.md"]).toBe("# Culture doc");
expect(Buffer.from(written["/tmp/staging/culture.md"]!).toString("utf8")).toBe("# Culture doc");
expect(written["/tmp/staging/bundle.yaml"]).toBeDefined();
});
@@ -11,6 +11,10 @@
// composition never silently truncates (mini-req 9, D2: budgets flag, never govern).
import { describe, it, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { makeProfileReadFile } from "../src/domain/context-packs/profile-source-resolver.js";
import type { ContextPackAtom } from "../src/domain/context-packs/context-pack-types.js";
import { composeProfile, ProfileComposeError } from "../src/domain/context-packs/profile-composer.js";
@@ -109,6 +113,24 @@ describe("composeProfile — runtime split (mini-req 3)", () => {
});
describe("composeProfile — fail-loud resolution (the Atom-1 contract carried through)", () => {
it("composes a real section after a fenced example containing a marker with trailing text", () => {
const root = mkdtempSync(join(tmpdir(), "profile-fence-"));
try {
writeFileSync(join(root, "walk.md"), [
"## Example", "```markdown", "```language", "## Example-only heading", "```",
"## Instructions", "continue with the real instructions",
].join("\n"));
const readFile = makeProfileReadFile({ packDir: root, roots: {} });
const composed = composeProfile({
atoms: [atom({ id: "instructions", address: "walk.md#instructions" })],
situation: "fresh", runtime: "claude", readFile,
});
expect(composed.pieces[0]?.text).toBe("## Instructions\ncontinue with the real instructions");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("a missing file stops the compose with the atom named", () => {
const graph = [atom({ id: "ghosty", address: "ghost.md#nope", order: 1 })];
expect(() => composeProfile({ atoms: graph, situation: "fresh", runtime: "claude", readFile }))
+37 -2
View File
@@ -7,7 +7,9 @@
// the threshold — the anti-noise direction);
// stalled-after-claim — claimed, past threshold, zero substantive motion; evidence named;
// parked — state=blocked only. Wake health is a separate `rig parked`
// diagnosis; a wakeless blocked row still projects as parked.
// diagnosis; a wakeless blocked row still projects as parked;
// terminal — the obligation is closed (done/handed-off/failed/denied/canceled):
// no pickup question is outstanding, regardless of claim age.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
@@ -22,7 +24,7 @@ import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
const THRESHOLD_ENV = "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES";
interface PickupShape {
state: "unclaimed" | "working" | "stalled-after-claim" | "parked";
state: "unclaimed" | "working" | "stalled-after-claim" | "parked" | "terminal";
evidence?: string;
}
const pickupOf = (item: QueueItem): PickupShape | undefined =>
@@ -129,6 +131,39 @@ describe("S04 pickup receipts — derived, visible, threshold-honest", () => {
expect(pickupOf(item)?.state).toBe("parked");
});
it("TERMINAL: a claimed-and-closed done row past the threshold stops reading stalled-after-claim", async () => {
const row = await mkRow();
repo.claim({ qitemId: row.qitemId, destinationSession: "worker@r" });
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done", closureReason: "no-follow-on", transitionNote: "closed" });
// Age the WHOLE history 60 min: the closed row must not fall through to the
// stall timer once its last meaningful change is older than the threshold.
ageClaim(row.qitemId, 60);
db.prepare("UPDATE queue_transitions SET ts = ? WHERE qitem_id = ? AND state = 'done'")
.run(new Date(Date.now() - 60 * 60_000).toISOString(), row.qitemId);
const item = repo.getById(row.qitemId)!;
expect(item.state).toBe("done");
expect(pickupOf(item)?.state).toBe("terminal");
});
it("TERMINAL: every closed state derives terminal, and a terminal row is never a stalled finding", async () => {
const mod = (await import("../src/domain/queue-pickup.js")) as {
derivePickup: (facts: {
state: string; claimedAt: string | null; lastHeartbeat: string | null;
postClaimMotionCount: number; lastMeaningfulAt?: string;
}) => PickupShape;
stalledPickupFinding: (item: QueueItem) => { kind: string } | null;
};
const old = new Date(Date.now() - 60 * 60_000).toISOString();
for (const state of ["done", "failed", "denied", "canceled", "handed-off"]) {
expect(mod.derivePickup({ state, claimedAt: old, lastHeartbeat: null, postClaimMotionCount: 0 }))
.toEqual({ state: "terminal", evidence: `closed (${state})` });
}
// A terminal row never feeds the S02 sweep as a stalled-claim finding.
const done = await mkRow();
repo.update({ qitemId: done.qitemId, actorSession: "daemon@kernel", state: "done", closureReason: "no-follow-on", transitionNote: "closed" });
expect(mod.stalledPickupFinding(repo.getById(done.qitemId)!)).toBeNull();
});
it("THRESHOLD from CONFIG, observed by EFFECT: raising the threshold flips a stalled row back to working", async () => {
const row = await mkRow();
repo.claim({ qitemId: row.qitemId, destinationSession: "worker@r" });
+31
View File
@@ -181,6 +181,25 @@ describe("Rig CRUD routes", () => {
expect(body.env.OPENRIG_SESSION_NAME).toBe("orch1-lead@rigged-buildout");
});
it.each(["claude-code", "codex"])("attach-self checks the optional existing-node runtime guard %s", async (runtime) => {
const rig = repo.createRig("runtime-guard");
const node = repo.addNode(rig.id, "dev.worker", { runtime: "claude-code" });
const res = await app.request(`/api/rigs/${rig.id}/attach-self`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ logicalId: "dev.worker", runtime, displayName: "worker@runtime-guard" }),
});
if (runtime === "claude-code") {
expect(res.status).toBe(201);
expect((await res.json()).nodeId).toBe(node.id);
expect(sessionRegistry.getBindingForNode(node.id)).toBeTruthy();
} else {
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("runtime_mismatch");
expect(sessionRegistry.getBindingForNode(node.id)).toBeFalsy();
}
});
it("POST /api/rigs/:id/attach-self can self-attach a tmux-backed shell without discovery", async () => {
const rig = repo.createRig("rigged-buildout");
const node = repo.addNode(rig.id, "dev1.impl2", { runtime: "claude-code" });
@@ -259,6 +278,18 @@ describe("Rig CRUD routes", () => {
expect(body.error).toContain("either logicalId");
});
it("attach-self still requires a runtime when creating a pod member", async () => {
const rig = repo.createRig("missing-pod-runtime");
db.prepare("INSERT INTO pods (id, rig_id, namespace, label) VALUES (?, ?, ?, ?)").run("guard-pod", rig.id, "dev", "Dev");
const res = await app.request(`/api/rigs/${rig.id}/attach-self`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ podNamespace: "dev", memberName: "worker" }),
});
expect(res.status).toBe(400);
expect(repo.getRig(rig.id)?.nodes).toHaveLength(0);
});
it("POST /api/rigs/:id/attach-self returns 409 when the target node is already bound", async () => {
const rig = repo.createRig("rigged-buildout");
const node = repo.addNode(rig.id, "orch1.lead", { runtime: "claude-code" });
+61 -6
View File
@@ -11,10 +11,11 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
import { seatLifecycleService } from "../src/routes/seat.js";
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
// Never run a provider, shell, tmux or startup. OS confinement also denies them.
// No provider, tmux or startup. Config-selection cases execute only a private
// fake Claude through the generated shell command; other child calls are mocked.
vi.mock("node:child_process", () => ({ execFile: vi.fn() }));
// sandbox-exec deliberately denies execute eligibility too. Only that access
// check is simulated; path resolution/stat/replacement use private real files.
// Simulate eligibility for inert fixtures; path resolution/stat/replacement use
// private real files. Config-selection cases additionally execute their fake.
vi.mock("node:fs", async importOriginal => {
const fs = await importOriginal<typeof import("node:fs")>();
return { ...fs, accessSync: (file: string) => { if (!(fs.statSync(file).mode & 0o111)) throw Error("not executable"); } };
@@ -65,6 +66,58 @@ function fixture() {
const input = { seatRef: "owner@rig", mode: "auto", actor: "operator", reason: "deliberate choice" };
describe("S03 production managed capability selection", () => {
it.each([
["unset", undefined], ["relative", "./config"],
["absolute", "/inert/explicit-config"], ["empty", ""],
])("preserves %s config selection in help and the executed launch", async (_label, selected) => {
const f = fixture();
if (selected === undefined) delete f.env.CLAUDE_CONFIG_DIR;
else f.env.CLAUDE_CONFIG_DIR = selected;
const helpReceipt = path.join(f.cwd, "help-env.json");
// Observe the actual child environment, using only synthetic credentials.
writeFileSync(f.executable, `#!${process.execPath}\n
const fs = require('node:fs');
const keys = ['CLAUDE_CONFIG_DIR', 'HOME', 'ANTHROPIC_API_KEY', 'OPENRIG_HOME',
'OPENRIG_NODE_ID', 'OPENRIG_RUNTIME', 'OPENRIG_SESSION_NAME', 'OPENRIG_OCCUPANT_GENERATION'];
const env = Object.fromEntries(keys.filter(k => process.env[k] !== undefined).map(k => [k, process.env[k]]));
if (process.argv.includes('--help')) {
fs.writeFileSync(${JSON.stringify(helpReceipt)}, JSON.stringify(env));
console.log(${JSON.stringify(help)});
} else console.log(JSON.stringify({ env, args: process.argv.slice(2), cwd: process.cwd() }));
`);
const native = await vi.importActual<typeof import("node:child_process")>("node:child_process");
vi.mocked(execFile).mockImplementation(native.execFile);
const prepared = await f.managed.prepare({ nodeId: "node", session: "seat", pane: "%1" }, "auto");
const configDir = path.resolve(f.cwd, selected ?? path.join(f.env.HOME!, ".claude"));
expect(prepared.configDir).toBe(configDir);
const args = ["--permission-mode", "auto", "--name", "seat's literal name"];
const command = prepared.command(args);
expect(command).not.toContain(f.env.ANTHROPIC_API_KEY);
const stdout = await new Promise<string>((resolve, reject) => {
native.execFile("/bin/sh", ["-c", command], { encoding: "utf8", timeout: 3000,
env: { ...f.env, CLAUDE_CONFIG_DIR: "/inert/not-the-managed-selection", OPENRIG_NODE_ID: "not-the-target" } },
(error, out) => error ? reject(error) : resolve(out));
});
const launched = JSON.parse(stdout);
const queried = JSON.parse(readFileSync(helpReceipt, "utf8"));
for (const env of [queried, launched.env]) {
if (selected === undefined) expect.soft(env).not.toHaveProperty("CLAUDE_CONFIG_DIR");
else expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
}
if (selected === undefined) expect.soft(command).not.toContain("CLAUDE_CONFIG_DIR=");
expect(queried).not.toHaveProperty("ANTHROPIC_API_KEY");
expect(launched).toMatchObject({ cwd: f.cwd, args, env: {
HOME: f.env.HOME, ANTHROPIC_API_KEY: f.env.ANTHROPIC_API_KEY, OPENRIG_HOME: f.env.OPENRIG_HOME,
OPENRIG_NODE_ID: "node", OPENRIG_RUNTIME: "claude-code", OPENRIG_SESSION_NAME: "seat",
OPENRIG_OCCUPANT_GENERATION: "generation-1",
} });
});
it("detects unset config becoming explicit even when the storage directory stays the same", async () => {
const f = fixture(); delete f.env.CLAUDE_CONFIG_DIR;
const prepared = await f.managed.prepare({ nodeId: "node", session: "seat" }, "auto");
f.env.CLAUDE_CONFIG_DIR = path.join(f.env.HOME!, ".claude");
expect(() => prepared.command([])).toThrow(/context changed/);
});
it("selects and audits through the production service/adapter seam, without launch or secret copies", async () => {
const f = fixture(); vi.stubEnv("PATH", f.daemonBin);
expect(await f.service.setPermissions(input)).toEqual(expect.objectContaining({ ok: true, changed: true, to: { runtime: "claude-code", mode: "auto" } }));
@@ -145,10 +198,12 @@ describe("S03 bound launch across existing paths", () => {
expect(f.calls[0]).toContain("'OPENRIG_OCCUPANT_GENERATION=reserved-next'");
expect(f.db.prepare("SELECT generation_uuid FROM occupant_tenures").get()).toEqual({ generation_uuid: "generation-1" });
});
it("captures the fork token only from the bound config root and preserves call-entry arguments", async () => {
it.each(["unset", "explicit"])("captures the fork token from the %s config root and preserves call-entry arguments", async selection => {
const f = fixture(); const reads: string[] = [];
if (selection === "unset") delete f.env.CLAUDE_CONFIG_DIR;
const configDir = selection === "unset" ? path.join(f.env.HOME!, ".claude") : path.join(f.cwd, "config");
const adapter = new ClaudeCodeAdapter({ tmux: f.tmux, claudeManagedLaunch: f.managed, fsOps: { ...fsOps,
homedir: "/different/daemon/home", exists: p => p === path.join(f.cwd,"config","sessions"), readdir: () => ["token.json"],
homedir: "/different/daemon/home", exists: p => p === path.join(configDir,"sessions"), readdir: () => ["token.json"],
readFile: p => { reads.push(p); return JSON.stringify({ name: "original", sessionId: "new-fork-token" }); } } });
const opts = { name: "original", forkSource: { kind: "native_id" as const, value: "original-parent" } };
vi.mocked(execFile).mockImplementation(((_f: string, _a: string[], _o: any, done: any) => {
@@ -156,7 +211,7 @@ describe("S03 bound launch across existing paths", () => {
opts.name = "changed"; opts.forkSource.value = "other-history"; done(null, help);
}) as any);
expect(await adapter.launchHarness(f.binding, opts)).toMatchObject({ ok: true, resumeToken: "new-fork-token" });
expect(reads).toEqual([path.join(f.cwd,"config","sessions","token.json")]);
expect(reads).toEqual([path.join(configDir,"sessions","token.json")]);
expect(f.calls[0]).toContain("'--resume' 'original-parent'"); expect(f.calls[0]).toContain("'--name' 'original'");
expect(f.calls[0]).not.toContain("other-history"); expect(f.calls[0]).not.toContain("other-model");
});
@@ -17,6 +17,8 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
import { observeCodexSandbox } from "../src/domain/permission-drift.js";
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { DefaultOccupantInvalidator, type OccupantInvalidator } from "../src/domain/occupant-invalidator.js";
describe("SeatHandoverService", () => {
let db: Database.Database;
@@ -96,7 +98,7 @@ describe("SeatHandoverService", () => {
return { runtime: "codex", launchHarness, checkReady } as unknown as RuntimeAdapter;
}
function newService(adapter: TmuxAdapter = tmux()): SeatHandoverService {
function newService(adapter: TmuxAdapter = tmux(), occupantInvalidator: OccupantInvalidator = { invalidateRetiringOccupant }): SeatHandoverService {
return new SeatHandoverService({
db,
rigRepo,
@@ -109,7 +111,7 @@ describe("SeatHandoverService", () => {
runtimeAdapters: { codex: codexAdapter() },
contextUsageStore: { readSidecar } as never,
resumeTokenCapturer: { captureCodexThreadId } as never,
occupantInvalidator: { invalidateRetiringOccupant },
occupantInvalidator,
activityOracle: { declareOccupantSwap },
predecessorRecapResolver: resolvePredecessorRecap as never,
readinessTimeoutMs: 50,
@@ -1115,6 +1117,77 @@ describe("SeatHandoverService", () => {
expect(durableRows()).toBe(before);
});
// #141: a YAML re-import archives the stopped earlier generation, which keeps its binding to the seat's
// canonical session name. A composer-launched successor reuses that name, so the archived shadow must not
// reject the live seat's handover after the successor has already replaced the process.
function seedArchivedGeneration(): void {
const old = rigRepo.createRig("seat-rig");
const oldNode = rigRepo.addNode(old.id, "dev.impl", { runtime: "codex", cwd: "/project" });
sessionRegistry.updateStatus(sessionRegistry.registerSession(oldNode.id, "dev-impl@seat-rig").id, "exited");
sessionRegistry.updateBinding(oldNode.id, { tmuxSession: "dev-impl@seat-rig", tmuxPane: "%0" });
rigRepo.archiveRig(old.id);
}
it.each(["fresh", "rebuild"] as const)("#141: an archived earlier generation's binding does not block a %s handover of the live seat", async (source) => {
seedArchivedGeneration();
const { node } = seedSeat();
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source });
expect(result).toMatchObject({ ok: true });
expect(respawnPane).toHaveBeenCalledTimes(1);
expect(killSession).not.toHaveBeenCalled();
expect(sessionRegistry.getBindingForNode(node.id)?.tmuxSession).toBe("dev-impl@seat-rig");
const nodeRow = db.prepare("SELECT handover_result FROM nodes WHERE id = ?").get(node.id) as { handover_result: string | null };
expect(nodeRow.handover_result).toBe("complete");
});
it("#141 control: another UNARCHIVED rig's binding to the seat's session name still blocks a fresh handover", async () => {
seedSeat();
const otherRig = rigRepo.createRig("other-rig");
const otherNode = rigRepo.addNode(otherRig.id, "dev.other", { runtime: "codex" });
sessionRegistry.updateBinding(otherNode.id, { tmuxSession: "dev-impl@seat-rig" });
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "fresh" });
expect(result).toMatchObject({ ok: false, code: "successor_already_managed" });
});
it("#141 control: an archived rig's binding still blocks a DISCOVERED successor of that name", async () => {
seedSeat();
const discovered = seedDiscovery();
const archivedRig = rigRepo.createRig("archived-rig");
const archivedNode = rigRepo.addNode(archivedRig.id, "dev.other", { runtime: "codex" });
sessionRegistry.updateBinding(archivedNode.id, { tmuxSession: "successor-session" });
rigRepo.archiveRig(archivedRig.id);
const before = durableRows();
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: `discovered:${discovered.id}` });
expect(result).toMatchObject({ ok: false, code: "successor_already_managed" });
expect(durableRows()).toBe(before);
});
it("a successful fresh handover keeps the seat's blocked row's park timer live (real invalidator and repositories)", async () => {
seedSeat();
const gen = (session: string) => sessionRegistry.currentOccupantGenerationForSession(session);
const queue = new QueueRepository(db, eventBus, { validateRig: () => true, resolveOccupantGeneration: gen });
const jobs = new WatchdogJobsRepository(db, undefined, gen);
queue.attachWatchdogJobsRepository(jobs);
const row = await queue.create({ sourceSession: "orch-lead@seat-rig", destinationSession: "dev-impl@seat-rig", body: "work" } as never);
queue.update({ qitemId: row.qitemId, actorSession: "dev-impl@seat-rig", state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: check CI", wakeAfterSeconds: 1800 } as never);
const timer = (queue.getParkWakeStatus(row.qitemId) as { ref: string }).ref;
const handover = newService(tmux(), new DefaultOccupantInvalidator({
enforcer: { invalidateOccupant() {} }, contextUsage: { invalidateOccupantSidecar() {} }, watchdog: jobs, queue,
}));
const result = await handover.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "fresh" });
expect(result.ok).toBe(true);
expect(jobs.getById(timer)?.state).toBe("active");
expect((queue.getParkWakeStatus(row.qitemId) as { live: boolean }).live).toBe(true);
});
it("fails before mutation when the seat has no current occupant", async () => {
seedSeat({ withSession: false });
const discovered = seedDiscovery();
@@ -72,8 +72,8 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
const { tmux, sendText, sendKeys } = tmuxWithPane(async () => shell);
const result = await watchdogSend(new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux }), "dev-impl@my-rig");
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
expect(result.error).toContain(`bare ${shell.replace(/^-/, "")} shell`);
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
expect(result.error).toContain(`${shell.replace(/^-/, "")} as the foreground command`);
expect(result.error).toContain("No text was sent");
expect(sendText).not.toHaveBeenCalled();
expect(sendKeys).not.toHaveBeenCalled();
@@ -151,7 +151,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
];
it.each(unproved)("shell label still refuses %s without input", async (_name, mutate) => {
const { transport, sendText, sendKeys } = wrappedSeat(vi.fn(async () => mutate(wrapperProcesses())));
expect(await watchdogSend(transport, "dev-check@my-rig")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
expect(await watchdogSend(transport, "dev-check@my-rig")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
expect(sendText).not.toHaveBeenCalled();
expect(sendKeys).not.toHaveBeenCalled();
});
@@ -163,7 +163,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
if (kind === "missing resume identity") sessionRegistry.clearResumeToken(session.id);
if (kind === "changed process") listProcesses.mockResolvedValueOnce(wrapperProcesses()).mockResolvedValueOnce(wrapperProcesses().slice(0, -1));
if (kind === "unavailable processes") listProcesses.mockRejectedValue(new Error("process observation failed"));
expect(await watchdogSend(transport, "dev-check@my-rig")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
expect(await watchdogSend(transport, "dev-check@my-rig")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
expect(sendText).not.toHaveBeenCalled();
expect(sendKeys).not.toHaveBeenCalled();
});
@@ -223,7 +223,10 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
];
it.each(unprovedClaude)("#197 retains refusal for %s", async (_label, mutate) => {
const { transport, sendText, sendKeys } = wrappedClaude(vi.fn(async () => mutate(claudeProcesses())));
expect(await transport.send("dev-check@my-rig", "existing review")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
const result = await transport.send("dev-check@my-rig", "existing review");
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
expect(result.error).toContain("could not verify");
expect(result.error).not.toMatch(/runtime is not running|Relaunch the seat|would run as shell commands/);
expect(sendText).not.toHaveBeenCalled();
expect(sendKeys).not.toHaveBeenCalled();
});
@@ -234,7 +237,10 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
if (kind === "changed process") listProcesses.mockResolvedValueOnce(claudeProcesses()).mockResolvedValueOnce(claudeProcesses().slice(0, -1));
if (kind === "changed bound pane") tmux.getPanePid = async target => target === "%1" ? 999 : 1135;
if (kind === "process lookup failed") listProcesses.mockRejectedValue(new Error("unavailable"));
expect(await transport.send("dev-check@my-rig", "existing review")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
const result = await transport.send("dev-check@my-rig", "existing review");
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
expect(result.error).toContain("could not verify");
expect(result.error).not.toMatch(/runtime is not running|Relaunch the seat|would run as shell commands/);
expect(sendText).not.toHaveBeenCalled();
expect(sendKeys).not.toHaveBeenCalled();
});
@@ -266,7 +272,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
expect(sendKeys).toHaveBeenCalledOnce();
} else {
expect(stored.lastNudgeResult).toContain("failed:");
expect(stored.lastNudgeResult).toContain("bare sh shell");
expect(stored.lastNudgeResult).toContain("could not verify");
expect(sendText).not.toHaveBeenCalled();
expect(sendKeys).not.toHaveBeenCalled();
}
@@ -322,7 +328,7 @@ describe("#142 the parked-owner wake records the refusal honestly and does not r
const first = await makeParkedOwnerConsumerPolicy(deps()).evaluate(job);
expect(first.action).toBe("send");
const refusal = `Refused: '${SEAT}' shows a bare zsh shell, so its claude-code runtime is not running. Text sent there would run as shell commands. Relaunch the seat first. No text was sent.`;
const refusal = `Refused: '${SEAT}' reports zsh as the foreground command, but OpenRig could not verify its expected claude-code agent in the bound pane. The agent may still be running behind a wrapper. No text was sent.`;
history.push({
historyId: "h1", jobId: "job-1", evaluatedAt: new Date().toISOString(), outcome: "sent", skipReason: null,
deliveryTargetSession: SEAT, deliveryStatus: "failed", deliveryMessage: "wake",
@@ -332,7 +338,7 @@ describe("#142 the parked-owner wake records the refusal honestly and does not r
const second = await makeParkedOwnerConsumerPolicy(deps()).evaluate(job);
expect(second.action).toBe("skip");
expect(JSON.stringify(second.notes)).toMatch(/already[-_]woken/);
expect(transitions.some((t) => t.transitionNote?.startsWith(FAILED_PREFIX) && t.transitionNote.includes("runtime is not running"))).toBe(true);
expect(nudges.some((n) => n.startsWith(NUDGE_FAIL_PREFIX) && n.includes("runtime is not running"))).toBe(true);
expect(transitions.some((t) => t.transitionNote?.startsWith(FAILED_PREFIX) && t.transitionNote.includes("could not verify"))).toBe(true);
expect(nudges.some((n) => n.startsWith(NUDGE_FAIL_PREFIX) && n.includes("could not verify"))).toBe(true);
});
});
@@ -274,6 +274,33 @@ describe("PL-slice-story-view-v0 slices routes", () => {
expect(body[99]).toBe(99);
});
it.each(["bytes=-0", "bytes=1000-"])("rejects unsatisfiable proof asset range %s", async (range) => {
writeSlice(slicesRoot, "invalid-range-slice", { "README.md": "---\n---\n" });
const dir = path.join(dogfoodRoot, "invalid-range-slice-20260504", "videos");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "demo.mp4"), Buffer.alloc(1000));
const res = await app.request("/api/slices/invalid-range-slice/proof-asset/videos/demo.mp4", {
headers: { Range: range },
});
expect(res.status).toBe(416);
expect(res.headers.get("Content-Range")).toBe("bytes */1000");
});
it.each(["bytes=-1000", "bytes=-2000"])("serves a whole proof asset for long suffix %s", async (range) => {
writeSlice(slicesRoot, "suffix-video-slice", { "README.md": "---\n---\n" });
const dir = path.join(dogfoodRoot, "suffix-video-slice-20260504", "videos");
fs.mkdirSync(dir, { recursive: true });
const bytes = Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251));
fs.writeFileSync(path.join(dir, "demo.mp4"), bytes);
const res = await app.request("/api/slices/suffix-video-slice/proof-asset/videos/demo.mp4", {
headers: { Range: range },
});
expect(res.status).toBe(206);
expect(res.headers.get("Content-Range")).toBe("bytes 0-999/1000");
expect(res.headers.get("Content-Length")).toBe("1000");
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array(bytes));
});
it("rejects path-traversal attempts with 400", async () => {
writeSlice(slicesRoot, "trav-slice", { "README.md": "---\n---\n" });
const dir = path.join(dogfoodRoot, "trav-slice-20260504");
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more