mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 11:41:38 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c8a69638b | ||
|
|
5fd07e50a1 | ||
|
|
4efa28a036 | ||
|
|
ac4b70526a | ||
|
|
63a7375d2b | ||
|
|
2cea5cc34c | ||
|
|
78c10eac00 | ||
|
|
5f7e5f8ac4 | ||
|
|
70c6143a1a | ||
|
|
b5ce309f3d | ||
|
|
a63e940d6a | ||
|
|
13d639680c | ||
|
|
bbae974d0b | ||
|
|
60059f404a | ||
|
|
53d1db9e65 | ||
|
|
6da68650a1 | ||
|
|
752a87201b | ||
|
|
0b359b07c4 |
No files matched your search
+392
@@ -8,6 +8,398 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.11]
|
||||
|
||||
OpenRig 0.7.11 is a patch for people running their own agent teams, cut from
|
||||
main `b40bc71c`. Its biggest change: an ordinary send or work notice now goes
|
||||
into a person's typed draft by default instead of being refused (see Behaviour
|
||||
that changed). A person can now work with their team from their own coding
|
||||
agent through the new `openrig` plugin or from an ACP app such as Zed or T3
|
||||
Code, and, as an experiment that's off by default, answer the team's questions
|
||||
from an assistant connected through a grant service. A prepared managed compaction waits for an idle moment instead
|
||||
of asking again, and a restore stopped at a prompt clears itself. On Linux, a
|
||||
daemon run as a systemd user service no longer takes its seats down when its
|
||||
unit stops or restarts, and writable terminal tiles reflow text when the screen resizes. There
|
||||
are fixes across the queue, workflows, launch and speed under load, many
|
||||
contributed or reported by people using OpenRig. The OpenShell worker's
|
||||
maintained cleanup now works on OpenShell 0.1.6, but the worker still isn't end
|
||||
to end: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Back up your database first.** Five new migrations run at the daemon's
|
||||
first start: 150 (node launch wrapper), 151 (Pi account switches), 152
|
||||
(workflow instance spec), 153 (events retention marks) and 154 (workflow
|
||||
instance root objective). 0.7.10 had none. With the daemon stopped, copy the
|
||||
file `$OPENRIG_DB` names. Going back to 0.7.10 after these migrations isn't
|
||||
established.
|
||||
- **Restart the daemon after installing:**
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.11
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
- **Linux, with the daemon run as a systemd user service:** the seats move into
|
||||
their own scope only when their tmux server next starts fresh. Until then the
|
||||
server stays in the daemon's unit, so stopping or restarting that unit still
|
||||
takes the seats down, as before. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **The openrig-core plugin moves from 0.1.26 to 0.1.27.**
|
||||
Its activity relay and refocus hooks changed, as did the refocusing,
|
||||
openrig-user and openrig-skills skills. Replaced files are kept under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from there. ([#2756](https://github.com/mvschwarz/openrig/pull/2756), [#2743](https://github.com/mvschwarz/openrig/pull/2743), [#2717](https://github.com/mvschwarz/openrig/pull/2717), [#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2647](https://github.com/mvschwarz/openrig/pull/2647), [#2619](https://github.com/mvschwarz/openrig/pull/2619))
|
||||
- **Sends now join a person's draft** (`transport.draft_delivery` = `deliver`).
|
||||
To keep the old refusal, run `rig config set transport.draft_delivery refuse`. ([#2645](https://github.com/mvschwarz/openrig/pull/2645))
|
||||
- **A Pi seat that declares an absolute `PI_CODING_AGENT_DIR`** in its `env:`
|
||||
now uses it as its account home and needs a readable `auth.json` there, or
|
||||
the launch stops. Prepare one, or remove the declaration to keep the managed
|
||||
seat login. ([#2630](https://github.com/mvschwarz/openrig/pull/2630))
|
||||
|
||||
### Sends and drafts
|
||||
|
||||
- **A send now goes into a person's typed draft.** Under the new
|
||||
`transport.draft_delivery` setting's default, `deliver`, an ordinary
|
||||
`rig send` or work notice to a seat whose prompt holds a person's typed draft
|
||||
pastes the message at the cursor and submits the combined input as one
|
||||
prompt, and the send's result says whether submission was confirmed.
|
||||
`refuse` (also `OPENRIG_TRANSPORT_DRAFT_DELIVERY` in the daemon's
|
||||
environment) restores the old refusal where OpenRig detects the draft, from
|
||||
the next send with no restart. Menus, permission prompts, a seat's typing
|
||||
guard, managed compaction and `--wait-for-idle` still refuse or wait under
|
||||
either value. ([#2645](https://github.com/mvschwarz/openrig/pull/2645))
|
||||
- **A draft only the input box shows is caught:** an ordinary Claude Code or
|
||||
Codex send reads the box once before pasting (three tmux calls, within
|
||||
500 ms). Under `refuse` the send is refused before any input; under `deliver`
|
||||
the result says it joined the draft. ([#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **Claude seats showing a statusLine or the background task panel** read as a
|
||||
normal composer under the draft-aware typing guard, so an empty box gets the
|
||||
delivery instead of `draft_input_unknown`. ([#2703](https://github.com/mvschwarz/openrig/pull/2703))
|
||||
- **The bare `✔ Update installed · Restart to update` row** of Claude Code
|
||||
2.1.319 in a narrow pane is recognised, so an idle seat's empty prompt reads
|
||||
empty and wakes go through under the draft-aware guard. Checked against the
|
||||
row as reported, not a live pane. ([#2662](https://github.com/mvschwarz/openrig/pull/2662))
|
||||
- **A full held-message quota no longer drops a sender's later wakes:** the
|
||||
sender's newest held message retires its earlier ones, listed as `coalesced`
|
||||
in `rig seat held-messages <seat>` and readable with `--id`. ([#2701](https://github.com/mvschwarz/openrig/pull/2701))
|
||||
- **Seats stopped while they owe work** don't get a burst of undeliverable
|
||||
wakes at each daemon start; the start-up log says `waiting=N (not replayed: …)`. ([#2620](https://github.com/mvschwarz/openrig/pull/2620))
|
||||
|
||||
### Compaction, restore and handover
|
||||
|
||||
- **A prepared managed compaction waits for its window.** When the restore map
|
||||
is complete but `/compact` couldn't go in (a draft, a tmux mode, no idle
|
||||
moment), the attempt is held as `prepared` and sends `/compact` at the next
|
||||
idle moment instead of asking the seat to prepare again about every 11
|
||||
minutes. `rig compact --state` shows the hold and `--cancel` disarms it. A
|
||||
busy seat isn't asked again after an ended attempt, across a restart too. ([#2698](https://github.com/mvschwarz/openrig/pull/2698), [#2716](https://github.com/mvschwarz/openrig/pull/2716))
|
||||
- **The final `/compact` waits out the preparation's own fresh activity:** its
|
||||
guarded send now allows the 15-second hook freshness window plus the 8-second
|
||||
capture budget (23 seconds, against 9), so a seat that's already idle, such
|
||||
as one out of context, gets `/compact` instead of a refusal. A busy seat can
|
||||
wait up to about 14 seconds longer; the attempt's deadline still limits it. ([#2764](https://github.com/mvschwarz/openrig/pull/2764))
|
||||
- **A restore stopped at a prompt clears itself** (such as Claude's resume
|
||||
selection) once the seat runs the conversation it was restoring, using the
|
||||
check `rig seat clear-attention` runs. The docs cover the manual route. ([#2711](https://github.com/mvschwarz/openrig/pull/2711), [#2674](https://github.com/mvschwarz/openrig/pull/2674))
|
||||
- **A queue wake refused at a prompt no longer goes in right after "Compacted"**
|
||||
while the compaction still owes its turn boundary and restore request. It
|
||||
goes in at the seat's next idle moment after that step clears, as a recovered
|
||||
notification. ([#2666](https://github.com/mvschwarz/openrig/pull/2666))
|
||||
- **A Codex helper inside a Claude seat** (such as `codex exec` from its Bash
|
||||
tool) no longer becomes the seat's resume token, so a restart resumes the
|
||||
Claude conversation instead of failing with `no_conversation_found`. ([#2740](https://github.com/mvschwarz/openrig/pull/2740))
|
||||
- **A Claude rebuild handover checks its priming packet:** if it's still
|
||||
staged, the handover reports the submission unconfirmed and says to submit
|
||||
the existing packet once, never to resend or rerun. ([#2650](https://github.com/mvschwarz/openrig/pull/2650))
|
||||
- **Restoring a Claude seat's activity hooks** finds the agent's own source from
|
||||
its saved launch recipe, not a same-name agent in the rig's folder. ([#2617](https://github.com/mvschwarz/openrig/pull/2617))
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry
|
||||
refreshes that skill from its source when a Claude or Codex seat is restored,
|
||||
keeping its conversation; `keep_saved` stays the default. ([#2626](https://github.com/mvschwarz/openrig/pull/2626))
|
||||
|
||||
### Your own coding agent
|
||||
|
||||
- **The `openrig` plugin,** shipped beside openrig-core, lets a person work with
|
||||
their local team from their own Claude Code or Codex: hand it work, follow
|
||||
it, answer the team's questions about it, and read the result. Claude Code:
|
||||
`claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig"`,
|
||||
then `claude plugin install openrig@openrig-app`. Codex: the same
|
||||
`codex plugin marketplace add` path, `codex plugin add openrig@openrig-app`, then
|
||||
`codex mcp add openrig --env OPENRIG_SESSION_NAME=<address> -- rig mcp serve --front-door`.
|
||||
The plugin's README has the steps. ([#2647](https://github.com/mvschwarz/openrig/pull/2647))
|
||||
- **VS Code, Copilot CLI and Cursor** install it too, with no seat; the README
|
||||
and `rig_whoami` say to add your own `rig mcp serve --front-door` server with
|
||||
`OPENRIG_SESSION_NAME` set and turn the plugin's off. A seatless app can still
|
||||
create a task, send and post, as `unknown-mcp-<8 hex>`. ([#2683](https://github.com/mvschwarz/openrig/pull/2683), [#2709](https://github.com/mvschwarz/openrig/pull/2709), [#2718](https://github.com/mvschwarz/openrig/pull/2718), [#2657](https://github.com/mvschwarz/openrig/pull/2657))
|
||||
- **Answers are recorded exactly as the person typed them.** ([#2733](https://github.com/mvschwarz/openrig/pull/2733))
|
||||
- **Marketplace files at the repository root** let Claude Code's
|
||||
`/plugin marketplace add mvschwarz/openrig`, and directories that take a public
|
||||
Git repo, install it from GitHub (marketplace `openrig-app`). ([#2719](https://github.com/mvschwarz/openrig/pull/2719))
|
||||
|
||||
### ACP apps: `rig acp serve`
|
||||
|
||||
- **OpenRig as an agent in ACP apps:** `rig acp serve --to <member@rig>` runs
|
||||
on stdio for an app that launches a custom agent (Zed's `agent_servers`, T3
|
||||
Code). A prompt becomes a queue row, each row the work reaches shows as a
|
||||
step, and the closing resolution is the reply. Nothing runs until an app
|
||||
starts it, and it talks to the daemon `rig` uses. ([#2648](https://github.com/mvschwarz/openrig/pull/2648), [#2714](https://github.com/mvschwarz/openrig/pull/2714))
|
||||
- **The team's questions reach the app** while a turn is open, as a permission
|
||||
request or a one-choice form, and the choice is the person's answer. Sessions
|
||||
can be listed and reopened, and a question left unanswered there is named as
|
||||
still open in the TUI and Slack. ([#2651](https://github.com/mvschwarz/openrig/pull/2651), [#2708](https://github.com/mvschwarz/openrig/pull/2708), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2712](https://github.com/mvschwarz/openrig/pull/2712))
|
||||
- **The `@openrig/acp` launcher** (`openrig-acp`) runs the matching
|
||||
`@openrig/cli`'s `rig acp serve` for the ACP registry's `npx` launch, with a
|
||||
new `acp.to` setting for the receiver. `npm run pack:release` packs it beside
|
||||
the CLI at the same version. ([#2655](https://github.com/mvschwarz/openrig/pull/2655), [#2715](https://github.com/mvschwarz/openrig/pull/2715))
|
||||
|
||||
### People and connected assistants
|
||||
|
||||
- Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
|
||||
- **Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
|
||||
- **An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
|
||||
- **TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
|
||||
- **Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
|
||||
|
||||
- **Assistants connected through a grant service** (experimental, unsupported,
|
||||
off by default: you serve `rig mcp serve --http --grant-service <url>` behind
|
||||
your own TLS tunnel, and an outside service grants each connection):
|
||||
`rig_question_list` and `rig_question_answer` let a registered person answer
|
||||
the team's questions as their own; `--task-destination <member@rig>` names
|
||||
the receiver for `rig_queue_create`, and a bare seat name resolves. ([#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2638](https://github.com/mvschwarz/openrig/pull/2638), [#2750](https://github.com/mvschwarz/openrig/pull/2750))
|
||||
- **The `relay` gateway connector** sends questions, updates and notices to an
|
||||
operator-owned bridge over loopback HTTP and takes replies back. It's off
|
||||
unless you add a relay binding, and the bridge, with its reach to a messaging
|
||||
platform, is yours (`docs/reference/gateway-relay.md`). ([#2624](https://github.com/mvschwarz/openrig/pull/2624))
|
||||
- **The web UI's For You feed** answers a local ask's questions on its card. ([#2612](https://github.com/mvschwarz/openrig/pull/2612))
|
||||
|
||||
### Queue, workflows and status
|
||||
|
||||
- **One `unclaimed-backlog` notice** replaces a finding per row for a seat that
|
||||
reads without claiming and has no escalation route or delegation parent. ([#2752](https://github.com/mvschwarz/openrig/pull/2752))
|
||||
- **Claims read `working`, not stalled,** for up to 60 minutes while a Claude
|
||||
owner has a live background shell, and 20 while a managed restore is owed. ([#2675](https://github.com/mvschwarz/openrig/pull/2675))
|
||||
- **`rig queue show`, `list`, `transitions`, `update` and `block` take
|
||||
`--host <id>`,** as `create` and `handoff` did; `claim` doesn't. ([#2678](https://github.com/mvschwarz/openrig/pull/2678))
|
||||
- **Cross-host handoffs** that override a claim wake the claimer, and a close
|
||||
to an unverifiable successor names `rig queue handoff`. ([#2734](https://github.com/mvschwarz/openrig/pull/2734), [#2621](https://github.com/mvschwarz/openrig/pull/2621))
|
||||
- **Code results:** an `openrig.code-result/v1` manifest as `evidenceRef` gives
|
||||
`rig_artifact_read` and the new `rig queue artifact` the commit pair to diff. ([#2659](https://github.com/mvschwarz/openrig/pull/2659))
|
||||
- **Workflows:** a run keeps its own spec when another pod's copy reuses the id
|
||||
and version; the root objective is kept and printed as `objective:`;
|
||||
`rig workflow unregister` removes versions; `trace --full` returns the whole
|
||||
trail. ([#2696](https://github.com/mvschwarz/openrig/pull/2696), [#2738](https://github.com/mvschwarz/openrig/pull/2738), [#2676](https://github.com/mvschwarz/openrig/pull/2676), [#2667](https://github.com/mvschwarz/openrig/pull/2667))
|
||||
- **Activity:** a helper harness inside a seat no longer reports its turns as
|
||||
the seat's, and a resumed seat no longer reads a false model divergence. ([#2743](https://github.com/mvschwarz/openrig/pull/2743), [#2664](https://github.com/mvschwarz/openrig/pull/2664))
|
||||
- **Also:** refocus traces projects outside `workspace.root`; a timed-out
|
||||
`rig stream emit` names a retry that can't duplicate; `rig context
|
||||
reconstruction inspect` and `view` read an exact revision from local stores.
|
||||
([#2717](https://github.com/mvschwarz/openrig/pull/2717), [#2619](https://github.com/mvschwarz/openrig/pull/2619), [#2207](https://github.com/mvschwarz/openrig/pull/2207))
|
||||
|
||||
### Launch, install and setup
|
||||
|
||||
- **On Linux, a daemon run as a systemd user service starts the seats' tmux
|
||||
server in its own user scope** (`openrig-tmux-<id>.scope`, with
|
||||
`OOMPolicy=continue` from systemd 253), so stopping or restarting the
|
||||
daemon's unit no longer ends the seats, and their memory no longer counts
|
||||
against it. A daemon started from a login session, or a failed `systemd-run`,
|
||||
starts the server as before with one log line giving the reason; macOS and
|
||||
Windows are unchanged. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **A Claude seat whose launch passes `--settings` JSON written with spaces**
|
||||
(such as `{"env": {…}}` from a proxy alias) can be verified again: OpenRig
|
||||
reads the value whole, so it finds the seat's conversation. ([#2665](https://github.com/mvschwarz/openrig/pull/2665))
|
||||
- **A Claude launcher script that execs the native installer's symlink**
|
||||
becomes ready after a launch or rebuild, and `--no-chrome` is recognised. ([#2751](https://github.com/mvschwarz/openrig/pull/2751))
|
||||
- **`launch_wrapper`,** an argv prefix whose launcher prepares the environment
|
||||
and execs the agent, works for local Claude Code, Pi and Codex seats; a
|
||||
wrapped seat never starts unwrapped. ([#2746](https://github.com/mvschwarz/openrig/pull/2746), [#2609](https://github.com/mvschwarz/openrig/pull/2609))
|
||||
- **`rig provider switch --seat <pi seat> --to-account <name> --account-home <dir> --reason "<why>"`**
|
||||
moves a Pi seat to another account through a confirmed successor, keeping its
|
||||
model, effort and wrapper; `--dry-run` shows the plan. A Pi seat whose spec
|
||||
declares its account home starts, resumes and switches accounts again: on the
|
||||
first 0.7.11 candidate it didn't start, because the staged command began with
|
||||
shell setup that `exec` can't run. ([#2630](https://github.com/mvschwarz/openrig/pull/2630), [#2782](https://github.com/mvschwarz/openrig/pull/2782))
|
||||
- **Claude seats that load openrig-core themselves** stop loading its skills
|
||||
twice: the next launch removes OpenRig's unchanged copies. ([#2660](https://github.com/mvschwarz/openrig/pull/2660))
|
||||
- **Also:** a short permission-hook policy name; `rig attach --self` outside
|
||||
tmux adds the rig; `rig host pair --timeout` holds the request that long;
|
||||
`rig config set` names a masking variable; daemon start and stop treat a
|
||||
probe the OS blocked as unknown; a bare piped `rig` skips the compile cache.
|
||||
([#2640](https://github.com/mvschwarz/openrig/pull/2640), [#2641](https://github.com/mvschwarz/openrig/pull/2641), [#2692](https://github.com/mvschwarz/openrig/pull/2692), [#2547](https://github.com/mvschwarz/openrig/pull/2547), [#2502](https://github.com/mvschwarz/openrig/pull/2502), [#2643](https://github.com/mvschwarz/openrig/pull/2643))
|
||||
|
||||
### Terminals and views
|
||||
|
||||
- **Writable tiles reflow with the screen.** They attach as in 0.7.10: the screen
|
||||
in use sizes the seat, and text reflows on resize. They stay attached until
|
||||
detached or closed. Read-only tiles keep showing the seat without attaching,
|
||||
typing into it or resizing it. Fixed web geometry is opt-in through
|
||||
`terminal.fixed_geometry` (off by default).
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868), [#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2821](https://github.com/mvschwarz/openrig/pull/2821))
|
||||
- **Herdr follow views** work on macOS too (protocol 22), open a labelled
|
||||
snapshot where live following isn't supported, and show a cleaner strip. ([#2663](https://github.com/mvschwarz/openrig/pull/2663), [#2706](https://github.com/mvschwarz/openrig/pull/2706))
|
||||
- **Closing the herdr-core terminal that started OpenRig** no longer ends the
|
||||
daemon or its seats (per herdr-core's documented behaviour; a real close
|
||||
wasn't exercised). ([#2737](https://github.com/mvschwarz/openrig/pull/2737))
|
||||
- **Also:** terminal snapshots keep colours; a reattach during a broker
|
||||
teardown works; a home under a symlinked parent records its real path;
|
||||
`rig doctor` says why tmux control mode is off. ([#2654](https://github.com/mvschwarz/openrig/pull/2654), [#2671](https://github.com/mvschwarz/openrig/pull/2671), [#2633](https://github.com/mvschwarz/openrig/pull/2633), [#2707](https://github.com/mvschwarz/openrig/pull/2707))
|
||||
|
||||
### Speed under load
|
||||
|
||||
- **The stuck sweep** batches its reads and yields every 50 rows: on a copy of
|
||||
a busy 340 MB database, a pass went from 10.2–11.4 s to 5.1–5.5 s and the
|
||||
longest stall from about 10 s to 0.57 s. ([#2649](https://github.com/mvschwarz/openrig/pull/2649))
|
||||
- **`view.changed` events are no longer stored,** so the `events` table grows
|
||||
about a third as fast on the busy host measured, and opt-in
|
||||
`retention.events_days` prunes eight history-only event types. ([#2695](https://github.com/mvschwarz/openrig/pull/2695), [#2699](https://github.com/mvschwarz/openrig/pull/2699))
|
||||
- **The web UI** loads the newest 100 events instead of the whole log. ([#2704](https://github.com/mvschwarz/openrig/pull/2704))
|
||||
- **`daemon.seat_poll_interval_ms`** (1000 to 2000) trades activity freshness
|
||||
for daemon CPU on large fleets. ([#2688](https://github.com/mvschwarz/openrig/pull/2688))
|
||||
- **Also:** with `OPENRIG_TMUX_CONTROL=1`, short sends and `set-option` use the
|
||||
control client; the idle-shell check reads the plain process table; skill
|
||||
catalog Git reads time out after five seconds. ([#2636](https://github.com/mvschwarz/openrig/pull/2636), [#2690](https://github.com/mvschwarz/openrig/pull/2690), [#2713](https://github.com/mvschwarz/openrig/pull/2713))
|
||||
|
||||
### Docs and tests
|
||||
|
||||
Daemon HTTP clients and bearer tokens ([#2652](https://github.com/mvschwarz/openrig/pull/2652)), the 0.7.10 capability map on
|
||||
main ([#2736](https://github.com/mvschwarz/openrig/pull/2736)), a neutral TUI demo snapshot ([#2724](https://github.com/mvschwarz/openrig/pull/2724)), a web UI editor timer
|
||||
([#2689](https://github.com/mvschwarz/openrig/pull/2689)), and test and CI fixes ([#2753](https://github.com/mvschwarz/openrig/pull/2753), [#2681](https://github.com/mvschwarz/openrig/pull/2681), [#2685](https://github.com/mvschwarz/openrig/pull/2685), [#2673](https://github.com/mvschwarz/openrig/pull/2673), [#2628](https://github.com/mvschwarz/openrig/pull/2628)).
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Sends and work notices that were refused with `prompt_draft` now go
|
||||
through by default, joined to the person's draft** (above).
|
||||
`rig config set transport.draft_delivery refuse` restores the old refusal
|
||||
where OpenRig detects the draft, from the next send with no restart; under
|
||||
`refuse`, a draft only the input box shows now refuses too. ([#2645](https://github.com/mvschwarz/openrig/pull/2645), [#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **A Pi seat with an absolute `PI_CODING_AGENT_DIR`** uses it as its account
|
||||
home on launch, restart, resume and restore; no `auth.json`, no launch. ([#2630](https://github.com/mvschwarz/openrig/pull/2630))
|
||||
- **`/api/events` no longer emits `view.changed`,** live or replayed, so new
|
||||
ones leave the web UI's log, activity feed and For You and the historical
|
||||
telemetry export; the view streams still carry them. ([#2695](https://github.com/mvschwarz/openrig/pull/2695))
|
||||
- **The stuck sweep** files one backlog notice where it filed one per row,
|
||||
skips `stalled-after-claim` inside the two windows above, re-checks each
|
||||
rule just before filing, and skips overlapping ticks. ([#2752](https://github.com/mvschwarz/openrig/pull/2752), [#2675](https://github.com/mvschwarz/openrig/pull/2675), [#2649](https://github.com/mvschwarz/openrig/pull/2649))
|
||||
- **Managed compaction:** a prepared attempt stays `waiting`, and a manual
|
||||
`rig compact` over it reads `already_in_progress`. ([#2698](https://github.com/mvschwarz/openrig/pull/2698), [#2716](https://github.com/mvschwarz/openrig/pull/2716))
|
||||
- **The daemon and seats no longer inherit `HC_SCOPE`, `HC_TERMINAL_ID` or
|
||||
`HC_TERMINAL_SOCKET`;** one already running keeps them until restarted. ([#2737](https://github.com/mvschwarz/openrig/pull/2737))
|
||||
- **On Linux, the seats' tmux server runs in its own systemd user scope** when
|
||||
the daemon is a user service (above): the daemon unit's stop, restart and
|
||||
memory accounting no longer cover the seats. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **Read-only terminal tiles** show the seat without attaching and end with
|
||||
tmux's own error if they cannot read it. Writable tiles use the ordinary
|
||||
sizing attach; detaching ends that viewer, while the agent keeps running.
|
||||
([#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Also:** each sender keeps one live held message per seat; daemon start
|
||||
doesn't re-send a failed wake to a seat with no receiver; restores record
|
||||
`restore.outcome_reconciled` themselves; `rig terminal open --follow` returns
|
||||
`follow_unsupported` with a snapshot. ([#2701](https://github.com/mvschwarz/openrig/pull/2701), [#2620](https://github.com/mvschwarz/openrig/pull/2620), [#2711](https://github.com/mvschwarz/openrig/pull/2711), [#2663](https://github.com/mvschwarz/openrig/pull/2663))
|
||||
- **MCP and ACP:** an empty or placeholder seat value means no seat; the
|
||||
seatless `rig_whoami` text changed; `--front-door` is `front-door/v2`;
|
||||
grant-service callers' `destination` is optional; `rig acp serve` gives
|
||||
form-taking apps a form, leads step titles with the latest note, and asks
|
||||
nothing after a cancel. ([#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2709](https://github.com/mvschwarz/openrig/pull/2709), [#2718](https://github.com/mvschwarz/openrig/pull/2718), [#2733](https://github.com/mvschwarz/openrig/pull/2733), [#2638](https://github.com/mvschwarz/openrig/pull/2638), [#2750](https://github.com/mvschwarz/openrig/pull/2750), [#2708](https://github.com/mvschwarz/openrig/pull/2708), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2655](https://github.com/mvschwarz/openrig/pull/2655))
|
||||
- **CLI:** `rig attach --self` outside tmux records `<name>@<rig>`;
|
||||
`rig host pair` keeps a request for its `--timeout`; `rig config set` and
|
||||
`reset` name environment overrides; `rig daemon start` and `stop` refuse when
|
||||
a blocked probe leaves identity or liveness unknown; `--host` receipts print
|
||||
a runnable command. ([#2641](https://github.com/mvschwarz/openrig/pull/2641), [#2692](https://github.com/mvschwarz/openrig/pull/2692), [#2547](https://github.com/mvschwarz/openrig/pull/2547), [#2502](https://github.com/mvschwarz/openrig/pull/2502), [#2678](https://github.com/mvschwarz/openrig/pull/2678))
|
||||
- **Seat launches:** unchanged OpenRig copies of openrig-core's skills are
|
||||
removed, with warnings; the permission hook reads the installed policy lists
|
||||
on each call; model divergence reads pending until the new occupant's record.
|
||||
([#2660](https://github.com/mvschwarz/openrig/pull/2660), [#2640](https://github.com/mvschwarz/openrig/pull/2640), [#2664](https://github.com/mvschwarz/openrig/pull/2664))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **OpenShell:** on OpenShell 0.1.6 the maintained cleanup (`delete-owned`)
|
||||
now works for a custom image, and recreate and an exact resume passed in our
|
||||
native run. A restored guest still receives its startup text again, so strict
|
||||
recall after a restore isn't met, and the worker isn't end to end. The CA fix
|
||||
holds on 0.1.6. Never give the guest the daemon's main API port (normally
|
||||
7433). After a same-generation reconciled launch, run
|
||||
`rig environment connect` yourself. ([#2755](https://github.com/mvschwarz/openrig/pull/2755))
|
||||
- **Drafts:** detection isn't complete. Under `refuse`, an unreadable input box
|
||||
lets the send go ahead, so an undetected draft goes with the message; under
|
||||
`deliver`, a result may not mention a draft it joined. ([#2645](https://github.com/mvschwarz/openrig/pull/2645), [#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **A post-compaction step can still hold** when the box reads as typed, under
|
||||
draft-aware delivery or the typing guard, or after a lost `/compact`. Run
|
||||
`rig compact <seat> --cancel`, then send a restore request naming the newest
|
||||
restore map, marker and packet. A queue wake refused at a prompt now waits for
|
||||
that step, but recovery at daemon start and a new handoff's wake don't check
|
||||
for it yet. ([#2575](https://github.com/mvschwarz/openrig/issues/2575), [#2666](https://github.com/mvschwarz/openrig/pull/2666))
|
||||
- **Draft-aware delivery to Claude:** a message held as `draft_input_unknown`
|
||||
still isn't retried. Whether managed compaction now gets past the banner
|
||||
(`target_activity_unknown`) wasn't retested. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
|
||||
- **Claude provider switch:** the failure reported in [#2341](https://github.com/mvschwarz/openrig/issues/2341) ("The probe pane
|
||||
returned to a shell instead of staying inside the runtime") isn't shown fixed.
|
||||
- **`rig exec`:** checked with Claude Code and Codex through their ACP
|
||||
adapters. A cancel is acknowledged, not settled, and says nothing about
|
||||
further spend. Cancel with `rig exec cancel`, not `rig acp cancel`.
|
||||
- **ACP apps:** checked in T3 Code (Beta) 0.1.7 and with a scripted client; the
|
||||
unanswered-question notice hasn't run in a live app, nor the note-first step
|
||||
title in Zed. A cancel stops waiting; the work carries on. ([#2648](https://github.com/mvschwarz/openrig/pull/2648), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2712](https://github.com/mvschwarz/openrig/pull/2712))
|
||||
- **The `openrig` plugin** hasn't run in Claude Desktop, the Codex app or
|
||||
Copilot CLI. A seatless app can't claim, update, hand off or answer
|
||||
questions, and everyone using the app on a machine acts as its seat. ([#2647](https://github.com/mvschwarz/openrig/pull/2647), [#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2683](https://github.com/mvschwarz/openrig/pull/2683))
|
||||
- **Grant-service assistants** still aren't woken: an item stays queued until
|
||||
its receiver claims it. **Relay:** "Accepted by bridge" means the bridge has
|
||||
it, not the person, and a failed send replays only at a restart. ([#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2624](https://github.com/mvschwarz/openrig/pull/2624))
|
||||
- **Event retention** can't be undone, and the database file doesn't shrink. ([#2699](https://github.com/mvschwarz/openrig/pull/2699))
|
||||
- **`keep_saved` skills:** a skill whose source changes between `rig down` and
|
||||
the restore comes back missing, named in the restore's warnings; an edited seat
|
||||
copy is kept. ([#2626](https://github.com/mvschwarz/openrig/pull/2626))
|
||||
- **Read-only terminal tiles** each run one tmux command a second while
|
||||
showing. The earlier eight-tile showing measurement used about 6–8% of one
|
||||
core on an Apple Silicon Mac mini, against about 0.2% for attached 0.7.10
|
||||
tiles. This polling cost no longer applies to writable tiles. ([#2757](https://github.com/mvschwarz/openrig/pull/2757))
|
||||
- **Early writable-tile input:** as in 0.7.10, Enter or Ctrl-C typed while a
|
||||
new tile is still attaching can close that viewer. The agent keeps running;
|
||||
reopen the view. Once attached, tmux handles input and paste directly.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Kept writable tiles from 0.7.8, 0.7.9 or pre-reflow 0.7.11** keep their old
|
||||
client. Manually refresh them: close only their owned viewer and reopen with
|
||||
`rig terminal open`. For a `--follow` view, use `--no-follow` first, then open
|
||||
a fresh view. This does not touch the agent or its conversation; it is not an
|
||||
automatic migration. Existing 0.7.10 plain and read-only tiles stay as they are.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Claude `launch_wrapper`** hasn't run with zsh, on macOS arm64 or with a real
|
||||
installed Claude; an alias or shell function named `claude` fails closed. ([#2746](https://github.com/mvschwarz/openrig/pull/2746))
|
||||
- **Going back to 0.7.10:** besides the migrations, an older permission hook
|
||||
gives named-policy Claude sessions no decision until relaunched. ([#2640](https://github.com/mvschwarz/openrig/pull/2640))
|
||||
- **Codex hooks:** a session kept running across an upgrade can show "Hook
|
||||
failed — plugin contents changed; hook approval required" until relaunched,
|
||||
and 0.7.11 changes openrig-core again. `rig seat restart --keep-conversation`
|
||||
refuses a Codex seat launched with developer instructions (`settings_drift`).
|
||||
- **Codex hook setup:** with smol-toml 1.9.x and an existing `hooks.json`,
|
||||
OpenRig can leave its activity hooks and trust records unchanged. The range
|
||||
(`^1.8.0`) still permits 1.9.x; the repository lockfile uses 1.8.0. ([#2720](https://github.com/mvschwarz/openrig/issues/2720))
|
||||
- **Claude Code 2.1.319 starts in auto mode by default,** so a new Claude seat
|
||||
may act without asking; manual mode restores asking.
|
||||
- **Also:** permission requests in seat status show only the latest, in memory
|
||||
for five minutes, and suggested rules can contain sensitive values; Slack
|
||||
private channels need the documented, untested route, direct messages aren't
|
||||
supported and people reads haven't run live; a Codex snapshot restore writes
|
||||
OpenRig's guidance blocks into a tracked `AGENTS.md` (check `git status`); a
|
||||
scheduled reminder that fails before input isn't retried; SSH host status
|
||||
reads `unknown` behind ProxyJump or ProxyCommand; advisories don't show on a
|
||||
native resume; seats handed over before 0.7.9 refuse stop, restart and
|
||||
clean; after a project-local install, plain `rig` isn't found in the TUI's
|
||||
own shell; with Claude Code 2.1.319, `rig up` can wrongly say "Startup prompt
|
||||
still staged … press Enter"; two npm installs into one folder at once once
|
||||
left a partial install, cause unknown.
|
||||
- **Fixed or changed from 0.7.10's known issues:** the bare update banner
|
||||
([#2662](https://github.com/mvschwarz/openrig/pull/2662)) and the Claude launcher-script readiness timeout with `--no-chrome`
|
||||
([#2751](https://github.com/mvschwarz/openrig/pull/2751)). 0.7.10's OpenShell limit is superseded by the one above.
|
||||
- **The earlier limits below that 0.7.11 doesn't fix or change still apply.**
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @billbenetti, @seanmcn, @smota, @Aummadour, @dajiaohuang, @w4rc0n,
|
||||
@Mohsintl, @twotnguyen, @ziadeh and @Nikhi00718 for merged contributions.
|
||||
Thanks to @billbenetti for the reports behind [#2752](https://github.com/mvschwarz/openrig/pull/2752), [#2660](https://github.com/mvschwarz/openrig/pull/2660), [#2738](https://github.com/mvschwarz/openrig/pull/2738), [#2749](https://github.com/mvschwarz/openrig/pull/2749), [#2666](https://github.com/mvschwarz/openrig/pull/2666) and [#2665](https://github.com/mvschwarz/openrig/pull/2665), to
|
||||
@pr0xc3nt4ur1 for the report behind [#2740](https://github.com/mvschwarz/openrig/pull/2740) and [#2743](https://github.com/mvschwarz/openrig/pull/2743), to @oneandonlydean for
|
||||
the process chain behind [#2751](https://github.com/mvschwarz/openrig/pull/2751), and to @niklasbaldi for the report and cause
|
||||
behind [#2654](https://github.com/mvschwarz/openrig/pull/2654).
|
||||
Thanks also to @ziadeh for the reports behind [#2622](https://github.com/mvschwarz/openrig/issues/2622) and [#2486](https://github.com/mvschwarz/openrig/issues/2486), and to @mlc-factory-bot for the reports behind [#2605](https://github.com/mvschwarz/openrig/issues/2605) and [#2607](https://github.com/mvschwarz/openrig/issues/2607).
|
||||
|
||||
## [0.7.10]
|
||||
|
||||
Published 2026-11-08 as `@openrig/[email protected]`, built from the commit tagged
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
|
||||
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
|
||||
|
||||
### Install it yourself
|
||||
|
||||
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
This runs OpenRig's install script from the `v0.7.11` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
|
||||
|
||||
@@ -353,6 +353,8 @@ the sections that were not read are `null`.
|
||||
| `rig host edit <id>` | — | `--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--notes <text>`<br>`--json` |
|
||||
| `rig host remove <id>` | — | `--json` |
|
||||
| `rig host pair <url>` | — | `--id <id>`<br>`--timeout <seconds>`<br>`--human <address>`<br>`--json` |
|
||||
| `rig host approve <request>` | — | `--bearer <token>`<br>`--json` |
|
||||
| `rig host deny <request>` | — | `--bearer <token>`<br>`--json` |
|
||||
| `rig host list` | `ls` | `--json` |
|
||||
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
|
||||
|
||||
@@ -361,12 +363,38 @@ even when it is bound to a tailnet address and so starts without one: pairing ha
|
||||
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
|
||||
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
|
||||
registered person can approve (none, or several with no default; name one with
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:324-343`).
|
||||
|
||||
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
|
||||
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
|
||||
10 minutes.
|
||||
|
||||
On a 0.7.11 or later target, its person answers the request in the TUI (`rig tui`, then `attention`: open it), on
|
||||
Steering, or in Slack: with Approve or Deny, or in their own words. Typed words never deny it.
|
||||
- **When the target has a kernel operator (`operator-agent@kernel`),** it asks. The person's answer, a click or their
|
||||
words, comes back to it as a reply, and it decides with `rig host approve <code>` or `rig host deny <code>`. It asks
|
||||
the person back when the answer isn't clear. The person is told once that their answer was received. If the operator
|
||||
isn't running, the request and that receipt say so, and they name the terminal route. The request stays pending, never
|
||||
granted or denied, until the operator decides or the request expires. If the operator doesn't pick up the answer, the
|
||||
person is told at expiry that it didn't.
|
||||
- **When the target has no kernel operator** (or the queue can't send rows to it, or OpenRig couldn't read which; the
|
||||
request says which), Approve and Deny decide at once. A reply in words, or with none (a file), is kept on the
|
||||
request, and the person is asked again with a new request.
|
||||
- **Either way,** the outcome reaches the person as its own update: approved or denied, and by whom.
|
||||
|
||||
On either target, `rig host approve <request>` or `rig host deny <request>` (the pairing code, or the request's
|
||||
approval row id), run on the target from an agent seat or a plain terminal, decides it. It needs the target daemon's operator bearer
|
||||
token: an agent seat carries `OPENRIG_AUTH_BEARER_TOKEN`, and a person sets it or passes `--bearer <token>`. Without
|
||||
it the daemon answers 401, and nothing is decided. It decides once, and refuses an
|
||||
unknown (404 `pair_request_unknown`), expired (409 `pair_request_expired`) or already-decided (409
|
||||
`pair_request_decided`) request, including one its row already decided before the requester's next check (a click on
|
||||
a target with no kernel operator, or a refusing close). It records who decided: the seat, or `human@host` from a plain terminal. The person
|
||||
gets one update with the result. An agent seat's `rig queue update <id> --state done --closure-reason no-follow-on
|
||||
--resolution approve` on an unanswered request still approves, and any other close that carries no answer and isn't
|
||||
the person's own reply, or `--state denied`, refuses. To refuse a pairing, answer Deny on a target with no kernel operator, run
|
||||
`rig host deny`, or let it expire (nothing is persisted). At its deadline a request's ask closes as expired, even when the requester stopped waiting, so a late click there isn't
|
||||
accepted. The decisions live in the daemon's memory, as the requests do, so a daemon restart drops an open request.
|
||||
|
||||
Registration makes a host visible in topology; remote FEED attention remains opt-in.
|
||||
The human-readable success output of `host add` and `host pair` now includes the FEED
|
||||
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
|
||||
|
||||
@@ -459,8 +459,10 @@ skills:
|
||||
|
||||
An unknown `on_restore` value produces an advisory and uses `keep_saved`; it does not invalidate the spec.
|
||||
|
||||
A restore keeps the skills a seat was launched with. With `keep_saved`, it puts back the saved copy and never takes
|
||||
today's bytes from the source. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
|
||||
A restore keeps the skills a seat was launched with where it can. With `keep_saved`, it puts back a missing copy only
|
||||
while its recorded source still has the saved bytes, and never takes today's bytes from the source: if `rig down`
|
||||
removed an unchanged copy and the source has changed since, the skill stays missing and the restore names it in its
|
||||
warnings. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
|
||||
spec as it is now, using the saved launch recipe and rig folder, and:
|
||||
|
||||
- replaces the seat's copy with the source folder as it is now, and records it, so `rig down` removes it and the next
|
||||
|
||||
@@ -51,7 +51,7 @@ in the case named:
|
||||
The daemon reads two tokens from its environment at start:
|
||||
|
||||
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
|
||||
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
|
||||
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`, `pair-decision`), and the `/api/rig-mode`
|
||||
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
|
||||
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
|
||||
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
|
||||
@@ -61,7 +61,9 @@ The daemon reads two tokens from its environment at start:
|
||||
|
||||
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
|
||||
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
|
||||
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
|
||||
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it. `rig host
|
||||
approve|deny` and the TUI's answers to asks send `OPENRIG_AUTH_BEARER_TOKEN` from their environment (`rig host` also
|
||||
takes `--bearer`), and otherwise the terminal token; refused for want of it, they say what to set.
|
||||
|
||||
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
|
||||
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
|
||||
|
||||
@@ -201,11 +201,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh`, installs the
|
||||
latest published `@openrig/cli` with `npm install -g`,
|
||||
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
Codex and may install a missing one, as described below. A failed step prints
|
||||
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.11`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
|
||||
@@ -76,12 +76,48 @@ then show the new session with the original read-only setting. This uses the
|
||||
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
|
||||
In-place handover and restart keep showing the same session.
|
||||
|
||||
Detaching a tile you typed in returns it to showing the seat. A different seat
|
||||
under the old name is never followed. If no replacement returns in time, or a
|
||||
Writable tiles attach immediately, as in 0.7.10. Their visible terminal sizes
|
||||
the agent's PTY under tmux's normal client sizing policy, and resizing the
|
||||
terminal reflows new output. Read-only tiles remain observers: they neither
|
||||
resize nor type into another seat. An ordinary tmux detach ends the writable
|
||||
tile without stopping the agent. A replacement with a different managed seat
|
||||
identity is not followed. If no replacement returns in time, or a
|
||||
session has no managed identity, reopen the rig view with
|
||||
`rig terminal open rig:<name> --window` (or your explicit provider). Views
|
||||
opened before this change must be closed and reopened to get this behavior.
|
||||
|
||||
As in published 0.7.10, Enter or Ctrl-C typed while a new writable tile is still
|
||||
attaching can close that viewer. The agent keeps running; reopen the view.
|
||||
Once attached, keys and paste use native tmux input handling.
|
||||
|
||||
## Sizing and older views
|
||||
|
||||
Use tmux's inherited `window-size latest` policy for the terminal most recently
|
||||
used to control sizing. OpenRig does not change a person's global tmux policy.
|
||||
A deliberate `window-size manual` setting remains an explicit fixed-size choice.
|
||||
OpenRig's temporary layout and readable-capture pins release by unsetting the
|
||||
window's local `window-size` option; they do not restore an old local override.
|
||||
With the last pin released, the window inherits the global policy again.
|
||||
|
||||
Browser terminals do not pin the agent's PTY by default. For an explicit fixed
|
||||
90-by-27 browser terminal, use `rig config set terminal.fixed_geometry true`.
|
||||
Set it to `false` to return to the default. This applies to newly created browser
|
||||
brokers after existing browser subscribers disconnect; it does not configure
|
||||
native Herdr tiles. A temporary readable capture uses
|
||||
`rig capture <seat> --readable` or `--size <columns>x<rows>` and releases its pin
|
||||
when the capture finishes. It does not unset a manually fixed window it did not
|
||||
pin.
|
||||
|
||||
Kept writable 0.7.8/0.7.9 viewers can retain an old `ignore-size` attach, and
|
||||
pre-reflow 0.7.11 viewers can retain the old mirrored display. Changing saved
|
||||
argv cannot change that running client. Close only the owned Herdr viewer pane
|
||||
and open a fresh view with `rig terminal open <view> --provider herdr`; the
|
||||
agent's tmux session and conversation keep running. For a retained `--follow`
|
||||
canvas, first select `--no-follow`, then close its owned viewer panes and open
|
||||
a fresh ordinary view. Continuing that old following canvas is not an automatic
|
||||
live-client migration. Existing 0.7.10 sizing clients and read-only viewers do
|
||||
not need this step.
|
||||
|
||||
## Desktop windows and previews
|
||||
|
||||
```sh
|
||||
|
||||
+703
-178
@@ -1,94 +1,194 @@
|
||||
# What changed in OpenRig 0.7.1
|
||||
# What changed in OpenRig 0.7.11
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
|
||||
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
|
||||
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
|
||||
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
|
||||
after an upgrade it can still hold the previous version's note.
|
||||
This note is for an agent on an install that was just upgraded to 0.7.11.
|
||||
OpenRig 0.7.11 sends ordinary messages and work notices into a person's typed
|
||||
draft by default instead of refusing them, lets a person reach the team from
|
||||
their own coding agent (the `openrig` plugin) or an ACP app (`rig acp serve`),
|
||||
holds a prepared managed compaction until the seat is idle, and clears a
|
||||
restore that stopped at a prompt. On Linux, a daemon run as a systemd user
|
||||
service keeps its seats when its unit restarts. It adds five database
|
||||
migrations. On OpenShell 0.1.6 the worker's maintained cleanup now works, but
|
||||
the worker still isn't end to end. If you upgraded
|
||||
from 0.7.9 or earlier, also read "What changed in OpenRig 0.7.10" below, and
|
||||
from 0.7.8 or earlier, "What changed in OpenRig 0.7.9" too. For the full
|
||||
capability map, read `rig context get onboarding-width/public-what-you-can-do.md`.
|
||||
After the upgraded daemon starts, `rig context get reference/whats-new.md`
|
||||
describes this version. Without a running daemon, read the packaged
|
||||
`daemon/docs/reference/whats-new.md`; `$OPENRIG_HOME/reference/whats-new.md` can
|
||||
still be the previous copy until startup.
|
||||
|
||||
## After the upgrade
|
||||
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
|
||||
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
|
||||
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
|
||||
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
|
||||
`daemon.db_conflict` (`warn`). None is removed.
|
||||
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
|
||||
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
|
||||
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
|
||||
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
|
||||
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
|
||||
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
|
||||
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
|
||||
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
|
||||
anyone edited the file; if someone did, reapply the change from the copy.
|
||||
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
|
||||
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
|
||||
next launch, restore or handover.
|
||||
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
|
||||
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
|
||||
doesn't repair it; ask the person which mode they want and set it once.
|
||||
- **Back up the database, then restart the daemon.** Five migrations run on
|
||||
the daemon's first start: 150 (node launch wrapper), 151 (Pi account
|
||||
switches), 152 (workflow instance spec), 153 (events retention marks) and 154
|
||||
(workflow instance root objective). If `$OPENRIG_DB` isn't backed up yet,
|
||||
copy that file (`rig config get db.path` shows it) with the daemon stopped,
|
||||
then `rig daemon start`. Going back to 0.7.10 after these migrations isn't
|
||||
established.
|
||||
- **New settings:** `transport.draft_delivery` (`deliver`), `acp.to` (empty),
|
||||
`daemon.seat_poll_interval_ms` (1000) and `retention.events_days` (`0`, keeps
|
||||
every event). `rig config` shows each with its source.
|
||||
- **The openrig-core plugin moves from 0.1.26 to 0.1.27.**
|
||||
Its activity relay and refocus hooks changed, as did the refocusing,
|
||||
openrig-user and openrig-skills skills. Managed refresh preserves replaced
|
||||
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
|
||||
you still need from the preserved copy.
|
||||
- **Linux, with the daemon as a systemd user service:** the seats move into
|
||||
their own scope only when their tmux server next starts fresh; until then,
|
||||
stopping or restarting the daemon's unit still takes them down.
|
||||
- **Tell the person sends now join their drafts** (below). To keep the refusal,
|
||||
run `rig config set transport.draft_delivery refuse`; it applies from the
|
||||
next send, with no restart.
|
||||
- **Pi seats:** a Pi seat that declares an absolute `PI_CODING_AGENT_DIR` in its
|
||||
`env:` now uses it as its account home and needs a readable `auth.json`
|
||||
there, or the launch stops. Prepare one, or remove the declaration to keep
|
||||
the managed seat login. Ask the person before changing their spec.
|
||||
- **herdr-core terminals:** new starts no longer inherit `HC_SCOPE`,
|
||||
`HC_TERMINAL_ID` or `HC_TERMINAL_SOCKET`; a daemon or tmux server already
|
||||
running with them keeps them until it's restarted.
|
||||
- **Claude seats that load openrig-core themselves** lose OpenRig's unchanged
|
||||
copies of its skills at their next launch, so each skill loads once; the
|
||||
launch result warns with the copies removed or kept.
|
||||
|
||||
## New things you can do
|
||||
## Sends and drafts
|
||||
|
||||
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
|
||||
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
|
||||
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
|
||||
readable (`rig seat held-messages`) and is never replayed.
|
||||
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
|
||||
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
|
||||
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
|
||||
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
|
||||
interventions are guidance only.
|
||||
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
|
||||
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
|
||||
one line to shared guidance files.
|
||||
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
|
||||
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
|
||||
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
|
||||
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
|
||||
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
|
||||
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
|
||||
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
|
||||
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
|
||||
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
|
||||
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
|
||||
--seat <session>` gives each caller its own seat.
|
||||
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
|
||||
`docs/reference/a2a-agents.md` covers A2A agents.
|
||||
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
|
||||
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
|
||||
`docs/reference/community-harness-examples/`.
|
||||
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
|
||||
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
|
||||
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
|
||||
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
|
||||
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
|
||||
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
|
||||
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
|
||||
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
|
||||
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
|
||||
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
|
||||
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
|
||||
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
|
||||
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
|
||||
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
|
||||
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
|
||||
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
|
||||
strands an accepted create or join whose effect hadn't landed.
|
||||
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
|
||||
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
|
||||
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
|
||||
`rig stream show <id>` says what became of an observation.
|
||||
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
|
||||
the kernel starts seats only for those.
|
||||
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
|
||||
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
|
||||
deliberate start.
|
||||
- **Under `deliver` (the default),** an ordinary `rig send` or work notice to a
|
||||
seat whose prompt holds a person's typed draft pastes the message at the
|
||||
cursor and submits the combined input as one prompt. The result says whether
|
||||
submission was confirmed and, when OpenRig saw the draft, that it joined it.
|
||||
An unset, unreadable or invalid value follows `deliver`.
|
||||
- **Still refused or held under either value:** menus, permission prompts, a
|
||||
seat's typing guard (`draft-aware`, `hold`), managed compaction's sends and
|
||||
`--wait-for-idle`.
|
||||
- **Under `refuse`,** a send is refused, and a work notice retried once the
|
||||
prompt is clear, where OpenRig detects the draft. Claude Code and Codex sends
|
||||
now also read the input box before pasting, so a draft only the box shows
|
||||
refuses too (`target_needs_input` / `prompt_draft`).
|
||||
- **The draft-aware typing guard** reads a Claude composer with a statusLine or
|
||||
the background task panel, and Claude Code 2.1.319's bare
|
||||
`✔ Update installed · Restart to update` row in a narrow pane, so an empty box
|
||||
gets the wake instead of `draft_input_unknown`.
|
||||
- **Held messages coalesce:** a sender's newest held message at a seat retires
|
||||
its earlier ones. Read them with `rig seat held-messages <seat>` (each with
|
||||
`--id <id>`); an older backlog clears at that sender's next held message or
|
||||
with `rig seat retire-held-message`.
|
||||
- **Stopped seats aren't woken at each daemon start:** their rows stay owed, and
|
||||
the start-up log says `waiting=N (not replayed: …)`. That isn't delivery.
|
||||
|
||||
## Behaviour that changed
|
||||
## Compaction, restore and handover
|
||||
|
||||
- **A prepared managed compaction is held, not re-requested.** When the map is
|
||||
complete but `/compact` couldn't go in, the attempt stays `waiting`, marked
|
||||
`prepared`, and sends `/compact` at the next idle moment with an empty box.
|
||||
`rig compact <seat> --state` shows it; a manual `rig compact` reads
|
||||
`already_in_progress`; `--cancel` disarms it. It ends on a context dip, a
|
||||
conversation change, a stale map (`prepared_map_stale`: 15 points of growth or
|
||||
2 hours) or a map that stops reading ready.
|
||||
- **The final `/compact` waits out the preparation's own fresh activity** (up
|
||||
to about 23 s, against 9 s), so an idle seat that ran out of context compacts
|
||||
instead of being refused; a busy seat can wait up to about 14 s longer.
|
||||
- **A queue wake refused at a prompt** no longer goes in right after
|
||||
"Compacted" while the compaction still owes its turn boundary and restore
|
||||
request; it goes in at the next idle moment after that step clears.
|
||||
- **A full restore stopped at a prompt clears itself** once the seat runs the
|
||||
conversation it was restoring. If a seat still reads `attention_required`, run
|
||||
`rig seat clear-attention <seat>`, with `--reason` after a partial restore or
|
||||
a community harness's assisted startup. `proof_superseded` means the restore
|
||||
changed mid-check.
|
||||
- **A Codex helper inside a Claude seat** no longer takes the seat's resume
|
||||
token (`transcript_not_this_session`).
|
||||
- **A Claude rebuild handover may report its priming packet unconfirmed.**
|
||||
Submit the existing packet once, only if it's still staged. Never resend it
|
||||
blindly or rerun the handover.
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry
|
||||
refreshes that skill from its source on restore; `keep_saved` is the default.
|
||||
|
||||
## Working with the team from outside
|
||||
|
||||
- **The `openrig` plugin** lets a person use their own Claude Code or Codex
|
||||
with their local team: hand it work, follow it, answer the team's questions
|
||||
in their own words, and read the result. Install it from the package
|
||||
(`claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig"`,
|
||||
then `claude plugin install openrig@openrig-app`) or from this repository on
|
||||
GitHub. Everyone using the app on a machine acts as the seat it names.
|
||||
- **VS Code, Copilot CLI and Cursor** start its server with no seat: add your
|
||||
own `rig mcp serve --front-door` server with `OPENRIG_SESSION_NAME` set and
|
||||
turn the plugin's off. A seatless app can create tasks, send and post as
|
||||
`unknown-mcp-<8 hex>`, but can't claim, update, hand off or answer.
|
||||
- **ACP apps:** `rig acp serve --to <member@rig>` (or the `acp.to` setting)
|
||||
makes the team an agent in an app that launches a custom ACP agent, such as
|
||||
Zed or T3 Code. Nothing runs until an app starts it, and it uses the daemon
|
||||
`rig` uses. A question left unanswered in the app stays open in the TUI and
|
||||
Slack. The `@openrig/acp` launcher is packed beside the CLI at the same
|
||||
version. See `docs/reference/acp-agents.md`.
|
||||
- **Assistants through a grant service** are experimental, unsupported and off
|
||||
by default: they need your own internet-reachable `rig mcp serve --http
|
||||
--grant-service <url>` and an outside grant service. A registered person can
|
||||
answer the team's questions from one, and `--task-destination <member@rig>`
|
||||
names where its tasks go (`docs/reference/hosted-members.md`).
|
||||
- **The `relay` gateway connector** is off unless you add a relay binding. It
|
||||
hands questions and updates to a bridge the operator runs on loopback;
|
||||
"Accepted by bridge" isn't delivery (`docs/reference/gateway-relay.md`).
|
||||
|
||||
## OpenShell workers
|
||||
|
||||
On OpenShell 0.1.6 the maintained cleanup (`delete-owned`) now works for a
|
||||
custom image, and recreate and an exact resume passed in our native run. A
|
||||
restored guest still receives its startup text again, so strict recall after a
|
||||
restore isn't met, and the worker isn't end to end. The CA fix holds on 0.1.6.
|
||||
"Never grant the guest access to the daemon's main API
|
||||
port (normally 7433)."
|
||||
|
||||
## Other changes to act on
|
||||
|
||||
- Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
|
||||
- **Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
|
||||
- **An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
|
||||
- **TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
|
||||
- **Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
|
||||
|
||||
- **Rows on another host:** `rig queue show`, `list`, `transitions`, `update`
|
||||
and `block` take `--host <id>`; `claim` doesn't, since only the destination
|
||||
seat on its own host claims. `--host` never follows `rig host select`.
|
||||
- **A seat's unclaimed backlog:** a seat with no escalation route or delegation
|
||||
parent gets one `unclaimed-backlog` notice. To route its findings to a
|
||||
watching seat:
|
||||
`rig edge add <rig> <root seat's logical id> <watching seat> --kind escalates_to`.
|
||||
- **Claims read `working`** for up to 60 minutes while a Claude owner has a
|
||||
live background shell, and 20 while a managed restore is owed.
|
||||
- **A cross-host handoff that overrides your claim** wakes you with the
|
||||
successor's id: stop and coordinate with that seat.
|
||||
- **Code results:** set an `openrig.code-result/v1` manifest as `evidenceRef`;
|
||||
`rig queue artifact <qitemId>` and `rig_artifact_read` return the commit pair.
|
||||
- **Workflows:** runs keep their own spec; `rig workflow unregister
|
||||
<name>[@<version>]` (or `--prune-missing`) removes registered versions;
|
||||
`rig workflow trace <id> --full` returns the whole trail; `show`, `trace` and
|
||||
`status` print `objective:`.
|
||||
- **`rig stream emit` timeouts:** repeat with the same `--id`, or check with
|
||||
`rig stream list` before emitting again.
|
||||
- **Launch and setup:** `launch_wrapper` works for local Claude Code, Pi and
|
||||
Codex seats; `rig provider switch --seat <pi seat> --to-account <name>
|
||||
--account-home <dir> --reason "<why>"` switches a Pi seat's account (`--dry-run`
|
||||
first), and a Pi seat with a declared account home starts again; `rig config set` says when
|
||||
an environment variable masks the saved value.
|
||||
- **Linux seats:** when the daemon runs as a systemd user service, the seats'
|
||||
tmux server starts in its own user scope (`openrig-tmux-<id>.scope`), so the
|
||||
daemon's unit can stop or restart without ending them. Otherwise the server
|
||||
starts as before, with one log line giving the reason.
|
||||
- **Writable terminal tiles** attach as in 0.7.10: the screen in use sizes the
|
||||
seat and text reflows on resize. Read-only tiles keep showing without attaching
|
||||
or resizing the seat. Web fixed geometry is opt-in through
|
||||
`terminal.fixed_geometry` (off by default). As in 0.7.10, Enter or Ctrl-C while
|
||||
the first attach is in flight can close that viewer; the agent keeps running,
|
||||
so reopen the view. Kept writable tiles from 0.7.8, 0.7.9 or pre-reflow 0.7.11
|
||||
need a manual refresh: close only the owned viewer and reopen with
|
||||
`rig terminal open`; for `--follow`, use `--no-follow` first, then open a fresh
|
||||
view. The agent and conversation are preserved; no automatic migration runs.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868), [#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2821](https://github.com/mvschwarz/openrig/pull/2821))
|
||||
- **A Claude seat launched with `--settings` JSON written with spaces** can be
|
||||
verified again.
|
||||
- **`view.changed` is no longer stored,** and `/api/events` no longer sends it.
|
||||
- **Old history-only events can be pruned, if you choose to.** `retention.events_days` (default `0`, which keeps every
|
||||
event) turns on a pass in the retention sweep (at daemon start and daily) that deletes `view.changed`,
|
||||
`snapshot.created`, `watchdog.evaluation_fired` and the `queue.created`, `queue.updated`, `queue.claimed`,
|
||||
@@ -107,105 +207,530 @@ after an upgrade it can still hold the previous version's note.
|
||||
- **With pruning on, `rig telemetry events` reports the pruned ranges.** A page that spans pruned events inside the
|
||||
retained window has a `retained_sequence_gap` for each missing seq range and `coverage.status` `partial` (it used to be
|
||||
`retained_window`); events pruned from the start of the log show up as `before_retained_floor`.
|
||||
- **Large fleets:** `daemon.seat_poll_interval_ms` (up to 2000) lowers CPU at
|
||||
the cost of activity freshness; keep 1000 if tmux is slow.
|
||||
- **Terminals:** Herdr follow views work on macOS (protocol 22) and open a
|
||||
snapshot (`follow_unsupported`) where live following isn't supported.
|
||||
|
||||
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
|
||||
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
|
||||
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
|
||||
what wasn't delivered.
|
||||
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
|
||||
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
|
||||
at `hold`.
|
||||
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
|
||||
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
|
||||
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
|
||||
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
|
||||
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
|
||||
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
|
||||
shows an MCP enablement menu, a question or a permission prompt.
|
||||
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
|
||||
`--yes` command.
|
||||
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
|
||||
and `--no-kernel`.
|
||||
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
|
||||
listener, such as Tailscale's, can't be verified.
|
||||
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
|
||||
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
|
||||
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
|
||||
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
|
||||
writable roots.
|
||||
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
|
||||
guidance files byte for byte.
|
||||
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
|
||||
nor actor gets the shared `actor_required` error.
|
||||
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
|
||||
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
|
||||
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
|
||||
which clears nothing. Keys for a recognized native menu are sent as before.
|
||||
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
|
||||
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
|
||||
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
|
||||
runs, so it doesn't relaunch them beside themselves.
|
||||
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
|
||||
handover. A launch whose staging fails stops instead of falling back.
|
||||
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
|
||||
request once.
|
||||
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
|
||||
longer stops health diagnoses from listing.
|
||||
## Limits to keep in view
|
||||
|
||||
## Output that changed shape
|
||||
- **OpenShell:** a restored guest gets its startup text again, strict recall
|
||||
isn't met, and the worker isn't end to end (above).
|
||||
- **Drafts:** detection isn't complete. Under `refuse`, an unreadable input box
|
||||
lets the send go ahead; under `deliver`, a result may not mention a draft.
|
||||
- **A post-compaction step can still hold** when the box reads as typed, under
|
||||
draft-aware delivery or the typing guard, or after a lost `/compact`. Run
|
||||
`rig compact <seat> --cancel`, then send a restore request naming the newest
|
||||
restore map, marker and packet. A refused queue wake now waits for that step,
|
||||
but recovery at daemon start and a new handoff's wake don't check yet.
|
||||
- **`keep_saved` skills:** a skill whose source changes between `rig down` and
|
||||
the restore comes back missing, named in the restore's warnings; an edited seat
|
||||
copy is kept.
|
||||
- **Terminal tiles** each run one tmux command a second while showing (8 tiles
|
||||
used about 6–8% of one core on an Apple Silicon Mac mini).
|
||||
- **A message held as `draft_input_unknown`** isn't retried, and whether
|
||||
managed compaction gets past the update banner wasn't retested.
|
||||
- **Claude provider switch:** the "probe pane returned to a shell" failure in
|
||||
[#2341](https://github.com/mvschwarz/openrig/issues/2341) isn't shown fixed;
|
||||
the launcher-script rebuild timeout and `--no-chrome` are fixed.
|
||||
- **ACP apps** were checked in T3 Code (Beta) 0.1.7 and with a scripted client;
|
||||
the unanswered-question notice hasn't run in a live app. A cancel stops
|
||||
waiting; the team's work carries on.
|
||||
- **The `openrig` plugin** hasn't run in Claude Desktop, the Codex app or
|
||||
Copilot CLI. **Grant-service assistants** still aren't woken.
|
||||
- **Claude `launch_wrapper`** hasn't run with zsh, on macOS arm64 or with a real
|
||||
installed Claude; an alias or shell function named `claude` fails closed.
|
||||
- **Going back to 0.7.10** isn't established; an older permission hook also
|
||||
gives named-policy Claude sessions no decision until they're relaunched.
|
||||
- **The other 0.7.10 limits below still apply:** `rig exec` cancels,
|
||||
permission requests in seat status, Codex hooks (including
|
||||
`rig seat restart --keep-conversation` refusing a Codex seat launched with
|
||||
developer instructions), smol-toml 1.9.x, Slack, Claude Code 2.1.319's auto
|
||||
mode, Codex snapshot restores and `AGENTS.md`, unretried reminders, SSH host
|
||||
status, advisories on a native resume, seats handed over before 0.7.9, `rig`
|
||||
in the TUI's shell after a project-local install, the "Startup prompt still
|
||||
staged" message, and a partial install from two npm installs at once.
|
||||
0.7.10's bare-banner and launcher-script limits are fixed, and its OpenShell
|
||||
limit is replaced by the one above.
|
||||
|
||||
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
|
||||
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
|
||||
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
|
||||
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
|
||||
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
|
||||
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
|
||||
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
|
||||
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
|
||||
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
|
||||
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
|
||||
`--since` to resume, instead of an empty list.
|
||||
---
|
||||
|
||||
## What to stop doing
|
||||
## What changed in OpenRig 0.7.10
|
||||
|
||||
- **Don't read `lastNudgeInputBox`.** It's gone.
|
||||
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
|
||||
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
|
||||
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
|
||||
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
|
||||
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
|
||||
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
|
||||
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
|
||||
The changes from 0.7.9 to 0.7.10, published on 2026-11-08, for an install
|
||||
upgrading from 0.7.9 or earlier. 0.7.10 added `rig exec` for finite runs on
|
||||
Claude Code or Codex, sent a held post-compaction step after two idle polls that
|
||||
can't read the input box, left every tmux mode before a delivery, and showed
|
||||
agent spec advisories at launch. Its OpenShell line below is corrected.
|
||||
|
||||
## Known gaps in 0.7.1
|
||||
### After the upgrade
|
||||
|
||||
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
|
||||
its prompt, press Enter in its pane, or ask the person to.
|
||||
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
|
||||
acting on it.
|
||||
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
|
||||
marks any alerts waiting to post to Slack as already seen.
|
||||
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
|
||||
(`tmux -V`).
|
||||
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
|
||||
everything" wait. On a slow host, start the daemon yourself first.
|
||||
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
|
||||
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
|
||||
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
|
||||
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
|
||||
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
|
||||
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
|
||||
gateway folder or database instead: that can lose newer people, bindings and delivery records.
|
||||
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
|
||||
--provider herdr`.
|
||||
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
|
||||
first. Pair a decision room with quiet hours, or decide before asking.
|
||||
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
|
||||
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
|
||||
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
|
||||
falling back to credential-bearing command-line arguments.
|
||||
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
|
||||
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
|
||||
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
|
||||
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
|
||||
only claude.ai subscription logins and reports `not_subscription_login`.
|
||||
- **Restart the daemon** to load the new code: `rig daemon stop`, then
|
||||
`rig daemon start`. This release adds no migrations.
|
||||
- **The bundled openrig-core plugin goes from 0.1.25 to 0.1.26.** Its Claude
|
||||
hooks add a `PermissionRequest` hook that reports permission requests to seat
|
||||
status, and its refocus hook and the refocusing skill's trace script changed.
|
||||
Managed refresh preserves replaced files under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from the preserved copy.
|
||||
- **Check agent specs before relaunching seats:** run `rig agent validate <path>`
|
||||
and read any `agent spec advisory` line. A profile's `uses.<category>` written
|
||||
as a single value now selects that resource; if the resource pool doesn't hold
|
||||
it, a member using that profile fails to resolve with "not found in resource
|
||||
pool". Ask the person before you change their spec.
|
||||
- **Codex hooks:** a Codex seat kept running across the upgrade can show "Hook
|
||||
failed — plugin contents changed; hook approval required" until relaunched; in
|
||||
0.7.9's upgrade test, one seat restarted with OpenRig's managed restart showed
|
||||
no review afterwards.
|
||||
- **OpenShell:** rebuild a guest image with this release's `guest.py` before
|
||||
using it; the CA repair is in that file, which the image carries.
|
||||
- **Codex tool commands use a non-login shell:** managed Codex seats now pass
|
||||
Codex `-c allow_login_shell=false`, so plain `rig` works however OpenRig was
|
||||
installed, with a Codex that supports `allow_login_shell` (checked in Codex's
|
||||
source at 0.162.1 and 0.166); not yet run with a live Codex seat. Setup that lives only in login or rc files, and shell functions and
|
||||
aliases, may be missing from tool commands, and a model request for
|
||||
`login: true` gets Codex's refusal, "login shell is disabled by config". To
|
||||
defer to Codex's own setting, run `rig config set launch.codex_login_shell
|
||||
true`; a login file that sets `PATH` outright can then hide `rig`. It applies
|
||||
at each seat's next launch, and a restart in place keeps the running choice.
|
||||
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
|
||||
|
||||
### Finite runs: `rig exec`
|
||||
|
||||
`docs/reference/acp-agents.md` is the rule. Read it before handing a row to
|
||||
Claude Code or Codex.
|
||||
- **Install the adapters first:** `@agentclientprotocol/claude-agent-acp`
|
||||
0.101.0 and `@agentclientprotocol/codex-acp` 2.5.0, as that page shows.
|
||||
`rig exec harnesses` reports the adapter and native versions it finds.
|
||||
- **Run:** `rig exec run <row> --harness claude` (or `codex`). `--plan` shows
|
||||
what would run and starts nothing; `--wait` waits for the outcome. A connector
|
||||
owns the run, so leaving `--wait` or closing the terminal doesn't stop it.
|
||||
Running it again returns the row's run instead of sending the task twice;
|
||||
`--again` starts another turn once that run has ended.
|
||||
- **Read the outcome** with `rig exec status <runId|row>`. `interrupted` means
|
||||
the connector died without recording an outcome, so the outcome is unknown. A
|
||||
`completed` turn is the agent's report, not finished work: check the work
|
||||
before you close the row. `--wait` exits 0 for `completed`, 2 for any other
|
||||
outcome and 3 when you stop waiting.
|
||||
- **Cancel** with `rig exec cancel <runId>`, not `rig acp cancel`. An
|
||||
acknowledgement isn't a settled shutdown, and it says nothing about further
|
||||
spend.
|
||||
- **Usage and cost** the agent reported are on the row's outcome note, for
|
||||
`rig acp run` too, each with its source; what it didn't report reads unknown.
|
||||
|
||||
### Compaction and delivery
|
||||
|
||||
- **A held post-compaction step goes in.** After two idle polls in a row that
|
||||
can't read a seat's input box, the next poll sends the step (the turn
|
||||
boundary, the restore request or the audit). `rig compact <seat> --state` and
|
||||
`rig seat status` say it went in over a box OpenRig couldn't read; they never
|
||||
call that box empty. A draft OpenRig can't recognise in that box may receive
|
||||
the restore text.
|
||||
- **Still held:** a box that reads as typed, with or without a suggestion; a
|
||||
seat whose draft-aware delivery or typing guard holds OpenRig's input; and the
|
||||
step after a `/compact` whose result was lost. To take over a held restore by
|
||||
hand, run `rig compact <seat> --cancel`, then send the seat a restore request
|
||||
naming its newest restore map, its restore marker and its packet. A step
|
||||
already being pasted can still land.
|
||||
- **tmux modes:** a send or wake now leaves every tmux mode in a seat's pane,
|
||||
including a chooser, customize or clock mode a person has open. If a send is
|
||||
still refused for a tmux mode, the refusal names why leaving failed.
|
||||
- **A scrolled pane with a stale record:** leaving the mode now goes through the
|
||||
seat's address, so a stale binding naming the same reused tmux pane, from
|
||||
another rig for example, no longer makes the send fail with nothing sent.
|
||||
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
|
||||
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
|
||||
as the same attachment when only OpenRig's tile script changed, and moves to
|
||||
the current command, so the viewer stays confirmed and the follow view goes on
|
||||
adding and closing tiles. A kept viewer that's already running keeps its
|
||||
earlier sizing for as long as it runs, its own re-attaches included; writable
|
||||
viewers opened after the upgrade size their pane. A different session, tmux,
|
||||
server or read-only setting still holds the view, as before. Checked against
|
||||
the scripts those releases used, not yet with a live Herdr. It follows
|
||||
@natefaerber's approach in
|
||||
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
0.7.10 fixes, in the source, the two problems that stopped the OpenShell worker
|
||||
in 0.7.9, but the worker hasn't run end to end. Don't rely on this path yet. The
|
||||
guest launcher now passes the CA settings the OpenShell exec supplies
|
||||
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
|
||||
`CURL_CA_BUNDLE`) to its Claude, and deleting an owned environment reads
|
||||
Podman's inventory through routes Podman serves, stopping as
|
||||
`environment_cleanup_unconfirmed` on any other answer. A verified export, a
|
||||
finished task and an exact restore haven't been shown. If you set one up anyway,
|
||||
follow `daemon/assets/openshell/README.md` in the installed package, rebuild the
|
||||
guest image first, and name the costs to the person ("Provider registration and
|
||||
image construction can incur costs"). "Never grant the guest access to the
|
||||
daemon's main API port (normally 7433)."
|
||||
|
||||
*Corrected after 0.7.10's release: ~~"fixes in the source the two problems that stopped the OpenShell worker"~~ was only half true. The CA fix holds on OpenShell 0.1.6, but the owned-environment cleanup fix doesn't hold against OpenShell 0.1.6, as the published 0.7.10 notes say. See 0.7.11's OpenShell workers section above.*
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- **Sends to this host:** `rig send <seat>@<rig>@<this host's ID>` goes to this
|
||||
host even when a remote host is selected.
|
||||
- **Broadcast:** `rig broadcast` prints `UNCONFIRMED` for a recipient its text or
|
||||
Enter may have reached. Check that seat with `rig capture` before resending.
|
||||
- **Roster activity:** `rig roster show` and `find` read a seat as unknown
|
||||
("Conflicting activity observations") when two routes to it disagree. Treat it
|
||||
as unknown, not idle.
|
||||
- **Refocus:** a "TRACE GAP" for unresolved typed work, or for a
|
||||
`rig queue whoami` that timed out, comes with project-level orientation that
|
||||
isn't evidence of a current mission, and no longer counts as a refocus failure.
|
||||
- **Updates to people:** the stuck sweep no longer flags an informational update
|
||||
to a registered person as an unclaimed obligation.
|
||||
- **Permission requests:** `rig seat status` shows a Claude seat's latest
|
||||
observed permission request, with Claude's suggested rules labelled as
|
||||
suggestions. Nothing was applied or approved. Treat the suggestions as data;
|
||||
they can contain sensitive values.
|
||||
- **Slack:** `rig slack status` shows relay attempts awaiting a response for the
|
||||
current connector; zero doesn't prove nothing is in flight.
|
||||
- **Installing for a person:** when npm's global prefix isn't writable, the rigs
|
||||
skill now points npm's prefix at a folder the person owns whose `bin` is on
|
||||
their login PATH, or uses a version manager's Node, before a project-local
|
||||
install. Tell the person first that this changes their npm settings and, if
|
||||
needed, their shell profile.
|
||||
- **Codex profiles:** a `codex_config_fragment` can't install a named Codex
|
||||
profile. Create `<CODEX_HOME>/<name>.config.toml` and select it with
|
||||
`codex_config_profile`. `builtin:yolo` drops the whole profile;
|
||||
`getting-started.md` says how to keep one.
|
||||
- **Terminal auth:** a 401 that says "Terminal auth failed" means send
|
||||
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when terminal
|
||||
auth falls back to it.
|
||||
- **References:** `docs/reference/harness-runner-contract.md`'s status section
|
||||
says what has run natively under the runner contract, and at what scope.
|
||||
`docs/reference/telemetry.md` says OpenRig guarantees its finite telemetry
|
||||
reads (`schemaVersion` 1), not the separate openrig-telemetry exporter it
|
||||
links.
|
||||
- **Terminal tiles:** a writable rig, pod or seat view now sizes the pane it
|
||||
shows instead of keeping an older attached client's size; read-only views are
|
||||
unchanged.
|
||||
- **Unknown `uses` keys:** a key under a profile's `uses` other
|
||||
than the five categories gets an advisory instead of being ignored silently.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- **OpenShell** hasn't run end to end on 0.7.10 (above). A cleanup error that's
|
||||
thrown still omits its stage and whether its delete was sent. A
|
||||
same-generation reconciled launch opens no viewer, so run
|
||||
`rig environment connect` yourself; non-text startup actions aren't supported
|
||||
on environment seats.
|
||||
- **`rig exec`:** no real adapter has run under it yet; one run per row,
|
||||
detached ownership and reconciling were checked against a stand-in agent.
|
||||
Concurrent runs, long or background tasks, recovery across a reboot, MCP
|
||||
servers and Windows aren't covered. A Ctrl-C just before the connector is
|
||||
launched isn't caught by `--wait`.
|
||||
- **Draft-aware delivery to Claude:** a narrow Claude pane showing
|
||||
`✔ Update installed · Restart to update` on its own line can still hold
|
||||
deliveries at an empty prompt, queue wakes included; 0.7.10 doesn't recognize
|
||||
that bare banner. A held message isn't replayed automatically by that path.
|
||||
- **Seats handed over before 0.7.9** keep their adopted origin until a later
|
||||
launched handover or managed launch records a new session. Until then
|
||||
`rig seat stop`, `clean` and in-place restart refuse them, and now say why.
|
||||
Don't use `rig unclaim` to get around it: that ends OpenRig's management of
|
||||
the seat.
|
||||
- **Permission requests in seat status** show only the latest one, held in
|
||||
memory for five minutes and lost on a daemon restart.
|
||||
- **Agent spec advisories** don't show for a native resume that continues a
|
||||
saved conversation.
|
||||
- **The other 0.7.9 limits below still apply:** open updates for a person, Slack,
|
||||
SSH host status, shared asks, permit records, existing seats, Codex hooks, and
|
||||
grant-service assistants not being woken. 0.7.9's limits on a restore request
|
||||
held after compaction, `rig broadcast`'s `FAILED`, what has run under the
|
||||
runner contract, and Codex seats' `rig: command not found` are fixed or
|
||||
changed. The 0.7.8 limits below that 0.7.9
|
||||
didn't fix or change, and the
|
||||
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
|
||||
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
|
||||
apply.
|
||||
|
||||
---
|
||||
|
||||
## What changed in OpenRig 0.7.9
|
||||
|
||||
The changes from 0.7.8 to 0.7.9, published on 2026-11-06, for an install
|
||||
upgrading from 0.7.8 or earlier.
|
||||
|
||||
### After the upgrade
|
||||
|
||||
- **Restart the daemon** to load the new code: `rig daemon stop`, then
|
||||
`rig daemon start`.
|
||||
- **Migrations 146 to 149** record a Pi seat's guidance file, what OpenRig last
|
||||
wrote in each managed guidance block, an index for reading a rig's outbox,
|
||||
and the stream intake position.
|
||||
- **Tell the person that stream intake is on.** Each `rig stream emit` now opens
|
||||
an intake row for the kernel queue worker (`queue.worker`), or adds a note to
|
||||
the one still unclaimed, and the row wakes the queue worker like any queue row.
|
||||
Earlier stream history isn't replayed. If they want 0.7.8's behaviour, with no
|
||||
intake rows, run `rig config set stream.intake off`.
|
||||
- **The bundled openrig-core plugin goes from 0.1.24 to 0.1.25.** Its
|
||||
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
|
||||
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
|
||||
you still need from the preserved copy.
|
||||
- **Where seat guidance goes now:**
|
||||
- a fresh Pi seat gets OpenRig's guidance in its own agent folder's
|
||||
`AGENTS.md`, unless that file or folder is a link;
|
||||
- a fresh Codex seat on Codex 0.164.0 or later gets it as launch developer
|
||||
instructions, not in the repository `AGENTS.md`, when no config layer
|
||||
OpenRig reads sets `developer_instructions`;
|
||||
- a Codex resume, fork, restore or handover successor, an older or unknown
|
||||
Codex, and a config layer that sets `developer_instructions` or can't be read
|
||||
still use the repository `AGENTS.md`;
|
||||
- if only the person's shell startup sets `CODEX_HOME`, OpenRig reads
|
||||
`~/.codex`, so a `developer_instructions` set in their home is replaced for
|
||||
that session. Declare the same absolute path as `CODEX_HOME` in the member's
|
||||
or rig's `env:` to have it checked. `docs/reference/agent-startup-guide.md`
|
||||
("Codex guidance at launch") is the rule;
|
||||
- a managed block a person edited by hand is kept and named
|
||||
(`guidance_block_edited`), not replaced. Read the warning before you change
|
||||
the block.
|
||||
- **Slack:** on the first start after upgrading, the connector edits the asks
|
||||
that ended earlier to show they were handled, at Slack's edit rate: about an
|
||||
hour for 450 asks. `rig slack status` shows pending edits.
|
||||
- **OpenShell:** rebuild a guest image built for 0.7.8 with this release's
|
||||
helper; an older helper rejects the new startup step, and the error names the
|
||||
rebuild.
|
||||
|
||||
### Runner seats
|
||||
|
||||
Section 7 of `docs/reference/harness-runner-contract.md` is the rule for
|
||||
permissions, and section 4 for activity. Read them before writing or changing a
|
||||
runner.
|
||||
- **Typed words:** a reply of just yes or no counts as the agent's allow or
|
||||
reject. Any other words choose nothing: OpenRig's default permits the request
|
||||
when the agent offered an allow option, and the read result carries the words
|
||||
whole as `said`. A runner may queue them for the agent as an ordinary prompt
|
||||
after the current turn, never as the answer; one that drops them still keeps
|
||||
the contract. The ask now tells the person this.
|
||||
- **Permit records:** each permit OpenRig decides by default (no one to ask, or
|
||||
an ask closed without a choice, including a row passed to an agent seat) is
|
||||
recorded once, as a stream item from the seat, naming the tool call, why no
|
||||
person answered and the option given. Read them with
|
||||
`rig stream list --tag runner-permission-permit` or `--source <seat>`. A record
|
||||
has no destination and never changes the permit. Stream intake reads past it, so
|
||||
it opens no intake row and wakes no one.
|
||||
- **The working beat:** while a prompt is outstanding and the agent is alive, a
|
||||
runner may post `active` / `liveness` so a long call keeps reading working.
|
||||
After delivering an answer or permit, it posts `active` /
|
||||
`permission_resolved`. A runner that sends no beats behaves as before. The Pi
|
||||
runner and the dsh example post it.
|
||||
- **Check a runner** with
|
||||
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`.
|
||||
It now covers the beat, and no longer falsely fails a correct runner on a busy
|
||||
machine.
|
||||
- **The dsh example** reads `DSH_SEAT_BIN` and `DSH_SEAT_HOME` from the member's
|
||||
`env:`, and section 8 names ACP's `session/resume`.
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
|
||||
but the worker still doesn't work in 0.7.9. Don't rely on this path in 0.7.9.
|
||||
Startup reaches the guest through a hash-checked first-input file, and nothing
|
||||
is typed through the host viewer. Readiness checks the guest's own execution,
|
||||
and a failed owned delete reports the stage that failed. Release testing on
|
||||
0.7.9 then found two more problems: the guest's Claude couldn't reach its model
|
||||
(`SELF_SIGNED_CERT_IN_CHAIN`), because the guest launcher left out the CA
|
||||
settings the OpenShell exec supplies, and deleting an owned environment never
|
||||
sent the delete, because its inventory step misread a "Not Found" reply. 0.7.10's
|
||||
fixes for both are described above. "Never grant the guest access to the
|
||||
daemon's main API port (normally 7433)."
|
||||
|
||||
### Assistants connected through a grant service (experimental)
|
||||
|
||||
Experimental, unsupported, off by default, and at the person's own risk. With
|
||||
`rig mcp serve --http --grant-service <url>` and a grant service the person
|
||||
supplies and runs, a connected assistant can claim, update and park the rows
|
||||
addressed to its own seat, and see its team with `rig_team`, within the scope its
|
||||
existing tools have. Nothing wakes it: an item stays queued until its receiver
|
||||
claims it. Name the outside service, and the exposure of serving MCP over HTTP,
|
||||
to the person before you set this up. `docs/reference/hosted-members.md` is the
|
||||
rule.
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- **Held messages:** a seat that starts, or takes over a seat, with held
|
||||
messages is told how many there are. Read them with
|
||||
`rig seat held-messages <seat>`.
|
||||
- **Unconfirmed sends:** `rig send --verify` now reports unconfirmed and exits 1
|
||||
when it can't see what its Enter did, and `rig walk` reports unconfirmed
|
||||
instead of lost. Inspect the recipient before any resend.
|
||||
- **Deadlines:** to accept a re-estimate without unclaiming the work, run
|
||||
`rig queue update <id> --closure-required-at <ISO>`.
|
||||
- **Shared workspaces:** any registered person can settle another's ask as
|
||||
themselves, and the first committed answer wins; a later answer gets 409
|
||||
`qitem_already_terminal` and records nothing. A person registered with no
|
||||
connector binding is asked on Steering and in the Feed only.
|
||||
- **Handover:** a fresh or rebuild successor's first turn lists its startup
|
||||
files, role first. A successor that started without a required startup file is
|
||||
marked in the seat list.
|
||||
- **Ambiguous rig names:** when an active rig name matches more than one rig,
|
||||
`rig env down`, `status` and `logs` list the matching rig IDs and stop, and
|
||||
`rig doctor --spec` reports conformance as unknown. Use the rig ID.
|
||||
- **Seat activity:** window samples older than their bound no longer decide a
|
||||
seat's activity, so a seat whose pane can't be read reads unknown rather than
|
||||
its last verdict. Treat unknown as unknown, not idle.
|
||||
- **Launch options:** `specs.builtins_visible: false` hides built-in specs
|
||||
(`rig specs ls --include-builtins` still lists them); `plugin_channels` gives
|
||||
one Claude member its own `--channels`, untested with a real channel plugin.
|
||||
- **Files:** `rig down` removes OpenRig's own additions from a working folder
|
||||
once no live seat uses them. `rig export` with no `-o` writes
|
||||
`rig-export.yaml` beside an existing `rig.yaml`. `rig workspace validate` exits
|
||||
1 for malformed optional frontmatter.
|
||||
- **Kernels:** a new default kernel starts in `<workspace.root>/kernel`.
|
||||
Existing kernels stay where they are. `docs/reference/agent-startup-guide.md`
|
||||
explains the optional move; a fresh launch there starts a new native
|
||||
conversation, so ask the person before moving.
|
||||
- **PATH:** if preflight warns that a Codex seat's login shell can't find `rig`,
|
||||
follow its fix. `getting-started.md` explains the `~/.profile` line and how to
|
||||
check it with `bash -lc 'command -v node rig'`.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- **OpenShell:** the worker doesn't work in 0.7.9 (above). A same-generation
|
||||
reconciled launch opens no viewer, so run `rig environment connect` yourself;
|
||||
non-text startup actions aren't supported on environment seats.
|
||||
- **Open updates for a person** read a 1,001-row window, so a page can come back
|
||||
short, marked `truncated`.
|
||||
- **Slack:** a private channel needs the documented route, which isn't tested,
|
||||
and direct messages aren't supported. The channel and people reads haven't
|
||||
been run against a live Slack workspace. Redaction of the bot token from a
|
||||
transport error can still leave a malformed token fragment shorter than six
|
||||
characters, so review an error before sharing it.
|
||||
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
|
||||
isn't proof the connection is broken. `rig host ls` reads the ssh config with
|
||||
`ssh -G`, which runs any `Match exec` command in it.
|
||||
- **Shared asks:** a click's option written before its answer row can stand
|
||||
beside or inside the closer's answer without the first clicker's authorship.
|
||||
- **Permit records** last as long as stream retention keeps them. A permit a
|
||||
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
|
||||
an ask opened before 0.7.9, a record may omit the option's kind.
|
||||
- **Existing seats** pick up Codex launch instructions and the single
|
||||
openrig-core skill load at their next fresh launch. A `CLAUDE.md` or
|
||||
`AGENTS.md` that already grew a line per launch isn't trimmed.
|
||||
- **Codex hooks after an upgrade:** Codex sessions kept running across the
|
||||
upgrade show "Hook failed — plugin contents changed; hook approval required"
|
||||
until relaunched.
|
||||
- **0.7.8's limits** on the dsh `env:` names, conformance false failures, typed
|
||||
words, the README's operator address, a symlinked rebuild, the rebuild receipt,
|
||||
a re-parked click and unrecorded default permits are fixed or changed. The
|
||||
other 0.7.8 limits below, and the
|
||||
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
|
||||
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
|
||||
apply.
|
||||
|
||||
---
|
||||
|
||||
## What changed in OpenRig 0.7.8
|
||||
|
||||
The changes from 0.7.7 to 0.7.8, published on 2026-11-05, for an install
|
||||
upgrading from 0.7.7 or earlier.
|
||||
|
||||
### After the upgrade
|
||||
|
||||
- **Restart the daemon** to load the new code: `rig daemon stop`, then
|
||||
`rig daemon start`.
|
||||
- **Migrations 142 to 145** store a Claude seat's MCP opt-out, seat activity
|
||||
grants, OpenShell environment bindings and a seat's chosen permission person.
|
||||
- **The bundled openrig-core plugin goes from 0.1.23 to 0.1.24.** One skill line
|
||||
changed. Managed refresh preserves replaced files under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from the preserved copy.
|
||||
|
||||
### Runner seats: a person answers permission requests
|
||||
|
||||
Section 7 of `docs/reference/harness-runner-contract.md` is the rule. Read it
|
||||
before writing or changing a runner.
|
||||
- **Who is asked:**
|
||||
- **the default person** (`rig gateway default`, or the only registered
|
||||
person) gets an ordinary ask in the Feed and on Slack;
|
||||
- **a chosen person,** after
|
||||
`rig seat set-permissions-to <seat> --to <person> --reason "…"`. `--clear`
|
||||
returns to the default;
|
||||
- **no one:** when no one can be asked, the request is "Permitted at once,
|
||||
with the agent's own allow option";
|
||||
- **refusal** is opt-in, with `--deny`. `rig seat status` shows the choice.
|
||||
- **What a person's answer does:** a click gives that option, a plain yes gives
|
||||
the allow, and a plain no gives the reject. "A person's no is never a grant."
|
||||
An unanswered request stays pending until the agent's turn ends.
|
||||
- **The runner's protocol:**
|
||||
- `POST /api/activity/permission-requests` submits the request;
|
||||
- `GET …/<askId>?waitSeconds=25` reads the answer (25 at most);
|
||||
- `POST …/<askId>/withdraw` withdraws it at the turn's end.
|
||||
|
||||
Deliver an answer once, only to the live request.
|
||||
- **Check a runner** with
|
||||
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`
|
||||
from the installed package. It needs Node and tmux, and no daemon, model or
|
||||
network.
|
||||
- **The worked example** is the dsh runner under
|
||||
`daemon/docs/reference/community-harness-examples/dsh/`.
|
||||
- **A seat activity grant** lets a runner outside the host's hook setup report
|
||||
activity for one seat and launch, without the install-wide hook token.
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
|
||||
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
|
||||
**In our release testing the worker never became usable:** after the sandbox was
|
||||
created and the native process started, `rig environment connect` failed when its
|
||||
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
|
||||
0.7.8; the later repairs are described above.
|
||||
|
||||
**What it needs:**
|
||||
- NVIDIA OpenShell 0.1.6;
|
||||
- a Linux image;
|
||||
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
|
||||
credentials aren't supported.
|
||||
|
||||
"Provider registration and image construction can incur costs", so name them to
|
||||
the person before you start. "Never grant the guest access to the daemon's main
|
||||
API port (normally 7433)."
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- **Mission following:** `rig terminal open mission:<id> --follow --provider herdr`
|
||||
follows a mission's current owners, with the same Herdr path as slices.
|
||||
- **Launch choices:**
|
||||
- `claude_mcp: none` on a Claude member launches it without MCP servers;
|
||||
- `rig config set terminal.tmux_server isolated` gives this OpenRig home its
|
||||
own persistent tmux server.
|
||||
- **Hosts:** `rig host edit` and `rig host remove` correct or retire a host
|
||||
entry. Use them instead of editing `hosts.yaml` by hand.
|
||||
- **Another user's daemon:** when `rig doctor` warns that the port is held by
|
||||
another OS user's daemon, follow its fix (`rig config set daemon.port <free-port>`),
|
||||
or set `OPENRIG_URL` to share that daemon on purpose.
|
||||
- **tmux control mode:** with `OPENRIG_TMUX_CONTROL=1`, OpenRig starts a control
|
||||
client only on tmux 3.7c or newer, and uses spawned clients otherwise.
|
||||
- **Read-only tmux clients:** a submit key refused because of one goes through
|
||||
the paste route.
|
||||
- **Slack:** a typed reply to a decision parked on its person is recorded as the
|
||||
answer. Read the person's words; don't treat the close as approval.
|
||||
- **The TUI:** agent pages now show health findings and recently finished items,
|
||||
and an agent spec's "not loaded in this view" means it wasn't read, not that
|
||||
nothing runs it.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- ~~**No harness has run end to end under the runner contract yet.** The dsh
|
||||
example and the conformance check were checked against stand-ins.~~ *Corrected
|
||||
after release:* 0.7.8's release testing ran a real dsh 0.2.1-rc.2 through the
|
||||
shipped dsh example runner for a person's allow-once and reject-once, and
|
||||
through an outside runner, dsh-seat 0.3.0, for launch, queue work, multiline
|
||||
input, activity, cancel and exact resume. The conformance check itself runs
|
||||
against a stand-in agent.
|
||||
- **The answer owner is a registered person,** and a colleague's answer is
|
||||
followed on this host only. A request handed to another host waits as needs
|
||||
input until the turn ends.
|
||||
- **Permits and answers aren't recorded** beyond the runner's pane and the ask.
|
||||
If the agent offers only `allow_always`, a permit makes its harness remember a
|
||||
permission nobody chose.
|
||||
- **Coming as follow-ups:** recording permits made when no one can be asked, and
|
||||
passing typed words that aren't a plain yes or no to the agent.
|
||||
- **The dsh example keeps one hold per seat:** two asks in one turn can leave one
|
||||
waiting after the hold is released.
|
||||
- **An OpenShell worker's activity reads unknown,** sampled from the pane, never
|
||||
idle.
|
||||
- **The [0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
|
||||
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits still apply.**
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7298,7 +7298,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7336,7 +7336,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7379,7 +7379,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/acp",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openrig/cli": "0.7.1"
|
||||
"@openrig/cli": "0.7.11"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "openrig",
|
||||
"name": "OpenRig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"description": "Send work to your OpenRig team of agents and follow it through to the result",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"website": "https://openrig.dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@openrig/[email protected]"
|
||||
"package": "@openrig/[email protected]1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"type": "module",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"keywords": [
|
||||
|
||||
@@ -12,6 +12,14 @@ export function terminalAuthHeaders(): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/** The credential for operator-gated writes (Mission Control actions, a pairing decision): an explicit bearer, else the
|
||||
* daemon's operator bearer configured as OPENRIG_AUTH_BEARER_TOKEN, else the terminal credential (the same token on a
|
||||
* daemon that aliases them; none where none is needed). */
|
||||
export function operatorAuthHeaders(bearer?: string): Record<string, string> {
|
||||
const token = bearer?.trim() || process.env.OPENRIG_AUTH_BEARER_TOKEN?.trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : terminalAuthHeaders();
|
||||
}
|
||||
|
||||
/** The header carrying the caller's seat identity, derived ONCE at the transport chokepoint. */
|
||||
export const SENDER_IDENTITY_HEADER = "X-OpenRig-Session";
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ export const READABLE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
/** What happened to the window after a readable read, as far as OpenRig knows. */
|
||||
export function readableRelease(release: string | undefined): string {
|
||||
switch (release) {
|
||||
case "given-back": return "its window was then given back its size and its own window-size";
|
||||
case "given-back": return "its size was put back and its window-size override was unset";
|
||||
case "kept-by-another-holder": return "a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes";
|
||||
case "failed": return "giving its window back failed; a later readable capture or web terminal visit gives it back";
|
||||
case "failed": return "giving its window back failed; a later readable capture can retry releasing its pin";
|
||||
default: return "its window's record had changed, so OpenRig left the window as it found it";
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ function renderCapture(data: Record<string, unknown>): void {
|
||||
const readable = r["readable"] as { resized?: boolean; width?: number; height?: number; reason?: string; release?: string; sizeBack?: string } | undefined;
|
||||
if (readable?.resized === true) {
|
||||
const after = readable.release === "given-back" && readable.sizeBack
|
||||
? `its own window-size was given back, but ${readable.sizeBack}`
|
||||
? `its window-size override was unset, but ${readable.sizeBack}`
|
||||
: readableRelease(readable.release);
|
||||
console.error(`[rig capture: ${r["sessionName"]}: read at ${readable.width}x${readable.height}; ${after}]`);
|
||||
} else if (readable?.resized === false) console.error(`[rig capture: ${r["sessionName"]}: not resized for reading: ${readable.reason}]`);
|
||||
@@ -190,11 +190,11 @@ Supported notes:
|
||||
"[rig capture: ...: pane WxH is smaller than 80x24, ...]" note on stderr says the prompt may be
|
||||
cut off and suggests --readable. --json has it as promptPane: width, height and hint.
|
||||
- --readable (or --size WxH) reads a seat whose pane is smaller than that: it resizes the seat's
|
||||
window, waits for the seat to redraw, captures, then puts the window back, both its size and
|
||||
its own window-size setting, so Herdr, tmux attach and other clients size it as before. The
|
||||
window, waits for the seat to redraw, captures, then puts its size back and unsets the temporary
|
||||
window-size override, so Herdr, tmux attach and other clients size it again. The
|
||||
seat redraws twice, and anyone watching it sees the resize briefly. A window that's already big
|
||||
enough is read as it is, and so is one whose size is pinned (window-size manual, as while the
|
||||
web terminal shows it), one whose sizing can't be read, and one with several panes. With --rig
|
||||
web terminal has opted into fixed geometry), one whose sizing can't be read, and one with several panes. With --rig
|
||||
or --pod, small seats are read at that size for up to 8 seconds in all, and the rest as they
|
||||
are. A stderr note says which; --json has it as readable.
|
||||
- --lines N shows at most the last N lines, counted up from the last non-blank line. When lines
|
||||
|
||||
@@ -119,6 +119,7 @@ Keys:
|
||||
POSIX launches otherwise start from daemon + seat environment, preserving shell startup.
|
||||
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
|
||||
If the scoped boundary is unavailable, launch continues with its previous environment and a diagnostic.
|
||||
terminal.fixed_geometry use 90x27 web geometry while subscribed (default off; other viewers share the pinned size)
|
||||
terminal.status_bar show the inner tmux status bar on launch (default off)
|
||||
terminal.tmux_server inherit the launching server, or isolated per OpenRig home (restart required)
|
||||
terminal.history_limit rows of tmux scrollback per pane in the ~/.tmux.conf block rig setup writes (2000-1000000; default 10000).
|
||||
|
||||
@@ -29,7 +29,7 @@ import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { DaemonClient, operatorAuthHeaders } from "../client.js";
|
||||
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
|
||||
import { parseFeedHostSubscriptionKey } from "../config-store.js";
|
||||
|
||||
@@ -884,7 +884,8 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
if (!opts.json) {
|
||||
console.log(`Pairing code: ${issued.code}`);
|
||||
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
|
||||
console.log(`On the target: rig queue update ${issued.approvalQitemId ?? "<qitem-id>"} --state done --closure-reason no-follow-on`);
|
||||
console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
|
||||
console.log(`On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve ${issued.code} (or rig host deny ${issued.code}), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.`);
|
||||
}
|
||||
|
||||
// A note names the bound actually used so a clamped value is not silently absorbed.
|
||||
@@ -958,6 +959,44 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
printHostNextSteps(added.entry);
|
||||
});
|
||||
|
||||
// The explicit decision on a pairing request made to THIS host: run by the agent that reads the person's answer, or by
|
||||
// a person at a terminal here. One decision per request; the daemon records who made it.
|
||||
for (const decision of ["approve", "deny"] as const) {
|
||||
cmd
|
||||
.command(decision)
|
||||
.description(decision === "approve"
|
||||
? "Approve a pairing request made to this host: the requesting machine receives this host's token"
|
||||
: "Deny a pairing request made to this host: nothing is handed over")
|
||||
.argument("<request>", "The pairing code, or the request's approval row id")
|
||||
.option("--bearer <token>", "This daemon's operator bearer token (or set OPENRIG_AUTH_BEARER_TOKEN); pairing targets always have one")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (request: string, opts: { bearer?: string; json?: boolean }) => {
|
||||
type Decided = { ok?: boolean; error?: string; message?: string; code?: string; requester?: string; by?: string };
|
||||
// The decision is gated by the operator bearer (OPENRIG_AUTH_BEARER_TOKEN), as rig queue resolve and rig mode are.
|
||||
let res: { status: number; data: Decided };
|
||||
try {
|
||||
res = await new DaemonClient().post<Decided>("/api/hosts/pair-decision", { request: request.trim(), decision },
|
||||
{ headers: operatorAuthHeaders(opts.bearer) });
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) console.log(JSON.stringify(res.data));
|
||||
if (res.status !== 200 || !res.data?.ok) {
|
||||
if (!opts.json) console.error(res.status === 401
|
||||
? `This daemon refused the decision without its operator bearer token. Set OPENRIG_AUTH_BEARER_TOKEN to the token this daemon runs with, or pass --bearer <token>. Nothing was decided.`
|
||||
: res.data?.message ?? res.data?.error ?? `the pairing decision failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) return;
|
||||
console.log(decision === "approve"
|
||||
? `Approved pairing ${res.data.code} from ${res.data.requester}, as ${res.data.by}. The requesting machine receives this host's token when it next checks.`
|
||||
: `Denied pairing ${res.data.code} from ${res.data.requester}, as ${res.data.by}. Nothing was handed over.`);
|
||||
});
|
||||
}
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
// OPR.0.4.6.MH1 QA fixback: the PRD/proof contract spells the verb
|
||||
|
||||
@@ -170,6 +170,7 @@ export interface RiggedConfig {
|
||||
tmuxServer: "inherit" | "isolated";
|
||||
/** Rows of tmux scrollback per pane; `rig setup` writes it into the managed `~/.tmux.conf` block. */
|
||||
historyLimit: number;
|
||||
fixedGeometry: boolean;
|
||||
inheritTmuxEnvironment: boolean;
|
||||
};
|
||||
// Slice 15 (OPR.0.7.0.15) — the ONE switch for compact command-output
|
||||
@@ -370,6 +371,7 @@ const DEFAULTS = {
|
||||
statusBar: false,
|
||||
tmuxServer: "inherit",
|
||||
historyLimit: 10000,
|
||||
fixedGeometry: false,
|
||||
inheritTmuxEnvironment: false,
|
||||
},
|
||||
// Slice 15 — compact command-output defaults OFF until the switch is on.
|
||||
@@ -533,6 +535,7 @@ export const VALID_KEYS = [
|
||||
"terminal.tmux_server",
|
||||
// The managed ~/.tmux.conf block's history-limit, written by `rig setup`; lockstep with the daemon twin.
|
||||
"terminal.history_limit",
|
||||
"terminal.fixed_geometry",
|
||||
"terminal.inherit_tmux_environment",
|
||||
// Slice 15 — compact command-output defaults; lockstep with the daemon twin.
|
||||
"output.compact",
|
||||
@@ -664,6 +667,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
|
||||
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
|
||||
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
|
||||
"terminal.fixed_geometry": { primary: "OPENRIG_TERMINAL_FIXED_GEOMETRY" },
|
||||
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
|
||||
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
|
||||
"output.whoami_peers": { primary: "OPENRIG_OUTPUT_WHOAMI_PEERS" },
|
||||
@@ -770,6 +774,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"terminal.status_bar": ["terminal", "statusBar"],
|
||||
"terminal.tmux_server": ["terminal", "tmuxServer"],
|
||||
"terminal.history_limit": ["terminal", "historyLimit"],
|
||||
"terminal.fixed_geometry": ["terminal", "fixedGeometry"],
|
||||
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
|
||||
"output.compact": ["output", "compact"],
|
||||
"output.whoami_peers": ["output", "whoamiPeers"],
|
||||
@@ -1415,6 +1420,7 @@ export class ConfigStore {
|
||||
statusBar: v("terminal.status_bar") as boolean,
|
||||
tmuxServer: v("terminal.tmux_server") as "inherit" | "isolated",
|
||||
historyLimit: v("terminal.history_limit") as number,
|
||||
fixedGeometry: v("terminal.fixed_geometry") as boolean,
|
||||
inheritTmuxEnvironment: v("terminal.inherit_tmux_environment") as boolean,
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -520,18 +520,20 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
pane = placeholder;
|
||||
} else pane = (await runTmux([...args, "-P", "-F", "#{pane_id}", `env -u TMUX ${first.paneCommand}`])).trim();
|
||||
// Nested source clients on this server can make a new window too narrow to split.
|
||||
// Use the viewer's configured detached size, then restore normal client resizing.
|
||||
// Use the viewer's detached size only while splitting; always release to inherited client sizing.
|
||||
const [width, height] = (await runTmux(["show-options", "-Av", "-t", viewer, "default-size"])).trim().split("x");
|
||||
const sizing = (await runTmux(["show-options", "-Awv", "-t", pane, "window-size"])).trim();
|
||||
await runTmux(["resize-window", "-t", pane, "-x", width!, "-y", height!]);
|
||||
await runTmux(["select-pane", "-t", pane, "-T", first.label]);
|
||||
for (const item of page.slice(1)) {
|
||||
pane = (await runTmux(["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
|
||||
await runTmux(["select-pane", "-t", pane, "-T", item.label]);
|
||||
await runTmux(["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
|
||||
try {
|
||||
await runTmux(["resize-window", "-t", pane, "-x", width!, "-y", height!]);
|
||||
await runTmux(["select-pane", "-t", pane, "-T", first.label]);
|
||||
for (const item of page.slice(1)) {
|
||||
pane = (await runTmux(["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
|
||||
await runTmux(["select-pane", "-t", pane, "-T", item.label]);
|
||||
await runTmux(["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
|
||||
}
|
||||
await runTmux(["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
|
||||
} finally {
|
||||
await runTmux(["set-option", "-wu", "-t", `${viewer}:${name}`, "window-size"]);
|
||||
}
|
||||
await runTmux(["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
|
||||
await runTmux(["set-option", "-w", "-t", `${viewer}:${name}`, "window-size", sizing]);
|
||||
}
|
||||
await runTmux(["select-window", "-t", `${viewer}:view-1`]);
|
||||
if (!window) {
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("Capture CLI", () => {
|
||||
const { out, err } = await run(["read@my-rig", "--readable"]);
|
||||
expect(requests.at(-1)!.readable).toEqual({ width: 120, height: 40 });
|
||||
expect(out).toEqual(["line1\nline2\n"]);
|
||||
expect(err).toEqual(["[rig capture: read@my-rig: read at 120x40; its window was then given back its size and its own window-size]"]);
|
||||
expect(err).toEqual(["[rig capture: read@my-rig: read at 120x40; its size was put back and its window-size override was unset]"]);
|
||||
});
|
||||
|
||||
it("says so when a web terminal took the window over meanwhile, instead of claiming a give-back", async () => {
|
||||
@@ -195,7 +195,7 @@ describe("Capture CLI", () => {
|
||||
|
||||
it("says when the size couldn't be put back, without claiming it was", async () => {
|
||||
const { err } = await run(["sizeback@my-rig", "--readable"]);
|
||||
expect(err).toEqual(["[rig capture: sizeback@my-rig: read at 120x40; its own window-size was given back, but putting its size back failed: busy]"]);
|
||||
expect(err).toEqual(["[rig capture: sizeback@my-rig: read at 120x40; its window-size override was unset, but putting its size back failed: busy]"]);
|
||||
});
|
||||
|
||||
it("a readable request waits out the daemon's readable budget; other captures keep the usual wait", async () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ function clearEnv(): () => void {
|
||||
// its custom home into default-path resolution; UNSETTING it would fall through
|
||||
// to the REAL ~/.openrig (foreign on-box state) — so tests set a fresh tmp home.
|
||||
"OPENRIG_HOME", "RIGGED_HOME",
|
||||
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_DAEMON_DB_CONFLICT",
|
||||
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_TERMINAL_FIXED_GEOMETRY", "OPENRIG_DAEMON_DB_CONFLICT",
|
||||
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
|
||||
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
|
||||
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
|
||||
@@ -188,6 +188,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"terminal.status_bar",
|
||||
"terminal.tmux_server",
|
||||
"terminal.history_limit",
|
||||
"terminal.fixed_geometry",
|
||||
"terminal.inherit_tmux_environment",
|
||||
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults switch.
|
||||
"output.compact",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// rig host approve|deny <request>: the explicit pairing decision on this host, from the agent that reads the person's
|
||||
// answer or from a person at a plain terminal. A local HTTP server stands in for the daemon; no real token.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { Command } from "commander";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { hostCommand } from "../src/commands/host.js";
|
||||
import { operatorAuthHeaders, terminalAuthHeaders } from "../src/client.js";
|
||||
import { createFullTestDb } from "../../daemon/test/helpers/test-app.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { makeTopologyDestinationCheck } from "../../daemon/src/domain/queue-destination-check.js";
|
||||
import { hostsRoutes } from "../../daemon/src/routes/hosts.js";
|
||||
|
||||
async function run(argv: string[]): Promise<{ out: string; err: string; exitCode: number | undefined }> {
|
||||
const out: string[] = []; const err: string[] = [];
|
||||
const ol = console.log; const oe = console.error; const oc = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...a: unknown[]) => out.push(a.join(" "));
|
||||
console.error = (...a: unknown[]) => err.push(a.join(" "));
|
||||
try {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(hostCommand());
|
||||
await prog.parseAsync(["node", "rig", "host", ...argv]);
|
||||
} finally { console.log = ol; console.error = oe; }
|
||||
const exitCode = process.exitCode as number | undefined;
|
||||
process.exitCode = oc;
|
||||
return { out: out.join("\n"), err: err.join("\n"), exitCode };
|
||||
}
|
||||
|
||||
describe("rig host approve|deny", () => {
|
||||
let dir: string;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
let server: http.Server;
|
||||
let requests: Array<{ url: string; headers: http.IncomingHttpHeaders; body: string }>;
|
||||
let reply: { status: number; body: unknown };
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostdecide-"));
|
||||
savedEnv = {};
|
||||
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_URL", "RIGGED_URL", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME", "OPENRIG_TERMINAL_BEARER_TOKEN"]) {
|
||||
savedEnv[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env.OPENRIG_HOME = dir;
|
||||
// A plain terminal's write credential (terminalAuthHeaders reads it from the OpenRig home).
|
||||
fs.writeFileSync(path.join(dir, "terminal-token"), "synthetic-terminal-token\n");
|
||||
requests = [];
|
||||
reply = { status: 200, body: { ok: true, decision: "approve", code: "424242", requester: "alex@laptop", by: "human@host" } };
|
||||
server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => { body += c; });
|
||||
req.on("end", () => {
|
||||
requests.push({ url: req.url ?? "", headers: req.headers, body });
|
||||
res.writeHead(reply.status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(reply.body));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
process.env.OPENRIG_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it("approve from a plain terminal: one authenticated write naming the request, no seat, and the result printed", async () => {
|
||||
const { out, err, exitCode } = await run(["approve", "424242"]);
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(err).toBe("");
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]!.url).toBe("/api/hosts/pair-decision");
|
||||
expect(JSON.parse(requests[0]!.body)).toEqual({ request: "424242", decision: "approve" });
|
||||
expect(requests[0]!.headers.authorization).toBe("Bearer synthetic-terminal-token");
|
||||
expect(requests[0]!.headers["x-openrig-session"]).toBeUndefined();
|
||||
expect(out).toBe("Approved pairing 424242 from alex@laptop, as human@host. The requesting machine receives this host's token when it next checks.");
|
||||
});
|
||||
|
||||
it("deny from a seat carries the seat, and a refusal prints the daemon's message and exits 1", async () => {
|
||||
process.env.OPENRIG_SESSION_NAME = "operator-agent@kernel";
|
||||
reply = { status: 409, body: { error: "pair_request_decided", decision: "approve", by: "human@host",
|
||||
message: "Pairing request 424242 was already approved by human@host; that stands." } };
|
||||
const { out, err, exitCode } = await run(["deny", "qitem-pair-1"]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(out).toBe("");
|
||||
expect(err).toBe("Pairing request 424242 was already approved by human@host; that stands.");
|
||||
expect(JSON.parse(requests[0]!.body)).toEqual({ request: "qitem-pair-1", decision: "deny" });
|
||||
expect(requests[0]!.headers["x-openrig-session"]).toBe("operator-agent@kernel");
|
||||
});
|
||||
});
|
||||
|
||||
// The real route behind the real command, where the daemon's operator bearer and the terminal token differ: the decision
|
||||
// takes the operator bearer, which an agent seat carries (OPENRIG_AUTH_BEARER_TOKEN) and a person passes with --bearer.
|
||||
describe("rig host approve against the real pairing route, with distinct credentials", () => {
|
||||
const AUTH = "synthetic-operator-bearer", TERMINAL = "synthetic-terminal-token-differs";
|
||||
const PERSON = "pair-person@external";
|
||||
const registry = { ok: true as const, entities: [{ entityId: "pair-person", class: "human" as const, displayName: "Pairing person", address: PERSON,
|
||||
connectorBindings: [], prefs: { deliveryClass: "A" as const } }] };
|
||||
let dir: string;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
let server: { close: () => void };
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostdecide-real-"));
|
||||
savedEnv = {};
|
||||
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_URL", "RIGGED_URL", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME", "OPENRIG_TERMINAL_BEARER_TOKEN", "OPENRIG_AUTH_BEARER_TOKEN"]) {
|
||||
savedEnv[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env.OPENRIG_HOME = dir;
|
||||
fs.writeFileSync(path.join(dir, "terminal-token"), `${TERMINAL}\n`);
|
||||
const db = createFullTestDb();
|
||||
const bus = new EventBus(db);
|
||||
const validateRig = makeTopologyDestinationCheck({ findRigsByName: () => [], loadHumanRegistry: () => registry, sharedChannels: () => [] });
|
||||
const repo = new QueueRepository(db, bus, { validateRig, loadHumanRegistry: () => registry,
|
||||
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("queueRepo" as never, repo as never); await next(); });
|
||||
app.route("/api/hosts", hostsRoutes({ bearerToken: AUTH, humanRegistry: () => registry }));
|
||||
await new Promise<void>((resolve) => {
|
||||
const s = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { base = `http://127.0.0.1:${info.port}`; resolve(); });
|
||||
server = { close: () => { s.close(); db.close(); } };
|
||||
});
|
||||
process.env.OPENRIG_URL = base;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
async function request(): Promise<{ pairId: string; code: string }> {
|
||||
return (await fetch(`${base}/api/hosts/pair-request`, { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requester: "requester@far-host" }) })).json() as Promise<{ pairId: string; code: string }>;
|
||||
}
|
||||
const poll = async (pairId: string) => (await fetch(`${base}/api/hosts/pair-request/${pairId}`)).json() as Promise<{ status: string; token?: string }>;
|
||||
|
||||
it("from an agent seat (OPENRIG_AUTH_BEARER_TOKEN in its env, a different terminal token on disk): approved, and the token is handed over", async () => {
|
||||
const pair = await request();
|
||||
process.env.OPENRIG_SESSION_NAME = "operator-agent@kernel";
|
||||
process.env.OPENRIG_AUTH_BEARER_TOKEN = AUTH;
|
||||
const { out, exitCode } = await run(["approve", pair.code]);
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(out).toContain(`Approved pairing ${pair.code} from requester@far-host, as operator-agent@kernel.`);
|
||||
expect(await poll(pair.pairId)).toEqual({ status: "approved", token: AUTH });
|
||||
});
|
||||
|
||||
it("from a plain terminal with --bearer: denied, recorded as a direct human action", async () => {
|
||||
const pair = await request();
|
||||
const { out, exitCode } = await run(["deny", pair.code, "--bearer", AUTH]);
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(out).toContain(`Denied pairing ${pair.code} from requester@far-host, as human@host.`);
|
||||
expect(await poll(pair.pairId)).toEqual({ status: "denied" });
|
||||
});
|
||||
|
||||
it("with only a terminal token that isn't the operator bearer: refused with what to set, and nothing is decided", async () => {
|
||||
const pair = await request();
|
||||
const { err, exitCode } = await run(["approve", pair.code]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(err).toBe("This daemon refused the decision without its operator bearer token. Set OPENRIG_AUTH_BEARER_TOKEN to the token this daemon runs with, or pass --bearer <token>. Nothing was decided.");
|
||||
expect(await poll(pair.pairId)).toMatchObject({ status: "pending" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("operatorAuthHeaders: the credential for operator-gated writes", () => {
|
||||
let dir: string;
|
||||
let saved: Record<string, string | undefined>;
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "operator-auth-"));
|
||||
saved = {};
|
||||
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_TERMINAL_BEARER_TOKEN", "OPENRIG_AUTH_BEARER_TOKEN"]) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||
process.env.OPENRIG_HOME = dir;
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||
});
|
||||
|
||||
it("takes the configured operator bearer with no terminal-token file, while terminal calls carry none", () => {
|
||||
process.env.OPENRIG_AUTH_BEARER_TOKEN = "synthetic-operator-bearer";
|
||||
expect(operatorAuthHeaders()).toEqual({ Authorization: "Bearer synthetic-operator-bearer" });
|
||||
expect(terminalAuthHeaders()).toEqual({});
|
||||
});
|
||||
|
||||
it("an explicit bearer wins; with none configured it falls back to the terminal credential, as before", () => {
|
||||
fs.writeFileSync(path.join(dir, "terminal-token"), "synthetic-terminal-token\n");
|
||||
process.env.OPENRIG_AUTH_BEARER_TOKEN = "synthetic-operator-bearer";
|
||||
expect(operatorAuthHeaders("synthetic-explicit")).toEqual({ Authorization: "Bearer synthetic-explicit" });
|
||||
delete process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
expect(operatorAuthHeaders()).toEqual({ Authorization: "Bearer synthetic-terminal-token" });
|
||||
expect(terminalAuthHeaders()).toEqual({ Authorization: "Bearer synthetic-terminal-token" });
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,10 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
|
||||
expect(JSON.parse(out.join("\n"))).toMatchObject({ ok: true, entry: { id: "vps-a", transport: "http" } });
|
||||
} else {
|
||||
expect(out.join("\n")).toContain("Pairing code: 424242");
|
||||
// The person on the target answers however they like (TUI, Steering, Slack); its kernel operator, or a terminal, decides.
|
||||
expect(out.join("\n")).toContain("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
|
||||
expect(out.join("\n")).toContain("On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve 424242 (or rig host deny 424242), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.");
|
||||
expect(out.join("\n")).not.toContain("On the target: rig queue update");
|
||||
expect(out.join("\n")).toContain("Paired. Host 'vps-a' registered");
|
||||
expect(out.join("\n")).toContain("rig host doctor vps-a");
|
||||
expect(out.join("\n")).toContain("rig config set feed.subscriptions.vps-a.enabled true");
|
||||
|
||||
@@ -72,6 +72,7 @@ it.each([
|
||||
expect(tmux(viewer, ["list-panes", "-s", "-t", `=openrig-view-${id}`, "-F", "#{pane_dead}"]).trim().split("\n")).toEqual(Array(19).fill("0"));
|
||||
for (const page of [1, 2]) {
|
||||
expect(tmux(viewer, ["show-options", "-Awv", "-t", `openrig-view-${id}:view-${page}`, "window-size"]).trim()).toBe("latest");
|
||||
expect(tmux(viewer, ["show-options", "-wv", "-t", `openrig-view-${id}:view-${page}`, "window-size"]).trim()).toBe("");
|
||||
}
|
||||
}, 40_000);
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ Startup uses a private, hash-checked `control/first-input.txt` file and a bounde
|
||||
|
||||
The placement name is supplied by the caller. Create refuses an already-listed name, and each binding retains its one selected name; OpenRig does not reserve names globally across bindings. Immediately before stop/delete, OpenRig checks the saved immutable identity, then the pinned vendor call addresses the name and workspace. A deliberate reuse of that name between inspection and the vendor call could affect the replacement. The pinned CLI has no expected-id condition for these calls. Later reconciliation cannot undo that mutation.
|
||||
|
||||
Exact restore onto recreated compute stages the current resolved role, skills, plugins and required startup files as destination files, including files originally intended only for a fresh start. Declared context changes, additions and removals are allowed. Current source and destination bytes are verified through the ordinary projection checks; a missing or stale required destination refuses launch. Startup actions and text are not replayed into the conversation. The checkpoint pins the original native history and verified artifact bytes, which remain separate and unchanged; it does not freeze the context.
|
||||
Exact restore onto recreated compute stages the current resolved role, skills, plugins and required startup files as destination files, including files originally intended only for a fresh start. Declared context changes, additions and removals are allowed. Current source and destination bytes are verified through the ordinary projection checks; a missing or stale required destination refuses launch. Saved startup actions are not resent, but the restored guest's first input asks it to read the projected `CLAUDE.md` and its startup files again, so it takes in its startup content again and strict recall after a restore isn't met. The checkpoint pins the original native history and verified artifact bytes, which remain separate and unchanged; it does not freeze the context.
|
||||
|
||||
A pending finite-completion attempt records its last outcome and attempt time in the existing operation receipt. Queue events and boot/manual checks observe it at most once per 60 seconds; there is no new polling timer. Eligibility is checked again after the driver's identity await, immediately before its stop command. A known refusal before dispatch releases only the unstarted effect claim; an uncertain dispatched request is never replayed. With no pending selection, a queue event performs one filtered query and no provider call; the operation table has no additional index or migration in this increment.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety-two top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.10`). This marker describes the pack's teaching, not publication or live
|
||||
`capability-delta-v0.7.11`). 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`.
|
||||
@@ -134,6 +134,12 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
`caller_cancelled` and never types later. **`--urgent`** marks a message urgent, as does a row
|
||||
at priority `urgent` or `critical`: its wake starts `Urgent (marked by …):`, and it gets through a
|
||||
seat whose typing guard lets only urgent input type (`openrig-urgent` or `urgent-only`, below).
|
||||
**A seat whose prompt holds a person's typed draft gets the message joined to it** by default
|
||||
(`transport.draft_delivery` `deliver`): the message is pasted at the cursor and the combined input
|
||||
submitted as one prompt, and the result says when it joined a draft OpenRig saw. Tell the person.
|
||||
`rig config set transport.draft_delivery refuse` brings back the refusal (`prompt_draft`) from the
|
||||
next send. Menus, permission prompts, a seat's typing guard, managed compaction and
|
||||
`--wait-for-idle` refuse or wait under either value. Draft detection isn't complete.
|
||||
- **`rig send --raw`** — send exact text or keystrokes without the From/To envelope;
|
||||
the interactive-prompt guard still applies. To deliberately drive an interactive prompt,
|
||||
**`--dangerously-interact --reason "<why>"`** is the explicit override and implies raw text.
|
||||
@@ -208,6 +214,15 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
judge and close. Each permission request is a row to `--permissions-to` (default: you). The guide
|
||||
is `docs/reference/acp-agents.md` in the source repo, and `docs/reference/a2a-agents.md` covers
|
||||
A2A agents.
|
||||
- **`rig acp serve --to <member@rig>`** — the other direction: the team as an agent inside an ACP
|
||||
app that launches a custom agent over stdio (Zed's `agent_servers`, T3 Code). A prompt becomes a
|
||||
queue row, each row the work reaches shows as a step, and the closing resolution is the reply.
|
||||
The team's questions reach the app as a permission request or a one-choice form, and the choice
|
||||
is recorded as the person's answer; one left unanswered there stays open in the TUI and Slack.
|
||||
`acp.to` sets the default receiver. Nothing runs until an app starts it, and it uses the daemon
|
||||
`rig` uses (`OPENRIG_URL` can name another). The `@openrig/acp` launcher runs the matching
|
||||
CLI's `rig acp serve` for the ACP registry's `npx` launch. A cancel stops waiting; the work
|
||||
carries on. It's been checked in T3 Code and with a scripted client.
|
||||
- **`rig exec run <row> --harness claude|codex`** — a finite task for Claude Code or Codex without
|
||||
building a seat: the same single turn through that harness's ACP adapter, run by a detached
|
||||
connector that outlives the command, so closing the terminal or leaving `--wait` doesn't stop it.
|
||||
@@ -228,7 +243,15 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
wait for a phrase like “show me my agents”; the same command brings their agents back later. No
|
||||
YAML, herdr when installed or plain tmux. The layout follows the terminal's width: on a narrow
|
||||
one the operator gets the first tab; on a wide one the dashboard and operator sit side by side
|
||||
and the advisor gets its own tab. A `kernel` view the person saved still wins. Inside herdr it
|
||||
and the advisor gets its own tab. A `kernel` view the person saved still wins.
|
||||
Writable tiles attach as in 0.7.10: the screen in use sizes the seat and text reflows on resize.
|
||||
Read-only tiles show without attaching or resizing it. Fixed web geometry is opt-in through
|
||||
`terminal.fixed_geometry` (off by default). Enter or Ctrl-C during the first attach can close
|
||||
the viewer; the agent keeps running, so reopen it. Kept writable 0.7.8, 0.7.9 or pre-reflow
|
||||
0.7.11 tiles need a manual refresh: close only their owned viewer and reopen with
|
||||
`rig terminal open`; for `--follow`, use `--no-follow` first, then open a fresh view.
|
||||
The agent and conversation are preserved; this is not an automatic migration.
|
||||
Inside herdr it
|
||||
opens a focused `openrig kernel` space. It opens where the caller is: Terminal gets a window and
|
||||
Ghostty a tab; from Claude Desktop, iTerm or VS Code on a Mac it opens a new Ghostty or Terminal
|
||||
window, and only then say that clicking macOS's Allow is fine. Herdr is visible only in a
|
||||
@@ -348,6 +371,10 @@ your context, your compaction and your replacement — and it is the only thing
|
||||
everything.
|
||||
- **`rig queue show`** — what a row *actually says*. The header is not the body. On a closed row,
|
||||
`--full` adds `closingTransition`: how it ended, who closed it and the closing note.
|
||||
`rig queue show`, `list`, `transitions`, `update` and `block` take `--host <id>` for a row on
|
||||
another host, as `create` and `handoff` do; `claim` doesn't, since only the destination seat
|
||||
claims, on its own host. `rig queue artifact <qitemId>` (and MCP `rig_artifact_read`) returns the
|
||||
exact commit pair from an `openrig.code-result/v1` manifest set as the row's `evidenceRef`.
|
||||
- **`rig queue claim` / `unclaim`** — is this mine and running, or still pending where two agents
|
||||
might double-work it. Put it down honestly when it is not yours.
|
||||
- **`rig queue update`** — record what happened in a form the rest of the system can act on. A
|
||||
@@ -468,7 +495,9 @@ relay is a reason to inspect the available routing, not to surrender judgment to
|
||||
inspect `rig gateway human list --json`; when several people are registered, choose a default
|
||||
with `rig gateway default <id>`, or name one in `workspace.operator_seat_name`, which is read first.
|
||||
- **`rig workflow show` / `trace`** — what this instance is, and every step, actor and exit that
|
||||
got it here.
|
||||
got it here. `trace --full` returns the whole trail, and `show`, `trace` and `status` print the run's
|
||||
`objective:`. **`rig workflow unregister <name>[@<version>]`** (or `--prune-missing`) removes
|
||||
registered versions; a running instance keeps the spec it started with.
|
||||
- **`rig workflow revise <instance>`** — compare the bound graph with current authored
|
||||
inputs before changing a progressed run. Ordinary show and TUI inspection distinguish
|
||||
source-only edits (such as a catalog address) from executable changes. Preview names
|
||||
@@ -580,6 +609,14 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
selects only the tmux server: home, credentials, filesystem and network stay shared.
|
||||
**`rig up <spec> --name <rig>`** runs one spec as another rig under that name; the rig keeps the
|
||||
name for `--existing`, snapshots and restore, and the spec file isn't changed.
|
||||
- **`launch_wrapper`** on a local Claude Code, Pi or Codex member is an argv prefix whose launcher
|
||||
prepares the environment and execs the agent; a wrapped seat never starts unwrapped. It hasn't
|
||||
run with zsh or with a real installed Claude.
|
||||
- **A person can work with the team from their own Claude Code or Codex** through the `openrig`
|
||||
plugin, shipped beside openrig-core (its README in the installed package has the install
|
||||
steps): hand it work, follow it, answer the team's questions in their own words, and read the
|
||||
result. VS Code, Copilot CLI and Cursor load it with no seat: give the app its own
|
||||
`rig mcp serve --front-door` server with `OPENRIG_SESSION_NAME` set and turn the plugin's off.
|
||||
- **A Claude member can bring its own setup** in the rig spec: `agent_file`,
|
||||
`append_system_prompt_file`, `plugin_dirs` and `advisor` (an Advisor model). They're copied into
|
||||
that seat's own storage, never the project, so one seat in a shared repository can have its own
|
||||
@@ -720,6 +757,9 @@ version, from checking the environment to a useful report to the OpenRig team.
|
||||
Stop distinguishes process/listener exit from a clean asynchronous drain: inspect the matching
|
||||
`daemon-shutdown.json` and log when completion is incomplete or unverified. No target is a
|
||||
distinct no-op, and a timeout never proves down. Use command help for exact limits and recovery.
|
||||
On Linux, when the daemon runs as a systemd user service, the seats' tmux server starts in its
|
||||
own user scope, so stopping or restarting the daemon's unit keeps the seats; otherwise it starts
|
||||
as before, with one log line giving the reason.
|
||||
Startup brings back a kernel that this start found down (what a reboot leaves). A kernel that
|
||||
was already down before this start stays down: `rig start` and `rig daemon start
|
||||
--wait-for-kernel` say so, and `rig up kernel --existing` brings it back.
|
||||
@@ -1200,6 +1240,11 @@ reaching them is ordinary work rather than an escalation.
|
||||
person can settle an ask that was asked of someone else, recorded as themselves beside the person
|
||||
asked. The first committed answer wins; a later one gets 409 `qitem_already_terminal` and records
|
||||
nothing. A park addressed to a registered person can be resolved.
|
||||
Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
|
||||
**Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
|
||||
**An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
|
||||
**TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
|
||||
**Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
|
||||
- **`rig gateway channel add <id> --display-name <name> --binding
|
||||
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
|
||||
`<id>@external` (Slack only). Anyone writing there reaches the team, registered or not. Each row
|
||||
@@ -1383,9 +1428,11 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
stop them without killing the rig.
|
||||
- **`rig environment`** — not `rig env`: places a worker seat in an NVIDIA OpenShell sandbox and returns
|
||||
verified result files (`setup`, `create` or `attach`, `export`; reference in the packaged
|
||||
`daemon/assets/openshell/README.md`). In 0.7.8's release testing the worker never became usable;
|
||||
0.7.10 fixes in the source the two problems that stopped it in 0.7.9, but it hasn't run end to
|
||||
end. Read the installed version's release notes before relying on it.
|
||||
`daemon/assets/openshell/README.md`). In 0.7.8's release testing the worker never became usable.
|
||||
In 0.7.11, on OpenShell 0.1.6, the maintained cleanup works for a custom image, and recreate
|
||||
and an exact resume passed in native testing; a restored guest still receives its startup text
|
||||
again, and the worker isn't end to end. Read the installed version's release notes before
|
||||
relying on it.
|
||||
- **`rig setup`** — what OpenRig would change about this machine, shown before it touches anything.
|
||||
It installs herdr by default on macOS and Linux (`--no-herdr` declines; if the install fails,
|
||||
views fall back to plain tmux) and on a Mac offers Ghostty once (`--ghostty` / `--no-ghostty`).
|
||||
@@ -1476,6 +1523,12 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
get a standard challenge; the key and its grant still decide what a caller may do. The rows a
|
||||
granted caller's actions write record whose grant acted: attribution, not authentication.
|
||||
Clients on MCP protocol revision 2026-07-28 and on the 2025 revisions share the same endpoint.
|
||||
With a grant service (experimental and off by default; you serve it to the internet behind
|
||||
your own tunnel), `--task-destination <member@rig>` names where a caller's `rig_queue_create`
|
||||
goes when it gives no destination, and a seat's name alone resolves within the grant's team. A
|
||||
grant that names a person adds `rig_question_list` and `rig_question_answer`, so that person
|
||||
answers the team's questions from their assistant, recorded as their answer through that
|
||||
connection.
|
||||
**An assistant connected this way can be a hosted member of a rig:** mark its roster entry
|
||||
`"hosted": true` and `rig_team` lists it with its declared capabilities and caveat, activity
|
||||
`unknown`. The server's instructions teach it the loop: its inbox, claiming, closing or handing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
|
||||
import { TerminalPolicyRefusal } from "./terminal-policy.js";
|
||||
import { DeliveryGuardError } from "./seat-delivery-guard.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport, type SendResult } from "./session-transport.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS, type SessionTransport, type SendResult } from "./session-transport.js";
|
||||
import type { ClaudeCompactionPolicy, SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { postCompactExtraSetting } from "./managed-restore-read-access.js";
|
||||
import * as fs from "node:fs";
|
||||
@@ -718,7 +718,8 @@ export class ClaudeCompactionEnforcer {
|
||||
this.now = opts?.now ?? (() => Date.now());
|
||||
this.sleep = opts?.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
||||
this.postCompactSendWaitMs = opts?.postCompactSendWaitMs ?? POST_COMPACT_SEND_WAIT_MS_DEFAULT;
|
||||
this.compactSendIdleWaitMs = opts?.compactSendIdleWaitMs ?? 1000;
|
||||
// A fresh preparation hook can veto even a visibly idle pane for the full send-readiness window.
|
||||
this.compactSendIdleWaitMs = opts?.compactSendIdleWaitMs ?? SEND_READINESS_FRESHNESS_MS;
|
||||
this.restartRecoveryWindowMs = opts?.restartRecoveryWindowMs ?? RESTART_RECOVERY_WINDOW_MS_DEFAULT;
|
||||
this.daemonInstance = opts?.daemonInstance ?? DAEMON_PROCESS_ID;
|
||||
this.resolveOccupantGeneration = opts?.resolveOccupantGeneration;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Every host pairing request's approval item comes from this address. */
|
||||
export const PAIR_SOURCE_SESSION = "host-pair@kernel";
|
||||
|
||||
/** A host pairing request's approval item. It's answered in place, like a runner's permission request: the pairing
|
||||
* poll reads the answer from the item, and a reply row would go to an address nothing reads (and that doesn't exist
|
||||
* on a daemon started without a kernel). */
|
||||
export function isHostPairingAsk(item: { sourceSession: string; evidenceRef?: string | null }): boolean {
|
||||
return item.sourceSession === PAIR_SOURCE_SESSION && (item.evidenceRef ?? "").startsWith("pair-request:");
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
|
||||
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 { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
|
||||
import { join } from "node:path";
|
||||
@@ -2058,9 +2059,10 @@ export class QueueRepository {
|
||||
async createHumanAnswer(input: QueueCreateInput & { inboundReplyTo: string; episodeKey?: string }): Promise<QueueItem> {
|
||||
const { episodeKey, ...create } = input;
|
||||
// A runner's permission request is answered in its ask, and the runner reads the answer back. No reply row: it
|
||||
// would wake the runner's terminal, where typed text reads as a prompt to its agent.
|
||||
// would wake the runner's terminal, where typed text reads as a prompt to its agent. A host pairing request is
|
||||
// answered in its ask too: the pairing poll reads it there, and nothing reads a reply.
|
||||
const ask = this.getById(input.inboundReplyTo);
|
||||
if (ask && runnerPermissionOrigin(ask)) return ask;
|
||||
if (ask && (runnerPermissionOrigin(ask) || isHostPairingAsk(ask))) return ask;
|
||||
const qitemId = humanAnswerId(input.inboundReplyTo, episodeKey);
|
||||
const existing = episodeKey ? this.getById(qitemId) : this.getHumanAnswer(input.inboundReplyTo, input.sourceSession);
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -220,6 +220,12 @@ export class SeatLaunchEnvironment {
|
||||
].join("\n");
|
||||
}
|
||||
const assignments = Object.entries(env).map(([key, value]) => shellQuote(`${key}=${value}`));
|
||||
// A long Pi command is exec'd from a staged script, and exec takes one command: `exec unset …; …` started no agent.
|
||||
// Its account step goes to env as -u instead (the selected home is already an assignment above).
|
||||
if (target.runtime === "pi" && accountStep) {
|
||||
const unset = [...(clearing && homeVar ? [homeVar] : []), ...(accountHome || selectedPiHome ? scrub : [])];
|
||||
return `/usr/bin/env ${unset.map((name) => `-u ${name}`).join(" ")} ${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${prefix}${command}`;
|
||||
}
|
||||
return `${accountStep}/usr/bin/env ${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${prefix}${command}`;
|
||||
} catch {
|
||||
// This best-effort correction is not a new launch admission gate. Do not
|
||||
|
||||
@@ -61,7 +61,7 @@ import { getLatestStartupFailedEvent, hasClaudeResumeIdentityWork, isRecoverable
|
||||
// (daintree #3938) cite Claude 1-3s / Codex 3-5s inter-tool-call gaps with a 6s idle debounce. A 15s
|
||||
// window comfortably spans one inter-tool gap (so a mid-turn reading stays trusted) while refusing to
|
||||
// authorize a send from a reading tens of seconds old. (EXA: agtx#14, daintree#3938.)
|
||||
const SEND_READINESS_FRESHNESS_MS = 15_000;
|
||||
export const SEND_READINESS_FRESHNESS_MS = 15_000;
|
||||
|
||||
// transport.verify_input_box: each extra input-box read gives up after this long and reads `unavailable`. A deadline
|
||||
// budget, not a guarantee: the pre-Enter read comes after the existing 200 ms wait and can add about this much before
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Frozen r6 attachment generator for exact upgrade recognition and read-only observation.
|
||||
|
||||
// A tile owns its view and attach processes; no daemon watcher. Its view reads the seat only after new activity.
|
||||
// Session names survive fresh launch, handover and restart. The node identity
|
||||
// must survive too: never follow a same-name session belonging to another seat.
|
||||
export function tileAttachArgv0711(
|
||||
tmux: string, server: readonly string[], session: string, readOnly: boolean,
|
||||
retrySeconds = 30, idleSeconds = 30,
|
||||
): string[] {
|
||||
// A tmux window has one size, and a client narrower than it sees only the left part of each line. So a tile shows the
|
||||
// seat by mirroring it rather than attaching, and attaches only while the person types in it.
|
||||
// - view: the seat's own screen, printed into the tile, whose terminal wraps each line at the tile's width. The tile is
|
||||
// then no tmux client of the seat, so it never sizes, reflows or types into it. The screen is read again only when
|
||||
// tmux records new activity in the window (read each second), and the view ends when the session goes, or, for a
|
||||
// writable tile, when the person presses a key.
|
||||
// - use (writable tiles): that first key goes to the seat and the tile attaches, sizing the seat while it's used, as
|
||||
// any client the person types in does. Once the tile has had no key for idleSeconds it detaches back to the view,
|
||||
// as any other detach of it does.
|
||||
// - A read-only tile only views.
|
||||
const open = readOnly
|
||||
? 'open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };'
|
||||
: 'open() { t=$1; shift; while use "$t" "$@"; do :; done; };';
|
||||
// No apostrophes or control characters in the script: Herdr also stores this
|
||||
// argv as its restart command. All external values stay positional arguments.
|
||||
const script = [
|
||||
// Files the script makes (the first input) are readable by this user alone.
|
||||
'umask 077; b0=$(printf "\\033[200~"); b1=$(printf "\\033[201~");',
|
||||
// A box-drawing rule (Claude Code draws them as wide as its window) is cut to the tile's width, so a narrow tile
|
||||
// doesn't spend most of its rows on one wrapped rule. The cut works on bytes, so no character can blank the view.
|
||||
// One tmux call a second reads the activity time and the screen together; the screen is drawn only after new
|
||||
// activity, and once more after it, since tmux records activity in whole seconds. The tile's width is read again
|
||||
// only after it's resized.
|
||||
// A session id is unique only within one tmux server, and a restarted server numbers its sessions from $0 again.
|
||||
// So each read also checks the server, and for a managed seat its identity, and anything else ends the view.
|
||||
'view() { t=$1; keys=$2; shift 2; last=; again=; d=$(printf "\\342\\224\\200"); nl=$(printf "\\nx"); nl=${nl%x};',
|
||||
'case "$t" in =*) p=;; *) p=${snapshot%% *};; esac; n=$node; cols=; rw=; rule=; trap "cols=" WINCH;',
|
||||
'while :; do',
|
||||
'if [ -n "$n" ]; then r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; show-environment -t "$t" OPENRIG_NODE_ID \\; capture-pane -p -e -t "$t" 2>/dev/null) || break;',
|
||||
'else r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; capture-pane -p -e -t "$t" 2>/dev/null) || break; fi;',
|
||||
'l=${r%%"$nl"*}; case "$r" in *"$nl"*) s=${r#*"$nl"};; *) s=;; esac; a=${l#* };',
|
||||
'[ -n "$a" ] && { [ -z "$p" ] || [ "${l%% *}" = "$p" ]; } || break;',
|
||||
'if [ -n "$n" ]; then [ "${s%%"$nl"*}" = "$n" ] || break; case "$s" in *"$nl"*) s=${s#*"$nl"};; *) s=;; esac; fi;',
|
||||
'if [ "$a" != "$last" ] || [ -n "$again" ]; then',
|
||||
'if [ "$a" != "$last" ]; then again=1; else again=; fi;',
|
||||
'case "$s" in *"$d$d$d$d"*) if [ -z "$cols" ]; then cols=$(stty size 2>/dev/null) || cols=; cols=${cols#* }; fi;',
|
||||
'case "$cols" in ""|*[!0-9]*) ;; *) if [ "$cols" -gt 0 ] && [ "$cols" -le 255 ]; then',
|
||||
'if [ "$cols" != "$rw" ]; then rule=$(printf "%${cols}s" "" | LC_ALL=C sed "s/ /$d/g"); rw=$cols; fi;',
|
||||
's=$(printf "%s\\n" "$s" | LC_ALL=C sed "s/\\($d\\)\\{$cols,\\}/$rule/g"); fi;; esac;; esac;',
|
||||
'printf "\\033[H\\033[2J%s\\n" "$s"; last=$a; fi;',
|
||||
// A writable tile waits for its next read on its own input, so the first key ends the wait at once. The input
|
||||
// goes to a file, which keeps every byte. A wait that can't run ends the view rather than reading without pause.
|
||||
'if [ -n "$keys" ]; then dd bs=4096 count=1 of="$f" 2>/dev/null || return 1; if [ -s "$f" ]; then return 0; fi;',
|
||||
'else sleep 1 || return 1; fi;',
|
||||
'done; return 1; };',
|
||||
// The first input goes through tmux as an attached client's does, and only while it's still the same seat. A
|
||||
// writable tile's terminal frames pastes while it shows, and until the tile attaches. The whole first burst is read
|
||||
// into a file, through any paste it opens, with a UTF-8 character it ends inside completed. Nothing passes through
|
||||
// a shell variable, so every byte is kept, NUL included, and flow-control and other special keys are read as keys.
|
||||
// - Keys alone go in the attach command itself, through the new client (send-keys -K): tmux runs them for that
|
||||
// client before anything typed since, so its key table applies, the prefix included. That needs a tmux 3.5 or later
|
||||
// server (3.3a has no -K, and 3.4 drops keys sent that way). The server's own #{version} decides, read just before
|
||||
// the attach: a newer tmux binary can still be talking to an older server. Below 3.5, or when the version can't be
|
||||
// read, they go to the pane before the tile attaches: a prefix typed first then reaches the seat instead of acting
|
||||
// as the prefix.
|
||||
// - A burst with a paste goes to the pane in order before the tile attaches: keys, then the paste (paste-buffer
|
||||
// frames it if the agent asked), then keys. tmux reads anything typed since only once the tile has attached.
|
||||
// The tile's terminal keeps that one mode (raw, paste framing on) from the first key until tmux takes the terminal at
|
||||
// attach, and tmux puts it back at detach; only a tile that doesn't attach restores it itself.
|
||||
// Without a terminal to read keys from, or a file to keep them in, a writable tile only views.
|
||||
'use() { t=$1; shift; f=$(mktemp 2>/dev/null) || f=;',
|
||||
'if [ -n "$f" ] && stty -icanon -echo -isig -iexten -ixon -icrnl min 0 time 10 2>/dev/null; then',
|
||||
'printf "\\033[?2004h"; view "$t" 1 "$@"; seen=$?; [ "$seen" -ne 0 ] || take;',
|
||||
'else view "$t" "" "$@"; seen=$?; fi;',
|
||||
'if [ "$seen" -ne 0 ] || ! here "$@"; then printf "\\033[?2004l"; stty sane 2>/dev/null; [ -z "$f" ] || rm -f "$f" "$f".*; return 1; fi;',
|
||||
'pz=; if LC_ALL=C grep -aqF -- "$b0" "$f"; then pz=1; put "$@"; fi;',
|
||||
'me=$(tty 2>/dev/null) || me=; w=; if [ -n "$me" ]; then idle "$me" "$@" & w=$!; fi;',
|
||||
'kk=; case "$("$@" display-message -p -t "$t" "#{version}" 2>/dev/null)" in next-*|master|[4-9].*|[1-9][0-9].*|3.[5-9]*|3.[1-9][0-9]*) kk=1;; esac;',
|
||||
'ng=1; if [ -z "$pz" ] && [ -s "$f" ]; then if [ -n "$kk" ]; then ng=; go "$@"; else kz 0 $(($(wc -c < "$f"))) "$@"; fi; fi; [ -z "$ng" ] || "$@" attach -t "$t";',
|
||||
'if [ -n "$w" ]; then kill "$w" 2>/dev/null; wait "$w" 2>/dev/null; fi; rm -f "$f" "$f".*; return 0; };',
|
||||
'take() { i=0; while [ "$i" -lt 256 ]; do',
|
||||
'if [ $(($(LC_ALL=C grep -abo -F -- "$b0" "$f" | wc -l))) -gt $(($(LC_ALL=C grep -abo -F -- "$b1" "$f" | wc -l))) ];',
|
||||
'then stty min 0 time 10 2>/dev/null; else stty min 0 time 1 2>/dev/null; i=$((i + 1)); fi;',
|
||||
'dd bs=4096 count=1 of="$f.n" 2>/dev/null && [ -s "$f.n" ] || break; cat "$f.n" >> "$f"; done; rm -f "$f.n";',
|
||||
'm=0; for c in $(tail -c 4 "$f" | od -An -tu1 -v); do',
|
||||
'if [ "$c" -lt 128 ]; then m=0; elif [ "$c" -lt 192 ]; then [ "$m" -eq 0 ] || m=$((m - 1)); elif [ "$c" -lt 224 ]; then m=1;',
|
||||
'elif [ "$c" -lt 240 ]; then m=2; elif [ "$c" -lt 248 ]; then m=3; else m=0; fi; done;',
|
||||
'if [ "$m" -gt 0 ]; then stty min 0 time 10 2>/dev/null; dd bs=1 count="$m" 2>/dev/null >> "$f"; fi; };',
|
||||
'here() { v=$("$@" display-message -p -t "$t" "#{pid} #{session_id}" 2>/dev/null) || return 1; [ -n "${v#* }" ] || return 1;',
|
||||
'[ -z "$p" ] || [ "${v%% *}" = "$p" ] || return 1;',
|
||||
'[ -z "$n" ] || [ "$("$@" show-environment -t "$t" OPENRIG_NODE_ID 2>/dev/null)" = "$n" ]; };',
|
||||
'kn() { case "$1" in 0) k=C-Space;; 9) k=Tab;; 13) k=Enter;; 27) k=Escape;; 28) k=C-\\\\;; 29) k=C-];; 30) k=C-^;; 31) k=C-_;; 127) k=BSpace;;',
|
||||
'*) k=C-$(printf "\\\\$(printf %o $(($1 + 96)))");; esac; };',
|
||||
// Keys alone: in the attach command, through the new client.
|
||||
'go() { set -- "$@" attach -t "$t"; x=0; r0=0; for c in $(od -An -tu1 -v "$f"); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi;',
|
||||
'kn "$c"; set -- "$@" \\; send-keys -K "$k"; r0=$((x + 1)); fi; x=$((x + 1)); done;',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi; "$@"; };',
|
||||
// tmux ends a command at an argument ending in ;, so a run of text keeps its last ; escaped.
|
||||
'tx() { u=$(dd if="$f" bs=1 skip="$1" count=$(($2 - $1)) 2>/dev/null); case "$u" in *";") u="${u%?}\\\\;";; esac; };',
|
||||
// A burst with a paste: each part to the pane, in order, before the tile attaches.
|
||||
'put() { z=$(($(wc -c < "$f"))); pos=0; j=0; while [ "$pos" -lt "$z" ]; do',
|
||||
'a=$(dd if="$f" bs=1 skip="$pos" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b0" | head -n 1); a=${a%%:*};',
|
||||
'if [ -z "$a" ]; then kz "$pos" "$z" "$@"; break; fi; kz "$pos" $((pos + a)) "$@"; a=$((pos + a + 6));',
|
||||
'e=$(dd if="$f" bs=1 skip="$a" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b1" | head -n 1); e=${e%%:*}; [ -n "$e" ] || e=$((z - a));',
|
||||
'j=$((j + 1)); dd if="$f" of="$f.p$j" bs=1 skip="$a" count="$e" 2>/dev/null;',
|
||||
'"$@" load-buffer -b "openrig-tile-$$-$j" "$f.p$j" \\; paste-buffer -p -d -b "openrig-tile-$$-$j" -t "$t" 2>/dev/null; pos=$((a + e + 6)); done; };',
|
||||
'kz() { u=$1; y=$2; shift 2; [ "$y" -gt "$u" ] || return 0; x=$u; r0=$u;',
|
||||
'for c in $(dd if="$f" bs=1 skip="$u" count=$((y - u)) 2>/dev/null | od -An -tu1 -v); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi;',
|
||||
'kn "$c"; "$@" send-keys -t "$t" "$k" 2>/dev/null; r0=$((x + 1)); fi; x=$((x + 1)); done;',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi; };',
|
||||
'idle() { me=$1; shift; while sleep 1; do',
|
||||
'a=$("$@" display-message -p -c "$me" "#{client_activity}" 2>/dev/null) || return 0;',
|
||||
`if [ $(($(date +%s) - a)) -ge ${idleSeconds} ]; then "$@" detach-client -t "$me" 2>/dev/null; return 0; fi;`,
|
||||
'done; };',
|
||||
open,
|
||||
'target=$1; shift;',
|
||||
// When the seat can't be read, any view ends as soon as it starts, and tmux's own error (or the shell's, when
|
||||
// tmux isn't found) says why.
|
||||
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || { open "=$target" "$@"; exec "$@" has-session -t "=$target"; };`,
|
||||
// display-message may succeed with empty/fallback context for a missing target.
|
||||
`if [ "\${snapshot#*:}" != "$target" ]; then open "=$target" "$@"; exec "$@" has-session -t "=$target"; fi;`,
|
||||
'snapshot=${snapshot%%:*};',
|
||||
'node=$("$@" show-environment -t "${snapshot#* }" OPENRIG_NODE_ID 2>/dev/null) || node=;',
|
||||
'case "$node" in OPENRIG_NODE_ID=?*) ;; *) node=;; esac;',
|
||||
'if [ -z "$node" ]; then',
|
||||
'printf "%s\\n" "No managed identity for $target; reopen this view after replacement.";',
|
||||
`open "\${snapshot#* }" "$@"; exit 0; fi;`,
|
||||
'while :; do',
|
||||
`open "\${snapshot#* }" "$@";`,
|
||||
`left=${retrySeconds};`,
|
||||
'while [ "$left" -gt 0 ]; do',
|
||||
'next=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || next=;',
|
||||
'if [ "${next#*:}" = "$target" ]; then next=${next%%:*}; else next=; fi;',
|
||||
'if [ -n "$next" ]; then',
|
||||
'identity=$("$@" show-environment -t "${next#* }" OPENRIG_NODE_ID 2>/dev/null) || identity=;',
|
||||
// A session can disappear between the two metadata reads; unknown waits.
|
||||
'if [ -n "$identity" ]; then',
|
||||
'if [ "$identity" != "$node" ]; then printf "%s\\n" "Seat identity changed for $target; reopen its view."; exit 1; fi;',
|
||||
// A view that ended while its session is still there (a failed read) views it again.
|
||||
'if [ "$next" = "$snapshot" ]; then sleep 1 || exit 1; fi; snapshot=$next; break;',
|
||||
'fi; fi;',
|
||||
'sleep 1; left=$((left - 1));',
|
||||
'done;',
|
||||
'if [ "$left" -eq 0 ]; then printf "%s\\n" "No replacement for $target; reopen the view with rig terminal open."; exit 0; fi;',
|
||||
'done',
|
||||
].join(" ");
|
||||
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
|
||||
}
|
||||
@@ -180,25 +180,28 @@ export class HerdrFollowView {
|
||||
// Validate ALL old identities before any change. One failed read retains
|
||||
// the whole view, rather than presenting an untrusted partial inventory.
|
||||
for (const tile of state.tiles) {
|
||||
let expectedTarget = tile.target;
|
||||
const currentMember = view.task.members.find(m => keyOf(m.key.hostId, m.key.nodeId) === tile.key);
|
||||
if (currentMember?.availability === "available") {
|
||||
const target = view.opened.find(p => p.seat === currentMember.seat);
|
||||
// An upgrade can change only the tile script; the kept tile then follows the current target.
|
||||
// Recognise an older generated script, but don't rewrite its saved target until the
|
||||
// actual viewer is verified. An old live writable client may require an explicit reopen.
|
||||
if (target?.attachArgv && tile.target.attachArgv && target.paneCommand !== tile.target.paneCommand
|
||||
&& target.readOnly === tile.target.readOnly && earlierTileAttachment(tile.target.attachArgv, target.attachArgv, target.readOnly)) {
|
||||
tile.target = target;
|
||||
expectedTarget = target;
|
||||
}
|
||||
if (!target || target.paneCommand !== tile.target.paneCommand || target.readOnly !== tile.target.readOnly
|
||||
|| JSON.stringify(target.attachArgv) !== JSON.stringify(tile.target.attachArgv)) {
|
||||
if (!target || target.paneCommand !== expectedTarget.paneCommand || target.readOnly !== expectedTarget.readOnly
|
||||
|| JSON.stringify(target.attachArgv) !== JSON.stringify(expectedTarget.attachArgv)) {
|
||||
throw new Error(`${tile.seat}: Core's current attachment differs from the retained viewer; keeping the previous view unconfirmed.`);
|
||||
}
|
||||
}
|
||||
let current: ViewerPane;
|
||||
try { current = await currentPane(tile.viewer); }
|
||||
catch (error) { throw new Error(`${tile.seat}: unknown: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
const verified = await viewers.lookup(tile.member, tile.target);
|
||||
const verified = await viewers.lookup(tile.member, expectedTarget);
|
||||
if (!verified.ok) throw new Error(`${tile.seat}: ${viewerFailure(tile.key, tile.target, verified)} Keeping the whole view.`);
|
||||
if (JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error(`${tile.seat}: Viewer mapping changed; keeping the whole view.`);
|
||||
tile.target = expectedTarget;
|
||||
delete state.viewerFailures?.[tile.key];
|
||||
observations.set(tile.key, { focused: current.focused!, input: await this.deps.input?.(tile.target) ?? "unknown" });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getDefaultOpenRigPath } from "../../openrig-compat.js";
|
||||
import { resolveOnPath } from "../../adapters/tmux-exec.js";
|
||||
import type { ComposedPane } from "./terminal-provider.js";
|
||||
import { earlierTileAttachment } from "./tile-attach.js";
|
||||
import { shellQuote } from "../../adapters/shell-quote.js";
|
||||
|
||||
export interface ViewerKey { hostId: string; nodeId: string }
|
||||
export interface ViewerMapping { workspaceId: string; paneId: string; terminalId: string; readOnly: boolean }
|
||||
@@ -176,13 +177,17 @@ export class HerdrViewer {
|
||||
const id = JSON.stringify([key.hostId, key.nodeId]);
|
||||
entry = journal.entries[id];
|
||||
let created = false;
|
||||
// An upgrade can change only the tile script (argv[2]); the seat it attaches is the same, so the kept
|
||||
// viewer moves to the current script.
|
||||
let reopen = false;
|
||||
let earlier = false;
|
||||
// Updating provenance alone cannot replace a live client. Keep old observers unchanged; a writable
|
||||
// legacy viewer is reopened explicitly, preserving the source agent and avoiding unowned client changes.
|
||||
if (entry && entry.command !== target.paneCommand && entry.readOnly === target.readOnly && Array.isArray(entry.argv)
|
||||
&& entry.command === entry.argv.map(shellQuote).join(" ")
|
||||
&& earlierTileAttachment(entry.argv, target.attachArgv, target.readOnly)) {
|
||||
entry.command = target.paneCommand; entry.argv = target.attachArgv; if (provision) save();
|
||||
earlier = true;
|
||||
reopen = !entry.readOnly;
|
||||
}
|
||||
if (entry && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
|
||||
if (entry && !earlier && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
|
||||
return unconfirmed("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
|
||||
}
|
||||
if (!entry) {
|
||||
@@ -235,6 +240,7 @@ export class HerdrViewer {
|
||||
delete journal.entries[id]; save();
|
||||
return { ok: false, code: "viewer_not_provisioned", error: "The old viewer is confirmed absent; ensure again to create a verified replacement." };
|
||||
}
|
||||
if (reopen) return unconfirmed(`Close only the owned Herdr viewer pane ${current.pane_id}, then reopen this view with rig terminal open. The agent's tmux session is preserved; its old client cannot be migrated by changing saved argv alone.`);
|
||||
if (entry.phase === "created") {
|
||||
if (!provision) return unconfirmed("The created viewer process is not yet confirmed.");
|
||||
if (!await stillCurrent()) return unconfirmed("Task membership changed before the viewer process started.");
|
||||
|
||||
@@ -1,155 +1,24 @@
|
||||
import { tileAttachArgv078, tileAttachArgv079, tileAttachArgv0710 } from "./earlier-tile-attach.js";
|
||||
import { tileAttachArgv0711 } from "./earlier-tile-attach-0711.js";
|
||||
|
||||
// A tile owns its view and attach processes; no daemon watcher. Its view reads the seat only after new activity.
|
||||
// Session names survive fresh launch, handover and restart. The node identity
|
||||
// must survive too: never follow a same-name session belonging to another seat.
|
||||
/** Writable tiles are native sizing clients: tmux's latest active client controls the PTY, including on resize.
|
||||
* Once attached, tmux handles keys/paste directly; no capture mirror, replay buffer or idle detacher.
|
||||
* Cross-rig read-only tiles retain their observer view and never type into or resize the source seat. */
|
||||
export function tileAttachArgv(
|
||||
tmux: string, server: readonly string[], session: string, readOnly: boolean,
|
||||
retrySeconds = 30, idleSeconds = 30,
|
||||
): string[] {
|
||||
// A tmux window has one size, and a client narrower than it sees only the left part of each line. So a tile shows the
|
||||
// seat by mirroring it rather than attaching, and attaches only while the person types in it.
|
||||
// - view: the seat's own screen, printed into the tile, whose terminal wraps each line at the tile's width. The tile is
|
||||
// then no tmux client of the seat, so it never sizes, reflows or types into it. The screen is read again only when
|
||||
// tmux records new activity in the window (read each second), and the view ends when the session goes, or, for a
|
||||
// writable tile, when the person presses a key.
|
||||
// - use (writable tiles): that first key goes to the seat and the tile attaches, sizing the seat while it's used, as
|
||||
// any client the person types in does. Once the tile has had no key for idleSeconds it detaches back to the view,
|
||||
// as any other detach of it does.
|
||||
// - A read-only tile only views.
|
||||
const open = readOnly
|
||||
? 'open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };'
|
||||
: 'open() { t=$1; shift; while use "$t" "$@"; do :; done; };';
|
||||
// No apostrophes or control characters in the script: Herdr also stores this
|
||||
// argv as its restart command. All external values stay positional arguments.
|
||||
const script = [
|
||||
// Files the script makes (the first input) are readable by this user alone.
|
||||
'umask 077; b0=$(printf "\\033[200~"); b1=$(printf "\\033[201~");',
|
||||
// A box-drawing rule (Claude Code draws them as wide as its window) is cut to the tile's width, so a narrow tile
|
||||
// doesn't spend most of its rows on one wrapped rule. The cut works on bytes, so no character can blank the view.
|
||||
// One tmux call a second reads the activity time and the screen together; the screen is drawn only after new
|
||||
// activity, and once more after it, since tmux records activity in whole seconds. The tile's width is read again
|
||||
// only after it's resized.
|
||||
// A session id is unique only within one tmux server, and a restarted server numbers its sessions from $0 again.
|
||||
// So each read also checks the server, and for a managed seat its identity, and anything else ends the view.
|
||||
'view() { t=$1; keys=$2; shift 2; last=; again=; d=$(printf "\\342\\224\\200"); nl=$(printf "\\nx"); nl=${nl%x};',
|
||||
'case "$t" in =*) p=;; *) p=${snapshot%% *};; esac; n=$node; cols=; rw=; rule=; trap "cols=" WINCH;',
|
||||
'while :; do',
|
||||
'if [ -n "$n" ]; then r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; show-environment -t "$t" OPENRIG_NODE_ID \\; capture-pane -p -e -t "$t" 2>/dev/null) || break;',
|
||||
'else r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; capture-pane -p -e -t "$t" 2>/dev/null) || break; fi;',
|
||||
'l=${r%%"$nl"*}; case "$r" in *"$nl"*) s=${r#*"$nl"};; *) s=;; esac; a=${l#* };',
|
||||
'[ -n "$a" ] && { [ -z "$p" ] || [ "${l%% *}" = "$p" ]; } || break;',
|
||||
'if [ -n "$n" ]; then [ "${s%%"$nl"*}" = "$n" ] || break; case "$s" in *"$nl"*) s=${s#*"$nl"};; *) s=;; esac; fi;',
|
||||
'if [ "$a" != "$last" ] || [ -n "$again" ]; then',
|
||||
'if [ "$a" != "$last" ]; then again=1; else again=; fi;',
|
||||
'case "$s" in *"$d$d$d$d"*) if [ -z "$cols" ]; then cols=$(stty size 2>/dev/null) || cols=; cols=${cols#* }; fi;',
|
||||
'case "$cols" in ""|*[!0-9]*) ;; *) if [ "$cols" -gt 0 ] && [ "$cols" -le 255 ]; then',
|
||||
'if [ "$cols" != "$rw" ]; then rule=$(printf "%${cols}s" "" | LC_ALL=C sed "s/ /$d/g"); rw=$cols; fi;',
|
||||
's=$(printf "%s\\n" "$s" | LC_ALL=C sed "s/\\($d\\)\\{$cols,\\}/$rule/g"); fi;; esac;; esac;',
|
||||
'printf "\\033[H\\033[2J%s\\n" "$s"; last=$a; fi;',
|
||||
// A writable tile waits for its next read on its own input, so the first key ends the wait at once. The input
|
||||
// goes to a file, which keeps every byte. A wait that can't run ends the view rather than reading without pause.
|
||||
'if [ -n "$keys" ]; then dd bs=4096 count=1 of="$f" 2>/dev/null || return 1; if [ -s "$f" ]; then return 0; fi;',
|
||||
'else sleep 1 || return 1; fi;',
|
||||
'done; return 1; };',
|
||||
// The first input goes through tmux as an attached client's does, and only while it's still the same seat. A
|
||||
// writable tile's terminal frames pastes while it shows, and until the tile attaches. The whole first burst is read
|
||||
// into a file, through any paste it opens, with a UTF-8 character it ends inside completed. Nothing passes through
|
||||
// a shell variable, so every byte is kept, NUL included, and flow-control and other special keys are read as keys.
|
||||
// - Keys alone go in the attach command itself, through the new client (send-keys -K): tmux runs them for that
|
||||
// client before anything typed since, so its key table applies, the prefix included.
|
||||
// - A burst with a paste goes to the pane in order before the tile attaches: keys, then the paste (paste-buffer
|
||||
// frames it if the agent asked), then keys. tmux reads anything typed since only once the tile has attached.
|
||||
// The tile's terminal keeps that one mode (raw, paste framing on) from the first key until tmux takes the terminal at
|
||||
// attach, and tmux puts it back at detach; only a tile that doesn't attach restores it itself.
|
||||
// Without a terminal to read keys from, or a file to keep them in, a writable tile only views.
|
||||
'use() { t=$1; shift; f=$(mktemp 2>/dev/null) || f=;',
|
||||
'if [ -n "$f" ] && stty -icanon -echo -isig -iexten -ixon -icrnl min 0 time 10 2>/dev/null; then',
|
||||
'printf "\\033[?2004h"; view "$t" 1 "$@"; seen=$?; [ "$seen" -ne 0 ] || take;',
|
||||
'else view "$t" "" "$@"; seen=$?; fi;',
|
||||
'if [ "$seen" -ne 0 ] || ! here "$@"; then printf "\\033[?2004l"; stty sane 2>/dev/null; [ -z "$f" ] || rm -f "$f" "$f".*; return 1; fi;',
|
||||
'pz=; if LC_ALL=C grep -aqF -- "$b0" "$f"; then pz=1; put "$@"; fi;',
|
||||
'me=$(tty 2>/dev/null) || me=; w=; if [ -n "$me" ]; then idle "$me" "$@" & w=$!; fi; printf "\\033[?2004l";',
|
||||
'if [ -z "$pz" ] && [ -s "$f" ]; then go "$@"; else "$@" attach -t "$t"; fi;',
|
||||
'if [ -n "$w" ]; then kill "$w" 2>/dev/null; wait "$w" 2>/dev/null; fi; rm -f "$f" "$f".*; return 0; };',
|
||||
'take() { i=0; while [ "$i" -lt 256 ]; do',
|
||||
'if [ $(($(LC_ALL=C grep -abo -F -- "$b0" "$f" | wc -l))) -gt $(($(LC_ALL=C grep -abo -F -- "$b1" "$f" | wc -l))) ];',
|
||||
'then stty min 0 time 10 2>/dev/null; else stty min 0 time 1 2>/dev/null; i=$((i + 1)); fi;',
|
||||
'dd bs=4096 count=1 of="$f.n" 2>/dev/null && [ -s "$f.n" ] || break; cat "$f.n" >> "$f"; done; rm -f "$f.n";',
|
||||
'm=0; for c in $(tail -c 4 "$f" | od -An -tu1 -v); do',
|
||||
'if [ "$c" -lt 128 ]; then m=0; elif [ "$c" -lt 192 ]; then [ "$m" -eq 0 ] || m=$((m - 1)); elif [ "$c" -lt 224 ]; then m=1;',
|
||||
'elif [ "$c" -lt 240 ]; then m=2; elif [ "$c" -lt 248 ]; then m=3; else m=0; fi; done;',
|
||||
'if [ "$m" -gt 0 ]; then stty min 0 time 10 2>/dev/null; dd bs=1 count="$m" 2>/dev/null >> "$f"; fi; };',
|
||||
'here() { v=$("$@" display-message -p -t "$t" "#{pid} #{session_id}" 2>/dev/null) || return 1; [ -n "${v#* }" ] || return 1;',
|
||||
'[ -z "$p" ] || [ "${v%% *}" = "$p" ] || return 1;',
|
||||
'[ -z "$n" ] || [ "$("$@" show-environment -t "$t" OPENRIG_NODE_ID 2>/dev/null)" = "$n" ]; };',
|
||||
'kn() { case "$1" in 0) k=C-Space;; 9) k=Tab;; 13) k=Enter;; 27) k=Escape;; 28) k=C-\\\\;; 29) k=C-];; 30) k=C-^;; 31) k=C-_;; 127) k=BSpace;;',
|
||||
'*) k=C-$(printf "\\\\$(printf %o $(($1 + 96)))");; esac; };',
|
||||
// Keys alone: in the attach command, through the new client.
|
||||
'go() { set -- "$@" attach -t "$t"; x=0; r0=0; for c in $(od -An -tu1 -v "$f"); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi;',
|
||||
'kn "$c"; set -- "$@" \\; send-keys -K "$k"; r0=$((x + 1)); fi; x=$((x + 1)); done;',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi; "$@"; };',
|
||||
// tmux ends a command at an argument ending in ;, so a run of text keeps its last ; escaped.
|
||||
'tx() { u=$(dd if="$f" bs=1 skip="$1" count=$(($2 - $1)) 2>/dev/null); case "$u" in *";") u="${u%?}\\\\;";; esac; };',
|
||||
// A burst with a paste: each part to the pane, in order, before the tile attaches.
|
||||
'put() { z=$(($(wc -c < "$f"))); pos=0; j=0; while [ "$pos" -lt "$z" ]; do',
|
||||
'a=$(dd if="$f" bs=1 skip="$pos" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b0" | head -n 1); a=${a%%:*};',
|
||||
'if [ -z "$a" ]; then kz "$pos" "$z" "$@"; break; fi; kz "$pos" $((pos + a)) "$@"; a=$((pos + a + 6));',
|
||||
'e=$(dd if="$f" bs=1 skip="$a" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b1" | head -n 1); e=${e%%:*}; [ -n "$e" ] || e=$((z - a));',
|
||||
'j=$((j + 1)); dd if="$f" of="$f.p$j" bs=1 skip="$a" count="$e" 2>/dev/null;',
|
||||
'"$@" load-buffer -b "openrig-tile-$$-$j" "$f.p$j" \\; paste-buffer -p -d -b "openrig-tile-$$-$j" -t "$t" 2>/dev/null; pos=$((a + e + 6)); done; };',
|
||||
'kz() { u=$1; y=$2; shift 2; [ "$y" -gt "$u" ] || return 0; x=$u; r0=$u;',
|
||||
'for c in $(dd if="$f" bs=1 skip="$u" count=$((y - u)) 2>/dev/null | od -An -tu1 -v); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi;',
|
||||
'kn "$c"; "$@" send-keys -t "$t" "$k" 2>/dev/null; r0=$((x + 1)); fi; x=$((x + 1)); done;',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi; };',
|
||||
'idle() { me=$1; shift; while sleep 1; do',
|
||||
'a=$("$@" display-message -p -c "$me" "#{client_activity}" 2>/dev/null) || return 0;',
|
||||
`if [ $(($(date +%s) - a)) -ge ${idleSeconds} ]; then "$@" detach-client -t "$me" 2>/dev/null; return 0; fi;`,
|
||||
'done; };',
|
||||
open,
|
||||
'target=$1; shift;',
|
||||
// When the seat can't be read, any view ends as soon as it starts, and tmux's own error (or the shell's, when
|
||||
// tmux isn't found) says why.
|
||||
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || { open "=$target" "$@"; exec "$@" has-session -t "=$target"; };`,
|
||||
// display-message may succeed with empty/fallback context for a missing target.
|
||||
`if [ "\${snapshot#*:}" != "$target" ]; then open "=$target" "$@"; exec "$@" has-session -t "=$target"; fi;`,
|
||||
'snapshot=${snapshot%%:*};',
|
||||
'node=$("$@" show-environment -t "${snapshot#* }" OPENRIG_NODE_ID 2>/dev/null) || node=;',
|
||||
'case "$node" in OPENRIG_NODE_ID=?*) ;; *) node=;; esac;',
|
||||
'if [ -z "$node" ]; then',
|
||||
'printf "%s\\n" "No managed identity for $target; reopen this view after replacement.";',
|
||||
`open "\${snapshot#* }" "$@"; exit 0; fi;`,
|
||||
'while :; do',
|
||||
`open "\${snapshot#* }" "$@";`,
|
||||
`left=${retrySeconds};`,
|
||||
'while [ "$left" -gt 0 ]; do',
|
||||
'next=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || next=;',
|
||||
'if [ "${next#*:}" = "$target" ]; then next=${next%%:*}; else next=; fi;',
|
||||
'if [ -n "$next" ]; then',
|
||||
'identity=$("$@" show-environment -t "${next#* }" OPENRIG_NODE_ID 2>/dev/null) || identity=;',
|
||||
// A session can disappear between the two metadata reads; unknown waits.
|
||||
'if [ -n "$identity" ]; then',
|
||||
'if [ "$identity" != "$node" ]; then printf "%s\\n" "Seat identity changed for $target; reopen its view."; exit 1; fi;',
|
||||
// A view that ended while its session is still there (a failed read) views it again.
|
||||
'if [ "$next" = "$snapshot" ]; then sleep 1 || exit 1; fi; snapshot=$next; break;',
|
||||
'fi; fi;',
|
||||
'sleep 1; left=$((left - 1));',
|
||||
'done;',
|
||||
'if [ "$left" -eq 0 ]; then printf "%s\\n" "No replacement for $target; reopen the view with rig terminal open."; exit 0; fi;',
|
||||
'done',
|
||||
].join(" ");
|
||||
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
|
||||
return readOnly ? tileAttachArgv0711(tmux, server, session, true, retrySeconds, idleSeconds)
|
||||
: tileAttachArgv0710(tmux, server, session, false, retrySeconds);
|
||||
}
|
||||
|
||||
/** Whether a kept tile is the one an earlier release generated for the same seat: exactly the command 0.7.10's, 0.7.9's,
|
||||
/** Whether a kept tile is the one an earlier release generated for the same seat: exactly the command pre-reflow 0.7.11's, 0.7.10's, 0.7.9's,
|
||||
* or 0.7.5-0.7.8's, script makes for the current tile's session, tmux, server and read-only intent. An upgrade changes
|
||||
* only OpenRig's own script; any other difference, an edited script included, is a different attachment. */
|
||||
export function earlierTileAttachment(kept: readonly string[], current: readonly string[], readOnly: boolean): boolean {
|
||||
if (current.length < 6) return false;
|
||||
const [session, tmux, ...server] = current.slice(4) as [string, string, ...string[]];
|
||||
return [tileAttachArgv0710, tileAttachArgv079, tileAttachArgv078].some((make) => {
|
||||
return [tileAttachArgv0711, tileAttachArgv0710, tileAttachArgv079, tileAttachArgv078].some((make) => {
|
||||
const earlier = make(tmux, server, session, readOnly);
|
||||
return earlier.length === kept.length && earlier.every((word, i) => word === kept[i]);
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ export const SETTINGS_VALID_KEYS = [
|
||||
"terminal.tmux_server",
|
||||
// The managed ~/.tmux.conf block's history-limit; written by `rig setup` (CLI), twin of the CLI key.
|
||||
"terminal.history_limit",
|
||||
"terminal.fixed_geometry",
|
||||
"terminal.inherit_tmux_environment",
|
||||
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults; CLI-read, twin of the CLI key.
|
||||
"output.compact",
|
||||
@@ -334,6 +335,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
|
||||
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
|
||||
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
|
||||
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
|
||||
"terminal.fixed_geometry": { primary: "OPENRIG_TERMINAL_FIXED_GEOMETRY" },
|
||||
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
|
||||
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
|
||||
"output.whoami_peers": { primary: "OPENRIG_OUTPUT_WHOAMI_PEERS" },
|
||||
@@ -439,6 +441,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"terminal.status_bar": ["terminal", "statusBar"],
|
||||
"terminal.tmux_server": ["terminal", "tmuxServer"],
|
||||
"terminal.history_limit": ["terminal", "historyLimit"],
|
||||
"terminal.fixed_geometry": ["terminal", "fixedGeometry"],
|
||||
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
|
||||
"output.compact": ["output", "compact"],
|
||||
"output.whoami_peers": ["output", "whoamiPeers"],
|
||||
@@ -754,6 +757,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
case "terminal.status_bar": return false;
|
||||
case "terminal.tmux_server": return "inherit";
|
||||
case "terminal.history_limit": return 10000;
|
||||
case "terminal.fixed_geometry": return false;
|
||||
case "terminal.inherit_tmux_environment": return false;
|
||||
case "output.compact": return false;
|
||||
case "output.whoami_peers": return 8;
|
||||
|
||||
@@ -56,7 +56,10 @@ import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/hosts-registry-writer.js";
|
||||
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry, type SshHostEntry } from "../domain/hosts/hosts-registry-reader.js";
|
||||
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
|
||||
import type { QueueRepository } from "../domain/queue-repository.js";
|
||||
import { UNATTRIBUTED_ACTOR, type QueueItem, type QueueRepository } from "../domain/queue-repository.js";
|
||||
import { KERNEL_OPERATOR_SESSION } from "../domain/policies/mode-scheduled-end.js";
|
||||
import type { HumanQuestion } from "../domain/human-questions.js";
|
||||
import { PAIR_SOURCE_SESSION } from "../domain/hosts/pairing-ask.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { loadHumanRegistry, type LoadResult } from "../domain/gateway/human-registry.js";
|
||||
import { resolveDefaultPerson } from "../domain/gateway/participants.js";
|
||||
@@ -89,7 +92,34 @@ function describePairTtl(ms: number): string {
|
||||
}
|
||||
const PAIR_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
const PAIR_SOURCE_SESSION = "host-pair@kernel";
|
||||
/** The pairing request's one question. A person answers it with a click or in their own words. Where an agent reads
|
||||
* answers (the request's owner), both are context it acts on with `rig host approve|deny`; on an agent-free host the
|
||||
* person's click is the decision. */
|
||||
const PAIR_QUESTION_ID = "pair";
|
||||
const PAIR_APPROVE = "approve";
|
||||
function pairQuestion(requester: string, code: string): HumanQuestion[] {
|
||||
return [{ id: PAIR_QUESTION_ID, question: `Pair ${requester} (code ${code}) with this host?`, options: [
|
||||
{ id: PAIR_APPROVE, label: "Approve: hand this host's bearer token to the requester" },
|
||||
{ id: "deny", label: "Deny" },
|
||||
] }];
|
||||
}
|
||||
|
||||
/** A close that refuses the request: a denied, canceled or failed state; a done close with any reason but no-follow-on
|
||||
* (Steering's Deny and Drop, an agent seat's `--closure-reason denied`); or a done close that carries no answer and
|
||||
* doesn't name Approve (an agent seat's bare close, a bare Approve verb). A close that carries the person's answer, a
|
||||
* click, their words, or a reply with no words (`answered`), refuses nothing by itself. */
|
||||
function pairClosedRefusing(item: QueueItem | null, answered = item?.humanAnswers?.[PAIR_QUESTION_ID] != null): boolean {
|
||||
if (!item) return false;
|
||||
if (item.state === "denied" || item.state === "canceled" || item.state === "failed") return true;
|
||||
return item.state === "done" && (item.closureReason !== "no-follow-on" || (!answered && item.resolution?.trim() !== PAIR_APPROVE));
|
||||
}
|
||||
|
||||
/** An agent seat's close that names Approve on a request nobody has answered (`rig queue update <id> --state done
|
||||
* --closure-reason no-follow-on --resolution approve`, the option-id answer ACP rows use). */
|
||||
function pairClosedApproving(item: QueueItem | null): boolean {
|
||||
return !!item && item.state === "done" && item.closureReason === "no-follow-on" && item.resolution?.trim() === PAIR_APPROVE
|
||||
&& item.humanAnswers?.[PAIR_QUESTION_ID] == null;
|
||||
}
|
||||
|
||||
interface IssuedPair {
|
||||
code: string;
|
||||
@@ -97,6 +127,20 @@ interface IssuedPair {
|
||||
requester: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
/** The person asked. */
|
||||
person: string;
|
||||
/** The seat that reads the person's answer and decides (the kernel operator); null when this host is agent-free, where
|
||||
* the person's own click, or `rig host approve|deny` in a terminal, decides. */
|
||||
owner: string | null;
|
||||
/** The explicit decision (`rig host approve|deny`), made once. */
|
||||
decision?: { approve: boolean; by: string };
|
||||
/** The person has been told their answer reached the owner. */
|
||||
receipted?: boolean;
|
||||
/** The expiry has been settled (its ask closed, the person told), by the poll or the expiry timer. */
|
||||
expirySettled?: boolean;
|
||||
/** Why an agent-free request has no owner: this host has no kernel operator, the queue can't take rows to it, or
|
||||
* OpenRig couldn't read which. */
|
||||
freeReason?: "none" | "unroutable" | "unknown";
|
||||
}
|
||||
|
||||
interface ClientPair {
|
||||
@@ -130,11 +174,133 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
router.use("/remove", requireAuth);
|
||||
router.use("/pair", requireAuth);
|
||||
router.use("/pair/:pairId", requireAuth);
|
||||
router.use("/pair-decision", requireAuth);
|
||||
|
||||
function getRepo(c: { get: (key: string) => unknown }): QueueRepository {
|
||||
return c.get("queueRepo" as never) as QueueRepository;
|
||||
}
|
||||
|
||||
/** Who reads a person's answer to a pairing request here: the kernel operator, when this host has one (a session, in any
|
||||
* state) and the queue takes rows to it; else nobody, and the request is agent-free, with the reason. A stopped
|
||||
* operator still owns it: the person's answer waits in its queue, and the person is told it isn't running. An operator
|
||||
* the queue can't take rows to can't own it: the person's answer would be refused on its way there. */
|
||||
function pairOwner(c: { get: (key: string) => unknown }): Pick<IssuedPair, "owner" | "freeReason"> {
|
||||
const repo = getRepo(c);
|
||||
try {
|
||||
if (!repo.db.prepare("SELECT 1 FROM sessions WHERE session_name = ? LIMIT 1").get(KERNEL_OPERATOR_SESSION)) return { owner: null, freeReason: "none" };
|
||||
return repo.acceptsDestination(KERNEL_OPERATOR_SESSION) ? { owner: KERNEL_OPERATOR_SESSION } : { owner: null, freeReason: "unroutable" };
|
||||
} catch { return { owner: null, freeReason: "unknown" }; }
|
||||
}
|
||||
|
||||
/** Why nobody reads answers on an agent-free request, as the person is told it. */
|
||||
function noReader(rec: IssuedPair): string {
|
||||
return rec.freeReason === "unroutable" ? `${KERNEL_OPERATOR_SESSION} can't receive answers on this host`
|
||||
: rec.freeReason === "unknown" ? "OpenRig couldn't tell whether an agent reads answers on this host"
|
||||
: `no agent reads answers on this host (it has no ${KERNEL_OPERATOR_SESSION})`;
|
||||
}
|
||||
|
||||
/** Whether the owner's session is running now; null when that can't be read. */
|
||||
function ownerRunning(c: { get: (key: string) => unknown }, owner: string): boolean | null {
|
||||
try { return !!getRepo(c).db.prepare("SELECT 1 FROM sessions WHERE session_name = ? AND status = 'running' LIMIT 1").get(owner); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/** The person's own reply closed the request (the direct-reply close, marked human-decision-resolved), whatever it
|
||||
* carried: words, or no words at all (a file). When the close can't be read, it isn't taken as a refusal. */
|
||||
function closedByReply(c: { get: (key: string) => unknown }, item: QueueItem | null): boolean {
|
||||
if (item?.state !== "done" || item.closureReason !== "no-follow-on") return false;
|
||||
try { return getRepo(c).closingTransition(item.qitemId, "done")?.ownerNotificationKind === "human-decision-resolved"; }
|
||||
catch { return true; }
|
||||
}
|
||||
|
||||
/** What the request's own row has decided, read the same way by the poll and by `rig host approve|deny`: a refusing
|
||||
* close, an agent seat's close naming Approve, or, on an agent-free host, the person's Approve or Deny click. Null
|
||||
* while it's undecided; a person's reply never decides it. */
|
||||
function rowDecision(c: { get: (key: string) => unknown }, rec: IssuedPair, item: QueueItem | null): boolean | null {
|
||||
const answer = item?.humanAnswers?.[PAIR_QUESTION_ID];
|
||||
if (pairClosedRefusing(item, answer != null || closedByReply(c, item)) || (!rec.owner && answer === "deny")) return false;
|
||||
if (pairClosedApproving(item) || (!rec.owner && answer === PAIR_APPROVE)) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ask the person to decide a pairing request: its approval row, with the one question. `lead` opens the body when it's
|
||||
* asked again. Returns the row's id. */
|
||||
async function askPerson(c: { get: (key: string) => unknown }, pairId: string, rec: IssuedPair, lead: string[] = []): Promise<string> {
|
||||
const { owner, code, requester } = rec;
|
||||
const item = await getRepo(c).create({
|
||||
// The owner asks, so the person's answer, a click or their words, comes back to it as an ordinary reply. An
|
||||
// agent-free request keeps the synthetic source, and its answers stay on the request (createHumanAnswer).
|
||||
sourceSession: owner ?? PAIR_SOURCE_SESSION,
|
||||
destinationSession: rec.person,
|
||||
tier: "human-gate",
|
||||
// An approval that expires: worth an alert, not only a place on Steering.
|
||||
humanAlert: true,
|
||||
summary: `Host pairing request ${code} from ${requester}`,
|
||||
evidenceRef: `pair-request:${pairId}`,
|
||||
humanQuestions: pairQuestion(requester, code),
|
||||
body: [
|
||||
...lead,
|
||||
`A remote operator (${requester}) is asking to pair with this host. "${requester}" is how the requesting machine describes itself.`,
|
||||
`Pairing code: ${code} — confirm it matches the code shown on the requesting side.`,
|
||||
...(owner ? [
|
||||
`Answer with Approve or Deny, or in your own words. ${owner} reads your answer and acts on it: a clear yes pairs, a clear no denies, and if your answer isn't clear it asks you back.`,
|
||||
...((running) => running === true ? [] : [`${running === false ? `${owner} isn't running right now, so your answer waits` : `OpenRig couldn't tell whether ${owner} is running, so your answer may wait`} until it starts; nothing is granted or denied meanwhile. To decide sooner, in a terminal on this host with its operator bearer token: rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`])(ownerRunning(c, owner)),
|
||||
`For ${owner}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming pairing code ${code}, and act on what they answer.`,
|
||||
] : [
|
||||
`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: ${noReader(rec)}.`,
|
||||
`To decide in a terminal on this host with its operator bearer token: rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`,
|
||||
]),
|
||||
`Approval hands this daemon's bearer token to the requester (full API access).`,
|
||||
`This request expires ${describePairTtl(rec.expiresAt - rec.createdAt)} after creation, at ${new Date(rec.expiresAt).toISOString().slice(0, 19).replace("T", " ")} UTC; expiry persists nothing.`,
|
||||
].join("\n"),
|
||||
});
|
||||
return item.qitemId;
|
||||
}
|
||||
|
||||
/** A decided or expired request: close its approval row if nobody has answered it, so it leaves the person's feed (an
|
||||
* expired one reads canceled, so a late click there can't look accepted), and tell the person the result. The
|
||||
* decision stands whatever this manages: each step is best effort. An expired request the person never answered is
|
||||
* closed without a message. */
|
||||
async function settlePairRequest(repo: QueueRepository, rec: IssuedPair, outcome: "approved" | "denied" | "expired", by: string, handedNow = false): Promise<void> {
|
||||
if (outcome === "expired") {
|
||||
if (rec.expirySettled) return;
|
||||
rec.expirySettled = true;
|
||||
}
|
||||
const item = rec.qitemId ? repo.getById(rec.qitemId) : null;
|
||||
const open = item?.state === "pending" || item?.state === "in-progress" || item?.state === "blocked";
|
||||
try {
|
||||
if (open) await repo.update(outcome === "expired"
|
||||
? { qitemId: rec.qitemId, actorSession: PAIR_SOURCE_SESSION, state: "canceled", transitionNote: `pairing request ${rec.code} expired undecided` }
|
||||
: { qitemId: rec.qitemId, actorSession: by, state: "done", closureReason: "no-follow-on", resolution: `pairing ${outcome} by ${by}`,
|
||||
transitionNote: `pairing request ${rec.code} ${outcome} by ${by}` });
|
||||
} catch { /* the decision stands */ }
|
||||
if (outcome === "expired" && open) return;
|
||||
// An expired request whose answer still sits unclaimed in the owner's queue: say the owner didn't pick it up.
|
||||
let unread = false;
|
||||
if (outcome === "expired" && rec.owner && rec.qitemId) {
|
||||
try { unread = !!repo.db.prepare("SELECT 1 FROM queue_items WHERE reply_to = ? AND destination_session = ? AND state = 'pending' LIMIT 1").get(rec.qitemId, rec.owner); } catch { /* can't tell */ }
|
||||
}
|
||||
const what = outcome === "approved" ? `approved by ${by}. ${handedNow ? "This host's token was handed to the requesting machine." : "This host's token is handed over when the requesting machine next checks, if it's still waiting."}`
|
||||
: outcome === "denied" ? `denied by ${by}. Nothing was handed over.`
|
||||
: rec.decision ? `expired after it was ${rec.decision.approve ? "approved" : "denied"} by ${rec.decision.by}, before the requesting machine collected the result. Nothing was handed over; the requester can run rig host pair again.`
|
||||
: `expired before it was decided${unread ? `: ${rec.owner} didn't pick up your answer` : ""}. Nothing was handed over; the requester can run rig host pair again.`;
|
||||
try {
|
||||
await repo.create({ sourceSession: rec.owner ?? PAIR_SOURCE_SESSION, destinationSession: rec.person, humanIntent: "update",
|
||||
summary: `Pairing ${rec.code} from ${rec.requester}: ${outcome}`, body: `Pairing request ${rec.code} from ${rec.requester}: ${what}` });
|
||||
} catch { /* the decision stands */ }
|
||||
}
|
||||
|
||||
/** At expiry, settle the request even when its requester stopped polling: its ask closes as expired, so a late click
|
||||
* there can't look accepted, and an answered one tells the person. Unref'd; a consumed request is left alone. */
|
||||
function armExpiry(repo: QueueRepository, pairId: string, rec: IssuedPair): void {
|
||||
const timer = setTimeout(() => {
|
||||
if (issued.get(pairId) !== rec) return;
|
||||
if (Date.now() <= rec.expiresAt) return armExpiry(repo, pairId, rec);
|
||||
void settlePairRequest(repo, rec, "expired", PAIR_SOURCE_SESSION).catch(() => { /* best effort */ });
|
||||
}, Math.min(Math.max(rec.expiresAt - Date.now(), 0) + 1, MAX_PAIR_TTL_MS));
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pointers-only READ for the dashboard host-config component (FR-5).
|
||||
// Not a write surface (P1's cap is the write family); rows mirror the
|
||||
@@ -247,30 +413,14 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
// Retain the request while its approval item is being created; remove it on failure.
|
||||
const ttlMs = pairTtlMs(body.timeoutMs);
|
||||
const createdAt = Date.now();
|
||||
issued.set(pairId, { code, qitemId: "", requester, createdAt, expiresAt: createdAt + ttlMs });
|
||||
const rec: IssuedPair = { code, qitemId: "", requester, createdAt, expiresAt: createdAt + ttlMs, person: humans[0]!.address, ...pairOwner(c) };
|
||||
issued.set(pairId, rec);
|
||||
|
||||
let qitemId: string;
|
||||
try {
|
||||
const item = await getRepo(c).create({
|
||||
sourceSession: PAIR_SOURCE_SESSION,
|
||||
destinationSession: humans[0]!.address,
|
||||
tier: "human-gate",
|
||||
// An approval that expires: worth an alert, not only a place on Steering.
|
||||
humanAlert: true,
|
||||
summary: `Host pairing request ${code} from ${requester}`,
|
||||
evidenceRef: `pair-request:${pairId}`,
|
||||
body: [
|
||||
`A remote operator (${requester}) is asking to pair with this host.`,
|
||||
`Pairing code: ${code} — confirm it matches the code shown on the requesting side.`,
|
||||
`APPROVE: rig queue update <this-qitem-id> --state done --closure-reason no-follow-on`,
|
||||
`DENY: rig queue update <this-qitem-id> --state denied`,
|
||||
`Approval hands this daemon's bearer token to the requester (full API access).`,
|
||||
`This request expires ${describePairTtl(ttlMs)} after creation, at ${new Date(createdAt + ttlMs).toISOString().slice(0, 19).replace("T", " ")} UTC; expiry persists nothing.`,
|
||||
].join("\n"),
|
||||
});
|
||||
qitemId = item.qitemId;
|
||||
const rec = issued.get(pairId);
|
||||
if (rec) rec.qitemId = qitemId;
|
||||
qitemId = await askPerson(c, pairId, rec);
|
||||
rec.qitemId = qitemId;
|
||||
armExpiry(getRepo(c), pairId, rec);
|
||||
} catch (err) {
|
||||
issued.delete(pairId);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -280,27 +430,108 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
return c.json({ pairId, code, approvalQitemId: qitemId });
|
||||
});
|
||||
|
||||
router.get("/pair-request/:pairId", (c) => {
|
||||
router.get("/pair-request/:pairId", async (c) => {
|
||||
const pairId = c.req.param("pairId");
|
||||
const rec = issued.get(pairId);
|
||||
if (!rec) return c.json({ error: "pair_unknown", message: "unknown or already-consumed pairing request" }, 404);
|
||||
|
||||
if (Date.now() > rec.expiresAt) {
|
||||
issued.delete(pairId);
|
||||
await settlePairRequest(getRepo(c), rec, "expired", PAIR_SOURCE_SESSION);
|
||||
return c.json({ status: "expired" });
|
||||
}
|
||||
// Approval hands over this daemon's bearer token, so only an explicit decision does: `rig host approve`, an agent
|
||||
// seat's close naming Approve, or, on an agent-free host, the person's own Approve click. A person's answer arriving
|
||||
// decides nothing where an agent reads it: the request stays pending until the agent acts or it expires.
|
||||
const item = rec.qitemId ? getRepo(c).getById(rec.qitemId) : null;
|
||||
const state = item?.state ?? "pending";
|
||||
if (state === "done") {
|
||||
// Single-shot handover: the first approved read consumes the pairing.
|
||||
issued.delete(pairId);
|
||||
return c.json({ status: "approved", token: bearerToken });
|
||||
const answer = item?.humanAnswers?.[PAIR_QUESTION_ID];
|
||||
const approved = rec.decision ? rec.decision.approve : rowDecision(c, rec, item);
|
||||
if (approved === null) {
|
||||
// The person's reply (their words, or a reply with no words, such as a file) decides nothing by itself.
|
||||
const replied = (answer != null && typeof answer === "object") || (answer == null && closedByReply(c, item));
|
||||
// Agent-free, the reply closed the request and its buttons, and nothing reads it: ask again, keeping their words on
|
||||
// the closed row, so the person still has a way to decide. A reply never denies.
|
||||
if (!rec.owner && replied) {
|
||||
const closed = rec.qitemId;
|
||||
rec.qitemId = ""; // one ask at a time: a poll meanwhile reads pending
|
||||
const words = answer != null && typeof answer === "object" ? answer.text.replace(/\s+/g, " ").trim() : "";
|
||||
try {
|
||||
rec.qitemId = await askPerson(c, pairId, rec, [words
|
||||
? `You wrote "${words.length > 200 ? `${words.slice(0, 200)}…` : words}" in reply to this request. Your words are kept, and they didn't deny anything: ${noReader(rec)}, so please decide with Approve or Deny here.`
|
||||
: `Your reply to this request had no words to keep here, and it didn't deny anything: ${noReader(rec)}, so please decide with Approve or Deny here.`]);
|
||||
} catch { rec.qitemId = closed; } // the next poll asks again
|
||||
return c.json({ status: "pending", code: rec.code });
|
||||
}
|
||||
// Where the owner reads answers, tell the person once that their answer was received, and whether the owner is
|
||||
// running to read it.
|
||||
if (rec.owner && !rec.receipted && (answer != null || replied)) {
|
||||
rec.receipted = true;
|
||||
const said = typeof answer === "string" ? `your ${answer === PAIR_APPROVE ? "Approve" : answer === "deny" ? "Deny" : answer} choice`
|
||||
: answer != null && typeof answer === "object" ? `"${answer.text.replace(/\s+/g, " ").trim().slice(0, 200)}"` : "your reply";
|
||||
const running = ownerRunning(c, rec.owner);
|
||||
const waiting = `Nothing is granted or denied until it acts, or the request expires at ${new Date(rec.expiresAt).toISOString().slice(0, 19).replace("T", " ")} UTC. To decide now, in a terminal on this host with its operator bearer token: rig host approve ${rec.code}, or rig host deny ${rec.code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`;
|
||||
const reading = running === true ? `${rec.owner} reads it and acts on it. You'll see the result here, or a question if your answer isn't clear.`
|
||||
: running === false ? `${rec.owner} isn't running, so it may not have read it yet. ${waiting}`
|
||||
: `OpenRig couldn't tell whether ${rec.owner} is running, so it may not have read it yet. ${waiting}`;
|
||||
try {
|
||||
await getRepo(c).create({ sourceSession: rec.owner, destinationSession: rec.person, humanIntent: "update",
|
||||
summary: `Pairing ${rec.code} from ${rec.requester}: answer received`,
|
||||
body: `Received ${said} on pairing request ${rec.code} from ${rec.requester}. ${reading}` });
|
||||
} catch { /* the answer is kept either way */ }
|
||||
}
|
||||
return c.json({ status: "pending", code: rec.code });
|
||||
}
|
||||
if (state === "denied" || state === "canceled" || state === "failed") {
|
||||
issued.delete(pairId);
|
||||
return c.json({ status: "denied" });
|
||||
// Single-shot: the first decided read consumes the pairing.
|
||||
issued.delete(pairId);
|
||||
// A decision from a close (the person's click on an agent-free host, an agent seat's close) is told to the person
|
||||
// here; `rig host approve|deny` told them when it decided. Not awaited: nothing between the expiry check above and
|
||||
// this response yields, so a slow update can't carry the handover past expiry.
|
||||
if (!rec.decision) {
|
||||
const by = (item && getRepo(c).closingTransition(item.qitemId, item.state)?.actorSession) || rec.person;
|
||||
void settlePairRequest(getRepo(c), rec, approved ? "approved" : "denied", by, true).catch(() => { /* the decision stands */ });
|
||||
}
|
||||
return c.json({ status: "pending", code: rec.code });
|
||||
return c.json(approved ? { status: "approved", token: bearerToken } : { status: "denied" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// TARGET side: the explicit decision (`rig host approve|deny`), from the request's owner agent or a person at a
|
||||
// terminal on this host. Write-gated like every other daemon write.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
router.post("/pair-decision", async (c) => {
|
||||
const body = (await c.req.json<{ request?: unknown; decision?: unknown }>().catch(() => ({}))) as { request?: unknown; decision?: unknown };
|
||||
const decision = body.decision;
|
||||
const ref = typeof body.request === "string" ? body.request.trim() : "";
|
||||
if ((decision !== "approve" && decision !== "deny") || !ref) {
|
||||
return c.json({ error: "pair_decision_invalid", message: "Name the request (its pairing code or approval row id) and decision: approve or deny." }, 400);
|
||||
}
|
||||
const matches = [...issued.entries()].filter(([, r]) => r.qitemId === ref || r.code === ref);
|
||||
if (matches.length === 0) {
|
||||
return c.json({ error: "pair_request_unknown", message: `No open pairing request ${ref} on this host: it's unknown, already handed over or denied, or the daemon restarted since it was made. Nothing was decided.` }, 404);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return c.json({ error: "pair_request_ambiguous", message: `Pairing code ${ref} matches ${matches.length} open requests; name one by its approval row: ${matches.map(([, r]) => r.qitemId).join(", ")}. Nothing was decided.` }, 409);
|
||||
}
|
||||
const [, rec] = matches[0]!;
|
||||
if (Date.now() > rec.expiresAt) {
|
||||
return c.json({ error: "pair_request_expired", message: `Pairing request ${rec.code} from ${rec.requester} expired at ${new Date(rec.expiresAt).toISOString().slice(0, 19).replace("T", " ")} UTC. Nothing was decided; the requester can run rig host pair again.` }, 409);
|
||||
}
|
||||
// One decision per request: an earlier `rig host approve|deny`, or one its row already records (a click on an
|
||||
// agent-free host, a refusing close, an agent seat's close naming Approve), even before the requester's next poll.
|
||||
const item = rec.qitemId ? getRepo(c).getById(rec.qitemId) : null;
|
||||
const decided = rec.decision ?? (() => {
|
||||
const approve = rowDecision(c, rec, item);
|
||||
return approve === null ? null : { approve, by: (item && getRepo(c).closingTransition(item.qitemId, item.state)?.actorSession) || rec.person };
|
||||
})();
|
||||
if (decided) {
|
||||
return c.json({ error: "pair_request_decided", decision: decided.approve ? "approve" : "deny", by: decided.by,
|
||||
message: `Pairing request ${rec.code} was already ${decided.approve ? "approved" : "denied"} by ${decided.by}; that stands.` }, 409);
|
||||
}
|
||||
// The acting seat, or the person at this host's terminal (no seat), recorded as a direct human action.
|
||||
const by = c.req.header("X-OpenRig-Session")?.trim() || UNATTRIBUTED_ACTOR;
|
||||
rec.decision = { approve: decision === "approve", by };
|
||||
await settlePairRequest(getRepo(c), rec, decision === "approve" ? "approved" : "denied", by);
|
||||
return c.json({ ok: true, decision, code: rec.code, requester: rec.requester, by });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -37,12 +37,12 @@ export function registerTerminalAuthOnly(app: Hono, opts: { bearerToken: string
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: Parameters<typeof import("@hono/node-ws").createNodeWebSocket>[0] extends { app: infer _A } ? never : never,
|
||||
opts: { bearerToken: string | null },
|
||||
opts: { bearerToken: string | null; fixedGeometry?: () => boolean },
|
||||
): void;
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: (createHandler: (c: unknown) => unknown) => unknown,
|
||||
opts: { bearerToken: string | null; livenessIntervalMs?: number },
|
||||
opts: { bearerToken: string | null; livenessIntervalMs?: number; fixedGeometry?: () => boolean },
|
||||
): void {
|
||||
const terminalAuth = terminalAuthMiddleware(opts);
|
||||
|
||||
@@ -53,7 +53,7 @@ export function registerTerminalWs(
|
||||
let registry: TerminalBrokerRegistry | null = null;
|
||||
const getRegistry = (tmux: BrokerTmux): TerminalBrokerRegistry => {
|
||||
if (!registry) {
|
||||
registry = new TerminalBrokerRegistry(tmux, { livenessMs: opts.livenessIntervalMs });
|
||||
registry = new TerminalBrokerRegistry(tmux, { livenessMs: opts.livenessIntervalMs, fixedGeometry: opts.fixedGeometry });
|
||||
}
|
||||
return registry;
|
||||
};
|
||||
|
||||
@@ -827,7 +827,8 @@ export function createApp(deps: AppDeps): Hono {
|
||||
const ws = createNodeWebSocket({ app });
|
||||
injectWebSocket = ws.injectWebSocket as never;
|
||||
_lastInjectWebSocket = injectWebSocket;
|
||||
registerTerminalWs(app, ws.upgradeWebSocket as never, { bearerToken: deps.terminalBearerToken ?? null });
|
||||
registerTerminalWs(app, ws.upgradeWebSocket as never, { bearerToken: deps.terminalBearerToken ?? null,
|
||||
fixedGeometry: () => deps.settingsStore?.resolveOne("terminal.fixed_geometry").value === true });
|
||||
} else if (deps.enableNodeWebSocket) {
|
||||
registerTerminalAuthOnly(app, { bearerToken: deps.terminalBearerToken ?? null });
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import { newPinHolder, readPinSizing, recordPin, releasePin, WINDOW_SIZE_PRIOR_O
|
||||
// for the same session (the per-connection bug): a second viewer of one seat
|
||||
// fought the first for output. The broker fixes that: ONE tmux pipe per
|
||||
// session, MANY subscribers, output fanned out to all, each subscriber seeded
|
||||
// with a cursor-safe snapshot of the current screen on attach, fixed geometry
|
||||
// (no client-driven resize), honest session-death reporting to ALL subscribers,
|
||||
// with a cursor-safe snapshot of the current screen on attach, optional fixed geometry,
|
||||
// honest session-death reporting to ALL subscribers,
|
||||
// and full cleanup when the last subscriber leaves.
|
||||
|
||||
const PIPE_PANE_POLL_MS = 50;
|
||||
@@ -33,15 +33,9 @@ const DEFAULT_LIVENESS_MS = 2000;
|
||||
const MAX_HISTORY_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Canonical fixed terminal geometry (FR-7). 90 cols (OPR.0.4.0.39, founder-directed):
|
||||
* Claude Code (Ink) + Codex CLI are RESPONSIVE TUIs that reflow to whatever width they
|
||||
* are given - they have no required width; 80 is the legacy fallback that users find
|
||||
* too narrow, so 90 sits comfortably above the 80 floor while being narrower than 120 so
|
||||
* the scaled static/live mirror reads bigger (more legible) in the topology grid cells.
|
||||
* 27 rows gives the classic-terminal 1.72:1 landscape shape (90x27 = ~650x378px, matching the canonical 80x24 aspect) (founder: classic terminal rectangle) while staying a workable agent-TUI height; subscribers fit/scroll/pan their
|
||||
* viewport but never resize the pane - so multiple viewers cannot shrink the session to
|
||||
* the smallest one. MUST stay in sync with the client mirror LIVE_TERMINAL_COLS
|
||||
* (packages/ui/.../terminal/terminal-geometry.ts) - the xterm grid must match the pane.
|
||||
* Browser rendering grid and explicit fixed-geometry opt-in size. The default broker does not resize the
|
||||
* agent PTY; native clients control it. Keep these browser grid values in sync with LIVE_TERMINAL_COLS/ROWS
|
||||
* in packages/ui/src/terminal/terminal-geometry.ts. This does not make the browser a native sizing client.
|
||||
*/
|
||||
export const CANONICAL_COLS = 90;
|
||||
export const CANONICAL_ROWS = 27;
|
||||
@@ -79,6 +73,8 @@ export interface BrokerTmux {
|
||||
}
|
||||
|
||||
export interface BrokerOptions {
|
||||
/** Explicit web fixed-geometry opt-in. Default false; native clients otherwise own the PTY size. */
|
||||
fixedGeometry?: boolean | (() => boolean);
|
||||
/** File-tail poll interval (ms). Default 50. */
|
||||
pollMs?: number;
|
||||
/** Session-liveness probe interval (ms). Default 2000. */
|
||||
@@ -199,6 +195,7 @@ export class TerminalSessionBroker {
|
||||
// The window this broker pinned, by its immutable id, and listed itself as a holder of. When the last live holder lets
|
||||
// go, the window gets back its own `window-size`, so native clients (Herdr, attach) size it again. Null: nothing held.
|
||||
private pinnedWindow: string | null = null;
|
||||
private readonly fixedGeometry: boolean;
|
||||
private readonly holder = newPinHolder();
|
||||
|
||||
constructor(sessionName: string, tmux: BrokerTmux, opts: BrokerOptions = {}) {
|
||||
@@ -208,6 +205,7 @@ export class TerminalSessionBroker {
|
||||
this.livenessMs = opts.livenessMs ?? DEFAULT_LIVENESS_MS;
|
||||
this.cols = opts.cols ?? CANONICAL_COLS;
|
||||
this.rows = opts.rows ?? CANONICAL_ROWS;
|
||||
this.fixedGeometry = typeof opts.fixedGeometry === "function" ? opts.fixedGeometry() : opts.fixedGeometry === true;
|
||||
this.maxHistoryBytes = opts.maxHistoryBytes ?? MAX_HISTORY_BYTES;
|
||||
this.onEmpty = opts.onEmpty;
|
||||
}
|
||||
@@ -420,19 +418,14 @@ export class TerminalSessionBroker {
|
||||
const alive = await this.tmux.hasSession(this.sessionName);
|
||||
if (!alive) return { ok: false, code: 1008, reason: `session not found: ${this.sessionName}` };
|
||||
|
||||
// FR-7 fixed geometry: window-size manual so tmux will NOT auto-shrink the
|
||||
// window to the smallest attached client; then the canonical width/height
|
||||
// ONCE. Deliberately NOT aggressive-resize, which does the opposite. The
|
||||
// window's own value is read first and recorded on the window with this
|
||||
// broker as a holder, and given back when the last holder lets go. The window
|
||||
// is named by its id, so the pin and the give-back reach the same window even
|
||||
// after a client switches windows, and the read, record and pin run in its
|
||||
// queue (window-pin.ts), so a readable capture in this daemon takes its turn.
|
||||
// Without a window id or a readable value, the session's window is pinned as
|
||||
// before with nothing to give back.
|
||||
const window = (await this.tmux.readWindow?.(this.sessionName, WINDOW_SIZE_PRIOR_OPTION).catch(() => null))?.id ?? null;
|
||||
const pinned = await (window ? withWindowPin(window, () => this.pinWindow(window)) : this.pinWindow(null));
|
||||
if (!pinned || this.torndown) return { ok: false, code: 1011, reason: "terminal broker unavailable" }; // dispose gives it back
|
||||
// Fixed web geometry is explicit opt-in. Record each holder on the immutable window id;
|
||||
// the last release unsets the temporary override so native clients size it again.
|
||||
// Unknown sizing is observed without making an untracked manual pin.
|
||||
if (this.fixedGeometry) {
|
||||
const window = (await this.tmux.readWindow?.(this.sessionName, WINDOW_SIZE_PRIOR_OPTION).catch(() => null))?.id ?? null;
|
||||
const pinned = await (window ? withWindowPin(window, () => this.pinWindow(window)) : this.pinWindow(null));
|
||||
if (!pinned || this.torndown) return { ok: false, code: 1011, reason: "terminal broker unavailable" };
|
||||
}
|
||||
|
||||
const outputPath = path.join(
|
||||
os.tmpdir(),
|
||||
@@ -461,11 +454,12 @@ export class TerminalSessionBroker {
|
||||
private async pinWindow(window: string | null): Promise<boolean> {
|
||||
const sizing = window ? await readPinSizing(this.tmux, window) : null;
|
||||
if (this.torndown) return false; // disposed: pin nothing
|
||||
if (sizing) {
|
||||
this.pinnedWindow = sizing.window;
|
||||
await recordPin(this.tmux, sizing, this.holder);
|
||||
}
|
||||
const target = sizing?.window ?? this.sessionName;
|
||||
if (!sizing) return true; // Unknown geometry: observe normally rather than leave an untracked manual pin.
|
||||
// A person's fixed window is not an OpenRig pin to take over or unset.
|
||||
if (sizing.live === "manual" && !(await this.tmux.readWindow?.(sizing.window, WINDOW_SIZE_PRIOR_OPTION).catch(() => null))?.userOption.trim()) return true;
|
||||
this.pinnedWindow = sizing.window;
|
||||
if (!(await recordPin(this.tmux, sizing, this.holder))) { this.pinnedWindow = null; return true; }
|
||||
const target = sizing.window;
|
||||
await this.tmux.setWindowOption(target, "window-size", "manual").catch(() => {});
|
||||
await this.tmux.resizeWindow(target, this.cols, this.rows).catch(() => {});
|
||||
return true;
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface PinTmux {
|
||||
restoreWindowOption?(target: string, option: string, value: string | null): Promise<TmuxResult>;
|
||||
}
|
||||
|
||||
/** A window, its own `window-size` now (`live`), the value to give back (`prior`), and the live holders of a pin. */
|
||||
/** A window, its own `window-size` now (`live`), the recorded pre-pin value (`prior`, retained for old holder compatibility), and the live holders of a pin. */
|
||||
export interface PinSizing {
|
||||
window: string;
|
||||
live: string | null;
|
||||
@@ -93,12 +93,12 @@ export async function recordPin(tmux: PinTmux, sizing: PinSizing, holder: string
|
||||
return written?.ok === true;
|
||||
}
|
||||
|
||||
/** What letting go did: the window got its prior back; another live holder keeps the pin; the window no longer lists
|
||||
/** What letting go did: the window resumed inherited sizing; another live holder keeps the pin; the window no longer lists
|
||||
* this holder; or the give-back failed (the record is kept for the next holder). */
|
||||
export type PinRelease = "given-back" | "kept-by-another-holder" | "not-listed" | "failed";
|
||||
|
||||
/** Let go of `holder`'s pin on `window`, once. While another live holder remains, only drop this one. As the last one,
|
||||
* run `beforeGiveBack` (a readable capture puts the size back), then give back the prior and drop the record. A window
|
||||
* run `beforeGiveBack` (a readable capture puts the size back), then unset the temporary window-size override and drop the record. A window
|
||||
* that no longer lists this holder (the record was cleared, or the tmux server restarted and `@N` is another window)
|
||||
* is left alone. */
|
||||
export function releasePin(tmux: PinTmux, window: string, holder: string, beforeGiveBack?: () => Promise<void>): Promise<PinRelease> {
|
||||
@@ -112,7 +112,7 @@ export function releasePin(tmux: PinTmux, window: string, holder: string, before
|
||||
return "kept-by-another-holder";
|
||||
}
|
||||
await beforeGiveBack?.().catch(() => {});
|
||||
const restored = await tmux.restoreWindowOption?.(window, "window-size", record.prior).catch(() => null);
|
||||
const restored = await tmux.restoreWindowOption?.(window, "window-size", null).catch(() => null);
|
||||
// Kept when the give-back failed: this holder is then no longer live, so the next one gives it back instead.
|
||||
if (!restored?.ok) return "failed";
|
||||
await tmux.restoreWindowOption?.(window, WINDOW_SIZE_PRIOR_OPTION, null).catch(() => {});
|
||||
|
||||
@@ -74,8 +74,8 @@ describe("rig capture says when a pane is too small to show the prompt the seat
|
||||
});
|
||||
});
|
||||
|
||||
// `rig capture --readable`: read a small seat at a readable size, then give its window back exactly: its size, and its
|
||||
// own window-size (resize-window sets that to manual, which would stop Herdr or attach sizing the window).
|
||||
// `rig capture --readable`: read at a readable size, put its size back, then unset the temporary local window-size
|
||||
// override so native clients size it under the inherited policy.
|
||||
describe("rig capture --readable reads a small seat, then gives its window back", () => {
|
||||
const dbs: Array<ReturnType<typeof createDb>> = [];
|
||||
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
|
||||
@@ -146,16 +146,16 @@ describe("rig capture --readable reads a small seat, then gives its window back"
|
||||
it.each([
|
||||
["inherited sizing is unset again", null],
|
||||
["the window's own sizing is set again", "largest"],
|
||||
])("reads at 120x40, then puts back its size and its sizing: %s", async (_, own) => {
|
||||
])("reads at 120x40, then puts back its size and resumes inherited sizing: %s", async (_, own) => {
|
||||
const { tmux, calls, window } = fakeTmux({ own });
|
||||
const result = await (await transportOver(tmux)).capture("review@rig", { readable: { width: 120, height: 40 } });
|
||||
expect(result).toMatchObject({ ok: true, content: "the whole prompt\n", readable: { resized: true, width: 120, height: 40, release: "given-back" } });
|
||||
expect(calls).toEqual([
|
||||
`set @2 @openrig-window-size-prior=${own ?? "inherit"} H`,
|
||||
"resize @2 120x40", "capture", "resize @2 18x12",
|
||||
`restore @2 window-size=${own}`, "restore @2 @openrig-window-size-prior=null",
|
||||
"restore @2 window-size=null", "restore @2 @openrig-window-size-prior=null",
|
||||
]);
|
||||
expect(window).toEqual({ own, recorded: "" });
|
||||
expect(window).toEqual({ own: null, recorded: "" });
|
||||
});
|
||||
|
||||
it("a web terminal that opens during the read keeps its pin: the read leaves the size to it and drops only itself", async () => {
|
||||
@@ -183,7 +183,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
|
||||
await atPanes; // the read has the window's sizing, and hasn't recorded its pin yet
|
||||
const ok = async () => ({ ok: true as const });
|
||||
const broker = new TerminalSessionBroker("review@rig", { ...tmux, hasSession: async () => true, startPipePane: ok, stopPipePane: ok,
|
||||
sendKeys: ok, sendText: ok, getPaneCursorPosition: async () => null } as never, { pollMs: 10, livenessMs: 60_000 });
|
||||
sendKeys: ok, sendText: ok, getPaneCursorPosition: async () => null } as never, { fixedGeometry: true, pollMs: 10, livenessMs: 60_000 });
|
||||
const browser = { send: () => {}, close: () => {} };
|
||||
const attaching = broker.attach(browser);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
@@ -244,7 +244,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
|
||||
])("a pin whose holders are gone (a crash) is taken over and given back: %s", async (_, panes) => {
|
||||
const { tmux, window } = fakeTmux({ own: "manual", recorded: `largest ${deadPid}.1`, panes });
|
||||
const result = await (await transportOver(tmux)).capture("review@rig", { readable: { width: 120, height: 40 } });
|
||||
expect(window).toEqual({ own: "largest", recorded: "" });
|
||||
expect(window).toEqual({ own: null, recorded: "" });
|
||||
expect(result.readable).toEqual(panes[0]!.width < 120
|
||||
? { resized: true, width: 120, height: 40, release: "given-back" }
|
||||
: { resized: false, reason: "the pane is already 130x45; a pin left on its window was given back" });
|
||||
@@ -337,7 +337,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
|
||||
|
||||
await tmux(["set-option", "-w", "-t", "=review@rig:", "window-size", "largest"]);
|
||||
expect((await transport.capture("review@rig", { readable: { width: 120, height: 40 } })).readable).toMatchObject({ resized: true });
|
||||
expect(await own("=review@rig:")).toBe("largest");
|
||||
expect(await own("=review@rig:")).toBe("");
|
||||
|
||||
await tmux(["set-option", "-w", "-t", "=review@rig:", "window-size", "manual"]);
|
||||
expect((await transport.capture("review@rig", { readable: { width: 120, height: 40 } })).readable).toMatchObject({ resized: false });
|
||||
@@ -353,7 +353,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
|
||||
class BrowserOpensDuringRead extends TmuxAdapter {
|
||||
override async capturePaneContent(target: string, lines: number) {
|
||||
if (!broker) {
|
||||
broker = new TerminalSessionBroker("review@rig", adapter, { pollMs: 20, livenessMs: 60000, onEmpty: () => { empty = true; } });
|
||||
broker = new TerminalSessionBroker("review@rig", adapter, { fixedGeometry: true, pollMs: 20, livenessMs: 60000, onEmpty: () => { empty = true; } });
|
||||
await broker.attach(browser);
|
||||
}
|
||||
return super.capturePaneContent(target, lines);
|
||||
@@ -376,7 +376,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
|
||||
expect(await own("=review@rig:")).toBe("manual");
|
||||
expect((await transport.capture("review@rig", { readable: { width: 120, height: 40 } })).readable)
|
||||
.toEqual({ resized: false, reason: "the pane is already 120x40; a pin left on its window was given back" });
|
||||
expect(await own("=review@rig:")).toBe("largest");
|
||||
expect(await own("=review@rig:")).toBe("");
|
||||
expect(await tmux(["show-options", "-q", "-w", "-v", "-t", "=review@rig:", "@openrig-window-size-prior"])).toBe("");
|
||||
} finally {
|
||||
await tmux(["kill-server"]).catch(() => {});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { ClaudeCompactionEnforcer as ActualEnforcer, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport } from "../src/domain/session-transport.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";
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
@@ -1333,7 +1333,7 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
return { ok: true, activity: { state: "idle" } as any, attempts: 1, waitedMs: 0 };
|
||||
};
|
||||
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, preserveInput: true, waitForIdleMs: 1_000 + CAPTURE_READABLE_BUDGET_MS, readinessFromPaneOnly: true })}`]);
|
||||
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, preserveInput: true, waitForIdleMs: SEND_READINESS_FRESHNESS_MS + CAPTURE_READABLE_BUDGET_MS, readinessFromPaneOnly: true })}`]);
|
||||
});
|
||||
|
||||
it.each([0, 75])("wait-for-idle failure means /compact never landed: the back-half is NOT seeded (preparation delay=%ims)", async (delayMs) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {mkdtempSync, mkdirSync, writeFileSync, renameSync, rmSync, readFileSync,
|
||||
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 {SessionTransport, CAPTURE_READABLE_BUDGET_MS} from '../src/domain/session-transport.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';
|
||||
import {ContextMonitor} from '../src/domain/context-monitor.js';
|
||||
@@ -41,9 +41,9 @@ function stageMap(f:ReturnType<typeof fixture>,suffix=''){
|
||||
}
|
||||
const compacts=(f:ReturnType<typeof fixture>)=>f.writes.filter(t=>t.startsWith('/compact'));
|
||||
// Real readable capture and managed send, including the adapter's final beforePaste check.
|
||||
async function tinyPane(kind:'empty'|'unknown'|'typed'='empty',liveProbe=true){
|
||||
const f=fixture();vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
await f.e.maybeAutoCompact(input);stageMap(f);
|
||||
async function tinyPane(kind:'empty'|'unknown'|'typed'='empty',liveProbe=true,manualBudget?:number){
|
||||
const f=fixture(manualBudget);vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
if(manualBudget===undefined){await f.e.maybeAutoCompact(input);stageMap(f);}
|
||||
const frame=readFileSync(join(import.meta.dirname,'fixtures/input-box/claude-code-2.1.296/openrig-seat-accept-edits-idle.txt'),'utf8').trimEnd().split('\n');
|
||||
const row=frame.findLastIndex(line=>line.startsWith('❯'));
|
||||
if(kind==='typed')frame[row]='❯ keep this draft';
|
||||
@@ -67,6 +67,55 @@ async function tinyPane(kind:'empty'|'unknown'|'typed'='empty',liveProbe=true){
|
||||
if(liveProbe)delete(f.transport as any).classifySendReadiness;
|
||||
return{...f,tmux,window};
|
||||
}
|
||||
// Exercise the real hook freshness, pane classifier, final composer check and transport wait.
|
||||
// Only the activity-store read and tmux I/O are fixtures; no readiness result is injected.
|
||||
function exhaustedPrompt(f:Awaited<ReturnType<typeof tinyPane>>){
|
||||
const screen=f.tmux.capturePaneScreen.getMockImplementation()!;
|
||||
f.tmux.capturePaneScreen.mockImplementation(async()=>(await screen()).replace(/^▎.*$/m,'Context left until auto-compact: 0%'));
|
||||
f.tmux.capturePaneContent.mockImplementation(f.tmux.capturePaneScreen.getMockImplementation()!);
|
||||
}
|
||||
function preparationHook(f:ReturnType<typeof fixture>){
|
||||
const hook={state:'running',reason:'user_prompt_submit',evidenceSource:'runtime_hook',stale:false,eventAt:new Date(f.clock()).toISOString()};
|
||||
(f.transport as any).agentActivityStore={getLatestForNode:()=>hook};
|
||||
}
|
||||
it('fresh preparation hook ages out before the guarded compact send on a visibly idle pane',async()=>{
|
||||
const f=await tinyPane();exhaustedPrompt(f);preparationHook(f);const start=f.clock();
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
expect(f.clock()-start).toBeGreaterThan(15_000);
|
||||
expect(f.window).toEqual({width:11,height:20,own:null,recorded:''});
|
||||
});
|
||||
for(const budget of [40_000,9_000])it(`manual skip-map final send keeps its original ${budget}ms deadline with a delayed preparation hook`,async()=>{
|
||||
const f=await tinyPane('empty',true,budget);f.window.width=120;f.window.height=40;
|
||||
exhaustedPrompt(f);
|
||||
// The prep hook can arrive between the initial idle observation and final guarded send.
|
||||
const wait=f.transport.waitUntilIdle.bind(f.transport);
|
||||
f.transport.waitUntilIdle=async(...args)=>{const result=await wait(...args);preparationHook(f);return result;};
|
||||
const start=f.clock(),result=await f.e.triggerManualCompact(input,{operatorInitiated:true,skipMap:true});
|
||||
expect(f.writes.filter(text=>!text.startsWith('/compact'))).toHaveLength(1);
|
||||
expect(result).toMatchObject({triggered:budget>15_000});
|
||||
expect(compacts(f)).toHaveLength(budget>15_000?1:0);
|
||||
expect(f.clock()-start).toBeLessThanOrEqual(budget);
|
||||
expect(f.e.getPreparationState(seat)?.deadlineAt).toBe(start+budget);
|
||||
});
|
||||
for(const pane of ['running','permission','typed','unknown'] as const)it(`an aged preparation hook still cannot compact a ${pane} pane`,async()=>{
|
||||
const f=await tinyPane(pane==='typed'?'typed':pane==='unknown'?'unknown':'empty');
|
||||
if(pane==='running'||pane==='permission'){
|
||||
const screen=pane==='running'
|
||||
?readFileSync(join(import.meta.dirname,'fixtures/input-box/claude-code-2.1.296/after-enter-0.4s.J.txt'),'utf8')
|
||||
:'Allow this command?\n❯ 1. Yes, continue\n 2. No, cancel\nPress enter to continue';
|
||||
f.tmux.capturePaneScreen.mockImplementation(async()=>screen);f.tmux.capturePaneContent.mockImplementation(async()=>screen);
|
||||
}
|
||||
preparationHook(f);const start=f.clock();await f.e.maybeAutoCompact(input);
|
||||
expect(f.clock()-start).toBeGreaterThan(15_000);
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
for(const stop of ['cancel','replacement'] as const)it(`fresh-hook final wait still honors ${stop}`,async()=>{
|
||||
const f=await tinyPane();preparationHook(f);
|
||||
(f.transport as any).sleep=async(ms:number)=>{f.advance(ms);if(stop==='cancel')f.e.cancelPreparation(seat);else f.generation('generation-two');};
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
it.each(['live-pane','final-composer'])('submits managed /compact from an 11x20 pane through the %s readable read and restores the window',async gate=>{
|
||||
const f=await tinyPane('empty',gate==='live-pane');
|
||||
// The pre-existing readable path can classify this fixture and give the inherited sizing back.
|
||||
@@ -870,7 +919,7 @@ for(const fresh of [false,null,"below","high"] as const)it(`an armed completed m
|
||||
const monitor=new ContextMonitor(db as any,store as any,undefined,f.e);
|
||||
const beforeBusyPoll=f.clock();
|
||||
await monitor.pollOnce();expect(compacts(f)).toHaveLength(0);
|
||||
expect(f.clock()-beforeBusyPoll).toBeLessThanOrEqual(1_000+CAPTURE_READABLE_BUDGET_MS);
|
||||
expect(f.clock()-beforeBusyPoll).toBeLessThanOrEqual(SEND_READINESS_FRESHNESS_MS+CAPTURE_READABLE_BUDGET_MS);
|
||||
expect(f.e.getPreparationState(seat)?.status).toBe('waiting');
|
||||
f.activity('idle');await monitor.pollOnce();
|
||||
expect(compacts(f)).toHaveLength(fresh==='below'?0:1);expect(f.writes.filter(t=>!t.startsWith('/compact'))).toHaveLength(1);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Shipped tile argv in real PTYs on an isolated tmux server; no desktop or agent process.
|
||||
import fcntl, json, os, pty, shutil, signal, struct, subprocess, sys, tempfile, termios, time
|
||||
|
||||
argv = json.loads(sys.argv[1])
|
||||
root = tempfile.mkdtemp(prefix="tile-reflow-")
|
||||
sock = os.path.join(root, "server")
|
||||
env = dict(os.environ, TERM="xterm-256color")
|
||||
env.pop("TMUX", None)
|
||||
clients = []
|
||||
|
||||
def call(*args):
|
||||
return subprocess.check_output(["tmux", "-S", sock, *args], env=env, text=True, stderr=subprocess.PIPE, timeout=3).strip()
|
||||
|
||||
def until(fn, label):
|
||||
deadline = time.monotonic() + 6
|
||||
while time.monotonic() < deadline:
|
||||
for proc, master, slave in clients:
|
||||
try:
|
||||
while os.read(master, 65536): pass
|
||||
except (BlockingIOError, OSError): pass
|
||||
if fn(): return
|
||||
time.sleep(.04)
|
||||
raise AssertionError(label + "; actual=" + call("display-message", "-p", "-t", "seat:", "#{window_width}x#{window_height}"))
|
||||
|
||||
def tile(cols, rows):
|
||||
master, slave = pty.openpty()
|
||||
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
def setup():
|
||||
os.setsid()
|
||||
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
|
||||
proc = subprocess.Popen([sock if a == "TILE_SOCKET" else a for a in argv], env=env,
|
||||
stdin=slave, stdout=slave, stderr=slave, preexec_fn=setup)
|
||||
os.set_blocking(master, False)
|
||||
clients.append((proc, master, slave))
|
||||
return clients[-1]
|
||||
|
||||
def size(client, cols, rows):
|
||||
fcntl.ioctl(client[2], termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
os.killpg(client[0].pid, signal.SIGWINCH)
|
||||
|
||||
def check(cols, rows):
|
||||
until(lambda: call("display-message", "-p", "-t", "seat:", "#{window_width}x#{window_height}") == str(cols)+"x"+str(rows), "latest visible client sizes seat")
|
||||
until(lambda: "PTY-COLS:"+str(cols) in call("capture-pane", "-p", "-t", "seat:"), "agent PTY observes width")
|
||||
screen = call("capture-pane", "-p", "-t", "seat:")
|
||||
assert "END-REFLOW" in screen.replace("\n", ""), "full new text must remain readable"
|
||||
assert max(map(len, screen.splitlines())) <= cols, "new text wraps to visible columns"
|
||||
assert call("show-options", "-w", "-v", "-t", "seat:", "window-size") == "", "no local pin"
|
||||
return str(cols)+"x"+str(rows)
|
||||
|
||||
try:
|
||||
program = os.path.join(root, "app.py")
|
||||
received = os.path.join(root, "received")
|
||||
with open(program, "w") as f:
|
||||
f.write("import os,signal,sys,tty\ntty.setraw(0)\n"+
|
||||
"def draw(*_):\n c=os.get_terminal_size().columns\n print('PTY-COLS:'+str(c),flush=True)\n print('BEGIN-REFLOW '+('words '*35)+'END-REFLOW',flush=True)\n"+
|
||||
"signal.signal(signal.SIGWINCH,draw)\ndraw()\nprint('\\x1b[?2004h',end='',flush=True)\n"+
|
||||
"with open("+repr(received)+",'ab',buffering=0) as out:\n while True:\n out.write(os.read(0,65536))\n")
|
||||
call("-f", "/dev/null", "new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=reflow-fixture", "python3 -u "+program)
|
||||
call("set-option", "-g", "status", "off")
|
||||
phone = tile(40, 24)
|
||||
observed = [check(40, 24)]
|
||||
laptop = tile(120, 35)
|
||||
observed.append(check(120, 35))
|
||||
for cols, rows in [(160,45),(52,28),(132,40)]:
|
||||
size(laptop, cols, rows)
|
||||
observed.append(check(cols, rows))
|
||||
os.write(phone[1], b"p")
|
||||
observed.append(check(40,24))
|
||||
os.write(laptop[1], b"l")
|
||||
observed.append(check(132,40))
|
||||
def got():
|
||||
with open(received, "rb") as f: return f.read()
|
||||
until(lambda: got() == b"pl", "normal native input reaches seat")
|
||||
# The first burst into a new native client, split inside UTF-8, then arrows and control keys.
|
||||
first = tile(132, 40)
|
||||
until(lambda: len(call("list-clients", "-F", "#{client_tty}").splitlines()) == 3, "native attach ready")
|
||||
os.write(first[1], b"A"*63+b"\xc3")
|
||||
time.sleep(.3)
|
||||
os.write(first[1], b"\xa9END\x1b[A\x00\x13\x11")
|
||||
expected = b"pl"+b"A"*63+b"\xc3\xa9END\x1b[A\x00\x13\x11"
|
||||
until(lambda: got() == expected, "first keys arrive byte-for-byte and in order")
|
||||
# Native tmux, rather than a custom first-key buffer, carries a framed paste over 256 KiB.
|
||||
data = b"\x1b[200~"+b"".join(b"line %06d of a long paste\r" % i for i in range(16000))+b"\x1b[201~"
|
||||
sent, deadline = 0, time.monotonic()+30
|
||||
while sent < len(data):
|
||||
try: sent += os.write(first[1], data[sent:sent+4096])
|
||||
except BlockingIOError: time.sleep(.002)
|
||||
assert time.monotonic() < deadline, "native paste must keep moving"
|
||||
expected += data
|
||||
until(lambda: got() == expected, "large paste arrives whole and framed")
|
||||
# This is a new keypress after the paste, not part of its sub-millisecond burst.
|
||||
# tmux's default assume-paste-time bypasses bindings for such bursts, even on
|
||||
# a direct native attach. Respect that fixture option without changing it.
|
||||
time.sleep((int(call("show-options", "-gv", "assume-paste-time")) + 20) / 1000)
|
||||
os.write(first[1], b"\x02\x1b[5~")
|
||||
until(lambda: call("display-message", "-p", "-t", "seat:", "#{pane_in_mode}") == "1", "prefix then PageUp enters native copy mode")
|
||||
assert got() == expected, "prefix and PageUp do not reach the agent"
|
||||
print(json.dumps({"sizes":observed,"localPin":"unset","nativeInput":True,"firstKeys":True,"pasteBytes":len(data),"prefixCopyMode":True}))
|
||||
finally:
|
||||
subprocess.run(["tmux","-S",sock,"kill-server"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for proc, master, slave in clients:
|
||||
os.close(master); os.close(slave)
|
||||
try: proc.wait(timeout=4)
|
||||
except subprocess.TimeoutExpired: proc.terminate(); proc.wait(timeout=3)
|
||||
shutil.rmtree(root)
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
|
||||
shell, writable, readonly = sys.argv[1], json.loads(sys.argv[2]), json.loads(sys.argv[3])
|
||||
tmux = shutil.which("tmux")
|
||||
|
||||
# Wider than any tile below, so a tile that attached would show only the start of it.
|
||||
LINE = "START " + " ".join(str(i) for i in range(1, 45)) + " END"
|
||||
# A rule as wide as the seat's window, as Claude Code draws them.
|
||||
@@ -155,168 +156,8 @@ def read_only_beside_another_client(server):
|
||||
return {"window": server.geometry()}
|
||||
|
||||
|
||||
def writable_tile(server):
|
||||
tile = server.tile(writable, 40, 30)
|
||||
server.wait_for(lambda: LINE in server.seen(tile), "a writable tile shows the whole line before anyone types")
|
||||
# Every key is read as a key: no flow control, and no other special handling.
|
||||
iflags, flags = termios.tcgetattr(tile[2])[0], termios.tcgetattr(tile[2])[3]
|
||||
assert not iflags & termios.IXON and not flags & termios.IEXTEN, "a showing tile must read every key"
|
||||
assert server.client_ttys() == [] and server.geometry() == "160x20", "an unused writable tile is no client"
|
||||
# The first key reaches the seat, and the tile attaches for the rest.
|
||||
os.write(tile[1], b"hello")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first key attaches the tile")
|
||||
server.wait_for(lambda: "hello" in server.seat_screen(), "the first key reaches the seat")
|
||||
os.write(tile[1], b" world")
|
||||
server.wait_for(lambda: "hello world" in server.seat_screen(), "typing in the attached tile reaches the seat")
|
||||
# The tile sizes the seat while it's used, as any client the person types in does.
|
||||
server.wait_for(lambda: server.geometry() == "40x30", "the tile in use sizes the seat")
|
||||
used = server.geometry()
|
||||
# Idle for the tile's idle time (2 s here): it detaches back to the view.
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# After tmux's detach line, the view redraws the seat's screen itself.
|
||||
def viewing_again():
|
||||
after = server.seen(tile).rpartition("[detached")[2]
|
||||
return "\x1b[H\x1b[2J" in after and "hello world" in after.rpartition("\x1b[H\x1b[2J")[2]
|
||||
server.wait_for(viewing_again, "the detached tile views the seat again")
|
||||
# Enter as the first key goes to the seat, and attaches the tile again.
|
||||
os.write(tile[1], b"\r")
|
||||
server.wait_for(lambda: server.seat_screen().count("hello world") == 2, "Enter as the first key reaches the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first key attaches the tile again")
|
||||
return {"used": used}
|
||||
|
||||
|
||||
def recorder(server):
|
||||
# The seat asks for bracketed paste, as Claude Code does, and records every byte it's given, as a raw terminal
|
||||
# delivers them.
|
||||
return "printf \"\\033[?2004h\"; stty raw -echo; exec dd bs=1 of=" + server.socket + ".got 2>/dev/null"
|
||||
|
||||
|
||||
def received(server):
|
||||
try:
|
||||
return open(server.socket + ".got", "rb").read()
|
||||
except FileNotFoundError:
|
||||
return b""
|
||||
|
||||
|
||||
def writable_first_input(server):
|
||||
tile = server.tile(writable, 40, 30)
|
||||
server.wait_for(lambda: "\x1b[H\x1b[2J" in server.seen(tile), "the tile views the seat")
|
||||
# A paste that breaks inside a UTF-8 character, the rest arriving after the break.
|
||||
os.write(tile[1], b"A" * 63 + b"\xc3")
|
||||
time.sleep(0.3)
|
||||
os.write(tile[1], b"\xa9END")
|
||||
expected = b"A" * 63 + "\u00e9".encode() + b"END"
|
||||
server.wait_for(lambda: received(server) == expected, "the first input reaches the seat byte for byte")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first input attaches the tile")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# Ctrl-Space types a NUL.
|
||||
os.write(tile[1], b"\x00")
|
||||
expected += b"\x00"
|
||||
server.wait_for(lambda: received(server) == expected, "a NUL first key reaches the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "a NUL first key attaches the tile")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# tmux's prefix works as in an attached tile: Ctrl-b d detaches at once, and nothing reaches the seat.
|
||||
os.write(tile[1], b"\x02d")
|
||||
server.wait_for(lambda: "[detached" in server.seen(tile).rpartition("\x1b[?2004h")[0][-400:] or
|
||||
server.seen(tile).count("[detached") >= 3, "Ctrl-b d detaches the tile")
|
||||
time.sleep(0.5)
|
||||
assert received(server) == expected, "the prefix and its key must not reach the seat: " + repr(received(server)[len(expected):])
|
||||
# A paste while showing: the tile's terminal frames it only if the tile asked for that, as a terminal does.
|
||||
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
|
||||
framed = server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("\x1b[?2004l")
|
||||
paste = b"first line\rsecond line"
|
||||
os.write(tile[1], b"\x1b[200~" + paste + b"\x1b[201~" if framed else paste)
|
||||
expected += b"\x1b[200~" + paste + b"\x1b[201~"
|
||||
server.wait_for(lambda: received(server) == expected, "a paste reaches the seat as one paste")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# Ctrl-S and Ctrl-Q are keys like any other.
|
||||
os.write(tile[1], b"\x13\x11")
|
||||
expected += b"\x13\x11"
|
||||
server.wait_for(lambda: received(server) == expected, "Ctrl-S and Ctrl-Q reach the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "Ctrl-S and Ctrl-Q attach the tile")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# A key and, at once, a paste: the paste is still framed, and both arrive in order.
|
||||
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
|
||||
os.write(tile[1], b"A")
|
||||
time.sleep(0.03)
|
||||
framed = server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("\x1b[?2004l")
|
||||
os.write(tile[1], b"\x1b[200~" + paste + b"\x1b[201~" if framed else paste)
|
||||
expected += b"A\x1b[200~" + paste + b"\x1b[201~"
|
||||
server.wait_for(lambda: received(server) == expected, "a key then a paste arrive in order, the paste framed")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# A run of text that ends in ; keeps it, through the client and before a paste alike.
|
||||
os.write(tile[1], b"ls;\r")
|
||||
expected += b"ls;\r"
|
||||
server.wait_for(lambda: received(server) == expected, "a trailing ; reaches the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
|
||||
os.write(tile[1], b"x;\x1b[200~" + paste + b"\x1b[201~")
|
||||
expected += b"x;\x1b[200~" + paste + b"\x1b[201~"
|
||||
server.wait_for(lambda: received(server) == expected, "a trailing ; before a paste reaches the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# An arrow as the first key reaches the agent as typed.
|
||||
os.write(tile[1], b"\x1b[A")
|
||||
expected += b"\x1b[A"
|
||||
server.wait_for(lambda: received(server) == expected, "Up as the first key reaches the seat as Up")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# The prefix, then PgUp as a person types them: tmux scrolls back, and nothing reaches the seat.
|
||||
os.write(tile[1], b"\x02")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the prefix attaches the tile")
|
||||
os.write(tile[1], b"\x1b[5~")
|
||||
server.wait_for(lambda: server.call("display-message", "-p", "-t", "seat:", "#{pane_in_mode}") == "1", "Ctrl-b PgUp opens copy mode")
|
||||
os.write(tile[1], b"q")
|
||||
server.wait_for(lambda: server.call("display-message", "-p", "-t", "seat:", "#{pane_in_mode}") == "0", "q leaves copy mode")
|
||||
time.sleep(0.3)
|
||||
assert received(server) == expected, "Ctrl-b PgUp must not type into the seat: " + repr(received(server)[len(expected):])
|
||||
return {"bytes": len(received(server))}
|
||||
|
||||
|
||||
def writable_big_paste(server):
|
||||
tile = server.tile(writable, 40, 30)
|
||||
server.wait_for(lambda: "\x1b[?2004h" in server.seen(tile), "the tile frames pastes while it shows")
|
||||
# A paste of about 450 KB, framed as a terminal frames it, written as fast as the tile reads it.
|
||||
data = b"\x1b[200~" + b"".join(b"line %06d of a long paste\r" % i for i in range(16000)) + b"\x1b[201~"
|
||||
sent, deadline = 0, time.monotonic() + 90
|
||||
while sent < len(data):
|
||||
try:
|
||||
sent += os.write(tile[1], data[sent:sent + 4096])
|
||||
except BlockingIOError:
|
||||
time.sleep(0.002)
|
||||
server.drain()
|
||||
assert time.monotonic() < deadline, "the tile must keep reading the paste"
|
||||
server.wait_for(lambda: received(server) == data, "a paste over 256 KiB reaches the seat whole and framed", 90)
|
||||
server.wait_for(lambda: server.client_ttys() == [], "the tile detaches when idle after the paste", 10)
|
||||
return {"bytes": len(data)}
|
||||
|
||||
|
||||
def writable_first_input_window(server):
|
||||
# The attach is slowed to a second, so keys typed after the first burst land before tmux takes the terminal.
|
||||
slow = server.socket + ".slow-tmux"
|
||||
open(slow, "w").write("#!/bin/sh\ncase \" $* \" in *\" attach \"*) sleep 1;; esac\nexec " + tmux + " \"$@\"\n")
|
||||
os.chmod(slow, 0o700)
|
||||
argv = list(writable)
|
||||
argv[5] = slow
|
||||
tile = server.tile(argv, 40, 30)
|
||||
server.wait_for(lambda: "\x1b[H\x1b[2J" in server.seen(tile), "the tile views the seat")
|
||||
os.write(tile[1], b"y")
|
||||
time.sleep(0.5)
|
||||
# Enter and Ctrl-C, before the attach completes.
|
||||
os.write(tile[1], b"\r\x03")
|
||||
server.wait_for(lambda: received(server) == b"y\r\x03", "Enter and Ctrl-C before the attach reach the seat as typed")
|
||||
assert tile[0].poll() is None, "Ctrl-C before the attach must not end the tile"
|
||||
return {"bytes": len(received(server))}
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="tile-size-") as temporary:
|
||||
root = os.path.realpath(temporary)
|
||||
if sys.argv[4:] == ["big-paste"]:
|
||||
print(json.dumps({"bigPaste": cell(root, "f", writable_big_paste, recorder)}))
|
||||
sys.exit(0)
|
||||
result = {
|
||||
"readOnlyAlone": cell(root, "a", read_only_alone),
|
||||
"readOnlyBesideAnother": cell(root, "b", read_only_beside_another_client),
|
||||
"writable": cell(root, "c", writable_tile),
|
||||
"firstInput": cell(root, "d", writable_first_input, recorder),
|
||||
"firstInputWindow": cell(root, "e", writable_first_input_window, recorder),
|
||||
}
|
||||
print(json.dumps(result))
|
||||
print(json.dumps({
|
||||
"readOnlyAlone": cell(temporary, "a", read_only_alone),
|
||||
"readOnlyBesideAnother": cell(temporary, "b", read_only_beside_another_client),
|
||||
}))
|
||||
@@ -570,7 +570,7 @@ it("keeps a real empty mission workspace enabled through Core with a mission wai
|
||||
describe("follow views kept from an earlier release", () => {
|
||||
const quote = (s: string) => "'" + s.replace(/'/g, "'\"'\"'") + "'";
|
||||
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
"a writable follow view kept from %s still adds and closes tiles, and moves to the current script", async (_, make) => {
|
||||
"a kept %s follow view reuses sizing clients or names the explicit reopen step", async (_, make) => {
|
||||
const f = fixture(); expect(await f.follow()).toMatchObject({ ok: true, opened: ["a", "b", "c"] });
|
||||
// Rewrite the saved canvas and the viewer journal as that release left them.
|
||||
const earlier = (seat: string) => make("/usr/bin/tmux", ["-S", "/tmp/follow-seats.sock"], seat, false);
|
||||
@@ -584,6 +584,13 @@ describe("follow views kept from an earlier release", () => {
|
||||
}
|
||||
writeFileSync(f.journal(), JSON.stringify(journal));
|
||||
|
||||
if (make !== tileAttachArgv0710) {
|
||||
const before = f.calls.length;
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, following: { state: "retained" }, error: expect.stringContaining("Close only the owned Herdr viewer pane") });
|
||||
expect(f.calls.slice(before).some(c => ["create", "split", "run", "close"].includes(c[1]!))).toBe(false);
|
||||
expect(JSON.parse(readFileSync(canvasFile, "utf8")).tiles.map((t: { target: unknown }) => t.target)).toEqual(canvas.tiles.map((t: { target: unknown }) => t.target));
|
||||
return; // An operator closes the old viewer and opens a fresh ordinary view; no live-client migration claim.
|
||||
}
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
|
||||
await f.follow(["a", "b", "d"]); f.advance(4000);
|
||||
expect(await f.follow(["a", "b", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "d"] });
|
||||
|
||||
@@ -242,17 +242,11 @@ describe("owned Herdr viewer", () => {
|
||||
expect(await new HerdrViewer(socket, async () => { throw new Error("should not run"); }, "win32", f.root).ensure(key, f.target)).toMatchObject({ ok: false, code: "viewer_unsupported" });
|
||||
expect(await new HerdrViewer(socket, async () => JSON.stringify({ running: true, version: "0.9.5", protocol: 22, socket: "/other.sock" }), "linux", f.root).ensure(key, f.target)).toMatchObject({ ok: false });
|
||||
});
|
||||
it("a writable tile attaches, sizing, only from its one typing path; a read-only tile never attaches; recovery still attaches", () => {
|
||||
it("a writable tile attaches immediately and sizes natively; a read-only tile remains an observer", () => {
|
||||
const rw = tileAttachArgv("tmux", [], "a@team", false)[2]!;
|
||||
// Both attaches are the typing path's: a plain one (also after a paste), and one that carries the first keys.
|
||||
expect(rw.match(/attach -t/g)).toEqual(['attach -t', 'attach -t']);
|
||||
expect(rw).toContain('then pz=1; put "$@"; fi;');
|
||||
expect(rw).toContain('then go "$@"; else "$@" attach -t "$t"; fi;');
|
||||
expect(rw).toContain('go() { set -- "$@" attach -t "$t";');
|
||||
// The first input's files are readable by this user alone.
|
||||
expect(rw.startsWith("umask 077;")).toBe(true);
|
||||
expect(rw.match(/open "/g)).toHaveLength(4);
|
||||
expect(rw).toBe(tileAttachArgv0710("tmux", [], "a@team", false)[2]);
|
||||
expect(rw).not.toContain("ignore-size");
|
||||
expect(rw).not.toContain("use()");
|
||||
const ro = tileAttachArgv("tmux", [], "a@team", true)[2]!;
|
||||
expect(ro).toContain('open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };');
|
||||
expect(ro).not.toMatch(/attach -r|while use/);
|
||||
@@ -335,15 +329,28 @@ describe("viewers kept from an earlier release", () => {
|
||||
return () => JSON.parse(readFileSync(file, "utf8")).entries[id] as { command: string; argv: string[] };
|
||||
}
|
||||
|
||||
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
"keeps a writable viewer recorded by %s and moves it to the current script", async (_, make) => {
|
||||
const f = fixture(); expect((await f.viewer().ensure(key, f.target)).ok).toBe(true);
|
||||
const stored = keepFrom(f, make, false);
|
||||
expect(stored().command).not.toBe(f.target.paneCommand);
|
||||
expect(await f.viewer().lookup(key, f.target)).toMatchObject({ ok: true });
|
||||
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: false });
|
||||
expect(stored()).toMatchObject({ command: f.target.paneCommand, argv: f.target.attachArgv });
|
||||
it("keeps 0.7.10's already-sizing writable viewer unchanged", async () => {
|
||||
const f = fixture(); await f.viewer().ensure(key, f.target);
|
||||
const stored = keepFrom(f, tileAttachArgv0710, false);
|
||||
expect(stored().command).toBe(f.target.paneCommand);
|
||||
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: false });
|
||||
expect(f.calls.filter(a => a[1] === "create")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
"names the close-owned-viewer step for a kept %s writable client, without pretending argv migrated it", async (_, make) => {
|
||||
const f = fixture(); await f.viewer().ensure(key, f.target);
|
||||
const stored = keepFrom(f, make, false), before = stored();
|
||||
const result = await f.viewer().ensure(key, f.target);
|
||||
expect(result).toMatchObject({ ok: false, code: "viewer_unconfirmed" });
|
||||
expect(result.ok ? "" : result.error).toContain("Close only the owned Herdr viewer pane");
|
||||
expect(stored()).toEqual(before);
|
||||
expect(f.calls.filter(a => a[1] === "create")).toHaveLength(1);
|
||||
f.lose(false); // the person closed that viewer; complete endpoint enumeration proves absence
|
||||
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ code: "viewer_not_provisioned" });
|
||||
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: true });
|
||||
expect(stored()).toMatchObject({ command: f.target.paneCommand, argv: f.target.attachArgv });
|
||||
expect(f.calls.filter(a => a[0] === "workspace" && a[1] === "delete")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
// A host pairing hands over this daemon's bearer token only on an explicit decision. Where the kernel operator runs, the
|
||||
// person's answer, a click or their words, goes to it and it decides (`rig host approve|deny`); where none runs, the
|
||||
// person's Approve or Deny choice decides, or an agent seat's close that names Approve. Typed words never deny, and
|
||||
// never approve by themselves.
|
||||
// Test-only: real hosts routes, Mission Control HTTP route and write contract (Steering's and the TUI's answer path),
|
||||
// the real Slack inbound router (a button and a thread reply) and migrated SQLite. No Slack network, no real token.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { makeTopologyDestinationCheck } from "../src/domain/queue-destination-check.js";
|
||||
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { missionControlRoutes } from "../src/routes/mission-control.js";
|
||||
import { hostsRoutes } from "../src/routes/hosts.js";
|
||||
import { InboundRouter } from "../src/domain/gateway/slack/inbound.js";
|
||||
import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js";
|
||||
import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js";
|
||||
import { SeenStore, DeadLetterStore } from "../src/domain/gateway/slack/state-store.js";
|
||||
|
||||
const BEARER = "synthetic-pairing-bearer-fixture";
|
||||
const PERSON = "pair-person@external", AGENT = "dev-agent@fixture-rig", OPERATOR = "operator-agent@kernel";
|
||||
const registry = { ok: true as const, entities: [{ entityId: "pair-person", class: "human" as const, displayName: "Pairing person", address: PERSON,
|
||||
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "unused", role: "primary" as const, handle: "UPAIR" }], prefs: { deliveryClass: "A" as const } }] };
|
||||
|
||||
const closes: Array<() => void> = [];
|
||||
afterEach(() => { for (const close of closes.splice(0).reverse()) close(); });
|
||||
|
||||
/** The production destination check. `kernel: false` is a daemon started without a kernel rig (--no-kernel), where no
|
||||
* row can be addressed to `…@kernel`; the agent's rig is registered either way. `operator` is the kernel operator's
|
||||
* session state when the request is made (none: no session row). */
|
||||
async function fixture({ kernel = true, operator, ownershipUnreadable = false }: { kernel?: boolean; operator?: "running" | "exited"; ownershipUnreadable?: boolean } = {}) {
|
||||
const home = mkdtempSync(join(tmpdir(), "pair-explicit-"));
|
||||
const db = new Database(join(home, "queue.sqlite")); migrate(db, ALL_MIGRATIONS); const bus = new EventBus(db);
|
||||
if (operator) {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-kernel", "kernel");
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)").run("node-operator", "rig-kernel", "operator.agent");
|
||||
db.prepare("INSERT INTO sessions (node_id, session_name, status) VALUES (?, ?, ?)").run("node-operator", OPERATOR, operator);
|
||||
}
|
||||
const rigs = kernel ? ["kernel", "fixture-rig"] : ["fixture-rig"];
|
||||
const validateRig = makeTopologyDestinationCheck({ findRigsByName: (name) => rigs.filter((rig) => rig === name),
|
||||
loadHumanRegistry: () => registry, sharedChannels: () => [] });
|
||||
const repo = new QueueRepository(db, bus, { validateRig, loadHumanRegistry: () => registry,
|
||||
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
|
||||
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never); c.set("missionControlWriteContract" as never, contract as never); await next(); });
|
||||
app.route("/api/mission-control", missionControlRoutes());
|
||||
app.route("/api/hosts", hostsRoutes({ bearerToken: BEARER, humanRegistry: () => registry }));
|
||||
closes.push(() => { db.close(); rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
// The ownership read fails while the request is made (the sessions lookup throws).
|
||||
const real = db.prepare.bind(db);
|
||||
const unreadable = ownershipUnreadable ? vi.spyOn(db, "prepare").mockImplementation(((sql: string) => {
|
||||
if (sql.includes("FROM sessions WHERE session_name = ? LIMIT 1")) throw new Error("sessions unreadable");
|
||||
return real(sql);
|
||||
}) as typeof db.prepare) : null;
|
||||
const issued = await (await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requester: "requester@far-host" }) })).json() as { pairId: string; code: string; approvalQitemId: string };
|
||||
unreadable?.mockRestore();
|
||||
const ask = issued.approvalQitemId;
|
||||
const router = new InboundRouter({ queue: makeQueuePorts(repo), destination: "host-pair@kernel", seen: new SeenStore(join(home, "seen.jsonl")),
|
||||
deadLetter: new DeadLetterStore(join(home, "dead.jsonl")), resolveSender: () => ({ admitted: true, source: PERSON }),
|
||||
// The thread map's seat for a posted ask is the ask's source (slack-subsystem's threadMap.open).
|
||||
resolveRoute: () => ({ destination: repo.getById(ask)!.sourceSession, correlationQitemId: ask }),
|
||||
acknowledgeAnswer: async () => {}, recordHumanAnswer: input => repo.recordHumanAnswer(input),
|
||||
resolveHumanReply: makeHumanReplyResolver(repo, contract) });
|
||||
|
||||
/** The requester's poll: the status, and whether the response carries the token anywhere. */
|
||||
const poll = async () => {
|
||||
const res = await app.request(`/api/hosts/pair-request/${issued.pairId}`);
|
||||
const text = await res.text();
|
||||
return { http: res.status, body: res.status === 200 ? JSON.parse(text) as { status: string; token?: string } : null, leaksToken: text.includes(BEARER) };
|
||||
};
|
||||
/** Steering's question card and the TUI's acts both post Mission Control's answer verb. The TUI's carry the primary
|
||||
* user's authority and no seat header (attention-model.ts answerRequest); the route honours it only then. */
|
||||
const answer = async (input: Record<string, unknown>, qitemId = ask) => {
|
||||
const tui = input.authority === "primary-user";
|
||||
const res = await app.request("/api/mission-control/action", { method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(tui ? {} : { "X-OpenRig-Session": PERSON }) },
|
||||
body: JSON.stringify({ verb: "answer", qitemId, actorSession: PERSON, ...input }) });
|
||||
return { status: res.status, body: await res.json() as Record<string, unknown> };
|
||||
};
|
||||
/** `rig host approve|deny <request>`: from a seat (its header), or from a plain terminal (no seat). */
|
||||
const decide = async (request: string, decision: string, seat?: string) => {
|
||||
const res = await app.request("/api/hosts/pair-decision", { method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${BEARER}`, ...(seat ? { "X-OpenRig-Session": seat } : {}) },
|
||||
body: JSON.stringify({ request, decision }) });
|
||||
return { status: res.status, body: await res.json() as Record<string, unknown> };
|
||||
};
|
||||
/** Rows to the person other than the request: a re-ask, or the result's update. */
|
||||
const toPerson = () => repo.list({ limit: 1000 }).filter((item) => item.destinationSession === PERSON && item.qitemId !== ask);
|
||||
const slackButton = (optionId: string) => router.routeAction({ type: "block_actions", user: { id: "UPAIR" }, channel: { id: "CPAIR" },
|
||||
container: { type: "message", message_ts: "1.1", channel_id: "CPAIR" }, message: { ts: "1.1" },
|
||||
actions: [{ type: "button", block_id: "or-q:pair", action_id: `or-opt:${optionId}`, value: optionId, action_ts: "2.2" }] });
|
||||
const slackThreadReply = (text: string) => router.route({ type: "message", user: "UPAIR", channel: "CPAIR", thread_ts: "1.1", ts: "3.3", text });
|
||||
/** Rows created in reply to the request: an answer to it is kept on the request itself, so there are none. */
|
||||
const replyRows = () => repo.list({ limit: 1000 }).filter((item) => item.replyTo === ask || item.destinationSession === "host-pair@kernel");
|
||||
return { repo, contract, ask, issued, poll, answer, decide, toPerson, slackButton, slackThreadReply, replyRows };
|
||||
}
|
||||
|
||||
describe("host pairing with no kernel operator running: Approve and Deny decide, typed words are asked again", () => {
|
||||
it("the request carries one question, Approve or Deny, and tells the person typed words are kept and asked again", async () => {
|
||||
const f = await fixture();
|
||||
const item = f.repo.getById(f.ask)!;
|
||||
expect(item.sourceSession).toBe("host-pair@kernel");
|
||||
expect(item.humanQuestions).toEqual([{ id: "pair", question: `Pair requester@far-host (code ${f.issued.code}) with this host?`, options: [
|
||||
{ id: "approve", label: "Approve: hand this host's bearer token to the requester" }, { id: "deny", label: "Deny" }] }]);
|
||||
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (it has no ${OPERATOR}).`);
|
||||
expect(item.body).toContain(`rig host approve ${f.issued.code}, or rig host deny ${f.issued.code}`);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
});
|
||||
|
||||
// Steering's and the TUI's "reply <text>": the person's words close the request and are kept under its question.
|
||||
// Nothing on this host reads them, so they never deny: the person is asked again, and their choice there decides.
|
||||
it.each(["no", "yes", "who is this?"])("a typed reply %j (Steering or the TUI) never denies: words kept, the person is asked again, and Approve there pairs", async (words) => {
|
||||
const f = await fixture();
|
||||
expect((await f.answer({ decision: words })).status).toBe(200);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", closureReason: "no-follow-on",
|
||||
humanAnswers: { pair: { kind: "typed-reply", text: words } } });
|
||||
const [again, ...more] = f.toPerson();
|
||||
expect(more).toEqual([]);
|
||||
expect(again).toMatchObject({ state: "pending", sourceSession: "host-pair@kernel", humanQuestions: f.repo.getById(f.ask)!.humanQuestions });
|
||||
expect(again!.body).toContain(`You wrote "${words}" in reply to this request. Your words are kept, and they didn't deny anything`);
|
||||
// Polling again asks nothing more.
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.toPerson()).toHaveLength(1);
|
||||
expect((await f.answer({ questionId: "pair", optionId: "approve" }, again!.qitemId)).status).toBe(200);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
});
|
||||
|
||||
it.each(["yes", "no"])("a typed Slack thread reply %j never denies: words kept, the person is asked again", async (words) => {
|
||||
const f = await fixture();
|
||||
await f.slackThreadReply(words);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: { kind: "typed-reply", text: words } } });
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ state: "pending", sourceSession: "host-pair@kernel" })]);
|
||||
});
|
||||
|
||||
it("a plain terminal on this host decides with rig host approve, recorded as a direct human action, and the person is told", async () => {
|
||||
const f = await fixture();
|
||||
await f.answer({ decision: "yes please" });
|
||||
await f.poll();
|
||||
const again = f.toPerson()[0]!;
|
||||
expect(await f.decide(f.issued.code, "approve")).toEqual({ status: 200,
|
||||
body: { ok: true, decision: "approve", code: f.issued.code, requester: "requester@far-host", by: "human@host" } });
|
||||
// The open re-ask is closed with the result, and one update tells the person.
|
||||
expect(f.repo.getById(again.qitemId)).toMatchObject({ state: "done", closureReason: "no-follow-on", resolution: "pairing approved by human@host" });
|
||||
expect(f.toPerson().filter((item) => item.humanIntent === "update")).toEqual([expect.objectContaining({
|
||||
body: `Pairing request ${f.issued.code} from requester@far-host: approved by human@host. This host's token is handed over when the requesting machine next checks, if it's still waiting.` })]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Steering's question card", { questionId: "pair", optionId: "approve" }],
|
||||
["the TUI's Approve act (primary user)", { questionId: "pair", optionId: "approve", authority: "primary-user" }],
|
||||
] as const)("%s: Approve hands the token over once, then the pairing dies", async (_label, input) => {
|
||||
const f = await fixture();
|
||||
expect((await f.answer(input)).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: "approve" } });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
});
|
||||
|
||||
it("Slack's Approve button hands the token over once", async () => {
|
||||
const f = await fixture();
|
||||
await f.slackButton("approve");
|
||||
expect(f.repo.getById(f.ask)?.humanAnswers).toEqual({ pair: "approve" });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Steering's Deny choice", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "deny" })],
|
||||
["Slack's Deny button", (f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("deny")],
|
||||
] as const)("%s denies, with no token", async (_label, act) => {
|
||||
const f = await fixture();
|
||||
await act(f);
|
||||
expect(f.repo.getById(f.ask)?.humanAnswers).toEqual({ pair: "deny" });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
|
||||
// An answer is kept on the request, like a runner's permission request's: no reply row goes to host-pair@kernel, so it
|
||||
// works the same on a daemon without a kernel rig, and nothing is left for a seat that doesn't exist.
|
||||
it.each([true, false])("kernel rig registered: %s. An Approve choice (Steering, the TUI, Slack) closes the request in place and approves once", async (kernel) => {
|
||||
for (const [actor, act] of [
|
||||
[PERSON, (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "approve" })],
|
||||
// The terminal names no responder: the TUI's act is recorded unattributed.
|
||||
["human@host", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "approve", authority: "primary-user" })],
|
||||
[PERSON, (f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("approve")],
|
||||
] as const) {
|
||||
const f = await fixture({ kernel });
|
||||
const result = await act(f);
|
||||
// Steering and the TUI answer over HTTP; Slack's router returns its own disposition.
|
||||
if (typeof (result as { status?: unknown }).status === "number") expect((result as { status: number }).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", closureReason: "no-follow-on", humanAnswers: { pair: "approve" } });
|
||||
expect(f.repo.closingTransition(f.ask, "done")?.actorSession).toBe(actor);
|
||||
expect(f.replyRows()).toEqual([]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])("kernel rig registered: %s. A Deny choice (Steering, Slack) closes the request in place and denies once", async (kernel) => {
|
||||
for (const act of [
|
||||
(f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "deny" }),
|
||||
(f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("deny"),
|
||||
]) {
|
||||
const f = await fixture({ kernel });
|
||||
const result = await act(f);
|
||||
if (typeof (result as { status?: unknown }).status === "number") expect((result as { status: number }).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: "deny" } });
|
||||
expect(f.repo.closingTransition(f.ask, "done")?.actorSession).toBe(PERSON);
|
||||
expect(f.replyRows()).toEqual([]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])("kernel rig registered: %s. A typed reply (Steering, Slack) closes the request in place, words kept, never denied", async (kernel) => {
|
||||
for (const act of [
|
||||
(f: Awaited<ReturnType<typeof fixture>>) => f.answer({ decision: "yes" }),
|
||||
(f: Awaited<ReturnType<typeof fixture>>) => f.slackThreadReply("yes"),
|
||||
]) {
|
||||
const f = await fixture({ kernel });
|
||||
const result = await act(f);
|
||||
// Steering and the TUI answer over HTTP; Slack's router returns its own disposition.
|
||||
if (typeof (result as { status?: unknown }).status === "number") expect((result as { status: number }).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: { kind: "typed-reply", text: "yes" } } });
|
||||
expect(f.repo.closingTransition(f.ask, "done")?.actorSession).toBe(PERSON);
|
||||
expect(f.replyRows()).toEqual([]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("a click that decides is told to the person as the outcome, apart from their click", async () => {
|
||||
const f = await fixture();
|
||||
await f.slackButton("approve");
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ humanIntent: "update", summary: `Pairing ${f.issued.code} from requester@far-host: approved`,
|
||||
body: `Pairing request ${f.issued.code} from requester@far-host: approved by ${PERSON}. This host's token was handed to the requesting machine.` })]);
|
||||
});
|
||||
|
||||
it("an agent seat approves only by naming the choice: rig queue update --state done --closure-reason no-follow-on --resolution approve", async () => {
|
||||
const f = await fixture();
|
||||
await f.repo.update({ qitemId: f.ask, actorSession: AGENT, state: "done", closureReason: "no-follow-on", resolution: "approve" });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an agent seat's close without --resolution approve", async (f: Awaited<ReturnType<typeof fixture>>) => {
|
||||
await f.repo.update({ qitemId: f.ask, actorSession: AGENT, state: "done", closureReason: "no-follow-on" }); }],
|
||||
["an agent seat's close naming another outcome", async (f: Awaited<ReturnType<typeof fixture>>) => {
|
||||
await f.repo.update({ qitemId: f.ask, actorSession: AGENT, state: "done", closureReason: "no-follow-on", resolution: "approved, I think" }); }],
|
||||
["a bare Approve verb, which records no choice", async (f: Awaited<ReturnType<typeof fixture>>) => {
|
||||
await f.contract.act({ verb: "approve", qitemId: f.ask, actorSession: PERSON }); }],
|
||||
] as const)("%s denies, with no token", async (_label, act) => {
|
||||
const f = await fixture();
|
||||
await act(f);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", closureReason: "no-follow-on" });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
|
||||
it("an explicit Deny wins over a later agent close that names Approve", async () => {
|
||||
const f = await fixture();
|
||||
// A recorded Deny whose return hasn't closed the request yet (its close failed), then an agent's approving close.
|
||||
f.repo.recordHumanAnswer({ qitemId: f.ask, actorSession: PERSON, questionId: "pair", optionId: "deny" });
|
||||
expect(f.repo.getById(f.ask)?.state).toBe("pending");
|
||||
await f.repo.update({ qitemId: f.ask, actorSession: AGENT, state: "done", closureReason: "no-follow-on", resolution: "approve" });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
|
||||
it("a recorded Approve whose return didn't close the request still approves once; a later Steering Deny before the poll denies", async () => {
|
||||
const approvedOpen = await fixture();
|
||||
approvedOpen.repo.recordHumanAnswer({ qitemId: approvedOpen.ask, actorSession: PERSON, questionId: "pair", optionId: "approve" });
|
||||
expect(approvedOpen.repo.getById(approvedOpen.ask)?.state).toBe("pending");
|
||||
expect(await approvedOpen.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
|
||||
const deniedAfter = await fixture();
|
||||
deniedAfter.repo.recordHumanAnswer({ qitemId: deniedAfter.ask, actorSession: PERSON, questionId: "pair", optionId: "approve" });
|
||||
await deniedAfter.contract.act({ verb: "deny", qitemId: deniedAfter.ask, actorSession: PERSON });
|
||||
expect(await deniedAfter.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("host pairing with the kernel operator running: the person's answer goes to it, and it decides", () => {
|
||||
it("the operator asks, and the request tells it to act on a clear answer with rig host approve|deny", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
const item = f.repo.getById(f.ask)!;
|
||||
expect(item).toMatchObject({ sourceSession: OPERATOR, destinationSession: PERSON, tier: "human-gate" });
|
||||
expect(item.body).toContain(`Answer with Approve or Deny, or in your own words. ${OPERATOR} reads your answer and acts on it`);
|
||||
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
|
||||
});
|
||||
|
||||
it.each(["yes", "no", "yes, but which machine?"])("a typed %j (Steering) reaches the operator as a reply with the person's words, and decides nothing by itself", async (words) => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
expect((await f.answer({ decision: words })).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", closureReason: "no-follow-on", humanAnswers: { pair: { kind: "typed-reply", text: words } } });
|
||||
const replies = f.repo.list({ limit: 1000 }).filter((row) => row.replyTo === f.ask);
|
||||
expect(replies).toEqual([expect.objectContaining({ sourceSession: PERSON, destinationSession: OPERATOR, state: "pending" })]);
|
||||
expect(replies[0]!.body).toContain(words);
|
||||
expect(replies[0]!.body).toContain(`In reply to: ${f.ask}`);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
// Nobody is asked again: the operator reads the words. The person is told once that their answer was received.
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ sourceSession: OPERATOR, humanIntent: "update",
|
||||
summary: `Pairing ${f.issued.code} from requester@far-host: answer received`,
|
||||
body: `Received "${words}" on pairing request ${f.issued.code} from requester@far-host. ${OPERATOR} reads it and acts on it. You'll see the result here, or a question if your answer isn't clear.` })]);
|
||||
});
|
||||
|
||||
it("a stopped kernel operator still owns the request: the person sees it isn't running, the answer is kept, and nothing is granted or denied", async () => {
|
||||
const f = await fixture({ operator: "exited" });
|
||||
const item = f.repo.getById(f.ask)!;
|
||||
expect(item.sourceSession).toBe(OPERATOR);
|
||||
expect(item.body).toContain(`${OPERATOR} isn't running right now, so your answer waits until it starts; nothing is granted or denied meanwhile. To decide sooner, in a terminal on this host with its operator bearer token: rig host approve ${f.issued.code}, or rig host deny ${f.issued.code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
|
||||
await f.slackButton("approve");
|
||||
expect(f.repo.list({ limit: 1000 }).filter((row) => row.replyTo === f.ask)).toEqual([expect.objectContaining({ destinationSession: OPERATOR, state: "pending" })]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
const [receipt, ...more] = f.toPerson();
|
||||
expect(more).toEqual([]);
|
||||
expect(receipt!.body).toContain(`Received your Approve choice on pairing request ${f.issued.code} from requester@far-host. ${OPERATOR} isn't running, so it may not have read it yet. Nothing is granted or denied until it acts, or the request expires at `);
|
||||
expect(receipt!.body).toContain(`To decide now, in a terminal on this host with its operator bearer token: rig host approve ${f.issued.code}, or rig host deny ${f.issued.code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
|
||||
// The person's terminal route works without a seat.
|
||||
expect((await f.decide(f.issued.code, "approve")).body).toMatchObject({ ok: true, by: "human@host" });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
});
|
||||
|
||||
it("a typed Slack thread reply reaches the operator too, and decides nothing by itself", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
await f.slackThreadReply("go ahead");
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: { kind: "typed-reply", text: "go ahead" } } });
|
||||
expect(f.repo.list({ limit: 1000 }).filter((row) => row.replyTo === f.ask)).toEqual([expect.objectContaining({ destinationSession: OPERATOR })]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
});
|
||||
|
||||
it.each(["approve", "deny"])("a %s click reaches the operator as context: no token until it decides", async (optionId) => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
expect((await f.answer({ questionId: "pair", optionId })).status).toBe(200);
|
||||
expect(f.repo.list({ limit: 1000 }).filter((row) => row.replyTo === f.ask)).toEqual([expect.objectContaining({ destinationSession: OPERATOR })]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
});
|
||||
|
||||
it("the operator approves by code: the token is handed over once, and the person is told who approved", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
await f.answer({ decision: "yes" });
|
||||
expect(await f.decide(f.issued.code, "approve", OPERATOR)).toMatchObject({ status: 200, body: { ok: true, decision: "approve", by: OPERATOR } });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ sourceSession: OPERATOR, humanIntent: "update",
|
||||
summary: `Pairing ${f.issued.code} from requester@far-host: approved` })]);
|
||||
});
|
||||
|
||||
it("the operator denies by the request's row: nothing is handed over", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
await f.answer({ decision: "no thanks" });
|
||||
expect(await f.decide(f.ask, "deny", OPERATOR)).toMatchObject({ status: 200, body: { ok: true, decision: "deny", by: OPERATOR } });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
expect(f.toPerson()[0]!.body).toBe(`Pairing request ${f.issued.code} from requester@far-host: denied by ${OPERATOR}. Nothing was handed over.`);
|
||||
});
|
||||
|
||||
it("a decision is made once: a second one, an unknown request or a malformed call changes nothing", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
expect((await f.decide(f.issued.code, "deny", OPERATOR)).status).toBe(200);
|
||||
expect(await f.decide(f.issued.code, "approve", AGENT)).toMatchObject({ status: 409, body: { error: "pair_request_decided", decision: "deny", by: OPERATOR } });
|
||||
expect(await f.decide("000000", "approve", OPERATOR)).toMatchObject({ status: 404, body: { error: "pair_request_unknown" } });
|
||||
expect(await f.decide(f.issued.code, "yes", OPERATOR)).toMatchObject({ status: 400, body: { error: "pair_decision_invalid" } });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
|
||||
it("after expiry a decision is refused, the request reads expired, and an answer the operator didn't pick up is named", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
await f.answer({ decision: "yes" });
|
||||
const later = Date.now() + 11 * 60_000;
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(later);
|
||||
try {
|
||||
expect(await f.decide(f.issued.code, "approve", OPERATOR)).toMatchObject({ status: 409, body: { error: "pair_request_expired" } });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "expired" }, leaksToken: false });
|
||||
} finally { now.mockRestore(); }
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ humanIntent: "update",
|
||||
body: `Pairing request ${f.issued.code} from requester@far-host: expired before it was decided: ${OPERATOR} didn't pick up your answer. Nothing was handed over; the requester can run rig host pair again.` })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host pairing: one decision per request, and no reply is denied on a technicality", () => {
|
||||
it("a Deny click the requester hasn't polled yet stands: rig host approve is refused, and the poll denies", async () => {
|
||||
const f = await fixture();
|
||||
await f.slackButton("deny");
|
||||
expect(await f.decide(f.issued.code, "approve")).toMatchObject({ status: 409, body: { error: "pair_request_decided", decision: "deny", by: PERSON } });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
|
||||
it("an operator's refusing close the requester hasn't polled yet stands: rig host approve is refused", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
await f.repo.update({ qitemId: f.ask, actorSession: OPERATOR, state: "denied" });
|
||||
expect(await f.decide(f.issued.code, "approve", AGENT)).toMatchObject({ status: 409, body: { error: "pair_request_decided", decision: "deny", by: OPERATOR } });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
|
||||
it("an attachment-only Slack reply on an owner host reaches the operator and is never denied: the person gets a receipt", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
await f.slackThreadReply("");
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", closureReason: "no-follow-on" });
|
||||
expect(f.repo.getById(f.ask)?.humanAnswers?.pair).toBeUndefined();
|
||||
expect(f.repo.list({ limit: 1000 }).filter((row) => row.replyTo === f.ask)).toEqual([expect.objectContaining({ destinationSession: OPERATOR })]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ body: expect.stringContaining(`Received your reply on pairing request ${f.issued.code}`) })]);
|
||||
});
|
||||
|
||||
it("an attachment-only Slack reply on an agent-free host is never denied: the person is asked again", async () => {
|
||||
const f = await fixture();
|
||||
await f.slackThreadReply("");
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ state: "pending", sourceSession: "host-pair@kernel",
|
||||
body: expect.stringContaining("Your reply to this request had no words to keep here, and it didn't deny anything") })]);
|
||||
});
|
||||
|
||||
// The queue keeps no words for the literal text "[file reply]" (the text a Slack file reply carries), so these closes
|
||||
// carry no answer: they're still the person's reply, and never a denial.
|
||||
it.each([
|
||||
["Steering", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ decision: "[file reply]" })],
|
||||
["Slack", (f: Awaited<ReturnType<typeof fixture>>) => f.slackThreadReply("[file reply]")],
|
||||
] as const)("the literal text [file reply] typed in %s is never denied: asked again with no operator, a receipt with one", async (_label, act) => {
|
||||
const free = await fixture();
|
||||
await act(free);
|
||||
expect(free.repo.getById(free.ask)?.humanAnswers?.pair).toBeUndefined();
|
||||
expect(await free.poll()).toEqual({ http: 200, body: { status: "pending", code: free.issued.code }, leaksToken: false });
|
||||
expect(free.toPerson()).toEqual([expect.objectContaining({ state: "pending", body: expect.stringContaining("Your reply to this request had no words to keep here") })]);
|
||||
|
||||
const owned = await fixture({ operator: "running" });
|
||||
await act(owned);
|
||||
expect(await owned.poll()).toEqual({ http: 200, body: { status: "pending", code: owned.issued.code }, leaksToken: false });
|
||||
expect(owned.toPerson()).toEqual([expect.objectContaining({ body: expect.stringContaining(`Received your reply on pairing request ${owned.issued.code}`) })]);
|
||||
});
|
||||
|
||||
it("when OpenRig can't read whether the operator runs, the receipt says so instead of saying it reads the answer", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
await f.answer({ decision: "yes" });
|
||||
const real = f.repo.db.prepare.bind(f.repo.db);
|
||||
const prepare = vi.spyOn(f.repo.db, "prepare").mockImplementation(((sql: string) => {
|
||||
if (sql.includes("status = 'running'")) throw new Error("sessions unreadable");
|
||||
return real(sql);
|
||||
}) as typeof f.repo.db.prepare);
|
||||
try {
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
} finally { prepare.mockRestore(); }
|
||||
const [receipt] = f.toPerson();
|
||||
expect(receipt!.body).toContain(`OpenRig couldn't tell whether ${OPERATOR} is running, so it may not have read it yet.`);
|
||||
expect(receipt!.body).toContain(`rig host approve ${f.issued.code}`);
|
||||
expect(receipt!.body).not.toContain("reads it and acts on it");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host pairing: exact expiry, and an agent-free request says why nobody reads answers", () => {
|
||||
it("the outcome update can't hold the token: a hanging update doesn't delay the approved response", async () => {
|
||||
const f = await fixture();
|
||||
await f.slackButton("approve");
|
||||
const create = f.repo.create.bind(f.repo);
|
||||
const hang = vi.spyOn(f.repo, "create").mockImplementation(((input: Parameters<typeof f.repo.create>[0]) =>
|
||||
input.humanIntent === "update" ? new Promise(() => {}) : create(input)) as typeof f.repo.create);
|
||||
try {
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
} finally { hang.mockRestore(); }
|
||||
});
|
||||
|
||||
it("a decision the requester never collected expires, and the person is told it was decided but not collected", async () => {
|
||||
const f = await fixture({ operator: "running" });
|
||||
expect((await f.decide(f.issued.code, "approve", OPERATOR)).status).toBe(200);
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 11 * 60_000);
|
||||
try {
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "expired" }, leaksToken: false });
|
||||
} finally { now.mockRestore(); }
|
||||
expect(f.toPerson().map((row) => row.body)).toContain(`Pairing request ${f.issued.code} from requester@far-host: expired after it was approved by ${OPERATOR}, before the requesting machine collected the result. Nothing was handed over; the requester can run rig host pair again.`);
|
||||
});
|
||||
|
||||
it("an operator the queue can't send answers to doesn't own the request, and the request says so", async () => {
|
||||
const f = await fixture({ kernel: false, operator: "running" });
|
||||
const item = f.repo.getById(f.ask)!;
|
||||
expect(item.sourceSession).toBe("host-pair@kernel");
|
||||
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: ${OPERATOR} can't receive answers on this host.`);
|
||||
});
|
||||
|
||||
it("when OpenRig can't read who owns the request, it's agent-free and says it couldn't tell", async () => {
|
||||
const f = await fixture({ operator: "running", ownershipUnreadable: true });
|
||||
const item = f.repo.getById(f.ask)!;
|
||||
expect(item.sourceSession).toBe("host-pair@kernel");
|
||||
expect(item.body).toContain("Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: OpenRig couldn't tell whether an agent reads answers on this host.");
|
||||
await f.answer({ decision: "yes" });
|
||||
await f.poll();
|
||||
expect(f.toPerson()[0]!.body).toContain("and they didn't deny anything: OpenRig couldn't tell whether an agent reads answers on this host, so please decide");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host pairing: expiry when the requester stopped waiting", () => {
|
||||
it("the request's ask closes as expired at its deadline, so a late Approve click there isn't accepted", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout"] });
|
||||
try {
|
||||
const f = await fixture();
|
||||
const later = Date.now() + 11 * 60_000;
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(later);
|
||||
try {
|
||||
vi.advanceTimersByTime(11 * 60_000);
|
||||
await vi.waitFor(() => expect(f.repo.getById(f.ask)?.state).toBe("canceled"));
|
||||
expect(f.repo.closingTransition(f.ask, "canceled")?.transitionNote).toBe(`pairing request ${f.issued.code} expired undecided`);
|
||||
const late = await f.answer({ questionId: "pair", optionId: "approve" });
|
||||
expect(late.status).not.toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "canceled" });
|
||||
expect(f.repo.getById(f.ask)?.humanAnswers?.pair).toBeUndefined();
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "expired" }, leaksToken: false });
|
||||
} finally { now.mockRestore(); }
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { tmpdir } from "node:os";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
@@ -414,7 +415,8 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]);
|
||||
// The full schema: the approval item carries its question (human_questions) and records the person's answer.
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
// The REAL gate composition (startup.ts shape): human seats pass
|
||||
// before parse — pairing's approval item rides it.
|
||||
repo = new QueueRepository(db, new EventBus(db), {
|
||||
@@ -469,6 +471,11 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
|
||||
expect(item.summary).toContain(code);
|
||||
expect(item.evidenceRef).toContain("pair-request:");
|
||||
expect(item.state).toBe("pending");
|
||||
// This host has no kernel operator, so the request is agent-free: Approve and Deny decide, typed words are kept and the
|
||||
// person is asked again, and a terminal on this host can decide with rig host approve|deny.
|
||||
expect(item.sourceSession).toBe("host-pair@kernel");
|
||||
expect(item.body).toContain("Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (it has no operator-agent@kernel).");
|
||||
expect(item.body).toContain(`To decide in a terminal on this host with its operator bearer token: rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
|
||||
});
|
||||
|
||||
it("a request stays open as long as its requester waits, and its approval says how long and until when", async () => {
|
||||
@@ -517,13 +524,14 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("approve (close done) → approved + the bearer token, SINGLE-SHOT; the pairing then dies", async () => {
|
||||
it("approve (an agent seat's close naming Approve) → approved + the bearer token, SINGLE-SHOT; the pairing then dies", async () => {
|
||||
const { pairId, approvalQitemId } = await issue();
|
||||
|
||||
const pending = await app.request(`/api/hosts/pair-request/${pairId}`);
|
||||
expect(((await pending.json()) as { status: string }).status).toBe("pending");
|
||||
|
||||
await repo.update({ qitemId: approvalQitemId, actorSession: "alex@external", state: "done", closureReason: "no-follow-on" });
|
||||
// A close with no choice named doesn't approve (host-pair-explicit-approval.test.ts); this one names it.
|
||||
await repo.update({ qitemId: approvalQitemId, actorSession: "dev-agent@fixture-rig", state: "done", closureReason: "no-follow-on", resolution: "approve" });
|
||||
|
||||
const approved = await app.request(`/api/hosts/pair-request/${pairId}`);
|
||||
const body = await approved.json() as { status: string; token: string };
|
||||
@@ -534,6 +542,23 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
|
||||
expect(second.status).toBe(404);
|
||||
});
|
||||
|
||||
// Steering's Deny and Drop close the item done, with closure denied or canceled (mission-control-write-contract.ts
|
||||
// verbToClosure); a done with any reason but no-follow-on isn't an approval. Each denies and hands nothing over.
|
||||
it.each([
|
||||
["Steering's Deny", { closureReason: "denied", closureTarget: "operator denied" }],
|
||||
["Steering's Drop", { closureReason: "canceled", closureTarget: "not now" }],
|
||||
["another closure", { closureReason: "escalation", closureTarget: "ops@team" }],
|
||||
] as const)("%s (done, not no-follow-on) → denied, with no token, and the pairing dies", async (_label, closure) => {
|
||||
const { pairId, approvalQitemId } = await issue();
|
||||
await repo.update({ qitemId: approvalQitemId, actorSession: "alex@external", state: "done", ...closure });
|
||||
expect(repo.getById(approvalQitemId)).toMatchObject({ state: "done", closureReason: closure.closureReason });
|
||||
const res = await app.request(`/api/hosts/pair-request/${pairId}`);
|
||||
const body = await res.json() as { status: string; token?: string };
|
||||
expect(body).toEqual({ status: "denied" });
|
||||
expect(JSON.stringify(body)).not.toContain(BEARER);
|
||||
expect((await app.request(`/api/hosts/pair-request/${pairId}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("deny → status denied, and the pairing dies (nothing to hand over)", async () => {
|
||||
const { pairId, approvalQitemId } = await issue();
|
||||
await repo.update({ qitemId: approvalQitemId, actorSession: "alex@external", state: "denied" });
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// A Pi seat's command is staged into a private script that execs it (execInScript), so the agent replaces the staging
|
||||
// shell. A declared account home adds a credential scrub to that command; composed as `unset …; …`, the staged
|
||||
// `exec unset …` failed and no agent started (0.7.11). The real SeatLaunchEnvironment composes each Pi caller's
|
||||
// command, the real TmuxAdapter stages it, and /bin/sh runs the staged script with stale provider keys in its environment.
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
|
||||
import { TmuxAdapter, type TmuxFileOps } from "../src/adapters/tmux.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
const stale = { PI_CODING_AGENT_DIR: "/pi/stale", MISTRAL_API_KEY: "fixture-mistral", GEMINI_API_KEY: "fixture-gemini", OPENAI_API_KEY: "fixture-openai", ANTHROPIC_API_KEY: "fixture-anthropic" };
|
||||
|
||||
async function staged(target: Parameters<SeatLaunchEnvironment["command"]>[2], selection: "selected" | "none") {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-staged-exec-")));
|
||||
roots.push(root);
|
||||
const cli = path.join(root, "rig");
|
||||
fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
// Stands in for the Pi runner: says which process it is and what it was given. A real seat's command runs past the
|
||||
// 512-byte direct-typing bound (its runner path, state root and environment), so it is staged; this path does too.
|
||||
const runnerDir = path.join(root, ...Array.from({ length: 8 }, () => "nested-runner-directory-" + "x".repeat(24)));
|
||||
fs.mkdirSync(runnerDir, { recursive: true });
|
||||
const runner = path.join(runnerDir, "pi-runner");
|
||||
fs.writeFileSync(runner, "#!/bin/sh\nprintf 'pid=%s\\n' \"$$\"\nenv\n", { mode: 0o700 });
|
||||
const paneTmux = {
|
||||
getPaneCommand: async () => "bash",
|
||||
getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "pi-node" : "seat@rig",
|
||||
} as unknown as TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(paneTmux, { PATH: "/usr/bin:/bin", OPENRIG_HOME: root }, root, cli, undefined,
|
||||
(_node, _runtime, clearing) => selection === "none" ? undefined : clearing ? "/pi/configured" : "/pi/selected");
|
||||
const command = await launch.command("seat@rig", runner, target);
|
||||
const script = path.join(root, "launch.sh");
|
||||
const files = new Map<string, string>();
|
||||
let names = 0;
|
||||
const fileOps: TmuxFileOps = {
|
||||
// The first name is the staged script; later ones are the paste buffer that types its invocation.
|
||||
tmpName: () => names++ === 0 ? script : path.join(root, "paste.txt"), bufferName: () => "buffer",
|
||||
writeFile: vi.fn(async (file, text) => { files.set(file, text); }),
|
||||
unlink: vi.fn(async file => { files.delete(file); }),
|
||||
};
|
||||
const transport = new TmuxAdapter(vi.fn(async (request: string) => request.includes("#{pane_current_command}") ? "bash" : ""), fileOps);
|
||||
expect(await transport.sendShellCommand("seat@rig", command, undefined, { stageIfLong: true, execInScript: true })).toEqual({ ok: true });
|
||||
const body = files.get(script);
|
||||
if (body === undefined) return { command, body, ran: null };
|
||||
fs.writeFileSync(script, body, { mode: 0o600 });
|
||||
const child = spawn("/bin/sh", [script], { env: { PATH: "/usr/bin:/bin", ...stale } });
|
||||
let out = "", err = "";
|
||||
child.stdout.on("data", c => { out += c; });
|
||||
child.stderr.on("data", c => { err += c; });
|
||||
const code = await new Promise<number | null>(resolve => child.on("close", resolve));
|
||||
return { command, body, ran: { code, out, err, shellPid: child.pid } };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["launch, selection read from the seat", { nodeId: "pi-node", runtime: "pi", generation: "g1" }, "/pi/selected"],
|
||||
["resume with the seat's selection", { runtime: "pi", nodeId: "pi-node", accountHome: "/pi/selected" }, "/pi/selected"],
|
||||
["account switch to a new home", { nodeId: "pi-node", runtime: "pi", accountHome: "/pi/switched" }, "/pi/switched"],
|
||||
["clear back to the configured home", { nodeId: "pi-node", runtime: "pi", accountHome: null }, "/pi/configured"],
|
||||
] as const)("%s: the staged script scrubs provider keys and execs the agent in its own process", async (_case, target, home) => {
|
||||
const { body, ran } = await staged(target, "selected");
|
||||
expect(body).toBeDefined();
|
||||
expect(ran!.err).toBe("");
|
||||
expect(ran!.code).toBe(0);
|
||||
expect(ran!.out).toContain(`PI_CODING_AGENT_DIR=${home}\n`);
|
||||
expect(ran!.out).not.toContain("fixture-");
|
||||
// exec: the agent took over the staging shell's process.
|
||||
expect(ran!.out).toContain(`pid=${ran!.shellPid}\n`);
|
||||
});
|
||||
|
||||
it("a Pi seat with no account selection composes and stages exactly as before", async () => {
|
||||
const { command, body, ran } = await staged({ nodeId: "pi-node", runtime: "pi" }, "none");
|
||||
expect(command.startsWith("/usr/bin/env '")).toBe(true);
|
||||
expect(command).not.toMatch(/(^|[ ;])(unset|-u) /);
|
||||
if (body !== undefined) {
|
||||
expect(body.split("\n")[1]).toBe(`exec ${command}`);
|
||||
expect(ran!.code).toBe(0);
|
||||
expect(ran!.out).toContain(`pid=${ran!.shellPid}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
it("a clear with no configured home still removes the stale home, and the agent still execs", async () => {
|
||||
const { body, ran } = await staged({ nodeId: "pi-node", runtime: "pi", accountHome: null }, "none");
|
||||
expect(body).toBeDefined();
|
||||
expect(ran!.err).toBe("");
|
||||
expect(ran!.code).toBe(0);
|
||||
expect(ran!.out).not.toContain("PI_CODING_AGENT_DIR=");
|
||||
expect(ran!.out).toContain(`pid=${ran!.shellPid}\n`);
|
||||
});
|
||||
@@ -44,7 +44,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
/** One browser viewer comes and goes through the broker. */
|
||||
async function browserVisit(name: string, adapter = new TmuxAdapter(exec)): Promise<void> {
|
||||
let empty = false;
|
||||
const broker = new TerminalSessionBroker(name, adapter, { pollMs: 20, livenessMs: 1000, onEmpty: () => { empty = true; } });
|
||||
const broker = new TerminalSessionBroker(name, adapter, { fixedGeometry: true, pollMs: 20, livenessMs: 1000, onEmpty: () => { empty = true; } });
|
||||
const browser = viewer();
|
||||
await broker.attach(browser);
|
||||
expect(await size(name)).toBe("90x27"); // the fixed geometry while a browser watches
|
||||
@@ -52,6 +52,22 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
await until(async () => empty);
|
||||
}
|
||||
|
||||
it("a default browser stays open while the native client resizes the agent PTY", async () => {
|
||||
await nativeSession("default-web");
|
||||
const client = clients.at(-1)!;
|
||||
const broker = new TerminalSessionBroker("default-web", new TmuxAdapter(exec), { pollMs: 20 });
|
||||
try {
|
||||
await broker.attach(viewer());
|
||||
expect(await ownSizing("default-web")).toBe("");
|
||||
expect(await size("default-web")).toBe("150x45");
|
||||
client.stdin!.write("refresh-client -C 52x28\n");
|
||||
await until(async () => (await size("default-web")) === "52x28");
|
||||
client.stdin!.write("refresh-client -C 132x40\n");
|
||||
await until(async () => (await size("default-web")) === "132x40");
|
||||
expect(await ownSizing("default-web")).toBe("");
|
||||
} finally { await broker.dispose(); }
|
||||
});
|
||||
|
||||
it("a window that inherited its sizing inherits again, and the native client's size comes back", async () => {
|
||||
await nativeSession("inherits");
|
||||
await browserVisit("inherits");
|
||||
@@ -59,10 +75,24 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
await until(async () => (await size("inherits")) === "150x45");
|
||||
});
|
||||
|
||||
it("a window with its own sizing keeps that value", async () => {
|
||||
it("an opt-in browser preserves a person's manual geometry with no OpenRig pin record", async () => {
|
||||
await nativeSession("user-manual");
|
||||
await tmux("resize-window", "-t", "=user-manual:", "-x", "113", "-y", "31");
|
||||
const broker = new TerminalSessionBroker("user-manual", new TmuxAdapter(exec), { fixedGeometry: true, pollMs: 20 });
|
||||
try {
|
||||
await broker.attach(viewer());
|
||||
expect(await size("user-manual")).toBe("113x31");
|
||||
expect(await ownSizing("user-manual")).toBe("manual");
|
||||
expect(await record("user-manual")).toBe("");
|
||||
} finally { await broker.dispose(); }
|
||||
expect(await size("user-manual")).toBe("113x31");
|
||||
expect(await ownSizing("user-manual")).toBe("manual");
|
||||
});
|
||||
|
||||
it("an opt-in pin releases to inherited sizing even after an old local override", async () => {
|
||||
await nativeSession("own", "largest");
|
||||
await browserVisit("own");
|
||||
expect(await ownSizing("own")).toBe("largest");
|
||||
expect(await ownSizing("own")).toBe("");
|
||||
await until(async () => (await size("own")) === "150x45");
|
||||
});
|
||||
|
||||
@@ -70,7 +100,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
await nativeSession("switch");
|
||||
const pinned = await tmux("display-message", "-p", "-t", "=switch:", "#{window_id}");
|
||||
let empty = false;
|
||||
const broker = new TerminalSessionBroker("switch", new TmuxAdapter(exec), { pollMs: 20, livenessMs: 1000, onEmpty: () => { empty = true; } });
|
||||
const broker = new TerminalSessionBroker("switch", new TmuxAdapter(exec), { fixedGeometry: true, pollMs: 20, livenessMs: 1000, onEmpty: () => { empty = true; } });
|
||||
const browser = viewer();
|
||||
await broker.attach(browser);
|
||||
const other = await tmux("new-window", "-d", "-P", "-F", "#{window_id}", "-t", "=switch:");
|
||||
@@ -97,7 +127,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
expect(await ownSizing(name)).toBe("manual");
|
||||
expect(await size(name)).toBe("90x27");
|
||||
await browserVisit(name);
|
||||
expect(await ownSizing(name)).toBe(own ?? "");
|
||||
expect(await ownSizing(name)).toBe("");
|
||||
expect(await record(name)).toBe("");
|
||||
await until(async () => (await size(name)) === "150x45");
|
||||
});
|
||||
@@ -110,7 +140,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
}
|
||||
let empty = false;
|
||||
const closed: number[] = [];
|
||||
const broker = new TerminalSessionBroker("probe", new Unreachable(exec), { pollMs: 20, livenessMs: 50, onEmpty: () => { empty = true; } });
|
||||
const broker = new TerminalSessionBroker("probe", new Unreachable(exec), { fixedGeometry: true, pollMs: 20, livenessMs: 50, onEmpty: () => { empty = true; } });
|
||||
await broker.attach({ send: () => {}, close: (code) => { closed.push(code); } });
|
||||
expect(await size("probe")).toBe("90x27");
|
||||
reachable = false;
|
||||
@@ -125,7 +155,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
await nativeSession(name, "largest");
|
||||
const adapter = new TmuxAdapter(exec);
|
||||
const empty = [false, false];
|
||||
const brokers = [0, 1].map((i) => new TerminalSessionBroker(name, adapter, { pollMs: 20, livenessMs: 1000, onEmpty: () => { empty[i] = true; } }));
|
||||
const brokers = [0, 1].map((i) => new TerminalSessionBroker(name, adapter, { fixedGeometry: true, pollMs: 20, livenessMs: 1000, onEmpty: () => { empty[i] = true; } }));
|
||||
const browsers = [viewer(), viewer()];
|
||||
await brokers[0].attach(browsers[0]);
|
||||
await brokers[1].attach(browsers[1]);
|
||||
@@ -136,7 +166,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
expect(await size(name)).toBe("90x27");
|
||||
brokers[staying].detach(browsers[staying]);
|
||||
await until(async () => empty[staying]);
|
||||
expect(await ownSizing(name)).toBe("largest");
|
||||
expect(await ownSizing(name)).toBe("");
|
||||
expect(await record(name)).toBe("");
|
||||
await until(async () => (await size(name)) === "150x45");
|
||||
});
|
||||
@@ -149,7 +179,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
|
||||
try {
|
||||
await t("new-session", "-d", "-s", "reborn", "-x", "120", "-y", "40");
|
||||
let empty = false;
|
||||
const broker = new TerminalSessionBroker("reborn", new TmuxAdapter(execOn), { pollMs: 20, livenessMs: 60000, onEmpty: () => { empty = true; } });
|
||||
const broker = new TerminalSessionBroker("reborn", new TmuxAdapter(execOn), { fixedGeometry: true, pollMs: 20, livenessMs: 60000, onEmpty: () => { empty = true; } });
|
||||
const browser = viewer();
|
||||
await broker.attach(browser);
|
||||
const pinned = await t("display-message", "-p", "-t", "=reborn:", "#{window_id}");
|
||||
|
||||
@@ -34,7 +34,7 @@ it.each(["inherit", "isolated"] as const)("keeps remote session names intact thr
|
||||
});
|
||||
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(mode === "isolated"
|
||||
? ["local", "terminal", "attach", ...(readOnly ? ["--read-only"] : []), "--", session]
|
||||
: [`=${session}:`, "session-id"]);
|
||||
: [`=${session}:`, ...(readOnly ? [] : ["attach", "-t"]), "session-id"]);
|
||||
expect(view.opened[0]!.paneCommand).not.toContain("/local/only/socket");
|
||||
writeFileSync(argsFile, "");
|
||||
execFileSync("/bin/sh", ["-c", view.opened[0]!.recoveryCommand!], {
|
||||
|
||||
@@ -60,9 +60,9 @@ it.skipIf(process.platform === "win32")("keeps all four terminal tiles on their
|
||||
await viewer(["select-layout", "-t", "=view:", "tiled"]);
|
||||
}
|
||||
const shows = async (i: number, text: string) => (await viewer(["capture-pane", "-p", "-t", panes[i]!])).includes(text);
|
||||
// Each tile views its own seat, and none of them is a client of it.
|
||||
// Writable tiles are sizing clients; the read-only tile remains an observer.
|
||||
for (let i = 0; i < 4; i++) await until(() => shows(i, `SEAT${i}`));
|
||||
expect(await seats(["list-clients", "-F", "#{client_tty}"])).toBe("");
|
||||
expect((await seats(["list-clients", "-F", "#{session_name}"])).split("\n").sort()).toEqual(names.slice(0, 3));
|
||||
expect((await adapter.killSession(names[1]!)).ok).toBe(true);
|
||||
await create(1, "REPLACED_SEAT1");
|
||||
await until(() => shows(1, "REPLACED_SEAT1"));
|
||||
@@ -139,7 +139,7 @@ it.skipIf(process.platform === "win32").each([false, true])("a read-only tile st
|
||||
it.skipIf(process.platform === "win32")("stops showing a session whose seat identity changed in place", async () => {
|
||||
await fixture(async ({ socket, seats, viewer }) => {
|
||||
await seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", "printf 'ORIGINAL\\n'; exec cat"]);
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, 2).map(shellQuote).join(" ");
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", true, 2).map(shellQuote).join(" ");
|
||||
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("ORIGINAL"));
|
||||
await seats(["set-environment", "-t", "=seat", "OPENRIG_NODE_ID", "DIFFERENT", ";", "send-keys", "-t", "=seat:", "-l", "OTHER-SEAT"]);
|
||||
@@ -148,7 +148,7 @@ it.skipIf(process.platform === "win32")("stops showing a session whose seat iden
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("a detach returns a tile someone typed in to viewing its seat", async () => {
|
||||
it.skipIf(process.platform === "win32")("an ordinary detach ends the sizing tile while preserving its seat", async () => {
|
||||
await fixture(async ({ socket, seats, viewer }) => {
|
||||
await seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", "printf 'ORIGINAL\\n'; exec cat"]);
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, 2).map(shellQuote).join(" ");
|
||||
@@ -159,9 +159,9 @@ it.skipIf(process.platform === "win32")("a detach returns a tile someone typed i
|
||||
await until(async () => (await seats(["capture-pane", "-p", "-t", "=seat:"])).includes("typed"));
|
||||
await seats(["detach-client", "-s", "=seat"]);
|
||||
await until(async () => (await seats(["list-clients", "-F", "#{session_name}"])) === "");
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("typed"));
|
||||
expect(await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])).toBe("0");
|
||||
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("No replacement");
|
||||
await until(async () => (await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])) === "1");
|
||||
expect(await seats(["has-session", "-t", "=seat"])).toBe("");
|
||||
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).toContain("reopen the view");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,14 @@ function track(b: TerminalSessionBroker): TerminalSessionBroker {
|
||||
liveBrokers.push(b);
|
||||
return b;
|
||||
}
|
||||
it("a default web viewer does not pin or resize the native client's window", async () => {
|
||||
const setWindowOption = vi.fn(async () => ({ ok: true as const }));
|
||||
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
|
||||
const b = track(new TerminalSessionBroker("dev@rig", makeTmux({ setWindowOption, resizeWindow })));
|
||||
await b.attach(makeSub());
|
||||
expect(setWindowOption).not.toHaveBeenCalled();
|
||||
expect(resizeWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const b of liveBrokers.splice(0)) b.dispose();
|
||||
});
|
||||
@@ -250,11 +258,11 @@ describe("TerminalSessionBroker", () => {
|
||||
expect(resizeWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("test 7b: canonical geometry is set ONCE at open (window-size manual + 120xN, NOT aggressive-resize)", async () => {
|
||||
it("test 7b: explicitly requested fixed geometry is set ONCE at open (window-size manual + 120xN, NOT aggressive-resize)", async () => {
|
||||
const setWindowOption = vi.fn(async () => ({ ok: true as const }));
|
||||
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
|
||||
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ setWindowOption, resizeWindow }), {
|
||||
pollMs: 10,
|
||||
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ setWindowOption, resizeWindow, readWindow: async () => ({ id: "@2", userOption: "" }), getWindowLocalOption: async () => null }), {
|
||||
fixedGeometry: true, pollMs: 10,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
}));
|
||||
@@ -262,8 +270,8 @@ describe("TerminalSessionBroker", () => {
|
||||
await broker.attach(makeSub());
|
||||
|
||||
expect(resizeWindow).toHaveBeenCalledOnce();
|
||||
expect(resizeWindow).toHaveBeenCalledWith("dev@rig", 120, 40);
|
||||
expect(setWindowOption).toHaveBeenCalledWith("dev@rig", "window-size", "manual");
|
||||
expect(resizeWindow).toHaveBeenCalledWith("@2", 120, 40);
|
||||
expect(setWindowOption).toHaveBeenCalledWith("@2", "window-size", "manual");
|
||||
// aggressive-resize fights fixed geometry (shrinks to smallest client) — must NOT be set.
|
||||
expect(setWindowOption).not.toHaveBeenCalledWith("dev@rig", "aggressive-resize", expect.anything());
|
||||
});
|
||||
@@ -949,9 +957,9 @@ describe("TerminalSessionBroker - per-subscriber scroll-back (OPR.0.4.0.39)", ()
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the window's own sizing comes back when the broker lets go -------------
|
||||
// While a browser is subscribed the window is pinned (window-size manual). When the last live holder lets go, the window
|
||||
// gets back exactly what it had: its own value, or none so it inherits again, and native clients size it as before. The
|
||||
// ---- explicit web pins release to inherited sizing --------------------------
|
||||
// While a browser explicitly opts into fixed geometry the window is pinned (window-size manual). On last release,
|
||||
// the local override is unset, so native clients size it under the inherited policy. The
|
||||
// window is named by its id, and the pin is recorded on the window with its holders, so another live broker keeps it
|
||||
// and a later broker gives back a pin whose holders are gone.
|
||||
|
||||
@@ -986,7 +994,7 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
|
||||
}
|
||||
|
||||
function broker(tmux: BrokerTmux, onEmpty?: () => void): TerminalSessionBroker {
|
||||
return new TerminalSessionBroker("dev@rig", tmux, { pollMs: 10, onEmpty });
|
||||
return new TerminalSessionBroker("dev@rig", tmux, { pollMs: 10, onEmpty, fixedGeometry: true });
|
||||
}
|
||||
|
||||
/** A viewer comes and goes; resolves once the broker is evicted. */
|
||||
@@ -1002,7 +1010,7 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
|
||||
it.each([
|
||||
["the window's own value", "largest" as string | null],
|
||||
["none (inherited)", null],
|
||||
])("the last viewer leaving gives back %s to the pinned window, once", async (_, prior) => {
|
||||
])("last opt-in viewer releases %s to inherited sizing, once", async (_, prior) => {
|
||||
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
|
||||
const { tmux, calls, window, restoreWindowOption } = sizingTmux(prior, { resizeWindow });
|
||||
const evicted: string[] = [];
|
||||
@@ -1020,11 +1028,11 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
|
||||
expect(calls).toEqual([
|
||||
`set:@2:@openrig-window-size-prior=${prior ?? "inherit"} H`,
|
||||
"set:@2:window-size=manual",
|
||||
`restore:@2:window-size=${prior}`,
|
||||
"restore:@2:window-size=null",
|
||||
"restore:@2:@openrig-window-size-prior=null",
|
||||
"empty",
|
||||
]);
|
||||
expect(window).toEqual({ own: prior, recorded: "" });
|
||||
expect(window).toEqual({ own: null, recorded: "" });
|
||||
});
|
||||
|
||||
it.each(["first", "second"])("two brokers on one window: the pin lasts until the last leaves, whichever leaves %s", async (order) => {
|
||||
@@ -1040,7 +1048,7 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
|
||||
expect(window.own).toBe("manual"); // the other browser still watches: still pinned
|
||||
expect(calls.filter((call) => call.startsWith("restore:"))).toEqual([]);
|
||||
stays[0].detach(stays[1]);
|
||||
await vi.waitFor(() => expect(window).toEqual({ own: "largest", recorded: "" }));
|
||||
await vi.waitFor(() => expect(window).toEqual({ own: null, recorded: "" }));
|
||||
});
|
||||
|
||||
it("a pin held by a broker in another live daemon is kept; this broker only drops itself", async () => {
|
||||
@@ -1060,13 +1068,13 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
|
||||
expect(norm(window.recorded)).toBe(`${record} H`);
|
||||
expect(window.own).toBe("manual");
|
||||
await visit(tmux);
|
||||
expect(window).toEqual({ own: prior, recorded: "" });
|
||||
expect(window).toEqual({ own: null, recorded: "" });
|
||||
});
|
||||
|
||||
it("a pin whose holder's daemon is gone is given back by the next broker", async () => {
|
||||
const { tmux, window } = sizingTmux("manual", {}, `largest ${deadPid}.1`);
|
||||
await visit(tmux);
|
||||
expect(window).toEqual({ own: "largest", recorded: "" });
|
||||
expect(window).toEqual({ own: null, recorded: "" });
|
||||
});
|
||||
|
||||
it("a window that no longer lists this broker (a restarted tmux server reusing @2) is left alone", async () => {
|
||||
@@ -1095,22 +1103,41 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
|
||||
expect(norm(window.recorded)).toBe("largest H");
|
||||
const next = sizingTmux("manual", {}, window.recorded); // its holder is no longer live
|
||||
await visit(next.tmux);
|
||||
expect(next.window).toEqual({ own: "largest", recorded: "" });
|
||||
expect(next.window).toEqual({ own: null, recorded: "" });
|
||||
});
|
||||
|
||||
it("a window whose sizing couldn't be read is left as the broker set it", async () => {
|
||||
it("a window whose sizing could not be read receives no untracked pin", async () => {
|
||||
const { tmux, restoreWindowOption } = sizingTmux(undefined);
|
||||
await visit(tmux);
|
||||
expect(restoreWindowOption).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("without a window id the session's window is pinned as before, and nothing is given back to another window", async () => {
|
||||
it("without a window id no untracked pin is made", async () => {
|
||||
const { tmux, calls, restoreWindowOption } = sizingTmux("largest", { readWindow: async () => null });
|
||||
await visit(tmux);
|
||||
expect(calls).toEqual(["set:dev@rig:window-size=manual"]);
|
||||
expect(calls).toEqual([]);
|
||||
expect(restoreWindowOption).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a pin record that cannot be written makes no manual override or resize", async () => {
|
||||
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
|
||||
const { tmux, window } = sizingTmux("largest", {
|
||||
setWindowOption: async () => ({ ok: false, code: "unavailable", message: "busy" }), resizeWindow,
|
||||
});
|
||||
await visit(tmux);
|
||||
expect(resizeWindow).not.toHaveBeenCalled();
|
||||
expect(window).toEqual({ own: "largest", recorded: "" });
|
||||
});
|
||||
|
||||
it("an explicit manual window without an OpenRig pin stays untouched by an opt-in browser", async () => {
|
||||
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
|
||||
const { tmux, calls, window } = sizingTmux("manual", { resizeWindow });
|
||||
await visit(tmux);
|
||||
expect(resizeWindow).not.toHaveBeenCalled();
|
||||
expect(calls).toEqual([]);
|
||||
expect(window).toEqual({ own: "manual", recorded: "" });
|
||||
});
|
||||
|
||||
it("a failed open gives the sizing back too", async () => {
|
||||
const { tmux, window } = sizingTmux(null, {
|
||||
startPipePane: async () => ({ ok: false, code: "session_not_found", message: "gone" }),
|
||||
@@ -1124,13 +1151,13 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
|
||||
it("a probe that couldn't reach tmux closes the viewers and still gives the sizing back, before eviction", async () => {
|
||||
let probes = 0;
|
||||
const { tmux, calls, window } = sizingTmux("largest", { hasSession: async () => probes++ === 0 });
|
||||
const b = new TerminalSessionBroker("dev@rig", tmux, { pollMs: 10, livenessMs: 10, onEmpty: () => calls.push("empty") });
|
||||
const b = new TerminalSessionBroker("dev@rig", tmux, { fixedGeometry: true, pollMs: 10, livenessMs: 10, onEmpty: () => calls.push("empty") });
|
||||
const a = makeSub();
|
||||
await b.attach(a);
|
||||
await vi.waitFor(() => expect(calls.at(-1)).toBe("empty"));
|
||||
expect(a.closed[0]?.code).toBe(1001);
|
||||
expect(calls.slice(-3)).toEqual(["restore:@2:window-size=largest", "restore:@2:@openrig-window-size-prior=null", "empty"]);
|
||||
expect(window).toEqual({ own: "largest", recorded: "" });
|
||||
expect(calls.slice(-3)).toEqual(["restore:@2:window-size=null", "restore:@2:@openrig-window-size-prior=null", "empty"]);
|
||||
expect(window).toEqual({ own: null, recorded: "" });
|
||||
});
|
||||
|
||||
it("dispose gives the sizing back before the registry can create a new broker", async () => {
|
||||
@@ -1181,6 +1208,6 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
|
||||
await Promise.all([attached, disposed]);
|
||||
expect(a.closed[0]?.code).toBe(1011);
|
||||
expect(calls.at(-1)).toBe("empty");
|
||||
expect(window).toEqual({ own: "largest", recorded: "" });
|
||||
expect(window).toEqual({ own: null, recorded: "" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
|
||||
|
||||
it("the writable argv remains valid for Herdr restart reporting", () => {
|
||||
const argv = tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", false);
|
||||
expect(argv.every(word => !/['\u0000-\u001f\u007f]/.test(word))).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(spawnSync("tmux", ["-V"]).status !== 0)("the shipped writable tile follows phone, laptop and every outer-terminal resize", async () => {
|
||||
const { stdout } = await promisify(execFile)("python3", [
|
||||
fileURLToPath(new URL("./helpers/tmux-tile-reflow.py", import.meta.url)),
|
||||
JSON.stringify(tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", false)),
|
||||
], { timeout: 45000 });
|
||||
expect(JSON.parse(stdout)).toEqual({ sizes: ["40x24", "120x35", "160x45", "52x28", "132x40", "40x24", "132x40"], localPin: "unset", nativeInput: true, firstKeys: true, pasteBytes: 448012, prefixCopyMode: true });
|
||||
}, 50000);
|
||||
@@ -16,18 +16,11 @@ const helper = async (shell: string, ...more: string[]) => JSON.parse((await pro
|
||||
JSON.stringify(tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", true, 1, 2)),
|
||||
...more,
|
||||
], { encoding: "utf8", timeout: 150000 })).stdout);
|
||||
describe.skipIf(!available)("a tile views its seat without sizing it, and attaches only while the person types", () => {
|
||||
it.each(shells)("under %s: whole lines, the seat's own size, keys reaching the seat, and back to the view when idle", async (shell) => {
|
||||
describe.skipIf(!available)("read-only tiles remain observers beside native sizing clients", () => {
|
||||
it.each(shells)("under %s: whole lines without typing or sizing the seat", async (shell) => {
|
||||
expect(await helper(shell)).toEqual({
|
||||
readOnlyAlone: { window: "160x20" },
|
||||
readOnlyBesideAnother: { window: "160x20" },
|
||||
writable: { used: "40x30" },
|
||||
firstInput: { bytes: 183 },
|
||||
firstInputWindow: { bytes: 3 },
|
||||
});
|
||||
}, 180000);
|
||||
|
||||
it("a paste over 256 KiB arrives whole and framed, and the tile still detaches when idle after it", async () => {
|
||||
expect(await helper("/bin/sh", "big-paste")).toEqual({ bigPaste: { bytes: 6 + 16000 * 28 + 6 } });
|
||||
}, 180000);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import type { ComposedPane, ComposedView, OpenViewResult, TerminalProvider } fro
|
||||
const local = (seat: string, readOnly = false): ViewMemberInput => ({ seat, label: seat, tmuxSession: seat, host: null, readOnly, alive: true });
|
||||
|
||||
describe("#707 local tiles run the tmux the daemon resolves", () => {
|
||||
it("a provider PATH without tmux fails the bare command; the resolved path views the seat, and neither mode attaches untyped", () => {
|
||||
it("a provider PATH without tmux fails the bare command; the resolved path attaches writable tiles and observes read-only tiles", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-707-"));
|
||||
const narrow = join(root, "provider-path"); // the provider's PATH: system tools, no tmux
|
||||
const elsewhere = join(root, "elsewhere"); // where the daemon found tmux
|
||||
@@ -48,11 +48,15 @@ esac
|
||||
|
||||
const resolved = composeView("v", [local("seat'a", readOnly)], { resolveHost: () => null, localTmux: tmux });
|
||||
rmSync(seen, { force: true });
|
||||
rmSync(other, { force: true });
|
||||
const out = execFileSync("/bin/sh", ["-c", resolved.opened[0]!.paneCommand], { env, encoding: "utf8", timeout: 10000 });
|
||||
expect(out).toContain("SCREEN");
|
||||
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(["capture-pane", "-p", "-e", "-t", "session-id"]);
|
||||
// Nobody typed, so nothing attached or typed into the seat.
|
||||
expect(existsSync(other)).toBe(false);
|
||||
if (readOnly) expect(out).toContain("SCREEN");
|
||||
if (readOnly) {
|
||||
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(["capture-pane", "-p", "-e", "-t", "session-id"]);
|
||||
expect(existsSync(other)).toBe(false);
|
||||
} else {
|
||||
expect(readFileSync(other, "utf8").trimEnd().split("\n")).toEqual(["attach", "-t", "session-id"]);
|
||||
}
|
||||
expect(resolved.opened[0]!.readOnly).toBe(readOnly);
|
||||
}
|
||||
} finally {
|
||||
@@ -80,8 +84,8 @@ esac
|
||||
const view = composeView("v", [local("seat", readOnly)], { resolveHost: () => null, localTmux: tmux });
|
||||
const result = spawnSync("/bin/sh", ["-c", view.opened[0]!.paneCommand], { env: { PATH: narrow }, encoding: "utf8", timeout: 10000 });
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("SCREEN");
|
||||
expect(result.status).toBe(readOnly ? 1 : 0);
|
||||
if (readOnly) expect(result.stdout).toContain("SCREEN");
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
|
||||
@@ -346,12 +346,15 @@ describe("terminal WebSocket broker (multi-subscriber route)", () => {
|
||||
const startPipePaneCalls: string[] = [];
|
||||
const resizeWindowCalls: Array<{ cols: number; rows: number }> = [];
|
||||
let capturedOutputPath: string | null = null;
|
||||
let fixedGeometry = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("tmuxAdapter" as never, {
|
||||
hasSession: async () => true,
|
||||
readWindow: async (name: string) => ({ id: name, userOption: "" }),
|
||||
getWindowLocalOption: async () => null,
|
||||
setWindowOption: async () => ({ ok: true }),
|
||||
resizeWindow: async (_n: string, cols: number, rows: number) => {
|
||||
resizeWindowCalls.push({ cols, rows });
|
||||
@@ -371,7 +374,7 @@ describe("terminal WebSocket broker (multi-subscriber route)", () => {
|
||||
await next();
|
||||
});
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
registerTerminalWs(app, upgradeWebSocket as never, { bearerToken: BROKER_TOKEN });
|
||||
registerTerminalWs(app, upgradeWebSocket as never, { bearerToken: BROKER_TOKEN, fixedGeometry: () => fixedGeometry });
|
||||
brokerServer = serve({ fetch: app.fetch, port: BROKER_PORT, hostname: "127.0.0.1" });
|
||||
injectWebSocket(brokerServer);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
@@ -415,21 +418,21 @@ describe("terminal WebSocket broker (multi-subscriber route)", () => {
|
||||
b.close();
|
||||
}, 10000);
|
||||
|
||||
it("a client resize message never resizes the pane (FR-7): only the one canonical geometry call", async () => {
|
||||
it("default browser traffic never pins the PTY; a newly opted-in broker uses fixed geometry", async () => {
|
||||
resizeWindowCalls.length = 0;
|
||||
const ws = await openWs("resize-session");
|
||||
// One canonical-geometry resize happens at broker open.
|
||||
await vi.waitFor(() => {
|
||||
expect(resizeWindowCalls).toHaveLength(1);
|
||||
}, { timeout: 1000 });
|
||||
expect(resizeWindowCalls[0]).toEqual({ cols: 90, rows: 27 });
|
||||
|
||||
ws.send(JSON.stringify({ type: "resize", cols: 200, rows: 9 }));
|
||||
// Give the message time to (not) be processed.
|
||||
await new Promise<void>((r) => setTimeout(r, 120));
|
||||
expect(resizeWindowCalls).toHaveLength(1); // unchanged - the resize was ignored
|
||||
|
||||
expect(resizeWindowCalls).toEqual([]);
|
||||
ws.close();
|
||||
|
||||
fixedGeometry = true;
|
||||
const opted = await openWs("fixed-session");
|
||||
await vi.waitFor(() => expect(resizeWindowCalls).toEqual([{ cols: 90, rows: 27 }]));
|
||||
opted.send(JSON.stringify({ type: "resize", cols: 200, rows: 9 }));
|
||||
await new Promise<void>((r) => setTimeout(r, 120));
|
||||
expect(resizeWindowCalls).toEqual([{ cols: 90, rows: 27 }]);
|
||||
opted.close();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
|
||||
@@ -116,6 +116,8 @@ describe("config routes (User Settings v0)", () => {
|
||||
// + 1 output.whoami_peers (slice 15 P5 whoami peer bound; default 8).
|
||||
// + 1 usage.pi_cost_facts (slice 02 Pi cost from persisted entries; default off).
|
||||
// + 1 usage.pi_openrouter_charge (slice 02 K4 OpenRouter charge beside the Pi estimate; default off).
|
||||
// + 1 terminal.fixed_geometry (web pin opt-in; default off).
|
||||
expect(body.settings["terminal.fixed_geometry"]).toMatchObject({ value: false, source: "default" });
|
||||
// + 1 terminal.tmux_server (per-instance server selection; default inherit).
|
||||
expect(body.settings["terminal.tmux_server"]).toMatchObject({ value: "inherit", source: "default" });
|
||||
// + 1 transport.verify_input_box (input-box evidence for verified Claude Code and Codex sends; default off).
|
||||
@@ -138,7 +140,7 @@ describe("config routes (User Settings v0)", () => {
|
||||
// + 1 acp.to (the ACP agent's receiver; default empty).
|
||||
// + 1 daemon.seat_poll_interval_ms (#2687 the seat pollers' cadence; startup-only, default 1000).
|
||||
// + 1 retention.events_days (the history-only event types of events; default 0 = keep).
|
||||
expect(Object.keys(body.settings).length).toBe(100);
|
||||
expect(Object.keys(body.settings).length).toBe(101);
|
||||
expect(body.settings["launch.codex_login_shell"]).toMatchObject({ value: false, source: "default", defaultValue: false });
|
||||
expect(body.settings["transport.draft_delivery"]).toMatchObject({ value: "deliver", source: "default", defaultValue: "deliver" });
|
||||
expect(body.settings["retention.events_days"]).toMatchObject({ value: 0, source: "default", defaultValue: 0 });
|
||||
|
||||
@@ -25,7 +25,7 @@ const DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX = "compaction/post-compact-extra.md"
|
||||
|
||||
function clearEnv(): () => void {
|
||||
const keys = [
|
||||
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_DAEMON_DB_CONFLICT",
|
||||
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_TERMINAL_FIXED_GEOMETRY", "OPENRIG_DAEMON_DB_CONFLICT",
|
||||
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
|
||||
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
|
||||
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
|
||||
@@ -99,6 +99,15 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
expect(() => store.set("terminal.tmux_server", "isolate")).toThrow('must be "inherit" or "isolated"');
|
||||
});
|
||||
|
||||
it("web fixed geometry is off by default and explicitly selectable through file or environment", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(store.resolveOne("terminal.fixed_geometry")).toMatchObject({ value: false, source: "default" });
|
||||
store.set("terminal.fixed_geometry", "true");
|
||||
expect(store.resolveOne("terminal.fixed_geometry")).toMatchObject({ value: true, source: "file" });
|
||||
process.env.OPENRIG_TERMINAL_FIXED_GEOMETRY = "false";
|
||||
expect(store.resolveOne("terminal.fixed_geometry")).toMatchObject({ value: false, source: "env" });
|
||||
});
|
||||
|
||||
it("SETTINGS_VALID_KEYS matches the documented settings key set", () => {
|
||||
expect([...SETTINGS_VALID_KEYS]).toEqual([
|
||||
"daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms",
|
||||
@@ -170,6 +179,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
"terminal.status_bar",
|
||||
"terminal.tmux_server",
|
||||
"terminal.history_limit",
|
||||
"terminal.fixed_geometry",
|
||||
"terminal.inherit_tmux_environment",
|
||||
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults switch.
|
||||
"output.compact",
|
||||
|
||||
@@ -13,8 +13,15 @@ export interface DaemonClientOptions {
|
||||
baseUrl?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
headers?: Record<string, string> | (() => Record<string, string>);
|
||||
/** Headers for operator-gated writes (answering an ask), which the daemon checks against its operator bearer. Absent:
|
||||
* the same headers as every other call. */
|
||||
actionHeaders?: Record<string, string> | (() => Record<string, string>);
|
||||
}
|
||||
|
||||
/** What a person reads when the daemon refuses an answer for want of its operator bearer. Never carries a token. */
|
||||
export const ACTION_UNAUTHORIZED = "This daemon refused the answer without its operator bearer token, so nothing was recorded. "
|
||||
+ "Start the TUI with OPENRIG_AUTH_BEARER_TOKEN set to the token this daemon runs with, then answer again.";
|
||||
|
||||
export class StartupRequestError extends Error {
|
||||
constructor(readonly status: number, readonly result: Record<string, unknown>) {
|
||||
super([String(result.message ?? result.error ?? `Startup request returned HTTP ${status}`),
|
||||
@@ -69,16 +76,18 @@ export class DaemonClient {
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private optionalFetchImpl: typeof fetch;
|
||||
private readonly headerSource: Record<string, string> | (() => Record<string, string>);
|
||||
private readonly actionHeaderSource: Record<string, string> | (() => Record<string, string>) | undefined;
|
||||
|
||||
constructor(options: DaemonClientOptions = {}) {
|
||||
this.baseUrl = (options.baseUrl ?? process.env["OPENRIG_URL"] ?? "http://127.0.0.1:7433").replace(/\/$/, "");
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.optionalFetchImpl = this.fetchImpl;
|
||||
this.headerSource = options.headers ?? {};
|
||||
this.actionHeaderSource = options.actionHeaders;
|
||||
}
|
||||
|
||||
forPage(page: PageRead, signal: AbortSignal): DaemonClient {
|
||||
const client = new DaemonClient({ baseUrl: this.baseUrl, headers: this.headerSource,
|
||||
const client = new DaemonClient({ baseUrl: this.baseUrl, headers: this.headerSource, actionHeaders: this.actionHeaderSource,
|
||||
fetchImpl: page.fetch(this.fetchImpl, signal) });
|
||||
client.optionalFetchImpl = page.fetch(this.fetchImpl, signal, true);
|
||||
return client;
|
||||
@@ -88,6 +97,11 @@ export class DaemonClient {
|
||||
return typeof this.headerSource === "function" ? this.headerSource() : this.headerSource;
|
||||
}
|
||||
|
||||
private get actionHeaders(): Record<string, string> {
|
||||
const source = this.actionHeaderSource;
|
||||
return source === undefined ? this.headers : typeof source === "function" ? source() : source;
|
||||
}
|
||||
|
||||
/** S19 AM-R18 — open the oracle's SSE event stream (FR-8: HTTP stays in THIS module).
|
||||
* FEATURE-DETECTED: a non-OK or non-event-stream answer (an older daemon, a foreign
|
||||
* server) returns null — the caller disables the leg permanently and the TUI behaves
|
||||
@@ -124,13 +138,14 @@ export class DaemonClient {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async post(route: string, body: unknown): Promise<unknown> {
|
||||
private async post(route: string, body: unknown, options: { headers?: Record<string, string>; unauthorized?: string } = {}): Promise<unknown> {
|
||||
const res = await this.fetchImpl(`${this.baseUrl}${route}`, {
|
||||
method: "POST",
|
||||
headers: { ...this.headers, "Content-Type": "application/json" },
|
||||
headers: { ...(options.headers ?? this.headers), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const parsed = await res.json().catch(() => null);
|
||||
if (res.status === 401 && options.unauthorized) throw new Error(options.unauthorized);
|
||||
if (!res.ok) {
|
||||
const detail = parsed && typeof parsed === "object"
|
||||
? "error" in parsed ? ` — ${(parsed as { error: unknown }).error}`
|
||||
@@ -415,7 +430,7 @@ export class DaemonClient {
|
||||
}
|
||||
/** The mission-control write Steering uses to answer a request (POST /api/mission-control/action). */
|
||||
missionControlAction(input: { verb: "approve" | "answer" | "resolve"; qitemId: string; actorSession: string; questionId?: string; optionId?: string; decision?: string; authority?: "primary-user" }) {
|
||||
return this.post("/api/mission-control/action", input) as Promise<{ verb: string; qitemId: string; closedQitem: unknown;
|
||||
return this.post("/api/mission-control/action", input, { headers: this.actionHeaders, unauthorized: ACTION_UNAUTHORIZED }) as Promise<{ verb: string; qitemId: string; closedQitem: unknown;
|
||||
answer?: { outcome: "recorded" | "answered"; unanswered: string[]; message?: string } }>;
|
||||
}
|
||||
attentionAggregate() {
|
||||
|
||||
@@ -73,13 +73,17 @@ async function run(): Promise<void> {
|
||||
});
|
||||
const view = createViewState({ instanceId, getSnapshot: () => snapshot, timeZoneWarning: "Reading configured timezone…" });
|
||||
let startupHeaders: () => Record<string, string> = () => ({});
|
||||
let actionHeaders: () => Record<string, string> = () => ({});
|
||||
if (cliEntry) {
|
||||
try {
|
||||
const cli = await import(pathToFileURL(join(dirname(cliEntry), "client.js")).href);
|
||||
startupHeaders = cli.terminalAuthHeaders;
|
||||
// Answering an ask is operator-gated: the daemon's operator bearer when one is configured, else the terminal
|
||||
// credential as before (a CLI without the helper keeps that).
|
||||
actionHeaders = typeof cli.operatorAuthHeaders === "function" ? () => cli.operatorAuthHeaders() : cli.terminalAuthHeaders;
|
||||
} catch { /* the startup read will expose an unavailable/unauthorized prerequisite */ }
|
||||
}
|
||||
const client = demo ? null : new DaemonClient({ baseUrl: argOf(args, "--url"), headers: startupHeaders });
|
||||
const client = demo ? null : new DaemonClient({ baseUrl: argOf(args, "--url"), headers: startupHeaders, actionHeaders });
|
||||
let startup: StartupController | null = null;
|
||||
let nativeAttached = false;
|
||||
let launching = false;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Answering an ask from the TUI is operator-gated: the daemon checks its operator bearer (OPENRIG_AUTH_BEARER_TOKEN), which
|
||||
// can differ from the terminal token every other TUI call carries. The client sends the action headers only on that
|
||||
// write. Synthetic credentials; the joined cells run the daemon's real Mission Control and pairing routes in-process.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ACTION_UNAUTHORIZED, DaemonClient } from "../src/daemon-client.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { makeTopologyDestinationCheck } from "../../daemon/src/domain/queue-destination-check.js";
|
||||
import { MissionControlWriteContract } from "../../daemon/src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlActionLog } from "../../daemon/src/domain/mission-control/mission-control-action-log.js";
|
||||
import { missionControlRoutes } from "../../daemon/src/routes/mission-control.js";
|
||||
import { hostsRoutes } from "../../daemon/src/routes/hosts.js";
|
||||
|
||||
const ACTION = "synthetic-operator-bearer", TERMINAL = "synthetic-terminal-token";
|
||||
const PERSON = "pair-person@external";
|
||||
|
||||
/** Records the Authorization header each call carried, by method and route. */
|
||||
function recording() {
|
||||
const seen: Array<{ method: string; route: string; auth: string | undefined }> = [];
|
||||
const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
seen.push({ method: init?.method ?? "GET", route: new URL(String(url)).pathname, auth: headers.get("authorization") ?? undefined });
|
||||
return new Response(JSON.stringify({ verb: "answer", qitemId: "q-1", closedQitem: null }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
return { seen, fetchImpl };
|
||||
}
|
||||
|
||||
describe("the TUI's answer carries the operator bearer; every other call keeps its terminal credential", () => {
|
||||
it("with distinct operator and terminal credentials, the answer carries the operator bearer and a read the terminal token", async () => {
|
||||
const r = recording();
|
||||
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: r.fetchImpl,
|
||||
headers: () => ({ Authorization: `Bearer ${TERMINAL}` }), actionHeaders: () => ({ Authorization: `Bearer ${ACTION}` }) });
|
||||
await client.missionControlAction({ verb: "answer", qitemId: "q-1", actorSession: PERSON, decision: "yes" });
|
||||
await client.queueItem("q-1");
|
||||
expect(r.seen).toEqual([
|
||||
{ method: "POST", route: "/api/mission-control/action", auth: `Bearer ${ACTION}` },
|
||||
{ method: "GET", route: "/api/queue/q-1", auth: `Bearer ${TERMINAL}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it("with an operator bearer configured and no terminal-token file, the answer carries the bearer and reads carry none", async () => {
|
||||
const r = recording();
|
||||
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: r.fetchImpl, headers: () => ({}),
|
||||
actionHeaders: () => ({ Authorization: `Bearer ${ACTION}` }) });
|
||||
await client.missionControlAction({ verb: "answer", qitemId: "q-1", actorSession: PERSON, decision: "yes" });
|
||||
await client.queueItem("q-1");
|
||||
expect(r.seen.map((s) => s.auth)).toEqual([`Bearer ${ACTION}`, undefined]);
|
||||
});
|
||||
|
||||
it("with no action headers, the answer keeps the existing fallback: the same credential as every other call", async () => {
|
||||
const r = recording();
|
||||
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: r.fetchImpl, headers: () => ({ Authorization: `Bearer ${TERMINAL}` }) });
|
||||
await client.missionControlAction({ verb: "answer", qitemId: "q-1", actorSession: PERSON, decision: "yes" });
|
||||
expect(r.seen.map((s) => s.auth)).toEqual([`Bearer ${TERMINAL}`]);
|
||||
});
|
||||
|
||||
it("a page client keeps the action headers", async () => {
|
||||
const r = recording();
|
||||
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: r.fetchImpl,
|
||||
headers: () => ({ Authorization: `Bearer ${TERMINAL}` }), actionHeaders: () => ({ Authorization: `Bearer ${ACTION}` }) });
|
||||
const page = client.forPage({ fetch: (impl: typeof fetch) => impl } as never, new AbortController().signal);
|
||||
await page.missionControlAction({ verb: "answer", qitemId: "q-1", actorSession: PERSON, decision: "yes" });
|
||||
expect(r.seen.map((s) => s.auth)).toEqual([`Bearer ${ACTION}`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("against the daemon's real routes, a pairing answer without the operator bearer stays unauthorized and decides nothing", () => {
|
||||
const closes: Array<() => void> = [];
|
||||
afterEach(() => { for (const close of closes.splice(0)) close(); });
|
||||
|
||||
async function daemon() {
|
||||
const home = mkdtempSync(join(tmpdir(), "tui-action-auth-"));
|
||||
const db = new Database(join(home, "queue.sqlite")); migrate(db, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(db);
|
||||
const registry = { ok: true as const, entities: [{ entityId: "pair-person", class: "human" as const, displayName: "Pairing person",
|
||||
address: PERSON, connectorBindings: [], prefs: { deliveryClass: "A" as const } }] };
|
||||
const validateRig = makeTopologyDestinationCheck({ findRigsByName: () => [], loadHumanRegistry: () => registry, sharedChannels: () => [] });
|
||||
const repo = new QueueRepository(db, bus, { validateRig, loadHumanRegistry: () => registry,
|
||||
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
|
||||
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never);
|
||||
c.set("missionControlWriteContract" as never, contract as never); await next(); });
|
||||
app.route("/api/mission-control", missionControlRoutes({ bearerToken: ACTION }));
|
||||
app.route("/api/hosts", hostsRoutes({ bearerToken: ACTION, humanRegistry: () => registry }));
|
||||
closes.push(() => { db.close(); rmSync(home, { recursive: true, force: true }); });
|
||||
const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => app.fetch(new Request(String(url), init))) as typeof fetch;
|
||||
const issued = await (await fetchImpl("http://daemon/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requester: "requester@far-host" }) })).json() as { pairId: string; approvalQitemId: string };
|
||||
const poll = async () => (await (await fetchImpl(`http://daemon/api/hosts/pair-request/${issued.pairId}`)).json()) as { status: string };
|
||||
const tui = (action: Record<string, string> | undefined) => new DaemonClient({ baseUrl: "http://daemon", fetchImpl,
|
||||
headers: () => ({ Authorization: `Bearer ${TERMINAL}` }), ...(action ? { actionHeaders: () => action } : {}) });
|
||||
// The TUI's act: the primary user at the terminal, with no seat header.
|
||||
const approve = (client: DaemonClient) => client.missionControlAction({ verb: "answer", qitemId: issued.approvalQitemId,
|
||||
actorSession: PERSON, questionId: "pair", optionId: "approve", authority: "primary-user" });
|
||||
return { repo, issued, poll, tui, approve };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["the terminal token only (the old wiring)", undefined],
|
||||
["a wrong operator bearer", { Authorization: "Bearer synthetic-wrong-bearer" }],
|
||||
["no credential at all", {}],
|
||||
] as const)("%s: 401 with what to set and no token in it, and the request stays pending", async (_label, action) => {
|
||||
const d = await daemon();
|
||||
const refused = await d.approve(d.tui(action)).then(() => null, (err: Error) => err.message);
|
||||
expect(refused).toBe(ACTION_UNAUTHORIZED);
|
||||
expect(refused).not.toContain(ACTION);
|
||||
expect(refused).not.toContain(TERMINAL);
|
||||
expect(d.repo.getById(d.issued.approvalQitemId)).toMatchObject({ state: "pending" });
|
||||
expect(d.repo.getById(d.issued.approvalQitemId)?.humanAnswers?.pair).toBeUndefined();
|
||||
expect(await d.poll()).toMatchObject({ status: "pending" });
|
||||
});
|
||||
|
||||
it("with the operator bearer, the person's Approve is recorded and decides the agent-free request", async () => {
|
||||
const d = await daemon();
|
||||
await d.approve(d.tui({ Authorization: `Bearer ${ACTION}` }));
|
||||
expect(d.repo.getById(d.issued.approvalQitemId)).toMatchObject({ state: "done", humanAnswers: { pair: "approve" } });
|
||||
expect(await d.poll()).toMatchObject({ status: "approved" });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
Reference in new issue
Block a user