mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 18:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
515bff6ceb | ||
|
|
48c4800828 | ||
|
|
bd08e0f094 | ||
|
|
c2815a6139 | ||
|
|
a1dd3333b4 | ||
|
|
0a6d575bd8 | ||
|
|
94fa158ce2 | ||
|
|
315316301a | ||
|
|
f809178714 | ||
|
|
27444ad275 | ||
|
|
3b32f76d12 | ||
|
|
8476bf03d5 | ||
|
|
2c24b49e4f | ||
|
|
921e09cce3 | ||
|
|
675af15159 | ||
|
|
e6e0702045 | ||
|
|
ac921ac5d1 | ||
|
|
bfd3a7f596 | ||
|
|
240598c11d | ||
|
|
aefbf29938 | ||
|
|
75a56e530c | ||
|
|
1a2b1d5b75 | ||
|
|
2ae1df25a4 | ||
|
|
3d3eb9f09f | ||
|
|
9efe42b257 | ||
|
|
835526b565 | ||
|
|
3bb7a410d5 | ||
|
|
602ba44ff1 | ||
|
|
2d0280dc63 | ||
|
|
5dbc554b76 | ||
|
|
93600b92ca | ||
|
|
53b6c1e253 | ||
|
|
eae973d9ec | ||
|
|
ed5a47b7af | ||
|
|
b0ff239df6 | ||
|
|
3949c8adb7 | ||
|
|
07ed2ebb7f | ||
|
|
db713f34e6 | ||
|
|
5d7ee4ffb7 |
No files matched your search
@@ -119,3 +119,4 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
|
||||
- 2026-11-10, F4 (0.7.11, #2662 leg (a)), delivery (draft-aware, under Claude's update banner): beneath Claude's bare update banner, a draft-aware delivery pasted the message and withheld Enter, leaving it in the composer, where 0.7.10 held it. Caught at the cut by a native delivery run and accepted with disclosure, so it reached 0.7.11. Missed by footer-grammar fixtures that had no paste hint beside the update notice. Fixed in #2786 for 0.7.12.
|
||||
- 2026-11-10, Pi case 1c (0.7.11, #2630), launch (Pi account home): a Pi seat with its own Pi login and a declared absolute `PI_CODING_AGENT_DIR` stopped at launch even with an `auth.json` there, with a misleading missing-account-file error, where 0.7.10 started it. Found at the cut by source and function-level tests, not natively, and accepted with disclosure, so it reached 0.7.11. Not fixed: #2803 (0.7.12) makes the launch error name the real cause, and the stop stands. Scenario owed: a Pi seat with its own login and a declared account home.
|
||||
- 2026-11-12, a tracked `AGENTS.md` (0.7.12, repaired by #3039), restore and relaunch (Codex guidance blocks): on a Codex seat, a snapshot `rig restore`, and `rig down` followed by `rig up --existing`, each wrote OpenRig's managed guidance blocks back into the person's tracked `AGENTS.md` (249 B to 17,316 B), leaving the worktree modified. Seen at the cut by a native Codex run through restore and `up --existing`, and accepted with disclosure, so it reached 0.7.12: its release text says to check `git status` after either. Snapshot restore was already a disclosed 0.7.11 limit; `up --existing` was newly observed, and whether it regressed isn't established. The check that would tell, the same `up --existing` run on the previous release, hasn't run. #3039 merged after 0.7.13's cut, so the repair is on main for 0.7.14, not in 0.7.13. Scenario owed: a Codex seat in a repository with a tracked `AGENTS.md`, checked with `git status` after a restore and after `up --existing`.
|
||||
- 2026-11-12, `rig queue create` latency (0.7.13), queue writes (the CLI-to-daemon create path): on one performance host, under a before/after rule frozen ahead of the run (six alternating arms of 100 creates each, at the same offered load), whole `rig queue create` invocations had a median of 304.6 ms against 0.7.12's 276.0 ms, +10.39% (about +29 ms), and the two versions' per-arm medians didn't overlap. The p95 moved from 320.1 to 349.3 ms. Send latency, and memory and CPU under load, stayed within the comparison's unchanged range; the idle-memory comparison was inconclusive. Split at the daemon's insert timestamp, both the part before the insert (CLI start, transport, the daemon's handling before the insert) and the part after it moved, so no single segment carries the change, and the cause is unassigned. Caught at the cut by the release's representative-load performance comparison and accepted with disclosure, so it reached 0.7.13. Missed by the package suites and stub scenarios, which don't time whole invocations against the previous release. Not fixed: 0.7.14's stability comparison times the same path with CLI startup, transport, daemon handling and exit recorded separately. Scenario owed: the same before/after create timing on retained history, on one host at a fixed offered load, with those boundaries recorded.
|
||||
@@ -84,12 +84,14 @@ Shared test vectors are in `schemas/fixtures/identity-vectors.json`.
|
||||
|
||||
`seats` gives a `pod.member` the runtimes it may use, and the profile each runtime uses. A member it doesn't list keeps
|
||||
the runtime `rig.yaml` gives it. `presets` names full mappings, and `recommended` names one of them, which must be
|
||||
`rig.yaml` as written. `rig bundle create`, `rig bundle configurations` and `rig bundle check` refuse a file that breaks
|
||||
these rules.
|
||||
`rig.yaml` as written. `rig bundle check` reports a file that breaks these rules as a finding, and
|
||||
`rig bundle configurations` names the problem instead of listing.
|
||||
|
||||
`--preset` and `--seat` choose a configuration (see the [rig bundle reference](rig-bundle.md)). Any mix of each seat's
|
||||
declared runtimes can be built, not only the presets; a runtime a seat doesn't declare is refused, and nothing is built.
|
||||
Without either flag, create builds `rig.yaml` as written. The built archive's manifest records
|
||||
declared runtimes can be built, not only the presets. A runtime a seat doesn't declare, or a choice from a file that
|
||||
breaks these rules, is refused, and nothing is built. Without either flag, `rig bundle create`, and `rig bundle inspect`,
|
||||
`rig bundle install` and `rig up` of a GitHub folder link, use `rig.yaml` as written and don't check
|
||||
`configurations.yaml`. The built archive's manifest records
|
||||
`configuration: { id, preset }` outside the package digest; `preset` is there only when `--preset` or `--seat` chose a
|
||||
mapping that matches a declared preset.
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ The CLI itself finds the daemon in these orders:
|
||||
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
|
||||
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
|
||||
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
|
||||
the state file below.
|
||||
the state file below. If no state is read, probe the configured `daemon.host` and `daemon.port` instead (defaulting
|
||||
to `127.0.0.1:7433`).
|
||||
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
|
||||
`~/.rigged/daemon.json`.
|
||||
|
||||
@@ -231,6 +232,7 @@ With `needs-me.read` supported:
|
||||
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
|
||||
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
|
||||
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
|
||||
- **`GET /api/workflow/list`** accepts exact `project`, `mission`, `slice`, `rig` (the bound rig), and `name` (the workflow name) filters, repeatable `tag` filters on the instance's free-form scope tags, and `limit` from 1 to 10,000. Filters combine with AND and apply before the limit; results keep their existing oldest-first order. Instances without the requested scope or binding do not match. Without these filters, existing list behavior is unchanged.
|
||||
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
|
||||
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
|
||||
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
|
||||
|
||||
@@ -208,8 +208,8 @@ curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/i
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh`, installs the
|
||||
latest published `@openrig/cli` with `npm install -g`,
|
||||
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check if the installed checker is
|
||||
present (otherwise it reports the check skipped), then runs `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
Codex and may install a missing one, as described below. A failed step prints
|
||||
`FAILED [n/4] <command or check> (exit <code>)` for steps 1–3. If step 4 is not ready, it reports
|
||||
`INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any Fix, then rerun
|
||||
@@ -308,11 +308,12 @@ team. `rig daemon start --no-kernel` is for automation or an explicit request to
|
||||
omit the kernel; it leaves you without that operator.
|
||||
|
||||
On a fresh instance, explicit daemon startup chooses the kernel variant from
|
||||
successful native auth probes: Claude alone, Codex alone, or both. It starts the
|
||||
kernel in the background without a project starter. An absent unused provider is
|
||||
fine; if both accounts are authenticated, the kernel uses both even when your
|
||||
later project uses just one. A request to use only one provider for everything
|
||||
is a separate choice.
|
||||
the providers selected by `rig setup --providers` that pass native auth probes:
|
||||
Claude alone, Codex alone, or both. If no choice was recorded, it considers every
|
||||
signed-in provider. `OPENRIG_PROVIDER_SELECTED` in the daemon's environment
|
||||
overrides the saved choice. It starts the kernel in the background without a
|
||||
project starter. An absent unused provider is fine. The kernel choice and a
|
||||
later project's provider choice are separate.
|
||||
|
||||
`/api/kernel/status` retains the chosen template (`rig.yaml`,
|
||||
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
> runner is qualified beyond that.
|
||||
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
|
||||
> handoff. These interfaces do not establish that a particular runner implements them correctly.
|
||||
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
|
||||
> - **Current source:** per-launch credentials for hosted Pi, Pi-RPC presets and declared community runners (section 4).
|
||||
> This credential does not qualify new native input or cancellation behavior.
|
||||
> - **Unchanged:** plain community presets and the other built-in runtimes (Claude Code, Codex, OMP, terminal).
|
||||
>
|
||||
> **What has run natively, in 0.7.8's release testing, against a Claude Haiku model:**
|
||||
> - **dsh-seat 0.3.0**, an outside runner for dsh 0.2.1-rc.2 over ACP: launch and first input, useful queue work, a
|
||||
@@ -127,6 +129,29 @@ immediately before the preset's native command, after OpenRig's launcher assignm
|
||||
|
||||
## 4. Activity: report turn boundaries
|
||||
|
||||
Managed hosted Pi, Pi-RPC presets and declared community runners can receive an actual-launch credential in
|
||||
`OPENRIG_ACTIVITY_HOOK_TOKEN`, with their attempt in `OPENRIG_RUN_ID`. Keep this credential in the runner's own
|
||||
process; remove it from the native agent and tool environment. It is delivered through tmux's private environment,
|
||||
never as a literal launch argument or launch-result field. A report that supplies `runId` requires this bound
|
||||
credential; knowing a generation or the install token does not supply run authority.
|
||||
|
||||
Issuing or delivering this optional credential never blocks an otherwise supported launch or restore. If the launch
|
||||
anchor, PID, generation or private credential channel is unavailable, the existing unbound install-token path remains;
|
||||
that path adds no new run-authority claim and is not attributed to a selected run. A refused bound report stays
|
||||
refused: it is never silently retried with the install token. If native dispatch already occurred before activation
|
||||
failed, Core preserves the dispatched outcome and does not replay input; cleanup addresses only that launch's grant.
|
||||
Unreadable observations cannot revoke a grant. Confirmed exit/replacement or intentional lifecycle cleanup still can.
|
||||
|
||||
Core activates a pending credential after dispatch against its selected session, pane and recorded runner. A staged
|
||||
successor waits for the existing binding cutover; pending posts receive `activity_grant_pending` and must retry.
|
||||
An unreadable binding is `activity_grant_unverified` (409, retryable): it could not be verified now, not proven stale.
|
||||
Nothing is recorded and the grant is retained; retry when its binding can be checked again.
|
||||
Failed launches revoke their own credential. Relaunch replaces the active run; an explicit no-launch adoption can
|
||||
retain it only with the original concrete pane/process evidence and an adoption-only tenure continuation. These
|
||||
checks bind a runner's reports to its launch; they do not independently verify its claimed native identity. Existing
|
||||
host hooks, contained guest grants and install-token Pi usage keep their legacy behavior. Pi usage also accepts a
|
||||
current bound Pi credential. No native input channel or cancellation capability is implied by this credential.
|
||||
|
||||
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
|
||||
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
|
||||
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
|
||||
@@ -401,6 +426,7 @@ answer. Only the seat's opt-in refuses and holds attention.
|
||||
| `stale_generation` | 409 | With the activity token: the post isn't from the seat's current occupant. | answers `cancelled` |
|
||||
| `activity_grant_wrong_seat` | 403 | The grant speaks for another seat. | answers `cancelled` |
|
||||
| `activity_grant_stale` | 409 | The grant's launch generation no longer occupies the seat. | answers `cancelled` |
|
||||
| `activity_grant_unverified` | 409 | The binding could not be verified now; this is retryable, not evidence of replacement or cancellation. | permits under the existing unavailable-answer policy |
|
||||
| `activity_grant_revoked` | 401 | The grant was revoked while its launch generation still occupies the seat, or before it has registered, so this may still be the live request; a replaced occupant's grant reads `activity_grant_stale` instead, even once revoked. | permits |
|
||||
| `activity_grant_pending` | 409 | The grant's launch hasn't registered its occupant yet. | permits |
|
||||
| `duplicate_request` | 409 | This request id was already submitted in another turn; an agent may reuse an id. | permits |
|
||||
|
||||
@@ -92,8 +92,9 @@ warning. Enabled diagnosis policy admits one bounded packet even though the
|
||||
product denominator is unknown. Ordinary unavailable/stale detector records
|
||||
remain ineligible. Diagnosis and human-notification traffic are excluded from
|
||||
the numerator and cannot recursively generate another investigation. So is the
|
||||
daemon's own bookkeeping: transitions by system actors (`*@system` and the
|
||||
bare `system` of claim handover) and daemon notes recorded under any actor
|
||||
daemon's own bookkeeping: transitions by system actors (`*@system`, the
|
||||
bare `system` of claim handover, `daemon@kernel` and every `system:*` actor;
|
||||
other kernel seats such as `human@kernel` still count) and daemon notes recorded under any actor
|
||||
(`auto-unparked:`, `blocker custody moved `, `park wake `, `parked-owner `).
|
||||
The reported `transitionIds` are the counted transitions.
|
||||
|
||||
@@ -143,7 +144,8 @@ explicitly linked project or mission work resolves a scope. Its bounds: at most
|
||||
qitems, 1,000 members / 10,000 transitions per family, 200 workflow
|
||||
receipts, 200 declared slices, and 100 proof files per selected slice. Overflow
|
||||
refuses visibly. When more than 200 families (roots) were touched, it evaluates the
|
||||
200 with the most transitions in the window, then by lineage ID, and reports the
|
||||
200 with the most transitions in the window (every transition, daemon bookkeeping
|
||||
included, which bounds the counted ones), then by lineage ID, and reports the
|
||||
result as partial coverage with the total and omitted counts. Omitted families
|
||||
were not evaluated, so having no finding for them is not a healthy verdict.
|
||||
Context files are bounded to 64 KiB. A lineage beginning before
|
||||
|
||||
@@ -124,10 +124,12 @@ manual replay. A recurring reminder keeps its next normal cycle under a new occu
|
||||
finishing its accounting does not type the same occurrence twice.
|
||||
|
||||
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
|
||||
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
|
||||
correct the destination and register a replacement reminder. A one-shot park timer records its
|
||||
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
|
||||
there while the timer stays armed. Deliberately held
|
||||
reminder keeps its next normal cycle, and a permanent transport failure leaves it a terminal job:
|
||||
correct the destination and register a replacement reminder. A one-shot park timer (`--wake-after`)
|
||||
has no later cycle, so neither the retry limit nor a permanent transport failure ends it while its
|
||||
task is still set aside: each failure before input is recorded on the parked row while the timer
|
||||
stays armed, and it retries until it's delivered or the park ends. If input may have reached the
|
||||
seat, it records the unconfirmed wake on the parked row and ends. Deliberately held
|
||||
input keeps its existing retained-message custody. Condition watches and parked-owner policy
|
||||
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
|
||||
|
||||
@@ -289,11 +291,14 @@ Unknown: not said yet.
|
||||
stopped (the first fire comes one interval after you register it), and it stops when the agent that registered it
|
||||
is replaced. It's fine for agent-to-agent reminders. Your own watch timers also show as `periodic-reminder` in
|
||||
`rig watchdog list`: that's the one-shot timer `--wake-after` arms, not a registered repeating reminder. It's retired
|
||||
when it fires or when the task stops being set aside. A wake held back because you were typing (a retained
|
||||
delivery) wasn't delivered, so it doesn't use the timer up: it's tried again at each interval until an attempt goes
|
||||
through (delivered or failed), which retires it. A timer another task shares through `--wake-watchdog` stays live
|
||||
for that task when this task is handed off or stopped, and the same rule applies to its wake. None of this is a
|
||||
delivery guarantee.
|
||||
after delivery, when the occurrence ends, or when the task stops being set aside. A wake held back because you were typing (a retained
|
||||
delivery) wasn't delivered, so it doesn't use the timer up. A failure known to have happened before input reached
|
||||
the seat also keeps the same wake owed and retries on the existing cadence until delivery or the park ends. If input
|
||||
may have reached the seat, the occurrence ends without automatic replay; inspect the receipt before retrying.
|
||||
A timer another task shares through `--wake-watchdog` stays live for that task when this task is
|
||||
handed off or stopped, and a held-back wake doesn't use it up. Once this task's park has ended, though,
|
||||
a failure before input to that task is retried only up to `queue.wake_retry_cap` times (a permanent
|
||||
transport failure ends it at once), and then the timer ends. None of this is a delivery guarantee.
|
||||
- **Transcript bytes as "usage".** `context-usage-threshold` measures an agent's transcript, not an account's
|
||||
allowance. For an allowance, see "A usage warning" above.
|
||||
|
||||
|
||||
@@ -324,6 +324,90 @@ envelope. An OpenRig older than 0.7.3 doesn't read a recorded envelope, so after
|
||||
the seat launches without it. The field is also supported on legacy flat Codex nodes; other
|
||||
runtimes reject it.
|
||||
|
||||
For an explicit filesystem policy, use `filesystem` **instead of** `sandbox` and its
|
||||
workspace-write fields. This is the same per-seat selection and audited setter:
|
||||
|
||||
```yaml
|
||||
cwd: /workspace/shared
|
||||
env:
|
||||
CODEX_HOME: /accounts/codex # existing account home; never copied
|
||||
codex_permissions:
|
||||
approval: never
|
||||
filesystem:
|
||||
":minimal": read
|
||||
":workspace_roots": write
|
||||
"/workspace/shared/private": deny
|
||||
network_access: false
|
||||
```
|
||||
|
||||
The mapping accepts native `read`, `write` and `deny` values at absolute paths,
|
||||
`~/path`, or `:root`, `:minimal`, `:workspace_roots` (alias `:project_roots`),
|
||||
`:tmpdir` and `:slash_tmp`.
|
||||
It must be nonempty; nested subpath maps and arbitrary Codex settings are not part
|
||||
of this option. Omitted network access becomes `false`. Include the read access
|
||||
your tools and installed Codex executable need; this custom profile adds no
|
||||
workspace, temporary, queue-state, seat-folder or Git-directory write grants of
|
||||
its own. Native Codex decides whether the host can enforce the supplied rules.
|
||||
Filesystem approval accepts `never` or `on-request`; the retired `untrusted`
|
||||
approval is rejected before the selection is saved.
|
||||
|
||||
OpenRig emits the policy as invocation-local `permissions.openrig-seat` and
|
||||
`default_permissions` overrides, with the selected approval. It writes no policy
|
||||
file, so two seats can share both `CODEX_HOME` and `cwd` without overwriting each
|
||||
other. Clearing the selection changes only that seat's record. Existing
|
||||
account-scoped `codex_config_fragment` resources and their root-key rejection
|
||||
stay unchanged.
|
||||
|
||||
Before dispatch, OpenRig compares Codex's native configuration with the seat's
|
||||
selection. Positive evidence of access widening, a conflicting profile or
|
||||
approval choice, an excluding managed allowlist, or a missing/empty selected
|
||||
profile refuses launch. Compatible managed defaults and allowlists, harmless
|
||||
descriptions and provably narrower flat rules are accepted. Legacy sandbox
|
||||
settings are inert under the invocation's explicit permission-profile selection.
|
||||
Null fields are treated as absent, including unfamiliar optional fields.
|
||||
Managed `defaultPermissions` is a fallback, not a forced selection; the
|
||||
effective profile and managed allowlist determine whether the agent's choice holds.
|
||||
|
||||
A timeout, reader failure, unfamiliar representation or unsupported managed
|
||||
requirement continues with the authored flags and reports `policy not verified:
|
||||
<reason>` in the launch observation and status. The policy is never dropped or
|
||||
swapped, and account/project settings are never edited to make the check pass.
|
||||
Absent selection or approval fields, unfamiliar approval strings or profile formats,
|
||||
and unclear overlap between native selectors and authored path rules are also unverified.
|
||||
An authored selector that may cover an extra grant is considered before a literal
|
||||
ancestor would reject that grant, except that an explicit non-root deny ancestor
|
||||
always takes precedence over possible selector coverage. A full-root deny, written
|
||||
as `/` or `:root`, is a default narrower grants may refine: possible authored
|
||||
selector coverage stays unverified, while a proven outside grant still refuses.
|
||||
Codex 0.167.2's `:minimal` adds system read defaults, not `/home` or `/Users`
|
||||
user-data coverage; extra grants there under a root deny or with no root rule still refuse unless
|
||||
another authored rule may cover them. Other minimal-path coverage stays unexpanded.
|
||||
Duplicate aliases from a
|
||||
lower layer leave their own coverage unverified; they do not hide a known widening
|
||||
at another path.
|
||||
Inconclusive coverage warnings name the extra rule and access. An excluding managed
|
||||
allowlist is reported before an effective selection or approval mismatch.
|
||||
Concrete refusals name the conflicting rule and offer a remedy; handover preserves
|
||||
that remedy while leaving the predecessor running.
|
||||
Without an explicit `CODEX_HOME`, OpenRig resolves the normal account home and
|
||||
pins it for both the read and launch; an unresolvable home is reported as
|
||||
unverified. A named `codex_config_profile` is retained but unverified because the
|
||||
native reader cannot load that layer on this path. A `launch_wrapper` is also
|
||||
retained and unverified: it may change the effective home/configuration, so a
|
||||
read without that wrapper cannot certify the launch.
|
||||
|
||||
The filesystem selection replaces permission-mode and team/kernel defaults,
|
||||
including ambient YOLO. Fresh launches, both resume paths and restore emit the
|
||||
same selection. Hand-resuming from the command shown by `rig ps` does not yet
|
||||
carry this filesystem policy. A rebuild uses the saved filesystem policy ahead of an observed
|
||||
predecessor sandbox; clearing or replacing the policy after a snapshot takes
|
||||
effect on restore. Status reports the plan and its limits, not observed native
|
||||
enforcement. Handover checks the successor's filesystem selection before
|
||||
signaling the predecessor; a concrete conflict leaves that predecessor running.
|
||||
Configuration can change between the preflight and native startup;
|
||||
the read and launch are not atomic. Codex's sandbox governs local command
|
||||
execution, not every connector, account or external service.
|
||||
|
||||
For an existing seat, put the envelope in a JSON file, or change its profile, with the existing
|
||||
audited permission command:
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@ reaction on any other message, including a person's reply in the thread, is igno
|
||||
`reaction_added` event: add both under **OAuth & Permissions** and **Event Subscriptions**, then
|
||||
reinstall the app to the workspace.
|
||||
|
||||
Human-row posts suppress link and media previews by default. Run `rig slack setup --unfurl on` to
|
||||
allow Slack previews, or `rig slack setup --unfurl off` to suppress them again. The selection reaches
|
||||
the running connector on its next rewire or daemon restart; it does not remove already posted previews.
|
||||
|
||||
To have reactions ignored instead, run `rig slack setup --reactions off`. The agent that asked
|
||||
never hears of a reaction, including one still waiting to be retried, and a person answers an ask by
|
||||
typing a reply in its thread. Slack still sends the event and the manifest keeps it, so switching
|
||||
|
||||
@@ -347,9 +347,7 @@ async function withClient<T>(
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400 && body && typeof body === "object"
|
||||
&& (body as { error?: unknown }).error === "remote_queue_write_failed"
|
||||
&& (body as { outcome?: unknown }).outcome === "indeterminate") {
|
||||
if (isIndeterminateRemoteWrite(status, body)) {
|
||||
console.error("The write outcome is INDETERMINATE if the request may have reached a daemon — reconcile by ID before any retry.");
|
||||
}
|
||||
if (json) {
|
||||
@@ -463,6 +461,34 @@ function writeReceipt(full: boolean | undefined, ctx: ReceiptContext): ReceiptCo
|
||||
return !full && compactOutputEnabled() ? ctx : null;
|
||||
}
|
||||
|
||||
/** A handoff whose outcome is unknown may have committed. This names the source row to read before any retry, on the
|
||||
* daemon the command used, and where a successor would be. It never says the handoff failed, names a successor id, or
|
||||
* asks for the handoff to be repeated. */
|
||||
function handoffRecoveryHint(qitemId: string, hostId: string | undefined): string {
|
||||
const remote = remoteHostOf(hostId);
|
||||
return `Before any retry, read source row ${qitemId} at the same endpoint and OPENRIG_HOME: `
|
||||
+ `rig queue show ${shellQuote(qitemId)} --full --json. Read its recorded outcome: a handed-off row names its successor`
|
||||
+ (remote ? ` on host ${shellQuote(remote)}` : "")
|
||||
+ ", or says why it can't. A row that still reads open doesn't show the handoff failed: it may still commit.";
|
||||
}
|
||||
|
||||
/** A handoff whose answer was lost (no answer in time, a lost connection, or an unreadable response) carries the hint in
|
||||
* its error. A pre-header disconnect can follow a committed handoff, as it can a create, so the shared renderer must not
|
||||
* call it undelivered. */
|
||||
function withHandoffRecovery(error: unknown, qitemId: string, hostId: string | undefined): unknown {
|
||||
if (!(error instanceof DaemonConnectionError || error instanceof DaemonResponseError)) return error;
|
||||
if (error instanceof DaemonConnectionError) error.writeOutcome = "unknown";
|
||||
error.message += ` ${handoffRecoveryHint(qitemId, hostId)}`;
|
||||
return error;
|
||||
}
|
||||
|
||||
/** The selected daemon's forward to another host lost the remote's answer: the write was sent and may still commit. */
|
||||
function isIndeterminateRemoteWrite(status: number, body: unknown): boolean {
|
||||
return status >= 400 && !!body && typeof body === "object"
|
||||
&& (body as { error?: unknown }).error === "remote_queue_write_failed"
|
||||
&& (body as { outcome?: unknown }).outcome === "indeterminate";
|
||||
}
|
||||
|
||||
/** A `--host` id that may name another daemon. Empty and `local` never do; one that
|
||||
* equals the selected daemon's own host id is settled by {@link settleReceiptHost}. */
|
||||
function remoteHostOf(hostId: string | undefined): string | undefined {
|
||||
@@ -1687,12 +1713,13 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
}, crossHostWriteOptions(hostResolved.hostId));
|
||||
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => { throw withHandoffRecovery(error, qitemId, hostResolved.hostId); });
|
||||
const hint = hostSubstitutionHint(res.status, res.data, opts.to, opts.host, hostResolved);
|
||||
if (hint) console.error(hint);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, {
|
||||
verb: "handoff", nudgeSuppressed: opts.nudge === false, remoteHost: remoteHostOf(hostResolved.hostId),
|
||||
}));
|
||||
if (isIndeterminateRemoteWrite(res.status, res.data)) console.error(handoffRecoveryHint(qitemId, hostResolved.hostId));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1789,12 +1816,13 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
}, crossHostWriteOptions(hostResolved.hostId));
|
||||
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => { throw withHandoffRecovery(error, qitemId, hostResolved.hostId); });
|
||||
const hint = hostSubstitutionHint(res.status, res.data, opts.to, opts.host, hostResolved);
|
||||
if (hint) console.error(hint);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, {
|
||||
verb: "handoff-and-complete", nudgeSuppressed: opts.nudge === false, remoteHost: remoteHostOf(hostResolved.hostId),
|
||||
}));
|
||||
if (isIndeterminateRemoteWrite(res.status, res.data)) console.error(handoffRecoveryHint(qitemId, hostResolved.hostId));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
.option("--secrets-env-file <path>", "path to the 0600 env file with SLACK_BOT_TOKEN / SLACK_APP_TOKEN")
|
||||
.option("--required-scopes <csv>", "comma-separated bot scopes to require at verify time")
|
||||
.option("--reactions <mode>", "what a person's emoji reaction on an OpenRig post does: route (default) makes a row for the seat that asked; off ignores reactions, so an acknowledging emoji costs no agent turn")
|
||||
.option("--unfurl <mode>", "link and media previews on human-row posts: off (default) or on; takes effect when the connector rewires")
|
||||
.option("--reason <reason>", "Reason recorded with the configuration change", "configure human delivery")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.action(async (opts) => {
|
||||
@@ -203,6 +204,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
secretsEnvFile: opts.secretsEnvFile ?? cur.secretsEnvFile,
|
||||
requiredScopes: opts.requiredScopes ? String(opts.requiredScopes).split(",").map((s: string) => s.trim()).filter(Boolean) : cur.requiredScopes,
|
||||
...(opts.reactions !== undefined ? { reactions: opts.reactions } : {}),
|
||||
...(opts.unfurl !== undefined ? { unfurl: opts.unfurl } : {}),
|
||||
};
|
||||
const { runChannelOperation, channelStateDigest } = await import("@openrig/daemon/gateway-slack");
|
||||
const result = await runChannelOperation({
|
||||
|
||||
@@ -516,8 +516,15 @@ Examples:
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List workflow instances; optionally filter by status")
|
||||
.description("List workflow instances with optional exact filters")
|
||||
.option("--status <s>", "Filter by status (active | waiting | completed | failed | aborted)")
|
||||
.option("--project <id>", "Match the instance project")
|
||||
.option("--mission <id>", "Match the instance mission")
|
||||
.option("--slice <id>", "Match the instance slice")
|
||||
.option("--rig <name>", "Match the bound rig")
|
||||
.option("--name <workflow>", "Match the workflow name")
|
||||
.option("--tag <tag>", "Match a scope tag; repeat to require all tags", (tag: string, previous: string[]) => [...previous, tag], [])
|
||||
.option("--limit <n>", "Maximum matching instances (1-10000)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
@@ -525,10 +532,12 @@ Examples:
|
||||
$ rig workflow list --status active --json
|
||||
$ rig workflow list --status waiting | jq -r '.instances[].instanceId'
|
||||
`)
|
||||
.action(async (opts: { status?: string; json?: boolean }) => {
|
||||
.action(async (opts: { status?: string; project?: string; mission?: string; slice?: string; rig?: string; name?: string; tag: string[]; limit?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.status) params.set("status", opts.status);
|
||||
for (const field of ["project", "mission", "slice", "rig", "name", "limit"] as const) if (opts[field] !== undefined) params.set(field, opts[field]!);
|
||||
for (const tag of opts.tag) params.append("tag", tag);
|
||||
const qs = params.toString();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DaemonClient, ForeignLocalDaemonError, connectionErrorCode, formatDaemo
|
||||
import { blockedConnectionGuidance, daemonMayAnswer, isBlockedConnectionCode } from "./daemon-reachability.js";
|
||||
export { daemonMayAnswer } from "./daemon-reachability.js";
|
||||
import type { DaemonStartLock } from "./daemon-start-lock.js";
|
||||
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
|
||||
import { DAEMON_START_READINESS_WAIT_MS as STARTUP_READINESS_DEADLINE_MS, DAEMON_START_PROBE_TIMEOUT_MS as STARTUP_PROBE_TIMEOUT_MS, DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
// Types only: the module itself (with the daemon's yaml-reading system world) is imported where an
|
||||
@@ -297,7 +297,7 @@ const DEFAULT_DB = "openrig.sqlite";
|
||||
// bounded, but do not kill a healthy daemon during first-boot slack.
|
||||
// #1786: one elapsed deadline, started when the readiness loop begins (after the
|
||||
// child is spawned and its handlers are attached), covers every primary probe,
|
||||
// listener check and backoff (the 20s the old 80 x 250ms delays gave). Each probe
|
||||
// listener check and backoff in one 180-second owned-child wait. Each probe
|
||||
// may take up to STARTUP_PROBE_TIMEOUT_MS, so a busy child that answers correctly
|
||||
// in a second is accepted, but no probe timer outlives the deadline (a 2s startup
|
||||
// ceiling, not a universal header wait). The deadline only stops new probes and
|
||||
@@ -305,8 +305,7 @@ const DEFAULT_DB = "openrig.sqlite";
|
||||
// answer already in hand when the clock passes it is still used, with the usual
|
||||
// liveness and publication rechecks. A failed start may add up to
|
||||
// DAEMON_STOP_WAIT_MS of owned-child cleanup.
|
||||
const STARTUP_READINESS_DEADLINE_MS = 20_000;
|
||||
const STARTUP_PROBE_TIMEOUT_MS = 2_000;
|
||||
const STARTUP_PROGRESS_INTERVAL_MS = 20_000;
|
||||
const HEALTHZ_DELAY_MS = 250;
|
||||
// OPR.0.4.7 slice-05 item-4: the WHOLE status health probe stays within ONE normal
|
||||
// CLI request window (~5s, matching DaemonClient's timeoutMs) — immediate connection
|
||||
@@ -859,7 +858,9 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
let databaseWarnings: string[] = [];
|
||||
let lastProbe = "health endpoint not responding";
|
||||
let unverified: string[] | null = null;
|
||||
deadline = Date.now() + STARTUP_READINESS_DEADLINE_MS;
|
||||
const readinessStartedAt = Date.now();
|
||||
deadline = readinessStartedAt + STARTUP_READINESS_DEADLINE_MS;
|
||||
let nextProgressAt = readinessStartedAt + STARTUP_PROGRESS_INTERVAL_MS;
|
||||
while (remaining() > 0) {
|
||||
unverified = null;
|
||||
try {
|
||||
@@ -891,6 +892,10 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
if (error instanceof StartupIdentityError) throw error;
|
||||
lastProbe = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
if (remaining() > 0 && Date.now() >= nextProgressAt) {
|
||||
console.error(`Waiting for daemon child ${pid} readiness (${Math.floor((Date.now() - readinessStartedAt) / 1000)}s elapsed; ${STARTUP_READINESS_DEADLINE_MS / 1000}s limit). This start has not published readiness.`);
|
||||
nextProgressAt = Date.now() + STARTUP_PROGRESS_INTERVAL_MS;
|
||||
}
|
||||
await Promise.race([new Promise((resolve) => setTimeout(resolve, Math.min(HEALTHZ_DELAY_MS, remaining()))), failed]);
|
||||
}
|
||||
// In the last round the child's own endpoint named its PID, and only another listener's check stayed
|
||||
|
||||
@@ -412,9 +412,12 @@ export function resolveWorkPosition(opts: {
|
||||
let sliceName: string | null = null;
|
||||
let frontier: WorkInstallAltitude = "project";
|
||||
if (opts.mission !== undefined) {
|
||||
missionRoot = join(missionsRoot, opts.mission);
|
||||
if (existsSync(missionRoot)) {
|
||||
const canonicalMissionRoot = canonicalExisting(missionRoot);
|
||||
const nominalMissionRoot = join(missionsRoot, opts.mission);
|
||||
if (!existsSync(nominalMissionRoot)) {
|
||||
// Not a refusal: the project's context still serves. Say so, and never report a folder that doesn't exist.
|
||||
warnings.push(`mission '${opts.mission}' not found under ${missionsRoot}${opts.slice !== undefined ? `, so slice '${opts.slice}' wasn't looked up` : ""}; using the project's context`);
|
||||
} else {
|
||||
const canonicalMissionRoot = canonicalExisting(nominalMissionRoot);
|
||||
if (!canonicalMissionRoot || !inside(projectRoot, canonicalMissionRoot)) {
|
||||
return failure("mission_root_escape", `mission '${opts.mission}' resolves outside the selected project root`);
|
||||
}
|
||||
|
||||
@@ -632,19 +632,54 @@ export const MEMBER_WORKFLOW = [
|
||||
|
||||
// Short guidance a client may show the model (the server's MCP instructions). An external caller's connection asks the
|
||||
// team for work and isn't one of its members, so it isn't given the member workflow.
|
||||
const SHARED_INSTRUCTIONS = [
|
||||
"This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant (rig_whoami shows which, and whether the caller is authenticated; rig_team shows a rig's seats and its lead). The daemon attributes this server's writes to that seat.",
|
||||
"To hand work to a seat, use rig_queue_create and keep its qitemId; read the answer later with rig_queue_show, which names the next item when the work was handed on. To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. Queue items are durable: they outlive this conversation and this server.",
|
||||
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
|
||||
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
|
||||
];
|
||||
/** The paragraphs every seat's connection shares, naming a tool only where `has` says the connection offers it. A local or
|
||||
* authenticated member is offered every tool named here; an external grant is offered only the ones its grant names. */
|
||||
function sharedInstructions(has: (tool: string) => boolean): string[] {
|
||||
const finding = [has("rig_whoami") ? "rig_whoami shows which, and whether the caller is authenticated" : "",
|
||||
has("rig_team") ? "rig_team shows a rig's seats and its lead" : ""].filter(Boolean).join("; ");
|
||||
const showing = "rig_queue_show, which names the next item when the work was handed on";
|
||||
const handing = has("rig_queue_create")
|
||||
? `To hand work to a seat, use rig_queue_create and keep its qitemId${has("rig_queue_show") ? `; read the answer later with ${showing}` : ""}. `
|
||||
: has("rig_queue_show") ? `Read an item's answer with ${showing}. ` : "";
|
||||
return [
|
||||
`This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant${finding ? ` (${finding})` : ""}. The daemon attributes this server's writes to that seat.`,
|
||||
`${handing}${has("rig_queue_handoff") ? "To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. " : ""}Queue items are durable: they outlive this conversation and this server.`,
|
||||
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
|
||||
`A question reserved for a person is theirs to answer: don't answer it on their behalf.${has("rig_queue_decide") ? " rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one." : ""}`,
|
||||
];
|
||||
}
|
||||
const SHARED_INSTRUCTIONS = sharedInstructions(() => true);
|
||||
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
|
||||
|
||||
/** For a connection that relays a person's request to another assistant (an external caller, or the front door): which one
|
||||
* they mean is theirs to say when more than one fits, never inferred from the agent's own history. The listing steps need
|
||||
* rig_team, so a connection without it is told only to ask. Pickup is said only where it's true: another connected
|
||||
* assistant waits for its person; on the front door, a running team seat is woken, and one sent to anyway waits until it's
|
||||
* back. A member, which works items, isn't given it. */
|
||||
function askWhichAssistant(canList: boolean, frontDoor: boolean): string {
|
||||
if (!canList) {
|
||||
return "If more than one assistant could be the one your person means, ask which; don't pick from your own history or memory. If your person names one clearly, go ahead.";
|
||||
}
|
||||
return [
|
||||
"Before sending a request to another assistant, list the team's members.",
|
||||
"If more than one could be the one your person means, ask which, using their names from the list (like \"Sam's Claude\"); if the list shows only addresses, describe each in plain words from what the list says about it (like \"the Claude assistant\"), never reading the address out.",
|
||||
"Don't pick from your own history or memory.",
|
||||
"If the list shows the chosen one detached or stopped, or a connected assistant with no check-in yet or overdue, say so before sending and ask whether to send it anyway.",
|
||||
"If the list says the chosen one's person was removed from the team, don't send; tell your person and ask whether to send it to someone else.",
|
||||
...(frontDoor ? ["When you send to a running team seat, tell your person that seat will pick it up on its own."] : []),
|
||||
"When you send to another connected assistant, tell your person it will be picked up the next time that assistant's person opens it, or on a schedule they've set up.",
|
||||
...(frontDoor ? ["If you sent it anyway, tell your person it waits until that one is back."] : []),
|
||||
"If your person names one clearly, or says to send it anyway to one that isn't connected, go ahead without asking again.",
|
||||
"If your person still asks to send to one whose person was removed, tell them it can't be delivered: requests to a removed person's assistant are closed unanswered.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/** rig mcp serve --front-door: the seat is a person's agent in their own app, the front door to their team, not a member
|
||||
* that works its inbox. The team's questions to it are the person's to answer. */
|
||||
export const FRONT_DOOR_WORKFLOW_VERSION = "front-door/v2";
|
||||
export const FRONT_DOOR_WORKFLOW_VERSION = "front-door/v3";
|
||||
export const FRONT_DOOR_WORKFLOW = [
|
||||
`Front door (${FRONT_DOOR_WORKFLOW_VERSION}). This seat is a person's agent in their own app: you hand the team their work and bring back what it produced, and you aren't one of its members.`,
|
||||
askWhichAssistant(true, true),
|
||||
"Items addressed to your seat (rig_queue_list with owned set) are usually the team's questions about work you handed it. They're the person's to answer, not yours: show each to the person with the team's draft, and record only what the person tells you.",
|
||||
"To record an answer, claim the item (rig_queue_claim), then close it done with closure reason no-follow-on and exactly what the person typed in resolution, with no label, quotation marks or words of your own (rig_queue_update). If the person isn't there, leave the item open and tell them it's waiting. Never choose an answer for them, and don't claim an item you can't close with their words.",
|
||||
"Nothing wakes this conversation when the team finishes: check your work and your items when the person asks or comes back.",
|
||||
@@ -671,16 +706,22 @@ const EXTERNAL_PERSON_INSTRUCTIONS = [
|
||||
"To answer one, show the person the question and its options as rig_question_list gives them. Then pass rig_question_answer its qitemId, the revision you showed, and either the options they chose or exactly what they typed, with no label or words of your own. If the question changed, show them the new one; if it was already answered or closed, say so. It's recorded as their answer, given through this connection. A question reserved for a person is theirs to answer, never yours.",
|
||||
].join("\n");
|
||||
|
||||
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
|
||||
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
|
||||
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.",
|
||||
].join("\n");
|
||||
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. It names
|
||||
* only the tools that grant offers. */
|
||||
function externalInboxInstructions(tools: ReadonlySet<string>): string {
|
||||
return [...sharedInstructions((tool) => tools.has(tool)),
|
||||
`Items addressed to your seat are yours${tools.has("rig_queue_list") ? ": rig_queue_list with owned set lists them" : ""}. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
|
||||
if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS;
|
||||
const tools = externalTools(access.external);
|
||||
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS
|
||||
const workflow = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? externalInboxInstructions(tools)
|
||||
: tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
|
||||
// Only a grant with team/list may reach another connected assistant's seat, and it lists the team with rig_team.
|
||||
const instructions = tools.has("rig_queue_create")
|
||||
? `${workflow}\n${askWhichAssistant(tools.has("rig_team"), false)}` : workflow;
|
||||
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
|
||||
}
|
||||
|
||||
@@ -1823,7 +1864,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
{
|
||||
...worded("rig_team", "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.", READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
rigId: z.string().optional().describe("Rig name or identifier; optional for an external grant to discover its team"),
|
||||
rigId: z.string().optional().describe("Rig name or id. A connection from outside the rig (an external grant) usually leaves this out, and the tool finds its team; if it passes one, it must be the granted rig's name, never its id or the team's display name."),
|
||||
}),
|
||||
},
|
||||
async ({ rigId }) => {
|
||||
@@ -1840,7 +1881,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
rigId = rigs[0]!;
|
||||
}
|
||||
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
|
||||
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
|
||||
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`, "Leave rigId out to see this connection's own team.");
|
||||
const rig = await rigIdFor(client, rigId);
|
||||
if ("content" in rig) return rig;
|
||||
if (inferred && !rig.resolved) {
|
||||
|
||||
@@ -42,7 +42,7 @@ it.each(["0", "person-terminal"])("real attach -> returned env -> actual From ->
|
||||
const db = createDb(); migrate(db, ALL_MIGRATIONS);
|
||||
const rigs = new RigRepository(db), pods = new PodRepository(db), sessions = new SessionRegistry(db), bus = new EventBus(db);
|
||||
const rig = rigs.createRig("sample-team"), person = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
|
||||
const operator = rigs.addNode(rig.id, "operator.agent", { runtime: "terminal" });
|
||||
const operator = rigs.addNode(rig.id, "operator.agent", { runtime: "claude-code" });
|
||||
sessions.updateBinding(operator.id, { tmuxSession: "operator-physical", tmuxPane: "%43" }); sessions.registerClaimedSession(operator.id, "operator-physical");
|
||||
const writes: Array<{ target: string; pane: string; text: string }> = [];
|
||||
const tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" }),
|
||||
@@ -92,7 +92,7 @@ it.each(["0", "person-terminal"])("real attach -> returned env -> actual From ->
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "operator-agent@sample-team"); vi.stubEnv("OPENRIG_NODE_ID", operator.id);
|
||||
await run(sendCommand(deps), ["send", from, "A reply to the actual From address.", "--json"]);
|
||||
expect(writes[1]).toMatchObject({ target: physical, pane: "%42" });
|
||||
expect(writes[1]?.text).toContain("A reply to the actual From address.");
|
||||
expect(writes[1]?.text).toBe("A reply to the actual From address.");
|
||||
expect(sessions.getBindingForNode(person.id)?.tmuxSession).toBe(physical);
|
||||
expect(sessions.getSessionsForRig(rig.id).filter(s => s.nodeId === person.id).map(s => s.sessionName)).toEqual([physical]);
|
||||
expect(rigs.getRig(rig.id)?.nodes.find(n => n.id === person.id)?.label).toBe("Taylor");
|
||||
|
||||
@@ -718,6 +718,32 @@ id: OPR.0.5.8.13
|
||||
});
|
||||
});
|
||||
|
||||
it("warns and keeps the project's context when the selected mission doesn't exist, with or without --slice", async () => {
|
||||
const missionsRoot = join(alphaRoot, "missions");
|
||||
for (const slice of [undefined, "01-live-work"]) {
|
||||
const result = await captureLogs(async () => {
|
||||
await makeCommand().parseAsync([
|
||||
"node", "rig", "context", "work-install",
|
||||
"--project", "alpha", "--mission", "release-0.7.13", ...(slice ? ["--slice", slice] : []), "--json",
|
||||
]);
|
||||
});
|
||||
expect(result.exitCode).toBeUndefined();
|
||||
const plan = JSON.parse(result.logs.join("")) as {
|
||||
position: { mission: string; missionRoot: string | null; sliceRoot: string | null; frontier: string };
|
||||
pieces: Array<{ altitude: string }>;
|
||||
warnings: string[];
|
||||
};
|
||||
expect(plan.position).toMatchObject({ mission: "release-0.7.13", missionRoot: null, sliceRoot: null, frontier: "project" });
|
||||
expect(plan.pieces.every((piece) => piece.altitude === "project")).toBe(true);
|
||||
expect(plan.warnings).toContain(`mission 'release-0.7.13' not found under ${missionsRoot}${slice ? `, so slice '${slice}' wasn't looked up` : ""}; using the project's context`);
|
||||
}
|
||||
const text = await captureLogs(async () => {
|
||||
await makeCommand().parseAsync(["node", "rig", "context", "work-install", "--project", "alpha", "--mission", "release-0.7.13"]);
|
||||
});
|
||||
expect(text.exitCode).toBeUndefined();
|
||||
expect(text.errLogs).toContain(`Warning: mission 'release-0.7.13' not found under ${missionsRoot}; using the project's context`);
|
||||
});
|
||||
|
||||
it("uses explicit mission composition as the strict slice-selection boundary", async () => {
|
||||
const missionRoot = join(alphaRoot, "missions", "alpha-active");
|
||||
writeFileSync(join(missionRoot, "slices", "01-live-work", "slice.yaml"), `schema: openrig.slice/v0alpha1
|
||||
|
||||
@@ -6,6 +6,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { acquireDaemonStartLock, currentBootId } from "../src/daemon-start-lock.js";
|
||||
// Resolve the startup import before a fake clock can finish with no timer yet scheduled.
|
||||
import "@openrig/daemon/instance-initialization";
|
||||
import { createProcessLiveness, readProcessState } from "../src/commands/daemon.js";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
@@ -117,13 +119,78 @@ describe("child-bound startup", () => {
|
||||
expect((outcome as Error).message).toMatch(/identity mismatch.*observed 888/);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
});
|
||||
it("keeps waiting for the same child after 20 seconds and publishes its verified readiness at 45 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const progress = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const f = fixture(); const fetch = f.deps.fetch; const began = Date.now();
|
||||
f.deps.fetch = async (url) => {
|
||||
const response = await fetch(url);
|
||||
if (Date.now() - began < 45_000) throw Object.assign(new Error("still initializing"), { code: "ECONNREFUSED" });
|
||||
return response;
|
||||
};
|
||||
const pending = startDaemon(opts, f.deps).catch((error: Error) => error);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
|
||||
expect(f.lock.recordChild).toHaveBeenCalledWith(f.child.pid);
|
||||
expect(f.lock.release).not.toHaveBeenCalled();
|
||||
expect(progress).toHaveBeenCalledWith(expect.stringContaining("Waiting for daemon child 12345 readiness"));
|
||||
await vi.runAllTimersAsync();
|
||||
const outcome = await pending;
|
||||
expect(outcome instanceof Error ? outcome.message : outcome.pid).toBe(f.child.pid);
|
||||
expect(Date.now() - began).toBe(45_000);
|
||||
expect(JSON.parse(f.files.get(STATE_FILE)!)).toMatchObject({ pid: f.child.pid, port: opts.port, host: opts.host });
|
||||
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
|
||||
expect(f.lock.release).toHaveBeenCalledWith(false);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(progress.mock.calls.length).toBeLessThanOrEqual(3);
|
||||
} finally { progress.mockRestore(); }
|
||||
});
|
||||
it("stops on a child exit after the old deadline instead of waiting out the new cap", async () => {
|
||||
vi.useFakeTimers();
|
||||
const progress = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const f = fixture(); const fetch = f.deps.fetch;
|
||||
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
|
||||
setTimeout(() => { f.child.exitCode = 1; f.child.emit("exit", 1, null); }, 35_000);
|
||||
const { outcome, elapsed } = await settle(f);
|
||||
expect((outcome as Error).message).toMatch(/exited/);
|
||||
expect(elapsed).toBe(35_000);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.lock.release).toHaveBeenCalledWith(false);
|
||||
} finally { progress.mockRestore(); }
|
||||
});
|
||||
it("keeps a quick verified start immediate without a waiting progress line", async () => {
|
||||
vi.useFakeTimers();
|
||||
const progress = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const f = fixture(); const { outcome, elapsed } = await settle(f);
|
||||
expect(outcome instanceof Error ? outcome.message : outcome.pid).toBe(f.child.pid);
|
||||
expect(elapsed).toBe(0);
|
||||
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
|
||||
expect(progress).not.toHaveBeenCalled();
|
||||
} finally { progress.mockRestore(); }
|
||||
});
|
||||
it("returns pending within one readiness deadline without stopping its still-starting child", async () => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture(); const fetch = f.deps.fetch;
|
||||
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
|
||||
const probeStarts: number[] = [];
|
||||
const began = Date.now();
|
||||
f.deps.fetch = async (url) => {
|
||||
await fetch(url);
|
||||
probeStarts.push(Date.now());
|
||||
return new Promise(() => {});
|
||||
};
|
||||
const { outcome, elapsed } = await settle(f);
|
||||
expect(elapsed).toBe(180_000);
|
||||
expect(probeStarts.length).toBeGreaterThan(60);
|
||||
expect(probeStarts.every(at => at < began + 180_000)).toBe(true);
|
||||
expect(probeStarts.slice(1).every((at, i) => at - probeStarts[i]! <= 2_250)).toBe(true);
|
||||
expect((outcome as Error).message).toMatch(/Last probe: .*timed out|Last probe: .*healthz/);
|
||||
expect(elapsed).toBeLessThanOrEqual(20_000);
|
||||
expect(elapsed).toBeLessThanOrEqual(180_000);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.lock.release).toHaveBeenCalledWith(true);
|
||||
@@ -137,7 +204,7 @@ describe("child-bound startup", () => {
|
||||
const fetch = f.deps.fetch; const urls: string[] = [];
|
||||
f.deps.fetch = async (url) => {
|
||||
const res = await fetch(url);
|
||||
if (urls.push(url) - 1 === at) vi.setSystemTime(Date.now() + 21_000);
|
||||
if (urls.push(url) - 1 === at) vi.setSystemTime(Date.now() + 181_000);
|
||||
if (secondary && url.includes("127.0.0.2")) throw new Error("timeout");
|
||||
return res;
|
||||
};
|
||||
@@ -183,7 +250,7 @@ describe("child-bound startup", () => {
|
||||
f.deps.fetch = async (url) => (await fetch(url), calls++ % 2 === 0 ? { ok: true, json: async () => f.body } : new Promise(() => {}));
|
||||
const { outcome, elapsed } = await settle(f);
|
||||
expect((outcome as Error).message).toMatch(/listener verification is incomplete.*reservation retained/);
|
||||
expect(elapsed).toBeLessThanOrEqual(20_000);
|
||||
expect(elapsed).toBeLessThanOrEqual(180_000);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -433,7 +500,7 @@ it.each(["missing", "stale"] as const)("keeps a slow child discoverable with %s
|
||||
expect(message).toContain(path.join(path.dirname(STATE_FILE), "daemon-start.lock"));
|
||||
expect(message).toContain("only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'");
|
||||
expect(message).toContain("'rig daemon stop' cannot target this unpublished child");
|
||||
expect(Date.now() - began).toBeLessThanOrEqual(20_000);
|
||||
expect(Date.now() - began).toBeLessThanOrEqual(180_000);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.child.exitCode).toBeNull();
|
||||
expect(f.files.get(STATE_FILE)).toBe(previousState);
|
||||
|
||||
@@ -14,6 +14,12 @@ import { runFrontDoor, resolveTuiPath, probeFrontDoor, type FrontDoorIo } from "
|
||||
import { diagnoseRuntimePosture, observeClaudePermission } from "../../daemon/src/domain/permission-drift.js";
|
||||
import { DaemonClient, DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "../src/client.js";
|
||||
|
||||
// The compiled front door's spawns guard against a hang (the piped TUI never exiting). A hang never ends, so a budget well
|
||||
// past a slow start still catches one: loaded CI runners took just over 5 s to start the CLI, and 30 s leaves room.
|
||||
const HANG_LIMIT_MS = 30_000;
|
||||
/** Those tests outlive their spawn's budget, so the spawn's own result is what reports a hang. */
|
||||
const HANG_TEST_TIMEOUT_MS = HANG_LIMIT_MS + 10_000;
|
||||
|
||||
// A GUARANTEED-refused local port (bind an ephemeral port, capture it, close → connecting refuses).
|
||||
// A REAL socket refusal, not a hand-built error — buys the actual Node/undici rejection shape.
|
||||
async function refusedPort(): Promise<number> {
|
||||
@@ -487,7 +493,7 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
|
||||
const started = Date.now();
|
||||
const result = spawnSync(process.execPath, [binWrapper], {
|
||||
input: "x\n",
|
||||
timeout: 5000,
|
||||
timeout: HANG_LIMIT_MS,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(result.status, JSON.stringify({
|
||||
@@ -496,13 +502,13 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
|
||||
stdout: result.stdout?.slice(-4096), stderr: result.stderr?.slice(-4096),
|
||||
})).toBe(0);
|
||||
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
|
||||
});
|
||||
}, HANG_TEST_TIMEOUT_MS);
|
||||
|
||||
it.skipIf(!existsSync(binWrapper))("PUBLIC bin subcommands are untouched (rig --version via wrapper)", () => {
|
||||
const result = spawnSync(process.execPath, [binWrapper, "--version"], { timeout: 5000, encoding: "utf-8" });
|
||||
const result = spawnSync(process.execPath, [binWrapper, "--version"], { timeout: HANG_LIMIT_MS, encoding: "utf-8" });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
}, HANG_TEST_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
describe("script-safety integration (the compiled front door)", () => {
|
||||
@@ -511,22 +517,22 @@ describe("script-safety integration (the compiled front door)", () => {
|
||||
const started = Date.now();
|
||||
const result = spawnSync(process.execPath, [cliEntry], {
|
||||
input: "x\n",
|
||||
timeout: 5000,
|
||||
timeout: HANG_LIMIT_MS,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, OPENRIG_URL: "", OPENRIG_PORT: "" },
|
||||
});
|
||||
expect(Date.now() - started).toBeLessThan(5000);
|
||||
expect(Date.now() - started).toBeLessThan(HANG_LIMIT_MS);
|
||||
// pre-slice-17 baseline: commander's clean-help path prints usage and
|
||||
// exits 0 — the front door PRESERVES the piped path byte-for-byte
|
||||
expect(result.status).toBe(0);
|
||||
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
|
||||
expect(`${result.stderr}${result.stdout}`).not.toMatch(/\n\s+at /);
|
||||
});
|
||||
}, HANG_TEST_TIMEOUT_MS);
|
||||
|
||||
it.skipIf(!existsSync(cliEntry))("`rig --help` still exits 0 with the full usage (front-door regression)", () => {
|
||||
const result = spawnSync(process.execPath, [cliEntry, "--help"], { timeout: 5000, encoding: "utf-8" });
|
||||
const result = spawnSync(process.execPath, [cliEntry, "--help"], { timeout: HANG_LIMIT_MS, encoding: "utf-8" });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/Usage: rig/);
|
||||
expect(result.stdout).toMatch(/daemon/);
|
||||
});
|
||||
}, HANG_TEST_TIMEOUT_MS);
|
||||
});
|
||||
@@ -19,6 +19,16 @@ function mockClient(overrides?: {
|
||||
return client;
|
||||
}
|
||||
|
||||
// What a connection that relays a person's request is told when more than one assistant could be the one they mean. With
|
||||
// rig_team it lists the team first; pickup is said only where it's true for the path.
|
||||
const ASK_LISTED = "Before sending a request to another assistant, list the team's members. If more than one could be the one your person means, ask which, using their names from the list (like \"Sam's Claude\"); if the list shows only addresses, describe each in plain words from what the list says about it (like \"the Claude assistant\"), never reading the address out. Don't pick from your own history or memory. If the list shows the chosen one detached or stopped, or a connected assistant with no check-in yet or overdue, say so before sending and ask whether to send it anyway. If the list says the chosen one's person was removed from the team, don't send; tell your person and ask whether to send it to someone else.";
|
||||
const ASK_RUNNING_PICKUP = "When you send to a running team seat, tell your person that seat will pick it up on its own.";
|
||||
const ASK_CONNECTED_PICKUP = "When you send to another connected assistant, tell your person it will be picked up the next time that assistant's person opens it, or on a schedule they've set up.";
|
||||
const ASK_SENT_ANYWAY = "If you sent it anyway, tell your person it waits until that one is back.";
|
||||
const ASK_UNLISTED = "If more than one assistant could be the one your person means, ask which; don't pick from your own history or memory. If your person names one clearly, go ahead.";
|
||||
const ASK_GO_AHEAD = "If your person names one clearly, or says to send it anyway to one that isn't connected, go ahead without asking again.";
|
||||
const ASK_REMOVED_FACT = "If your person still asks to send to one whose person was removed, tell them it can't be delivered: requests to a removed person's assistant are closed unanswered.";
|
||||
|
||||
describe("MCP Server", () => {
|
||||
let mcpClient: Client;
|
||||
let daemonClient: DaemonClient;
|
||||
@@ -395,6 +405,10 @@ describe("MCP Server", () => {
|
||||
expect(instructions).not.toContain(MEMBER_WORKFLOW);
|
||||
expect(instructions).not.toContain("without asking whether you may");
|
||||
for (const part of ["the person's to answer", "record only what the person tells you", "Never choose an answer for them", "exactly what the person typed in resolution, with no label"]) expect(FRONT_DOOR_WORKFLOW).toContain(part);
|
||||
// It hands the team the person's work, so which assistant they mean is theirs to say when several fit. Its list shows
|
||||
// team seats and connected assistants: only a running team seat picks a request up on its own.
|
||||
expect(FRONT_DOOR_WORKFLOW).toContain(`${ASK_LISTED} ${ASK_RUNNING_PICKUP} ${ASK_CONNECTED_PICKUP} ${ASK_SENT_ANYWAY} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
|
||||
expect(FRONT_DOOR_WORKFLOW).not.toContain("<name>");
|
||||
// An agent told to say the words are the person's wrapped them as `The person's answer: "…"`, so the stored answer wasn't what they typed.
|
||||
expect(FRONT_DOOR_WORKFLOW).not.toContain("saying they're the person's");
|
||||
const front = await mcpClient.callTool({ name: "rig_whoami", arguments: {} });
|
||||
@@ -877,11 +891,70 @@ describe("MCP Server", () => {
|
||||
expect(instructions).not.toContain(MEMBER_WORKFLOW_VERSION);
|
||||
expect(instructions).not.toContain("basically authorized");
|
||||
expect(instructions).toContain("You may omit destination");
|
||||
// Asked to "send my Claude assistant one request" on a team with four Claude seats, a client picked one from its own history.
|
||||
// Without rig_team it can't list the team, and its destinations aren't other connected assistants: it's told only to ask.
|
||||
expect(instructions).toContain(ASK_UNLISTED);
|
||||
expect(instructions).not.toContain("list the team's members");
|
||||
expect(instructions).not.toContain("removed from the team");
|
||||
expect(instructions).not.toContain("closed unanswered");
|
||||
expect(instructions).not.toMatch(/picked up|pick it up/);
|
||||
expect(instructions).not.toMatch(/rig_whoami|rig_team|rig_queue_handoff|rig_queue_decide/);
|
||||
await external.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("an external request grant with team/list lists the team before asking which assistant, and says when another connected one picks up", async () => {
|
||||
const server = createMcpServer(mockClient(), { seat: "cus-1-caller@team",
|
||||
access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false, external: { objects: { queue: ["create", "show", "list"], team: ["list"] } } } });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const external = new Client({ name: "external-client", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
await external.connect(clientTransport);
|
||||
const instructions = external.getInstructions() ?? "";
|
||||
expect(instructions).toContain(`${ASK_LISTED} ${ASK_CONNECTED_PICKUP} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
|
||||
expect(instructions).not.toContain(ASK_RUNNING_PICKUP);
|
||||
expect(instructions).not.toContain(ASK_SENT_ANYWAY);
|
||||
await external.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a full inbox grant", { queue: ["create", "show", "list", "claim", "update"], team: ["list"] }],
|
||||
["a narrow inbox grant", { queue: ["claim", "update"] }],
|
||||
])("%s's instructions name only tools its connection offers", async (_label, objects) => {
|
||||
const server = createMcpServer(mockClient(), { seat: "cus-1-caller@team",
|
||||
access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false, external: { objects } } });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const external = new Client({ name: "external-client", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
await external.connect(clientTransport);
|
||||
const instructions = external.getInstructions() ?? "";
|
||||
const offered = new Set((await external.listTools()).tools.map((tool) => tool.name));
|
||||
const named = [...new Set(instructions.match(/\brig_[a-z_]+/g) ?? [])];
|
||||
expect(instructions).toContain("claim it (rig_queue_claim)");
|
||||
expect(named.filter((tool) => !offered.has(tool))).toEqual([]);
|
||||
expect(instructions).not.toMatch(/rig_whoami|rig_queue_handoff|rig_queue_decide/);
|
||||
expect(instructions.includes("rig_team")).toBe(offered.has("rig_team"));
|
||||
// Only a grant that can hand the team work is told to ask which assistant the person means; with team/list it lists the
|
||||
// team first and may reach another connected assistant, so it's told when that one picks the request up.
|
||||
expect(instructions.includes("ask which")).toBe(offered.has("rig_queue_create"));
|
||||
if (offered.has("rig_queue_create")) expect(instructions).toContain(`${ASK_LISTED} ${ASK_CONNECTED_PICKUP} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
|
||||
await external.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a member's instructions keep every shared paragraph word for word", async () => {
|
||||
const instructions = mcpClient.getInstructions() ?? "";
|
||||
for (const paragraph of [
|
||||
"This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant (rig_whoami shows which, and whether the caller is authenticated; rig_team shows a rig's seats and its lead). The daemon attributes this server's writes to that seat.",
|
||||
"To hand work to a seat, use rig_queue_create and keep its qitemId; read the answer later with rig_queue_show, which names the next item when the work was handed on. To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. Queue items are durable: they outlive this conversation and this server.",
|
||||
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
|
||||
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
|
||||
]) expect(instructions).toContain(paragraph);
|
||||
// A member works items rather than relaying a person's request, so it isn't told to ask which assistant they mean.
|
||||
expect(instructions).not.toContain("ask which");
|
||||
});
|
||||
|
||||
it("shows a connected assistant's person plain titles and openings, keeping each tool's rules and hints", async () => {
|
||||
// What a person sees: each tool's title and the first sentence of its description.
|
||||
const plain: Record<string, [title: string, sentence: string]> = {
|
||||
|
||||
@@ -6,7 +6,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { QueueDeps } from "../src/commands/queue.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
import { DaemonTimeoutError } from "../src/client.js";
|
||||
import { DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "../src/client.js";
|
||||
import { runProgram } from "../src/cli-error.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
@@ -44,13 +45,15 @@ function makeDeps(replies: Reply[]): { deps: QueueDeps; requests: Request[] } {
|
||||
describe("cross-host queue writes wait for the daemon's answer", () => {
|
||||
let home: string;
|
||||
let stderr: string[];
|
||||
let stdout: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "queue-xhost-wait-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "lead@rig-a");
|
||||
stderr = [];
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
stdout = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { stdout.push(a.join(" ")); });
|
||||
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { stderr.push(a.join(" ")); });
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { stderr.push(String(chunk)); return true; });
|
||||
process.exitCode = undefined;
|
||||
@@ -100,4 +103,78 @@ describe("cross-host queue writes wait for the daemon's answer", () => {
|
||||
expect(all).toContain("The write outcome is INDETERMINATE");
|
||||
expect(all).toMatch(/repeat the same create with --id 'qitem-\d{14}-[0-9a-f]{16}'/);
|
||||
});
|
||||
|
||||
// A handoff's lost answer leaves the source row, on the daemon the command used, as the thing to read: its successor is
|
||||
// named there once recorded. The error never names a successor id, says the handoff failed, or asks to repeat it.
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
for (const [where, to, successor] of [
|
||||
["to another host", "dev@rig-b@vps-b", "Read its recorded outcome: a handed-off row names its successor on host 'vps-b', or says why it can't."],
|
||||
["on this daemon", "dev@rig-b", "Read its recorded outcome: a handed-off row names its successor, or says why it can't."],
|
||||
] as const) {
|
||||
it(`a ${verb} ${where} that gets no answer in time names the source row to read before any retry`, async () => {
|
||||
let thrown: unknown;
|
||||
await run([verb, "qitem-src-1", "--to", to, "--summary", "s"],
|
||||
[new DaemonTimeoutError(`Request to http://localhost:7433/api/queue/qitem-src-1/${verb} timed out after 15000ms`)])
|
||||
.catch((e: unknown) => { thrown = e; });
|
||||
expect(thrown).toBeInstanceOf(DaemonTimeoutError);
|
||||
const message = (thrown as Error).message;
|
||||
expect(message).toContain("Before any retry, read source row qitem-src-1 at the same endpoint and OPENRIG_HOME: rig queue show 'qitem-src-1' --full --json.");
|
||||
expect(message).toContain(successor);
|
||||
expect(message).toContain("doesn't show the handoff failed");
|
||||
expect(message).not.toMatch(/--id|repeat the/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rendered as a person sees it, through the shared program renderer: a lost connection or an unreadable response after
|
||||
// a handoff was sent is an unknown outcome, never "not delivered", and keeps the source row to read.
|
||||
async function runRendered(argv: string[], replies: Reply[], json: boolean): Promise<{ text: string; code: number }> {
|
||||
const { deps } = makeDeps(replies);
|
||||
const lines: string[] = [];
|
||||
const code = await runProgram(createProgram({ queueDeps: deps }), ["node", "rig", "queue", ...argv, ...(json ? ["--json"] : [])],
|
||||
{ out: (l) => lines.push(l), err: (l) => lines.push(l), exit: () => {} });
|
||||
return { text: [...stderr, ...lines].join("\n"), code };
|
||||
}
|
||||
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
for (const [kind, error] of [
|
||||
["a lost connection", () => new DaemonConnectionError("Cannot connect to the daemon at http://localhost:7433: fetch failed", "ECONNRESET")],
|
||||
["an unreadable response", () => new DaemonResponseError(200, "")],
|
||||
] as const) {
|
||||
for (const json of [false, true]) {
|
||||
it(`a ${verb} that meets ${kind} reads as an unknown outcome, never undelivered (${json ? "--json" : "human"})`, async () => {
|
||||
const { text, code } = await runRendered([verb, "qitem-src-1", "--to", "dev@rig-b", "--summary", "s"], [error()], json);
|
||||
expect(code).toBe(1);
|
||||
expect(text).toContain("outcome is UNKNOWN");
|
||||
expect(text).not.toMatch(/not delivered/i);
|
||||
expect(text).toContain("Before any retry, read source row qitem-src-1");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The selected daemon's forward can answer with a structured reply instead of throwing. An explicit indeterminate one
|
||||
// gets the same source-row hint, with the reply and its exit code unchanged; a refusal before any forward implies no
|
||||
// write that may still commit.
|
||||
const INDETERMINATE = { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "unreachable",
|
||||
outcome: "indeterminate", detail: "remote queue write timed out after 10000ms; the remote may still commit it" } };
|
||||
const REFUSED = { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "unsupported-transport",
|
||||
detail: "host 'vps-b' is SSH-declared" } };
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
it(`a ${verb} whose forward answers indeterminate names the source row to read, keeping the reply and exit code`, async () => {
|
||||
await run([verb, "qitem-src-1", "--to", "dev@rig-b@vps-b", "--summary", "s"], [INDETERMINATE]);
|
||||
const all = stderr.join("\n");
|
||||
expect(all).toContain("Before any retry, read source row qitem-src-1 at the same endpoint and OPENRIG_HOME: rig queue show 'qitem-src-1' --full --json.");
|
||||
expect(all).toContain("a handed-off row names its successor on host 'vps-b', or says why it can't.");
|
||||
expect(stdout).toEqual([JSON.stringify(INDETERMINATE.data)]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it(`a ${verb} the daemon refused to forward says nothing may still commit`, async () => {
|
||||
await run([verb, "qitem-src-1", "--to", "dev@rig-b@vps-b", "--summary", "s"], [REFUSED]);
|
||||
expect(stderr.join("\n")).not.toMatch(/may still commit|INDETERMINATE|Before any retry/);
|
||||
expect(stdout).toEqual([JSON.stringify(REFUSED.data)]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -14,6 +14,13 @@ interface Result {
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
// A create waits on nothing (each claim is one exclusive file create), so one still running after a minute is hung; a
|
||||
// slow start on a loaded CI runner took over 15 s. A batch's creates run together, so the batch tests allow each batch the
|
||||
// full limit plus one for setup and checks, and a hung create there is reported by its own limit. A single-create test
|
||||
// keeps the suite's default timeout, which reports a hang first.
|
||||
const CREATE_LIMIT_MS = 60_000;
|
||||
const batchesTimeout = (batches: number) => (batches + 1) * CREATE_LIMIT_MS;
|
||||
|
||||
describe("public slice creation ordinal claims", () => {
|
||||
let root: string;
|
||||
let workspace: string;
|
||||
@@ -49,7 +56,7 @@ describe("public slice creation ordinal claims", () => {
|
||||
];
|
||||
try {
|
||||
const result = await promisify(execFile)(process.execPath, args, {
|
||||
env: ownedEnv, encoding: "utf8", timeout: 15000,
|
||||
env: ownedEnv, encoding: "utf8", timeout: CREATE_LIMIT_MS,
|
||||
});
|
||||
return { exitCode: 0, ...result };
|
||||
} catch (error) {
|
||||
@@ -95,7 +102,7 @@ describe("public slice creation ordinal claims", () => {
|
||||
expect(fs.readdirSync(path.join(mission, "slices"), { withFileTypes: true }).filter((entry) => entry.isDirectory())).toHaveLength(33);
|
||||
expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original);
|
||||
expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false);
|
||||
}, 30000);
|
||||
}, batchesTimeout(4));
|
||||
|
||||
it("twenty eight-process manifest-backed batches preserve the admitted concurrency contract", async () => {
|
||||
const ref = "slices/01-existing/slice.yaml";
|
||||
@@ -144,7 +151,7 @@ describe("public slice creation ordinal claims", () => {
|
||||
expect(new Set(successful(results).map((slice) => slice.id)).size).toBe(160);
|
||||
expect(fs.readFileSync(path.join(unrelated, "PROOF.md"), "utf8")).toBe("# unrelated authored proof\n");
|
||||
expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false);
|
||||
}, 180000);
|
||||
}, batchesTimeout(20));
|
||||
|
||||
it("a later create repairs only pending complete registrations and preserves authored membership", async () => {
|
||||
const ref = "slices/01-existing/slice.yaml";
|
||||
|
||||
@@ -121,6 +121,19 @@ describe("S10 CLI cutover — admin verbs route to the daemon", () => {
|
||||
});
|
||||
|
||||
describe("S10 CLI cutover — config surfaces survive on the daemon-homed modules", () => {
|
||||
it("saves and reports the human-row preview choice", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "slack-preview-"));
|
||||
homes.push(home);
|
||||
const logs: string[] = [];
|
||||
const deps: SlackDeps = { home, log: message => logs.push(message), clientFactory: () => ({ get: async () => { throw new Error("offline"); } }) };
|
||||
await run(slackCommand(deps), ["setup", "--unfurl", "on"]);
|
||||
expect(JSON.parse(readFileSync(join(home, "slack-connector.json"), "utf8")).unfurl).toBe("on");
|
||||
logs.length = 0;
|
||||
await run(slackCommand(deps), ["status"]);
|
||||
expect(logs.join("\n")).toContain("human-row previews");
|
||||
expect(logs.join("\n")).toContain("on");
|
||||
});
|
||||
|
||||
it("retires the legacy alert-tag setup knob", () => {
|
||||
const { deps } = makeDeps();
|
||||
const setup = slackCommand(deps).commands.find((command) => command.name() === "setup");
|
||||
|
||||
@@ -311,6 +311,15 @@ describe("rig workflow CLI (PL-004 Phase D)", () => {
|
||||
expect(calls.find((c) => c.path === "/api/workflow/list?status=active")).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes combined workflow filters and repeated tags to the list API", async () => {
|
||||
const url = "/api/workflow/list?status=active&project=p&mission=m&slice=s&rig=rig&name=wf&limit=4&tag=one&tag=two";
|
||||
const { deps, calls } = makeDeps({ routes: { [`GET ${url}`]: { status: 200, data: [] } } });
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "workflow", "list", "--status", "active", "--project", "p", "--mission", "m", "--slice", "s", "--rig", "rig", "--name", "wf", "--limit", "4", "--tag", "one", "--tag", "two", "--json"]);
|
||||
expect(calls.find((call) => call.path === url)).toBeDefined();
|
||||
});
|
||||
|
||||
it("show GETs /api/workflow/:instanceId", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/workflow/inst-1": { status: 200, data: { instanceId: "inst-1" } } },
|
||||
|
||||
+4
-1
@@ -106,7 +106,10 @@ You are about to lose every connection you have built. Spend this turn making th
|
||||
3. **Open the map with a summary** of 10 to 20 lines: who you are, what you hold, what mattered in this
|
||||
window, what is next and who authorizes it, and any hold in force. **Publish that summary as your seat
|
||||
recap** too: save it, plus a line naming the map's path, to a file and run
|
||||
`rig context recap-write --file <that file>`, which writes your own seat's recap. If your world profile has a seat
|
||||
`rig context recap-write --file <that file>`, which writes your own seat's recap. Give the file's literal absolute
|
||||
path, with no shell variable assignment or command substitution: Claude Code's allow rules don't match past an
|
||||
assignment (apart from a few known-safe variables), and an ask or deny rule still wins, so such a command can stop at
|
||||
an approval prompt, and OpenRig won't send `/compact` while it waits. If your world profile has a seat
|
||||
recap atom, it loads this recap, and an old recap would be served as if it were current.
|
||||
4. **Then write the connections.** Choose what this seat needs; these are examples, not a template:
|
||||
- **State:** rows you hold (id, state), branches and PR heads, the hold or release in force and who gave
|
||||
|
||||
@@ -30,6 +30,8 @@ on an OpenRig team load `openrig-core` instead.
|
||||
1. OpenRig is installed and runs a team on this machine: `rig ps` lists it.
|
||||
2. Pick an address for this app on that team, such as `cursor@my-team`. The queue takes work for it; nothing is
|
||||
launched. To tell the team what it's for, add it to a roster as a hosted member (`docs/reference/hosted-members.md`).
|
||||
3. **Claude Code 2.1.119 or later.** On earlier versions, while the plugin's OpenRig seat is blank, Claude Code silently
|
||||
drops its MCP server: the skill loads, but your agent has no OpenRig tools.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -127,8 +127,8 @@ person needs in their project or home folder and name that path.
|
||||
joining as optional, with the effects below. Ask the person yes or no after this short explanation:
|
||||
- This adds me as a named member of the team, with my name on messages.
|
||||
- In a plain shell, joining writes no files in your project or personal settings.
|
||||
- Inside tmux on 0.6.11, my sender and reply address is your existing tmux session name, even a number like `0`.
|
||||
`rig attach --self` returns that address and `rig whoami` confirms it; `--display-name` does not change it.
|
||||
- Inside tmux, my sender and reply address is `<member>@<rig>`, even when your tmux session is named `0`;
|
||||
`rig attach --self` returns it and `rig whoami` confirms it; `--display-name` does not change it.
|
||||
- Inside tmux, other agents can type into this window, and stopping the team (`rig down`) also closes this tmux
|
||||
session (both from source, not hand-tested). Transcript recording and, for Claude Code, writing
|
||||
`.openrig/context-collector.cjs` and `.claude/settings.local.json` for the status line were hand-tested on 0.6.11.
|
||||
@@ -147,7 +147,8 @@ person needs in their project or home folder and name that path.
|
||||
`rig queue create --source <their id>@external --destination <seat> --summary "<one line>" --body-file <file>`: the
|
||||
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
|
||||
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
|
||||
shell above is the route.
|
||||
shell above is the route. For work whose questions and result must come back to this session, use a return
|
||||
address ("If the person stays with you" below), not a declared name.
|
||||
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
|
||||
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
|
||||
name someone else), and don't offer again.
|
||||
@@ -168,9 +169,10 @@ person needs in their project or home folder and name that path.
|
||||
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
|
||||
(never the logical ID or a guessed name) and send:
|
||||
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
|
||||
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
|
||||
results to relay." Carry their constraints, which logins they have (an installed provider that isn't signed in
|
||||
doesn't count), and any permission choice they made, chosen or declined, in either handoff.
|
||||
If the person stays here, hand the goal over as a task from your own address instead, so the operator's questions
|
||||
and the result come back to you ("If the person stays with you" below). Carry their constraints, which logins they
|
||||
have (an installed provider that isn't signed in doesn't count), and any permission choice they made, chosen or
|
||||
declined, in either handoff.
|
||||
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
|
||||
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
|
||||
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
|
||||
@@ -181,11 +183,54 @@ person needs in their project or home folder and name that path.
|
||||
Check those logins (`claude auth status`, `codex login status`), or go by what the person told you. If one is missing
|
||||
or you can't tell, say so before launching and let them choose: the operator adapts the team to the logins they have,
|
||||
or they sign in first.
|
||||
- **Relay the operator's questions** unless the person declined the Codex offer below and explicitly chose the operator route: the operator asks in its own pane, and the person may not be looking there.
|
||||
After each step you hand it, read its screen with `rig capture <canonicalSessionName>`. When it asks the person
|
||||
something (start this team? which folder? which option?), ask them here in the operator's words and send their
|
||||
answer back with `rig send`, or tell them to answer in the operator's pane. Unlike a prompt or a menu, a question
|
||||
doesn't show up as a stopped seat, so don't leave it to a background wait.
|
||||
- **If the person stays with you** (unless they declined the Codex offer below and explicitly chose the operator
|
||||
route), you're their front door to the team: you hand it the goal, bring its questions to them and bring back what
|
||||
it produced, through the task queue rather than the operator's screen.
|
||||
- **Your return address:** if this session joined the team (above), it's the `OPENRIG_SESSION_NAME` that
|
||||
`rig attach` printed. Otherwise agree one with the person on the kernel's rig, such as `claude-app@kernel` or
|
||||
`codex-app@kernel`. It's an address the queue takes tasks for; nothing is launched for it and no file is written.
|
||||
If the person keeps more than one of these conversations, give each its own address (for example
|
||||
`claude-app-2@kernel`); one address shares its questions.
|
||||
To tell the team what it's for, the person can add it to a roster as a hosted member
|
||||
(`docs/reference/hosted-members.md` in the OpenRig package).
|
||||
Put it in front of every `rig queue` command: `OPENRIG_SESSION_NAME=<address> rig queue ...` (add
|
||||
`OPENRIG_HOME=<home>` when OpenRig doesn't use the default home). A `--source` name only labels the one task it's
|
||||
on: claiming and answering the team's questions need the address in `OPENRIG_SESSION_NAME`.
|
||||
- **Hand over the goal:** write it to a file with what the message above carries (that you're the agent that
|
||||
installed OpenRig and the person stays with you, the goal, folder, branch, constraints, logins and permission
|
||||
choices), and end it with this paragraph, filling in your address:
|
||||
|
||||
> To ask me anything, create a queue item to `<address>` (`rig queue create --destination <address> ...`), then
|
||||
> block this item on that new item's id (`rig queue block <this id> --on <question id>`), so my answer wakes you. I
|
||||
> check it when the person asks. Don't ask in your own terminal: the person isn't there. If you hand this item on,
|
||||
> copy this paragraph into the new item. When you finish, put the outcome in this item's resolution and any file in
|
||||
> its evidence ref.
|
||||
|
||||
Then run
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <canonicalSessionName> --summary "<the goal in a sentence>" --body-file <file> --verify`.
|
||||
Tell the person the task ID it prints, and keep the goal, your address and that ID: they're how you find this work
|
||||
again.
|
||||
- **The team's questions** come to your address as tasks:
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue list --owned --state pending,in-progress`. Read each in full
|
||||
(`rig queue show <id> --full`) and show the person the whole question in the team's words. Record only what they
|
||||
tell you, a condition included ("yes, but only on a branch"): claim it, then
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue update <id> --state done --closure-reason no-follow-on --resolution "<their words>"`.
|
||||
Add no label, quotation marks or notes of your own, and don't turn their words into a yes or a no: the agent that
|
||||
asked decides what they mean. You speak as yourself, never as the person. If they aren't there to answer, leave
|
||||
it open and tell them it's waiting; never answer for them.
|
||||
- **A question only on the operator's screen:** if the operator asks in its pane and nothing reached your address,
|
||||
read it with `rig capture <canonicalSessionName>`, show the person, and send their words as a new task to the
|
||||
operator that names your task's ID, not as keys typed into its pane.
|
||||
- **Delivery isn't the task's state.** A task's state says where the work is: pending (created and readable, not
|
||||
yet claimed), claimed or in progress (an agent took it), handed off (follow the next item) or done (read its
|
||||
resolution). Whether its agent was woken is a separate fact, the wake outcome `--verify` reports: `verified` (it
|
||||
rendered in the seat's pane) or `claimed` (picked up); `delivered-ack-pending` (sent, render unconfirmed: not
|
||||
proof it was woken); `retained` (the seat's typing guard held the wake, the task is still there to read, and it
|
||||
isn't retried automatically: wake the task's destination once its input is free with
|
||||
`rig send <destination> "task <id> is yours"`); `failed` (the wake didn't land, and the task is still
|
||||
owed: wake it the same way); `indeterminate` or `still-pending` (unknown, neither failed nor delivered: check
|
||||
before you wake it again). A pending task with no wake isn't lost or done.
|
||||
- **Nothing wakes this chat** when the team asks or finishes: check your address when the person asks or comes back.
|
||||
- **If the operator answers with a model error,** such as "… not supported when using Codex with a ChatGPT account", its
|
||||
Codex sign-in works but that account can't run the model. OpenRig put the operator on Codex because the sign-in
|
||||
passed. Tell the person which model failed. If Claude Code is signed in, offer to move the operator to it; on their
|
||||
@@ -216,9 +261,9 @@ person needs in their project or home folder and name that path.
|
||||
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
|
||||
Another kind of menu you can answer with
|
||||
the key that screen shows, or ask the person. Capture it again before sending anything more.
|
||||
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
|
||||
`rig queue list --destination <your address>` and `rig queue show <id> --full`, and read an agent's screen with
|
||||
`rig capture <seat>@<rig>`.
|
||||
- **Replies:** in a plain shell, other agents can't type into this session. Their questions and work for you arrive
|
||||
at your return address ("If the person stays with you" above). `rig capture <seat>@<rig>` shows what an agent is
|
||||
doing; it isn't how answers reach you.
|
||||
|
||||
### Codex: one offer, once, after OpenRig starts
|
||||
|
||||
@@ -257,21 +302,29 @@ offer, so the person sees the real file, not a placeholder:
|
||||
|
||||
Follow these steps by default. Only when the person declined the Codex offer above and explicitly chose the operator route does the operator own this follow-through.
|
||||
|
||||
- **Find the team's task:** the operator gives the goal to the team's lead as a queue task: `dev-build@starter`, or
|
||||
`orch-lead` in `workshop` and `factory` (for starter and factory, `rig specs preview <name> --kind rig` says what
|
||||
each seat does; for a running team, `rig ps --nodes --rig <rig>`). Find it with
|
||||
`rig queue list --destination <seat>@<rig> -a`.
|
||||
- **Find the team's task:** if you gave the operator a task, start from it: when the operator hands it to the team's
|
||||
lead, `rig queue show <id> --json` lists the next item under `successors`. Otherwise, or if the operator started a
|
||||
new task for the lead (`dev-build@starter`, or `orch-lead` in `workshop` and `factory`; for starter and factory,
|
||||
`rig specs preview <name> --kind rig` says what each seat does; for a running team, `rig ps --nodes --rig <rig>`),
|
||||
find it with `rig queue list --destination <seat>@<rig> -a`.
|
||||
- **Follow it to the result:** a task that changed hands isn't finished. `rig queue show <id> --full` names the seat it
|
||||
went to, and that seat's next task is the one whose `handedOffFrom` is that ID
|
||||
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result, and
|
||||
use `rig capture <seat>@<rig>` to see what it's doing. Then read what it produced and tell the person what the team
|
||||
actually did (the branch, commit, review or PR text), not just that you sent it.
|
||||
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result,
|
||||
and report what the record says; `rig capture <seat>@<rig>` only shows what an agent is doing right now. Then read
|
||||
what it produced and tell the person what the team actually did (the branch, commit, review or PR text), not just
|
||||
that you sent it.
|
||||
- **Give the team more work:** write the goal to a file: what to change, in which repository and branch, what done
|
||||
looks like, and that the result comes back to you (the branch or commit, the review, any PR text). Then
|
||||
`rig queue create --source <your name> --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file>`, and tell that agent with
|
||||
`rig send <seat>@<rig> "task <id> is yours"`. A message informs, and a queue task is the work someone owns.
|
||||
`--source` names you when this session hasn't joined the team.
|
||||
looks like, and that the result comes back to you (the branch or commit, the review, any PR text), ending with the
|
||||
reply paragraph above. Then
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file> --verify`.
|
||||
A message informs, and a queue task is the work someone owns. Read its wake outcome as above before you send
|
||||
anything else.
|
||||
- **Then ask what's next.** If no team can take the work, fix that or ask the person. Don't quietly do it yourself.
|
||||
- **Coming back later:** in a new session, use the same return address and the task IDs you gave the person. Read
|
||||
each with `rig queue show <id> --json` and follow its `successors` to the newest item, as above;
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue list --mine -a` lists everything this address sent or owns, finished work
|
||||
included (without `-a` it shows only open items). Follow that work rather than creating it again.
|
||||
Adding OpenRig's MCP plugin to this app later starts a new session; it doesn't carry this conversation over.
|
||||
- **Other ways to work with the team:** `rig send <seat>@<rig> "..."` types into an agent's terminal,
|
||||
`rig capture <seat>@<rig>` reads its screen, and `rig ps --nodes --rig <rig>` shows who's on it.
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ already does it, or two verbs crossed already answer it. **You cannot look up a
|
||||
not know exists.** So the point of what follows is not skill. It is recognition: that when a task
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety-two top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.12`). This marker describes the pack's teaching, not publication or live
|
||||
Ninety-three top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.13`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -749,7 +749,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
conversation continuity stays unverified. Both refuse while typing protection is on.
|
||||
- **`rig unclaim` / `rig release`** — stop managing one adopted session, or all of them,
|
||||
**without killing the process or the agent inside it**. Handing something back to its human is a
|
||||
first-class move, not an abandonment.
|
||||
first-class move, not an abandonment. To stop an adopted session after unclaiming it, end its
|
||||
process and its tmux session; closing only the viewing window detaches it. Since 0.7.13 a fresh
|
||||
`rig launch` after `rig unclaim` ended the server's last session reopens the server (a released
|
||||
session that's still running blocks it, as before).
|
||||
|
||||
> **Two words that mean different things at different altitudes.** `unclaim` and `release` each
|
||||
> exist both here — *stop managing a live session* — and in the queue — *put a work row back down*.
|
||||
@@ -866,7 +869,9 @@ version, from checking the environment to a useful report to the OpenRig team.
|
||||
effectful handover path.** Both forms perform the operation by default; pass `--dry-run`
|
||||
to plan without changing the seat. Check the selected source and continuity evidence first. A
|
||||
seat can be handed over again into its own pane, so a seat left half-handed-over recovers by
|
||||
running the handover again. A Claude seat started through a `#!` wrapper that runs Claude without
|
||||
running the handover again. A handover whose successor was launched but not ready says it may be
|
||||
running: look at the pane before retrying. A handover cleans up the retiring occupant's own claims
|
||||
and jobs. A Claude seat started through a `#!` wrapper that runs Claude without
|
||||
`exec`, or through the installer's `~/.local/bin/claude` symlink, rebuilds with `--source
|
||||
rebuild`; a failed rebuild leaves the resume target on the earlier conversation, so rebuild again
|
||||
rather than relaunching it fresh.
|
||||
@@ -1231,6 +1236,9 @@ reaching them is ordinary work rather than an escalation.
|
||||
see which host you are acting on, and check a registered host is *actually* usable end to end.
|
||||
`rig host edit <id>` corrects an entry (its id and transport stay fixed), and `rig host remove <id>` deletes
|
||||
only the registry entry: credential files, the learned identity and the selection stay as they are.
|
||||
- **`rig network`** — **experimental, unsupported and at your own risk.** It connects one local seat
|
||||
to a hub you already have. The daemon starts the connector, sweeps leftover credential folders and
|
||||
schedules local rounds even with no binding, so it isn't inert, and its migrations apply on upgrade.
|
||||
- **A cross-host queue write that times out reads INDETERMINATE, not failed.** The CLI waits 15
|
||||
seconds for the daemon's answer, and `rig queue create` with no answer reads the row back once by
|
||||
its ID. Reconcile by that ID before any retry; don't send the write twice.
|
||||
@@ -1275,6 +1283,11 @@ reaching them is ordinary work rather than an escalation.
|
||||
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
|
||||
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
|
||||
stays the default, and a person's connector binding can be unset.
|
||||
**Since 0.7.13 a person's runner answer gets a note back:** who decides and in what state, or that
|
||||
the answer is kept and the tool hasn't run, and a runner waiting on it wakes on the decision. Read
|
||||
that note before asking the person to answer again. The answer a receiving agent reads says what an
|
||||
answer typed at the machine's terminal means, to act on a clear one with the request's own decision
|
||||
command, and how to ask the person back, with a new ask to them, when it's unclear.
|
||||
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
|
||||
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
|
||||
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
|
||||
@@ -1413,7 +1426,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
conversation it is in the middle of. **`provider signals` reports anomalies rather than a
|
||||
listing** — unbound seats, accounts shared across several seats. A Codex seat showing its
|
||||
usage-limit banner reads activity `unknown` with reason `usage_limit`, and keeps it while its
|
||||
screen redraws; `provider signals` can show a provider-event signal for that seat's account that
|
||||
screen redraws (a banner with a curly apostrophe included); a Claude seat whose usage-limit message
|
||||
states a reset time is parked until then, instead of being retried every few minutes; `provider signals` can show a provider-event signal for that seat's account that
|
||||
allows a switch decision. With `provider.usage_windows` on (off by default), `rig provider status`
|
||||
shows Codex's windows and Claude's 5-hour and weekly windows from the seats' status lines, each
|
||||
seat's configured account, and with `--all-hosts` or `--hosts <ids>` every registered machine's
|
||||
@@ -1484,7 +1498,11 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
|
||||
adds its one line to shared guidance files (`skills.router_line.enabled`).
|
||||
Setup offers to register the person when none is registered, and a team started from the
|
||||
person's own assistant fits their own logins, with a review before anything is committed.
|
||||
person's own assistant fits their own logins, with a review before anything is committed. Since
|
||||
0.7.13, `rig daemon start` ends with that offer when the person registry can be read and holds
|
||||
nobody. The kernel operator's startup is also taught to run `rig gateway participants` before its
|
||||
first greeting and offer registration when nobody is registered. The agent asks first and
|
||||
registers only on the person's yes.
|
||||
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
|
||||
reset, or stopped reporting entirely. The last one is a signal, not a gap.
|
||||
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
|
||||
@@ -1581,6 +1599,9 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
|
||||
2xx records only that the ring was accepted, not that anything woke. When work waits for a
|
||||
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
|
||||
A hosted member's roster entry shows whether it's checking in: its expected cadence and its last
|
||||
check-in, read as checking in, overdue, not yet checked in or checking in on request, and unknown
|
||||
when that can't be read. It's a status, never a gate on delivery.
|
||||
**Internet-facing hosted members are experimental, unsupported, off by default and at your
|
||||
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
|
||||
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
|
||||
@@ -1676,6 +1697,6 @@ deliberate writing.
|
||||
|
||||
**`rig --help`**, then `rig <verb> --help`.
|
||||
|
||||
Ninety-two verbs ship, and this page named most of them once. **You will not remember which — you
|
||||
Ninety-three verbs ship, and this page named most of them once. **You will not remember which — you
|
||||
are meant to remember only that the list is long enough to be worth reading before you build
|
||||
anything.** The question is never *how do I write this*; it is **does this already exist**.
|
||||
@@ -11,8 +11,8 @@ import { execRuntimeProbe } from "./runtime-probe-exec.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { codexPostureArg } from "./yolo-mode.js";
|
||||
import { observeCodexSandbox } from "../domain/permission-drift.js";
|
||||
import { codexEnvelopeArgs } from "../domain/codex-rebuild-permissions.js";
|
||||
import { codexMemberEnvelope, observeCodexEnvelope, type CodexMemberPermissions } from "../domain/codex-member-permissions.js";
|
||||
import { codexMemberLaunch, type CodexMemberPermissions } from "../domain/codex-member-permissions.js";
|
||||
import { codexPolicyWarning, resolveCodexFilesystemHome, verifyCodexFilesystemPolicy, type CodexFilesystemVerifier } from "../domain/codex-filesystem-preflight.js";
|
||||
import { unknownDaemonSupportMessage, type CodexDaemonSupportDetector } from "../domain/codex-daemon-support.js";
|
||||
import { codexNetworkDefaultArg, type CodexNetworkDefaultReader } from "../domain/codex-network-default.js";
|
||||
|
||||
@@ -40,6 +40,7 @@ interface CodexResumeOptions {
|
||||
readNetworkDefault?: CodexNetworkDefaultReader;
|
||||
/** launch.codex_login_shell: true keeps Codex's own login shells for tool commands. Absent: off. */
|
||||
codexLoginShell?: () => boolean;
|
||||
verifyFilesystemPolicy?: CodexFilesystemVerifier;
|
||||
/** Ensure the managed Codex config (hooks, trust) just before a resume, as a runtime-adapter launch does, in the
|
||||
* seat's own home when it has one. */
|
||||
ensureManagedConfig?: (codexHome?: string) => Promise<void>;
|
||||
@@ -93,7 +94,17 @@ export class CodexResumeAdapter {
|
||||
if (wrapped && !this.options.seatLaunchEnvironment) {
|
||||
return { ok: false, code: "resume_failed", message: "launch_wrapper support unavailable; no agent started." };
|
||||
}
|
||||
const codexHome = seatCodexHome ?? this.options.codexHome;
|
||||
const filesystem = codexPermissions && "filesystem" in codexPermissions ? codexPermissions : undefined;
|
||||
const codexHome = seatCodexHome ?? this.options.codexHome ?? (filesystem ? resolveCodexFilesystemHome() : undefined);
|
||||
let policyWarning: string | undefined;
|
||||
if (filesystem) {
|
||||
try {
|
||||
const verified = await (this.options.verifyFilesystemPolicy ?? verifyCodexFilesystemPolicy)({ permissions: filesystem, cwd, codexHome, launchPath: this.options.launchPath, launchWrapper,
|
||||
codexConfigProfile: codexConfigProfile?.trim() || undefined });
|
||||
if (!verified.ok) return { ok: false, code: "resume_failed", message: verified.error };
|
||||
if (!verified.verified) policyWarning = codexPolicyWarning(verified.reason);
|
||||
} catch { policyWarning = codexPolicyWarning("Native Codex configuration reader failed"); }
|
||||
}
|
||||
|
||||
// Only a seat's own home is passed on, so a call for a seat without one is exactly as before.
|
||||
await (seatCodexHome ? this.options.ensureManagedConfig?.(seatCodexHome) : this.options.ensureManagedConfig?.());
|
||||
@@ -120,15 +131,16 @@ export class CodexResumeAdapter {
|
||||
}
|
||||
|
||||
const profileArg = codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : "";
|
||||
const envelope = codexPermissions ? codexMemberEnvelope(codexPermissions) : undefined;
|
||||
const posture = envelope ? profileArg + codexEnvelopeArgs(envelope) : codexPostureArg(profileArg, process.env, resolvedPosture);
|
||||
const appliedLaunch = envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture);
|
||||
const postureArg = posture + operationalLaunchArg("codex", envelope ? { nonInterruptive }
|
||||
const choice = codexPermissions ? codexMemberLaunch(codexPermissions) : undefined;
|
||||
const posture = choice ? profileArg + choice.args : codexPostureArg(profileArg, process.env, resolvedPosture);
|
||||
const appliedLaunch = choice ? choice.observation : observeCodexSandbox(posture);
|
||||
if (policyWarning) appliedLaunch.reason = policyWarning;
|
||||
const postureArg = posture + operationalLaunchArg("codex", choice ? { nonInterruptive }
|
||||
: { kernelAuthority, teamPermissionDefault, nonInterruptive, launchPosture: resolvedPosture });
|
||||
const readNetworkDefault = this.options.readNetworkDefault;
|
||||
const networkArg = envelope ? "" : await codexNetworkDefaultArg(seatCodexHome && readNetworkDefault ? ((dir: string) => readNetworkDefault(dir, seatCodexHome)) : readNetworkDefault,
|
||||
const networkArg = choice ? "" : await codexNetworkDefaultArg(seatCodexHome && readNetworkDefault ? ((dir: string) => readNetworkDefault(dir, seatCodexHome)) : readNetworkDefault,
|
||||
appliedLaunch, cwd, tmuxSessionName);
|
||||
const workspaceArg = !envelope && teamPermissionDefault && !codexConfigProfile?.trim()
|
||||
const workspaceArg = !choice && teamPermissionDefault && !codexConfigProfile?.trim()
|
||||
&& appliedLaunch.state === "observed" && appliedLaunch.value === "workspace-write"
|
||||
? codexTeamWorkspaceArg(this.options.prepareTeamWorkspace, tmuxSessionName) : "";
|
||||
// The seat's own writable roots, as a launch gets them: its git metadata dirs (so it can still commit) and its topology
|
||||
@@ -152,7 +164,7 @@ export class CodexResumeAdapter {
|
||||
|
||||
const launchEnv = [this.options.launchPath ? `PATH=${shellQuote(this.options.launchPath)}` : "", codexHome ? `CODEX_HOME=${shellQuote(codexHome)}` : ""].filter(Boolean);
|
||||
const staged = this.options.seatLaunchEnvironment
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { codexCwd: cwd, runtime: "codex", ...(seatCodexHome ? { codexHome: seatCodexHome } : {}), ...(wrapped ? { launchWrapper: wrapped } : {}) })
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { codexCwd: cwd, runtime: "codex", ...((filesystem ? codexHome : seatCodexHome) ? { codexHome: filesystem ? codexHome : seatCodexHome } : {}), ...(wrapped ? { launchWrapper: wrapped } : {}) })
|
||||
: launchEnv.length ? `env ${launchEnv.join(" ")} ${cmd}` : cmd;
|
||||
if (beforeNativeDispatch && !beforeNativeDispatch()) return { ok: false, code: "attention_required", message: "Selected history changed during Codex resume preparation; no native command was sent." };
|
||||
const textResult = beforeNativeDispatch
|
||||
@@ -164,7 +176,7 @@ export class CodexResumeAdapter {
|
||||
|
||||
const result = await this.verifyResume(tmuxSessionName);
|
||||
const launchFacts = daemonSupport ? { ...appliedLaunch, daemonOptOut: daemonSupport.kind === "supported" } : appliedLaunch;
|
||||
return result.ok ? { ...result, appliedLaunch: launchFacts } : result;
|
||||
return result.ok ? { ...result, appliedLaunch: launchFacts, ...(policyWarning ? { warnings: [policyWarning] } : {}) } : result;
|
||||
}
|
||||
|
||||
// Mirrors ClaudeResumeAdapter.verifyResume: poll the pane, run the native
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { codexEnvelopeArgs } from "../domain/codex-rebuild-permissions.js";
|
||||
import { codexMemberEnvelope, observeCodexEnvelope, readProfileFingerprint, type CodexProfileObservation, type ProfileFingerprint } from "../domain/codex-member-permissions.js";
|
||||
import { codexMemberLaunch, observeCodexEnvelope, readProfileFingerprint, type CodexProfileObservation, type ProfileFingerprint } from "../domain/codex-member-permissions.js";
|
||||
import { codexPolicyWarning, resolveCodexFilesystemHome, verifyCodexFilesystemPolicy, type CodexFilesystemDecision, type CodexFilesystemVerifier } from "../domain/codex-filesystem-preflight.js";
|
||||
import { operationalLaunchArg } from "./kernel-authority.js";
|
||||
import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
@@ -176,6 +177,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// the real probe by default.
|
||||
private verifyProfilePreflight: (profile: string, cwd: string, home?: string) => Promise<CodexProfileProbeResult>;
|
||||
private profileFingerprint: (path: string) => Promise<ProfileFingerprint>;
|
||||
private verifyFilesystemPolicy: CodexFilesystemVerifier;
|
||||
// A handover checks the exact binding before retiring its predecessor. Consume
|
||||
// that result once at dispatch, rather than repeat a refusal after cutover.
|
||||
private filesystemPreflight = new WeakMap<NodeBinding, CodexFilesystemDecision>();
|
||||
// #69: whether the installed Codex supports --no-daemon. Startup wires the real probe;
|
||||
// absent (unit tests, other embedders) keeps the existing invocation unchanged.
|
||||
private detectDaemonSupport?: CodexDaemonSupportDetector;
|
||||
@@ -238,6 +243,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
verifyProfilePreflight?: (profile: string, cwd?: string, home?: string) => Promise<CodexProfileProbeResult>;
|
||||
/** The setter's optional profile hash; defaults to a bounded, non-blocking read. */
|
||||
profileFingerprint?: (path: string) => Promise<ProfileFingerprint>;
|
||||
verifyFilesystemPolicy?: CodexFilesystemVerifier;
|
||||
detectDaemonSupport?: CodexDaemonSupportDetector;
|
||||
readNetworkDefault?: CodexNetworkDefaultReader;
|
||||
codexLoginShell?: () => boolean;
|
||||
@@ -275,6 +281,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
this.codexHome = deps.codexHome;
|
||||
this.seatHome = deps.seatHome;
|
||||
this.profileFingerprint = deps.profileFingerprint ?? readProfileFingerprint;
|
||||
this.verifyFilesystemPolicy = deps.verifyFilesystemPolicy ?? verifyCodexFilesystemPolicy;
|
||||
this.launchPath = deps.launchPath;
|
||||
this.detectDaemonSupport = deps.detectDaemonSupport;
|
||||
this.readNetworkDefault = deps.readNetworkDefault;
|
||||
@@ -309,8 +316,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
/** The home a seat's Codex uses: its own from its member or rig `env:`, else the daemon's explicit CODEX_HOME. Undefined
|
||||
* keeps the legacy OS/PID home behavior. */
|
||||
private homeFor(binding: { nodeId?: string; accountHome?: string | null } | undefined): string | undefined {
|
||||
return this.ownHome(binding) ?? this.codexHome;
|
||||
private homeFor(binding: { nodeId?: string; accountHome?: string | null; codexPermissions?: NodeBinding["codexPermissions"] } | undefined): string | undefined {
|
||||
const selected = this.ownHome(binding) ?? this.codexHome;
|
||||
return binding?.codexPermissions && "filesystem" in binding.codexPermissions
|
||||
? selected ?? resolveCodexFilesystemHome(undefined, this.fs.homedir) : selected;
|
||||
}
|
||||
|
||||
/** Only the seat's own CODEX_HOME, undefined when it declares none. Probes and readers get it as an extra argument
|
||||
@@ -319,8 +328,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return binding?.accountHome ?? (binding?.nodeId ? this.seatHome?.(binding.nodeId, binding.accountHome === null) : undefined);
|
||||
}
|
||||
|
||||
private ownHomeCommandOpt(binding: { nodeId?: string; accountHome?: string | null }): { codexHome?: string } {
|
||||
const own = this.ownHome(binding);
|
||||
private ownHomeCommandOpt(binding: NodeBinding): { codexHome?: string } {
|
||||
const own = binding.codexPermissions && "filesystem" in binding.codexPermissions ? this.homeFor(binding) : this.ownHome(binding);
|
||||
return own ? { codexHome: own } : {};
|
||||
}
|
||||
|
||||
@@ -465,7 +474,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// Codex's "continue without trusting" choice writes no state, so an existing install with no hooks.state entry for any
|
||||
// of OpenRig's hooks may be an unrecorded skip, or an older install that never granted them. OpenRig trusts its own
|
||||
// exact shipped hooks there as on a first install (#17). Any recorded choice for one of them, or a layer it can't read,
|
||||
// leaves native review in place. A proven owned grant may renew its content approval.
|
||||
// leaves native review in place. A proven owned grant may renew its content approval, and its declarations when a new
|
||||
// shipped bundle changes them and every approval still matches OpenRig's own record.
|
||||
const firstInstall = !this.fs.exists(cacheRoot) && !layers.some(layer => layer.state === "read"
|
||||
&& Object.hasOwn((parseToml(layer.content).plugins ?? {}) as object, CODEX_CORE_PLUGIN_KEY));
|
||||
const noOwnHookChoice = (read: CodexConfigLayerRead[]) => {
|
||||
@@ -482,14 +492,20 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
let content = before;
|
||||
const planned = planOwnedCodexContentApproval({ configPath, content, layers: currentLayers, shipped, installed: cachedDir, freshGrant });
|
||||
if (!planned) return;
|
||||
for (const [key, hash] of Object.entries(planned.trustedHashes)) content = upsertCodexHookTrust(content, key, hash);
|
||||
// The declaration writer ends the file with one newline; keep the owner's own ending.
|
||||
if (content !== before) content = content.replace(/\n*$/, /\n*$/.exec(before)?.[0] ?? "");
|
||||
for (const [key, hash] of Object.entries(planned.hashes)) content = upsertCodexHookTrust(content, key, hash, "trusted_content_hash");
|
||||
// The line writer must not alter examples in strings or duplicate quoted keys.
|
||||
try {
|
||||
const expected = parseToml(before) as { hooks: { state: Record<string, Record<string, unknown>> } };
|
||||
for (const [key, hash] of Object.entries(planned.trustedHashes)) expected.hooks.state[key]!.trusted_hash = hash;
|
||||
for (const [key, hash] of Object.entries(planned.hashes)) expected.hooks.state[key]!.trusted_content_hash = hash;
|
||||
if (!isDeepStrictEqual(parseToml(content), expected)) return;
|
||||
} catch { return; } // Leave native review and owner bytes intact for unsupported TOML spellings.
|
||||
if (this.fs.readFile(configPath) !== before) return; // A concurrent native choice wins.
|
||||
// A concurrent native choice wins: in this file, or in any layer the plan read (a layer appearing, changing,
|
||||
// disappearing or becoming unreadable counts).
|
||||
if (this.fs.readFile(configPath) !== before || !isDeepStrictEqual(readLayers(), currentLayers)) return;
|
||||
if (content !== before) this.fs.writeFile(configPath, content);
|
||||
recordOwnedCodexApproval(configPath, planned.approval);
|
||||
};
|
||||
@@ -891,6 +907,36 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return { delivered, failed, ...(warnings.length ? { warnings } : {}) };
|
||||
}
|
||||
|
||||
/** No managed config writes, pane input, termination or bindings change here. */
|
||||
async preflightLaunch(binding: NodeBinding): Promise<{ ok: true; warnings?: string[] } | { ok: false; error: string }> {
|
||||
const decision = await this.checkFilesystemPolicy(binding);
|
||||
if (!decision) return { ok: true };
|
||||
if (!decision.ok) return decision;
|
||||
const profile = await this.checkProfileLoad(binding);
|
||||
if (!profile.ok) return profile;
|
||||
this.filesystemPreflight.set(binding, decision);
|
||||
return decision.verified ? { ok: true } : { ok: true, warnings: [codexPolicyWarning(decision.reason)] };
|
||||
}
|
||||
|
||||
private async checkFilesystemPolicy(binding: NodeBinding): Promise<CodexFilesystemDecision | undefined> {
|
||||
if (!binding.codexPermissions || !("filesystem" in binding.codexPermissions)) return undefined;
|
||||
try {
|
||||
return await this.verifyFilesystemPolicy({ permissions: binding.codexPermissions, cwd: binding.cwd,
|
||||
codexHome: this.homeFor(binding), launchPath: this.launchPath, launchWrapper: binding.launchWrapper,
|
||||
codexConfigProfile: binding.codexConfigProfile?.trim() || undefined });
|
||||
} catch {
|
||||
return { ok: true, verified: false, reason: "Native Codex configuration reader failed" };
|
||||
}
|
||||
}
|
||||
|
||||
private async checkProfileLoad(binding: NodeBinding): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const profile = binding.codexConfigProfile?.trim();
|
||||
if (!profile) return { ok: true };
|
||||
const own = this.ownHome(binding);
|
||||
const probe = await (own ? this.verifyProfilePreflight(profile, binding.cwd, own) : this.verifyProfilePreflight(profile, binding.cwd));
|
||||
return probe.ok ? { ok: true } : { ok: false, error: `${probe.error}${probe.migrationHint ? `\n Fix: ${probe.migrationHint}` : ""}` };
|
||||
}
|
||||
|
||||
async launchHarness(
|
||||
binding: NodeBinding,
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; beforeNativeDispatch?: () => boolean },
|
||||
@@ -914,6 +960,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const prepared = await this.prepareLaunch(binding, opts.name, undefined, { installCorePlugin: !opts.resumeToken });
|
||||
if (!prepared.ok) return prepared;
|
||||
const { modelArg, effortArg, appliedLaunch, postureArg, queueStateDirArg, gitDirArg, seatFolderArg, daemonArg, networkArg, loginShellArg } = prepared;
|
||||
const policyNotice = appliedLaunch.reason?.startsWith("policy not verified:") ? { warnings: [appliedLaunch.reason] } : {};
|
||||
|
||||
// Fork branch: `codex fork <parent_thread_id>`. Captures the NEW thread id
|
||||
// post-fork. Parent thread id is NOT persisted onto the new seat record
|
||||
@@ -954,7 +1001,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// For a handover a missed capture is pending metadata, not a failed launch: the fork returns
|
||||
// without a token and the handover's readiness checks still decide success. Any other fork
|
||||
// keeps its unsent context pending for `rig seat continue`, which verifies the child first.
|
||||
if (opts.forkSource.pendingIdAllowed) return { ok: true, appliedLaunch };
|
||||
if (opts.forkSource.pendingIdAllowed) return { ok: true, appliedLaunch, ...policyNotice };
|
||||
return {
|
||||
ok: false,
|
||||
error: "codex fork: could not capture new post-fork thread id",
|
||||
@@ -962,7 +1009,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
forkIdentityPending: true,
|
||||
};
|
||||
}
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch };
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch, ...policyNotice };
|
||||
}
|
||||
|
||||
// OPR.0.4.8.2: one posture decision (codexPostureArg) for the fresh launch too — YOLO forces
|
||||
@@ -989,17 +1036,17 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (opts.resumeToken) {
|
||||
const verification = await this.verifyResumeLaunch(binding.tmuxSession, updatePrompt, { resumeToken: opts.resumeToken, ...this.ownHomeOpt(binding) });
|
||||
if (!verification.ok) return verification;
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id", appliedLaunch,
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id", appliedLaunch, ...policyNotice,
|
||||
...(verification.resumedProcess ? { resumedProcess: verification.resumedProcess } : {}) };
|
||||
}
|
||||
|
||||
const threadId = await this.captureFreshThreadId(binding, updatePrompt);
|
||||
if (updatePrompt.failure) return updatePrompt.failure;
|
||||
if (threadId) {
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch };
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch, ...policyNotice };
|
||||
}
|
||||
|
||||
return { ok: true, appliedLaunch };
|
||||
return { ok: true, appliedLaunch, ...policyNotice };
|
||||
}
|
||||
|
||||
/** The command launchHarness would type to resume `resumeToken`, wrapped in the seat launch environment,
|
||||
@@ -1007,7 +1054,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
async buildResumeCommand(
|
||||
binding: NodeBinding,
|
||||
opts: { name: string; resumeToken: string; running?: RunningCodexLaunch; launchShell?: string },
|
||||
): Promise<{ ok: true; command: string; core: string; appliedLaunch: AppliedLaunchObservation } | { ok: false; error: string }> {
|
||||
): Promise<{ ok: true; command: string; core: string; appliedLaunch: AppliedLaunchObservation; warnings?: string[] } | { ok: false; error: string }> {
|
||||
if (binding.launchWrapper?.length && !this.seatLaunchEnvironment) {
|
||||
return { ok: false, error: "launch_wrapper support unavailable; no agent started." };
|
||||
}
|
||||
@@ -1022,7 +1069,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
} catch {
|
||||
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
|
||||
}
|
||||
return { ok: true, command, core, appliedLaunch: prepared.appliedLaunch };
|
||||
return { ok: true, command, core, appliedLaunch: prepared.appliedLaunch,
|
||||
...(prepared.appliedLaunch.reason?.startsWith("policy not verified:") ? { warnings: [prepared.appliedLaunch.reason] } : {}) };
|
||||
}
|
||||
|
||||
/** Type a command buildResumeCommand built into the pane's shell, then verify the resume as launchHarness
|
||||
@@ -1073,6 +1121,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
* prepared parts come from the running Codex instead, and no Codex process is started to decide them. */
|
||||
private async prepareLaunch(binding: NodeBinding, name: string, running: RunningCodexLaunch | undefined,
|
||||
opts: { installCorePlugin: boolean }): Promise<PreparedCodexLaunch | { ok: false; error: string }> {
|
||||
const checkedBeforeCutover = this.filesystemPreflight.get(binding);
|
||||
const decision = checkedBeforeCutover ?? await this.checkFilesystemPolicy(binding);
|
||||
this.filesystemPreflight.delete(binding);
|
||||
if (decision && !decision.ok) return decision;
|
||||
// The config write and, for a seat that selected openrig-core, its install run as one step under the config lock, so
|
||||
// concurrent launches never interleave them. An unselected seat's launch never installs.
|
||||
// A seat with its own CODEX_HOME gets OpenRig's managed config (hooks, feature flag) and plugin in that home.
|
||||
@@ -1091,15 +1143,17 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const effortArg = effort ? ` -c ${shellQuote(`model_reasoning_effort="${effort}"`)}` : "";
|
||||
const profile = binding.codexConfigProfile?.trim();
|
||||
const profileArg = profile ? ` -p ${shellQuote(profile)}` : "";
|
||||
// A handover rebuild's observed predecessor envelope, else the seat's own envelope, replaces the posture decision.
|
||||
const memberEnvelope = binding.codexRebuildPermissions || !binding.codexPermissions ? undefined : codexMemberEnvelope(binding.codexPermissions);
|
||||
const envelope = binding.codexRebuildPermissions ?? memberEnvelope;
|
||||
const posture = envelope
|
||||
const filesystemPermissions = binding.codexPermissions && "filesystem" in binding.codexPermissions ? binding.codexPermissions : undefined;
|
||||
// An authored filesystem policy always wins over a stale observed legacy envelope. Legacy rebuilds keep their carry behavior.
|
||||
const memberChoice = binding.codexPermissions && (!binding.codexRebuildPermissions || filesystemPermissions) ? codexMemberLaunch(binding.codexPermissions) : undefined;
|
||||
const envelope = memberChoice ? undefined : binding.codexRebuildPermissions;
|
||||
const posture = memberChoice ? profileArg + memberChoice.args : envelope
|
||||
? profileArg + codexEnvelopeArgs(envelope)
|
||||
: codexPostureArg(profileArg, process.env, binding.launchPosture);
|
||||
const appliedLaunch: AppliedLaunchObservation = envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture);
|
||||
const appliedLaunch: AppliedLaunchObservation = memberChoice?.observation ?? (envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture));
|
||||
if (decision && !decision.verified) appliedLaunch.reason = codexPolicyWarning(decision.reason);
|
||||
// The seat's envelope is not full access, so a policy's full_bypass extras don't follow it.
|
||||
const postureArg = posture + operationalLaunchArg(this.runtime, memberEnvelope ? { ...binding, kernelAuthority: false, launchPosture: undefined } : binding);
|
||||
const postureArg = posture + operationalLaunchArg(this.runtime, memberChoice ? { ...binding, kernelAuthority: false, launchPosture: undefined } : binding);
|
||||
|
||||
if (running) {
|
||||
// The running Codex already loaded its profile; drift in -p is the caller's to compare.
|
||||
@@ -1107,10 +1161,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// conversation id, as on every resume, so the resume proof reads the id even when a dir has a space.
|
||||
return { ok: true, model, modelArg, effort, effortArg, profile, appliedLaunch, postureArg,
|
||||
queueStateDirArg: "",
|
||||
gitDirArg: running.addDirs.map((dir) => ` --add-dir ${shellQuote(dir)}`).join(""),
|
||||
gitDirArg: (filesystemPermissions ? [] : running.addDirs).map((dir) => ` --add-dir ${shellQuote(dir)}`).join(""),
|
||||
seatFolderArg: "",
|
||||
daemonOptOut: running.daemonOptOut, daemonArg: running.daemonOptOut ? " --no-daemon" : "",
|
||||
networkArg: running.networkOverride ? ` -c ${shellQuote(CODEX_NETWORK_DEFAULT_OVERRIDE)}` : "",
|
||||
networkArg: !filesystemPermissions && running.networkOverride ? ` -c ${shellQuote(CODEX_NETWORK_DEFAULT_OVERRIDE)}` : "",
|
||||
loginShellArg: running.nonLoginShell ? codexNonLoginShellArg : "" };
|
||||
}
|
||||
|
||||
@@ -1118,19 +1172,14 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// [profiles.<name>] table or invalid TOML must fail BEFORE the opaque
|
||||
// `codex -p <profile> resume` failure. An absent .config.toml passes
|
||||
// (Codex default-layers it; advisor Option B).
|
||||
if (profile) {
|
||||
const probeResult = await (own ? this.verifyProfilePreflight(profile, binding.cwd, own) : this.verifyProfilePreflight(profile, binding.cwd));
|
||||
if (!probeResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${probeResult.error}${probeResult.migrationHint ? `\n Fix: ${probeResult.migrationHint}` : ""}`,
|
||||
};
|
||||
}
|
||||
if (profile && !checkedBeforeCutover) {
|
||||
const loaded = await this.checkProfileLoad(binding);
|
||||
if (!loaded.ok) return loaded;
|
||||
}
|
||||
// Codex rejects writable --add-dir roots under a read-only sandbox. Only
|
||||
// suppress OpenRig's generated roots when later project layers cannot make it writable.
|
||||
// A seat envelope states its sandbox: read-only gets no generated roots; workspace-write keeps them.
|
||||
const suppressAutoAddDirs = memberEnvelope ? memberEnvelope.sandboxPolicy.type === "read-only"
|
||||
const suppressAutoAddDirs = memberChoice ? memberChoice.suppressAutoAddDirs
|
||||
: Boolean(!binding.codexRebuildPermissions && profile && posture === profileArg && this.profileRemainsReadOnly(profile, binding.cwd, home));
|
||||
// Issue #121: fresh, resume and fork get the same git metadata dirs, so a restored or forked seat can still commit.
|
||||
// A rebuild keeps the writable roots its observed envelope names instead.
|
||||
@@ -1139,7 +1188,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// The seat's own topology folder, so it can keep its LEARNED.md and recap: the same launches and the same suppression.
|
||||
const seatFolder = binding.codexRebuildPermissions || suppressAutoAddDirs ? undefined : this.prepareSeatFolder?.(name);
|
||||
const seatFolderArg = seatFolder ? ` --add-dir ${shellQuote(seatFolder)}` : "";
|
||||
const teamWorkspaceArg = !envelope && binding.teamPermissionDefault && !profile
|
||||
const teamWorkspaceArg = !memberChoice && !envelope && binding.teamPermissionDefault && !profile
|
||||
&& appliedLaunch.state === "observed" && appliedLaunch.value === "workspace-write"
|
||||
? codexTeamWorkspaceArg(this.prepareTeamWorkspace, name) : "";
|
||||
const queueStateDirArg = binding.codexRebuildPermissions || suppressAutoAddDirs
|
||||
@@ -1153,7 +1202,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const daemonOptOut = daemonSupport?.kind === "supported";
|
||||
const daemonArg = daemonOptOut ? " --no-daemon" : "";
|
||||
// #275: on the plain floor, network access unless Codex reports an opt-out or policy. An envelope states its own.
|
||||
const networkArg = envelope ? ""
|
||||
const networkArg = memberChoice || envelope ? ""
|
||||
: await codexNetworkDefaultArg(own && this.readNetworkDefault ? ((cwd: string) => this.readNetworkDefault!(cwd, own)) : this.readNetworkDefault, appliedLaunch, binding.cwd, name);
|
||||
|
||||
// The launch facts keep the probed daemon choice, so a printed recovery command can repeat it; no probe, no fact.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RunnerGrantIssuer, RunnerGrantLaunch } from "../domain/runner-grant-launch.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { EnvironmentStartup } from "../domain/environment-startup.js";
|
||||
import type {
|
||||
RuntimeAdapter,
|
||||
@@ -28,6 +30,7 @@ import { PiCommunityRuntimeAdapter } from "./pi-community-runtime-adapter.js";
|
||||
|
||||
export interface CommunityHarnessAdapterDeps {
|
||||
environmentStartup?: EnvironmentStartup;
|
||||
runnerGrants?: RunnerGrantIssuer;
|
||||
tmux: Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand"> & Partial<Pick<TmuxAdapter, "capturePaneContent" | "listPanes">>;
|
||||
seatLaunchEnvironment?: Pick<SeatLaunchEnvironment, "command" | "resolveLaunchWrapper">;
|
||||
presetsDir?: () => string;
|
||||
@@ -173,6 +176,8 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
onSent?: () => void, launcherPrefix = ""): Promise<HarnessLaunchResult> {
|
||||
if (preset.namesConversation) return this.launchNamed(binding, session, preset.command, resumeRef, beforeInput, onSent, launcherPrefix);
|
||||
let attempt: LaunchAttempt | undefined;
|
||||
let authority: RunnerGrantLaunch | undefined;
|
||||
let launchSucceeded = false;
|
||||
// The record is optional: a folder that can't be made leaves the identity unavailable, and the harness still launches.
|
||||
let storageNote: string | undefined;
|
||||
if (preset.launchResult) {
|
||||
@@ -188,41 +193,72 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
storageNote = `community harness "${this.runtime}": its launch identity is unavailable: its launch-result folder couldn't be made (${(error as Error).message})`;
|
||||
}
|
||||
}
|
||||
let command: string;
|
||||
const runId = attempt?.attempt ?? randomUUID();
|
||||
let authorityUnavailable = false;
|
||||
let dispatchedWithoutAuthority = false;
|
||||
if (preset.runner) {
|
||||
try { authority = await this.deps.runnerGrants?.(binding, this.runtime, runId, 1); }
|
||||
catch { authorityUnavailable = true; }
|
||||
}
|
||||
try {
|
||||
command = this.deps.seatLaunchEnvironment
|
||||
? await this.deps.seatLaunchEnvironment.command(session, preset.command, { runtime: this.runtime, nodeId: binding.nodeId, launcherPrefix,
|
||||
...(binding.launchWrapper?.length ? { failClosed: true, ...(!preset.runner ? { launchWrapper: binding.launchWrapper } : {}) } : {}) })
|
||||
: launcherPrefix + preset.command;
|
||||
} catch (error) {
|
||||
if (!binding.launchWrapper?.length) throw error;
|
||||
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
|
||||
let command: string;
|
||||
try {
|
||||
command = this.deps.seatLaunchEnvironment
|
||||
? await this.deps.seatLaunchEnvironment.command(session, preset.command, { runtime: this.runtime, nodeId: binding.nodeId, launcherPrefix, ...(authority ? { generation: authority.generation } : {}),
|
||||
...(binding.launchWrapper?.length ? { failClosed: true, ...(!preset.runner ? { launchWrapper: binding.launchWrapper } : {}) } : {}) })
|
||||
: launcherPrefix + preset.command;
|
||||
} catch (error) {
|
||||
if (!binding.launchWrapper?.length) throw error;
|
||||
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
|
||||
}
|
||||
const fallback = command;
|
||||
if (authority) command = authority.wrap(command);
|
||||
let sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
|
||||
if (!sent.ok && sent.code === "launch_path_too_long" && authority && command !== fallback) {
|
||||
// This result proves no input was dispatched. Retry only the same pre-grant command.
|
||||
try { await authority.finish(false); } catch { /* optional cleanup cannot block the original launch */ }
|
||||
authority = undefined; authorityUnavailable = true;
|
||||
sent = await this.deps.tmux.sendShellCommand(session, fallback, beforeInput, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (sent.ok) {
|
||||
try { authority?.dispatched(); }
|
||||
catch {
|
||||
try { await authority?.finish(false); } catch { /* preserve the already dispatched effect */ }
|
||||
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
|
||||
}
|
||||
}
|
||||
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
|
||||
onSent?.();
|
||||
// A runner reports its conversation in this record; without one, its seat needs a person rather than reading ready.
|
||||
if (!attempt && storageNote && preset.runner) return { ok: false, recovery: "attention_required", error: storageNote };
|
||||
if (!attempt) { launchSucceeded = true; return storageNote ? { ok: true, warnings: [storageNote] } : { ok: true }; }
|
||||
const read = await waitForLaunchResult(attempt, this.runtime, { ...this.deps.launchResultWait, root: this.deps.launchResultRoot?.() ?? launchResultRoot() });
|
||||
if (resumeRef) {
|
||||
// A conversation that's gone is never replaced by a new one: nothing started, and the person decides.
|
||||
if (!read.ok && "missing" in read) {
|
||||
return { ok: false, missingConversation: true, error: `Couldn't resume community harness "${this.runtime}": conversation ${resumeRef}: ${read.reason}.` };
|
||||
}
|
||||
// Unconfirmed or different: the seat keeps the conversation it asked for, and the person decides. No retry.
|
||||
if (!read.ok) {
|
||||
return { ok: false, recovery: "attention_required", resumeUnproven: true,
|
||||
error: `Community harness "${this.runtime}": resuming conversation ${resumeRef} isn't confirmed: ${read.reason}. The seat keeps conversation ${resumeRef}; nothing was restarted.` };
|
||||
}
|
||||
if (read.nativeRef !== resumeRef) {
|
||||
return { ok: false, recovery: "attention_required", resumeMismatch: { opened: read.nativeRef },
|
||||
error: `Community harness "${this.runtime}": asked to resume conversation ${resumeRef}, its launcher reports conversation ${read.nativeRef} instead. The seat keeps conversation ${resumeRef}, and OpenRig doesn't switch it to ${read.nativeRef}. Nothing was restarted.` };
|
||||
}
|
||||
}
|
||||
if (read.ok) { launchSucceeded = true; return { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher",
|
||||
...(authority ? { activityGrantId: authority.grantId } : {}),
|
||||
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
|
||||
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
|
||||
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) }; }
|
||||
const unavailable = `community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`;
|
||||
return preset.runner ? { ok: false, recovery: "attention_required", error: unavailable } : { ok: true, warnings: [unavailable] };
|
||||
|
||||
} finally {
|
||||
try { await authority?.finish(launchSucceeded); } catch { /* optional reporting cannot change a native launch outcome */ }
|
||||
}
|
||||
const sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
|
||||
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
|
||||
onSent?.();
|
||||
// A runner reports its conversation in this record; without one, its seat needs a person rather than reading ready.
|
||||
if (!attempt && storageNote && preset.runner) return { ok: false, recovery: "attention_required", error: storageNote };
|
||||
if (!attempt) return storageNote ? { ok: true, warnings: [storageNote] } : { ok: true };
|
||||
const read = await waitForLaunchResult(attempt, this.runtime, { ...this.deps.launchResultWait, root: this.deps.launchResultRoot?.() ?? launchResultRoot() });
|
||||
if (resumeRef) {
|
||||
// A conversation that's gone is never replaced by a new one: nothing started, and the person decides.
|
||||
if (!read.ok && "missing" in read) {
|
||||
return { ok: false, missingConversation: true, error: `Couldn't resume community harness "${this.runtime}": conversation ${resumeRef}: ${read.reason}.` };
|
||||
}
|
||||
// Unconfirmed or different: the seat keeps the conversation it asked for, and the person decides. No retry.
|
||||
if (!read.ok) {
|
||||
return { ok: false, recovery: "attention_required", resumeUnproven: true,
|
||||
error: `Community harness "${this.runtime}": resuming conversation ${resumeRef} isn't confirmed: ${read.reason}. The seat keeps conversation ${resumeRef}; nothing was restarted.` };
|
||||
}
|
||||
if (read.nativeRef !== resumeRef) {
|
||||
return { ok: false, recovery: "attention_required", resumeMismatch: { opened: read.nativeRef },
|
||||
error: `Community harness "${this.runtime}": asked to resume conversation ${resumeRef}, its launcher reports conversation ${read.nativeRef} instead. The seat keeps conversation ${resumeRef}, and OpenRig doesn't switch it to ${read.nativeRef}. Nothing was restarted.` };
|
||||
}
|
||||
}
|
||||
if (read.ok) return { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher" };
|
||||
const unavailable = `community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`;
|
||||
return preset.runner ? { ok: false, recovery: "attention_required", error: unavailable } : { ok: true, warnings: [unavailable] };
|
||||
}
|
||||
|
||||
/** OpenRig names the conversation: a new one gets a fresh name in OPENRIG_CONVERSATION, and a resume passes the saved name
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RunnerGrantLaunch } from "../domain/runner-grant-launch.js";
|
||||
// OPR.0.4.6.PI1 FR-6 — Pi seat resume (mirrors codex-resume.ts).
|
||||
//
|
||||
// Resume is HONEST session-file continuation: relaunch the pi-runner with
|
||||
@@ -29,6 +30,8 @@ export interface PiResumeFsOps {
|
||||
}
|
||||
|
||||
interface PiResumeOptions {
|
||||
/** Core resolves the registered restore binding; the runner never supplies it. */
|
||||
runnerGrant?: (session: string, runId: string, cwd: string) => Promise<RunnerGrantLaunch>;
|
||||
/** Resolve the persisted/configured account without relying on live tmux metadata. */
|
||||
accountHomeForSession?: (session: string) => string | undefined;
|
||||
stateRootForSession?: (session: string) => string;
|
||||
@@ -105,64 +108,92 @@ export class PiResumeAdapter {
|
||||
// only states stamped with THIS attempt's launchId. The prior record is
|
||||
// read FIRST so the durable catch-up cursor (lastEntryId, FR-5) survives.
|
||||
const launchId = (this.options.newLaunchId ?? (() => randomUUID()))();
|
||||
const prior = this.fs.exists(seat.runnerStatePath) ? parsePiRunnerState(this.readSafe(seat.runnerStatePath)) : null;
|
||||
this.fs.writeFile(
|
||||
seat.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
let authority: RunnerGrantLaunch | undefined;
|
||||
let authorityUnavailable = false;
|
||||
let dispatchedWithoutAuthority = false;
|
||||
if (this.runtime === "pi") {
|
||||
try { authority = await this.options.runnerGrant?.(tmuxSessionName, launchId, cwd); }
|
||||
catch { authorityUnavailable = true; }
|
||||
}
|
||||
let launchSucceeded = false;
|
||||
try {
|
||||
const prior = this.fs.exists(seat.runnerStatePath) ? parsePiRunnerState(this.readSafe(seat.runnerStatePath)) : null;
|
||||
this.fs.writeFile(
|
||||
seat.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
|
||||
const trust = this.runtime === "omp"
|
||||
? (yoloEnabled(process.env, resolvedPosture) ? "approve" : "no-approve")
|
||||
: piTrust(this.options.trustPosture, process.env, resolvedPosture);
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
let nodeId: string | undefined;
|
||||
if (this.runtime === "pi" && this.options.seatLaunchEnvironment) {
|
||||
try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; }
|
||||
catch { /* Account-free resume retains best-effort identity correction. */ }
|
||||
}
|
||||
const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName)
|
||||
?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined;
|
||||
if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) {
|
||||
return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." };
|
||||
}
|
||||
if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing.");
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome,
|
||||
runtime: this.runtime,
|
||||
runnerEntryPath: this.paths.runnerEntryPath,
|
||||
sessionName: tmuxSessionName,
|
||||
stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot,
|
||||
cwd,
|
||||
// The model declaration must survive resume: the runner's provider-key
|
||||
// allowlist keys off the declared provider (VM leg finding).
|
||||
model: model ?? undefined,
|
||||
thinking: this.runtime === "pi" ? effort ?? undefined : undefined,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). Same decision as the launch path:
|
||||
// YOLO forces `approve` on every restored seat; otherwise the configured posture.
|
||||
trust,
|
||||
sessionFile,
|
||||
launchId,
|
||||
usageFacts: this.runtime === "pi" && (this.options.usageFactsEnabled?.() ?? false),
|
||||
openRouterCharge: this.runtime === "pi" && (this.options.openRouterChargeEnabled?.() ?? false),
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
const trust = this.runtime === "omp"
|
||||
? (yoloEnabled(process.env, resolvedPosture) ? "approve" : "no-approve")
|
||||
: piTrust(this.options.trustPosture, process.env, resolvedPosture);
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
let nodeId: string | undefined;
|
||||
if (this.runtime === "pi" && this.options.seatLaunchEnvironment) {
|
||||
try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; }
|
||||
catch { /* Account-free resume retains best-effort identity correction. */ }
|
||||
}
|
||||
const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName)
|
||||
?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined;
|
||||
if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) {
|
||||
return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." };
|
||||
}
|
||||
if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing.");
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome,
|
||||
runtime: this.runtime,
|
||||
runnerEntryPath: this.paths.runnerEntryPath,
|
||||
sessionName: tmuxSessionName,
|
||||
stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot,
|
||||
cwd,
|
||||
// The model declaration must survive resume: the runner's provider-key
|
||||
// allowlist keys off the declared provider (VM leg finding).
|
||||
model: model ?? undefined,
|
||||
thinking: this.runtime === "pi" ? effort ?? undefined : undefined,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). Same decision as the launch path:
|
||||
// YOLO forces `approve` on every restored seat; otherwise the configured posture.
|
||||
trust,
|
||||
sessionFile,
|
||||
launchId,
|
||||
usageFacts: this.runtime === "pi" && (this.options.usageFactsEnabled?.() ?? false),
|
||||
openRouterCharge: this.runtime === "pi" && (this.options.openRouterChargeEnabled?.() ?? false),
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
|
||||
const fallback = cmd;
|
||||
const command = this.runtime === "pi" && this.options.seatLaunchEnvironment
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome })
|
||||
: fallback;
|
||||
// Short commands retain the direct path; long commands exec from a private script.
|
||||
let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
const fallback = cmd;
|
||||
let command = this.runtime === "pi" && this.options.seatLaunchEnvironment
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome, ...(authority ? { generation: authority.generation } : {}) })
|
||||
: fallback;
|
||||
const originalCommand = command;
|
||||
if (authority) command = authority.wrap(command);
|
||||
// Short commands retain the direct path; long commands exec from a private script.
|
||||
let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && authority && command !== originalCommand) {
|
||||
try { await authority.finish(false); } catch { /* optional authority cleanup cannot prevent the original resume */ }
|
||||
authority = undefined; authorityUnavailable = true;
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, originalCommand, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && originalCommand !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, code: "resume_failed", message: textResult.message };
|
||||
}
|
||||
try { authority?.dispatched(); }
|
||||
catch {
|
||||
try { await authority?.finish(false); } catch { /* native dispatch already happened; never replay */ }
|
||||
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
|
||||
}
|
||||
const result = await this.verifyResume(tmuxSessionName, sessionFile, launchId);
|
||||
launchSucceeded = result.ok;
|
||||
return result.ok ? { ...result, appliedLaunch,
|
||||
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
|
||||
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
|
||||
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) } : result;
|
||||
} finally {
|
||||
try { await authority?.finish(launchSucceeded); } catch { /* optional reporting cannot turn a completed resume into failure */ }
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, code: "resume_failed", message: textResult.message };
|
||||
}
|
||||
const result = await this.verifyResume(tmuxSessionName, sessionFile, launchId);
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
// Poll the runner's launch-scoped sidecar ONLY — never stale pane
|
||||
|
||||
@@ -827,7 +827,7 @@ export class RunnerCore {
|
||||
|
||||
constructor(
|
||||
private io: RunnerIo,
|
||||
private identity: { sessionName: string; nodeId?: string; launchId?: string; generation?: string },
|
||||
private identity: { sessionName: string; nodeId?: string; launchId?: string; generation?: string; runId?: string },
|
||||
private opts: { catchUpSince?: string; runtime?: RunnerRuntime | string; usageFacts?: boolean; openRouterCharge?: boolean;
|
||||
/** K4: the credential observation taken before Pi was spawned. */
|
||||
launchCredentialEvidence?: PiCredentialEvidence;
|
||||
@@ -1314,11 +1314,11 @@ export class RunnerCore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-deliver OMP identity until the daemon confirms the resume token.
|
||||
/** Re-deliver OMP or managed run identity until the daemon confirms the resume token.
|
||||
* Called from a timer so a daemon restart during the first turn cannot
|
||||
* leave a seat with history but no restorable token. */
|
||||
retrySessionIdentity(): void {
|
||||
if (this.runtime === "omp" && this.ready) this.postSessionIdentity();
|
||||
if ((this.runtime === "omp" || this.identity.runId) && this.ready) this.postSessionIdentity();
|
||||
}
|
||||
|
||||
/** A resume that came up on another session file started a different session: the requested one stays the seat's. */
|
||||
@@ -1330,9 +1330,10 @@ export class RunnerCore {
|
||||
// Never the identity of a session other than the one this launch resumes: it would replace the seat's resume token.
|
||||
if (this.resumedElsewhere()) return;
|
||||
if (this.runtime === "omp" && (!this.sessionFile || !this.io.sessionFileExists?.(this.sessionFile))) return;
|
||||
if (this.runtime === "omp" && (this.identityPersisted || this.identityInFlight)) return;
|
||||
if ((this.runtime === "omp" || this.identity.runId) && (this.identityPersisted || this.identityInFlight)) return;
|
||||
const result = this.io.postActivity({
|
||||
eventFamily: "session_identity",
|
||||
...(this.identity.runId ? { runId: this.identity.runId } : {}),
|
||||
sessionName: this.identity.sessionName,
|
||||
nodeId: this.identity.nodeId ?? null,
|
||||
runtime: this.runtime,
|
||||
@@ -1342,7 +1343,7 @@ export class RunnerCore {
|
||||
sessionFile: this.sessionFile ?? null,
|
||||
occurredAt: this.io.now(),
|
||||
});
|
||||
if (this.runtime !== "omp" || !result) return;
|
||||
if ((this.runtime !== "omp" && !this.identity.runId) || !result) return;
|
||||
this.identityInFlight = true;
|
||||
void result.then(
|
||||
(body) => { if (body?.tokenPersisted === true) this.identityPersisted = true; },
|
||||
@@ -1360,6 +1361,7 @@ export class RunnerCore {
|
||||
if (hookEvent === "Stop") this.lastHeartbeatMs = Date.parse(this.io.now());
|
||||
return {
|
||||
sequence: ++this.activitySequence,
|
||||
...(this.identity.runId ? { runId: this.identity.runId } : {}),
|
||||
sessionName: this.identity.sessionName,
|
||||
nodeId: this.identity.nodeId ?? null,
|
||||
generation: this.identity.generation ?? null,
|
||||
@@ -1689,6 +1691,9 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
);
|
||||
|
||||
const endpoint = resolveActivityEndpoint(process.env);
|
||||
// A legacy install-token launch does not acquire run authority from inherited environment.
|
||||
// Preserve a bound credential's supplied claim so Core can refuse a stale or mismatched report.
|
||||
const runId = endpoint?.token.startsWith("oag_") ? process.env.OPENRIG_RUN_ID : undefined;
|
||||
const childEnv = buildPiChildEnv(process.env as Record<string, string | undefined>, {
|
||||
agentDir: paths.agentDir,
|
||||
sessionsDir: paths.sessionsDir,
|
||||
@@ -1822,7 +1827,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => response.ok && runtime === "omp" ? await response.json() as Record<string, unknown> : null)
|
||||
.then(async (response) => response.ok && (runtime === "omp" || runId) ? await response.json() as Record<string, unknown> : null)
|
||||
.catch(() => null)
|
||||
.finally(() => clearTimeout(timeout));
|
||||
});
|
||||
@@ -1838,9 +1843,9 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
postActivity: (payload) => {
|
||||
if (!endpoint || typeof fetch !== "function") return;
|
||||
const result = postInOrder(payload).catch(() => null);
|
||||
// OMP reads the acknowledgement so identity delivery can retry; null
|
||||
// Managed runs and OMP read the acknowledgement so identity delivery can retry; null
|
||||
// (transport failure or a non-OK reply) means "not yet persisted".
|
||||
if (runtime === "omp") return result;
|
||||
if (runtime === "omp" || runId) return result;
|
||||
},
|
||||
writeSidecar: (state) => {
|
||||
try {
|
||||
@@ -1889,6 +1894,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
sessionName: args.sessionName, nodeId: process.env.OPENRIG_NODE_ID, launchId: args.launchId,
|
||||
// Carry the emitting tenure; never infer it from a later daemon read or Pi event.
|
||||
generation: process.env.OPENRIG_OCCUPANT_GENERATION,
|
||||
runId: runId,
|
||||
}, { catchUpSince, runtime, usageFacts: args.usageFacts === true, openRouterCharge: args.openRouterCharge === true, launchCredentialEvidence,
|
||||
requestedSessionFile: args.sessionFile });
|
||||
|
||||
@@ -1905,8 +1911,8 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
}
|
||||
core.handlePiLine(line);
|
||||
});
|
||||
// Re-deliver OMP identity until the daemon confirms the resume token.
|
||||
const identityRetry = runtime === "omp" ? setInterval(() => core.retrySessionIdentity(), IDENTITY_RETRY_MS) : undefined;
|
||||
// Re-deliver managed run identity after pending activation/cutover, and legacy OMP identity as before.
|
||||
const identityRetry = runtime === "omp" || runId ? setInterval(() => core.retrySessionIdentity(), IDENTITY_RETRY_MS) : undefined;
|
||||
identityRetry?.unref();
|
||||
const heartbeatTimer = startHeartbeatTimer(core, runtime);
|
||||
// The last few stderr lines, so an OMP that exits during startup is
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RunnerGrantLaunch, RunnerGrantIssuer } from "../domain/runner-grant-launch.js";
|
||||
import { piAccountSwitchStateRoot } from "../domain/pi-account-state.js";
|
||||
// OPR.0.4.6.PI1 — the Pi runtime adapter (RPC-first, runner-in-a-pane).
|
||||
//
|
||||
@@ -49,6 +50,7 @@ export interface PiAdapterFsOps {
|
||||
|
||||
export interface PiRuntimeAdapterDeps {
|
||||
tmux: TmuxAdapter;
|
||||
runnerGrants?: RunnerGrantIssuer;
|
||||
seatLaunchEnvironment?: SeatLaunchEnvironment;
|
||||
fsOps: PiAdapterFsOps;
|
||||
/** Root under which every Pi seat gets its isolated state dir (FR-7).
|
||||
@@ -77,6 +79,7 @@ export interface PiRuntimeAdapterDeps {
|
||||
export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
readonly runtime: string = "pi";
|
||||
protected tmux: TmuxAdapter;
|
||||
protected runnerGrants?: RunnerGrantIssuer;
|
||||
protected seatLaunchEnvironment?: SeatLaunchEnvironment;
|
||||
protected fs: PiAdapterFsOps;
|
||||
protected stateRoot: string;
|
||||
@@ -94,6 +97,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
this.usageFactsEnabled = deps.usageFactsEnabled ?? (() => false);
|
||||
this.openRouterChargeEnabled = deps.openRouterChargeEnabled ?? (() => false);
|
||||
this.tmux = deps.tmux;
|
||||
this.runnerGrants = deps.runnerGrants;
|
||||
this.seatLaunchEnvironment = deps.seatLaunchEnvironment;
|
||||
this.fs = deps.fsOps;
|
||||
this.stateRoot = deps.stateRoot;
|
||||
@@ -107,6 +111,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
getCommunityDeps(): PiRuntimeAdapterDeps {
|
||||
return {
|
||||
tmux: this.tmux,
|
||||
runnerGrants: this.runnerGrants,
|
||||
seatLaunchEnvironment: this.seatLaunchEnvironment,
|
||||
fsOps: this.fs,
|
||||
stateRoot: this.stateRoot,
|
||||
@@ -280,7 +285,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
async launchHarness(
|
||||
binding: NodeBinding,
|
||||
opts: { name: string; resumeToken?: string; forkSource?: ForkSource },
|
||||
opts: { name: string; resumeToken?: string; forkSource?: ForkSource; beforeNativeDispatch?: () => boolean },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
const wrapped = binding.launchWrapper?.length ? binding.launchWrapper : undefined;
|
||||
if (wrapped && (this.runtime !== "pi" || !this.seatLaunchEnvironment)) {
|
||||
@@ -341,90 +346,123 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
// The prior record is read FIRST so the durable catch-up cursor
|
||||
// (lastEntryId, FR-5) survives the reset.
|
||||
const launchId = this.newLaunchId();
|
||||
const prior = this.readRunnerState(location.sessionName, location.stateRoot);
|
||||
this.fs.writeFile(
|
||||
paths.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
let authority: RunnerGrantLaunch | undefined;
|
||||
let authorityUnavailable = false;
|
||||
let dispatchedWithoutAuthority = false;
|
||||
if (this.runtime !== "omp") {
|
||||
try { authority = await this.runnerGrants?.(binding, this.runtime, launchId, this.runtime === "pi" ? 0 : 2); }
|
||||
catch { authorityUnavailable = true; }
|
||||
}
|
||||
let authorizedLaunchSucceeded = false;
|
||||
try {
|
||||
const prior = this.readRunnerState(location.sessionName, location.stateRoot);
|
||||
this.fs.writeFile(
|
||||
paths.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
|
||||
const trust = this.runtime === "omp"
|
||||
? (yoloEnabled(process.env, binding.launchPosture) ? "approve" : "no-approve")
|
||||
: piTrust(this.trustPosture, process.env, binding.launchPosture);
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
// An account home is Pi's own, as on main: OMP and a pi-rpc fork take none, and the runner refuses --account-home
|
||||
// for any runtime but pi.
|
||||
const accountHome = this.runtime !== "pi" ? undefined
|
||||
: binding.accountHome !== undefined ? binding.accountHome
|
||||
: binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined;
|
||||
const preparedHome = accountHome === null && binding.nodeId
|
||||
? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome;
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome: preparedHome ?? undefined,
|
||||
runtime: this.runtime,
|
||||
command: this.commandName,
|
||||
runnerEntryPath: this.runnerEntryPath,
|
||||
sessionName: location.sessionName,
|
||||
stateRoot: location.stateRoot,
|
||||
cwd: binding.cwd,
|
||||
model: binding.model,
|
||||
// The seat's resolved effort, sent as Pi's --thinking; OMP unchanged.
|
||||
thinking: this.runtime === "omp" ? undefined : binding.effort,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). YOLO forces `approve` on every
|
||||
// seat; otherwise the configured posture. Same decision used on the restore path (pi-resume).
|
||||
trust,
|
||||
sessionFile: opts.resumeToken,
|
||||
forkRef,
|
||||
launchId,
|
||||
usageFacts: this.runtime === "pi" && this.usageFactsEnabled(),
|
||||
openRouterCharge: this.runtime === "pi" && this.openRouterChargeEnabled(),
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
const trust = this.runtime === "omp"
|
||||
? (yoloEnabled(process.env, binding.launchPosture) ? "approve" : "no-approve")
|
||||
: piTrust(this.trustPosture, process.env, binding.launchPosture);
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
// An account home is Pi's own, as on main: OMP and a pi-rpc fork take none, and the runner refuses --account-home
|
||||
// for any runtime but pi.
|
||||
const accountHome = this.runtime !== "pi" ? undefined
|
||||
: binding.accountHome !== undefined ? binding.accountHome
|
||||
: binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined;
|
||||
const preparedHome = accountHome === null && binding.nodeId
|
||||
? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome;
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome: preparedHome ?? undefined,
|
||||
runtime: this.runtime,
|
||||
command: this.commandName,
|
||||
runnerEntryPath: this.runnerEntryPath,
|
||||
sessionName: location.sessionName,
|
||||
stateRoot: location.stateRoot,
|
||||
cwd: binding.cwd,
|
||||
model: binding.model,
|
||||
// The seat's resolved effort, sent as Pi's --thinking; OMP unchanged.
|
||||
thinking: this.runtime === "omp" ? undefined : binding.effort,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). YOLO forces `approve` on every
|
||||
// seat; otherwise the configured posture. Same decision used on the restore path (pi-resume).
|
||||
trust,
|
||||
sessionFile: opts.resumeToken,
|
||||
forkRef,
|
||||
launchId,
|
||||
usageFacts: this.runtime === "pi" && this.usageFactsEnabled(),
|
||||
openRouterCharge: this.runtime === "pi" && this.openRouterChargeEnabled(),
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
|
||||
const fallback = cmd;
|
||||
// Every Pi-protocol runtime (Pi and its pi-rpc forks) takes the seat launch environment; OMP unchanged.
|
||||
const command = this.runtime !== "omp" && this.seatLaunchEnvironment
|
||||
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, accountHome })
|
||||
: fallback;
|
||||
// Stage long commands without leaving a shell above the live runner.
|
||||
let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
}
|
||||
const fallback = cmd;
|
||||
// Every Pi-protocol runtime (Pi and its pi-rpc forks) takes the seat launch environment; OMP unchanged.
|
||||
let command = this.runtime !== "omp" && this.seatLaunchEnvironment
|
||||
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: authority?.generation ?? binding.launchGeneration, runtime: this.runtime, accountHome })
|
||||
: fallback;
|
||||
const originalCommand = command;
|
||||
if (authority) command = authority.wrap(command);
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
|
||||
// Stage long commands without leaving a shell above the live runner.
|
||||
let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && authority && command !== originalCommand) {
|
||||
try { await authority.finish(false); } catch { /* optional authority cleanup cannot prevent the original launch */ }
|
||||
authority = undefined; authorityUnavailable = true;
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, originalCommand, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && originalCommand !== fallback && accountHome === undefined) {
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
}
|
||||
|
||||
// The runner writes the sidecar after its first successful get_state;
|
||||
// that sidecar (not pane content) is the token source of truth (FR-5).
|
||||
const state = await this.waitForRunnerReady(location.sessionName, launchId, location.stateRoot);
|
||||
if (!state.ok) return state.failure;
|
||||
try { authority?.dispatched(); }
|
||||
catch {
|
||||
try { await authority?.finish(false); } catch { /* dispatch already happened; never retry native input */ }
|
||||
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
|
||||
}
|
||||
|
||||
const sessionFile = state.value.sessionFile;
|
||||
if (!sessionFile) {
|
||||
return { ok: false, error: `${this.runtime} launch: the runner became ready but reported no session file` };
|
||||
}
|
||||
if (opts.resumeToken && sessionFile !== opts.resumeToken) {
|
||||
// An exact resume must continue the requested history; a runner
|
||||
// that came up on any other file started a different session. Same
|
||||
// contract as the separate resume adapter's verifyResume.
|
||||
return { ok: false, error: `${this.runtime} resume: the runner is ready but does not report the requested session file` };
|
||||
}
|
||||
if (forkRef && sessionFile === forkRef) {
|
||||
// The adapter contract requires the NEW post-fork token, never the
|
||||
// parent's (runtime-adapter.ts fork rule).
|
||||
return { ok: false, error: `${this.runtime} fork: the runner reported the parent session file instead of the post-fork child` };
|
||||
}
|
||||
const validation = this.validateToken(sessionFile);
|
||||
if (!validation.ok) {
|
||||
return { ok: false, error: `${this.runtime} launch: the runner reported a malformed session file (${validation.error})` };
|
||||
}
|
||||
// The runner writes the sidecar after its first successful get_state;
|
||||
// that sidecar (not pane content) is the token source of truth (FR-5).
|
||||
const state = await this.waitForRunnerReady(location.sessionName, launchId, location.stateRoot);
|
||||
if (!state.ok) return state.failure;
|
||||
|
||||
// OMP lazily creates its JSONL on the first persisted turn. Readiness
|
||||
// is real, but until then there is no history to resume after a reboot.
|
||||
if (this.runtime === "omp" && !this.fs.exists(validation.token)) return { ok: true, appliedLaunch };
|
||||
return { ok: true, resumeToken: validation.token, resumeType: this.resumeType, appliedLaunch };
|
||||
const sessionFile = state.value.sessionFile;
|
||||
if (!sessionFile) {
|
||||
return { ok: false, error: `${this.runtime} launch: the runner became ready but reported no session file` };
|
||||
}
|
||||
if (opts.resumeToken && sessionFile !== opts.resumeToken) {
|
||||
// An exact resume must continue the requested history; a runner
|
||||
// that came up on any other file started a different session. Same
|
||||
// contract as the separate resume adapter's verifyResume.
|
||||
return { ok: false, error: `${this.runtime} resume: the runner is ready but does not report the requested session file` };
|
||||
}
|
||||
if (forkRef && sessionFile === forkRef) {
|
||||
// The adapter contract requires the NEW post-fork token, never the
|
||||
// parent's (runtime-adapter.ts fork rule).
|
||||
return { ok: false, error: `${this.runtime} fork: the runner reported the parent session file instead of the post-fork child` };
|
||||
}
|
||||
const validation = this.validateToken(sessionFile);
|
||||
if (!validation.ok) {
|
||||
return { ok: false, error: `${this.runtime} launch: the runner reported a malformed session file (${validation.error})` };
|
||||
}
|
||||
|
||||
// OMP lazily creates its JSONL on the first persisted turn. Readiness
|
||||
// is real, but until then there is no history to resume after a reboot.
|
||||
if (this.runtime === "omp" && !this.fs.exists(validation.token)) return { ok: true, appliedLaunch };
|
||||
authorizedLaunchSucceeded = true;
|
||||
return { ok: true, resumeToken: validation.token, resumeType: this.resumeType, appliedLaunch,
|
||||
...(authority ? { activityGrantId: authority.grantId } : {}),
|
||||
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
|
||||
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
|
||||
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) };
|
||||
} finally {
|
||||
try { await authority?.finish(authorizedLaunchSucceeded); } catch { /* optional reporting cannot change the native launch outcome */ }
|
||||
}
|
||||
}
|
||||
|
||||
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
|
||||
|
||||
@@ -1103,9 +1103,8 @@ export class TmuxAdapter {
|
||||
// A paste the guard proves it left unsubmitted (#2429) is submitted first, under this lease, once this send's own
|
||||
// checks pass; they then aren't run a second time.
|
||||
beforePaste: async () => {
|
||||
// This send's own checks can wait (a conversation lookup, an input capture), so they run first: the guard's
|
||||
// read of the input is then the last wait before the paste (#3076). A check that refuses leaves a leftover's
|
||||
// record for the next delivery.
|
||||
// This send's own checks can wait (a conversation lookup, an input capture), so they run first, and the
|
||||
// guard's checks run last (#3076). A check that refuses leaves a leftover's record for the next delivery.
|
||||
await options?.beforePaste?.();
|
||||
const checks = async () => { beforeWrite(); beforeInput?.(); };
|
||||
// The synchronous checks run again just before the key: the guard's re-read awaits after `checks`.
|
||||
|
||||
@@ -2,6 +2,12 @@ import { writeFileSync, renameSync } from "node:fs";
|
||||
|
||||
export const DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
export const DAEMON_STOP_WAIT_MS = DAEMON_SHUTDOWN_TIMEOUT_MS + 2_000;
|
||||
// Shared lifecycle budgets: an async command caller must outlive the owned-child
|
||||
// readiness wait, one probe, shutdown cleanup, and a small process/I/O margin.
|
||||
export const DAEMON_START_READINESS_WAIT_MS = 180_000;
|
||||
export const DAEMON_START_PROBE_TIMEOUT_MS = 2_000;
|
||||
export const DAEMON_START_COMMAND_TIMEOUT_MS = DAEMON_START_READINESS_WAIT_MS
|
||||
+ DAEMON_START_PROBE_TIMEOUT_MS + DAEMON_STOP_WAIT_MS + 5_000;
|
||||
export const DAEMON_HTTP_CONNECTION_GRACE_MS = 250;
|
||||
export const DAEMON_SHUTDOWN_RECEIPT = "daemon-shutdown.json";
|
||||
export interface DaemonShutdownReceipt {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runBoundActivityGrantsSchema } from "./migrations/165_run_bound_activity_grants.js";
|
||||
import { piAccountSwitchesSchema } from "./migrations/151_pi_account_switches.js";
|
||||
import { workflowInstanceSpecSchema } from "./migrations/152_workflow_instance_spec.js";
|
||||
import { eventsRetentionMarksSchema } from "./migrations/153_events_retention_marks.js";
|
||||
@@ -332,5 +333,6 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
sessionAddressReadIndexesSchema,
|
||||
networkInstallationsSchema,
|
||||
queueItemsReplyToIndexSchema,
|
||||
runBoundActivityGrantsSchema,
|
||||
queueListReadIndexesSchema,
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Durable actual-run binding; legacy grants remain explicitly unbound. */
|
||||
export const runBoundActivityGrantsSchema: Migration = {
|
||||
name: "165_run_bound_activity_grants.sql",
|
||||
sql: `
|
||||
ALTER TABLE activity_grants ADD COLUMN run_id TEXT;
|
||||
ALTER TABLE activity_grants ADD COLUMN session_id TEXT;
|
||||
ALTER TABLE activity_grants ADD COLUMN activated_at TEXT;
|
||||
ALTER TABLE activity_grants ADD COLUMN binding_json TEXT;
|
||||
CREATE INDEX idx_activity_grants_run ON activity_grants(node_id, run_id);
|
||||
CREATE UNIQUE INDEX idx_activity_grants_active_run ON activity_grants(node_id)
|
||||
WHERE run_id IS NOT NULL AND activated_at IS NOT NULL AND revoked_at IS NULL;
|
||||
`,
|
||||
};
|
||||
@@ -1,17 +1,25 @@
|
||||
// A seat activity grant: the authority a workload outside the host's own hook setup, such as
|
||||
// a guest in another environment or a runner inside one, uses to post activity for ONE seat and ONE launch generation.
|
||||
// a guest in another environment or a runner inside one, uses to post activity for ONE seat. Managed hosted runners
|
||||
// additionally bind that authority to one actual launch, its concrete Core pane and originating session.
|
||||
// The install-wide hook token (activity-endpoint.ts) speaks for every seat, so it never goes into a guest; a grant goes
|
||||
// there instead, under the same variable name, so runners need no change. The ingress (routes/activity.ts) resolves
|
||||
// the grant first and only then accepts the seat and generation a post claims.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { SessionRegistry } from "./session-registry.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { monotonicFactory } from "ulid";
|
||||
|
||||
const ulid = monotonicFactory();
|
||||
const TOKEN_PREFIX = "oag_";
|
||||
|
||||
export type RunGrantBinding = { runtime: string; runner: number; pane: string; panePid: number };
|
||||
export type ActivityGrant = {
|
||||
runId: string | null;
|
||||
sessionId: string | null;
|
||||
activatedAt: string | null;
|
||||
binding: RunGrantBinding | null;
|
||||
grantId: string;
|
||||
nodeId: string;
|
||||
generation: string;
|
||||
@@ -28,12 +36,15 @@ export class ActivityGrantError extends Error {
|
||||
}
|
||||
|
||||
type Row = { grant_id: string; node_id: string; generation: string; issued_at: string; issued_by: string;
|
||||
revoked_at: string | null; revoke_reason: string | null };
|
||||
revoked_at: string | null; revoke_reason: string | null; run_id?: string | null; session_id?: string | null;
|
||||
activated_at?: string | null; binding_json?: string | null };
|
||||
|
||||
const sha256 = (token: string): string => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
function toGrant(row: Row): ActivityGrant {
|
||||
return { grantId: row.grant_id, nodeId: row.node_id, generation: row.generation, issuedAt: row.issued_at,
|
||||
let binding: RunGrantBinding | null = null;
|
||||
try { binding = row.binding_json ? JSON.parse(row.binding_json) : null; } catch { /* unavailable */ }
|
||||
return { runId: row.run_id ?? null, sessionId: row.session_id ?? null, activatedAt: row.activated_at ?? null, binding, grantId: row.grant_id, nodeId: row.node_id, generation: row.generation, issuedAt: row.issued_at,
|
||||
issuedBy: row.issued_by, revokedAt: row.revoked_at, revokeReason: row.revoke_reason };
|
||||
}
|
||||
|
||||
@@ -45,13 +56,17 @@ export function hasActivityGrantsTable(db: Database.Database): boolean {
|
||||
export class ActivityGrantStore {
|
||||
constructor(private readonly db: Database.Database, private readonly now: () => Date = () => new Date()) {}
|
||||
|
||||
/** Issue the node's grant for `generation`: the occupant generation its launch reserved, which may not be registered
|
||||
* yet. One active grant per node, so this revokes any earlier one ("reissued"): a lost response is reconciled by
|
||||
* issuing again. The token is returned once; only its sha256 is stored. */
|
||||
issue(input: { nodeId: string; generation: string; issuedBy: string }): ActivityGrant & { token: string } {
|
||||
/** Legacy issuance revokes earlier grants as before. An actual-run issuance stays pending and preserves
|
||||
* the active predecessor; only another pending credential for this same run is superseded. Activation owns cutover.
|
||||
* The token is returned once; only its sha256 is stored. */
|
||||
issue(input: { nodeId: string; generation: string; issuedBy: string; run?: { runId: string; sessionId?: string; binding: RunGrantBinding } }): ActivityGrant & { token: string } {
|
||||
if (!input.nodeId.trim() || !input.generation.trim() || !input.issuedBy.trim()) {
|
||||
throw new ActivityGrantError("invalid_grant_input", "An activity grant needs a node, a launch generation and who issued it.");
|
||||
}
|
||||
if (input.run && (!input.run.runId.trim() || !input.run.binding.runtime || !input.run.binding.pane
|
||||
|| !Number.isSafeInteger(input.run.binding.panePid) || input.run.binding.panePid <= 0)) {
|
||||
throw new ActivityGrantError("invalid_grant_input", "A run grant needs a concrete Core launch binding.");
|
||||
}
|
||||
const token = `${TOKEN_PREFIX}${randomBytes(32).toString("hex")}`;
|
||||
const grantId = `agrant-${ulid()}`;
|
||||
const issuedAt = this.now().toISOString();
|
||||
@@ -59,9 +74,14 @@ export class ActivityGrantStore {
|
||||
if (!this.db.prepare("SELECT 1 FROM nodes WHERE id = ?").get(input.nodeId)) {
|
||||
throw new ActivityGrantError("node_not_found", `No seat with node ${input.nodeId}.`);
|
||||
}
|
||||
this.revokeActive(input.nodeId, "reissued", issuedAt);
|
||||
if (input.run) {
|
||||
this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = 'reissued' WHERE node_id = ? AND run_id = ? AND activated_at IS NULL AND revoked_at IS NULL")
|
||||
.run(issuedAt, input.nodeId, input.run.runId);
|
||||
} else this.revokeActive(input.nodeId, "reissued", issuedAt);
|
||||
this.db.prepare(`INSERT INTO activity_grants (grant_id, node_id, generation, token_sha256, issued_by, issued_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run(grantId, input.nodeId, input.generation, sha256(token), input.issuedBy, issuedAt);
|
||||
if (input.run) this.db.prepare("UPDATE activity_grants SET run_id = ?, session_id = ?, binding_json = ? WHERE grant_id = ?")
|
||||
.run(input.run.runId, input.run.sessionId ?? null, JSON.stringify(input.run.binding), grantId);
|
||||
})();
|
||||
return { ...this.get(grantId)!, token };
|
||||
}
|
||||
@@ -80,10 +100,154 @@ export class ActivityGrantStore {
|
||||
}
|
||||
|
||||
activeForNode(nodeId: string): ActivityGrant | null {
|
||||
const row = this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL").get(nodeId) as Row | undefined;
|
||||
const row = this.db.prepare(this.hasRunColumns()
|
||||
? "SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL AND (run_id IS NULL OR activated_at IS NOT NULL) ORDER BY activated_at DESC LIMIT 1"
|
||||
: "SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL").get(nodeId) as Row | undefined;
|
||||
return row ? toGrant(row) : null;
|
||||
}
|
||||
|
||||
/** Activate only against the committed Core binding. Staged launches remain pending until cutover. */
|
||||
activate(grantId: string): boolean {
|
||||
return this.db.transaction(() => {
|
||||
const grant = this.get(grantId);
|
||||
if (!grant?.runId || grant.revokedAt || !this.matchesBinding(grant, false, !grant.activatedAt)) return false;
|
||||
if (grant.activatedAt) return true;
|
||||
const active = this.activeForNode(grant.nodeId);
|
||||
if (active?.runId && active.grantId > grant.grantId && this.bindingMatch(active, true) !== false) return false;
|
||||
const session = this.currentSession(grant.nodeId)!;
|
||||
// Only this pending, Core-selected launch may fill an empty declaration; never overwrite a conflicting record.
|
||||
if (grant.binding!.runtime !== "pi" && session.agent_app == null && session.agent_runner == null) {
|
||||
this.db.prepare("UPDATE sessions SET agent_app = ?, agent_runner = ? WHERE id = ?")
|
||||
.run(grant.binding!.runtime, grant.binding!.runner, session.id);
|
||||
}
|
||||
const at = this.now().toISOString();
|
||||
this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = 'run_replaced' WHERE node_id = ? AND grant_id <> ? AND revoked_at IS NULL AND (run_id IS NULL OR activated_at IS NOT NULL)")
|
||||
.run(at, grant.nodeId, grantId);
|
||||
this.db.prepare("UPDATE activity_grants SET activated_at = ?, session_id = ? WHERE grant_id = ? AND revoked_at IS NULL")
|
||||
.run(at, session.id, grantId);
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
/** Core's existing claimed-session commit is the staged activation point. Only an unambiguous
|
||||
* matching launch may restore its recorded runner declaration onto that new session row. */
|
||||
reconcileBinding(nodeId: string): void {
|
||||
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return;
|
||||
try { this.db.transaction(() => {
|
||||
const grants = (this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND run_id IS NOT NULL AND revoked_at IS NULL")
|
||||
.all(nodeId) as Row[]).map(toGrant);
|
||||
if (grants.length === 0) return;
|
||||
const session = this.currentSession(nodeId);
|
||||
if (!session) return;
|
||||
const registry = new SessionRegistry(this.db);
|
||||
const generation = registry.currentOccupantTenure(nodeId)?.generationUuid;
|
||||
const candidates = grants.filter(grant => grant.binding?.pane === session.tmux_pane && grant.binding.runtime === session.runtime
|
||||
&& (grant.generation === generation || (grant.activatedAt && registry.isOccupantContinuedByAdoption(nodeId, grant.generation)
|
||||
&& this.adoptionAnchorMatches(grant, session.session_name))));
|
||||
if (candidates.length === 1) {
|
||||
const grant = candidates[0]!;
|
||||
if (grant.binding!.runtime !== "pi" && session.agent_app == null && session.agent_runner == null) {
|
||||
this.db.prepare("UPDATE sessions SET agent_app = ?, agent_runner = ? WHERE id = ?")
|
||||
.run(grant.binding!.runtime, grant.binding!.runner, session.id);
|
||||
}
|
||||
if (!grant.activatedAt) this.activate(grant.grantId);
|
||||
}
|
||||
for (const grant of grants) {
|
||||
if (grant.activatedAt && this.bindingMatch(grant, true) === false) this.revoke({ grantId: grant.grantId }, "binding_replaced");
|
||||
}
|
||||
})(); } catch { /* Optional reconciliation cannot turn an unreadable binding into revocation or fail registration. */ }
|
||||
}
|
||||
|
||||
/** Pending issuance IDs for a Core-reserved operation, captured before its recovery awaits.
|
||||
* This selects cleanup work; generation alone never authenticates a report or activates a grant. */
|
||||
pendingForGeneration(nodeId: string, generation: string): ActivityGrant[] {
|
||||
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return [];
|
||||
return (this.db.prepare(`SELECT * FROM activity_grants WHERE node_id = ? AND generation = ?
|
||||
AND run_id IS NOT NULL AND activated_at IS NULL AND revoked_at IS NULL`).all(nodeId, generation) as Row[]).map(toGrant);
|
||||
}
|
||||
|
||||
/** A status observation for an old row cannot revoke the replacement's run. */
|
||||
revokeForSession(sessionId: string, reason: string): void {
|
||||
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return;
|
||||
const row = this.db.prepare("SELECT node_id FROM sessions WHERE id = ?").get(sessionId) as { node_id: string } | undefined;
|
||||
if (!row) return;
|
||||
const current = this.currentSession(row.node_id);
|
||||
const grants = (this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND run_id IS NOT NULL AND revoked_at IS NULL")
|
||||
.all(row.node_id) as Row[]).map(toGrant);
|
||||
for (const grant of grants) {
|
||||
if ((grant.sessionId === sessionId && (current?.id === sessionId || this.bindingMatch(grant, true) === false))
|
||||
|| (grant.activatedAt && current?.id === sessionId)) {
|
||||
this.revoke({ grantId: grant.grantId }, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
standing(grantId: string): "current" | "pending" | "stale" | "unverified" | "revoked" {
|
||||
const grant = this.get(grantId);
|
||||
if (!grant || grant.revokedAt) return "revoked";
|
||||
if (!grant.runId || !this.hasRunColumns()) return "stale";
|
||||
if (!grant.activatedAt) return "pending";
|
||||
const binding = this.bindingMatch(grant, true);
|
||||
return binding === null ? "unverified" : binding ? "current" : "stale";
|
||||
}
|
||||
|
||||
private hasRunColumns(): boolean {
|
||||
return (this.db.prepare("PRAGMA table_info(activity_grants)").all() as Array<{ name: string }>).some(row => row.name === "run_id");
|
||||
}
|
||||
|
||||
private currentSession(nodeId: string): { id: string; session_name: string; agent_app: string | null; agent_runner: number | null; tmux_pane: string | null; runtime: string | null } | undefined {
|
||||
return this.db.prepare(`SELECT s.id, s.session_name, s.agent_app, s.agent_runner, b.tmux_pane, n.runtime
|
||||
FROM sessions s JOIN nodes n ON n.id=s.node_id JOIN bindings b ON b.node_id=n.id AND b.tmux_session=s.session_name
|
||||
WHERE s.node_id=? AND s.id=(SELECT id FROM sessions WHERE node_id=? ORDER BY id DESC LIMIT 1)
|
||||
AND s.status NOT IN ('exited','detached','superseded')`).get(nodeId, nodeId) as ReturnType<ActivityGrantStore["currentSession"]>;
|
||||
}
|
||||
|
||||
private matchesBinding(grant: ActivityGrant, allowAdoption: boolean, allowUnrecorded = false): boolean {
|
||||
return this.bindingMatch(grant, allowAdoption, allowUnrecorded) === true;
|
||||
}
|
||||
|
||||
/** Unknown evidence refuses reports, but cannot authorize destructive lifecycle cleanup. */
|
||||
private bindingMatch(grant: ActivityGrant, allowAdoption: boolean, allowUnrecorded = false): boolean | null {
|
||||
try {
|
||||
const anchor = grant.binding, session = this.currentSession(grant.nodeId);
|
||||
if (!anchor || !session || session.tmux_pane !== anchor.pane || session.runtime !== anchor.runtime) return false;
|
||||
if (anchor.runtime !== "pi" && ((session.agent_app != null && session.agent_app !== anchor.runtime)
|
||||
|| (session.agent_runner != null && session.agent_runner !== anchor.runner))) return false;
|
||||
const declarationMissing = anchor.runtime !== "pi" && (session.agent_app == null || session.agent_runner == null)
|
||||
&& !(allowUnrecorded && session.agent_app == null && session.agent_runner == null && [1, 2].includes(anchor.runner));
|
||||
const registry = new SessionRegistry(this.db);
|
||||
const current = registry.currentOccupantTenure(grant.nodeId)?.generationUuid;
|
||||
if (!current) return null;
|
||||
const adoption = current !== grant.generation;
|
||||
if (adoption && (!allowAdoption || !registry.isOccupantContinuedByAdoption(grant.nodeId, grant.generation))) return false;
|
||||
if (!adoption && grant.sessionId && grant.sessionId !== session.id) return false;
|
||||
const evidence = new SeatIdentityStore(this.db).getForNode(grant.nodeId);
|
||||
if (adoption) {
|
||||
const match = this.adoptionAnchorMatch(grant, session.session_name);
|
||||
if (match !== true) return match;
|
||||
}
|
||||
if (evidence?.sessionName === session.session_name && evidence.evidence.registeredPane === anchor.pane
|
||||
&& Date.parse(evidence.observedAt) >= Date.parse(grant.issuedAt)
|
||||
&& evidence.evidence.observedPanePid != null && evidence.evidence.observedPanePid !== anchor.panePid) return false;
|
||||
// Missing declaration is unknown only after ruling out positive binding/tenure/process replacement.
|
||||
return declarationMissing ? null : true;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
private adoptionAnchorMatches(grant: ActivityGrant, sessionName: string): boolean {
|
||||
return this.adoptionAnchorMatch(grant, sessionName) === true;
|
||||
}
|
||||
|
||||
private adoptionAnchorMatch(grant: ActivityGrant, sessionName: string): boolean | null {
|
||||
const store = new SeatIdentityStore(this.db), evidence = store.getForNode(grant.nodeId);
|
||||
const process = store.getProcessEvidence(grant.nodeId);
|
||||
if (!evidence || !process || evidence.sessionName !== sessionName || process.sessionName !== sessionName
|
||||
|| evidence.evidence.observedPanePid == null || process.panePid == null) return null;
|
||||
return !!grant.binding && evidence.sessionName === sessionName && process.sessionName === sessionName
|
||||
&& evidence.evidence.registeredPane === grant.binding.pane
|
||||
&& evidence.evidence.observedPanePid === grant.binding.panePid && process.panePid === grant.binding.panePid;
|
||||
}
|
||||
|
||||
/** The grant a presented token belongs to, revoked or not, or null for any other token. */
|
||||
authenticate(token: string | null | undefined): ActivityGrant | null {
|
||||
if (!token?.startsWith(TOKEN_PREFIX) || !hasActivityGrantsTable(this.db)) return null;
|
||||
|
||||
@@ -63,6 +63,8 @@ export const POST_COMPACT_RESTORE_COOLDOWN_MS_DEFAULT = 10 * 60_000;
|
||||
// writing the restore map can take a minute+; the wait returns as soon as the
|
||||
// seat is idle, so this only bounds a pathological never-idle case.
|
||||
export const MANUAL_PREP_WAIT_MS_DEFAULT = 180_000;
|
||||
/** How often a manual attempt reads its seat while it waits (for the map, or after a /compact the seat wasn't idle for). */
|
||||
export const MANUAL_SEAT_READ_INTERVAL_MS = 5_000;
|
||||
// Operational ceiling, not a measured optimum. Existing context polls advance auto preparation.
|
||||
export const AUTO_PREP_WAIT_MS_DEFAULT = 25 * 60_000;
|
||||
// How long a stopped attempt holds automatic compaction off for its seat before a fresh attempt may begin. Without it one
|
||||
@@ -224,7 +226,7 @@ export function buildCompactCommand(compactInstruction: string): string {
|
||||
}
|
||||
|
||||
function buildPreCompactPrepPrompt(input: {
|
||||
usedPercentage: number;
|
||||
usedPercentage: number | null;
|
||||
thresholdPercent: number;
|
||||
preCompactInstruction?: string | null;
|
||||
/** The policy's compaction instruction when it is too long for the /compact command. */
|
||||
@@ -239,7 +241,7 @@ function buildPreCompactPrepPrompt(input: {
|
||||
]
|
||||
: [
|
||||
"OpenRig automatic compaction preparation is now required.",
|
||||
`Current context usage is ${input.usedPercentage}%; configured compaction threshold is ${input.thresholdPercent}%.`,
|
||||
`Current context usage is ${input.usedPercentage ?? "unknown"}%; configured compaction threshold is ${input.thresholdPercent}%.`,
|
||||
`The ${AUTO_PREP_WAIT_MS_DEFAULT / 60_000}-minute ceiling starts after preparation delivery returns; completing the restore map and becoming idle must fit within it.`,
|
||||
];
|
||||
pieces.push(
|
||||
@@ -253,6 +255,7 @@ function buildPreCompactPrepPrompt(input: {
|
||||
pieces.push(
|
||||
`Write this attempt's complete restore map to ${JSON.stringify(input.preparation.mapPath + ".tmp")}, using your ordinary file-edit tool. Append the exact completion marker below as its LAST line, then finish and close the file. OpenRig will atomically publish it to ${JSON.stringify(input.preparation.mapPath)}; do not run a shell command to rename or publish it. Never write the final file incrementally.`,
|
||||
`Completion marker: ${input.preparation.marker}`,
|
||||
"To avoid one cause of approval prompts, which hold /compact until someone answers them: write files with your file-edit tool (acceptEdits covers your working directory, not a preparation root outside it), and if you publish a seat recap, run rig context recap-write --file with the file's literal absolute path, with no shell variable assignment or command substitution, so an allow rule for rig commands can match it.",
|
||||
"The marker identifies this attempt and occupant; it does not certify the map's quality. Keep the normal ranked restore-map content.",
|
||||
`Near the top of the map, copy this note word for word; the restore request will have you read it: ${MANAGED_COMPACT_TRUST_NOTE}`,
|
||||
...(input.overflowCompactInstruction?.trim()
|
||||
@@ -398,7 +401,7 @@ export function buildPostCompactRestorePrompt(input: {
|
||||
|
||||
function buildPostCompactCompliancePrompt(postRestoreAuditInstruction?: string | null): string {
|
||||
const pieces = [
|
||||
"Now audit your compaction restore before doing any other work.",
|
||||
"Now audit your compaction restore: read the required items below before acting on the restored work, and keep any hold you verified as live.",
|
||||
];
|
||||
const instruction = postRestoreAuditInstruction?.trim();
|
||||
if (instruction) {
|
||||
@@ -409,7 +412,7 @@ function buildPostCompactCompliancePrompt(postRestoreAuditInstruction?: string |
|
||||
"For each item, mark read depth as FULL, PARTIAL, or NOT_READ.",
|
||||
"Include the refocusing skill and the topology and work trace sources. Account for the current traces actually delivered during restore; do not rerun Python to duplicate them. If no current trace arrived, report that delivery gap. Read required notes and full sources with the native file-read tool; pointers, compact summaries and truncated extracts do not count as full source reads.",
|
||||
"Required items are your restore map's ranked entries above your restore class's tier line (with no map: the instruction files and the packet's restore-instructions.md); the other restore packet files and the session JSONL stay lookup-only.",
|
||||
"Read every required item that is not FULL in full now, without skimming, then report the final read-depth table before continuing.",
|
||||
"Read every required item that is not FULL in full now, without skimming. Report the final read-depth table and the reconnect note as you resume; don't hold unaffected work for them.",
|
||||
);
|
||||
return pieces.join(" ");
|
||||
}
|
||||
@@ -498,6 +501,10 @@ export interface PreparationState {
|
||||
mapUsedPercentage?: number;
|
||||
/** A record too old to resume that this attempt replaced (see `getStrandedCompaction`): its restore may have been owed. */
|
||||
supersedes?: StrandedCompaction;
|
||||
/** A manual attempt's latest reading of its seat showed a prompt (a permission request its preparation turn reached,
|
||||
* say) that holds /compact: since when, and when it was last seen. `current` is false when a later reading couldn't
|
||||
* establish the seat's state, so the prompt is an earlier observation. Cleared when a reading shows idle or work. */
|
||||
awaitingInput?: { since: number; reason: string; observedAt: number; current: boolean };
|
||||
}
|
||||
/** An open record from before a restart, too old to resume: the seat's restore steps may still be owed. */
|
||||
export interface StrandedCompaction { attemptId: string; mapPath: string; phase: string; recordedAt: string }
|
||||
@@ -1409,7 +1416,6 @@ export class ClaudeCompactionEnforcer {
|
||||
return { triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" };
|
||||
}
|
||||
if (input.runtime !== "claude-code") return this.recordManualFailure(input.sessionName, "runtime_filter");
|
||||
if (input.usedPercentage == null) return this.recordManualFailure(input.sessionName, "no_usage_data");
|
||||
// The preparation prompt cannot be written to such a seat either, so the whole attempt is refused up front.
|
||||
if (this.paneUnknownProtected(input.sessionName)) return this.recordManualFailure(input.sessionName, "pane_unknown_protected");
|
||||
const actor = opts.actor ?? { source: "caller" as const, session: null };
|
||||
@@ -1429,34 +1435,69 @@ export class ClaudeCompactionEnforcer {
|
||||
};
|
||||
this.setManualStage(input.sessionName, "preparing", undefined, opts.operatorInitiated === true);
|
||||
await this.deliverPreparation(input, attempt);
|
||||
// Manual preparation can pause for an ordinary approval, before or after the map is complete. OpenRig never answers
|
||||
// it. It keeps the latest completed reading of the seat on the attempt (awaitingInput), recorded before deadline
|
||||
// reconciliation, so --state and the expiry reason describe the seat now rather than an earlier prompt.
|
||||
const observe = (reading: { ok: boolean; reason?: string; activity?: { state?: string } } | null) => {
|
||||
const state = reading === null ? "unknown" : reading.ok ? "idle"
|
||||
: reading.reason === "target_needs_input" ? "needs_input" : reading.activity?.state ?? "unknown";
|
||||
if (state === "needs_input") {
|
||||
attempt.awaitingInput = { since: attempt.awaitingInput?.since ?? this.now(), reason: "target_needs_input", observedAt: this.now(), current: true };
|
||||
} else if (state === "unknown") {
|
||||
if (attempt.awaitingInput) attempt.awaitingInput = { ...attempt.awaitingInput, current: false };
|
||||
} else delete attempt.awaitingInput;
|
||||
};
|
||||
// While the map is written, one bounded reading every MANUAL_SEAT_READ_INTERVAL_MS, never one per map check. The first
|
||||
// comes an interval after delivery: a reading taken as the preparation turn starts tells nothing.
|
||||
let nextRead = this.now() + MANUAL_SEAT_READ_INTERVAL_MS;
|
||||
while (attempt.status === "waiting" && !opts.skipMap && !this.mapReady(attempt)) {
|
||||
const remaining = attempt.deadlineAt! - this.now();
|
||||
if (remaining > 0 && this.now() >= nextRead) {
|
||||
let reading;
|
||||
try { reading = await this.sessionTransport.waitUntilIdle(input.sessionName, Math.min(remaining, CAPTURE_READABLE_BUDGET_MS), attempt.controller.signal, { singleObservation: true, allowDraft: true }); }
|
||||
catch { reading = null; }
|
||||
observe(reading);
|
||||
nextRead = this.now() + MANUAL_SEAT_READ_INTERVAL_MS;
|
||||
}
|
||||
this.reconcilePreparations();
|
||||
if (attempt.status !== "waiting") break;
|
||||
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
|
||||
}
|
||||
if (attempt.status !== "waiting") return failed(attempt.reason ?? "preparation_incomplete");
|
||||
// Manual preparation can pause for an ordinary approval after the map is complete.
|
||||
// Keep the same classifier/deadline and wait outside the input lease; never answer it.
|
||||
let idle;
|
||||
do {
|
||||
const remaining = attempt.deadlineAt! - this.now();
|
||||
if (remaining <= 0) return failed(this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete");
|
||||
try { idle = await this.sessionTransport.waitUntilIdle(input.sessionName, remaining, attempt.controller.signal, { allowDraft: true }); }
|
||||
catch { idle = { ok: false, reason: "transport_unavailable" }; }
|
||||
this.reconcilePreparations();
|
||||
if (idle.ok || idle.reason !== "target_needs_input" || attempt.status !== "waiting") break;
|
||||
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
|
||||
this.reconcilePreparations();
|
||||
} while (attempt.status === "waiting");
|
||||
if (!idle.ok || attempt.status !== "waiting") {
|
||||
// The transport was given this attempt's remaining budget; name its timeout like preparation expiry.
|
||||
const reason = attempt.reason ?? (idle.ok ? "preparation_incomplete"
|
||||
: idle.reason === "wait_for_idle_timeout"
|
||||
? this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete"
|
||||
: idle.reason);
|
||||
return failed(reason);
|
||||
// The budget ran out: name a prompt the latest reading showed, else as before.
|
||||
const expired = () => attempt.awaitingInput?.current ? "preparation_needs_input"
|
||||
: this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete";
|
||||
let compact: { ok: boolean; reason?: string; deferredBy?: { reason?: string; activity?: { state?: string } } };
|
||||
for (;;) {
|
||||
let idle;
|
||||
do {
|
||||
const remaining = attempt.deadlineAt! - this.now();
|
||||
if (remaining <= 0) return failed(expired());
|
||||
// The wait can run to the deadline. Each reading it completes reaches the attempt as it lands, so a --state read or
|
||||
// deadline reconciliation during the wait never takes an answered prompt for a current one.
|
||||
try { idle = await this.sessionTransport.waitUntilIdle(input.sessionName, remaining, attempt.controller.signal,
|
||||
{ allowDraft: true, onObservation: (activity) => observe({ ok: false, activity }) }); }
|
||||
catch { idle = { ok: false, reason: "transport_unavailable" }; }
|
||||
observe(idle);
|
||||
this.reconcilePreparations();
|
||||
if (idle.ok || idle.reason !== "target_needs_input" || attempt.status !== "waiting") break;
|
||||
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
|
||||
this.reconcilePreparations();
|
||||
} while (attempt.status === "waiting");
|
||||
if (!idle.ok || attempt.status !== "waiting") {
|
||||
// The transport was given this attempt's remaining budget; name its timeout like preparation expiry.
|
||||
const reason = attempt.reason ?? (idle.ok ? "preparation_incomplete"
|
||||
: idle.reason === "wait_for_idle_timeout" ? expired() : idle.reason);
|
||||
return failed(reason);
|
||||
}
|
||||
compact = await this.sendPreparedCompact(input, attempt, opts.skipMap === true);
|
||||
// The seat wasn't idle at the send after all (its preparation turn reached a prompt, or did more work): nothing was
|
||||
// written, so wait again within this attempt's deadline rather than disarm. The send reads the pane while the idle
|
||||
// wait can take a hook's idle, so pause one reading interval first: a disagreement can't loop sends without a break.
|
||||
if (compact.ok || compact.reason !== "waiting_for_idle") break;
|
||||
observe({ ok: false, ...compact.deferredBy });
|
||||
await this.sleep(Math.min(MANUAL_SEAT_READ_INTERVAL_MS, Math.max(1, attempt.deadlineAt! - this.now())));
|
||||
}
|
||||
const compact = await this.sendPreparedCompact(input, attempt, opts.skipMap === true);
|
||||
if (!compact.ok) {
|
||||
const outcome = failed(compact.reason ?? "send_failed");
|
||||
// A /compact whose result was lost still owes its restore steps, and this request authorizes them.
|
||||
@@ -1590,7 +1631,7 @@ export class ClaudeCompactionEnforcer {
|
||||
const composed = composeManagedCompactCommand({ compactInstruction: attempt.compactInstruction, mapPath: attempt.mapPath });
|
||||
prep = await this.sessionTransport.send(input.sessionName, buildPreCompactPrepPrompt({
|
||||
overflowCompactInstruction: composed.instructionInCommand ? null : attempt.compactInstruction,
|
||||
usedPercentage: input.usedPercentage!, thresholdPercent: policy.thresholdPercent,
|
||||
usedPercentage: input.usedPercentage ?? null, thresholdPercent: policy.thresholdPercent,
|
||||
preCompactInstruction: policy.preCompactInstruction, preparation: attempt, constructedAt: this.now(),
|
||||
}), { openrigNotice: true, allowDraftWhileIdle: true, ...idleGate, beforeWrite: () => {
|
||||
this.reconcilePreparations();
|
||||
@@ -1634,7 +1675,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.reconcilePreparations();
|
||||
}
|
||||
|
||||
private async sendPreparedCompact(input: EnforcerInput, attempt: PreparationAttempt, skipMap: boolean): Promise<{ok: boolean; reason?: string}> {
|
||||
private async sendPreparedCompact(input: EnforcerInput, attempt: PreparationAttempt, skipMap: boolean): Promise<{ok: boolean; reason?: string; deferredBy?: { reason?: string; activity?: { state?: string } }}> {
|
||||
const check = () => {
|
||||
this.reconcilePreparations();
|
||||
this.checkAttemptPolicy(input, attempt);
|
||||
@@ -1705,13 +1746,15 @@ export class ClaudeCompactionEnforcer {
|
||||
if (notSent && typeof windowReason === "string" && HOLD_REASONS.has(windowReason) && this.holdPrepared(input.sessionName, attempt, windowReason)) {
|
||||
return { ok: false, reason: PREPARED_AWAITING_WINDOW };
|
||||
}
|
||||
// Only a known pre-input idle deferral keeps the automatic attempt armed for a later poll; the transport
|
||||
// marks those `not_reached`. Reconciliation still bounds the idle grace and stops on an occupant change.
|
||||
// Only a known pre-input idle deferral keeps the attempt armed: an automatic one for a later poll, a manual one for
|
||||
// the rest of its deadline (its caller waits again). The transport marks those `not_reached`. Reconciliation still
|
||||
// bounds the idle grace and stops on an occupant change.
|
||||
// A typing-guard draft hold (`draft_*`) is `not_reached` too, but it is a refusal, not an idle wait: it stops
|
||||
// as not sent, and a fresh attempt may begin after the retry cooldown.
|
||||
const draftHold = policyDraftHold || (typeof result.reason === "string" && result.reason.startsWith("draft_"));
|
||||
if (attempt.mode === "automatic" && attempt.status === "waiting" && notSent && phase === "not_reached" && !draftHold) {
|
||||
return { ok: false, reason: "waiting_for_idle" };
|
||||
if (attempt.status === "waiting" && notSent && phase === "not_reached" && !draftHold
|
||||
&& (attempt.mode === "automatic" || this.now() < attempt.deadlineAt!)) {
|
||||
return { ok: false, reason: "waiting_for_idle", deferredBy: { reason: result.reason, activity: result.activity } };
|
||||
}
|
||||
// A compact may have been sent despite a lost result. Never replay automatically.
|
||||
attempt.compact = notSent ? "not_sent" : "uncertain";
|
||||
@@ -1790,7 +1833,9 @@ export class ClaudeCompactionEnforcer {
|
||||
else if (!attempt.prepared && attempt.deadlineAt !== null && this.now() >= this.compactDeadline(attempt)!) {
|
||||
const ready = this.mapReady(attempt);
|
||||
if (!(ready && this.holdPrepared(session, attempt, "map_complete_seat_never_idle"))) {
|
||||
this.stopPreparation(session, ready ? "map_complete_seat_never_idle" : "preparation_incomplete");
|
||||
// A manual attempt whose latest reading is a prompt says so (only the manual wait records awaitingInput).
|
||||
this.stopPreparation(session, attempt.awaitingInput?.current ? "preparation_needs_input"
|
||||
: ready ? "map_complete_seat_never_idle" : "preparation_incomplete");
|
||||
}
|
||||
}
|
||||
if (enabled) attempt.policyWasEnabled = true;
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import {
|
||||
CODEX_SEAT_PERMISSION_PROFILE,
|
||||
codexFilesystemOverrides,
|
||||
type CodexFilesystemAccess,
|
||||
type CodexFilesystemPermissions,
|
||||
} from "./codex-member-permissions.js";
|
||||
import { readCodexConfiguration } from "./codex-network-default.js";
|
||||
|
||||
export type CodexFilesystemDecision =
|
||||
| { ok: true; verified: true }
|
||||
| { ok: true; verified: false; reason: string }
|
||||
| { ok: false; error: string };
|
||||
const verified = (): CodexFilesystemDecision => ({ ok: true, verified: true });
|
||||
const refuse = (error: string, remedy: string): CodexFilesystemDecision => ({ ok: false, error: `${error}\nFix: ${remedy}` });
|
||||
const unknown = (reason: string): CodexFilesystemDecision => ({ ok: true, verified: false, reason });
|
||||
export const codexPolicyWarning = (reason: string): string => `policy not verified: ${reason}`;
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
// Null means absent, including options added by a later native reader.
|
||||
const present = (record: Record<string, unknown>) => Object.entries(record).filter(([, value]) => value != null);
|
||||
function combine(...checks: CodexFilesystemDecision[]): CodexFilesystemDecision {
|
||||
let inconclusive: Extract<CodexFilesystemDecision, { verified: false }> | undefined;
|
||||
for (const check of checks) {
|
||||
if (!check.ok) return check;
|
||||
if (!check.verified) inconclusive ??= check;
|
||||
}
|
||||
return inconclusive ?? verified();
|
||||
}
|
||||
const accessRank = { deny: 0, read: 1, write: 2 };
|
||||
const isAccess = (value: unknown): value is CodexFilesystemAccess => value === "read" || value === "write" || value === "deny";
|
||||
const canonicalKey = (key: string) => key === ":project_roots" ? ":workspace_roots" : key;
|
||||
function ambiguousKeys(keys: string[]): Set<string> {
|
||||
const seen = new Set<string>(), ambiguous = new Set<string>();
|
||||
for (const key of keys.map(canonicalKey)) {
|
||||
if (seen.has(key)) ambiguous.add(key);
|
||||
seen.add(key);
|
||||
}
|
||||
return ambiguous;
|
||||
}
|
||||
const literalAbsolute = (key: string) => path.isAbsolute(key) && path.normalize(key) === key && !/[?*\[\]{}]/.test(key);
|
||||
const nativeSelectors = new Set([":root", ":minimal", ":workspace_roots", ":tmpdir", ":slash_tmp"]);
|
||||
const literalHomePath = (key: string) => key.startsWith("~/") && path.normalize(key) === key && !/[?*\[\]{}]/.test(key);
|
||||
|
||||
/** Compare only established flat coverage. Native expansion, inheritance and
|
||||
* unfamiliar syntax stay unknown; this is not a second sandbox implementation. */
|
||||
function compareFilesystem(actual: unknown, permissions: CodexFilesystemPermissions): CodexFilesystemDecision {
|
||||
if (actual == null) return refuse("Rule permissions.openrig-seat.filesystem: the agent declares read/write/deny rules, but the effective Codex profile has no mapping.",
|
||||
"Select a Codex profile that retains the agent's filesystem rules, or deliberately change the agent's codex_permissions.");
|
||||
if (!isRecord(actual)) return unknown("Codex selected filesystem mapping has an unfamiliar representation");
|
||||
const entries = present(actual);
|
||||
if (!entries.length) return refuse("Rule permissions.openrig-seat.filesystem: the agent declares read/write/deny rules, but the effective Codex mapping is empty.",
|
||||
"Restore the intended filesystem mapping in the effective Codex profile before launching.");
|
||||
const authored = Object.fromEntries(Object.entries(permissions.filesystem).map(([key, value]) => [canonicalKey(key), value]));
|
||||
const effective = Object.fromEntries(entries.map(([key, value]) => [canonicalKey(key), value]));
|
||||
const authoredAmbiguous = ambiguousKeys(Object.keys(permissions.filesystem));
|
||||
const ambiguous = new Set([...authoredAmbiguous, ...ambiguousKeys(entries.map(([key]) => key))]);
|
||||
const at = (rules: Record<string, unknown>, key: string, extraAccess?: CodexFilesystemAccess): CodexFilesystemAccess | undefined => {
|
||||
if (ambiguous.has(key)) return undefined;
|
||||
if (isAccess(rules[key])) return rules[key];
|
||||
const fullRoot = rules[":root"] ?? (literalAbsolute(path.sep) ? rules[path.sep] : undefined);
|
||||
const rootAccess = isAccess(fullRoot) ? fullRoot : "deny";
|
||||
const ancestors = Object.keys(rules).filter(root => (literalAbsolute(root) || literalHomePath(root)) && key.startsWith(root.endsWith(path.sep) ? root : root + path.sep))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
const root = ancestors[0];
|
||||
const ancestorAccess = root ? rules[root] : rootAccess;
|
||||
// Native selectors/home paths can overlap authored literal paths or other
|
||||
// selectors. Do not infer a widening where an existing grant may cover it;
|
||||
// native expansion remains Codex's job, not another policy engine here.
|
||||
// Only the agent's own ambiguity can affect coverage of another key;
|
||||
// duplicate aliases introduced by a lower layer stay local to those aliases.
|
||||
// Non-root denies are restrictions; either full-root spelling is a
|
||||
// default that narrower grants may refine under Codex's native rules.
|
||||
const denyAncestor = root !== undefined && root !== path.sep && rules[root] === "deny";
|
||||
// Codex 0.167.2's Linux/macOS minimal defaults are system paths, not
|
||||
// these user-data roots. Other minimal coverage stays unexpanded.
|
||||
const outsideMinimal = literalAbsolute(key) && /^\/(?:home|Users)(?:\/|$)/i.test(key);
|
||||
const possibleCoverage = extraAccess !== undefined && !denyAncestor
|
||||
&& (!isAccess(ancestorAccess) || accessRank[extraAccess] > accessRank[ancestorAccess])
|
||||
&& (authoredAmbiguous.size > 0 || Object.entries(rules).some(([rule, access]) => rule !== ":root" && rule !== path.sep
|
||||
&& !(rule === ":minimal" && outsideMinimal)
|
||||
&& (nativeSelectors.has(key) || literalHomePath(key) || !literalAbsolute(rule))
|
||||
&& isAccess(access) && accessRank[access] >= accessRank[extraAccess]));
|
||||
if (nativeSelectors.has(key)) return key !== ":root" && possibleCoverage ? undefined : rootAccess;
|
||||
if (!literalAbsolute(key) && !literalHomePath(key)) return undefined;
|
||||
if (key !== path.sep && possibleCoverage) return undefined;
|
||||
if (root) return isAccess(rules[root]) ? rules[root] : undefined;
|
||||
if (isAccess(fullRoot)) return fullRoot;
|
||||
if (literalHomePath(key)) return "deny";
|
||||
if (Object.keys(rules).some(rule => !literalAbsolute(rule) && rule !== ":minimal")) return undefined;
|
||||
// :minimal read cannot grant write; its native read coverage is not
|
||||
// enumerated here, so extra reads outside a literal ancestor stay unknown.
|
||||
if (rules[":minimal"] != null && !outsideMinimal) return rules[":minimal"] === "read" ? "read" : undefined;
|
||||
return "deny";
|
||||
};
|
||||
const checks: CodexFilesystemDecision[] = [];
|
||||
const unfamiliar = entries.some(([, value]) => !isAccess(value));
|
||||
for (const [key, value] of Object.entries(effective)) {
|
||||
if (!isAccess(value)) { checks.push(unknown("Codex returned an unfamiliar filesystem access value or nested mapping")); continue; }
|
||||
const expected = at(authored, key, value);
|
||||
if (expected === undefined) { checks.push(unknown(`Filesystem rule ${JSON.stringify(key)}: the effective configuration grants ${value}; agent coverage could not be compared.`)); continue; }
|
||||
if (accessRank[value] > accessRank[expected]) return refuse(
|
||||
`Filesystem rule ${JSON.stringify(key)}: agent coverage is at most ${expected}; a lower Codex layer (account or trusted project) grants ${value}.`,
|
||||
"Remove the conflicting lower-layer grant, or deliberately update the agent's codex_permissions to the intended access.");
|
||||
if (!(key in authored) && value === "read" && expected === "read" && !Object.keys(authored).some(root =>
|
||||
root === ":root" || (literalAbsolute(root) && literalAbsolute(key) && key.startsWith(root.endsWith(path.sep) ? root : root + path.sep)))) {
|
||||
checks.push(unknown(`Filesystem rule ${JSON.stringify(key)}: native minimal read coverage could not be compared.`));
|
||||
}
|
||||
}
|
||||
for (const [key, access] of Object.entries(authored)) {
|
||||
if (key in effective) continue;
|
||||
const remaining = at(effective, key);
|
||||
if (remaining !== undefined && accessRank[remaining] > accessRank[access] && !unfamiliar) return refuse(
|
||||
`Filesystem rule ${JSON.stringify(key)}: the agent requires ${access}; the effective Codex policy omits that restriction and grants ${remaining}.`,
|
||||
"Restore the agent's restriction in the effective Codex profile, or deliberately change the agent's codex_permissions.");
|
||||
checks.push(unknown("An authored filesystem rule is absent from the effective mapping"));
|
||||
}
|
||||
return combine(...checks);
|
||||
}
|
||||
|
||||
const UNRELATED_REQUIREMENTS = new Set([
|
||||
"modelProvider", "modelProviders", "allowedLoginMethods", "cliAuthCredentialsStore", "chatgptBaseUrl",
|
||||
"additionalDeveloperInstructions", "allowedWindowsSandboxImplementations", "allowedWebSearchModes",
|
||||
"allowManagedHooksOnly", "allowBrowserAndComputerUse", "allowAppshots", "allowRemoteControl",
|
||||
"computerUse", "browserUse", "inAppBrowser", "hooks", "enforceResidency", "models", "sqliteHome",
|
||||
"logDir", "modelCatalogJson", "checkForUpdateOnStartup", "allowLoginShell", "feedback", "otel",
|
||||
// Legacy sandbox allowlists do not select a permission profile.
|
||||
"allowedSandboxModes",
|
||||
]);
|
||||
function compareRequirements(read: unknown, permissions: CodexFilesystemPermissions): CodexFilesystemDecision {
|
||||
if (!isRecord(read) || !("requirements" in read)) return unknown("Codex configRequirements/read returned an unrecognized response");
|
||||
const checks: CodexFilesystemDecision[] = [];
|
||||
if (present(read).some(([key]) => key !== "requirements" && key !== "supportsIndependentSpeedModes")) checks.push(unknown("Codex returned an unfamiliar requirements response field"));
|
||||
if (read.requirements == null) return combine(...checks);
|
||||
if (!isRecord(read.requirements)) return unknown("Codex managed requirements have an unfamiliar shape");
|
||||
for (const [key, value] of present(read.requirements)) {
|
||||
if (UNRELATED_REQUIREMENTS.has(key)) continue;
|
||||
if (key === "defaultPermissions" && typeof value === "string") {
|
||||
// A fallback, not a forced choice. The effective selection and allowlists
|
||||
// below establish whether Codex actually applied the agent's profile.
|
||||
continue;
|
||||
}
|
||||
if (key === "allowedApprovalPolicies" && Array.isArray(value) && value.every(policy => typeof policy === "string")) {
|
||||
if (!value.includes(permissions.approval)) return refuse("Rule allowedApprovalPolicies: the managed allowlist excludes the agent's approval choice.",
|
||||
"Ask the managed-policy owner to allow the intended approval, or select an allowed approval for the agent.");
|
||||
continue;
|
||||
}
|
||||
if (key === "allowedPermissionProfiles" && isRecord(value)) {
|
||||
if (value[CODEX_SEAT_PERMISSION_PROFILE] === false) return refuse("Rule allowedPermissionProfiles: the managed allowlist explicitly excludes the agent's filesystem profile.",
|
||||
"Ask the managed-policy owner to allow the agent's filesystem profile, or select an allowed policy for the agent.");
|
||||
const entries = present(value);
|
||||
if (entries.every(([, allowed]) => typeof allowed === "boolean")) {
|
||||
if (value[CODEX_SEAT_PERMISSION_PROFILE] !== true) return refuse("Rule allowedPermissionProfiles: the managed allowlist does not include the agent's filesystem profile.",
|
||||
"Ask the managed-policy owner to allow the agent's filesystem profile, or select an allowed policy for the agent.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
checks.push(unknown("Codex returned an unsupported managed requirement"));
|
||||
}
|
||||
return combine(...checks);
|
||||
}
|
||||
|
||||
/** Positive conflicts refuse; unavailable evidence never replaces the authored
|
||||
* invocation or edits managed policy. Unknowns cannot mask a known conflict. */
|
||||
export function decideCodexFilesystemPolicy(request: {
|
||||
permissions: CodexFilesystemPermissions; configRead: unknown; requirementsRead: unknown;
|
||||
}): CodexFilesystemDecision {
|
||||
const { permissions, configRead, requirementsRead } = request;
|
||||
const requirements = compareRequirements(requirementsRead, permissions);
|
||||
if (!requirements.ok) return requirements;
|
||||
const checks: CodexFilesystemDecision[] = [requirements];
|
||||
if (!isRecord(configRead) || !isRecord(configRead.config)) return combine(...checks, unknown("Codex config/read did not return a recognized effective configuration"));
|
||||
const config = configRead.config;
|
||||
if (config.default_permissions == null || typeof config.default_permissions !== "string") checks.push(unknown("Codex permission selection is absent or has an unfamiliar representation"));
|
||||
else if (config.default_permissions !== CODEX_SEAT_PERMISSION_PROFILE) return refuse("Rule default_permissions: the agent selects its filesystem profile; the effective Codex configuration selects a different profile.",
|
||||
"Remove the conflicting account or trusted-project selection, or deliberately change the agent's codex_permissions.");
|
||||
if (typeof config.approval_policy !== "string" || !["never", "on-request", "untrusted"].includes(config.approval_policy)) checks.push(unknown("Codex approval selection is absent or unfamiliar"));
|
||||
else if (config.approval_policy !== permissions.approval) return refuse("Rule approval_policy: the effective Codex configuration conflicts with the agent's approval choice.",
|
||||
"Align the account, trusted-project or managed approval policy with the agent's intended approval choice.");
|
||||
if (config.permissions == null) return refuse("Rule permissions.openrig-seat: the agent selects a filesystem profile, but the effective Codex configuration has no profile mapping.",
|
||||
"Restore the selected profile in the effective Codex configuration before launching.");
|
||||
if (!isRecord(config.permissions)) return combine(...checks, unknown("Codex permission profiles have an unfamiliar representation"));
|
||||
const selected = config.permissions[CODEX_SEAT_PERMISSION_PROFILE];
|
||||
if (selected == null) return refuse("Rule permissions.openrig-seat: the agent selects a filesystem profile that is absent from the effective Codex configuration.",
|
||||
"Restore the selected profile in the effective Codex configuration before launching.");
|
||||
if (!isRecord(selected)) return combine(...checks, unknown("Codex selected profile has an unfamiliar representation"));
|
||||
if (!present(selected).length) return refuse("Rule permissions.openrig-seat: the agent selects filesystem read/write/deny rules, but the effective Codex profile is empty.",
|
||||
"Restore the selected profile's intended filesystem rules before launching.");
|
||||
const unfamiliarProfile = present(selected).some(([key]) => key !== "filesystem" && key !== "network" && key !== "description");
|
||||
if (unfamiliarProfile) checks.push(unknown("Codex returned an unfamiliar selected profile option"));
|
||||
if (!(unfamiliarProfile && selected.filesystem == null)) checks.push(compareFilesystem(selected.filesystem, permissions));
|
||||
const network = selected.network;
|
||||
if (!isRecord(network)) checks.push(unknown("Codex selected network policy could not be compared"));
|
||||
else {
|
||||
if (network.enabled === true && !permissions.network_access) return refuse("Rule network.enabled: the agent denies network access; the effective Codex profile allows it.",
|
||||
"Remove the conflicting lower-layer network grant, or deliberately enable network_access in the agent's codex_permissions.");
|
||||
if (typeof network.enabled !== "boolean") checks.push(unknown("Codex selected network access is unfamiliar"));
|
||||
if (present(network).some(([key]) => key !== "enabled")) checks.push(unknown("Codex returned unfamiliar network options"));
|
||||
}
|
||||
return combine(...checks);
|
||||
}
|
||||
|
||||
/** Pin the normal account home for this opt-in path so the reader and dispatch
|
||||
* do not accidentally use different pane-shell defaults. */
|
||||
export function resolveCodexFilesystemHome(home?: string, normalHome?: string): string | undefined {
|
||||
try {
|
||||
const resolved = home ?? process.env.CODEX_HOME ?? path.join(normalHome ?? os.homedir(), ".codex");
|
||||
return resolved && path.isAbsolute(resolved) && !/[\0\r\n]/.test(resolved) ? resolved : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
export async function verifyCodexFilesystemPolicy(request: {
|
||||
permissions: CodexFilesystemPermissions; cwd: string; codexHome?: string;
|
||||
launchPath?: string; launchWrapper?: readonly string[]; codexConfigProfile?: string;
|
||||
}): Promise<CodexFilesystemDecision> {
|
||||
if (request.launchWrapper?.length) return unknown("launch_wrapper may change the effective home or configuration; the unwrapped reader cannot certify it");
|
||||
// app-server does not expose this CLI profile layer on the supported path.
|
||||
// Keep -p on the actual invocation and leave enforcement to Codex.
|
||||
if (request.codexConfigProfile?.trim()) return unknown("the native reader cannot load the selected codex_config_profile");
|
||||
const codexHome = resolveCodexFilesystemHome(request.codexHome);
|
||||
if (!codexHome) return unknown("the actual Codex account home could not be resolved");
|
||||
try {
|
||||
const read = await readCodexConfiguration({ cwd: request.cwd, codexHome, launchPath: request.launchPath,
|
||||
args: ["-c", `approval_policy=${JSON.stringify(request.permissions.approval)}`,
|
||||
...codexFilesystemOverrides(request.permissions).flatMap(value => ["-c", value]),
|
||||
"-c", "features.plugins=false", "-c", "features.apps=false"] });
|
||||
if (!read.ok) return unknown(read.reason);
|
||||
return decideCodexFilesystemPolicy({ permissions: request.permissions, configRead: read.configRead, requirementsRead: read.requirementsRead });
|
||||
} catch { return unknown("Native Codex configuration reader failed"); }
|
||||
}
|
||||
|
||||
export type CodexFilesystemVerifier = typeof verifyCodexFilesystemPolicy;
|
||||
@@ -4,12 +4,14 @@ import { constants as fsConstants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { CodexPermissionEnvelope } from "./codex-rebuild-permissions.js";
|
||||
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { AppliedLaunchObservation } from "./permission-drift.js";
|
||||
|
||||
/** A Codex seat's own sandbox and approval choice, emitted as explicit flags on its managed launches and resumes.
|
||||
* Full access is not an envelope: it stays the full_bypass mode. Normalized, a workspace-write envelope states every
|
||||
* field the emitter sets, so nothing falls back to a profile's value for them. */
|
||||
export interface CodexMemberPermissions {
|
||||
export interface CodexSandboxPermissions {
|
||||
sandbox: "workspace-write" | "read-only";
|
||||
approval: "never" | "on-request" | "untrusted";
|
||||
network_access?: boolean;
|
||||
@@ -18,20 +20,60 @@ export interface CodexMemberPermissions {
|
||||
exclude_slash_tmp?: boolean;
|
||||
}
|
||||
|
||||
export type CodexFilesystemAccess = "read" | "write" | "deny";
|
||||
|
||||
/** An explicit native filesystem policy, mutually exclusive with the legacy sandbox fields. */
|
||||
export interface CodexFilesystemPermissions {
|
||||
filesystem: Record<string, CodexFilesystemAccess>;
|
||||
approval: Exclude<CodexSandboxPermissions["approval"], "untrusted">;
|
||||
network_access?: boolean;
|
||||
sandbox?: never;
|
||||
writable_roots?: never;
|
||||
exclude_tmpdir_env_var?: never;
|
||||
exclude_slash_tmp?: never;
|
||||
}
|
||||
|
||||
export type CodexMemberPermissions = CodexSandboxPermissions | CodexFilesystemPermissions;
|
||||
|
||||
const KEYS = ["sandbox", "approval", "network_access", "writable_roots", "exclude_tmpdir_env_var", "exclude_slash_tmp"];
|
||||
const WORKSPACE_ONLY = ["network_access", "writable_roots", "exclude_tmpdir_env_var", "exclude_slash_tmp"];
|
||||
const FILESYSTEM_KEYS = ["filesystem", "approval", "network_access"];
|
||||
const FILESYSTEM_SPECIAL_PATHS = new Set([":root", ":minimal", ":workspace_roots", ":project_roots", ":tmpdir", ":slash_tmp"]);
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
export function validateCodexMemberPermissions(value: unknown, label = "codex_permissions"): CodexMemberPermissions {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected a mapping with sandbox and approval`);
|
||||
const raw = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(raw)) if (!KEYS.includes(key)) throw new Error(`${label}.${key}: unsupported key; supported keys are ${KEYS.join(", ")}`);
|
||||
const sandbox = raw.sandbox;
|
||||
if (!isRecord(value)) throw new Error(`${label}: expected a mapping with sandbox or filesystem, and approval`);
|
||||
const raw = value;
|
||||
const keys = "filesystem" in raw ? FILESYSTEM_KEYS : KEYS;
|
||||
for (const key of Object.keys(raw)) if (!keys.includes(key)) throw new Error(`${label}.${key}: unsupported key; supported keys are ${keys.join(", ")}`);
|
||||
const approval = raw.approval;
|
||||
if (sandbox === "danger-full-access") throw new Error(`${label}.sandbox: full access is the full_bypass mode, not an envelope; select it with --mode full_bypass`);
|
||||
if (sandbox !== "workspace-write" && sandbox !== "read-only") throw new Error(`${label}.sandbox: expected workspace-write or read-only`);
|
||||
if (approval !== "never" && approval !== "on-request" && approval !== "untrusted") {
|
||||
throw new Error(`${label}.approval: expected never, on-request or untrusted`);
|
||||
}
|
||||
if ("filesystem" in raw) {
|
||||
if (approval === "untrusted") {
|
||||
throw new Error(`${label}.approval: untrusted is not supported with filesystem; expected never or on-request`);
|
||||
}
|
||||
if (!isRecord(raw.filesystem) || Object.keys(raw.filesystem).length === 0) {
|
||||
throw new Error(`${label}.filesystem: expected a nonempty mapping of paths to read, write or deny`);
|
||||
}
|
||||
const filesystem: CodexFilesystemPermissions["filesystem"] = {};
|
||||
for (const [path, access] of Object.entries(raw.filesystem)) {
|
||||
if (/[\0\r\n]/.test(path) || !(isAbsolute(path) || path.startsWith("~/") || FILESYSTEM_SPECIAL_PATHS.has(path))) {
|
||||
throw new Error(`${label}.filesystem: expected an absolute path, ~/path or a supported native special path; got ${JSON.stringify(path)}`);
|
||||
}
|
||||
if (access !== "read" && access !== "write" && access !== "deny") {
|
||||
throw new Error(`${label}.filesystem[${JSON.stringify(path)}]: expected read, write or deny`);
|
||||
}
|
||||
filesystem[path] = access;
|
||||
}
|
||||
if (raw.network_access !== undefined && typeof raw.network_access !== "boolean") throw new Error(`${label}.network_access: expected true or false`);
|
||||
return { approval, filesystem, network_access: raw.network_access === true };
|
||||
}
|
||||
const sandbox = raw.sandbox;
|
||||
if (sandbox === "danger-full-access") throw new Error(`${label}.sandbox: full access is the full_bypass mode, not an envelope; select it with --mode full_bypass`);
|
||||
if (sandbox !== "workspace-write" && sandbox !== "read-only") throw new Error(`${label}.sandbox: expected workspace-write or read-only`);
|
||||
if (sandbox === "read-only") {
|
||||
const extra = WORKSPACE_ONLY.find(key => raw[key] !== undefined);
|
||||
if (extra) throw new Error(`${label}.${extra}: only valid with sandbox workspace-write`);
|
||||
@@ -42,26 +84,48 @@ export function validateCodexMemberPermissions(value: unknown, label = "codex_pe
|
||||
}
|
||||
const roots = raw.writable_roots ?? [];
|
||||
if (!Array.isArray(roots)) throw new Error(`${label}.writable_roots: expected a list of absolute paths`);
|
||||
roots.forEach((root, index) => {
|
||||
const writableRoots: string[] = [];
|
||||
roots.forEach((root: unknown, index) => {
|
||||
if (typeof root !== "string" || !root.trim() || !isAbsolute(root) || /[\0\r\n]/.test(root)) {
|
||||
throw new Error(`${label}.writable_roots[${index}]: expected an absolute path without newlines`);
|
||||
}
|
||||
writableRoots.push(root);
|
||||
});
|
||||
return {
|
||||
sandbox: "workspace-write", approval,
|
||||
network_access: raw.network_access === true, writable_roots: [...roots] as string[],
|
||||
network_access: raw.network_access === true, writable_roots: writableRoots,
|
||||
exclude_tmpdir_env_var: raw.exclude_tmpdir_env_var === true, exclude_slash_tmp: raw.exclude_slash_tmp === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The native policy codexEnvelopeArgs emits for this choice. */
|
||||
export function codexMemberEnvelope(permissions: CodexMemberPermissions): CodexPermissionEnvelope {
|
||||
export function codexMemberEnvelope(permissions: CodexSandboxPermissions): CodexPermissionEnvelope {
|
||||
if (permissions.sandbox === "read-only") return { approvalPolicy: permissions.approval, sandboxPolicy: { type: "read-only" } };
|
||||
return { approvalPolicy: permissions.approval, sandboxPolicy: { type: "workspace-write",
|
||||
writable_roots: permissions.writable_roots ?? [], network_access: permissions.network_access === true,
|
||||
exclude_tmpdir_env_var: permissions.exclude_tmpdir_env_var === true, exclude_slash_tmp: permissions.exclude_slash_tmp === true } };
|
||||
}
|
||||
|
||||
/** Invocation-local profile: never written into the account home or the shared project. */
|
||||
export const CODEX_SEAT_PERMISSION_PROFILE = "openrig-seat";
|
||||
|
||||
export function codexFilesystemOverrides(permissions: CodexFilesystemPermissions): string[] {
|
||||
const entries = Object.entries(permissions.filesystem).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(",");
|
||||
return [`default_permissions=${JSON.stringify(CODEX_SEAT_PERMISSION_PROFILE)}`,
|
||||
`permissions={${JSON.stringify(CODEX_SEAT_PERMISSION_PROFILE)}={filesystem={${entries}},network={enabled=${permissions.network_access === true}}}}`];
|
||||
}
|
||||
|
||||
/** The same authored choice reaches launch, both resumes and the status plan. This reports argv, not enforcement. */
|
||||
export function codexMemberLaunch(permissions: CodexMemberPermissions): { args: string; observation: AppliedLaunchObservation; suppressAutoAddDirs: boolean } {
|
||||
if ("filesystem" in permissions) {
|
||||
return { args: ` -a ${permissions.approval}` + codexFilesystemOverrides(permissions).map(value => ` -c ${shellQuote(value)}`).join(""),
|
||||
observation: { runtime: "codex", axis: "sandbox", state: "observed", value: `permissions:${CODEX_SEAT_PERMISSION_PROFILE}`,
|
||||
reason: "emitted_launch_arguments", ...(permissions.approval === "never" ? { approvalPolicy: "never" } : {}) }, suppressAutoAddDirs: true };
|
||||
}
|
||||
const envelope = codexMemberEnvelope(permissions);
|
||||
return { args: codexEnvelopeArgs(envelope), observation: observeCodexEnvelope(envelope), suppressAutoAddDirs: permissions.sandbox === "read-only" };
|
||||
}
|
||||
|
||||
/** What an emitted envelope's arguments ask for; the native result still needs its own observation. */
|
||||
export function observeCodexEnvelope(envelope: CodexPermissionEnvelope): AppliedLaunchObservation {
|
||||
return { runtime: "codex", axis: "sandbox", state: "observed", value: envelope.sandboxPolicy.type,
|
||||
|
||||
@@ -122,57 +122,67 @@ export const CODEX_NETWORK_READER_DEADLINE_MS = 4_000;
|
||||
|
||||
/** The production reader: asynchronous, so the daemon keeps serving while Codex starts. */
|
||||
export function codexNetworkDefaultReader(options: CodexNetworkReaderOptions = {}): CodexNetworkDefaultReader {
|
||||
const deadlineMs = options.deadlineMs ?? CODEX_NETWORK_READER_DEADLINE_MS;
|
||||
const graceMs = options.graceMs ?? 500;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? 4 * 1024 * 1024;
|
||||
return async (cwd, seatHome) => {
|
||||
const codexHome = seatHome ?? options.codexHome;
|
||||
const started = performance.now();
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.launchPath ? { PATH: options.launchPath } : {}),
|
||||
...(options.home ? { HOME: options.home } : {}),
|
||||
...(codexHome ? { CODEX_HOME: codexHome } : {}),
|
||||
};
|
||||
// The launch flag `-s workspace-write` is `sandbox_mode` for the server; plugins and apps are
|
||||
// switched off for the reader only. app-server serves stdio by default.
|
||||
const args = ["-c", 'sandbox_mode="workspace-write"', "-c", "features.plugins=false", "-c", "features.apps=false", "app-server"];
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
// Its own process group, so cleanup reaches the reader's descendants and nothing else.
|
||||
child = spawn("codex", args, { cwd, env, stdio: ["pipe", "pipe", "ignore"], detached: true });
|
||||
} catch (error) {
|
||||
return { ...no(`codex app-server could not start: ${firstLine(error)}`), elapsedMs: Math.round(performance.now() - started) };
|
||||
}
|
||||
const decision = await converse(child, cwd, deadlineMs, maxOutputBytes);
|
||||
await stopGroup(child, graceMs);
|
||||
const result = await readCodexConfiguration({ ...options, cwd, codexHome: seatHome ?? options.codexHome,
|
||||
args: ["-c", 'sandbox_mode="workspace-write"', "-c", "features.plugins=false", "-c", "features.apps=false"] });
|
||||
const decision = result.ok ? decideCodexNetworkDefault(result.configRead, result.requirementsRead) : no(result.reason);
|
||||
return { ...decision, elapsedMs: Math.round(performance.now() - started) };
|
||||
};
|
||||
}
|
||||
|
||||
function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: number, maxOutputBytes: number): Promise<CodexNetworkDecision> {
|
||||
export type CodexConfigurationRead = { ok: true; configRead: unknown; requirementsRead: unknown } | { ok: false; reason: string };
|
||||
const unread = (reason: string): CodexConfigurationRead => ({ ok: false, reason });
|
||||
|
||||
/** One native config read with the caller's launch overrides. No thread, turn or persistent server. */
|
||||
export async function readCodexConfiguration(options: CodexNetworkReaderOptions & { cwd: string; args: readonly string[] }): Promise<CodexConfigurationRead> {
|
||||
const deadlineMs = options.deadlineMs ?? CODEX_NETWORK_READER_DEADLINE_MS;
|
||||
const graceMs = options.graceMs ?? 500;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? 4 * 1024 * 1024;
|
||||
const { cwd, codexHome } = options;
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.launchPath ? { PATH: options.launchPath } : {}),
|
||||
...(options.home ? { HOME: options.home } : {}),
|
||||
...(codexHome ? { CODEX_HOME: codexHome } : {}),
|
||||
};
|
||||
// app-server serves stdio by default. The caller states the same permission overrides as its launch.
|
||||
const args = [...options.args, "app-server"];
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
// Its own process group, so cleanup reaches the reader's descendants and nothing else.
|
||||
child = spawn("codex", args, { cwd, env, stdio: ["pipe", "pipe", "ignore"], detached: true });
|
||||
} catch (error) {
|
||||
return unread(`codex app-server could not start: ${firstLine(error)}`);
|
||||
}
|
||||
const decision = await converse(child, cwd, deadlineMs, maxOutputBytes);
|
||||
await stopGroup(child, graceMs);
|
||||
return decision;
|
||||
}
|
||||
|
||||
function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: number, maxOutputBytes: number): Promise<CodexConfigurationRead> {
|
||||
return new Promise((resolve) => {
|
||||
const answers = new Map<number, Record<string, unknown>>();
|
||||
let buffered = "";
|
||||
let received = 0;
|
||||
let settled = false;
|
||||
const finish = (decision: CodexNetworkDecision) => {
|
||||
const finish = (decision: CodexConfigurationRead) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(deadline);
|
||||
resolve(decision);
|
||||
};
|
||||
const deadline = setTimeout(() => finish(no(`codex app-server did not answer within ${deadlineMs} ms`)), deadlineMs);
|
||||
const deadline = setTimeout(() => finish(unread(`codex app-server did not answer within ${deadlineMs} ms`)), deadlineMs);
|
||||
const send = (message: Record<string, unknown>) => {
|
||||
if (child.stdin?.writable) child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
};
|
||||
child.on("error", (error) => finish(no(`codex app-server could not start: ${firstLine(error)}`)));
|
||||
child.on("exit", () => finish(no("codex app-server exited before answering")));
|
||||
child.stdin?.on("error", () => finish(no("codex app-server closed its input before answering")));
|
||||
child.on("error", (error) => finish(unread(`codex app-server could not start: ${firstLine(error)}`)));
|
||||
child.on("exit", () => finish(unread("codex app-server exited before answering")));
|
||||
child.stdin?.on("error", () => finish(unread("codex app-server closed its input before answering")));
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk: string) => {
|
||||
received += Buffer.byteLength(chunk);
|
||||
if (received > maxOutputBytes) return finish(no("codex app-server answered with more output than expected"));
|
||||
if (received > maxOutputBytes) return finish(unread("codex app-server answered with more output than expected"));
|
||||
buffered += chunk;
|
||||
let newline: number;
|
||||
while ((newline = buffered.indexOf("\n")) >= 0) {
|
||||
@@ -183,7 +193,7 @@ function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: numb
|
||||
try { message = JSON.parse(line); } catch { continue; }
|
||||
// Only responses to our own requests: notifications and server requests carry a method.
|
||||
if (!isRecord(message) || typeof message.id !== "number" || "method" in message) continue;
|
||||
if (message.error !== undefined) return finish(no(`codex app-server returned an error for request ${message.id}`));
|
||||
if (message.error !== undefined) return finish(unread(`codex app-server returned an error for request ${message.id}`));
|
||||
if (message.id === 1) {
|
||||
send({ method: "initialized", params: {} });
|
||||
send({ id: 2, method: "config/read", params: { includeLayers: true, cwd } });
|
||||
@@ -192,7 +202,7 @@ function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: numb
|
||||
}
|
||||
answers.set(message.id, message);
|
||||
if (answers.has(2) && answers.has(3)) {
|
||||
return finish(decideCodexNetworkDefault(answers.get(2)!.result, answers.get(3)!.result));
|
||||
return finish({ ok: true, configRead: answers.get(2)?.result, requirementsRead: answers.get(3)?.result });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,10 +79,14 @@ export function codexLegacyPluginTrustPath(configPath: string): string {
|
||||
interface OwnedApproval { schema: 1; plugin: string; contentHash: string; declarations: Record<string, string> }
|
||||
export const ownedCodexApprovalPath = (configPath: string) => path.join(path.dirname(configPath), ".openrig-core-hook-trust.json");
|
||||
|
||||
/** No native baseline is changed. Existing declarations must already be approved; only content approval may advance. */
|
||||
/**
|
||||
* No native baseline is changed. Content approval advances for approved declarations. A declaration changed by a new
|
||||
* shipped bundle is renewed only from OpenRig's own record (#17): every approval must still equal the recorded
|
||||
* declaration and content. `trustedHashes` names the declarations to rewrite; `hashes` the content approvals.
|
||||
*/
|
||||
export function planOwnedCodexContentApproval(input: {
|
||||
configPath: string; content: string; layers: CodexConfigLayerRead[]; shipped: string; installed: string; freshGrant?: boolean;
|
||||
}): { hashes: Record<string, string>; approval: OwnedApproval } | undefined {
|
||||
}): { hashes: Record<string, string>; trustedHashes: Record<string, string>; approval: OwnedApproval } | undefined {
|
||||
try {
|
||||
if (codexPluginContentHash(input.shipped, true) !== codexPluginContentHash(input.installed, true)) return;
|
||||
// The trees just matched byte for byte, so approve the shipped bytes rather than a second read of the cache.
|
||||
@@ -111,26 +115,41 @@ export function planOwnedCodexContentApproval(input: {
|
||||
const states = doc.hooks?.state ?? {};
|
||||
for (const [key, hash] of Object.entries(declarations)) {
|
||||
const state = states[key];
|
||||
if (!state || (state.enabled !== undefined && state.enabled !== true) || state.trusted_hash !== hash
|
||||
if (!state || (state.enabled !== undefined && state.enabled !== true) || typeof state.trusted_hash !== "string"
|
||||
|| Object.keys(state).some(field => !["enabled", "trusted_hash", "trusted_content_hash"].includes(field))
|
||||
|| findTomlTable(input.content.split("\n"), ["hooks", "state", key]) < 0
|
||||
|| docs.some(layer => otherChoice(layer.doc, key, layer.path !== input.configPath))) return;
|
||||
}
|
||||
const approval: OwnedApproval = { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY, contentHash, declarations };
|
||||
const hashes = Object.fromEntries(Object.keys(declarations).map(key => [key, contentHash]));
|
||||
if (input.freshGrant) return { hashes, approval };
|
||||
if (Object.keys(declarations).every(key => states[key].trusted_content_hash === contentHash)) return { hashes: {}, approval };
|
||||
const changed = Object.entries(declarations).filter(([key, hash]) => states[key].trusted_hash !== hash);
|
||||
const parseRecord = (saved: string): OwnedApproval | undefined => {
|
||||
const previous: OwnedApproval = JSON.parse(saved);
|
||||
return previous.schema === 1 && previous.plugin === CODEX_CORE_PLUGIN_KEY && /^sha256:[a-f0-9]{64}$/.test(previous.contentHash)
|
||||
&& Object.keys(previous.declarations).length === 6 ? previous : undefined;
|
||||
};
|
||||
if (changed.length > 0) {
|
||||
// Only OpenRig's own saved record proves these approvals are its earlier grant; the legacy fallback below doesn't.
|
||||
const saved = readRecord(ownedCodexApprovalPath(input.configPath));
|
||||
const previous = saved === undefined ? undefined : parseRecord(saved);
|
||||
if (input.freshGrant || !previous) return;
|
||||
for (const key of Object.keys(declarations)) {
|
||||
if (states[key].trusted_hash !== previous.declarations[key] || states[key].trusted_content_hash !== previous.contentHash) return;
|
||||
}
|
||||
return { hashes, trustedHashes: Object.fromEntries(changed), approval };
|
||||
}
|
||||
if (input.freshGrant) return { hashes, trustedHashes: {}, approval };
|
||||
if (Object.keys(declarations).every(key => states[key].trusted_content_hash === contentHash)) return { hashes: {}, trustedHashes: {}, approval };
|
||||
const baseline = readRecord(codexLegacyPluginTrustPath(input.configPath));
|
||||
if (Object.keys(declarations).every(key => (states[key].trusted_content_hash ?? baseline) === contentHash)) return { hashes, approval };
|
||||
if (Object.keys(declarations).every(key => (states[key].trusted_content_hash ?? baseline) === contentHash)) return { hashes, trustedHashes: {}, approval };
|
||||
const saved = readRecord(ownedCodexApprovalPath(input.configPath));
|
||||
const previous: OwnedApproval = saved === undefined ? { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY,
|
||||
contentHash: LEGACY_CONTENT, declarations: legacyDeclarations } : JSON.parse(saved);
|
||||
if (previous.schema !== 1 || previous.plugin !== CODEX_CORE_PLUGIN_KEY || !/^sha256:[a-f0-9]{64}$/.test(previous.contentHash)
|
||||
|| Object.keys(previous.declarations).length !== 6) return;
|
||||
const previous = saved === undefined ? { schema: 1 as const, plugin: CODEX_CORE_PLUGIN_KEY,
|
||||
contentHash: LEGACY_CONTENT, declarations: legacyDeclarations } : parseRecord(saved);
|
||||
if (!previous) return;
|
||||
for (const [key, hash] of Object.entries(declarations)) {
|
||||
if (previous.declarations[key] !== hash || (states[key].trusted_content_hash ?? baseline) !== previous.contentHash) return;
|
||||
}
|
||||
return { hashes, approval };
|
||||
return { hashes, trustedHashes: {}, approval };
|
||||
} catch { return undefined; } // Unknown ownership or config leaves native review in place.
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
// specimen class) is gateway-routable: a PANELLESS virtual identity whose
|
||||
// delivery rides the gateway, exactly like its @external address.
|
||||
// 4. A hosted member whose roster entry declares a trigger is hosted. One with no
|
||||
// trigger stays unroutable, so its person is told; its teaching says it reads the
|
||||
// queue when its owner next starts a session, not to fix the address.
|
||||
// trigger stays unroutable, so its person is told; its teaching says it picks up its
|
||||
// work when its person asks it to or on a schedule they set, not to fix the address.
|
||||
// 5. Anything else is unroutable, with teaching that names BOTH checks.
|
||||
import { parseSessionName } from "../session-name.js";
|
||||
import type { RosterWake } from "../rosters.js";
|
||||
@@ -77,7 +77,7 @@ export function classifyDestination(destination: string, deps: ClassifyDeps): De
|
||||
return {
|
||||
class: "unroutable",
|
||||
teaching: `unroutable: '${destination}' is a hosted member with no wake trigger, so nothing can push work to it. ` +
|
||||
`It reads the queue when its owner next starts a session.`,
|
||||
`It picks up its work when its person asks it to, or on a schedule they set.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface SlackConnectorConfig {
|
||||
* "off" = reactions are ignored, so an acknowledging emoji costs no agent turn. Not in DEFAULT_CONFIG, like `channelMap`.
|
||||
*/
|
||||
reactions?: SlackReactionsMode;
|
||||
/** Link and media previews on human-row posts; absent defaults to off. */
|
||||
unfurl?: "on" | "off";
|
||||
}
|
||||
|
||||
export const SLACK_REACTIONS_MODES = ["route", "off"] as const;
|
||||
@@ -119,6 +121,9 @@ function validateConfig(cfg: SlackConnectorConfig, opts: { reading?: boolean } =
|
||||
if (!opts.reading && cfg.reactions !== undefined && !SLACK_REACTIONS_MODES.includes(cfg.reactions)) {
|
||||
throw new Error(`reactions must be one of ${SLACK_REACTIONS_MODES.join(", ")} (got ${String(cfg.reactions)})`);
|
||||
}
|
||||
if (!opts.reading && cfg.unfurl !== undefined && cfg.unfurl !== "on" && cfg.unfurl !== "off") {
|
||||
throw new Error(`unfurl must be on or off (got ${String(cfg.unfurl)})`);
|
||||
}
|
||||
}
|
||||
|
||||
export function configPathFor(home?: string): string {
|
||||
@@ -176,6 +181,7 @@ export function staticReadiness(cfg: SlackConnectorConfig, hasBotToken: boolean,
|
||||
{ ok: Boolean(cfg.inboundDestination), label: "inbound-destination",
|
||||
detail: cfg.inboundDestination === INHERIT_INBOUND_DESTINATION ? `${INHERIT_INBOUND_DESTINATION} (the host's default receiving agent)` : cfg.inboundDestination },
|
||||
{ ok: cfg.enabled, label: "enabled", detail: cfg.enabled ? "yes" : "no (run `rig slack enable`)" },
|
||||
{ ok: true, label: "human-row previews", detail: cfg.unfurl === "on" ? "on" : "off" },
|
||||
...channelMapReadiness(cfg),
|
||||
...botInboxReadiness(cfg),
|
||||
...forwardReadiness(cfg),
|
||||
|
||||
@@ -199,10 +199,13 @@ export interface InboundDeps {
|
||||
/** S10 thread routing (deterministic, zero inference): resolve the destination + tags for an
|
||||
* admitted event. Absent → every event lands on the static `destination` (the pre-routing
|
||||
* shape, and the fallback the tests pin). */
|
||||
resolveRoute?: (ev: SlackEvent) => { destination: string; tags?: string[]; correlationQitemId?: string; routeClass?: string };
|
||||
resolveRoute?: (ev: SlackEvent) => { destination: string; tags?: string[]; correlationQitemId?: string; arrivalThread?: { threadTs: string; channel: string }; routeClass?: string };
|
||||
/** Open a thread root for a person's top-level message (its own ts), so the seat's
|
||||
* `--reply-to` reply and later updates land under it. Absent: nothing is minted. */
|
||||
mintThread?: (m: { threadTs: string; channel: string; human: string; seat: string; conversationId: string }) => void;
|
||||
/** Record the thread a reply arrived in without correlating it to a human gate, so the seat's
|
||||
* `--reply-to` answer returns there. Absent: nothing is recorded. */
|
||||
recordArrivalThread?: (input: { qitemId: string; threadTs: string; channel: string }) => void;
|
||||
/** Continue an exact human gate through the existing Mission Control resolve primitive. */
|
||||
/** `typedReply`: a typed thread reply, whose words a decision parked on its person stores as its answer; a click's return never does. */
|
||||
resolveHumanReply?: (input: { qitemId: string; actorSession: string; decision: string; messageTs?: string; typedReply?: boolean }) => Promise<"resolved" | "resolved-uncertain" | "already-resolved" | "not-applicable">;
|
||||
@@ -481,6 +484,9 @@ export class InboundRouter {
|
||||
this.deps.log?.(`thread root not recorded for ${qitemId} (ts=${ts}): ${(e as Error).message}; its reply will post top-level`);
|
||||
}
|
||||
}
|
||||
if (!replayedAnswer && route.arrivalThread) {
|
||||
this.deps.recordArrivalThread?.({ qitemId, threadTs: route.arrivalThread.threadTs, channel: route.arrivalThread.channel });
|
||||
}
|
||||
this.deps.seen.mark(eventId, "landed"); // durable qitem exists → safe to mark
|
||||
this.deps.log?.(`qitem ${qitemId} -> ${route.destination} (ts=${ts})`);
|
||||
return { landed: true, qitemId, correlationQitemId: route.correlationQitemId, replyResolution };
|
||||
|
||||
@@ -474,6 +474,8 @@ export interface PostChatMessageInput {
|
||||
blocks?: unknown[];
|
||||
/** Thread reply: the PARENT message's ts (never a reply's ts — the affordance discriminator). */
|
||||
thread_ts?: string;
|
||||
unfurl_links?: boolean;
|
||||
unfurl_media?: boolean;
|
||||
}
|
||||
|
||||
export type PostChatMessageResult =
|
||||
@@ -505,6 +507,8 @@ export async function postChatMessage(
|
||||
const body: Record<string, unknown> = { channel: input.channel, text: input.text };
|
||||
if (input.blocks?.length) body.blocks = input.blocks;
|
||||
if (input.thread_ts) body.thread_ts = input.thread_ts;
|
||||
if (input.unfurl_links !== undefined) body.unfurl_links = input.unfurl_links;
|
||||
if (input.unfurl_media !== undefined) body.unfurl_media = input.unfurl_media;
|
||||
let r = await callWebApi("chat.postMessage", token, body, fetchImpl, timeoutMs);
|
||||
if (!r.ok && r.status === 429) {
|
||||
const waitMs = (r.retryAfterSeconds ?? 0) * 1000;
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface SubsystemSlackDeliveryOpts {
|
||||
* (no map) nothing is recorded and the channel is resolved as before. */
|
||||
pinChannel?: boolean;
|
||||
sourceLabel: string; // host/box/rig — from config, never hardcoded (item 7)
|
||||
/** Human-row previews; absent defaults off. */
|
||||
unfurl?: "on" | "off";
|
||||
/** This daemon's machine name, read per post, shown after a seat sender (message.ts `machine`). */
|
||||
machine?: () => string | null;
|
||||
bodyExcerpt?: number;
|
||||
@@ -332,7 +334,8 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
|
||||
};
|
||||
const postInput = (possibleRepeat = false) => {
|
||||
const shown = render(possibleRepeat, q.stateUnconfirmed === true, q.quietUnreadable === true);
|
||||
return { channel, text: shown.text, blocks: shown.blocks, thread_ts: threadTs };
|
||||
const unfurl = opts.unfurl === "on";
|
||||
return { channel, text: shown.text, blocks: shown.blocks, thread_ts: threadTs, unfurl_links: unfurl, unfurl_media: unfurl };
|
||||
};
|
||||
const payloadHash = () => createHash("sha256").update(JSON.stringify({ channel, text: render().text, blocks: render().blocks, thread_ts: threadTs })).digest("hex");
|
||||
const failurePrefix = `${decision.decisionId}${TRANSPORT_FAILURE_RECEIPT_PREFIX}`;
|
||||
|
||||
@@ -44,7 +44,7 @@ import { HUMAN_ALERT_TAG, loadHumanRegistry, resolveSlackHandle } from "../human
|
||||
import { sharedChannelAddresses, sharedChannelFor } from "../participants.js";
|
||||
import { hasLiveHumanGate, isBlockerLive, type QueueRepository } from "../../queue-repository.js";
|
||||
import { humanAnswerText } from "../../human-questions.js";
|
||||
import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
|
||||
import { formatReplyToChoice, formatArrivalThread, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
|
||||
import { parseSessionName } from "../../session-name.js";
|
||||
import type { FetchImpl } from "./slack-api.js";
|
||||
import {
|
||||
@@ -544,7 +544,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
if (root.channel !== channel) return { kind: "fallback", reason: "root-other-channel", threadTs: root.threadTs };
|
||||
return { kind: "thread", threadTs: root.threadTs };
|
||||
}
|
||||
if (!item.replyTo) return { kind: "fallback", reason: "root-missing", qitemId: item.qitemId };
|
||||
if (!item.replyTo) {
|
||||
// #2990: this reply has no gate correlation, but its arrival thread was recorded when
|
||||
// it landed. Its answer returns there; the conversation's gate still recognizes only
|
||||
// the newest root. Consult this record only at the end of the replyTo chain.
|
||||
const arrival = opts.queueRepo.arrivalThreadFor(item.qitemId);
|
||||
const root = arrival ? threadMap.resolveByThread(arrival.threadTs, arrival.channel) : null;
|
||||
if (root) {
|
||||
if (root.state === "closed") return { kind: "fallback", reason: "root-closed", threadTs: root.threadTs };
|
||||
if (root.human !== (p.destinationSession ?? "")) return { kind: "fallback", reason: "root-other-human", threadTs: root.threadTs };
|
||||
if (root.seat !== (p.sourceSession ?? "")) return { kind: "fallback", reason: "root-other-seat", threadTs: root.threadTs };
|
||||
if (root.channel !== channel) return { kind: "fallback", reason: "root-other-channel", threadTs: root.threadTs };
|
||||
return { kind: "thread", threadTs: root.threadTs };
|
||||
}
|
||||
return { kind: "fallback", reason: "root-missing", qitemId: item.qitemId };
|
||||
}
|
||||
item = opts.queueRepo.getById(item.replyTo);
|
||||
}
|
||||
return item
|
||||
@@ -558,14 +572,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
// person, a closed root, a live gate) posts top-level in the replying seat's own channel, as before.
|
||||
const personRootChannel = (p: OutboundPostPayload): string | null => {
|
||||
let item = p.replyTo ? opts.queueRepo.getById(p.replyTo) : null;
|
||||
let lastItem = item;
|
||||
for (let depth = 0; item && depth < MAX_REPLY_TO_CHAIN; depth++) {
|
||||
const root = threadMap.resolveByConversation(item.qitemId);
|
||||
if (root) {
|
||||
if (!item.tags?.includes(SLACK_THREAD_ROOT_TAG)) return null;
|
||||
return deriveReplyToChoice(p, root.channel).kind === "thread" ? root.channel : null;
|
||||
}
|
||||
lastItem = item;
|
||||
item = item.replyTo ? opts.queueRepo.getById(item.replyTo) : null;
|
||||
}
|
||||
// #2990: A reply in a superseded person-owned root uses that root's channel when a channel map would otherwise move it.
|
||||
const arrival = !item && lastItem ? opts.queueRepo.arrivalThreadFor(lastItem.qitemId) : null;
|
||||
const root = arrival ? threadMap.resolveByThread(arrival.threadTs, arrival.channel) : null;
|
||||
const owner = root ? opts.queueRepo.getById(root.conversationId) : null;
|
||||
if (root && owner?.tags?.includes(SLACK_THREAD_ROOT_TAG) && deriveReplyToChoice(p, root.channel).kind === "thread") return root.channel;
|
||||
return null;
|
||||
};
|
||||
// Decided ONCE and recorded on the row before the first post. Every later attempt reuses
|
||||
@@ -617,6 +638,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
},
|
||||
pinChannel: Boolean(cfg.channelMap?.length),
|
||||
sourceLabel: cfg.sourceLabel,
|
||||
unfurl: cfg.unfurl === "on" ? "on" : "off",
|
||||
// P6: each post names the machine it comes from, the daemon's own host id.
|
||||
machine: () => getSelfHostId(),
|
||||
fetchImpl: opts.fetchImpl,
|
||||
@@ -1392,6 +1414,13 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
log(`thread stamp failed for ${m.conversationId}: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
recordArrivalThread: (input) => {
|
||||
try {
|
||||
opts.queueRepo.update({ qitemId: input.qitemId, actorSession: REPLY_TO_CHOICE_ACTOR, transitionNote: formatArrivalThread(input) });
|
||||
} catch (e) {
|
||||
log(`arrival thread record failed for ${input.qitemId}: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
resolveHumanReply: opts.resolveHumanReply,
|
||||
// #193 — a button click records its answer on the decision the clicked root belongs to;
|
||||
// a failed hand-back is retried with the event dead-letters, and each click is confirmed
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface InboundRoute {
|
||||
tags: string[];
|
||||
/** Exact human-gate qitem this thread was opened for. Absent on unmapped traffic. */
|
||||
correlationQitemId?: string;
|
||||
/** The thread a reply arrived in when its superseded root no longer correlates to the gate. */
|
||||
arrivalThread?: { threadTs: string; channel: string };
|
||||
/** The routing class that fired — receipts per class ride the row tags + logs. */
|
||||
routeClass: "existing-thread" | "closed-thread" | "unmapped-thread" | "human-initiated";
|
||||
}
|
||||
@@ -77,7 +79,7 @@ export function makeThreadRouteResolver(opts: {
|
||||
return {
|
||||
destination: mapping.seat,
|
||||
tags: [...BASE_TAGS, "thread", `reply-to:${mapping.conversationId}`],
|
||||
...(current ? { correlationQitemId: mapping.conversationId } : {}),
|
||||
...(current ? { correlationQitemId: mapping.conversationId } : { arrivalThread: { threadTs, channel: mapping.channel } }),
|
||||
routeClass,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { readHealthArtifact, healthSelectedContext } from "./health-context.js";
|
||||
import { validateMissionComposition } from "./lifecycle-manifest.js";
|
||||
import { healthHash, type HealthPolicyStore } from "./health-policy.js";
|
||||
import type { HealthDetectorObservation, HealthObservationSource, HealthSourceCoverage } from "./health-detectors.js";
|
||||
import { isOpenRigSender } from "../lib/pane-envelope.js";
|
||||
import { adaptQueueTransitionEvidence, boundHealthEvidence, deriveHealthSourceFreshness, healthEpisodeId, type HealthScope, type PassiveCeremony, type CeremonyProgressAssessment } from "./health-projection.js";
|
||||
|
||||
const detector = "process.ceremony-amplification";
|
||||
@@ -22,9 +23,13 @@ const tagged = (tags: string[], prefix: string) => tags.filter((t) => t.startsWi
|
||||
* parked-owner wakes (queue-repository.ts, policies/parked-owner-consumer.ts) are excluded under any actor. */
|
||||
const DAEMON_BOOKKEEPING_NOTE_PREFIXES = ["auto-unparked:", "blocker custody moved ", "park wake ", "parked-owner "] as const;
|
||||
|
||||
/** The daemon's own actors: `*@system` (watchdog, queue, stuck-sweep, wake-ladder, claim, ...) and the
|
||||
* bare `system` that releases or transfers claims when a seat's occupant changes (queue-repository.ts). */
|
||||
const isSystemActor = (actor: string) => actor === "system" || actor.endsWith("@system");
|
||||
/** The daemon's own actors: `*@system` (watchdog, queue, stuck-sweep, wake-ladder, claim, ...), the
|
||||
* bare `system` that releases or transfers claims when a seat's occupant changes (queue-repository.ts),
|
||||
* and OpenRig's own sender (`isOpenRigSender`): the exact `daemon@kernel` (wake escalations, stream intake,
|
||||
* the Slack gateway, quiet-release, delivery-deferral, ...) and every `system:*` actor (queue fallback
|
||||
* routing, compaction restore notices, health, ...). Other kernel seats (`human@kernel`,
|
||||
* `advisor-lead@kernel`) are seats and still count. */
|
||||
const isSystemActor = (actor: string) => actor === "system" || actor.endsWith("@system") || isOpenRigSender(actor);
|
||||
|
||||
/** #2762: a coordination transition is one a seat decided; system actors and daemon bookkeeping notes are not. */
|
||||
function isCoordinationTransition(t: { actorSession: string; transitionNote: string | null }): boolean {
|
||||
@@ -85,10 +90,11 @@ export class PassiveCeremonySource implements HealthObservationSource {
|
||||
if (!row) throw new Error("health_passive_lineage_parent_unavailable");
|
||||
}
|
||||
}
|
||||
// Past the limit, evaluate the busiest families instead of none. A family qualifies
|
||||
// by its transition count, so ranking by transitions in the window (then lineage ID,
|
||||
// for a stable order) keeps every family that could qualify unless more than the
|
||||
// limit do. The rest are reported as omitted: unevaluated, never healthy.
|
||||
// Past the limit, evaluate the busiest families instead of none. The ranking counts every
|
||||
// transition in the window, daemon bookkeeping included (then lineage ID, for a stable order).
|
||||
// That raw count bounds the counted transitions a family qualifies by, so a family that could
|
||||
// qualify is left out only when more than the limit reach the threshold on raw traffic.
|
||||
// The rest are reported as omitted: unevaluated, never healthy.
|
||||
const selectedFamilies = new Set([...roots.keys()]
|
||||
.sort((a, b) => activity.get(b)! - activity.get(a)! || (a < b ? -1 : a > b ? 1 : 0))
|
||||
.slice(0, familyLimit));
|
||||
|
||||
@@ -110,9 +110,12 @@ export function buildCodexResumeDisplay(
|
||||
const profile = input.codexConfigProfile ?? null;
|
||||
const profileArg = profile ? ` -p ${shellQuote(profile)}` : "";
|
||||
const effectivePosture = input.resolvedPosture ?? "floor";
|
||||
const envelope = input.codexPermissions ? codexMemberEnvelope(input.codexPermissions) : undefined;
|
||||
const permissions = input.codexPermissions;
|
||||
const hasFilesystemPolicy = permissions !== undefined && "filesystem" in permissions;
|
||||
const envelope = permissions && !("filesystem" in permissions) ? codexMemberEnvelope(permissions) : undefined;
|
||||
// Derive displayed flags from the same three inputs adapters/codex-resume.ts uses (member permission envelope,
|
||||
// resolved posture with an empty env so ambient OPENRIG_YOLO never leaks, and operationalLaunchArg).
|
||||
// A filesystem choice currently uses the posture fallback here; label that omission below.
|
||||
const posture = envelope ? profileArg + codexEnvelopeArgs(envelope) : codexPostureArg(profileArg, {}, effectivePosture);
|
||||
const postureArg = posture + operationalLaunchArg(
|
||||
"codex",
|
||||
@@ -163,6 +166,17 @@ export function buildCodexResumeDisplay(
|
||||
&& recorded.state === "observed"
|
||||
&& recorded.value !== null;
|
||||
|
||||
if (hasFilesystemPolicy) {
|
||||
return {
|
||||
command,
|
||||
status: hasObservedRecord ? "recorded_uncertain" : "no_recorded_launch",
|
||||
label: composeCodexResumeLabel(
|
||||
`shown command uses posture fallback ${postureArg.trim()}; does not carry the agent's filesystem policy (managed resume carries it)`,
|
||||
hasLaunchWrapper,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!envelope && input.emptySavedPosture) {
|
||||
const fallbackClause = `saved posture empty, showing fallback ${currentFlags} (managed resume posture unknown)`;
|
||||
if (!hasObservedRecord) {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { NativePermissionStore, type StoredNativePermissionSelection } from "./native-permission-store.js";
|
||||
import { claudePostureFlag, codexPostureArg, planModelAliasNotice, yoloEnabled } from "../adapters/yolo-mode.js";
|
||||
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
|
||||
import { codexMemberEnvelope } from "./codex-member-permissions.js";
|
||||
import { codexMemberLaunch } from "./codex-member-permissions.js";
|
||||
import { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } from "../adapters/kernel-authority.js";
|
||||
import { RigRepository } from "./rig-repository.js";
|
||||
import type { NodeBinding } from "./runtime-adapter.js";
|
||||
@@ -94,7 +93,7 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
|
||||
// A Codex seat's envelope replaces the posture decision with its own flags, as the launch does.
|
||||
const envelope = runtime === "codex" ? planned.codexPermissions : undefined;
|
||||
const nativeArgument = runtime === "claude-code" ? claudePostureFlag(env, posture, mode)
|
||||
: envelope ? `${profile ? ` -p ${profile}` : ""}${codexEnvelopeArgs(codexMemberEnvelope(envelope))}`.trim()
|
||||
: envelope ? `${profile ? ` -p ${profile}` : ""}${codexMemberLaunch(envelope).args}`.trim()
|
||||
: codexPostureArg(profile ? ` -p ${profile}` : "", env, posture).trim();
|
||||
const openRigAdds: PermissionAccount["openRigAdds"] = { claudeAllow: [], claudeAsk: [], notes: [] };
|
||||
const unknown = [NOT_READ, UNVERIFIED,
|
||||
@@ -119,7 +118,11 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
|
||||
controls.push(`rig seat set-permissions ${seat} --rules <file.json> --reason "<why>"`);
|
||||
}
|
||||
if (envelope) {
|
||||
openRigAdds.notes.push(envelope.sandbox === "workspace-write"
|
||||
if ("filesystem" in envelope) openRigAdds.notes.push(
|
||||
"Invocation-local filesystem policy; OpenRig adds no writable directories or network grant. No shared policy file is written.",
|
||||
"Each launch reads native configuration and refuses a legacy sandbox or a merged policy mismatch. A named codex_config_profile is unsupported with this choice.",
|
||||
"This status describes the saved plan; native enforcement is not observed here.");
|
||||
else openRigAdds.notes.push(envelope.sandbox === "workspace-write"
|
||||
? "Extra writable roots for OpenRig's queue state, and git metadata folders on a fresh launch, beside the envelope's own roots."
|
||||
: "No extra writable roots: the envelope is read-only.",
|
||||
`Network access ${envelope.sandbox === "workspace-write" && envelope.network_access ? "on" : "off"}, as the envelope states; OpenRig adds no network setting of its own.`,
|
||||
|
||||
@@ -268,7 +268,8 @@ export function diagnoseRuntimePosture(input: {
|
||||
const launchArguments = applied?.state === "observed" && applied.runtime === input.runtime
|
||||
&& (input.runtime === "claude-code" || input.runtime === "codex");
|
||||
const enforcement = launchArguments
|
||||
? unknownEnforcement(applied.axis, applied.value, null, "native_permission_effect_unverified")
|
||||
? unknownEnforcement(applied.axis, applied.value, null,
|
||||
applied.runtime === "codex" && applied.reason?.startsWith("policy not verified:") ? applied.reason : "native_permission_effect_unverified")
|
||||
: input.runtime === "claude-code"
|
||||
? inspectClaude(input.cwd, input.applied, input.fs)
|
||||
: inspectLaunchBoundRuntime(input.runtime, input.applied);
|
||||
|
||||
@@ -37,9 +37,13 @@ export function makeContextUsageThresholdPolicy(
|
||||
if (threshold == null) {
|
||||
return { action: "terminal", reason: "threshold_invalid" };
|
||||
}
|
||||
// A name that names no single seat right now (an archived generation of it restored for a moment, say)
|
||||
// has no generation to measure. Skip and say so; the watch stays active and is measured again once
|
||||
// the name resolves, never from another node's numbers. A name nothing carries any more is a seat
|
||||
// that's gone (its rig or node removed): the watch ends.
|
||||
if (!job.occupantGeneration) {
|
||||
return {
|
||||
action: "terminal",
|
||||
action: job.occupantGenerationAmbiguous ? "skip" : "terminal",
|
||||
reason: "occupant_generation_unresolved",
|
||||
notes: { targetSession: job.target.session },
|
||||
};
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface PolicyJob {
|
||||
/** The condition this job last told its target about (the receipt below), or null. */
|
||||
lastFiredCondition: string | null;
|
||||
occupantGeneration: string | null;
|
||||
/** With no occupantGeneration: true when the name is still carried but names no single seat, false when it's gone. */
|
||||
occupantGenerationAmbiguous?: boolean;
|
||||
currentGenerationTranscriptPending: boolean;
|
||||
requiredReceiptSatisfied: boolean;
|
||||
requiredReceiptDeferred: boolean;
|
||||
|
||||
@@ -81,7 +81,7 @@ import { getSelfHostId } from "./hosts/fanout-contract.js";
|
||||
import { parseSessionName, isHumanSeatSessionRef } from "./session-name.js";
|
||||
import { runnerPermissionOrigin } from "./runner-permissions.js";
|
||||
import { isHostPairingAsk } from "./hosts/pairing-ask.js";
|
||||
import { parseReplyToChoice, parseReplyToThreadMissing, formatReplyToChoice, describeReplyToFallback, REPLY_TO_CHOICE_ACTOR, type ReplyToChoice } from "./reply-to-choice.js";
|
||||
import { parseReplyToChoice, parseArrivalThread, parseReplyToThreadMissing, formatReplyToChoice, describeReplyToFallback, REPLY_TO_CHOICE_ACTOR, type ReplyToChoice } from "./reply-to-choice.js";
|
||||
import { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
|
||||
import { join } from "node:path";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
@@ -2113,7 +2113,7 @@ export class QueueRepository {
|
||||
// command line (it carries the row's summary), and nothing there can claim the row. So nothing
|
||||
// is typed; the row stays queued and visible. `failed:` keeps it on the wake ladder, whose
|
||||
// retries come back here and type nothing before it escalates.
|
||||
if (isBoundTerminalSeat(this.db, destinationSession)) {
|
||||
if (isBoundTerminalSeat(this.db, destinationSession) || isBoundTerminalSeat(this.db, physicalSessionName(this.db, destinationSession))) {
|
||||
return {
|
||||
classified: "failed",
|
||||
nudgeResult: `failed:terminal-seat: '${destinationSession}' is a terminal seat (runtime: terminal), not an agent, so no wake was typed into it and nothing there can claim this row. The row stays queued; route it to an agent seat.`,
|
||||
@@ -4825,6 +4825,17 @@ export class QueueRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** #2990 — the thread an inbound reply actually arrived in, without gate correlation.
|
||||
* Only an in-process daemon write counts, with the same trust rule as replyToChoiceFor. */
|
||||
arrivalThreadFor(qitemId: string): { threadTs: string; channel: string } | null {
|
||||
for (const t of this.transitionLog.listForQitem(qitemId).reverse()) {
|
||||
if (t.actorSession !== REPLY_TO_CHOICE_ACTOR || t.identityProvenance !== null) continue;
|
||||
const arrival = parseArrivalThread(t.transitionNote ?? "");
|
||||
if (arrival) return arrival;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** #96 — whether some update recorded this Slack root as the thread it posts into. Such a
|
||||
* root is shared: its owner's next decision must not reuse it (see slack-subsystem). */
|
||||
isReplyToThread(threadTs: string, where?: { channel: string }): boolean {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { openPromptRefusals, CLOSE_PREFIX, PARKED_OWNER_POLICY_NAME, REFUSED_PRE
|
||||
import { findQueueRecovery, recoveryTag } from "./queue-recovery.js";
|
||||
import { lastMeaningfulTransition, type WaitingView } from "./queue-waiting.js";
|
||||
import { readWakeOutcome } from "./connection-contract.js";
|
||||
import { runSyncSite } from "./sync-site-wrap.js";
|
||||
// S01 (OPR.0.5.5.1) — WAKE OR ESCALATE ON BATONS. A handoff whose wake fails must never
|
||||
// silently park: today one failed nudge is recorded and nothing follows (the measured
|
||||
// dominant 0.5.3 failure class — a perfect surviving packet, a recipient never woken).
|
||||
@@ -1062,6 +1063,33 @@ const PROMPT_CLEARED_NOTE = "prompt escalation: the seat's prompt read clear";
|
||||
* spares JSON parsing of the rows that cannot match. The alert tag holds no LIKE wildcard. */
|
||||
const PROMPT_ALERT_ROWS = `(tags LIKE '%"${PROMPT_ALERT_TAG}"%' OR tags LIKE '%\\%')`;
|
||||
|
||||
/** The rows a prompt refusal can still concern: open rows, and the rows an open prompt alert recovers (its exact
|
||||
* `recovery-for:<id>` tags), each kept when one of its own transitions carries a refusal note. Read from the open rows
|
||||
* (the state index) and then each candidate's own transitions (the qitem index), not by matching the note across the
|
||||
* whole transition log, which only grows. Ordered by each row's first refusal: the order the whole-log read returned
|
||||
* under its observed plan (it had no ORDER BY), now explicit. A recovered id is cut after the 13-byte ASCII prefix as
|
||||
* bytes, because text substr stops at a NUL, which a caller-supplied id can hold; the join then compares it exactly. */
|
||||
export const PROMPT_REFUSAL_CANDIDATES_SQL = `WITH scored AS MATERIALIZED (
|
||||
SELECT q.qitem_id, q.destination_session,
|
||||
(SELECT min(t.transition_id) FROM queue_transitions t
|
||||
WHERE t.qitem_id = q.qitem_id AND t.transition_note LIKE @refused) AS first_refusal
|
||||
FROM (
|
||||
SELECT qitem_id FROM queue_items WHERE state IN ('pending','in-progress','blocked')
|
||||
UNION
|
||||
SELECT CAST(substr(CAST(j.value AS BLOB), 14) AS TEXT) FROM queue_items a,
|
||||
json_each(CASE WHEN json_valid(a.tags) THEN a.tags ELSE '[]' END) j
|
||||
WHERE a.state IN ('pending','in-progress','blocked') AND json_valid(a.tags)
|
||||
AND EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(a.tags) THEN a.tags ELSE '[]' END) WHERE value = @alertTag)
|
||||
AND j.type = 'text' AND substr(j.value, 1, 13) = 'recovery-for:'
|
||||
) c JOIN queue_items q ON q.qitem_id = c.qitem_id)
|
||||
SELECT qitem_id, destination_session FROM scored WHERE first_refusal IS NOT NULL ORDER BY first_refusal`;
|
||||
|
||||
/** Named in the slow-operation log, so a stall during it is attributed to this read: one begin and one end record per tick. */
|
||||
export function readPromptRefusalCandidates(db: Database.Database): Array<{ qitem_id: string; destination_session: string }> {
|
||||
return runSyncSite("queue.wake_ladder.prompt_candidates", () => db.prepare(PROMPT_REFUSAL_CANDIDATES_SQL)
|
||||
.all({ refused: `${REFUSED_PREFIX}%`, alertTag: PROMPT_ALERT_TAG }) as Array<{ qitem_id: string; destination_session: string }>);
|
||||
}
|
||||
|
||||
/** Active watchdog jobs whose latest attempt was refused at a target's prompt. A run of refusals starts
|
||||
* after the job's latest other entry at that target (looked up past the 50-entry read when the read is all refusals) or
|
||||
* after this seat's latest prompt-cleared alert retirement, whichever is later; the key names that boundary, so it stays
|
||||
@@ -1192,13 +1220,7 @@ async function advancePromptRefusals(
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const candidates = deps.db.prepare(`SELECT DISTINCT q.qitem_id, q.destination_session FROM queue_items q
|
||||
JOIN queue_transitions t ON t.qitem_id = q.qitem_id
|
||||
WHERE t.transition_note LIKE ? AND (q.state IN ('pending','in-progress','blocked') OR EXISTS (
|
||||
SELECT 1 FROM queue_items a WHERE a.state IN ('pending','in-progress','blocked')
|
||||
AND json_valid(a.tags) AND EXISTS (SELECT 1 FROM json_each(a.tags) WHERE value = ?)
|
||||
AND EXISTS (SELECT 1 FROM json_each(a.tags) WHERE value = 'recovery-for:' || q.qitem_id)))`)
|
||||
.all(`${REFUSED_PREFIX}%`, PROMPT_ALERT_TAG) as Array<{ qitem_id: string; destination_session: string }>;
|
||||
const candidates = readPromptRefusalCandidates(deps.db);
|
||||
// Every tag value of every final prompt disposition, read once per tick when the first episode needs it,
|
||||
// rather than one scan of all terminal rows per open episode. The row predicates are the per-episode lookup's;
|
||||
// membership is the same exact json_each value equality; and nothing below writes before the last episode is
|
||||
|
||||
@@ -148,8 +148,9 @@ export function assessCurrentStateRehydrateEligibility(
|
||||
typeof session.resume_token === "string" && session.resume_token.length > 0
|
||||
);
|
||||
|
||||
if (node.podId && !hasStartupContext) {
|
||||
blockers.push(`node ${node.logicalId} is pod-aware but has no persisted startup context`);
|
||||
// A resume token is enough for a pod-aware seat too: restore resumes it with an empty plan and names the gap.
|
||||
if (node.podId && !hasStartupContext && !hasResumeToken) {
|
||||
blockers.push(`node ${node.logicalId} is pod-aware but has no persisted startup context or resume token`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// #96 — the durable record of where a --reply-to update posts. It is written on the row
|
||||
// ONCE, before the first post, so every retry, reconcile scan and --verify read the same
|
||||
// choice instead of re-deriving it after the referenced item or its root may have changed.
|
||||
//
|
||||
// #2990 — a reply arriving in a superseded Slack root records that thread separately
|
||||
// from gate correlation. An update answering the reply returns there, while only the
|
||||
// conversation's newest root can answer its human gate. The same actor/provenance rule
|
||||
// as the reply-to choice protects this in-process record.
|
||||
|
||||
const REPLY_TO_CHOICE_PREFIX = "slack-reply-to-choice";
|
||||
export const SLACK_ARRIVAL_THREAD_PREFIX = "slack-arrival-thread";
|
||||
|
||||
/** The only actor whose choice note is trusted: an agent can append any transition note, so
|
||||
* a note from anyone else must never steer where the update posts. */
|
||||
@@ -67,6 +73,23 @@ export function parseReplyToChoice(note: string): ReplyToChoice | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatArrivalThread(arrival: { threadTs: string; channel: string }): string {
|
||||
return `${SLACK_ARRIVAL_THREAD_PREFIX} thread_ts=${arrival.threadTs} channel=${arrival.channel}`;
|
||||
}
|
||||
|
||||
/** Parse an arrival-thread note (null when the note is not one). Callers check the actor. */
|
||||
export function parseArrivalThread(note: string): { threadTs: string; channel: string } | null {
|
||||
if (!note.startsWith(SLACK_ARRIVAL_THREAD_PREFIX + " ")) return null;
|
||||
const fields = new Map<string, string>();
|
||||
for (const token of note.slice(SLACK_ARRIVAL_THREAD_PREFIX.length + 1).split(/\s+/)) {
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) fields.set(token.slice(0, eq), token.slice(eq + 1));
|
||||
}
|
||||
const threadTs = fields.get("thread_ts");
|
||||
const channel = fields.get("channel");
|
||||
return threadTs && channel ? { threadTs, channel } : null;
|
||||
}
|
||||
|
||||
/** #2234: where Slack actually put a --reply-to update that its choice threaded. The thread's root was gone, so Slack posted
|
||||
* the update as a new top-level message. Recorded after the post by the same actor, and read only for the receipt: retries and
|
||||
* reconciliation keep the recorded choice. */
|
||||
|
||||
@@ -176,6 +176,7 @@ export interface RestoreCheckOpts {
|
||||
// --- Deps (framework-free per ADR-0001; reads from existing projections per ADR-0002) ---
|
||||
|
||||
export interface NodeInventoryEntry {
|
||||
codexConfigProfile?: string | null;
|
||||
nodeId?: string | null;
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
@@ -192,6 +193,16 @@ export interface NodeInventoryEntry {
|
||||
tmuxAttachCommand: string | null;
|
||||
latestError: string | null;
|
||||
cwd?: string | null;
|
||||
/** The seat's recorded restore choice and resume token (the inventory carries them). */
|
||||
restorePolicy?: string | null;
|
||||
resumeType?: string | null;
|
||||
resumeToken?: string | null;
|
||||
}
|
||||
|
||||
/** The restore's own resume rule: a seat with a resume token, a resume type and the resume policy resumes. */
|
||||
function seatResumes(node: NodeInventoryEntry): boolean {
|
||||
return !!node.resumeToken && !!node.resumeType && node.resumeType !== "none" && node.runtime !== "terminal"
|
||||
&& (node.restorePolicy ?? "resume_if_possible") === "resume_if_possible";
|
||||
}
|
||||
|
||||
export interface RestoreCheckDeps {
|
||||
@@ -365,7 +376,7 @@ export class RestoreCheckService {
|
||||
// are not skipping (full, --ready/includeReady, or a non-ready seat).
|
||||
const omitReadyDetail = opts.compact && !opts.includeReady && readinessCheck.status === "green";
|
||||
|
||||
const startupContextCheck = this.checkStartupContext(node);
|
||||
const startupContextCheck = this.checkStartupContext(node, rig.name);
|
||||
if ("unknownChecks" in startupContextCheck) {
|
||||
return this.buildUnknown([
|
||||
...checks,
|
||||
@@ -455,6 +466,8 @@ export class RestoreCheckService {
|
||||
fsOps: { exists: this.deps.exists },
|
||||
servicesRecord: input.servicesRecord,
|
||||
currentRuntimes: Object.fromEntries(inventory.map((current) => [current.logicalId, current.runtime])),
|
||||
currentCodexProfiles: Object.fromEntries(inventory.filter((current) => current.codexConfigProfile !== undefined)
|
||||
.map((current) => [current.logicalId, current.codexConfigProfile!])),
|
||||
});
|
||||
const { blockers, warnings } = validation;
|
||||
return {
|
||||
@@ -897,7 +910,7 @@ export class RestoreCheckService {
|
||||
};
|
||||
}
|
||||
|
||||
private checkStartupContext(node: NodeInventoryEntry): { check: CheckEntry } | { unknownChecks: CheckEntry[] } {
|
||||
private checkStartupContext(node: NodeInventoryEntry, rigName: string): { check: CheckEntry } | { unknownChecks: CheckEntry[] } {
|
||||
const session = node.canonicalSessionName ?? node.logicalId;
|
||||
const check = `seat.${session}.startup-context`;
|
||||
const runningReady = node.sessionStatus === "running" && node.startupStatus === "ready";
|
||||
@@ -908,6 +921,7 @@ export class RestoreCheckService {
|
||||
check,
|
||||
runningReady,
|
||||
`Startup context cannot be inspected because node id is missing for ${session}`,
|
||||
rigName,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -925,9 +939,26 @@ export class RestoreCheckService {
|
||||
}],
|
||||
};
|
||||
case "missing":
|
||||
// A seat that only ever resumed or was attached has a resume token and no saved startup context. Restore tries its
|
||||
// saved conversation without replaying a saved startup plan, as on any resume (a resume replays no startup context).
|
||||
// A pod-aware seat's restore names the gap; a legacy seat's resume path is unchanged and names none.
|
||||
if (seatResumes(node)) {
|
||||
return {
|
||||
check: {
|
||||
check,
|
||||
status: "yellow",
|
||||
evidence: `${probe.evidence}. Restore tries ${session}'s saved conversation without replaying a saved startup plan${node.podId ? ", and names this gap" : ""}.`,
|
||||
remediation: "No startup-context repair is needed to restore it.",
|
||||
remediationSafe: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: this.buildStartupContextAvailabilityCheck(check, runningReady, probe.evidence, rigName),
|
||||
};
|
||||
case "malformed":
|
||||
return {
|
||||
check: this.buildStartupContextAvailabilityCheck(check, runningReady, probe.evidence),
|
||||
check: this.buildStartupContextAvailabilityCheck(check, runningReady, probe.evidence, rigName),
|
||||
};
|
||||
case "ok":
|
||||
break;
|
||||
@@ -1008,12 +1039,14 @@ export class RestoreCheckService {
|
||||
check: string,
|
||||
runningReady: boolean,
|
||||
evidence: string,
|
||||
rigName: string,
|
||||
): CheckEntry {
|
||||
return {
|
||||
check,
|
||||
status: runningReady ? "yellow" : "red",
|
||||
evidence,
|
||||
remediation: "Recreate the seat startup context from the rig or agent spec before trusting replay inputs",
|
||||
// A fresh seat launch reads the saved startup context and refuses without one, so it isn't offered here.
|
||||
remediation: `No command records startup context for this seat yet. To see what restore would do for it, preview with: rig up ${shellQuote(rigName)} --existing --plan (read-only; it records nothing).`,
|
||||
remediationSafe: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
Session,
|
||||
Checkpoint,
|
||||
RestoreSnapshotSelection,
|
||||
NodeStartupSnapshot,
|
||||
} from "./types.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
@@ -440,6 +441,9 @@ export class RestoreOrchestrator {
|
||||
servicesRecord: this.rigRepo.getServicesRecord(rigId),
|
||||
freshLogicalIds: opts?.freshLogicalIds,
|
||||
currentRuntimes: Object.fromEntries((this.rigRepo.getRig(rigId)?.nodes ?? []).map((current) => [current.logicalId, current.runtime])),
|
||||
currentCodexProfiles: Object.fromEntries((this.rigRepo.getRig(rigId)?.nodes ?? [])
|
||||
.filter((current) => current.codexConfigProfile !== undefined)
|
||||
.map((current) => [current.logicalId, current.codexConfigProfile!])),
|
||||
});
|
||||
const topology = resolveSnapshotRestoreTopology(snapshot.data);
|
||||
// A required startup file that is missing holds only its own seat. That seat isn't launched, because it would start
|
||||
@@ -1822,7 +1826,8 @@ export class RestoreOrchestrator {
|
||||
// runtime and write Claude hooks into another agent's working folder
|
||||
// (with a saved context, the compatibility gate already stops that
|
||||
// mismatch), and a pod-aware agent must get no write from this recovery
|
||||
// at all — it still ends at the "Harness not started" gate, unchanged.
|
||||
// at all. With no saved context and a resume token, restore tries its resume
|
||||
// on an empty plan (replayCtx below), still without this recovery.
|
||||
const activityAdapter = opts?.adapters?.["claude-code"];
|
||||
const recoveryRuntime = startupCtx?.runtime
|
||||
?? (!isPodAware && liveRuntime === node.runtime ? node.runtime : null);
|
||||
@@ -2044,6 +2049,20 @@ export class RestoreOrchestrator {
|
||||
const replayContained = resumeRequested && !!resumeToken;
|
||||
const startupRuntime = startupCtx?.runtime ?? node.runtime ?? null;
|
||||
const startupAdapter = seatAdapter(opts?.adapters, startupRuntime, session?.communityHarness);
|
||||
// A seat that only ever resumed or was attached (attach, adopt, reconcile-session, a set resume token) has a resume
|
||||
// token but no saved startup context. A resume replays none anyway (replayContained), so restore tries the resume
|
||||
// with an empty plan and names the gap, rather than stopping on data the resume doesn't use. Guidance, activity hooks
|
||||
// and the guidance record read the saved context only, so no guidance is put back and no startup context is written
|
||||
// for this seat; the adapter's ordinary bootstrap and the seat's existing skill restoration still run, as on any resume.
|
||||
const resumeWithoutContext = isPodAware && !startupCtx && replayContained && startupRuntime !== null && startupRuntime !== "terminal";
|
||||
// A fresh seat launch reads the saved startup context and refuses without one, so a rollback names none for this seat.
|
||||
const noContextFresh = "This seat has no saved startup context, and no command records one for it yet, so no fresh start is offered here.";
|
||||
const replayCtx: NodeStartupSnapshot | null = startupCtx
|
||||
?? (resumeWithoutContext ? { runtime: startupRuntime!, projectionEntries: [], resolvedStartupFiles: [], startupActions: [] } : null);
|
||||
if (resumeWithoutContext && startupAdapter && launchResult) {
|
||||
warnings?.push(`Restore: ${node.logicalId}: no saved startup context, so restore tries its saved conversation without replaying a saved startup plan.`);
|
||||
activityHookGaps?.push("startup_context: no saved startup context; restore tried the saved conversation without replaying a saved startup plan.");
|
||||
}
|
||||
// New compute has no retained context filesystem. Restore the saved file selection, never conversation actions.
|
||||
const restoreEnvironmentContext = replayContained && !!startupAdapter?.environment && !!data.environmentBindingsByNode?.[node.id];
|
||||
|
||||
@@ -2056,13 +2075,14 @@ export class RestoreOrchestrator {
|
||||
&& baseStatus !== "resumed"
|
||||
&& startupRuntime !== null
|
||||
&& startupRuntime !== "terminal"
|
||||
&& (!startupCtx || !startupAdapter)
|
||||
&& (!replayCtx || !startupAdapter)
|
||||
) {
|
||||
// A deliberate fresh launch with no captured startup context is the
|
||||
// never-occupied-seat case from #49. The restore launcher has already
|
||||
// created a shell, so remove that provisional session before handing the
|
||||
// seat to the normal recipe-backed launch path. Resume requests stay on
|
||||
// the fail-closed path below: no durable context means no safe resume.
|
||||
// seat to the normal recipe-backed launch path. A resume with a token
|
||||
// goes on with an empty plan (replayCtx above); a resume without one stays
|
||||
// on the fail-closed path below: no durable context means no safe resume.
|
||||
if (!startupCtx && !resumeRequested && this.freshLaunch) {
|
||||
// Keep the usable provisional shell when the callback exists but its
|
||||
// recipe/member inputs are unavailable. A callback being present alone
|
||||
@@ -2099,23 +2119,23 @@ export class RestoreOrchestrator {
|
||||
};
|
||||
}
|
||||
}
|
||||
const cause = startupCtx ? `no ${startupRuntime} runtime adapter` : "no startup context";
|
||||
const cause = replayCtx ? `no ${startupRuntime} runtime adapter` : "no startup context";
|
||||
const error = `Harness not started: ${cause} for ${node.logicalId}.`;
|
||||
warnings?.push(`Restore: ${error}`);
|
||||
return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error };
|
||||
}
|
||||
|
||||
// Attempt restore-safe startup replay if context available
|
||||
if (data.nodeStartupContext && opts?.adapters && launchResult) {
|
||||
if (startupCtx) {
|
||||
const adapter = seatAdapter(opts.adapters, startupCtx.runtime, session?.communityHarness);
|
||||
if ((data.nodeStartupContext || replayCtx) && opts?.adapters && launchResult) {
|
||||
if (replayCtx) {
|
||||
const adapter = seatAdapter(opts.adapters, replayCtx.runtime, session?.communityHarness);
|
||||
if (adapter) {
|
||||
// Prefilter: check which files/entries still exist
|
||||
const existsFn = opts.fsOps?.exists ?? (() => true);
|
||||
const sourceEntries = replayContained && !restoreEnvironmentContext ? [] : startupCtx.projectionEntries.map((e) => reanchorShippedProjectionEntry(e, undefined, existsFn));
|
||||
const sourceEntries = replayContained && !restoreEnvironmentContext ? [] : replayCtx.projectionEntries.map((e) => reanchorShippedProjectionEntry(e, undefined, existsFn));
|
||||
// #261: recognized built-in startup files follow the running install.
|
||||
const sourceFiles = replayContained && !restoreEnvironmentContext ? launchGuidance ?? [] : withContextEntryFloor(startupCtx.resolvedStartupFiles.map((f) => reanchorBuiltinStartupFile(f, undefined, undefined, existsFn)));
|
||||
const sourceActions = replayContained ? [] : startupCtx.startupActions;
|
||||
const sourceFiles = replayContained && !restoreEnvironmentContext ? launchGuidance ?? [] : withContextEntryFloor(replayCtx.resolvedStartupFiles.map((f) => reanchorBuiltinStartupFile(f, undefined, undefined, existsFn)));
|
||||
const sourceActions = replayContained ? [] : replayCtx.startupActions;
|
||||
const filteredEntries = sourceEntries.filter((e) => {
|
||||
if (!existsFn(e.absolutePath)) {
|
||||
warnings?.push(`Restore: missing projection entry ${e.absolutePath} (skipped)`);
|
||||
@@ -2153,7 +2173,7 @@ export class RestoreOrchestrator {
|
||||
// it is, an edited one is named, and a missing one whose recorded source is gone or has changed is not put back
|
||||
// with today's bytes. Only the rest is projected or installed. Nothing is re-resolved from today's spec. Native
|
||||
// resume replays nothing (sourceEntries is empty). The whole selection stays in the saved startup context.
|
||||
const seatSkills = (startupCtx.runtime === "claude-code" || startupCtx.runtime === "codex") && !replayContained ? seatSkillPathsForNode(this.db, node.id) : undefined;
|
||||
const seatSkills = (replayCtx.runtime === "claude-code" || replayCtx.runtime === "codex") && !replayContained ? seatSkillPathsForNode(this.db, node.id) : undefined;
|
||||
let replaySkip: { entryIds: Set<string>; files: Set<string> } | undefined;
|
||||
if (seatSkills) {
|
||||
const replay = seatSkillReplay({ entries: filteredEntries, files: filteredFiles, paths: seatSkills,
|
||||
@@ -2164,7 +2184,7 @@ export class RestoreOrchestrator {
|
||||
|
||||
// Build fresh projection plan (all safe_projection; replaySkip says which seat skills are left alone)
|
||||
const plan: import("./projection-planner.js").ProjectionPlan = {
|
||||
runtime: startupCtx.runtime,
|
||||
runtime: replayCtx.runtime,
|
||||
cwd: launchCwd,
|
||||
entries: filteredEntries.map((e) => ({
|
||||
...e,
|
||||
@@ -2181,7 +2201,7 @@ export class RestoreOrchestrator {
|
||||
const binding = {
|
||||
...launchResult.binding,
|
||||
// #875: the guidance file this seat's launch recorded; an older snapshot recorded none, so its file stays the rule's.
|
||||
guidanceFile: startupCtx.guidanceFile ?? null,
|
||||
guidanceFile: replayCtx.guidanceFile ?? null,
|
||||
// #3039: the resumed launch carries the guidance chosen above, so startup doesn't ask again and the adapter queues it.
|
||||
...(launchGuidance ? { guidanceLaunchInstructions: true as const } : {}),
|
||||
...(data.environmentBindingsByNode?.[node.id] ? { environmentRestore: data.environmentBindingsByNode[node.id] } : {}),
|
||||
@@ -2251,7 +2271,9 @@ export class RestoreOrchestrator {
|
||||
nodeId: node.id,
|
||||
logicalId: node.logicalId,
|
||||
status: "awaiting-decision",
|
||||
error: `Original session unresumable: resume attempted but the runtime reported fresh continuity. The blank session was rolled back; no session is running. If that degradation is acceptable, start only this seat fresh: ${this.freshSeatCommand(rigId, data, node.logicalId)}.`,
|
||||
error: resumeWithoutContext
|
||||
? `Original session unresumable: resume attempted but the runtime reported fresh continuity. The blank session was rolled back; no session is running. ${noContextFresh}`
|
||||
: `Original session unresumable: resume attempted but the runtime reported fresh continuity. The blank session was rolled back; no session is running. If that degradation is acceptable, start only this seat fresh: ${this.freshSeatCommand(rigId, data, node.logicalId)}.`,
|
||||
};
|
||||
}
|
||||
const finalStatus = (isPodAware && resumeRequested)
|
||||
@@ -2297,7 +2319,9 @@ export class RestoreOrchestrator {
|
||||
nodeId: node.id,
|
||||
logicalId: node.logicalId,
|
||||
status: "awaiting-decision",
|
||||
error: `Original session unresumable: ${startupResult.errors.join("; ")}. The blank session was rolled back; no session is running. OpenRig's records can't tell whether this conversation was ever used. If you know this seat was never used, nothing is lost by starting only this seat fresh, ${steps}. Otherwise restore the original session manually.`,
|
||||
error: resumeWithoutContext
|
||||
? `Original session unresumable: ${startupResult.errors.join("; ")}. The blank session was rolled back; no session is running. ${noContextFresh} Restore the original session manually.`
|
||||
: `Original session unresumable: ${startupResult.errors.join("; ")}. The blank session was rolled back; no session is running. OpenRig's records can't tell whether this conversation was ever used. If you know this seat was never used, nothing is lost by starting only this seat fresh, ${steps}. Otherwise restore the original session manually.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -2638,6 +2662,7 @@ export class RestoreOrchestrator {
|
||||
...(launchWrapper?.length ? ([launchWrapper] as const) : []),
|
||||
);
|
||||
if (result.ok) {
|
||||
for (const note of result.warnings ?? []) warnings?.push(`${sessionName}: ${note}`);
|
||||
if (result.appliedLaunch && launchGeneration) this.appliedLaunchStore.recordGeneration(launchGeneration, result.appliedLaunch);
|
||||
return { kind: "resumed" };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export function validatePreRestore(
|
||||
freshLogicalIds?: string[];
|
||||
/** The runtime each seat has now, by logical id. A seat not listed is read as its saved runtime. */
|
||||
currentRuntimes?: Record<string, string | null | undefined>;
|
||||
/** Current explicit seat selections; a missing key is unknown, null is an explicit absence. */
|
||||
currentCodexProfiles?: Record<string, string | null>;
|
||||
},
|
||||
): { blockers: RestoreValidationBlocker[]; warnings: string[] } {
|
||||
const blockers: RestoreValidationBlocker[] = [];
|
||||
@@ -88,6 +90,12 @@ export function validatePreRestore(
|
||||
}
|
||||
|
||||
for (const node of topology.intendedNodes) {
|
||||
if (node.runtime === "codex" && node.codexConfigProfile
|
||||
&& opts.currentRuntimes?.[node.logicalId] === "codex"
|
||||
&& opts.currentCodexProfiles && Object.hasOwn(opts.currentCodexProfiles, node.logicalId)
|
||||
&& opts.currentCodexProfiles[node.logicalId] === null) {
|
||||
warnings.push(`Restore pre-validation: ${node.logicalId} had Codex profile ${node.codexConfigProfile} in this snapshot, but its current seat selection has no named profile. Restore uses the current selection; an ordinary floor launch uses workspace-write instead. Review rig seat set-permissions before restoring if this change is unintended. The snapshot does not prove what a prior process enforced.`);
|
||||
}
|
||||
const checkpoint = checkpoints[node.id] ?? null;
|
||||
if (checkpoint && !node.cwd) {
|
||||
add({
|
||||
|
||||
@@ -23,6 +23,7 @@ import { validateResumeToken } from "./resume-token-validation.js";
|
||||
import { readProcessTable } from "./process-table.js";
|
||||
import type { HomeRuntime } from "./seat-runtime-home.js";
|
||||
import { readSeatClaudeMcp, type ClaudeMcp } from "./claude-mcp.js";
|
||||
import { ObservationPass } from "./process-observation-pass.js";
|
||||
|
||||
export interface ResumeRefreshSession {
|
||||
/** The seat, when known, so its own runtime home is read. */
|
||||
@@ -58,6 +59,8 @@ interface ResumeMetadataRefresherDeps {
|
||||
};
|
||||
/** #421 — start time of the pane's current Claude process; a sidecar sampled earlier is not used. */
|
||||
claudeProcessStartedAt?: ResumeTokenCaptureDeps["claudeProcessStartedAt"];
|
||||
/** One fresh identity lister per seat, shared only within a snapshot refresh. */
|
||||
claudeStartedAtForPass?: (pass: ObservationPass) => NonNullable<ResumeTokenCaptureDeps["claudeProcessStartedAt"]>;
|
||||
/** Provider root used by the daemon's Claude launches; no scan of other homes. */
|
||||
claudeConfigDir?: string;
|
||||
listClaudeProcesses?: NativeProcessLister;
|
||||
@@ -77,6 +80,7 @@ export class ResumeMetadataRefresher {
|
||||
private homeDir: string;
|
||||
private contextUsageStore: ResumeMetadataRefresherDeps["contextUsageStore"] | null;
|
||||
private claudeProcessStartedAt: ResumeMetadataRefresherDeps["claudeProcessStartedAt"] | null;
|
||||
private claudeStartedAtForPass: ResumeMetadataRefresherDeps["claudeStartedAtForPass"];
|
||||
private claudeConfigDir: string;
|
||||
private seatHome?: (nodeId: string, runtime: HomeRuntime) => string | undefined;
|
||||
private listClaudeProcesses: NativeProcessLister | undefined;
|
||||
@@ -112,6 +116,7 @@ export class ResumeMetadataRefresher {
|
||||
this.homeDir = deps.homeDir ?? os.homedir();
|
||||
this.contextUsageStore = deps.contextUsageStore ?? null;
|
||||
this.claudeProcessStartedAt = deps.claudeProcessStartedAt ?? null;
|
||||
this.claudeStartedAtForPass = deps.claudeStartedAtForPass;
|
||||
this.claudeConfigDir = deps.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? nodePath.join(this.homeDir, ".claude");
|
||||
this.seatHome = deps.seatHome;
|
||||
this.listClaudeProcesses = deps.listClaudeProcesses;
|
||||
@@ -151,6 +156,8 @@ export class ResumeMetadataRefresher {
|
||||
},
|
||||
): Promise<void> {
|
||||
const fillNullOnly = opts?.fillNullOnly === true;
|
||||
const claudeStartedAt = fillNullOnly && this.claudeStartedAtForPass
|
||||
? this.claudeStartedAtForPass(new ObservationPass()) : this.claudeProcessStartedAt;
|
||||
// Snapshot-time discovery is SINGLE-ATTEMPT (mini-req 2): the 8-attempt
|
||||
// 250ms-sleep loop exists for the adoption boundary racing a booting codex;
|
||||
// on a recurring tick it multiplied `ps` spawns per seat, forever.
|
||||
@@ -207,7 +214,7 @@ export class ResumeMetadataRefresher {
|
||||
if (sidecar?.ok) {
|
||||
const token = sidecar.data.session_id;
|
||||
if (typeof token === "string" && token.trim().length > 0
|
||||
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, this.claudeProcessStartedAt))) {
|
||||
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, claudeStartedAt))) {
|
||||
this.sessionRegistry.updateResumeToken(session.sessionId, "claude_id", token.trim(), "scrape");
|
||||
}
|
||||
}
|
||||
@@ -231,7 +238,7 @@ export class ResumeMetadataRefresher {
|
||||
if (sidecar?.ok) {
|
||||
const derived = sidecar.data.session_id;
|
||||
if (typeof derived === "string" && derived.trim().length > 0 && derived.trim() === session.resumeToken
|
||||
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, this.claudeProcessStartedAt))) {
|
||||
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, claudeStartedAt))) {
|
||||
this.sessionRegistry.markResumeProbeResult(session.sessionId, "resumable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function addRigEdge(
|
||||
if (LAUNCH_DEPENDENCY_KINDS.has(input.kind) && createsLaunchCycle(rig.edges, candidate)) {
|
||||
return {
|
||||
ok: false, code: "edge_cycle",
|
||||
message: `a ${input.kind} edge from ${input.from} to ${input.to} would make launch order cycle, and launch and restore refuse a cycle`,
|
||||
message: `a ${input.kind} edge from ${input.from} to ${input.to} would make launch order cycle; launch and restore require an acyclic order`,
|
||||
};
|
||||
}
|
||||
if (input.plan) return { ok: true, rigId: input.rigId, outcome: "would_add", edge };
|
||||
|
||||
@@ -2696,7 +2696,8 @@ export class PodRigInstantiator {
|
||||
nodeId: input.nodeId,
|
||||
tmuxSession: launchResult.binding.tmuxSession,
|
||||
tmuxWindow: null,
|
||||
tmuxPane: null,
|
||||
// Only the run-grant launch owners need this anchor; unrelated builtins keep their existing session target.
|
||||
tmuxPane: input.member.runtime === "pi" || isCommunityHarnessRuntime(input.member.runtime) ? launchResult.binding.tmuxPane : null,
|
||||
cmuxWorkspace: null,
|
||||
cmuxSurface: null,
|
||||
updatedAt: "",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { NodeBinding } from "./runtime-adapter.js";
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import { SessionRegistry } from "./session-registry.js";
|
||||
|
||||
export interface RunnerGrantLaunch {
|
||||
readonly grantId: string;
|
||||
readonly generation: string;
|
||||
wrap(command: string): string;
|
||||
dispatched(): void;
|
||||
finish(ok: boolean): Promise<void>;
|
||||
}
|
||||
export type RunnerGrantIssuer = (binding: NodeBinding, runtime: string, runId: string, runner: number) => Promise<RunnerGrantLaunch>;
|
||||
|
||||
/** Shared by actual Pi/community launch owners. The token crosses only tmux's private environment channel;
|
||||
* the typed command carries an unpredictable environment KEY, never its value. The runner's shell consumes and
|
||||
* removes that key before exec. Neither a failed await nor a later readiness result may revoke a successor. */
|
||||
export function runnerGrantIssuer(db: Database.Database, tmux: Pick<TmuxAdapter, "getPanePid" | "setSessionEnv" | "unsetSessionEnv">): RunnerGrantIssuer {
|
||||
return async (binding, runtime, runId, runner) => {
|
||||
const session = binding.tmuxSession, pane = binding.tmuxPane;
|
||||
if (!session || !pane) throw new Error("Runner authority needs a concrete launch pane.");
|
||||
// sendShellCommand stages the payload through /bin/sh for non-POSIX pane shells too.
|
||||
// The private bootstrap itself names /bin/sh; it must not narrow the existing parent-shell support.
|
||||
const panePid = await tmux.getPanePid(pane);
|
||||
if (!panePid) throw new Error("Runner authority needs a concrete launch process.");
|
||||
const registry = new SessionRegistry(db), grants = new ActivityGrantStore(db);
|
||||
const predecessorGeneration = registry.currentOccupantTenure(binding.nodeId)?.generationUuid;
|
||||
const generation = binding.launchGeneration ?? predecessorGeneration;
|
||||
if (!generation) throw new Error("Runner authority needs the reserved launch generation.");
|
||||
const row = db.prepare("SELECT id FROM sessions WHERE node_id=? AND session_name=? ORDER BY id DESC LIMIT 1")
|
||||
.get(binding.nodeId, session) as { id: string } | undefined;
|
||||
// An explicitly staged successor has no managed row until cutover.
|
||||
const grant = grants.issue({ nodeId: binding.nodeId, generation, issuedBy: "runner-launch",
|
||||
run: { runId, ...(registry.currentOccupantTenure(binding.nodeId)?.generationUuid === generation && row ? { sessionId: row.id } : {}),
|
||||
binding: { runtime, runner, pane, panePid } } });
|
||||
const key = `OPENRIG_RUN_GRANT_${grant.grantId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`;
|
||||
try { await tmux.setSessionEnv(session, key, grant.token); }
|
||||
catch {
|
||||
try { grants.revoke({ grantId: grant.grantId }, "credential_distribution_failed"); }
|
||||
finally {
|
||||
try { await tmux.unsetSessionEnv(session, key); } catch { /* private write may have succeeded before failing */ }
|
||||
}
|
||||
throw new Error("Runner credential distribution failed; no runner started.");
|
||||
}
|
||||
return {
|
||||
grantId: grant.grantId,
|
||||
generation: grant.generation,
|
||||
wrap(command) {
|
||||
const target = shellQuote(`=${session}`);
|
||||
// No eval: token text is treated only as data. Disable tracing before reading the private value.
|
||||
const script = `set +x; unset OPENRIG_RUN_ID; if _openrig_grant=$(tmux show-environment -t ${target} ${key} 2>/dev/null) && `
|
||||
+ `case "$_openrig_grant" in ${key}=oag_*) true;; *) false;; esac && tmux set-environment -t ${target} -u ${key} 2>/dev/null; then `
|
||||
+ `OPENRIG_ACTIVITY_HOOK_TOKEN=\${_openrig_grant#*=}; OPENRIG_RUN_ID=${shellQuote(runId)}; OPENRIG_OCCUPANT_GENERATION=${shellQuote(grant.generation)}; `
|
||||
+ `export OPENRIG_ACTIVITY_HOOK_TOKEN OPENRIG_RUN_ID OPENRIG_OCCUPANT_GENERATION; `
|
||||
+ `else tmux set-environment -t ${target} -u ${key} 2>/dev/null || :; printf '%s\\n' 'Run-bound reporting unavailable; using existing install-token reporting.' >&2; fi; `
|
||||
+ `unset _openrig_grant; exec ${command}`;
|
||||
return `/bin/sh -c ${shellQuote(script)}`;
|
||||
},
|
||||
dispatched() {
|
||||
const currentGeneration = registry.currentOccupantTenure(binding.nodeId)?.generationUuid;
|
||||
if (currentGeneration !== predecessorGeneration && currentGeneration !== generation) {
|
||||
throw new Error("Runner launch selection changed during dispatch; authority was refused.");
|
||||
}
|
||||
if (!grants.activate(grant.grantId) && registry.isOccupantGenerationRegistered(binding.nodeId, generation)) {
|
||||
throw new Error("Runner launch binding changed during dispatch; authority was refused.");
|
||||
}
|
||||
},
|
||||
async finish(ok) {
|
||||
// A staged readiness result can arrive after another cutover. It owns only this issued grant.
|
||||
try {
|
||||
const currentGeneration = ok ? registry.currentOccupantTenure(binding.nodeId)?.generationUuid : undefined;
|
||||
if (!ok || (currentGeneration !== predecessorGeneration && currentGeneration !== generation)) {
|
||||
grants.revoke({ grantId: grant.grantId }, "launch_failed_or_uncertain");
|
||||
}
|
||||
} finally {
|
||||
// Also retry cleanup when the launch shell continued without the private value.
|
||||
try { await tmux.unsetSessionEnv(session, key); } catch { /* optional cleanup cannot undo dispatch */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -147,7 +147,10 @@ export type HarnessLaunchRecovery = "retry_fresh" | "attention_required";
|
||||
|
||||
// `launchedProcess` (Claude resume launches): the first process the launch's own readiness check
|
||||
// pinned as the one it started, kept even when a later sample saw it replaced (#1077).
|
||||
export type HarnessLaunchResult = { firstInput?: import("./community-harness-first-input.js").FirstInputPassed;
|
||||
export type HarnessLaunchResult = {
|
||||
/** Non-secret issuance ID for exact cleanup by later Core readiness owners. Never the token. */
|
||||
activityGrantId?: string;
|
||||
firstInput?: import("./community-harness-first-input.js").FirstInputPassed;
|
||||
/** A community launcher that opts in was handed the seat's declared model as OPENRIG_MODEL: "confirmed" when the launch
|
||||
* command's send succeeded, "uncertain" when typing began and the send then failed. Never proof of the model that runs. */
|
||||
modelInput?: { model: string; dispatch: "confirmed" | "uncertain" } } & (
|
||||
@@ -250,6 +253,9 @@ export interface RuntimeAdapter {
|
||||
/** Claude's managed capability/launch seam, shared with seat selection. */
|
||||
readonly claudeManagedLaunch?: import("./claude-managed-launch.js").ClaudeManagedLaunch;
|
||||
readonly runtime: string;
|
||||
/** Check launch preconditions before an in-place handover ends the predecessor.
|
||||
* A concrete conflict refuses; an inconclusive check may allow launch with a warning. */
|
||||
preflightLaunch?(binding: NodeBinding): Promise<{ ok: true; warnings?: string[] } | { ok: false; error: string }>;
|
||||
/** The file an adapter's project() writes for a skill when it lives outside
|
||||
* the Claude project tree, for "already in place" detection. Absent = the
|
||||
* instantiator's default target. */
|
||||
|
||||
@@ -516,7 +516,8 @@ export class SeatDeliveryGuard {
|
||||
/** No recent writable-client activity before a short wait, the input still empty when read again after it, and still
|
||||
* no recent activity after that read. A time tmux can't report holds the paste, as an unreadable input does. The
|
||||
* read it returns is the one the paste goes on. Typing after this final check and before the paste can still leave
|
||||
* the notice beside the person's draft: the Enter is then refused, nothing is submitted or deleted (#3076). */
|
||||
* the notice beside the person's draft. In the tested append-mode schedule the Enter is then refused and nothing is
|
||||
* submitted or deleted; that isn't a guarantee for every interleaving (#3076). */
|
||||
private async quietBeforePaste(target: GuardTarget, read: () => Promise<ComposerInput | null>,
|
||||
unknown: (reading: ComposerInput | null) => DeliveryGuardError): Promise<ComposerInput> {
|
||||
const quiet = async () => {
|
||||
@@ -649,3 +650,12 @@ export function resolveGuardTarget(db: Database.Database, name: string): GuardTa
|
||||
const { archivedAt: _archivedAt, physicalName: _physicalName, logicalId: _logicalId, rigName: _rigName, latestStatus: _latestStatus, ...target } = pool[0]!;
|
||||
return target;
|
||||
}
|
||||
|
||||
/** An external-CLI seat's newest occupant generation by its external session name, which resolveGuardTarget's address
|
||||
* rules don't match: the one unarchived external_cli binding with that name, else null (none or several). */
|
||||
export function resolveExternalCliOccupant(db: Database.Database, name: string): string | null {
|
||||
const rows = db.prepare(`SELECT (SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=b.node_id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant
|
||||
FROM bindings b JOIN nodes n ON n.id=b.node_id JOIN rigs r ON r.id=n.rig_id
|
||||
WHERE b.attachment_type='external_cli' AND b.external_session_name=? AND r.archived_at IS NULL`).all(name) as Array<{ occupant: string | null }>;
|
||||
return rows.length === 1 ? rows[0]!.occupant : null;
|
||||
}
|
||||
@@ -518,7 +518,9 @@ export class SeatHandoverService {
|
||||
if (permissionOverride.codexPermissions) {
|
||||
// Saved envelopes retain ordinary launch additions; only an observed predecessor
|
||||
// policy uses the exact carry path. Describe policy, not an observed native delta.
|
||||
const directoryPolicy = permissionOverride.codexPermissions.sandbox === "workspace-write"
|
||||
const directoryPolicy = "filesystem" in permissionOverride.codexPermissions
|
||||
? "with its explicit filesystem policy and no additional writable directory grants"
|
||||
: permissionOverride.codexPermissions.sandbox === "workspace-write"
|
||||
? "with OpenRig's usual Git metadata, seat-folder and pod-state writable roots as on a fresh launch"
|
||||
: "without additional writable directory grants, as on a read-only fresh launch";
|
||||
permissionContinuity = { state: "selection_used", detail: `The seat's saved permission envelope was used ${directoryPolicy}. This describes launch policy, not an observed difference. The predecessor policy was not carried; native successor equivalence is unverified.`
|
||||
@@ -617,7 +619,9 @@ export class SeatHandoverService {
|
||||
// The registry binding is unchanged (commit never ran). A resolve_pane failure leaves the live
|
||||
// retiree wholly untouched; a failure after the in-place respawn leaves the seat re-wakeable from
|
||||
// its provider session file (never destroyed). Inspect tmux/daemon logs and retry.
|
||||
guidance: "The seat's registry binding is unchanged. If the failure was after the in-place respawn, the seat is re-wakeable from its provider session file. Inspect tmux/daemon logs and retry.",
|
||||
guidance: launch.code === "successor_preflight_failed"
|
||||
? `The predecessor is still running and its binding is unchanged. ${launch.message}`
|
||||
: "The seat's registry binding is unchanged. If the failure was after the in-place respawn, the seat is re-wakeable from its provider session file. Inspect tmux/daemon logs and retry.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -657,7 +661,7 @@ export class SeatHandoverService {
|
||||
// Partial: the successor is live in the preserved pane but the context packet never landed —
|
||||
// unwind the discovery candidate (cleanup marks it vanished; it NEVER kills the preserved seat)
|
||||
// and leave the binding unchanged (no false-green). The seat is re-wakeable from its session file.
|
||||
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId);
|
||||
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId);
|
||||
return {
|
||||
ok: false,
|
||||
code: "context_delivery_failed",
|
||||
@@ -742,7 +746,7 @@ export class SeatHandoverService {
|
||||
if (!delivered.ok) {
|
||||
// Same partial-state contract as the fresh packet: unwind the candidate,
|
||||
// binding unchanged, seat re-wakeable — never a false complete.
|
||||
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId);
|
||||
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId);
|
||||
return {
|
||||
ok: false,
|
||||
code: "context_delivery_failed",
|
||||
@@ -779,8 +783,8 @@ export class SeatHandoverService {
|
||||
sourceOutcome,
|
||||
launchAdditionNotes,
|
||||
startedWithoutRequired,
|
||||
launchWarnings: coreCopyNotes,
|
||||
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId),
|
||||
launchWarnings: [...coreCopyNotes, ...(launch.warnings ?? [])],
|
||||
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1458,10 +1462,6 @@ export class SeatHandoverService {
|
||||
};
|
||||
}
|
||||
committed = outcome;
|
||||
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
|
||||
// oracle sees the swap as its own event, keyed by the durable node id, identified
|
||||
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
|
||||
this.activityOracle?.declareOccupantSwap(input.node.id, committed.newSessionId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1471,6 +1471,16 @@ export class SeatHandoverService {
|
||||
};
|
||||
}
|
||||
|
||||
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
|
||||
// oracle sees the swap as its own event, keyed by the durable node id, identified
|
||||
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
|
||||
try {
|
||||
this.activityOracle?.declareOccupantSwap(input.node.id, committed.newSessionId);
|
||||
} catch (err) {
|
||||
// A notification failure cannot roll back the committed binding or abandon its grant.
|
||||
console.warn(`Seat activity swap notification failed: ${err instanceof Error ? err.name : typeof err}`);
|
||||
}
|
||||
|
||||
this.eventBus.notifySubscribers(committed.event);
|
||||
const postStatus = this.statusService.getStatus(`${input.status.logical_id}@${input.status.rig_name}`);
|
||||
const currentStatus = postStatus.ok
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PiRunnerState } from "../adapters/pi-runner-protocol.js";
|
||||
import { ActivityGrantStore, hasActivityGrantsTable } from "./activity-grants.js";
|
||||
import { observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, listNativeProcesses, type NativeProcessLister, type NativeProcessObservation } from "./native-process-lineage.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { claudeResumeRotation } from "./session-registry.js";
|
||||
@@ -91,6 +93,7 @@ export interface SeatIdentityReconcilerDeps {
|
||||
tmux: Pick<TmuxAdapter, "listSessions" | "getPanePid" | "getPaneCommand"> & Partial<Pick<TmuxAdapter, "readAllPaneProcesses" | "listPanes">>;
|
||||
now?: () => Date;
|
||||
listProcesses?: NativeProcessLister;
|
||||
readPiRunnerState?: (sessionName: string) => PiRunnerState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +113,7 @@ export class SeatIdentityReconciler {
|
||||
private readonly now: () => Date;
|
||||
private readonly store: SeatIdentityStore;
|
||||
private readonly listProcesses: NativeProcessLister;
|
||||
private readonly readPiRunnerState?: SeatIdentityReconcilerDeps["readPiRunnerState"];
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconciling = false;
|
||||
private generation = 0;
|
||||
@@ -120,6 +124,7 @@ export class SeatIdentityReconciler {
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.store = new SeatIdentityStore(deps.db);
|
||||
this.listProcesses = deps.listProcesses ?? listNativeProcesses;
|
||||
this.readPiRunnerState = deps.readPiRunnerState;
|
||||
}
|
||||
|
||||
private runningSeats(): RunningSeatRow[] {
|
||||
@@ -172,6 +177,23 @@ export class SeatIdentityReconciler {
|
||||
|
||||
private async reconcileSweep(generation: number): Promise<void> {
|
||||
const seats = this.runningSeats();
|
||||
// Capture exact issuance before any observation awaits. A delayed verdict cannot revoke a successor.
|
||||
const grants = hasActivityGrantsTable(this.db) ? new ActivityGrantStore(this.db) : null;
|
||||
const observedGrants = new Map(seats.map(seat => {
|
||||
const grant = grants?.activeForNode(seat.node_id);
|
||||
let bootstrapComplete = false;
|
||||
if (grant?.binding?.runtime === "pi" && grant.binding.runner === 0) {
|
||||
// A shell can be the launch bootstrap. Capture current-attempt readiness BEFORE observing the pane;
|
||||
// a later ready result cannot turn an earlier bootstrap-shell sample into a definitive exit.
|
||||
try {
|
||||
const state = this.readPiRunnerState?.(seat.session_name);
|
||||
bootstrapComplete = !!state && state.launchId === grant.runId && (state.ready || !!state.exited);
|
||||
} catch { /* Missing launch evidence is unknown, not proof that the runner exited. */ }
|
||||
}
|
||||
return [seat.node_id, grant?.runId && grant.binding?.pane === seat.tmux_pane
|
||||
? { grantId: grant.grantId, shellExitConfirmed: grant.binding.runtime !== "pi" || grant.binding.runner !== 0 || bootstrapComplete }
|
||||
: null];
|
||||
}));
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded), except a released seat's.
|
||||
this.store.pruneExcept([...seats.map((s) => s.node_id), ...this.releasedSeats()]);
|
||||
if (seats.length === 0) return;
|
||||
@@ -272,6 +294,11 @@ export class SeatIdentityReconciler {
|
||||
const verdict = await this.computeVerdict(seat, liveSessions, observedAt, nativeProofs.get(seat.node_id) ?? null, panes);
|
||||
if (generation !== this.generation) return;
|
||||
this.store.upsert(verdict);
|
||||
const observedGrant = observedGrants.get(seat.node_id);
|
||||
if (observedGrant && (verdict.verdict === "pane_missing"
|
||||
|| (verdict.verdict === "mismatch" && verdict.reason === "process_identity_mismatch" && observedGrant.shellExitConfirmed))) {
|
||||
grants!.revoke({ grantId: observedGrant.grantId }, "runner_exit_observed");
|
||||
}
|
||||
} catch {
|
||||
if (generation !== this.generation) return;
|
||||
// A single seat's tmux failure must not crash the loop; record it as
|
||||
@@ -342,9 +369,15 @@ export class SeatIdentityReconciler {
|
||||
}
|
||||
const pid = batched ? batched.pid : await this.tmux.getPanePid(seat.tmux_pane);
|
||||
if (pid === null) {
|
||||
// The registered pane no longer resolves. Distinguish "the whole tmux
|
||||
// session is gone" from "the pane within a live session is gone".
|
||||
// getPanePid collapses transport/read failures into null. Only a readable census
|
||||
// showing another pane, or a successful session snapshot proving absence, establishes loss.
|
||||
const sessionAlive = liveSessions.has(seat.session_name);
|
||||
if (sessionAlive) {
|
||||
const currentPanes = await this.tmux.listPanes?.(seat.session_name, true).catch(() => null);
|
||||
if (!currentPanes?.length || currentPanes.some(pane => pane.id === seat.tmux_pane)) {
|
||||
return this.tmuxUnavailableVerdict(seat, observedAt);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
verdict: "pane_missing",
|
||||
|
||||
@@ -951,7 +951,7 @@ export class SeatLifecycleService {
|
||||
let persisted: PersistedEvent | null = null;
|
||||
const result = this.db.transaction(() => {
|
||||
// Read the current record for the audit; malformed old bytes stay visible as such and are replaced.
|
||||
const row = this.db.prepare("SELECT codex_permissions_json FROM nodes WHERE id = ? AND runtime = 'codex'").get(seat.nodeId) as { codex_permissions_json: string | null } | undefined;
|
||||
const row = this.db.prepare("SELECT codex_permissions_json, codex_config_profile FROM nodes WHERE id = ? AND runtime = 'codex'").get(seat.nodeId) as { codex_permissions_json: string | null; codex_config_profile: string | null } | undefined;
|
||||
if (!row) throw new Error("Seat runtime changed; the envelope was not changed.");
|
||||
let from: unknown = null;
|
||||
try { from = readCodexMemberPermissions(this.db, seat.nodeId) ?? null; }
|
||||
@@ -968,7 +968,7 @@ export class SeatLifecycleService {
|
||||
nodeId: seat.nodeId, from, to, ...(replaced ? { replaced } : {}), actor, reason, effect: "future_launches_only" });
|
||||
}
|
||||
return { ok: true as const, seat, from, to, changed, ...(replaced ? { replaced } : {}),
|
||||
effect: `The envelope applies at the next separately authorized managed launch or resume, as explicit sandbox and approval flags that a profile or policy posture doesn't override.${replaced ? " It replaces the seat's recorded permission mode." : ""} The current process and history are unchanged. Older readers may ignore it; check rollback compatibility and preserve restrictions before downgrading.` };
|
||||
effect: `The envelope applies at the next separately authorized managed launch or resume, ${"filesystem" in to ? "as explicit permission and approval flags, with native configuration conflicts refused and inconclusive checks reported as policy not verified." : "as explicit sandbox and approval flags that a profile or policy posture doesn't override."}${replaced ? " It replaces the seat's recorded permission mode." : ""} The current process and history are unchanged. Older readers may ignore it; check rollback compatibility and preserve restrictions before downgrading.` };
|
||||
})();
|
||||
if (persisted) this.eventBus.notifySubscribers(persisted);
|
||||
return result;
|
||||
@@ -1940,7 +1940,11 @@ export class SeatLifecycleService {
|
||||
const probed = await this.probeLiveness(session.session_name, "in-place restart refuses rather than act on an unknown session");
|
||||
if ("code" in probed) return probed;
|
||||
if (probed.state === "absent") {
|
||||
return { ok: false, code: "session_not_live", message: `Session "${session.session_name}" is absent in tmux; there is nothing to restart in place.` };
|
||||
return { ok: false, code: "session_not_live", message: `Session "${session.session_name}" is absent in tmux; there is nothing to restart in place.`,
|
||||
// A recoverable seat (#3131) comes back in its saved conversation through the ordinary launch, leaving the rest of the rig alone.
|
||||
...(resolved.entry.lifecycleState === "recoverable"
|
||||
? { guidance: `The seat is recoverable from its saved conversation: rig launch ${shellQuote(seat.rigName)} ${shellQuote(seat.logicalId)} (add --plan to preview).` }
|
||||
: {}) };
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(node.id);
|
||||
if (!binding?.tmuxSession || binding.tmuxSession !== session.session_name || !binding.tmuxPane) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { piAccountStateRoot, piAccountSwitchStateRoot } from "./pi-account-state.js";
|
||||
import { piSeatPaths } from "../adapters/pi-runner-protocol.js";
|
||||
@@ -416,7 +417,9 @@ export class SeatRuntimeSwitchService {
|
||||
.run(id, node.id, sessionName, row.staging_session, ctx.predecessorPane, ctx.predecessorGeneration, successorGeneration, node.runtime, target,
|
||||
ctx.input.reason, ctx.input.requestedBy ?? null, JSON.stringify(ctx.plan), deadlineAt, stamp(), stamp());
|
||||
let successorPane: string | null = null;
|
||||
let activityGrantId: string | undefined;
|
||||
const abandon = async (why: string): Promise<RuntimeSwitchRefusal> => {
|
||||
if (activityGrantId) new ActivityGrantStore(db).revoke({ grantId: activityGrantId }, "cutover_abandoned");
|
||||
const retired = await this.retireStaging(row.staging_session, node.id, "Uncommitted successor");
|
||||
db.prepare(`UPDATE seat_runtime_switches SET phase = 'abandoned', outcome = ?, attention = ?, updated_at = ? WHERE id = ? AND committed_at IS NULL`)
|
||||
.run(why, retired.ok ? null : `The staged successor couldn't be retired: ${retired.message}`, stamp(), id);
|
||||
@@ -486,6 +489,7 @@ export class SeatRuntimeSwitchService {
|
||||
const attention = launch.code === "successor_attention_required" ? " It is waiting at a login, trust or approval prompt." : "";
|
||||
return abandon(`The ${target} successor didn't start: ${launch.message}.${attention}`);
|
||||
}
|
||||
activityGrantId = launch.activityGrantId;
|
||||
const warnings = [...ctx.plan.warnings, ...(launch.notes ?? [])];
|
||||
|
||||
// 2. The handover, as the successor's first turn; the confirmation it asks for is the evidence it can carry on.
|
||||
@@ -546,6 +550,7 @@ export class SeatRuntimeSwitchService {
|
||||
committed = this.commit({ ...ctx, id, successorGeneration, successorPane: successorPane!, appliedLaunch: launch.appliedLaunch,
|
||||
launchResume: launch.resumeToken ? { type: launch.resumeType, token: launch.resumeToken } : null });
|
||||
} catch (error) {
|
||||
if (activityGrantId) new ActivityGrantStore(db).revoke({ grantId: activityGrantId }, "cutover_failed");
|
||||
const back = await tmux.swapRuntimeSwitchPanes({ nodeId: node.id, stagingSession: row.staging_session, canonicalSession: sessionName,
|
||||
canonicalPane: successorPane!, stagedPane: ctx.predecessorPane });
|
||||
if (!back.ok) {
|
||||
@@ -728,6 +733,13 @@ export class SeatRuntimeSwitchService {
|
||||
const row = this.openSwitch(nodeId);
|
||||
if (!row) return { ok: true, outcome: "No runtime switch needs recovery.", switchId: null };
|
||||
const { db, tmuxAdapter: tmux } = this.deps;
|
||||
if (!row.committed_at) {
|
||||
const grants = new ActivityGrantStore(db);
|
||||
// The durable operation owns this unique reservation. Capture/revoke its exact pending IDs before awaits;
|
||||
// active predecessor authority and other reservations are outside this recovery.
|
||||
const pending = grants.pendingForGeneration(nodeId, row.successor_generation);
|
||||
for (const grant of pending) grants.revoke({ grantId: grant.grantId }, "interrupted_cutover_abandoned");
|
||||
}
|
||||
const at = () => this.now().toISOString();
|
||||
const stuck = (message: string) => {
|
||||
db.prepare("UPDATE seat_runtime_switches SET attention = ?, updated_at = ? WHERE id = ?").run(message, at(), row.id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { monotonicFactory } from "ulid";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -327,6 +328,8 @@ export class SessionRegistry {
|
||||
.run(id, nodeId, sessionName);
|
||||
this.mintOccupantTenureBestEffort(nodeId, kind, reservedGeneration); // atom-B: mint this occupant generation
|
||||
this.observeWatchdogRegistration(nodeId, sessionName, kind);
|
||||
try { new ActivityGrantStore(this.db).reconcileBinding(nodeId); }
|
||||
catch { /* Optional run attribution must not fail an already registered claimed session. */ }
|
||||
|
||||
return this.rowToSession(
|
||||
this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRow
|
||||
@@ -492,6 +495,15 @@ export class SessionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name existence is independent of best-effort tenure recording. */
|
||||
carriesSessionName(sessionName: string): boolean {
|
||||
return !!this.db.prepare(`SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE r.archived_at IS NULL AND (
|
||||
EXISTS (SELECT 1 FROM sessions s WHERE s.node_id = n.id AND s.session_name = ?)
|
||||
OR EXISTS (SELECT 1 FROM bindings b WHERE b.node_id = n.id AND (b.tmux_session = ? OR b.external_session_name = ?))
|
||||
) LIMIT 1`).get(sessionName, sessionName, sessionName);
|
||||
}
|
||||
|
||||
private rowToTenure(row: OccupantTenureRow): OccupantTenure {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -505,6 +517,7 @@ export class SessionRegistry {
|
||||
}
|
||||
|
||||
updateStatus(sessionId: string, status: string): void {
|
||||
if (["exited", "detached", "superseded"].includes(status)) new ActivityGrantStore(this.db).revokeForSession(sessionId, status);
|
||||
this.db
|
||||
.prepare("UPDATE sessions SET status = ?, intentionally_stopped = 0, last_seen_at = datetime('now') WHERE id = ?")
|
||||
.run(status, sessionId);
|
||||
@@ -513,6 +526,7 @@ export class SessionRegistry {
|
||||
/** A supported stop is distinct from an observed exit. Any later status write
|
||||
* clears the intent, including a resume of this same session row. */
|
||||
markIntentionallyStopped(sessionId: string): void {
|
||||
new ActivityGrantStore(this.db).revokeForSession(sessionId, "stopped");
|
||||
this.db.prepare(
|
||||
"UPDATE sessions SET status = 'exited', intentionally_stopped = 1, last_seen_at = datetime('now') WHERE id = ?",
|
||||
).run(sessionId);
|
||||
|
||||
@@ -2686,7 +2686,7 @@ export class SessionTransport {
|
||||
// segment must never edit a line of the exact text.
|
||||
if (opts?.exactText !== undefined && !opts.dangerouslyInteract) {
|
||||
const { exactText, ...rest } = opts;
|
||||
if (!isBoundTerminalSeat(this.db, sessionName)) return this.send(sessionName, text, rest);
|
||||
if (!isBoundTerminalSeat(this.db, sessionName) && !isBoundTerminalSeat(this.db, physicalSessionName(this.db, sessionName))) return this.send(sessionName, text, rest);
|
||||
return { ...(await this.send(sessionName, exactText, { ...rest, stampISO: undefined, typedExactly: true })), envelopeOmitted: true };
|
||||
}
|
||||
const observe = () => { try { return this.compactionObserver?.(sessionName) ?? null; } catch { return null; } };
|
||||
@@ -3930,12 +3930,13 @@ export class SessionTransport {
|
||||
|
||||
/** Wait on the existing classifier without occupying the input/lifecycle lease. With `singleObservation`, one
|
||||
* observation within `timeoutMs` and no wait after it: a busy reading returns at once. */
|
||||
async waitUntilIdle(sessionName: string, timeoutMs: number, signal?: AbortSignal, opts?: { singleObservation?: boolean; allowDraft?: true }) {
|
||||
async waitUntilIdle(sessionName: string, timeoutMs: number, signal?: AbortSignal, opts?: { singleObservation?: boolean; allowDraft?: true; onObservation?: (activity: AgentActivity) => void }) {
|
||||
const meta = this.getSessionMeta(sessionName);
|
||||
return this.waitForIdle({ sessionName, runtime: meta.runtime, attachmentType: meta.attachmentType, timeoutMs, signal,
|
||||
binding: { sessionName, nodeId: meta.nodeId, occupant: meta.occupant, pane: meta.pane },
|
||||
...(opts?.allowDraft ? { allowDraft: true } : {}),
|
||||
...(opts?.singleObservation ? { singleObservation: true } : {}) });
|
||||
...(opts?.singleObservation ? { singleObservation: true } : {}),
|
||||
...(opts?.onObservation ? { onObservation: opts.onObservation } : {}) });
|
||||
}
|
||||
|
||||
private async waitForIdle(input: {
|
||||
@@ -3954,6 +3955,8 @@ export class SessionTransport {
|
||||
allowUnknown?: boolean;
|
||||
/** One observation only: a reading that isn't idle ends the wait at once. */
|
||||
singleObservation?: boolean;
|
||||
/** Called with each observation that completes in time, so a caller can report the seat's state while it waits. */
|
||||
onObservation?: (activity: AgentActivity) => void;
|
||||
// Optional display value for timeout messages. The deadline always comes
|
||||
// from timeoutMs; when set, expiry reports this value instead, so a
|
||||
// re-wait running on a leftover budget still names the configured wait.
|
||||
@@ -4024,6 +4027,7 @@ export class SessionTransport {
|
||||
return expire();
|
||||
}
|
||||
last = activity;
|
||||
input.onObservation?.(activity);
|
||||
const waitedMs = input.timeoutMs - (deadline - Date.now());
|
||||
|
||||
if (activity.state === "idle" || (input.allowUnknown && activity.state === "unknown") || (input.allowDraft && this.deliversOverDraft(activity))) {
|
||||
|
||||
@@ -93,7 +93,8 @@ export function parseSkillFrontmatter(content: string): ParseResult {
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(fmText!);
|
||||
// The delimiter's preceding LF is outside the capture; on CRLF files its CR is not.
|
||||
parsed = parseYaml(fmText!.replace(/\r$/, ""));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, reason: `frontmatter YAML parse error: ${msg}` };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import { nonInterruptiveNotice } from "../adapters/non-interruptive.js";
|
||||
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -414,6 +415,7 @@ export class StartupOrchestrator {
|
||||
? "rebuilt"
|
||||
: "fresh";
|
||||
let appliedLaunch: AppliedLaunchObservation | undefined;
|
||||
let activityGrantId: string | undefined;
|
||||
let resumedProcess: { pid: number; startedAt: string } | undefined;
|
||||
const forkContext: PendingForkContext | undefined = input.adapter.runtime === "codex"
|
||||
&& input.forkSource?.kind === "native_id" && input.forkSource.value?.trim()
|
||||
@@ -691,6 +693,7 @@ export class StartupOrchestrator {
|
||||
warnings.push(`Model "${launchResult.modelInput.model}": the launch command's send is uncertain, so it isn't recorded as passed.`);
|
||||
}
|
||||
if (launchResult.ok) {
|
||||
activityGrantId = launchResult.activityGrantId;
|
||||
appliedLaunch = launchResult.appliedLaunch;
|
||||
resumedProcess = launchResult.resumedProcess;
|
||||
if (input.adapter.runtime === "claude-code" && launchResumeToken?.trim()) {
|
||||
@@ -820,6 +823,7 @@ export class StartupOrchestrator {
|
||||
const readinessTimeoutMs = resolveReadinessTimeoutMs(input.readinessTimeoutMs, this.readinessSettings);
|
||||
const readiness = await this.waitForReady(input.adapter, input.binding, readinessTimeoutMs);
|
||||
if (!readiness.ready) {
|
||||
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "readiness_failed");
|
||||
const hasPendingReadinessWork = getPendingReadinessWork();
|
||||
if (isAttentionRequiredReadinessCode(readiness.code)) {
|
||||
errors.push(`Startup requires attention: ${readiness.reason ?? "unknown"}`);
|
||||
@@ -829,6 +833,7 @@ export class StartupOrchestrator {
|
||||
return this.fail(input, forkContext ? "attention_required" : "failed", errors, undefined, forkContext ?? false, undefined, false, "readiness_check", hasPendingReadinessWork);
|
||||
}
|
||||
} catch (err) {
|
||||
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "readiness_failed");
|
||||
errors.push(`Readiness check error: ${(err as Error).message}`);
|
||||
return this.fail(input, "failed", errors, undefined, false, undefined, false, "readiness_check", getPendingReadinessWork());
|
||||
}
|
||||
@@ -994,6 +999,7 @@ export class StartupOrchestrator {
|
||||
try {
|
||||
const readiness = await input.adapter.checkReady(input.binding);
|
||||
if (!readiness.ready && isAttentionRequiredReadinessCode(readiness.code)) {
|
||||
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "post_delivery_readiness_failed");
|
||||
// A fresh Claude start whose last startup turn was refused hasn't read what that turn sent: it's owed too.
|
||||
const refusedLast = input.adapter.runtime === "claude-code" && isFreshLaunch && deliveryInput.sendOrder > 0
|
||||
? owed([], readiness) : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import { ulid } from "ulid";
|
||||
import { seatEnvLaunchVars, type SeatEnv } from "./seat-env.js";
|
||||
import { SEAT_NODE_ENV, SEAT_PROJECT_DIR_ENV } from "./claude-core-plugin-env.js";
|
||||
@@ -94,6 +95,7 @@ export type SuccessorLaunchResult =
|
||||
| {
|
||||
ok: true;
|
||||
discoveredId: string;
|
||||
activityGrantId?: string;
|
||||
tmuxSession: string;
|
||||
tmuxPane: string;
|
||||
resumeToken?: string;
|
||||
@@ -248,6 +250,21 @@ export class SuccessorSessionLauncher {
|
||||
};
|
||||
}
|
||||
|
||||
const binding = successorBinding(input.node, departingSession, pane.id, cwd, input.occupantGeneration);
|
||||
const adapter = input.node.runtime ? this.runtimeAdapters[input.node.runtime] : undefined;
|
||||
let preflightWarnings: string[] = [];
|
||||
if (adapter?.preflightLaunch) {
|
||||
try {
|
||||
const checked = await adapter.preflightLaunch(binding);
|
||||
if (!checked.ok) return { ok: false, code: "successor_preflight_failed", step: "create_successor",
|
||||
message: checked.error, replacementStarted: false };
|
||||
preflightWarnings = checked.warnings ?? [];
|
||||
} catch (error) {
|
||||
return { ok: false, code: "successor_preflight_failed", step: "create_successor",
|
||||
message: `Successor launch preflight threw: ${error instanceof Error ? error.message : String(error)}`, replacementStarted: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Terminate the retiree IN PLACE, then respawn the now-dead pane WITHOUT -k so native scrollback
|
||||
// survives (respawn-pane -k CLEARS the pane history — verified on tmux 3.6a; that would defeat the
|
||||
// money-proof). The swap: set remain-on-exit so the pane survives the exit → graceful SIGTERM
|
||||
@@ -302,7 +319,7 @@ export class SuccessorSessionLauncher {
|
||||
// not repointed. When the harness launch was already sent and only readiness failed, the pane is NOT left as
|
||||
// a bare shell: the successor may still come up there, unadopted (#2985). The failure says so, so the caller
|
||||
// can tell the operator that a retry would end it.
|
||||
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration);
|
||||
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration, undefined, binding);
|
||||
if (!started.ok) {
|
||||
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true,
|
||||
tmuxPane: pane.id, ...(started.launched ? { successorLaunched: true } : {}) };
|
||||
@@ -318,6 +335,7 @@ export class SuccessorSessionLauncher {
|
||||
cwd: input.node.cwd ?? undefined,
|
||||
});
|
||||
|
||||
const warnings = [...preflightWarnings, ...(respawned.warnings ?? [])];
|
||||
return {
|
||||
ok: true,
|
||||
discoveredId: discovered.id,
|
||||
@@ -326,8 +344,9 @@ export class SuccessorSessionLauncher {
|
||||
resumeToken: started.resumeToken,
|
||||
resumeType: started.resumeType,
|
||||
appliedLaunch: started.appliedLaunch,
|
||||
...(started.activityGrantId ? { activityGrantId: started.activityGrantId } : {}),
|
||||
retireeExit: terminated.path,
|
||||
...(respawned.warnings ? { warnings: respawned.warnings } : {}),
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -345,7 +364,7 @@ export class SuccessorSessionLauncher {
|
||||
occupantGeneration: string;
|
||||
onPaneCreated: (pane: string) => void;
|
||||
staged: Omit<StagedLaunch, "canonicalSession">;
|
||||
}): Promise<{ ok: true; tmuxPane: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] }
|
||||
}): Promise<{ ok: true; activityGrantId?: string; tmuxPane: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] }
|
||||
| { ok: false; code: string; message: string }> {
|
||||
const env = compactEnv({
|
||||
OPENRIG_NODE_ID: input.node.id,
|
||||
@@ -394,7 +413,8 @@ export class SuccessorSessionLauncher {
|
||||
forkSource?: ForkSource,
|
||||
launchGeneration?: string | null,
|
||||
staged?: StagedLaunch,
|
||||
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
|
||||
preparedBinding?: NodeBinding,
|
||||
): Promise<{ ok: true; activityGrantId?: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
|
||||
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
|
||||
if (!adapter) {
|
||||
return {
|
||||
@@ -411,39 +431,7 @@ export class SuccessorSessionLauncher {
|
||||
// edge of the same seat, so its launch must read the seat's spec, else the
|
||||
// running topology silently drifts from the founder-designed one at every
|
||||
// handover. (config profile is still a tracked follow-on.)
|
||||
const binding: NodeBinding = {
|
||||
id: "",
|
||||
nodeId: node.id,
|
||||
attachmentType: "tmux",
|
||||
tmuxSession,
|
||||
tmuxWindow: null,
|
||||
tmuxPane,
|
||||
cmuxWorkspace: null,
|
||||
cmuxSurface: null,
|
||||
updatedAt: "",
|
||||
cwd: cwd ?? "",
|
||||
launchGeneration: launchGeneration ?? undefined,
|
||||
nonInterruptive: node.nonInterruptive,
|
||||
kernelAuthority: node.kernelAuthority,
|
||||
teamPermissionDefault: node.teamPermissionDefault,
|
||||
claudePermissions: node.claudePermissions,
|
||||
claudeMcp: node.runtime === "claude-code" ? node.claudeMcp : undefined,
|
||||
codexPermissions: node.codexPermissions,
|
||||
// Seam B: continuity — the successor launches at the departing seat's posture.
|
||||
...(node.launchPosture ? { launchPosture: node.launchPosture } : {}),
|
||||
...(node.permissionMode ? { permissionMode: node.permissionMode } : {}),
|
||||
// 0.5.2-07: the successor reads the seat's SPEC-pinned model (adapter emits -m/--model).
|
||||
model: node.model ?? undefined,
|
||||
// #75: the successor reads the seat's resolved effort (saved choice, else authored).
|
||||
effort: node.effort ?? undefined,
|
||||
advisor: node.advisor ?? undefined,
|
||||
// 0.5.2-07 A4-profile: the successor reads the seat's SPEC-pinned codex config profile (adapter emits -p).
|
||||
codexConfigProfile: node.codexConfigProfile ?? undefined,
|
||||
codexRebuildPermissions: node.codexRebuildPermissions,
|
||||
...(node.launchAdditions ? { launchAdditions: node.launchAdditions } : {}),
|
||||
...(node.launchWrapper?.length ? { launchWrapper: node.launchWrapper } : {}),
|
||||
...(staged?.binding ?? {}),
|
||||
};
|
||||
const binding = preparedBinding ?? successorBinding(node, tmuxSession, tmuxPane, cwd, launchGeneration, staged?.binding);
|
||||
|
||||
// Slice 16: a staged runtime-switch successor gets its target materials before its harness starts.
|
||||
let notes: string[] | undefined;
|
||||
@@ -483,10 +471,14 @@ export class SuccessorSessionLauncher {
|
||||
// Catch it here and return a STRUCTURED ok:false so the caller fails loudly at
|
||||
// step=start_agent, exactly like a returned readiness failure. Nothing kills the
|
||||
// pane (unwind invariant above): the launched successor may still be running there.
|
||||
const revokeRun = () => {
|
||||
if (launch.activityGrantId) new ActivityGrantStore(this.discoveryRepo.db).revoke({ grantId: launch.activityGrantId }, "successor_readiness_failed");
|
||||
};
|
||||
let readiness: ReadinessResult;
|
||||
try {
|
||||
readiness = await this.waitForReady(adapter, binding);
|
||||
} catch (err) {
|
||||
revokeRun();
|
||||
return {
|
||||
ok: false,
|
||||
code: "successor_readiness_failed",
|
||||
@@ -495,6 +487,7 @@ export class SuccessorSessionLauncher {
|
||||
};
|
||||
}
|
||||
if (!readiness.ready) {
|
||||
revokeRun();
|
||||
return {
|
||||
ok: false,
|
||||
code: isAttentionRequiredReadinessCode(readiness.code) ? "successor_attention_required" : "successor_not_ready",
|
||||
@@ -503,7 +496,7 @@ export class SuccessorSessionLauncher {
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, resumeToken, resumeType, appliedLaunch: launch.appliedLaunch, ...(notes?.length ? { notes } : {}) };
|
||||
return { ok: true, resumeToken, resumeType, ...(launch.activityGrantId ? { activityGrantId: launch.activityGrantId } : {}), appliedLaunch: launch.appliedLaunch, ...(notes?.length ? { notes } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -564,11 +557,49 @@ export class SuccessorSessionLauncher {
|
||||
* candidate vanished; the re-wakeable shell stays in the pane and the seat is recoverable from its
|
||||
* provider session file. (`tmuxSession` is retained for signature stability + call-site logging.)
|
||||
*/
|
||||
async cleanup(_tmuxSession: string, discoveredId: string | null): Promise<void> {
|
||||
async cleanup(_tmuxSession: string, discoveredId: string | null, activityGrantId?: string): Promise<void> {
|
||||
if (activityGrantId) new ActivityGrantStore(this.discoveryRepo.db).revoke({ grantId: activityGrantId }, "cutover_abandoned");
|
||||
if (discoveredId) this.discoveryRepo.markVanished([discoveredId]);
|
||||
}
|
||||
}
|
||||
|
||||
function successorBinding(node: SuccessorNode, tmuxSession: string, tmuxPane: string, cwd: string | undefined,
|
||||
launchGeneration?: string | null, bindingOverrides?: StagedLaunch["binding"]): NodeBinding {
|
||||
return {
|
||||
id: "",
|
||||
nodeId: node.id,
|
||||
attachmentType: "tmux",
|
||||
tmuxSession,
|
||||
tmuxWindow: null,
|
||||
tmuxPane,
|
||||
cmuxWorkspace: null,
|
||||
cmuxSurface: null,
|
||||
updatedAt: "",
|
||||
cwd: cwd ?? "",
|
||||
launchGeneration: launchGeneration ?? undefined,
|
||||
nonInterruptive: node.nonInterruptive,
|
||||
kernelAuthority: node.kernelAuthority,
|
||||
teamPermissionDefault: node.teamPermissionDefault,
|
||||
claudePermissions: node.claudePermissions,
|
||||
claudeMcp: node.runtime === "claude-code" ? node.claudeMcp : undefined,
|
||||
codexPermissions: node.codexPermissions,
|
||||
// Seam B: continuity — the successor launches at the departing seat's posture.
|
||||
...(node.launchPosture ? { launchPosture: node.launchPosture } : {}),
|
||||
...(node.permissionMode ? { permissionMode: node.permissionMode } : {}),
|
||||
// 0.5.2-07: the successor reads the seat's SPEC-pinned model (adapter emits -m/--model).
|
||||
model: node.model ?? undefined,
|
||||
// #75: the successor reads the seat's resolved effort (saved choice, else authored).
|
||||
effort: node.effort ?? undefined,
|
||||
advisor: node.advisor ?? undefined,
|
||||
// 0.5.2-07 A4-profile: the successor reads the seat's SPEC-pinned codex config profile (adapter emits -p).
|
||||
codexConfigProfile: node.codexConfigProfile ?? undefined,
|
||||
codexRebuildPermissions: node.codexRebuildPermissions,
|
||||
...(node.launchAdditions ? { launchAdditions: node.launchAdditions } : {}),
|
||||
...(node.launchWrapper?.length ? { launchWrapper: node.launchWrapper } : {}),
|
||||
...(bindingOverrides ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function compactEnv(input: Record<string, string | undefined>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { getDefaultOpenRigPath } from "../../openrig-compat.js";
|
||||
import type { TaskTerminalView, TaskMembership } from "./task-terminal-view.js";
|
||||
import { composeView, type ViewMemberInput } from "./view-composer.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { buildGridRoot } from "./herdr-adapter.js";
|
||||
import { daemonTmuxServerArgs, daemonTmuxServerInfo } from "../../adapters/tmux-server.js";
|
||||
@@ -293,17 +294,21 @@ export class TerminalService {
|
||||
: errorResult(name, "unsupported_terminal_session", `Provider '${name}' does not support selecting a named session. Use herdr with --session <name>.`);
|
||||
}
|
||||
|
||||
/** Resolved once, so a preview and the open that follows compose the same pane commands. */
|
||||
private resolveLocalTmux(): Promise<string | undefined> {
|
||||
this.localTmux ??= (async () => {
|
||||
try {
|
||||
const path = await this.deps.resolveLocalTmux?.();
|
||||
return typeof path === "string" && isAbsolute(path) ? path : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
return this.localTmux;
|
||||
/** Resolved once, so a preview and the open that follows compose the same pane commands; resolved again once that
|
||||
* file is gone (e.g. a removed version-manager shim), so a long-running daemon never hands out a dead tmux path. */
|
||||
private async resolveLocalTmux(): Promise<string | undefined> {
|
||||
const cached = await (this.localTmux ??= this.probeLocalTmux());
|
||||
if (cached === undefined || existsSync(cached)) return cached;
|
||||
return this.localTmux = this.probeLocalTmux();
|
||||
}
|
||||
|
||||
private async probeLocalTmux(): Promise<string | undefined> {
|
||||
try {
|
||||
const path = await this.deps.resolveLocalTmux?.();
|
||||
return typeof path === "string" && isAbsolute(path) ? path : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** An omitted provider takes the first in DEFAULT_PROVIDER_ORDER that is available and alive. With neither, it stays
|
||||
|
||||
@@ -142,6 +142,10 @@ interface WatchdogPolicyEngineDeps {
|
||||
* SKIPPING, so an unknown target-generation must never skip a legitimate wake (note-2 inversion).
|
||||
*/
|
||||
resolveTargetGeneration?: (sessionName: string) => string | null;
|
||||
/** Whether an unarchived node still carries this name, even without a recorded tenure. When resolveTargetGeneration is
|
||||
* unknown, a carried name is unresolved (a context watch skips and measures again); one carried by
|
||||
* nothing is a seat that's gone (the watch ends). Absent → gone, as before. */
|
||||
targetNameCarried?: (sessionName: string) => boolean;
|
||||
/**
|
||||
* Issue #1180 — the target seat's latest context-usage sample under its live
|
||||
* occupant generation (generation-guarded by the jobs repository). Null = no
|
||||
@@ -275,6 +279,7 @@ export class WatchdogPolicyEngine {
|
||||
private readonly now: () => Date;
|
||||
private readonly policies: Map<string, Policy>;
|
||||
private readonly resolveTargetGeneration?: (sessionName: string) => string | null;
|
||||
private readonly targetNameCarried?: (sessionName: string) => boolean;
|
||||
private readonly resolveContextUsage?: WatchdogPolicyEngineDeps["resolveContextUsage"];
|
||||
private readonly resolvePreDeliveryTerminalReason?: (input: { jobId: string }) => string | null;
|
||||
private readonly resolveQueueWait?: WatchdogPolicyEngineDeps["resolveQueueWait"];
|
||||
@@ -287,6 +292,7 @@ export class WatchdogPolicyEngine {
|
||||
this.eventBus = deps.eventBus;
|
||||
this.deliver = deps.deliver;
|
||||
this.resolveTargetGeneration = deps.resolveTargetGeneration;
|
||||
this.targetNameCarried = deps.targetNameCarried;
|
||||
this.resolveContextUsage = deps.resolveContextUsage;
|
||||
this.resolvePreDeliveryTerminalReason = deps.resolvePreDeliveryTerminalReason;
|
||||
this.onWakeAttempt = deps.onWakeAttempt;
|
||||
@@ -432,6 +438,8 @@ export class WatchdogPolicyEngine {
|
||||
lastFiredCondition: job.lastFiredCondition,
|
||||
occupantGeneration,
|
||||
currentGenerationTranscriptPending,
|
||||
occupantGenerationAmbiguous: isContextUsageThreshold && !occupantGeneration
|
||||
&& (this.targetNameCarried?.(job.targetSession) ?? false),
|
||||
requiredReceiptSatisfied,
|
||||
requiredReceiptDeferred,
|
||||
};
|
||||
|
||||
@@ -353,6 +353,28 @@ export class WorkflowInstanceStore {
|
||||
return rows.map(rowToInstance);
|
||||
}
|
||||
|
||||
/** Apply selectors before the optional bound and before expensive inspection. */
|
||||
listFiltered(filters: { status?: WorkflowInstanceStatus; project?: string; mission?: string; slice?: string; rig?: string; name?: string; tags?: string[]; limit?: number }): WorkflowInstance[] {
|
||||
const clauses: string[] = [];
|
||||
const args: Array<string | number> = [];
|
||||
if (filters.status) { clauses.push("status = ?"); args.push(filters.status); }
|
||||
if (filters.name) { clauses.push("workflow_name = ?"); args.push(filters.name); }
|
||||
if (filters.rig) { clauses.push(this.hasBoundRigColumn ? "bound_rig = ?" : "0"); if (this.hasBoundRigColumn) args.push(filters.rig); }
|
||||
for (const field of ["project", "mission", "slice"] as const) {
|
||||
if (!filters[field]) continue;
|
||||
clauses.push(this.hasScopeColumn ? `json_extract(scope_json, '$.${field}') = ?` : "0");
|
||||
if (this.hasScopeColumn) args.push(filters[field]!);
|
||||
}
|
||||
for (const tag of filters.tags ?? []) {
|
||||
clauses.push(this.hasScopeColumn ? "EXISTS (SELECT 1 FROM json_each(workflow_instances.scope_json, '$.tags') WHERE value = ?)" : "0");
|
||||
if (this.hasScopeColumn) args.push(tag);
|
||||
}
|
||||
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
||||
const limit = filters.limit === undefined ? "" : " LIMIT ?";
|
||||
if (filters.limit !== undefined) args.push(filters.limit);
|
||||
return (this.db.prepare(`SELECT * FROM workflow_instances${where} ORDER BY created_at ASC${limit}`).all(...args) as InstanceRow[]).map(rowToInstance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frontier + status atomically. Caller is responsible for
|
||||
* wrapping in a transaction when this needs to compose with other
|
||||
|
||||
@@ -1845,8 +1845,9 @@ export class WorkflowRuntime {
|
||||
/** List instances (optionally filtered) with the deadline verdict attached. */
|
||||
listInstancesWithDeadline(
|
||||
status?: "active" | "waiting" | "completed" | "failed" | "aborted",
|
||||
filters?: Omit<Parameters<WorkflowInstanceStore["listFiltered"]>[0], "status">,
|
||||
): WorkflowInstanceWithInspection[] {
|
||||
const rows = status ? this.instanceStore.listByStatus(status) : this.instanceStore.listAll();
|
||||
const rows = filters ? this.instanceStore.listFiltered({ ...filters, status }) : status ? this.instanceStore.listByStatus(status) : this.instanceStore.listAll();
|
||||
return rows.map((row) => {
|
||||
const inspected = this.inspect(row.instanceId);
|
||||
return {
|
||||
|
||||
@@ -158,6 +158,14 @@ function bindToGrant(c: Context, store: AgentActivityStore, grant: ActivityGrant
|
||||
if (revoked && !permissionRoute) {
|
||||
return refuse(401, "activity_grant_revoked", "This activity grant was revoked; nothing was recorded.");
|
||||
}
|
||||
if (grant.runId && body.runId !== undefined && body.runId !== grant.runId) {
|
||||
return refuse(409, "activity_grant_stale", "This post names another run; nothing was recorded.");
|
||||
}
|
||||
const claimedRuntime = stringOrNull(body.runtime);
|
||||
if (grant.binding && ((claimedRuntime && claimedRuntime !== grant.binding.runtime)
|
||||
|| (body.eventFamily === "session_identity" && !claimedRuntime))) {
|
||||
return refuse(403, "activity_grant_runtime_mismatch", "This runner grant speaks only for its bound runtime; nothing was recorded.");
|
||||
}
|
||||
const claimedNode = stringOrNull(body.nodeId);
|
||||
const claimedSession = stringOrNull(body.sessionName);
|
||||
if ((claimedNode && claimedNode !== grant.nodeId)
|
||||
@@ -178,6 +186,15 @@ function bindToGrant(c: Context, store: AgentActivityStore, grant: ActivityGrant
|
||||
* hasn't registered yet (pending). A `revoked` grant whose occupant wasn't replaced reads revoked: it was reissued for
|
||||
* the same launch. `outcome` says what the refusal left undone. */
|
||||
function grantStandingRefusal(c: Context, store: AgentActivityStore, grant: ActivityGrant, outcome: string, revoked = false): Response | null {
|
||||
if (grant.runId) {
|
||||
const standing = new ActivityGrantStore(store.db).standing(grant.grantId);
|
||||
if (standing === "current") return null;
|
||||
if (standing === "unverified") {
|
||||
return c.json({ ok: false, code: "activity_grant_unverified", retryable: true,
|
||||
error: `This runner grant couldn't be verified now; retry verification; ${outcome}` }, 409);
|
||||
}
|
||||
return c.json({ ok: false, code: `activity_grant_${standing}`, error: `This runner grant is ${standing}; ${outcome}` }, standing === "revoked" ? 401 : 409);
|
||||
}
|
||||
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
|
||||
let standing: ReturnType<typeof grantGeneration>;
|
||||
try {
|
||||
@@ -211,7 +228,10 @@ async function piUsageRequest(c: Context, what: string, needs: (db: SessionRegis
|
||||
}
|
||||
const authHeader = c.req.header("authorization");
|
||||
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length).trim() : null;
|
||||
if (bearerToken !== expectedToken && (c.req.header("x-openrig-activity-token") ?? null) !== expectedToken) {
|
||||
const headerToken = c.req.header("x-openrig-activity-token") ?? null;
|
||||
const grantStore = new ActivityGrantStore(sessionRegistry.db);
|
||||
const grant = grantStore.authenticate(bearerToken) ?? grantStore.authenticate(headerToken);
|
||||
if (bearerToken !== expectedToken && headerToken !== expectedToken && !grant?.runId) {
|
||||
return { error: c.json({ ok: false, code: "activity_hook_unauthorized", error: `${what} require the configured local hook token.` }, 401) };
|
||||
}
|
||||
let body: Record<string, unknown>;
|
||||
@@ -223,6 +243,12 @@ async function piUsageRequest(c: Context, what: string, needs: (db: SessionRegis
|
||||
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
||||
return { error: c.json({ ok: false, code: "invalid_pi_usage", error: `${what} require a JSON object` }, 400) };
|
||||
}
|
||||
if (grant?.runId) {
|
||||
const bound = bindToGrant(c, store, grant, body);
|
||||
if ("refused" in bound) return { error: bound.refused };
|
||||
if (grant.binding?.runtime !== "pi") return { error: c.json({ ok: false, code: "activity_grant_wrong_seat", error: "Pi usage requires a Pi run." }, 403) };
|
||||
body = bound.body;
|
||||
}
|
||||
const sessionName = stringOrNull(body.sessionName);
|
||||
const piSessionId = stringOrNull(body.piSessionId);
|
||||
if (!sessionName || !piSessionId) {
|
||||
@@ -326,6 +352,9 @@ async function postHook(c: Context, installTokenAccepted: boolean): Promise<Resp
|
||||
return c.json({ ok: false, code: "invalid_json", error: "Request body must be a JSON object." }, 400);
|
||||
}
|
||||
|
||||
if (Object.hasOwn(body, "runId") && !grant?.runId) {
|
||||
return c.json({ ok: false, code: "run_grant_required", error: "Actual-run reports require their run-bound credential." }, 403);
|
||||
}
|
||||
if (typeof body.sessionName === "string") body = { ...body, sessionName: physicalSessionName(store.db, body.sessionName) };
|
||||
if (grant) {
|
||||
const bound = bindToGrant(c, store, grant, body);
|
||||
@@ -466,7 +495,7 @@ function isPiRpcHarness(db: import("better-sqlite3").Database, sessionId: string
|
||||
if (!generation) reason = "generation_unverifiable";
|
||||
else if (!current || !sessionRegistry.isOccupantGenerationRegistered(resolved.nodeId, generation)) {
|
||||
reason = "generation_unresolvable";
|
||||
} else if (current.generationUuid !== generation) reason = "generation_mismatch";
|
||||
} else if (current.generationUuid !== generation && !(grant?.runId && grant.generation === generation)) reason = "generation_mismatch";
|
||||
} catch {
|
||||
return c.json({
|
||||
ok: false, code: "generation_resolver_error", tokenPersisted: false,
|
||||
|
||||
@@ -63,8 +63,8 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
}
|
||||
|
||||
// Read the KNOWN context-usage projection BEFORE triggering. When usage is
|
||||
// absent / stale / unknown we pass null (never invent a value); the enforcer
|
||||
// returns an honest `no_usage_data` reason for a Claude seat.
|
||||
// absent / stale / unknown we pass null (never invent a value); an explicit
|
||||
// manual request doesn't depend on the threshold, so it proceeds without it.
|
||||
const usage = usageStore.getForNode(row.node_id, sessionName);
|
||||
const outcome = await enforcer.triggerManualCompact(
|
||||
{
|
||||
@@ -170,7 +170,8 @@ function manualOutcomeMessage(sessionName: string, outcome: Extract<ManualCompac
|
||||
const prep = outcome.preparation;
|
||||
if (!prep || prep.delivery === "not_sent") return manualReasonMessage(sessionName, outcome.reason);
|
||||
const effect = prep.delivery === "delivered" ? "Preparation was sent" : "Preparation may have reached the seat (delivery is unconfirmed)";
|
||||
const detail = (outcome.reason === "preparation_incomplete" || outcome.reason === "map_complete_seat_never_idle")
|
||||
const detail = (outcome.reason === "preparation_incomplete" || outcome.reason === "map_complete_seat_never_idle"
|
||||
|| outcome.reason === "preparation_needs_input")
|
||||
? manualReasonMessage(sessionName, outcome.reason)
|
||||
: outcome.restoreOwed
|
||||
? `/compact was not confirmed (${outcome.reason}) and is never sent again. Its restore steps follow in case it ran; the restore request tells the seat to say so and skip if it didn't compact.`
|
||||
@@ -202,6 +203,8 @@ function manualReasonMessage(sessionName: string, reason: string): string {
|
||||
return `The occupant generation could not be resolved for '${sessionName}'; no preparation or compact was sent. For the confirmed live seat, run rig reconcile-session ${shellQuote(sessionName)} --no-launch, then explicitly retry rig compact ${shellQuote(sessionName)}. Reconciliation does not restart this attempt.`;
|
||||
case "map_complete_seat_never_idle":
|
||||
return `Compaction for '${sessionName}': map complete, seat never idle within the bounded wait; managed compaction is disarmed. Inspect rig compact ${shellQuote(sessionName)} --state before an explicit retry.`;
|
||||
case "preparation_needs_input":
|
||||
return `Compaction for '${sessionName}' waited for the seat to answer a prompt (a permission request in its preparation turn, say) and it didn't become idle within the bounded wait; managed compaction is disarmed. Answer the prompt in the seat, then retry explicitly: rig compact ${shellQuote(sessionName)}. Inspect rig compact ${shellQuote(sessionName)} --state first.`;
|
||||
case "preparation_incomplete":
|
||||
return `The restore map was not completed in time for '${sessionName}'; managed compaction is disarmed. Inspect rig compact ${sessionName} --state, retry explicitly, or use --skip-map once.`;
|
||||
case "pane_unknown_protected":
|
||||
|
||||
@@ -44,22 +44,25 @@ import { CodexThreadIdResolver } from "../domain/codex-thread-id.js";
|
||||
import { resolveLiveCodexThreadId } from "../domain/model-divergence/current-generation-record.js";
|
||||
import { SeatIdentityStore } from "../domain/seat-identity-store.js";
|
||||
import { parseSqliteUtcMs } from "../domain/sqlite-time.js";
|
||||
import { runSyncSiteWhenSlow } from "../domain/sync-site-wrap.js";
|
||||
|
||||
const generationCensus = new ProcessCensus({ freshnessMs: 0 }); // coalesce concurrent receipts; recheck each later read
|
||||
const generationThreadIds = new CodexThreadIdResolver({ codexHome: process.env.CODEX_HOME || undefined });
|
||||
|
||||
/** The node inventory's synchronous steps run one at a time, each after a turn of the event loop. They're SQLite work
|
||||
* that can't yield, so concurrent requests ran their whole builds back to back and a read queued behind a burst (a
|
||||
* /healthz, a queue read) waited for all of them; now it waits for one step. Steps that await I/O run outside. */
|
||||
* /healthz, a queue read) waited for all of them; now it waits for one step. Steps that await I/O run outside.
|
||||
* A step that takes the slow threshold or more is named `site` in the slow-operation log. That's its own synchronous
|
||||
* time, measured once its turn comes: not the wait for the turn, and not the settling of a promise it returns. */
|
||||
let inventoryTurn: Promise<void> = Promise.resolve();
|
||||
async function inventoryStep<T>(work: () => T): Promise<Awaited<T>> {
|
||||
async function inventoryStep<T>(site: string, work: () => T): Promise<Awaited<T>> {
|
||||
const previous = inventoryTurn;
|
||||
let release = () => {};
|
||||
inventoryTurn = new Promise<void>((resolve) => { release = () => resolve(); });
|
||||
try {
|
||||
await previous;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return await work();
|
||||
return await runSyncSiteWhenSlow(site, work);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
@@ -147,7 +150,7 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
|
||||
|
||||
const contextUsageStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
|
||||
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore | undefined;
|
||||
const inventory = await inventoryStep(() => contextUsageStore
|
||||
const inventory = await inventoryStep("nodes.inventory.build", () => contextUsageStore
|
||||
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore, transcriptStore)
|
||||
: getNodeInventory(deps.rigRepo.db, rigId));
|
||||
// Slice 15 — enrich with the two new orthogonal primitives. Order is
|
||||
@@ -181,8 +184,8 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
|
||||
seatActivity: deps.seatActivityService,
|
||||
captureFallback: full,
|
||||
});
|
||||
const withActivity = full ? await attachActivity() : await inventoryStep(attachActivity);
|
||||
const withTerminalAndWork = await inventoryStep(() => attachTerminalActivityAndWork(withActivity, {
|
||||
const withActivity = full ? await attachActivity() : await inventoryStep("nodes.inventory.activity_sync", attachActivity);
|
||||
const withTerminalAndWork = await inventoryStep("nodes.inventory.terminal_work", () => attachTerminalActivityAndWork(withActivity, {
|
||||
db: deps.rigRepo.db,
|
||||
seatActivity: deps.seatActivityService,
|
||||
agentActivity: deps.agentActivityStore,
|
||||
|
||||
@@ -321,10 +321,20 @@ export function workflowRoutes(): Hono {
|
||||
// runtime.continue()'s enriched instance.
|
||||
app.get("/list", (c) => {
|
||||
const status = c.req.query("status");
|
||||
if (status === "active" || status === "waiting" || status === "completed" || status === "failed" || status === "aborted") {
|
||||
return c.json(getRuntime(c).listInstancesWithDeadline(status));
|
||||
const parsedStatus = status === "active" || status === "waiting" || status === "completed" || status === "failed" || status === "aborted" ? status : undefined;
|
||||
const rawLimit = c.req.query("limit");
|
||||
if (rawLimit !== undefined && (!/^[1-9]\d*$/.test(rawLimit) || Number(rawLimit) > 10000)) return c.json({ error: "limit_invalid", message: "limit must be an integer from 1 to 10000" }, 400);
|
||||
const fields = ["project", "mission", "slice", "rig", "name"] as const;
|
||||
const filters: { project?: string; mission?: string; slice?: string; rig?: string; name?: string; tags?: string[]; limit?: number } = {};
|
||||
for (const field of fields) {
|
||||
const value = c.req.query(field);
|
||||
if (value !== undefined) { if (!value.trim()) return c.json({ error: "filter_invalid", message: `${field} must not be empty` }, 400); filters[field] = value; }
|
||||
}
|
||||
return c.json(getRuntime(c).listInstancesWithDeadline());
|
||||
const tags = c.req.queries("tag");
|
||||
if (tags?.some((tag) => !tag.trim())) return c.json({ error: "filter_invalid", message: "tag must not be empty" }, 400);
|
||||
if (tags?.length) filters.tags = tags;
|
||||
if (rawLimit !== undefined) filters.limit = Number(rawLimit);
|
||||
return c.json(getRuntime(c).listInstancesWithDeadline(parsedStatus, Object.keys(filters).length ? filters : undefined));
|
||||
});
|
||||
|
||||
// Lists every cached workflow_spec with an `isBuiltIn` flag computed
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runnerGrantIssuer } from "./domain/runner-grant-launch.js";
|
||||
import { piAccountStateRoot } from "./domain/pi-account-state.js";
|
||||
import { EnvironmentStartup } from "./domain/environment-startup.js";
|
||||
import { EnvironmentPlatform } from "./domain/environment-platform.js";
|
||||
@@ -14,7 +15,7 @@ import { makeWatchdogDeliver } from "./domain/watchdog-delivery.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { discoverTailscaleSelfNames } from "./middleware/browser-boundary.js";
|
||||
import { configureShadowCapture } from "./domain/shadow-capture.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "./domain/seat-delivery-guard.js";
|
||||
import { SeatDeliveryGuard, resolveExternalCliOccupant, resolveGuardTarget } from "./domain/seat-delivery-guard.js";
|
||||
import { seatCodexCorePluginDir, vendoredCorePluginDir } from "./domain/claude-core-plugin-env.js";
|
||||
import { queueRecoveryOwnsWake } from "./domain/queue-wake-ladder.js";
|
||||
import { HealthPolicyStore } from "./domain/health-policy.js";
|
||||
@@ -115,7 +116,7 @@ import { setSelfHostId, setSelfHostIdSource } from "./domain/hosts/fanout-contra
|
||||
import { UpCommandRouter } from "./domain/up-command-router.js";
|
||||
import { RigTeardownOrchestrator } from "./domain/rig-teardown.js";
|
||||
import { ResumeMetadataRefresher } from "./domain/resume-metadata-refresher.js";
|
||||
import { observeClaudePaneStartedAt } from "./domain/native-process-lineage.js";
|
||||
import { observeClaudePaneStartedAt, listNativeProcessesOrThrow, listNativeProcesses } from "./domain/native-process-lineage.js";
|
||||
import { TranscriptStore } from "./domain/transcript-store.js";
|
||||
import { resumeRunningTranscriptCaptures, wireTranscriptCaptureStops } from "./domain/transcript-capture.js";
|
||||
import { probeSessionActivity, SessionTransport, inspectComposerInput, draftDeliveryAllows, readDraftDeliverySetting } from "./domain/session-transport.js";
|
||||
@@ -652,6 +653,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const seatIdentityReconciler = new SeatIdentityReconciler({
|
||||
db,
|
||||
tmux: tmuxAdapter,
|
||||
readPiRunnerState: sessionName => piAdapter.readRunnerSidecar(sessionName).state,
|
||||
});
|
||||
// cmuxFactory takes precedence (for tests), then cmuxExec-based CLI transport, then default
|
||||
const cmuxFactory = opts?.cmuxFactory
|
||||
@@ -816,11 +818,20 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const piOpenRouterChargeEnabled = (): boolean => {
|
||||
try { return new ContextPackSettingsStore().resolveOne("usage.pi_openrouter_charge").value === true; } catch { return false; }
|
||||
};
|
||||
const runnerGrants = runnerGrantIssuer(db, tmuxAdapter);
|
||||
const piResume = new PiResumeAdapter(
|
||||
tmuxAdapter,
|
||||
{ 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 }) },
|
||||
{ stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath },
|
||||
{ accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
|
||||
{ runnerGrant: async (session, runId, cwd) => {
|
||||
const owners = db.prepare(`SELECT b.node_id AS nodeId FROM bindings b JOIN nodes n ON n.id=b.node_id
|
||||
JOIN sessions s ON s.node_id=n.id AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
|
||||
WHERE b.tmux_session=? AND s.session_name=? AND n.runtime='pi' AND s.status NOT IN ('superseded','detached','exited')`)
|
||||
.all(session, session) as Array<{ nodeId: string }>;
|
||||
const binding = owners.length === 1 ? sessionRegistry.getBindingForNode(owners[0]!.nodeId) : null;
|
||||
if (!binding) throw new Error("The restored Pi run has no unambiguous Core binding.");
|
||||
return runnerGrants({ ...binding, cwd }, "pi", runId, 0);
|
||||
}, accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
|
||||
);
|
||||
const ompResume = new OmpResumeAdapter(
|
||||
tmuxAdapter,
|
||||
@@ -1021,7 +1032,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
} });
|
||||
// OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps
|
||||
// shape as the Codex adapter; seat isolation roots under piStateRoot.
|
||||
const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const piAdapter = new PiRuntimeAdapter({ runnerGrants, managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const ompAdapter = new OmpRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; } }, stateRoot: ompStateRoot, runnerEntryPath: piRunnerEntryPath });
|
||||
// OPR.0.5.1.1 — the stub runtime adapter (Pi-shaped node-script runner in a pane).
|
||||
// Same fsOps shape as Pi; the compiled runner entry lives in the daemon dist.
|
||||
@@ -1214,7 +1225,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
db, rigRepo, podRepo,
|
||||
sessionRegistry, eventBus, nodeLauncher, startupOrchestrator,
|
||||
fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), exists: (p: string) => fs.existsSync(p) },
|
||||
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter }),
|
||||
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter, runnerGrants }),
|
||||
tmuxAdapter,
|
||||
agentImageLibrary,
|
||||
continuityPolicyMaterializer,
|
||||
@@ -1279,7 +1290,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
"omp": ompAdapter,
|
||||
"stub": stubAdapter,
|
||||
"terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter(),
|
||||
}, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter });
|
||||
}, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter, runnerGrants });
|
||||
let kernelBootTracker: import("./domain/kernel-boot-tracker.js").KernelBootTracker | undefined;
|
||||
try {
|
||||
const { bootKernelIfNeeded } = await import("./domain/kernel-boot.js");
|
||||
@@ -1406,7 +1417,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// #421 — the pane's current Claude process start time; a sidecar sampled earlier is not this
|
||||
// process's, so capture and null-fill skip it.
|
||||
const claudeProcessStartedAt = (sessionName: string) => observeClaudePaneStartedAt({ target: sessionName, tmux: tmuxAdapter });
|
||||
const resumeMetadataRefresher = new ResumeMetadataRefresher({ sessionRegistry, tmuxAdapter, contextUsageStore, claudeProcessStartedAt, codexHome: configuredCodexHome,
|
||||
const resumeMetadataRefresher = new ResumeMetadataRefresher({ sessionRegistry, tmuxAdapter, contextUsageStore, claudeProcessStartedAt,
|
||||
claudeStartedAtForPass: (pass) => (sessionName) => observeClaudePaneStartedAt({ target: sessionName, tmux: tmuxAdapter,
|
||||
listProcesses: pass.lister(listNativeProcessesOrThrow, listNativeProcesses) }), codexHome: configuredCodexHome,
|
||||
seatHome: (nodeId, runtime) => seatRuntimeHome(db, nodeId, runtime).home });
|
||||
const claimService = new ClaimService({
|
||||
db, rigRepo, sessionRegistry, discoveryRepo, eventBus, tmuxAdapter, transcriptStore,
|
||||
@@ -2376,8 +2389,12 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// (i-c) fire-time target-generation gate: resolve the target's LIVE occupant-generation (P12
|
||||
// occupant_tenures) so a generation-bound wake is refused once the target has handed over.
|
||||
// UNKNOWN (null) fails open → deliver. A drop of this line disables the gate silently (gen-bound
|
||||
// wakes would fire at the successor) — pinned in watchdog-target-gen-wiring.test.ts.
|
||||
resolveTargetGeneration: (s) => sessionRegistry.currentOccupantGenerationForSession(s),
|
||||
// wakes would fire at the successor) — pinned in watchdog-target-gen-wiring.test.ts. Read by the
|
||||
// seat the name addresses, not the newest row with that name: an archived generation of the same
|
||||
// seat can hold a newer one, and a context watchdog would then read that node's telemetry. An external-CLI seat
|
||||
// addressed by its external session name, which the guard's address rules don't match, reads its own.
|
||||
resolveTargetGeneration: (s) => { const target = resolveGuardTarget(db, s); return target ? target.occupant ?? null : resolveExternalCliOccupant(db, s); },
|
||||
targetNameCarried: (s) => sessionRegistry.carriesSessionName(s),
|
||||
// Issue #1180 — sampled context use for token/percent context watchdogs,
|
||||
// generation-guarded against a predecessor's frozen telemetry.
|
||||
resolveContextUsage: (s, occupantGeneration) =>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { piUsageEntriesSchema } from "../src/db/migrations/107_pi_usage_entries.js";
|
||||
import { piUsageChargesSchema } from "../src/db/migrations/108_pi_usage_charges.js";
|
||||
// A seat activity grant on the existing hook ingress, through the real route, store, occupant ledger and oracle. The
|
||||
// grant is resolved first; only then is the seat and generation a post claims accepted. A refused post records nothing,
|
||||
// so the seat's activity stays unknown. The install-wide token keeps today's behaviour.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
@@ -45,6 +48,7 @@ function fixture(options: { guest?: boolean } = {}) {
|
||||
c.set("agentActivityStore" as never, store as never);
|
||||
if (!options.guest) c.set("activityHookToken" as never, INSTALL_TOKEN as never);
|
||||
c.set("sessionRegistry" as never, sessions as never);
|
||||
c.set("eventBus" as never, new EventBus(db) as never);
|
||||
c.set("seatActivityService" as never, oracle as never);
|
||||
await next();
|
||||
});
|
||||
@@ -90,7 +94,7 @@ function fixture(options: { guest?: boolean } = {}) {
|
||||
const recorded = () => (db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.activity'").get() as { n: number }).n;
|
||||
const activity = (nodeId: string) => oracle.getSeatState(nodeId)?.activity ?? "unknown";
|
||||
const replace = (reserved?: string) => occupy(guest.id, GUEST, "handover", reserved);
|
||||
return { guest, other, sessions, grants, generation, issue, post, heldPost, raw, event, recorded, activity, replace };
|
||||
return { db, app, guest, other, sessions, grants, generation, issue, post, heldPost, raw, event, recorded, activity, replace };
|
||||
}
|
||||
|
||||
describe("a seat activity grant on the hook ingress", () => {
|
||||
@@ -116,6 +120,123 @@ describe("a seat activity grant on the hook ingress", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires bound authority for a claimed run even when install and generation are known", async () => {
|
||||
const f = fixture();
|
||||
const legacy = f.issue();
|
||||
for (const token of [INSTALL_TOKEN, legacy.token]) {
|
||||
expect(await f.post(token, f.event("Stop", "end_turn", { sessionName: GUEST, runId: "claimed-run" })))
|
||||
.toMatchObject({ status: 403, body: { code: "run_grant_required" } });
|
||||
}
|
||||
expect(f.recorded()).toBe(0);
|
||||
});
|
||||
|
||||
it("checks activation, run ID and the concrete binding before accepting run activity", async () => {
|
||||
const f = fixture();
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "actual-attempt", sessionId: row.id, binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 409, body: { code: "activity_grant_pending" } });
|
||||
expect(f.grants.activate(grant.grantId)).toBe(true);
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: "forged" }))).toMatchObject({ status: 409 });
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 200 });
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.guest.id);
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 409 });
|
||||
expect(f.recorded()).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses unverified run activity without recording or revoking, then recovers", async () => {
|
||||
const f = fixture();
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "temporarily-unverified", sessionId: row.id, binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
|
||||
expect(f.grants.activate(grant.grantId)).toBe(true);
|
||||
const read = vi.spyOn(SeatIdentityStore.prototype, "getForNode").mockImplementation(() => { throw new Error("observation unavailable"); });
|
||||
try {
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId })))
|
||||
.toMatchObject({ status: 409, body: { code: "activity_grant_unverified", retryable: true } });
|
||||
expect(f.recorded()).toBe(0);
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
} finally { read.mockRestore(); }
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 200 });
|
||||
expect(f.recorded()).toBe(1);
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.guest.id);
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId })))
|
||||
.toMatchObject({ status: 409, body: { code: "activity_grant_stale" } });
|
||||
expect(f.recorded()).toBe(1);
|
||||
});
|
||||
|
||||
it("allows a retained Pi run to reannounce identity after explicit same-process adoption", async () => {
|
||||
const f = fixture();
|
||||
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "retained-pi", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
f.grants.activate(grant.grantId);
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.guest.id, verdict: "unconfirmed", evidenceSource: "pane_process", reason: null,
|
||||
sessionName: GUEST, observedAt: new Date().toISOString(), evidence: { registeredPane: "%1", observedPid: 101,
|
||||
observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
f.sessions.registerClaimedSession(f.guest.id, GUEST, "adopt");
|
||||
expect(await f.post(grant.token, { runtime: "pi", eventFamily: "session_identity", hookEvent: "SessionStart",
|
||||
sessionName: GUEST, sessionId: "native-id", sessionFile: "/tmp/retained.jsonl", generation: grant.generation, runId: grant.runId }))
|
||||
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
|
||||
});
|
||||
|
||||
it.each(["claude-code", undefined])("refuses another or absent runtime identity on a Pi grant without changing the saved resume pointer: %s", async runtime => {
|
||||
const f = fixture();
|
||||
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
f.sessions.updateResumeToken(row.id, "pi_session_file", "/tmp/original-pi.jsonl", "hook");
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "pi-attempt", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
f.grants.activate(grant.grantId);
|
||||
const response = await f.post(grant.token, { runtime, eventFamily: "session_identity", hookEvent: "SessionStart",
|
||||
sessionId: "12345678-1234-1234-1234-123456789abc", generation: grant.generation, runId: grant.runId });
|
||||
expect(response).toMatchObject({ status: 403, body: { code: "activity_grant_runtime_mismatch" } });
|
||||
expect(f.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE id = ?").get(row.id))
|
||||
.toEqual({ resume_type: "pi_session_file", resume_token: "/tmp/original-pi.jsonl" });
|
||||
expect(f.db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.session_identity'").get()).toEqual({ n: 0 });
|
||||
expect(await f.post(grant.token, { runtime: "pi", eventFamily: "session_identity", hookEvent: "SessionStart",
|
||||
sessionId: "native-id", sessionFile: "/tmp/current-pi.jsonl", generation: grant.generation, runId: grant.runId }))
|
||||
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
|
||||
expect(f.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE id = ?").get(row.id))
|
||||
.toEqual({ resume_type: "pi_session_file", resume_token: "/tmp/current-pi.jsonl" });
|
||||
// Ordinary posts may omit runtime; a bound credential still supplies seat and generation.
|
||||
expect(await f.post(grant.token, { eventFamily: "turn_end", hookEvent: "Stop", runId: grant.runId })).toMatchObject({ status: 200 });
|
||||
expect(await f.post(grant.token, { runtime: "claude-code", eventFamily: "turn_end", hookEvent: "Stop", runId: grant.runId }))
|
||||
.toMatchObject({ status: 403, body: { code: "activity_grant_runtime_mismatch" } });
|
||||
// The install token still follows the existing identity path; it is not narrowed into a run-bound credential.
|
||||
expect(await f.post(INSTALL_TOKEN, { runtime: "claude-code", eventFamily: "session_identity", hookEvent: "SessionStart",
|
||||
sessionName: GUEST, sessionId: "12345678-1234-1234-1234-123456789abc", generation: grant.generation }))
|
||||
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
|
||||
});
|
||||
|
||||
it("accepts bound Pi usage while preserving legacy install-token usage", async () => {
|
||||
const f = fixture();
|
||||
f.db.exec(piUsageEntriesSchema.sql!);
|
||||
f.db.exec(piUsageChargesSchema.sql!);
|
||||
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "pi-attempt", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
const post = (token: string, path: string, extra = {}) => f.app.request(`/api/activity/${path}`, { method: "POST",
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ sessionName: GUEST, piSessionId: "pi-native-id", facts: [], charges: [], ...extra }) });
|
||||
for (const path of ["pi-usage", "pi-usage-charges"]) expect((await post(grant.token, path)).status).toBe(409);
|
||||
f.grants.activate(grant.grantId);
|
||||
for (const path of ["pi-usage", "pi-usage-charges"]) {
|
||||
expect((await post(grant.token, path)).status).toBe(200);
|
||||
expect((await post(INSTALL_TOKEN, path)).status).toBe(200);
|
||||
expect((await post(grant.token, path, { nodeId: f.other.id })).status).toBe(403);
|
||||
}
|
||||
f.grants.revoke({ grantId: grant.grantId }, "stopped");
|
||||
expect((await post(grant.token, "pi-usage")).status).toBe(401);
|
||||
});
|
||||
|
||||
const wrongSeat: Array<[string, (f: ReturnType<typeof fixture>) => Record<string, unknown>]> = [
|
||||
["another seat's name", () => ({ sessionName: OTHER })],
|
||||
["another seat's node", (f) => ({ nodeId: f.other.id })],
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
// The seat activity grant's store and the only activity values a guest receives.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { runnerGrantIssuer } from "../src/domain/runner-grant-launch.js";
|
||||
import { ActivityGrantStore, grantGeneration, guestActivityEnv } from "../src/domain/activity-grants.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
@@ -23,6 +30,16 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("issuing a seat activity grant", () => {
|
||||
|
||||
it.each(["claude-code", "codex", "terminal", "stub"])("does no run-binding work for an unrelated %s registration", runtime => {
|
||||
const f = fixture(); f.db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(runtime, f.node.id);
|
||||
const legacy = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "legacy" });
|
||||
const session = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig");
|
||||
expect(session.status).toBe("running");
|
||||
expect(f.grants.get(legacy.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.db.prepare("SELECT count(*) AS n FROM activity_grants WHERE run_id IS NOT NULL").get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it("returns the token once and stores only its sha256", () => {
|
||||
const f = fixture();
|
||||
const issued = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
|
||||
@@ -76,6 +93,331 @@ describe("issuing a seat activity grant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("actual run grants", () => {
|
||||
function run() {
|
||||
const f = fixture();
|
||||
const session = f.db.prepare("SELECT id FROM sessions WHERE node_id = ?").get(f.node.id) as { id: string };
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,'dev-guest@guest-rig','%1')").run(f.node.id);
|
||||
const anchor = { runtime: "synth", runner: 1, pane: "%1", panePid: 101 };
|
||||
f.db.prepare("UPDATE sessions SET agent_app = 'synth', agent_runner = 1 WHERE id = ?").run(session.id);
|
||||
const issue = (runId: string) => f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "launch", run: { runId, sessionId: session.id, binding: anchor } });
|
||||
return { ...f, session, anchor, issue };
|
||||
}
|
||||
|
||||
it("stages a successor without replacing its predecessor; activation and late cleanup address exact grants", () => {
|
||||
const f = run();
|
||||
const first = f.issue("attempt-1");
|
||||
expect(f.grants.standing(first.grantId)).toBe("pending");
|
||||
expect(f.grants.activeForNode(f.node.id)).toBeNull();
|
||||
expect(f.grants.activate(first.grantId)).toBe(true);
|
||||
const failed = f.issue("attempt-2");
|
||||
expect(f.grants.activeForNode(f.node.id)?.grantId).toBe(first.grantId);
|
||||
f.grants.revoke({ grantId: failed.grantId }, "failed launch");
|
||||
expect(f.grants.activate(failed.grantId)).toBe(false);
|
||||
const next = f.issue("attempt-3");
|
||||
expect(f.grants.activate(next.grantId)).toBe(true);
|
||||
expect(f.grants.standing(first.grantId)).toBe("revoked");
|
||||
f.grants.revoke({ grantId: first.grantId }, "late completion");
|
||||
expect(f.grants.standing(next.grantId)).toBe("current");
|
||||
expect(JSON.stringify(f.db.prepare("SELECT * FROM activity_grants").all())).not.toContain(next.token);
|
||||
});
|
||||
|
||||
it("supersedes pending credentials for the same run without touching another staged attempt", () => {
|
||||
const f = run();
|
||||
const a = f.issue("a"), b = f.issue("b"), replacement = f.issue("a");
|
||||
expect(f.grants.standing(a.grantId)).toBe("revoked");
|
||||
expect(f.grants.standing(b.grantId)).toBe("pending");
|
||||
expect(f.grants.standing(replacement.grantId)).toBe("pending");
|
||||
});
|
||||
|
||||
it("does not let an older pending dispatch replace a newer active run", () => {
|
||||
const f = run();
|
||||
const older = f.issue("older"), newer = f.issue("newer");
|
||||
expect(f.grants.activate(newer.grantId)).toBe(true);
|
||||
expect(f.grants.activate(older.grantId)).toBe(false);
|
||||
expect(f.grants.standing(newer.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("activates a staged runner only at the Core session and binding cutover", () => {
|
||||
const f = run();
|
||||
const first = f.issue("first");
|
||||
f.grants.activate(first.grantId);
|
||||
const generation = f.sessions.reserveOccupantGeneration()!;
|
||||
const staged = f.grants.issue({ nodeId: f.node.id, generation, issuedBy: "launch", run: {
|
||||
runId: "staged", binding: { ...f.anchor, pane: "%2", panePid: 202 } } });
|
||||
expect(f.grants.activate(staged.grantId)).toBe(false);
|
||||
expect(f.grants.activeForNode(f.node.id)?.grantId).toBe(first.grantId);
|
||||
// The reconciler can have sampled the predecessor while the successor was staging.
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
|
||||
reason: null, sessionName: "dev-guest@guest-rig", observedAt: "2999-01-01T00:00:00.000Z",
|
||||
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
|
||||
const successor = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "handover", generation);
|
||||
expect(f.grants.standing(staged.grantId)).toBe("current");
|
||||
expect(f.grants.get(staged.grantId)?.sessionId).toBe(successor.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("revoked");
|
||||
});
|
||||
|
||||
it("preserves explicit same-pane adoption only with retained concrete process evidence", () => {
|
||||
const f = run();
|
||||
const first = f.issue("first");
|
||||
f.grants.activate(first.grantId);
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
|
||||
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
|
||||
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
|
||||
expect(f.grants.standing(first.grantId)).toBe("current");
|
||||
f.db.prepare("UPDATE seat_identity_verdicts SET evidence_pane_pid = 999 WHERE node_id = ?").run(f.node.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("stale");
|
||||
});
|
||||
|
||||
it("revokes an adopted run on current-session stop after its concrete anchors are lost", () => {
|
||||
const f = run(); const first = f.issue("first"); f.grants.activate(first.grantId);
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process", reason: null,
|
||||
sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(), evidence: { registeredPane: "%1", observedPid: 101,
|
||||
observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
const adopted = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
|
||||
expect(f.grants.get(first.grantId)?.sessionId).toBe(f.session.id); // immutable origin evidence
|
||||
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
|
||||
f.sessions.markIntentionallyStopped(adopted.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("revoked");
|
||||
});
|
||||
|
||||
|
||||
it("preserves an active grant through unreadable binding evidence and recovery", () => {
|
||||
const f = run(), grant = f.issue("current"); f.grants.activate(grant.grantId);
|
||||
const prepare = f.db.prepare.bind(f.db);
|
||||
let bindingReads = 0;
|
||||
const spy = vi.spyOn(f.db, "prepare").mockImplementation(((sql: string) => {
|
||||
if (sql.includes("SELECT s.id, s.session_name") && ++bindingReads > 1) throw new Error("observation read unavailable");
|
||||
return prepare(sql);
|
||||
}) as typeof f.db.prepare);
|
||||
try {
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.grants.standing(grant.grantId)).toBe("unverified"); // refusal is not revocation
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("does not fail claimed registration because optional grant reconciliation cannot read", () => {
|
||||
const f = run();
|
||||
const spy = vi.spyOn(ActivityGrantStore.prototype, "reconcileBinding").mockImplementation(() => { throw new Error("grant table unavailable"); });
|
||||
try {
|
||||
const claimed = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig");
|
||||
expect(claimed.status).toBe("running");
|
||||
expect(f.sessions.currentOccupantTenure(f.node.id)).toBeTruthy();
|
||||
} finally { spy.mockRestore(); }
|
||||
});
|
||||
|
||||
|
||||
it("does not activate an older pending grant when the newer active binding cannot be read", () => {
|
||||
const f = run(), older = f.issue("older"), newer = f.issue("newer"); f.grants.activate(newer.grantId);
|
||||
const prepare = f.db.prepare.bind(f.db); let bindingReads = 0;
|
||||
const spy = vi.spyOn(f.db, "prepare").mockImplementation(((sql: string) => {
|
||||
if (sql.includes("SELECT s.id, s.session_name") && ++bindingReads === 2) throw new Error("newer binding unreadable");
|
||||
return prepare(sql);
|
||||
}) as typeof f.db.prepare);
|
||||
try { expect(f.grants.activate(older.grantId)).toBe(false); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(f.grants.standing(newer.grantId)).toBe("current");
|
||||
expect(f.grants.standing(older.grantId)).toBe("pending");
|
||||
});
|
||||
|
||||
it.each(["missing", "stale"])("preserves adopted authority when identity evidence is %s, then recovers", missing => {
|
||||
const f = run(), grant = f.issue("adopted"); f.grants.activate(grant.grantId);
|
||||
const evidence = { nodeId: f.node.id, verdict: "unconfirmed" as const, evidenceSource: "pane_process" as const,
|
||||
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
|
||||
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } };
|
||||
new SeatIdentityStore(f.db).upsert(evidence);
|
||||
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
|
||||
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
|
||||
if (missing === "stale") new SeatIdentityStore(f.db).upsert({ ...evidence, sessionName: "prior-session" });
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
f.grants.revokeForSession(f.session.id, "old_session_stopped");
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.grants.standing(grant.grantId)).toBe("unverified");
|
||||
new SeatIdentityStore(f.db).upsert(evidence);
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
|
||||
it.each(["anchor", "pid", "generation"] as const)("does not issue weaker authority when %s is missing", async missing => {
|
||||
const f = run();
|
||||
if (missing === "generation") f.db.prepare("DELETE FROM occupant_tenures WHERE node_id = ?").run(f.node.id);
|
||||
const setSessionEnv = vi.fn(async () => {});
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => missing === "pid" ? null : 101,
|
||||
setSessionEnv, unsetSessionEnv: async () => {} });
|
||||
await expect(begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: missing === "anchor" ? null : "%1" } as never,
|
||||
"synth", "unbound", 1)).rejects.toThrow(/authority needs/);
|
||||
expect(setSessionEnv).not.toHaveBeenCalled();
|
||||
expect(f.db.prepare("SELECT count(*) AS n FROM activity_grants").get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it.each(["read", "partial read", "remove"] as const)("executes unbound with the original install token when private credential %s fails", async failure => {
|
||||
const f = run(), dir = mkdtempSync(join(tmpdir(), "grant-fallback-"));
|
||||
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
||||
writeFileSync(join(dir, "tmux"), '#!/bin/sh\ncase "$1" in show-environment) [ "$TEST_FAIL" = read ] && exit 1; printf "%s=%s\\n" "$TEST_KEY" "$TEST_PRIVATE"; [ "$TEST_FAIL" != "partial read" ];; set-environment) [ "$TEST_FAIL" != remove ];; *) exit 1;; esac\n');
|
||||
chmodSync(join(dir, "tmux"), 0o700);
|
||||
let token = "", key = "";
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
|
||||
setSessionEnv: async (_session, name, value) => { key = name; token = value; }, unsetSessionEnv: async () => {} });
|
||||
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "not-claimed", 1);
|
||||
const program = `printf '%s' "$OPENRIG_ACTIVITY_HOOK_TOKEN|\${OPENRIG_RUN_ID-unset}|$OPENRIG_OCCUPANT_GENERATION"`;
|
||||
const result = spawnSync("/bin/sh", ["-xc", launch.wrap(`/bin/sh -c ${shellQuote(program)}`)], { encoding: "utf8", env: {
|
||||
...process.env, PATH: `${dir}:${process.env.PATH}`, OPENRIG_ACTIVITY_HOOK_TOKEN: "legacy-install", OPENRIG_RUN_ID: "stale-run",
|
||||
OPENRIG_OCCUPANT_GENERATION: "legacy-generation", TEST_FAIL: failure, TEST_KEY: key, TEST_PRIVATE: token,
|
||||
} });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toBe("legacy-install|unset|legacy-generation");
|
||||
expect(result.stderr).not.toContain(token);
|
||||
expect(result.stderr).toContain("using existing install-token reporting");
|
||||
await launch.finish(false);
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves an adopted grant when declaration is unavailable, without hiding positive replacement=%s", replaced => {
|
||||
const f = run(), grant = f.issue("adopted"); f.grants.activate(grant.grantId);
|
||||
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
|
||||
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.grants.standing(grant.grantId)).toBe("unverified");
|
||||
if (replaced) {
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
expect(f.grants.standing(grant.grantId)).toBe("revoked");
|
||||
return;
|
||||
}
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
|
||||
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
|
||||
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("preserves authority through an unavailable occupant generation and recovery", () => {
|
||||
const f = run(), grant = f.issue("current"); f.grants.activate(grant.grantId);
|
||||
const spy = vi.spyOn(SessionRegistry.prototype, "currentOccupantTenure").mockReturnValue(null);
|
||||
try {
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.grants.standing(grant.grantId)).toBe("unverified");
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("records a Core-selected runner declaration on a previously registered empty launch row", async () => {
|
||||
const f = run();
|
||||
f.db.prepare("UPDATE sessions SET agent_app = NULL, agent_runner = NULL WHERE id = ?").run(f.session.id);
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101, setSessionEnv: async () => {}, unsetSessionEnv: async () => {} });
|
||||
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "registered-first", 1);
|
||||
expect(() => launch.dispatched()).not.toThrow();
|
||||
expect(f.grants.standing(launch.grantId)).toBe("current");
|
||||
expect(f.db.prepare("SELECT agent_app, agent_runner FROM sessions WHERE id = ?").get(f.session.id)).toEqual({ agent_app: "synth", agent_runner: 1 });
|
||||
await launch.finish(true);
|
||||
});
|
||||
|
||||
it("activates an earlier staged grant after a later predecessor rotation becomes stale at cutover", () => {
|
||||
const f = run(); const first = f.issue("first"); f.grants.activate(first.grantId);
|
||||
const generation = f.sessions.reserveOccupantGeneration()!;
|
||||
const staged = f.grants.issue({ nodeId: f.node.id, generation, issuedBy: "launch", run: { runId: "staged",
|
||||
binding: { ...f.anchor, pane: "%2", panePid: 202 } } });
|
||||
const rotation = f.issue("predecessor-rotation");
|
||||
expect(f.grants.activate(rotation.grantId)).toBe(true);
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
|
||||
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "handover", generation);
|
||||
expect(f.grants.standing(staged.grantId)).toBe("current");
|
||||
expect(f.grants.standing(rotation.grantId)).toBe("revoked");
|
||||
});
|
||||
|
||||
it("revokes the current run on an explicit stop or observed exit", () => {
|
||||
const f = run();
|
||||
const first = f.issue("first");
|
||||
f.grants.activate(first.grantId);
|
||||
f.sessions.markIntentionallyStopped(f.session.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("revoked");
|
||||
});
|
||||
|
||||
it("distributes privately, and late failed cleanup cannot revoke the replacement", async () => {
|
||||
const f = run();
|
||||
const env = new Map<string, string>();
|
||||
const tmux = { getPaneCommand: async () => "bash", getPanePid: async () => 101,
|
||||
setSessionEnv: async (_session: string, key: string, value: string) => { env.set(key, value); },
|
||||
unsetSessionEnv: async (_session: string, key: string) => { env.delete(key); } };
|
||||
const begin = runnerGrantIssuer(f.db, tmux);
|
||||
const binding = { nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never;
|
||||
const first = await begin(binding, "synth", "first", 1);
|
||||
const token = [...env.values()][0]!;
|
||||
expect(first.wrap("node runner.js")).not.toContain(token);
|
||||
expect(first.wrap("node runner.js")).toContain("set +x");
|
||||
expect(first.wrap("node runner.js")).toContain("set-environment");
|
||||
first.dispatched();
|
||||
const second = await begin(binding, "synth", "second", 1);
|
||||
second.dispatched();
|
||||
await first.finish(false);
|
||||
expect(f.grants.activeForNode(f.node.id)?.runId).toBe("second");
|
||||
expect(env.size).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves the active predecessor when private credential distribution fails after its write", async () => {
|
||||
const f = run();
|
||||
const first = f.issue("first"); f.grants.activate(first.grantId);
|
||||
const env = new Map<string, string>();
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
|
||||
setSessionEnv: async (_session: string, key: string, token: string) => { env.set(key, token); throw new Error("private transport unavailable"); },
|
||||
unsetSessionEnv: async (_session: string, key: string) => { env.delete(key); } });
|
||||
await expect(begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never,
|
||||
"synth", "failed", 1)).rejects.toThrow("credential distribution failed");
|
||||
expect(f.grants.standing(first.grantId)).toBe("current");
|
||||
expect(f.grants.activeForNode(f.node.id)?.runId).toBe("first");
|
||||
expect(env.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the existing staged /bin/sh launch path for non-POSIX parent shells", async () => {
|
||||
const f = run();
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101, setSessionEnv: async () => {}, unsetSessionEnv: async () => {} });
|
||||
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never,
|
||||
"synth", "shell-independent-bootstrap", 1);
|
||||
expect(launch.wrap("node runner.js")).toMatch(/^\/bin\/sh -c /);
|
||||
await launch.finish(false);
|
||||
});
|
||||
|
||||
it("consumes the private tmux environment before exec without token text in argv or tracing", async () => {
|
||||
const f = run();
|
||||
const dir = mkdtempSync(join(tmpdir(), "run-grant-transfer-"));
|
||||
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const marker = join(dir, "consumed");
|
||||
writeFileSync(join(dir, "tmux"), '#!/bin/sh\ncase "$1" in show-environment) printf "%s=%s\\n" "$TEST_KEY" "$TEST_PRIVATE";; set-environment) touch "$TEST_REMOVED";; *) exit 1;; esac\n');
|
||||
chmodSync(join(dir, "tmux"), 0o700);
|
||||
let token = "", key = "";
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
|
||||
setSessionEnv: async (_session, name, value) => { key = name; token = value; }, unsetSessionEnv: async () => {} });
|
||||
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "opaque-run", 1);
|
||||
const program = `const t=process.env.OPENRIG_ACTIVITY_HOOK_TOKEN; process.stdout.write(JSON.stringify({bound:t===process.env.TEST_PRIVATE,run:process.env.OPENRIG_RUN_ID,generation:process.env.OPENRIG_OCCUPANT_GENERATION,argvLeak:process.argv.some(a=>a.includes(t))}));`;
|
||||
const command = launch.wrap(`${shellQuote(process.execPath)} -e ${shellQuote(program)}`);
|
||||
expect(command).not.toContain(token);
|
||||
const result = spawnSync("/bin/sh", ["-xc", command], { encoding: "utf8", env: { ...process.env, PATH: `${dir}:${process.env.PATH}`,
|
||||
OPENRIG_ACTIVITY_HOOK_TOKEN: "legacy-install", OPENRIG_OCCUPANT_GENERATION: "stale-shell", TEST_KEY: key, TEST_PRIVATE: token, TEST_REMOVED: marker } });
|
||||
expect(result.status, result.error?.message ?? result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ bound: true, run: "opaque-run", generation: f.generation, argvLeak: false });
|
||||
expect(result.stderr).not.toContain(token);
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
await launch.finish(false);
|
||||
});
|
||||
|
||||
it("refuses activation and standing after concrete pane or recorded runtime changes", () => {
|
||||
const f = run();
|
||||
const first = f.issue("first");
|
||||
expect(f.grants.activate(first.grantId)).toBe(true);
|
||||
const staged = f.issue("staged");
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("stale");
|
||||
expect(f.grants.activate(staged.grantId)).toBe(false);
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%1' WHERE node_id = ?").run(f.node.id);
|
||||
f.db.prepare("UPDATE sessions SET agent_runner = 0 WHERE id = ?").run(f.session.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the activity values a guest receives", () => {
|
||||
const grant = { token: "oag_guest", nodeId: "node-1", generation: "gen-1" };
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Database as DatabaseType } from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
import { observeClaudePermission } from "../src/domain/permission-drift.js";
|
||||
import { diagnoseRuntimePosture, observeClaudePermission, renderPermissionDriftSummary } from "../src/domain/permission-drift.js";
|
||||
|
||||
describe("AppliedLaunchObservationStore", () => {
|
||||
let db: DatabaseType;
|
||||
@@ -47,6 +47,23 @@ describe("AppliedLaunchObservationStore", () => {
|
||||
expect(db.prepare("SELECT COUNT(*) AS n FROM applied_launch_observations").get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it("preserves an inconclusive Codex policy reason through storage and rendered status", () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'codex' WHERE id = 'node-1'").run();
|
||||
registry.registerSession("node-1", "dev-impl@r1");
|
||||
const generation = registry.currentOccupantTenure("node-1")!.generationUuid;
|
||||
const reason = "policy not verified: native read timed out";
|
||||
expect(store.recordGeneration(generation, { runtime: "codex", axis: "sandbox", state: "observed",
|
||||
value: "permissions:openrig-seat", reason })).toBe(true);
|
||||
const applied = store.readCurrent("node-1");
|
||||
expect(applied?.reason).toBe(reason);
|
||||
const diagnostic = diagnoseRuntimePosture({ runtime: "codex", cwd: "/work", applied,
|
||||
fs: { readFile: () => "", cwdReadable: () => true, commandAvailable: () => true, claudePermissionModes: () => null } });
|
||||
expect(diagnostic.enforcement).toMatchObject({ state: "unknown", reason });
|
||||
expect(renderPermissionDriftSummary(diagnostic)).toContain(reason);
|
||||
registry.mintOccupantTenure("node-1", "handover");
|
||||
expect(store.readCurrent("node-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("never rebinds a delayed launch write onto a successor generation", () => {
|
||||
registry.registerSession("node-1", "dev-impl@r1");
|
||||
const launchedGeneration = registry.currentOccupantTenure("node-1")!.generationUuid;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// usage data short-circuit, send-failure no-dedup-update semantics.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { ClaudeCompactionEnforcer as ActualEnforcer, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import { ClaudeCompactionEnforcer as ActualEnforcer, MANUAL_SEAT_READ_INTERVAL_MS, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS, type SessionTransport } from "../src/domain/session-transport.js";
|
||||
import type { ClaudeCompactionPolicy, SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
|
||||
@@ -843,6 +843,9 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
expect(send.mock.calls[4]![1]).toContain("Required items are your restore map's ranked entries above your restore class's tier line");
|
||||
expect(send.mock.calls[4]![1]).toContain("the session JSONL stay lookup-only");
|
||||
expect(send.mock.calls[4]![1]).toContain("Read every required item that is not FULL in full now");
|
||||
expect(send.mock.calls[4]![1]).toContain("before acting on the restored work, and keep any hold you verified as live");
|
||||
expect(send.mock.calls[4]![1]).toContain("Report the final read-depth table and the reconnect note as you resume; don't hold unaffected work for them.");
|
||||
expect(send.mock.calls[4]![1]).not.toContain("before doing any other work");
|
||||
expect(send.mock.calls[4]![1]).not.toContain("Do not optimize for token conservation");
|
||||
expect(send.mock.calls[3]![1]).toContain("read your newest restore map and use the restore packet as a lookup");
|
||||
expect(send.mock.calls[3]![1]).not.toContain("read the restore packet files");
|
||||
@@ -1196,7 +1199,7 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("preparing");
|
||||
|
||||
// DEGRADED duplicate: usedPercentage null (bad-sidecar projection). The guard
|
||||
// now PRECEDES the no_usage_data recordManualFailure path, so it must NOT record
|
||||
// PRECEDES every manual refusal, so it must NOT record
|
||||
// skipped-or-failed / erase the preparing marker (the bug rev1-r2 caught).
|
||||
const degraded = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: null });
|
||||
expect(degraded).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" });
|
||||
@@ -1380,14 +1383,79 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
expect(enforcer.getManualCompactionState("codex@rig")?.stage).toBe("skipped-or-failed");
|
||||
});
|
||||
|
||||
it("no known usage sample → rejected with no_usage_data (never triggers blind)", async () => {
|
||||
it("no known usage sample: an explicit manual request still prepares and compacts; unknown usage isn't a reason to stop", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000 });
|
||||
|
||||
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: null });
|
||||
expect(outcome).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "no_usage_data" });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
|
||||
const texts = send.mock.calls.map((call) => String(call[1]));
|
||||
expect(texts[0]).toContain("OpenRig manual compaction was requested for this seat");
|
||||
expect(texts[0]).not.toContain("null");
|
||||
expect(texts[1]).toMatch(/^\/compact /);
|
||||
});
|
||||
|
||||
it("the preparation request asks for a turn free of approval prompts: file-edit writes and a literal recap-write path", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000 });
|
||||
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(String(send.mock.calls[0]![1])).toContain("To avoid one cause of approval prompts, which hold /compact until someone answers them: write files with your file-edit tool (acceptEdits covers your working directory, not a preparation root outside it), and if you publish a seat recap, run rig context recap-write --file with the file's literal absolute path, with no shell variable assignment or command substitution, so an allow rule for rig commands can match it.");
|
||||
});
|
||||
|
||||
it("when the idle wait and the /compact send disagree, each round pauses a reading interval, bounded by the deadline", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
// The idle wait reads idle (a hook), while every /compact finds the pane at a prompt and writes nothing.
|
||||
send.mockImplementation(async (_s: string, text: string) => text.startsWith("/compact")
|
||||
? { ok: false, sent: false, inputPhase: "not_reached", reason: "target_needs_input", activity: { state: "needs_input" } }
|
||||
: { ok: true });
|
||||
let clock = 1_700_000_000_000;
|
||||
const sleeps: number[] = [];
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { manualPrepWaitMs: 20_000, now: () => clock,
|
||||
sleep: async (ms) => { sleeps.push(ms); clock += ms; } });
|
||||
|
||||
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(outcome).toMatchObject({ triggered: false, reason: "preparation_needs_input" });
|
||||
const compacts = send.mock.calls.filter((call) => String(call[1]).startsWith("/compact")).length;
|
||||
expect(compacts).toBeGreaterThan(1);
|
||||
expect(compacts).toBeLessThanOrEqual(20_000 / MANUAL_SEAT_READ_INTERVAL_MS + 1);
|
||||
// Every deferred send is followed by a pause, the last one running into the deadline.
|
||||
expect(sleeps.filter((ms) => ms === MANUAL_SEAT_READ_INTERVAL_MS).length).toBe(compacts);
|
||||
});
|
||||
|
||||
it("a /compact refused before input because the seat reached a prompt waits again within the deadline, then compacts", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
send.mockImplementationOnce(async () => ({ ok: true })); // the preparation request lands
|
||||
// The seat read idle, then its preparation turn reached a permission prompt before /compact: refused, nothing written.
|
||||
send.mockImplementationOnce(async () => ({ ok: false, sent: false, inputPhase: "not_reached", reason: "target_needs_input" }));
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000, sleep: async () => {} });
|
||||
|
||||
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
|
||||
const texts = send.mock.calls.map((call) => String(call[1]));
|
||||
expect(texts.filter((text) => text.startsWith("/compact"))).toHaveLength(2);
|
||||
expect(enforcer.getPreparationState(SEAT)).toMatchObject({ status: "compact-sent" });
|
||||
});
|
||||
|
||||
it("a seat still at a prompt when the deadline passes stops naming the prompt, and --state showed what it waited on", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
let clock = 1_700_000_000_000;
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { manualPrepWaitMs: 1000, now: () => clock, sleep: async (ms) => { clock += ms; } });
|
||||
const seen: unknown[] = [];
|
||||
transport.waitUntilIdle = async () => {
|
||||
seen.push(enforcer.getPreparationState(SEAT)?.awaitingInput);
|
||||
return { ok: false, reason: "target_needs_input", error: "fixture", activity: { state: "needs_input" } as any, waitedMs: 0, attempts: 1 } as any;
|
||||
};
|
||||
|
||||
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(outcome).toMatchObject({ triggered: false, stage: "skipped-or-failed", reason: "preparation_needs_input",
|
||||
preparation: { delivery: "delivered", compact: "not_sent" } });
|
||||
expect(seen.slice(1)).toContainEqual({ since: expect.any(Number), reason: "target_needs_input", observedAt: expect.any(Number), current: true });
|
||||
expect(send.mock.calls.filter((call) => String(call[1]).startsWith("/compact"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("bounded to the triggered seat: only that seat is sent to; no other seat gets manual state", async () => {
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import type { CodexFilesystemPermissions } from "../src/domain/codex-member-permissions.js";
|
||||
import { decideCodexFilesystemPolicy, verifyCodexFilesystemPolicy } from "../src/domain/codex-filesystem-preflight.js";
|
||||
import { readCodexConfiguration } from "../src/domain/codex-network-default.js";
|
||||
|
||||
vi.mock("../src/domain/codex-network-default.js", () => ({ readCodexConfiguration: vi.fn() }));
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
const permissions: CodexFilesystemPermissions = {
|
||||
approval: "never", filesystem: { ":minimal": "read", "/work": "write", "/work/hidden": "deny" }, network_access: false,
|
||||
};
|
||||
function config() {
|
||||
return { config: { approval_policy: "never", default_permissions: "openrig-seat", sandbox_mode: null,
|
||||
sandbox_workspace_write: null, profile: null, permissions: { "openrig-seat": {
|
||||
description: null, extends: null, workspace_roots: null,
|
||||
filesystem: { "/work/hidden": "deny", "/work": "write", ":minimal": "read", glob_scan_max_depth: null },
|
||||
network: { enabled: false, allow_local_binding: null, allow_upstream_proxy: null,
|
||||
dangerously_allow_all_unix_sockets: null, dangerously_allow_non_loopback_proxy: null, domains: null,
|
||||
enable_socks5: null, enable_socks5_udp: null, mitm: null, mode: null, proxy_url: null, socks_url: null, unix_sockets: null },
|
||||
} } }, origins: {}, layers: [] };
|
||||
}
|
||||
function decide(configRead: unknown = config(), requirementsRead: unknown = { requirements: null }) {
|
||||
return decideCodexFilesystemPolicy({ permissions, configRead, requirementsRead });
|
||||
}
|
||||
|
||||
describe("Codex seat filesystem configuration preflight", () => {
|
||||
it("accepts the exact authored policy regardless of mapping key order", () => {
|
||||
expect(decide()).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("accepts the native nullable managed-policy response without overriding requirements", () => {
|
||||
expect(decide(config(), { requirements: { allowedApprovalPolicies: null, allowedApprovalsReviewers: null,
|
||||
allowedSandboxModes: null, allowedPermissionProfiles: null, defaultPermissions: null,
|
||||
featureRequirements: null, application: null, autoReview: null, network: null,
|
||||
futureFilesystemPolicy: null } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("allows other permission profiles in the shared home without selecting them", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "other-seat": { filesystem: { ":root": "write" } } });
|
||||
expect(decide(read)).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["lower-layer extra write", { filesystem: { ...permissions.filesystem, "/poison": "write" }, network: { enabled: false } }],
|
||||
["changed deny", { filesystem: { ...permissions.filesystem, "/work/hidden": "read" }, network: { enabled: false } }],
|
||||
["missing deny", { filesystem: { ":minimal": "read", "/work": "write" }, network: { enabled: false } }],
|
||||
["network changed", { filesystem: permissions.filesystem, network: { enabled: true } }],
|
||||
["empty filesystem", { filesystem: {}, network: { enabled: false } }],
|
||||
])("refuses %s without disclosing its values", (_label, profile) => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": profile });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.any(String) });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wrong profile selection", { default_permissions: "other" }],
|
||||
["wrong approval", { approval_policy: "on-request" }],
|
||||
["missing profile map", { permissions: null }],
|
||||
])("refuses %s", (_label, patch) => {
|
||||
const read = config();
|
||||
Object.assign(read.config, patch);
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it.each([undefined, null, {}, { config: [] }])("reports missing or malformed configuration %#", read => {
|
||||
expect(decideCodexFilesystemPolicy({ permissions, configRead: read, requirementsRead: { requirements: null } })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("accepts unrelated managed model settings and an approval allowlist that includes the choice", () => {
|
||||
expect(decide(config(), { requirements: { modelProvider: "openai", allowedApprovalPolicies: ["on-request", "never"],
|
||||
allowedPermissionProfiles: null, defaultPermissions: null, network: null, featureRequirements: null } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ allowedApprovalPolicies: ["on-request"] },
|
||||
{ allowedPermissionProfiles: { "other": true } },
|
||||
{ allowedPermissionProfiles: { "openrig-seat": false } },
|
||||
])("does not bypass managed policy %#", requirements => {
|
||||
expect(decide(config(), { requirements })).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it.each([undefined, null, {}, { requirements: [] }, { requirements: null, futurePolicy: true }])("reports unreadable requirements %#", read => {
|
||||
expect(decideCodexFilesystemPolicy({ permissions, configRead: config(), requirementsRead: read })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("authored ancestor and selector coverage matrix", () => {
|
||||
const ancestors = {
|
||||
"root read": { "/": "read" },
|
||||
"root write": { "/": "write" },
|
||||
"root deny": { "/": "deny" },
|
||||
"native root deny": { ":root": "deny" },
|
||||
"literal read": { ":root": "read", "/work/hidden": "read" },
|
||||
"literal write": { ":root": "read", "/work/hidden": "write" },
|
||||
"literal deny": { ":root": "read", "/work": "write", "/work/hidden": "deny" },
|
||||
} satisfies Record<string, CodexFilesystemPermissions["filesystem"]>;
|
||||
type Outcome = "accept" | "not-verified" | "refuse";
|
||||
// Expected outcomes are authored from the rule, not computed by another
|
||||
// coverage comparator. Columns: read, write, read+aliases, write+aliases.
|
||||
// Authored uncertainty warns; lower-layer aliases cannot hide a known
|
||||
// widening. A non-root deny wins; a root deny is a refinable default.
|
||||
const matrix = [
|
||||
["root read", "none", "accept", "refuse", "not-verified", "refuse"],
|
||||
["root read", "read", "accept", "refuse", "not-verified", "refuse"],
|
||||
["root read", "write", "accept", "not-verified", "not-verified", "not-verified"],
|
||||
["root write", "none", "accept", "accept", "not-verified", "not-verified"],
|
||||
["root write", "read", "accept", "accept", "not-verified", "not-verified"],
|
||||
["root write", "write", "accept", "accept", "not-verified", "not-verified"],
|
||||
["root deny", "none", "refuse", "refuse", "refuse", "refuse"],
|
||||
["root deny", "read", "not-verified", "refuse", "not-verified", "refuse"],
|
||||
["root deny", "write", "not-verified", "not-verified", "not-verified", "not-verified"],
|
||||
["native root deny", "none", "refuse", "refuse", "refuse", "refuse"],
|
||||
["native root deny", "read", "not-verified", "refuse", "not-verified", "refuse"],
|
||||
["native root deny", "write", "not-verified", "not-verified", "not-verified", "not-verified"],
|
||||
["literal read", "none", "accept", "refuse", "not-verified", "refuse"],
|
||||
["literal read", "read", "accept", "refuse", "not-verified", "refuse"],
|
||||
["literal read", "write", "accept", "not-verified", "not-verified", "not-verified"],
|
||||
["literal write", "none", "accept", "accept", "not-verified", "not-verified"],
|
||||
["literal write", "read", "accept", "accept", "not-verified", "not-verified"],
|
||||
["literal write", "write", "accept", "accept", "not-verified", "not-verified"],
|
||||
["literal deny", "none", "refuse", "refuse", "refuse", "refuse"],
|
||||
["literal deny", "read", "refuse", "refuse", "refuse", "refuse"],
|
||||
["literal deny", "write", "refuse", "refuse", "refuse", "refuse"],
|
||||
] satisfies [keyof typeof ancestors, "none" | "read" | "write", Outcome, Outcome, Outcome, Outcome][];
|
||||
const cases = matrix.flatMap(([ancestor, selector, read, write, readAliases, writeAliases]) => [
|
||||
{ ancestor, selector, grant: "read", aliases: false, outcome: read },
|
||||
{ ancestor, selector, grant: "write", aliases: false, outcome: write },
|
||||
{ ancestor, selector, grant: "read", aliases: true, outcome: readAliases },
|
||||
{ ancestor, selector, grant: "write", aliases: true, outcome: writeAliases },
|
||||
]);
|
||||
it.each(cases)("$ancestor; selector=$selector; grant=$grant; aliases=$aliases => $outcome", ({ ancestor, selector, grant, aliases, outcome }) => {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: {
|
||||
...ancestors[ancestor], ...(selector === "none" ? {} : { ":workspace_roots": selector }),
|
||||
} };
|
||||
const aliasAccess = selector === "none" ? "read" : selector;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: {
|
||||
...own.filesystem, ...(aliases ? { ":workspace_roots": aliasAccess, ":project_roots": aliasAccess } : {}),
|
||||
"/work/hidden/secret": grant,
|
||||
}, network: { enabled: false } } });
|
||||
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
|
||||
if (outcome === "refuse") expect(result).toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
|
||||
else if (outcome === "not-verified") expect(result).toMatchObject({ ok: true, verified: false });
|
||||
else expect(result).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("root deny refinements", () => {
|
||||
it.each(["/", ":root"])('keeps selector overlap unverified beneath %s deny', root => {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":slash_tmp": "write" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/", "/Users/x/.ssh/key"], [":root", "/Users/x/.ssh/key"],
|
||||
["/", "/home/x/.ssh/key"], [":root", "/home/x/.ssh/key"],
|
||||
])('refuses a user-data read outside minimal defaults beneath %s deny at %s', (root, key) => {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read", "/work": "write" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, [key]: "read" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining(JSON.stringify(key)) });
|
||||
});
|
||||
|
||||
it.each(["/", ":root"])('does not claim to expand minimal system reads beneath %s deny', root => {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/usr/bin/tool": "read" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it.each(["/", ":root"])('retains other authored coverage for user data beneath %s deny', root => {
|
||||
const key = "/Users/x/.ssh/key";
|
||||
for (const [rule, expected] of [[":workspace_roots", "not-verified"], ["/Users/x", "accept"]] as const) {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read", [rule]: "read" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, [key]: "read" }, network: { enabled: false } } });
|
||||
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
|
||||
if (expected === "accept") expect(result).toEqual({ ok: true, verified: true });
|
||||
else expect(result).toMatchObject({ ok: true, verified: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("minimal coverage without an authored root rule", () => {
|
||||
it.each(["/Users/x/.ssh/key", "/users/x/.ssh/key", "/HOME/x/.ssh/key"])("refuses an uncovered user-data read at %s", key => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "read" });
|
||||
const result = decide(read);
|
||||
expect(result).toMatchObject({ ok: false, error: expect.stringContaining(JSON.stringify(key)) });
|
||||
if (!result.ok) expect(result.error).toContain("Fix:");
|
||||
});
|
||||
|
||||
it("keeps an unexpanded system read unverified and names its key", () => {
|
||||
const key = "/opt/x";
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "read" });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining(JSON.stringify(key)) });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex seat filesystem verifier reader boundary", () => {
|
||||
it.each([["env", "CODEX_HOME=/other"], ["custom-wrapper"]])("reports a launch wrapper without certifying a different configuration %#", async launchWrapper => {
|
||||
vi.mocked(readCodexConfiguration).mockClear();
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", launchWrapper }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("launch_wrapper") });
|
||||
expect(readCodexConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["", "relative/home", "/absolute\nother"])("reports an unestablished account home %# before reading", async codexHome => {
|
||||
vi.mocked(readCodexConfiguration).mockClear();
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome })).toMatchObject({ ok: true, verified: false });
|
||||
expect(readCodexConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the seat home and candidate invocation policy, never sandbox or per-command selection overrides", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: true, configRead: config(), requirementsRead: { requirements: null } });
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", launchPath: "/bin" })).toEqual({ ok: true, verified: true });
|
||||
expect(readCodexConfiguration).toHaveBeenLastCalledWith({ cwd: "/work", codexHome: "/account", launchPath: "/bin", args: [
|
||||
"-c", 'approval_policy="never"', "-c", 'default_permissions="openrig-seat"', "-c",
|
||||
'permissions={"openrig-seat"={filesystem={":minimal"="read","/work"="write","/work/hidden"="deny"},network={enabled=false}}}',
|
||||
"-c", "features.plugins=false", "-c", "features.apps=false",
|
||||
] });
|
||||
});
|
||||
|
||||
it("returns an unavailable read as unverified", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: false, reason: "native read timed out" });
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("names the filesystem rule, access on each side and a remedy without dumping configuration", () => {
|
||||
const read = config();
|
||||
read.config.permissions["openrig-seat"].filesystem["/work/hidden"] = "read";
|
||||
Object.assign(read.config, { privateSetting: "do-not-print-this-value" });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringMatching(/\/work\/hidden.*agent.*deny.*(?:Codex|layer).*read/i) });
|
||||
const decision = decide(read);
|
||||
if (decision.ok) throw new Error("fixture must conflict");
|
||||
expect(decision.error).toContain("\nFix:");
|
||||
expect(decision.error).not.toContain("do-not-print-this-value");
|
||||
read.config.default_permissions = "private-profile-selection";
|
||||
const selection = decide(read);
|
||||
if (selection.ok) throw new Error("fixture must conflict");
|
||||
expect(selection.error).toContain("default_permissions");
|
||||
expect(selection.error).toContain("\nFix:");
|
||||
expect(selection.error).not.toContain("private-profile-selection");
|
||||
});
|
||||
|
||||
it("returns a thrown reader failure without exposing arbitrary error contents", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockRejectedValueOnce(new Error("do not print this"));
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" })).toEqual({
|
||||
ok: true, verified: false, reason: "Native Codex configuration reader failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the normal account home without an explicit CODEX_HOME", async () => {
|
||||
vi.stubEnv("CODEX_HOME", undefined);
|
||||
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: true, configRead: config(), requirementsRead: { requirements: null } });
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work" })).toEqual({ ok: true, verified: true });
|
||||
expect(readCodexConfiguration).toHaveBeenLastCalledWith(expect.objectContaining({ codexHome: `${os.homedir()}/.codex` }));
|
||||
});
|
||||
|
||||
it("keeps a named profile unverified instead of certifying the base configuration", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockClear();
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", codexConfigProfile: "model-only" }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("codex_config_profile") });
|
||||
expect(readCodexConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compatible policies and inconclusive reads", () => {
|
||||
it.each([":root", ":minimal"])(
|
||||
"refuses a known extra write grant at %s, including beside an unfamiliar representation", key => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "write", future: { access: "future" } });
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("refuses full-root read beyond minimal read", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":root": "read" });
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it.each([":workspace_roots", ":project_roots", ":tmpdir", ":slash_tmp", "~/x"])(
|
||||
"leaves extra %s coverage unverified when an authored path may already cover it", key => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "write" });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("does not refuse a potentially redundant slash_tmp grant beside an authored /tmp grant", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", "/tmp": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":slash_tmp": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("still refuses a selector write when no authored rule could cover write access", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":slash_tmp": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("does not invent a selector conflict by collapsing ambiguous coverage aliases", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", ":project_roots": "write", ":workspace_roots": "deny" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":tmpdir": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("effective configuration grants") });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ":workspace_roots": "read", ":project_roots": "read" },
|
||||
{ ":project_roots": "read", ":workspace_roots": "read" },
|
||||
])("refuses an outside write despite lower-layer duplicate aliases %#", aliases => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ...aliases, "/outside": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining('"/outside"') });
|
||||
});
|
||||
|
||||
it("leaves only the lower-layer duplicate aliases unverified when there is no separate conflict", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem,
|
||||
":workspace_roots": "read", ":project_roots": "read" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("leaves an extra absolute rule unverified when a native selector may cover it", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", ":tmpdir": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
const literal = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...literal.filesystem, "/outside": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: literal, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("checks authored selectors before refusing a write beneath a read-only literal ancestor", () => {
|
||||
const own = { ...permissions, filesystem: { "/": "read", ":slash_tmp": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
|
||||
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
|
||||
expect(result).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining('"/tmp/child"') });
|
||||
expect(result).toMatchObject({ reason: expect.stringContaining("write") });
|
||||
});
|
||||
|
||||
it("still refuses a direct literal-root widening beside an authored selector", () => {
|
||||
const own = { ...permissions, filesystem: { "/": "read", ":slash_tmp": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining('"/"') });
|
||||
});
|
||||
|
||||
it("refuses a read beneath the fixture's authored deny despite minimal read coverage", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work/hidden/secret": "read" });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
|
||||
});
|
||||
|
||||
it("refuses a write beneath an authored deny despite an authored write selector", () => {
|
||||
const own = { ...permissions, filesystem: { ...permissions.filesystem, ":workspace_roots": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/work/hidden/secret": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
|
||||
});
|
||||
|
||||
it("names the extra selector and access in an inconclusive coverage warning", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":slash_tmp": "write" });
|
||||
const result = decide(read);
|
||||
expect(result).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining('":slash_tmp"') });
|
||||
expect(result).toMatchObject({ reason: expect.stringContaining("write") });
|
||||
});
|
||||
|
||||
it.each(["never", "on-request"] satisfies CodexFilesystemPermissions["approval"][])(
|
||||
"reports an unfamiliar approval string as unverified for authored %s", approval => {
|
||||
const read = config();
|
||||
read.config.approval_policy = "future-native-approval";
|
||||
expect(decideCodexFilesystemPolicy({ permissions: { ...permissions, approval }, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("approval") });
|
||||
});
|
||||
|
||||
it("still refuses a recognized untrusted approval instead of treating it as a future spelling", () => {
|
||||
const read = config();
|
||||
read.config.approval_policy = "untrusted";
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining("approval_policy") });
|
||||
});
|
||||
|
||||
it("does not let an unfamiliar approval hide a known filesystem or network conflict", () => {
|
||||
const read = config();
|
||||
read.config.approval_policy = "future-native-approval";
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/outside": "write" });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining('"/outside"') });
|
||||
Object.assign(read.config.permissions["openrig-seat"], { filesystem: permissions.filesystem, network: { enabled: true } });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining("network.enabled") });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ allowedApprovalPolicies: ["on-request"] },
|
||||
{ allowedPermissionProfiles: { "openrig-seat": false } },
|
||||
])("reports an excluding managed allowlist ahead of effective selection or approval mismatches %#", requirements => {
|
||||
const read = config();
|
||||
Object.assign(read.config, { default_permissions: "other", approval_policy: "on-request" });
|
||||
expect(decide(read, { requirements })).toMatchObject({ ok: false, error: expect.stringContaining("managed allowlist") });
|
||||
read.config.default_permissions = "openrig-seat";
|
||||
expect(decide(read, { requirements })).toMatchObject({ ok: false, error: expect.stringContaining("managed allowlist") });
|
||||
});
|
||||
|
||||
it.each(["default_permissions", "approval_policy"])("reports absent or null %s without inventing a conflict", key => {
|
||||
for (const value of [undefined, null]) {
|
||||
const read = config();
|
||||
Object.assign(read.config, { [key]: value });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a new profile representation without a filesystem field but keeps independent conflicts", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { futureFilesystem: { access: "new-format" }, network: { enabled: false } } });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
read.config.permissions["openrig-seat"].network.enabled = true;
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("accepts an allowed effective selection even when the managed fallback selects another profile", () => {
|
||||
expect(decide(config(), { requirements: { defaultPermissions: "private-fallback-value",
|
||||
allowedPermissionProfiles: { "openrig-seat": true } } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("accepts a known selector grant already covered by an authored full-root grant", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":root": "write", ":workspace_roots": "write", "~/x": "read" });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: { ...permissions, filesystem: { ...permissions.filesystem, ":root": "write" } },
|
||||
configRead: read, requirementsRead: { requirements: null } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it.each(["default_permissions", "approval_policy"])("reports a future %s representation without inventing a conflict", key => {
|
||||
const read = config();
|
||||
Object.assign(read.config, { [key]: { selection: "future-native-format" } });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("refuses an explicitly excluded profile even beside unknown allowlist entries", () => {
|
||||
expect(decide(config(), { requirements: { allowedPermissionProfiles: { "openrig-seat": false, future: { allowed: true } } } }))
|
||||
.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ filesystem: permissions.filesystem, network: { enabled: false }, extends: ":workspace" },
|
||||
{ filesystem: permissions.filesystem, network: { enabled: false }, workspace_roots: { "/extra": true } },
|
||||
{ filesystem: permissions.filesystem, network: { enabled: false }, futurePolicy: true },
|
||||
{ filesystem: permissions.filesystem, network: { enabled: false, allow_local_binding: true } },
|
||||
{ filesystem: { ":workspace_roots": { ".": "write" } }, network: { enabled: false } },
|
||||
{ filesystem: { "/work": "allow" }, network: { enabled: false } },
|
||||
])("reports unfamiliar selected policy options %#", profile => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": profile });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ allowedApprovalPolicies: "never" }, { featureRequirements: { use_linux_sandbox_bwrap: false } },
|
||||
{ network: {} }, { futureFilesystemPolicy: true },
|
||||
])("reports unsupported requirements without refusing %#", requirements => {
|
||||
expect(decide(config(), { requirements })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("keeps positive configuration and managed conflicts visible alongside unknowns", () => {
|
||||
const read = config();
|
||||
read.config.default_permissions = "other";
|
||||
expect(decide(read, { requirements: { futureManagedPolicy: true } })).toMatchObject({ ok: false });
|
||||
expect(decide(config(), { requirements: { futureManagedPolicy: true, allowedApprovalPolicies: ["on-request"] } }))
|
||||
.toMatchObject({ ok: false });
|
||||
expect(decideCodexFilesystemPolicy({ permissions, configRead: null, requirementsRead: { requirements: { allowedPermissionProfiles: { "openrig-seat": false } } } }))
|
||||
.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("accepts inert legacy settings, a model-only profile and harmless descriptions", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config, { sandbox_mode: "read-only", sandbox_workspace_write: { network_access: true }, profile: "model-only" });
|
||||
Object.assign(read.config.permissions["openrig-seat"], { description: "description only" });
|
||||
expect(decide(read)).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("treats every null profile field as absent, including future fields", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"], { futureProfile: null });
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { futureFilesystem: null });
|
||||
Object.assign(read.config.permissions["openrig-seat"].network, { futureNetwork: null });
|
||||
expect(decide(read)).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("accepts a narrower filesystem policy and redundant subpath grants", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work": "read", "/work/child": "read" });
|
||||
expect(decide(read)).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("accepts matching managed selection and allowlists", () => {
|
||||
expect(decide(config(), { requirements: { defaultPermissions: "openrig-seat",
|
||||
allowedPermissionProfiles: { "openrig-seat": true }, allowedApprovalPolicies: ["on-request", "never"],
|
||||
allowedSandboxModes: ["read-only"] } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("compares the project_roots alias without silently collapsing conflicting spellings", () => {
|
||||
const withAlias: CodexFilesystemPermissions = { approval: "never", filesystem: { ":project_roots": "write" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ":workspace_roots": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: withAlias, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toEqual({ ok: true, verified: true });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: { ...withAlias, filesystem: { ":workspace_roots": "deny", ":project_roots": "write" } },
|
||||
configRead: read, requirementsRead: { requirements: null } })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ configRead: null, requirementsRead: { requirements: null } },
|
||||
{ configRead: config(), requirementsRead: { requirements: [] } },
|
||||
{ configRead: config(), requirementsRead: { requirements: { futureManagedPolicy: true } } },
|
||||
])("reports an unreadable shape or unknown requirement without refusing %#", request => {
|
||||
expect(decideCodexFilesystemPolicy({ permissions, ...request })).toMatchObject({ ok: true, verified: false, reason: expect.any(String) });
|
||||
});
|
||||
|
||||
it.each(["future-access", { read: ["/nested"] }])("reports unfamiliar filesystem representation without refusing %j", access => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work/hidden": access });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("still refuses a concrete extra write grant alongside an unfamiliar representation", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/poison": "write", "/future": { read: ["/nested"] } });
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("reports a reader timeout without dropping or replacing the invocation policy", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: false, reason: "native read timed out" });
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("timed out") });
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,71 @@ describe("the typed Codex envelope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a seat's explicit filesystem policy", () => {
|
||||
it("accepts the native project_roots alias without changing its authored spelling", () => {
|
||||
expect(validateCodexMemberPermissions({ approval: "never", filesystem: { ":project_roots": "write" } }))
|
||||
.toEqual({ approval: "never", filesystem: { ":project_roots": "write" }, network_access: false });
|
||||
});
|
||||
const policy = { approval: "never", filesystem: {
|
||||
":minimal": "read", ":workspace_roots": "read", "/lease/a": "write", "/lease/a/private": "deny",
|
||||
}, network_access: false };
|
||||
|
||||
it("accepts native read, write and deny entries without a legacy sandbox selection", () => {
|
||||
expect(validateCodexMemberPermissions(policy)).toEqual(policy);
|
||||
expect(validateCodexMemberPermissions({ approval: "on-request", filesystem: { ":minimal": "read" } }))
|
||||
.toEqual({ approval: "on-request", filesystem: { ":minimal": "read" }, network_access: false });
|
||||
});
|
||||
|
||||
it("keeps two seats' filesystem choices separate and clears only the selected seat", async () => {
|
||||
const f = fixture();
|
||||
const other = { approval: "on-request", filesystem: { ":minimal": "read", "/lease/b": "write" }, network_access: false };
|
||||
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true, to: policy });
|
||||
expect(await f.service.setPermissions({ ...set(other), seatRef: "dev-b@codex-team" })).toMatchObject({ ok: true, to: other });
|
||||
const store = new NativePermissionStore(f.db);
|
||||
expect(store.launchOverride(f.a.id, "codex").codexPermissions).toEqual(policy);
|
||||
expect(store.launchOverride(f.b.id, "codex").codexPermissions).toEqual(other);
|
||||
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", mode: "inherit", actor: "operator@codex-team", reason: "Clear only A" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(readCodexMemberPermissions(f.db, f.a.id)).toBeUndefined();
|
||||
expect(store.launchOverride(f.b.id, "codex").codexPermissions).toEqual(other);
|
||||
});
|
||||
|
||||
it("allows named-profile combinations in either setter direction and keeps the authored policy", async () => {
|
||||
const f = fixture();
|
||||
await f.service.setPermissions(set(reader));
|
||||
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "named", actor: "operator@codex-team", reason: "Select" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true });
|
||||
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(policy);
|
||||
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "inherit", actor: "operator@codex-team", reason: "Clear" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true });
|
||||
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "named", actor: "operator@codex-team", reason: "Conflict" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(policy);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...policy, approval: "untrusted" },
|
||||
{ ...policy, sandbox: "workspace-write" },
|
||||
{ ...policy, writable_roots: ["/another/root"] },
|
||||
{ ...policy, exclude_slash_tmp: true },
|
||||
{ ...policy, filesystem: {} },
|
||||
{ ...policy, filesystem: { "relative/path": "deny" } },
|
||||
{ ...policy, filesystem: { ":unknown": "write" } },
|
||||
{ ...policy, filesystem: { "/a\nb": "deny" } },
|
||||
{ ...policy, filesystem: { "/private": "none" } },
|
||||
{ ...policy, filesystem: { "/private": true } },
|
||||
{ ...policy, network_access: "yes" },
|
||||
])("rejects incomplete or mixed policy without replacing a saved choice: %j", async (invalid) => {
|
||||
expect(() => validateCodexMemberPermissions(invalid)).toThrow();
|
||||
const f = fixture();
|
||||
expect(await f.service.setPermissions(set(reader))).toMatchObject({ ok: true });
|
||||
expect(await f.service.setPermissions(set(invalid))).toMatchObject({ ok: false });
|
||||
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(reader);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a Codex seat's current envelope (offline, no native enforcement claim)", () => {
|
||||
it("gives two seats in one folder their own envelopes, as the seat's explicit choice", async () => {
|
||||
const f = fixture();
|
||||
@@ -249,7 +314,7 @@ describe("a Codex seat's current profile", () => {
|
||||
});
|
||||
|
||||
describe("both writers: the spec at creation, the setter afterwards, every launch reading the seat's record", () => {
|
||||
const authored = (profile: string, permissions?: CodexMemberPermissions) => ({ version: "0.2", name: "authored", pods: [{ id: "dev", label: "Dev", members: [
|
||||
const authored = (profile: string | undefined, permissions?: CodexMemberPermissions) => ({ version: "0.2", name: "authored", pods: [{ id: "dev", label: "Dev", members: [
|
||||
{ id: "impl", agent_ref: "local:impl.agent.yaml", profile: "default", runtime: "codex", cwd: "/same/project", codex_config_profile: profile,
|
||||
...(permissions ? { codex_permissions: permissions } : {}) },
|
||||
], edges: [] }], edges: [] });
|
||||
@@ -292,6 +357,27 @@ describe("both writers: the spec at creation, the setter afterwards, every launc
|
||||
expect(RigSpecSchema.validate(wrongRuntime).errors.join(" ")).toContain("codex_permissions");
|
||||
});
|
||||
|
||||
it("materializes and exports the filesystem choice with a named profile", async () => {
|
||||
const f = fixture();
|
||||
const policy: CodexMemberPermissions = { approval: "never", filesystem: { ":minimal": "read", "/lease/a": "write", "/lease/a/private": "deny" }, network_access: false };
|
||||
const raw = authored("model-only", policy);
|
||||
expect(RigSpecSchema.validate(raw).valid).toBe(true);
|
||||
const { setup, node, rigId } = await materialize(f.db, raw);
|
||||
expect(readCodexMemberPermissions(f.db, node.id)).toEqual(policy);
|
||||
const exported = new RigSpecExporter({ rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, podRepo: new PodRepository(f.db) }).exportRig(rigId);
|
||||
const decoded = RigSpecSchema.normalize(RigSpecCodec.parse(RigSpecCodec.serialize(exported as import("../src/domain/types.js").RigSpec)));
|
||||
expect(decoded.pods[0]!.members[0]!.codexPermissions).toEqual(policy);
|
||||
expect(decoded.pods[0]!.members[0]!.codexConfigProfile).toBe("model-only");
|
||||
setup.sessionRegistry.registerSession(node.id, "dev-impl@authored");
|
||||
const service = new SeatLifecycleService({ db: f.db, rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, eventBus: setup.eventBus,
|
||||
tmuxAdapter: {} as TmuxAdapter });
|
||||
expect(await service.setPermissions({ seatRef: "dev-impl@authored", codexProfile: "other-model", actor: "operator@authored", reason: "Model selection" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(await service.setPermissions({ seatRef: "dev-impl@authored", codexPermissions: policy, actor: "operator@authored", reason: "Keep policy" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(readCodexMemberPermissions(f.db, node.id)).toEqual(policy);
|
||||
});
|
||||
|
||||
it("restores with the seat's current profile, so an older snapshot can't bring back a replaced one", async () => {
|
||||
const f = fixture();
|
||||
const setup = createTestApp(f.db);
|
||||
@@ -322,6 +408,8 @@ describe("legacy flat specs carry the envelope too", () => {
|
||||
const exported = new RigSpecExporter({ rigRepo: f.rigRepo, sessionRegistry: new SessionRegistry(f.db) }).exportRig(f.a.rigId);
|
||||
const parsed = LegacyRigSpecCodec.parse(LegacyRigSpecCodec.serialize(exported as import("../src/domain/types.js").LegacyRigSpec));
|
||||
expect(LegacyRigSpecSchema.normalize(parsed).nodes.find(n => n.codexPermissions)!.codexPermissions).toEqual(lease);
|
||||
expect(LegacyRigSpecSchema.validate({ ...raw, nodes: [{ ...raw.nodes[0], codex_config_profile: "model-only",
|
||||
codex_permissions: { approval: "never", filesystem: { ":project_roots": "write" } } }] }).errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import { createHash } from "node:crypto";
|
||||
import { parse } from "smol-toml";
|
||||
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { codexPluginContentHash, codexLegacyPluginTrustPath, planOwnedCodexContentApproval } from "../src/domain/codex-owned-plugin-trust.js";
|
||||
import { codexPluginContentHash, codexLegacyPluginTrustPath, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../src/domain/codex-owned-plugin-trust.js";
|
||||
|
||||
const shipped = path.resolve(import.meta.dirname, "../assets/plugins/openrig-core");
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void) {
|
||||
function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void, onConfigRead?: () => void) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "owned-codex-hooks-")); roots.push(root);
|
||||
const plugin = path.join(root, "plugins/openrig-core"), home = path.join(root, "codex");
|
||||
fs.cpSync(shipped, plugin, { recursive: true }); fs.mkdirSync(home);
|
||||
@@ -23,7 +23,7 @@ function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void
|
||||
const cache = path.join(home, "plugins/cache/openrig/openrig-core", version);
|
||||
const calls: string[][] = [];
|
||||
const adapter = () => new CodexRuntimeAdapter({ tmux: {} as TmuxAdapter, codexHome: home,
|
||||
fsOps: { readFile: p => fs.readFileSync(p, "utf8"), writeFile: (p,c) => fs.writeFileSync(p,c), exists: fs.existsSync, mkdirp: p => fs.mkdirSync(p,{recursive:true}) },
|
||||
fsOps: { readFile: p => { if (p === file) onConfigRead?.(); return fs.readFileSync(p, "utf8"); }, writeFile: (p,c) => fs.writeFileSync(p,c), exists: fs.existsSync, mkdirp: p => fs.mkdirSync(p,{recursive:true}) },
|
||||
resolveCorePluginDir: () => plugin, codexSystemConfigDir: path.join(root, "etc"),
|
||||
runCodexCli: async args => {
|
||||
calls.push(args);
|
||||
@@ -361,3 +361,126 @@ it("R2 oa fresh nonmatching bundle gains no declaration-only legacy approval", a
|
||||
expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
|
||||
expect(Object.keys(f.read().hooks?.state ?? {})).toHaveLength(0);
|
||||
});
|
||||
|
||||
// #17: a home OpenRig granted for an earlier release whose Stop hook declaration differed (its timeout), recorded by
|
||||
// OpenRig's own planner and writer, then upgraded to the current shipped bundle.
|
||||
const stopKey = "openrig-core@openrig:hooks/codex.json:stop:0:0";
|
||||
const recordPath = (f: ReturnType<typeof fixture>) => path.join(f.home, ".openrig-core-hook-trust.json");
|
||||
function earlierReleaseGrant(f: ReturnType<typeof fixture>, extra = "") {
|
||||
const earlier = path.join(path.dirname(f.home), "earlier-shipped");
|
||||
fs.cpSync(shipped, earlier, { recursive: true });
|
||||
const manifestPath = path.join(earlier, "hooks/codex.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
manifest.hooks.Stop[0].hooks[0].timeout += 5;
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const earlierCache = path.join(path.dirname(f.cache), "earlier");
|
||||
fs.cpSync(earlier, earlierCache, { recursive: true });
|
||||
const { computeCodexHookTrust } = createRequire(import.meta.url)(path.join(shipped, "hooks/scripts/codex-hook-trust.cjs"));
|
||||
const content = codexPluginContentHash(earlier);
|
||||
let config = `[marketplaces.openrig]\nsource = ${JSON.stringify(path.dirname(f.plugin))}\n\n[plugins."openrig-core@openrig"]\nenabled = true\n`;
|
||||
for (const [event, groups] of Object.entries(manifest.hooks) as Array<[string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout: number }> }>]>) {
|
||||
groups.forEach((group, groupIndex) => group.hooks.forEach((handler, handlerIndex) => {
|
||||
const { key, hash } = computeCodexHookTrust(event, { ...handler, matcher: group.matcher, timeoutSec: handler.timeout,
|
||||
keySource: "openrig-core@openrig:hooks/codex.json", groupIndex, handlerIndex });
|
||||
config += `\n[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(hash)}\ntrusted_content_hash = ${JSON.stringify(content)}\n`;
|
||||
}));
|
||||
}
|
||||
config += extra;
|
||||
fs.writeFileSync(f.file, config);
|
||||
const plan = planOwnedCodexContentApproval({ configPath: f.file, content: config,
|
||||
layers: [{ name: "user config", path: f.file, state: "read", content: config }], shipped: earlier, installed: earlierCache, freshGrant: true });
|
||||
expect(plan).toBeDefined();
|
||||
recordOwnedCodexApproval(f.file, plan!.approval);
|
||||
const stop = (parse(config) as any).hooks.state[stopKey];
|
||||
return { config, record: fs.readFileSync(recordPath(f), "utf8"), earlierStop: stop.trusted_hash as string, earlierContent: content };
|
||||
}
|
||||
|
||||
const outside = '\n[hooks.state."other-plugin@market:hooks/codex.json:stop:0:0"]\ntrusted_hash = "sha256:outside"\ntrusted_content_hash = "sha256:outside-content"\n\n\n';
|
||||
it("the first launch after an upgrade that changed a declaration renews OpenRig's own recorded approvals and its record", async () => {
|
||||
const f = fixture(), earlier = earlierReleaseGrant(f, outside);
|
||||
expect(earlier.earlierStop).not.toBe((await freshInstallStates())[stopKey].trusted_hash);
|
||||
expect(await f.adapter().ensureCodexCorePlugin()).toMatchObject({ outcome: "installed" });
|
||||
const states = f.read().hooks.state;
|
||||
const fresh = await freshInstallStates();
|
||||
expect(Object.fromEntries(Object.entries(states).filter(([key]) => key.startsWith("openrig-core@openrig:")))).toEqual(fresh);
|
||||
expect(states["other-plugin@market:hooks/codex.json:stop:0:0"]).toEqual({ trusted_hash: "sha256:outside", trusted_content_hash: "sha256:outside-content" });
|
||||
const text = fs.readFileSync(f.file, "utf8");
|
||||
expect(text.endsWith(outside)).toBe(true);
|
||||
expect(text.replace(/^trusted(_content)?_hash = .*\n/gm, "")).toBe(earlier.config.replace(/^trusted(_content)?_hash = .*\n/gm, ""));
|
||||
const record = JSON.parse(fs.readFileSync(recordPath(f), "utf8"));
|
||||
expect(record).toEqual({ schema: 1, plugin: "openrig-core@openrig", contentHash: codexPluginContentHash(shipped),
|
||||
declarations: Object.fromEntries(Object.entries(fresh).map(([key, state]: [string, any]) => [key, state.trusted_hash])) });
|
||||
const renewed = fs.readFileSync(f.file, "utf8");
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
expect(fs.readFileSync(f.file, "utf8")).toBe(renewed);
|
||||
});
|
||||
|
||||
it("a restart after the new bundle was installed but before renewal renews the same way, without reinstalling", async () => {
|
||||
const f = fixture(); earlierReleaseGrant(f);
|
||||
fs.cpSync(f.plugin, f.cache, { recursive: true });
|
||||
expect(await f.adapter().ensureCodexCorePlugin()).toMatchObject({ outcome: "current" });
|
||||
expect(f.calls).toEqual([]);
|
||||
expect(f.read().hooks.state).toEqual(await freshInstallStates());
|
||||
});
|
||||
|
||||
it.each(["mismatched declaration", "mismatched content", "disabled", "other layer", "profile choice", "nested plugin entry", "record removed", "record of another plugin"])(
|
||||
"a changed declaration keeps Codex's review for %s", async condition => {
|
||||
const f = fixture(), earlier = earlierReleaseGrant(f);
|
||||
const header = `[hooks.state.${JSON.stringify(stopKey)}]`;
|
||||
const edit = (change: (text: string) => string) => fs.writeFileSync(f.file, change(fs.readFileSync(f.file, "utf8")));
|
||||
if (condition === "mismatched declaration") edit(text => text.replace(earlier.earlierStop, `sha256:${"0".repeat(64)}`));
|
||||
if (condition === "mismatched content") edit(text => text.replace(`${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\ntrusted_content_hash = "${earlier.earlierContent}"`,
|
||||
`${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\ntrusted_content_hash = "sha256:${"0".repeat(64)}"`));
|
||||
if (condition === "disabled") edit(text => text.replace(header, `${header}\nenabled = false`));
|
||||
if (condition === "other layer") {
|
||||
fs.mkdirSync(path.join(path.dirname(f.home), "etc"));
|
||||
fs.writeFileSync(path.join(path.dirname(f.home), "etc/config.toml"), `${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\n`);
|
||||
}
|
||||
if (condition === "profile choice") edit(text => `${text}\n[profiles.special.hooks.state.${JSON.stringify(stopKey)}]\nenabled = false\n`);
|
||||
if (condition === "nested plugin entry") edit(text => `${text}\n[profiles.special.plugins."openrig-core@openrig"]\nenabled = true\n`);
|
||||
if (condition === "record removed") fs.rmSync(recordPath(f));
|
||||
if (condition === "record of another plugin") fs.writeFileSync(recordPath(f), earlier.record.replace('"openrig-core@openrig"', '"other-plugin@market"'));
|
||||
const before = fs.readFileSync(f.file, "utf8"), record = fs.existsSync(recordPath(f)) ? fs.readFileSync(recordPath(f), "utf8") : undefined;
|
||||
expect(f.read().hooks.state[stopKey]).toBeDefined();
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
expect(fs.readFileSync(f.file, "utf8")).toBe(before);
|
||||
expect(fs.existsSync(recordPath(f)) ? fs.readFileSync(recordPath(f), "utf8") : undefined).toBe(record);
|
||||
});
|
||||
|
||||
it("the planner names exactly the changed declaration to rewrite, from the earlier grant's record", async () => {
|
||||
const f = fixture(), earlier = earlierReleaseGrant(f);
|
||||
fs.cpSync(f.plugin, f.cache, { recursive: true });
|
||||
const plan = planOwnedCodexContentApproval({ configPath: f.file, content: earlier.config,
|
||||
layers: [{ name: "user config", path: f.file, state: "read", content: earlier.config }], shipped, installed: f.cache });
|
||||
expect(plan?.trustedHashes).toEqual({ [stopKey]: (await freshInstallStates())[stopKey].trusted_hash });
|
||||
expect(Object.values(plan!.hashes)).toEqual(Array(6).fill(codexPluginContentHash(shipped)));
|
||||
});
|
||||
|
||||
// Codex records a person's decline of another OpenRig hook after the planner fingerprinted the installed bundle and
|
||||
// before OpenRig writes: on the first read of config.toml that follows, in the user config or in the system layer.
|
||||
it.each(["user config", "other layer"])("a choice recorded in the %s while the renewal is being prepared wins: no renewal and no new record", async where => {
|
||||
const header = '[hooks.state."openrig-core@openrig:hooks/codex.json:session_start:0:0"]';
|
||||
const decline = (text: string) => text.replace(header, `${header}\nenabled = false`);
|
||||
let planned = false, injected = false;
|
||||
const f: ReturnType<typeof fixture> = fixture("", false, undefined, () => {
|
||||
if (!planned || injected) return;
|
||||
injected = true;
|
||||
if (where === "user config") fs.writeFileSync(f.file, decline(fs.readFileSync(f.file, "utf8")));
|
||||
else {
|
||||
fs.mkdirSync(path.join(path.dirname(f.home), "etc"), { recursive: true });
|
||||
fs.writeFileSync(path.join(path.dirname(f.home), "etc/config.toml"), `${header}\nenabled = false\n`);
|
||||
}
|
||||
});
|
||||
const earlier = earlierReleaseGrant(f);
|
||||
const readdir = fs.readdirSync;
|
||||
const spy = vi.spyOn(fs, "readdirSync").mockImplementation(((dir: fs.PathLike, ...rest: unknown[]) => {
|
||||
if (String(dir).startsWith(f.cache)) planned = true;
|
||||
return (readdir as (...args: unknown[]) => unknown)(dir, ...rest);
|
||||
}) as typeof fs.readdirSync);
|
||||
try {
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(injected).toBe(true);
|
||||
expect(fs.readFileSync(f.file, "utf8")).toBe(where === "user config" ? decline(earlier.config) : earlier.config);
|
||||
expect(fs.readFileSync(recordPath(f), "utf8")).toBe(earlier.record);
|
||||
});
|
||||
@@ -138,6 +138,28 @@ function makeValidFlagPolicy(posture: "floor" | "full_bypass"): string {
|
||||
}
|
||||
|
||||
describe("Issue #2989 — native-resume-probe Codex show-and-label builder", () => {
|
||||
describe.each(["floor", "full_bypass", "auto"] as const)("filesystem policy with %s posture fallback", (resolvedPosture) => {
|
||||
it.each([false, true])("labels the omitted policy with recorded launch=%s, including wrappers", (hasRecord) => {
|
||||
const display = buildCodexResumeDisplay("tok-filesystem", {
|
||||
codexPermissions: { filesystem: { ":root": "read", "/work": "write" }, approval: "never" },
|
||||
resolvedPosture,
|
||||
recordedLaunch: hasRecord ? {
|
||||
runtime: "codex", axis: "sandbox", state: "observed", value: "permissions:openrig-seat",
|
||||
approvalPolicy: "never", reason: "emitted_launch_arguments",
|
||||
} : null,
|
||||
launchWrapper: ["env", "POLICY_WRAPPER=1"],
|
||||
});
|
||||
const fallback = buildCodexResumeDisplay("tok-filesystem", { resolvedPosture });
|
||||
expect(display.command).toBe(fallback.command);
|
||||
expect(display.command).not.toContain("default_permissions");
|
||||
expect(display.label).toContain("shown command uses posture fallback");
|
||||
expect(display.label).toContain("does not carry the agent's filesystem policy");
|
||||
expect(display.label).toContain("omits launch_wrapper");
|
||||
expect(display.label).not.toContain("a resume now uses");
|
||||
expect(display.status).toBe(hasRecord ? "recorded_uncertain" : "no_recorded_launch");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ import Database from "better-sqlite3";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
|
||||
import type { CodexFilesystemVerifier } from "../src/domain/codex-filesystem-preflight.js";
|
||||
import { ObservationPass } from "../src/domain/process-observation-pass.js";
|
||||
import { codexNonLoginShellArg } from "../src/domain/native-resume-probe.js";
|
||||
import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
|
||||
@@ -2842,6 +2845,203 @@ describe("a seat's own permission envelope (argv only, not native enforcement)",
|
||||
});
|
||||
});
|
||||
|
||||
describe("a seat's explicit filesystem policy (argv and native-read preflight, not enforcement)", () => {
|
||||
const filesystemChoice = { approval: "never", filesystem: { ":minimal": "read", "/same": "write", "/same/private": "deny" }, network_access: false } satisfies import("../src/domain/codex-member-permissions.js").CodexFilesystemPermissions;
|
||||
|
||||
it.each(["fresh", "resume", "codex_id", "codex_last"])("keeps a wrapped filesystem launch and reports unverified policy on %s", async kind => {
|
||||
const tmux = mockTmux();
|
||||
const environment = new SeatLaunchEnvironment(tmux, { PATH: "/bin" }, "/same");
|
||||
const command = vi.spyOn(environment, "command").mockImplementation(async (_session, core) => core);
|
||||
const verifyFilesystemPolicy: CodexFilesystemVerifier = async request => request.launchWrapper?.length
|
||||
? { ok: true, verified: false, reason: "launch_wrapper can change the checked configuration" } : { ok: true, verified: true };
|
||||
const launchWrapper = ["env", "CODEX_HOME=/other"];
|
||||
const options = { codexHome: "/shared-home", verifyFilesystemPolicy, seatLaunchEnvironment: environment,
|
||||
sleep: async () => {} };
|
||||
if (kind === "fresh" || kind === "resume") {
|
||||
const adapter = new CodexRuntimeAdapter({ ...options, tmux, fsOps: mockFs() });
|
||||
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice, launchWrapper };
|
||||
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
|
||||
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
|
||||
expect(result).toMatchObject({ ok: true, appliedLaunch: { reason: expect.stringContaining("policy not verified: launch_wrapper") } });
|
||||
} else {
|
||||
const restore = new CodexResumeAdapter(tmux, { ...options, maxWaitMs: 0 });
|
||||
expect(await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "floor", undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, filesystemChoice, undefined, launchWrapper))
|
||||
.toMatchObject({ ok: true, appliedLaunch: { reason: expect.stringContaining("policy not verified: launch_wrapper") } });
|
||||
}
|
||||
expect(command).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('default_permissions="openrig-seat"'), expect.objectContaining({ launchWrapper }));
|
||||
});
|
||||
|
||||
it.each(["fresh", "resume"])("emits the selected policy on %s without extra writable roots or shared policy files", async path => {
|
||||
const tmux = mockTmux(), filesystem = mockFs();
|
||||
const writes = vi.spyOn(filesystem, "writeFile");
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: true }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: filesystem, sleep: async () => {}, verifyFilesystemPolicy,
|
||||
codexHome: "/shared-home", resolveGitAddDirs: async () => ["/same/.git"], prepareSeatFolder: () => "/seat-state" });
|
||||
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice, launchPosture: "full_bypass" as const };
|
||||
const result = path === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
|
||||
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
const command = path === "fresh" ? vi.mocked(tmux.sendText).mock.calls[0]![1] : "core" in result ? result.core : "";
|
||||
expect(command).toContain('default_permissions="openrig-seat"');
|
||||
expect(command).toContain('"/same/private"="deny"');
|
||||
expect(command).not.toMatch(/--add-dir|danger-full-access|sandbox_workspace_write| -s /);
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ permissions: filesystemChoice, cwd: "/same", codexHome: "/shared-home" }));
|
||||
expect(writes.mock.calls.every(([path]) => !path.endsWith(".codex/config.toml"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a native-read mismatch before dispatching any command", async () => {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy: async () => ({ ok: false, error: "lower profile adds a writable root" }) });
|
||||
expect(await adapter.launchHarness({ ...makeBinding("/same"), codexPermissions: filesystemChoice }, { name: "dev-a@test-rig" }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining("lower profile") });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["fresh", "resume"])("pins the normal account home on the native read and %s command", async kind => {
|
||||
const previous = process.env.CODEX_HOME;
|
||||
delete process.env.CODEX_HOME;
|
||||
try {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: false, reason: "native read timed out" }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: { ...mockFs(), homedir: "/normal-account" }, sleep: async () => {}, verifyFilesystemPolicy });
|
||||
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice };
|
||||
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
|
||||
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
|
||||
expect(result).toMatchObject({ ok: true, appliedLaunch: { reason: "policy not verified: native read timed out" } });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ codexHome: "/normal-account/.codex" }));
|
||||
const command = kind === "fresh" ? vi.mocked(tmux.sendText).mock.calls[0]?.[1] : "command" in result ? result.command : "";
|
||||
expect(command).toContain("CODEX_HOME='/normal-account/.codex'");
|
||||
expect(command).toContain('default_permissions="openrig-seat"');
|
||||
expect(command).toContain('"/same/private"="deny"');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a thrown native reader as unverified before handover without changing config or sending input", async () => {
|
||||
const tmux = mockTmux(), filesystem = mockFs();
|
||||
const write = vi.spyOn(filesystem, "writeFile");
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: filesystem,
|
||||
verifyFilesystemPolicy: async () => { throw new Error("private error contents"); } });
|
||||
expect(await adapter.preflightLaunch({ ...makeBinding(), codexPermissions: filesystemChoice }))
|
||||
.toEqual({ ok: true, warnings: ["policy not verified: Native Codex configuration reader failed"] });
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries a pre-cutover check into one launch and checks again for a later launch", async () => {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>()
|
||||
.mockResolvedValueOnce({ ok: true, verified: false, reason: "native read timed out" })
|
||||
.mockResolvedValueOnce({ ok: false, error: "later policy conflict" });
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {}, verifyFilesystemPolicy });
|
||||
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice };
|
||||
expect(await adapter.preflightLaunch(binding)).toMatchObject({ ok: true });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
expect(await adapter.launchHarness(binding, { name: "dev-a@test-rig" }))
|
||||
.toMatchObject({ ok: true, warnings: ["policy not verified: native read timed out"],
|
||||
appliedLaunch: { reason: "policy not verified: native read timed out" } });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(1);
|
||||
vi.mocked(tmux.sendText).mockClear();
|
||||
expect(await adapter.launchHarness(binding, { name: "dev-a@test-rig" })).toMatchObject({ ok: false, error: "later policy conflict" });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(2);
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ homes: ["/shared-home", "/shared-home"], cwds: ["/same", "/same"] },
|
||||
{ homes: ["/shared-home", "/shared-home"], cwds: ["/project-a", "/project-b"] },
|
||||
{ homes: ["/home-a", "/home-b"], cwds: ["/same", "/same"] },
|
||||
] satisfies { homes: [string, string]; cwds: [string, string] }[])("keeps A/B/A policies separate with home/cwd sharing: %j", async ({ homes, cwds }) => {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: true }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {}, verifyFilesystemPolicy,
|
||||
seatHome: id => id === "A" ? homes[0] : homes[1] });
|
||||
const other: import("../src/domain/codex-member-permissions.js").CodexFilesystemPermissions = {
|
||||
approval: "on-request", filesystem: { ":minimal": "read", "/lease/b": "write" }, network_access: false,
|
||||
};
|
||||
for (const { id, cwd, permissions } of [
|
||||
{ id: "A", cwd: cwds[0], permissions: filesystemChoice },
|
||||
{ id: "B", cwd: cwds[1], permissions: other },
|
||||
{ id: "A", cwd: cwds[0], permissions: filesystemChoice },
|
||||
]) {
|
||||
expect(await adapter.launchHarness({ ...makeBinding(cwd), nodeId: id,
|
||||
codexPermissions: permissions }, { name: `dev-${id}@test-rig` })).toMatchObject({ ok: true });
|
||||
}
|
||||
const commands = vi.mocked(tmux.sendText).mock.calls.map(call => call[1]);
|
||||
expect(commands).toHaveLength(3);
|
||||
expect(commands[0]).toContain('"/same/private"="deny"');
|
||||
expect(commands[0]).not.toContain('"/lease/b"');
|
||||
expect(commands[1]).toContain('"/lease/b"="write"');
|
||||
expect(commands[1]).not.toContain('"/same/private"');
|
||||
expect(commands[2]).toEqual(commands[0]);
|
||||
expect(verifyFilesystemPolicy.mock.calls.map(call => call[0])).toEqual([
|
||||
expect.objectContaining({ codexHome: homes[0], cwd: cwds[0], permissions: filesystemChoice }),
|
||||
expect.objectContaining({ codexHome: homes[1], cwd: cwds[1], permissions: other }),
|
||||
expect.objectContaining({ codexHome: homes[0], cwd: cwds[0], permissions: filesystemChoice }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a named configuration profile and the authored filesystem policy on resume", async () => {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: false, reason: "profile reader unavailable" }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy,
|
||||
verifyProfilePreflight: async profile => ({ ok: true, profile }) });
|
||||
expect(await adapter.buildResumeCommand({ ...makeBinding("/same"), codexPermissions: filesystemChoice, codexConfigProfile: "named" },
|
||||
{ name: "dev-a@test-rig", resumeToken: "thread-A" })).toMatchObject({ ok: true,
|
||||
core: expect.stringMatching(/-p 'named' .*default_permissions="openrig-seat"/), appliedLaunch: { reason: "policy not verified: profile reader unavailable" } });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ codexConfigProfile: "named" }));
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["codex_id", "codex_last"])("legacy restore %s carries the same policy and refuses a failed native read", async kind => {
|
||||
const tmux = mockTmux();
|
||||
const restore = new CodexResumeAdapter(tmux, { codexHome: "/shared-home", maxWaitMs: 0, sleep: async () => {},
|
||||
verifyFilesystemPolicy: async () => ({ ok: true, verified: true }), resolveGitAddDirs: async () => ["/same/.git"] });
|
||||
await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "full_bypass", undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, filesystemChoice);
|
||||
const command = vi.mocked(tmux.sendText).mock.calls[0]![1];
|
||||
expect(command).toContain('default_permissions="openrig-seat"');
|
||||
expect(command).toContain('"/same/private"="deny"');
|
||||
expect(command).not.toMatch(/--add-dir|danger-full-access|sandbox_workspace_write| -s /);
|
||||
vi.mocked(tmux.sendText).mockClear();
|
||||
const refusing = new CodexResumeAdapter(tmux, { verifyFilesystemPolicy: async () => ({ ok: false, error: "policy mismatch" }) });
|
||||
expect(await refusing.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "floor", undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, filesystemChoice)).toMatchObject({ ok: false, message: "policy mismatch" });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["fresh", "resume", "codex_id", "codex_last"])("checks a filesystem seat's named profile load before dispatch on %s", async kind => {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy: CodexFilesystemVerifier = async () => ({ ok: true, verified: false, reason: "profile configuration cannot be compared" });
|
||||
if (kind === "fresh" || kind === "resume") {
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy,
|
||||
verifyProfilePreflight: async profile => ({ ok: false, profile, error: "profile failed to load" }) });
|
||||
const binding = { ...makeBinding(), codexPermissions: filesystemChoice, codexConfigProfile: "broken" };
|
||||
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
|
||||
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
|
||||
expect(result).toMatchObject({ ok: false, error: expect.stringContaining("profile failed to load") });
|
||||
} else {
|
||||
const restore = new CodexResumeAdapter(tmux, { verifyFilesystemPolicy, exec: async () => { throw new Error("profile failed to load"); } });
|
||||
expect(await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", "broken", "floor", undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, filesystemChoice)).toMatchObject({ ok: false, message: expect.stringContaining("profile failed to load") });
|
||||
}
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a filesystem seat's broken named profile during pre-cutover checks", async () => {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(),
|
||||
verifyFilesystemPolicy: async () => ({ ok: true, verified: false, reason: "profile configuration cannot be compared" }),
|
||||
verifyProfilePreflight: async profile => ({ ok: false, profile, error: "profile failed to load" }) });
|
||||
expect(await adapter.preflightLaunch({ ...makeBinding(), codexPermissions: filesystemChoice, codexConfigProfile: "broken" }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining("profile failed to load") });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #875 Codex: on a fresh launch of a supported Codex, OpenRig's managed guidance goes in as the launch's developer
|
||||
// instructions (`-c developer_instructions`, Codex 0.164.0), so the person's tracked repository AGENTS.md and the Codex
|
||||
// home are left alone. Anything else keeps the repository AGENTS.md, as before.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { communityHarnessLabel, loadCommunityHarnessPreset } from "../src/domain/community-harness-presets.js";
|
||||
import type { RunnerGrantIssuer } from "../src/domain/runner-grant-launch.js";
|
||||
import { CommunityHarnessRuntimeAdapter } from "../src/adapters/community-harness-runtime-adapter.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
|
||||
@@ -15,7 +16,7 @@ const PRESET = "format: 1\nname: synth\ncommand: synth-launch --serve\nmaintaine
|
||||
const binding = { tmuxSession: "w@r" } as NodeBinding;
|
||||
|
||||
/** A seat whose launcher writes `record(attempt)` to the file this launch names (or nothing, for undefined). */
|
||||
function seat(record?: (attempt: string, file: string) => unknown, preset = PRESET) {
|
||||
function seat(record?: (attempt: string, file: string) => unknown, preset = PRESET, runnerGrants?: RunnerGrantIssuer) {
|
||||
const presets = scratch("harness-presets-");
|
||||
writeFileSync(join(presets, "synth.yaml"), preset);
|
||||
const root = scratch("harness-results-");
|
||||
@@ -27,7 +28,7 @@ function seat(record?: (attempt: string, file: string) => unknown, preset = PRES
|
||||
return { ok: true as const };
|
||||
});
|
||||
const app = new CommunityHarnessRuntimeAdapter("synth", { tmux: { hasSession: vi.fn(async () => true), getPaneCommand: vi.fn(async () => "node"), sendShellCommand },
|
||||
presetsDir: () => presets, launchResultRoot: () => root, launchResultWait: { timeoutMs: 20, intervalMs: 10 } });
|
||||
runnerGrants, presetsDir: () => presets, launchResultRoot: () => root, launchResultWait: { timeoutMs: 20, intervalMs: 10 } });
|
||||
return { app, sendShellCommand, presets, root };
|
||||
}
|
||||
const good = (attempt: string) => ({ format: 1, attempt, harness: "synth", nativeRef: "chat-123", producer: "synth-launch 1.0" });
|
||||
@@ -158,3 +159,69 @@ describe("a community harness whose launcher can reopen a reported conversation"
|
||||
expect(loadCommunityHarnessPreset("synth", presets)).toMatchObject({ ok: false, message: expect.stringContaining("resume needs launch_result: true or names_conversation: true") });
|
||||
});
|
||||
});
|
||||
|
||||
describe("community run authority", () => {
|
||||
it.each([true, false])("binds the launch-result attempt and finalizes exact readiness=%s", async ready => {
|
||||
let runId: string | undefined;
|
||||
const dispatched = vi.fn(), finish = vi.fn(async (_ok: boolean) => {});
|
||||
const runnerGrants: RunnerGrantIssuer = async (_binding, runtime, attempt, runner) => {
|
||||
expect([runtime, runner]).toEqual(["synth", 1]); runId = attempt;
|
||||
return { grantId: "test-grant", generation: "generation", wrap: command => command, dispatched, finish };
|
||||
};
|
||||
const f = seat(ready ? (attempt) => { expect(attempt).toBe(runId); return good(attempt); } : undefined,
|
||||
`${PRESET}runner: true\n`, runnerGrants);
|
||||
expect((await f.app.launchHarness(binding, { name: "w@r" })).ok).toBe(ready);
|
||||
expect(runId).toBeTruthy();
|
||||
expect(dispatched).toHaveBeenCalledOnce();
|
||||
expect(finish).toHaveBeenCalledWith(ready);
|
||||
});
|
||||
|
||||
it("allocates and revokes an attempt when the launch-result folder is unavailable", async () => {
|
||||
const finish = vi.fn(async (_ok: boolean) => {});
|
||||
const issue = vi.fn(async (_binding, _runtime, _runId, _runner) => ({ grantId: "test-grant", generation: "generation", wrap: (command: string) => command, dispatched: () => {}, finish }));
|
||||
const f = seat(undefined, `${PRESET}runner: true\n`, issue);
|
||||
rmSync(f.root, { recursive: true });
|
||||
writeFileSync(f.root, "blocked");
|
||||
expect((await f.app.launchHarness(binding, { name: "w@r" })).ok).toBe(false);
|
||||
expect(issue.mock.calls[0]?.[2]).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("finalizes its exact community grant when post-dispatch standing refuses", async () => {
|
||||
const finish = vi.fn(async (_ok: boolean) => {});
|
||||
const f = seat(good, `${PRESET}runner: true\n`, async () => ({ grantId: "refused", generation: "generation", wrap: command => command,
|
||||
dispatched: () => { throw new Error("binding changed"); }, finish }));
|
||||
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
|
||||
expect(f.sendShellCommand).toHaveBeenCalledOnce();
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
|
||||
describe("community reporting grants are optional to native launch", () => {
|
||||
it("retries only its original command when the optional grant wrapper cannot be staged", async () => {
|
||||
const finish = vi.fn(async () => {}), dispatched = vi.fn();
|
||||
const issue: RunnerGrantIssuer = async () => ({ grantId: "own", generation: "g", wrap: command => `private-wrapper ${command}`, dispatched, finish });
|
||||
const f = seat(good, `${PRESET}runner: true\n`, issue);
|
||||
f.sendShellCommand.mockImplementationOnce(async () => ({ ok: false, code: "launch_path_too_long", message: "not dispatched" }) as never);
|
||||
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
|
||||
expect(f.sendShellCommand).toHaveBeenCalledTimes(2);
|
||||
expect(f.sendShellCommand.mock.calls[0]![1]).toBe(`private-wrapper ${f.sendShellCommand.mock.calls[1]![1]}`);
|
||||
expect(dispatched).not.toHaveBeenCalled();
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it.each(["setup", "activation"] as const)("preserves one dispatched native launch when grant %s fails", async failure => {
|
||||
const finish = vi.fn(async () => {});
|
||||
const issue: RunnerGrantIssuer = async () => {
|
||||
if (failure === "setup") throw new Error("credential environment unavailable");
|
||||
return { grantId: "affected", generation: "g", wrap: command => command,
|
||||
dispatched: () => { throw new Error("activation failed after send"); }, finish };
|
||||
};
|
||||
const f = seat(good, `${PRESET}runner: true\n`, issue);
|
||||
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
|
||||
expect(f.sendShellCommand).toHaveBeenCalledOnce();
|
||||
expect(f.sendShellCommand.mock.calls[0]![1]).not.toContain("OPENRIG_RUN_ID=");
|
||||
if (failure === "activation") expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {compactionRoutes} from '../src/routes/compaction.js';
|
||||
import {mkdtempSync, mkdirSync, writeFileSync, renameSync, rmSync, readFileSync, symlinkSync, lstatSync, statSync, utimesSync, chmodSync, existsSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join, dirname} from 'node:path';
|
||||
import {ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT} from '../src/domain/claude-compaction-enforcer.js';
|
||||
import {ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT, MANUAL_SEAT_READ_INTERVAL_MS} from '../src/domain/claude-compaction-enforcer.js';
|
||||
import {SessionTransport, CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS} from '../src/domain/session-transport.js';
|
||||
import {SeatDeliveryGuard} from '../src/domain/seat-delivery-guard.js';
|
||||
import {TmuxAdapter} from '../src/adapters/tmux.js';
|
||||
@@ -498,7 +498,7 @@ it('manual route preserves a non-timeout idle-wait refusal after map completion'
|
||||
it('preparation receipt: unresolved permission at deadline discloses the delivered prep and exact attempt',async()=>{
|
||||
const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('needs_input');});
|
||||
const res=await routeFixture(f)();const body=await res.json();
|
||||
expect(res.status).toBe(409);expect(body.reason).toBe('map_complete_seat_never_idle');
|
||||
expect(res.status).toBe(409);expect(body.reason).toBe('preparation_needs_input');expect(body.error).toContain('Answer the prompt in the seat');
|
||||
expect(body.preparation).toMatchObject({attemptId:f.e.getPreparationState(seat)!.attemptId,delivery:'delivered'});
|
||||
expect(body.error).toContain('Preparation was sent');expect(body.error).not.toContain('could not be sent');
|
||||
expect(body.error).toContain('disarmed');expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1);
|
||||
@@ -854,10 +854,74 @@ it('manual attention clears within the same attempt with one preparation and one
|
||||
});
|
||||
it('manual unresolved attention consumes the deadline and never revives',async()=>{
|
||||
const f=fixture();const start=f.clock();f.onSleep(async()=>{stageMap(f);f.activity('needs_input');});
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_needs_input'});
|
||||
expect(f.clock()).toBe(start+1000);expect(compacts(f)).toHaveLength(0);
|
||||
f.activity('idle');stageMap(f);await f.e.maybeAutoCompact(input);expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
// The manual wait reads the seat while the map is written too, and the attempt keeps only the latest reading: a prompt
|
||||
// is reported while it's there and named at expiry only when the latest reading still shows it.
|
||||
const readsBy=(ms:number)=>Math.floor(ms/MANUAL_SEAT_READ_INTERVAL_MS)+1;
|
||||
it('manual: a prompt before the map is reported in --state and names the expiry',async()=>{
|
||||
const f=fixture(20_000);const seen:any[]=[];
|
||||
f.onSleep(async()=>{f.activity('needs_input');seen.push(f.e.getPreparationState(seat)?.awaitingInput);});
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_needs_input'});
|
||||
expect(seen).toContainEqual(expect.objectContaining({reason:'target_needs_input',current:true}));
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
it('manual: a prompt before the map, answered, then the completed map compacts on the same attempt',async()=>{
|
||||
const f=fixture(20_000);let n=0;let attempt='';const seen:any[]=[];
|
||||
f.onSleep(async()=>{n++;attempt||=f.e.getPreparationState(seat)!.attemptId;seen.push(f.e.getPreparationState(seat)?.awaitingInput);
|
||||
if(n<30)f.activity('needs_input');else{f.activity('idle');stageMap(f);}});
|
||||
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(true);
|
||||
expect(seen).toContainEqual(expect.objectContaining({current:true}));
|
||||
expect(f.e.getPreparationState(seat)).toMatchObject({attemptId:attempt,status:'compact-sent'});
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
});
|
||||
it.each(['running','unknown'] as const)('manual: a prompt answered into %s is not reported as current at expiry',async(after)=>{
|
||||
const f=fixture(20_000);let n=0;vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
f.onSleep(async()=>{n++;stageMap(f);f.activity(n<8?'needs_input':after);});
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
|
||||
const state=f.e.getPreparationState(seat)!;
|
||||
if(after==='running')expect(state.awaitingInput).toBeUndefined();
|
||||
else expect(state.awaitingInput).toMatchObject({reason:'target_needs_input',current:false});
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
it('manual: prompt, then idle, then busy before the map is done: no stale prompt, the map expiry as before',async()=>{
|
||||
const f=fixture(20_000);let n=0;
|
||||
f.onSleep(async()=>{n++;f.activity(n<30?'needs_input':n<50?'idle':'running');});
|
||||
expect(readsBy(20_000)).toBeGreaterThanOrEqual(4);
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_incomplete'});
|
||||
expect(f.e.getPreparationState(seat)!.awaitingInput).toBeUndefined();
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
// The idle wait after the map can run to the deadline, so readings it completes must reach the attempt as they land: a
|
||||
// --state read (which also reconciles the deadline) during that wait describes the seat now.
|
||||
it.each(['running','unknown'] as const)('manual: a prompt answered into %s while the post-map wait runs; --state reads during the wait, and at the deadline, see no current prompt',async(after)=>{
|
||||
const f=fixture(20_000);vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
let n=0;let prompt:any;let attempt='';
|
||||
f.onSleep(async()=>{n++;if(n===1){attempt=f.e.getPreparationState(seat)!.attemptId;stageMap(f);f.activity('needs_input');}
|
||||
else if(n===2){prompt=f.e.getPreparationState(seat)?.awaitingInput;f.activity(after);}});
|
||||
// After the prompt the seat works on, or can't be read; on the third such reading of one wait, two --state reads land:
|
||||
// one as it is, one at the deadline, while that wait is still in flight.
|
||||
const read=(f.transport as any).classifySendReadiness;let later=0;let during:any;let atDeadline:any;
|
||||
(f.transport as any).classifySendReadiness=async(...args:any[])=>{
|
||||
if(n>=2&&++later===3){
|
||||
during=f.e.getPreparationState(seat)?.awaitingInput??null;
|
||||
f.advance(f.e.getPreparationState(seat)!.deadlineAt!-f.clock());
|
||||
atDeadline=f.e.getPreparationState(seat);
|
||||
}
|
||||
return read(...args);
|
||||
};
|
||||
const outcome=await f.e.triggerManualCompact(input,{operatorInitiated:true});
|
||||
expect(prompt).toMatchObject({reason:'target_needs_input',current:true});
|
||||
// A working seat clears the prompt; one that can't be read keeps it only as an earlier observation, never a current one.
|
||||
const earlier=after==='running'?undefined:expect.objectContaining({reason:'target_needs_input',current:false});
|
||||
expect(during??undefined).toEqual(earlier);
|
||||
expect(atDeadline).toMatchObject({attemptId:attempt,status:'stopped',reason:'map_complete_seat_never_idle'});
|
||||
expect(atDeadline.awaitingInput).toEqual(earlier);
|
||||
expect(outcome).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
it('manual default accepts a map after120s but before180s',async()=>{
|
||||
expect(MANUAL_PREP_WAIT_MS_DEFAULT).toBe(180_000);
|
||||
const f=fixture(null);const start=f.clock();f.onSleep(async()=>{if(f.clock()>=start+130_000)stageMap(f);});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TerminalPolicyStore } from "../src/domain/terminal-policy.js";
|
||||
//
|
||||
// Proves: the route resolves the target + reads the EXISTING context-usage
|
||||
// projection BEFORE calling the enforcer (known usage permits a manual request
|
||||
// independently of the threshold); non-Claude → 422 runtime_filter; unknown usage → 409 no_usage_data
|
||||
// independently of the threshold); non-Claude → 422 runtime_filter; unknown usage still prepares and compacts
|
||||
// (route passes null, never invents a value); ambiguous → 409; missing field →
|
||||
// 400; enforcer-unwired → 503. Reuses the shipped SessionTransport + the SAME
|
||||
// ClaudeCompactionEnforcer (no second path).
|
||||
@@ -277,7 +277,7 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
|
||||
expect(sentTexts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("unknown usage (no sample persisted) → 409 no_usage_data (never triggers blind)", async () => {
|
||||
it("unknown usage (no sample persisted): an explicit manual request doesn't depend on the threshold, so it prepares and compacts", async () => {
|
||||
seed();
|
||||
const { app, sentTexts } = buildApp();
|
||||
const res = await app.request("/api/compaction/trigger", {
|
||||
@@ -285,9 +285,11 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session: "dev-impl@my-rig" }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).reason).toBe("no_usage_data");
|
||||
expect(sentTexts).toHaveLength(0);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ ok: true, stage: "compact-sent" });
|
||||
expect(sentTexts[0]).toContain("OpenRig manual compaction was requested for this seat");
|
||||
expect(sentTexts[0]).not.toContain("null");
|
||||
expect(sentTexts.some((text) => text.startsWith("/compact"))).toBe(true);
|
||||
});
|
||||
|
||||
it("ambiguous session across rigs → 409", async () => {
|
||||
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user