Compare commits

..
Author SHA1 Message Date
Mike Schwarz c92b4f3c93 fix(delivery): ignore Claude's drawn cursor blank (#2933)
For #2925. On an idle Claude seat with the typing guard set to draft-aware, every automatic delivery was held as draft_input_busy while rig capture read the input box as empty. The guard's strict reader counted Claude's own drawn cursor, a single inverse-video blank at the start of the composer, as typed text; the capture annotation already trimmed it. The strict reader now recognises that one drawn blank at the start of a one-row Claude composer and reads the box as empty. Real draft text, ordinary spaces, a moved cursor and multi-row input keep the existing protection, and the explicit draft-aware mode's handling of unreadable input is unchanged. No new refusal or timeout. Reproduced in an isolated 68x31 tmux 3.6 pane with the reported frame: unchanged main held the send, and with the change the message submits once while a real draft stays untouched. It doesn't explain the reported delay of about fifteen minutes before the first hold.
2026-11-10 19:05:22 -08:00
Mike Schwarz 85b4ccf27a docs: clarify when seats receive new guidance (#2930)
Three corrections to the agent startup guide's section on how a running seat gets new guidance. A send can be retained rather than delivered, so the guide no longer promises next-turn delivery or relative order. A managed refocus after compaction waits for an eligible prompt, which may not be the immediate next one. And rig walk reads each named file and sends its contents, unlike a pointer to a file that the seat still has to read. Finding a file, a hook picking it up and the agent reading it stay separate facts. Docs only.
2026-11-10 18:37:30 -08:00
Mike Schwarz eb73d75edd refactor(daemon): resolve a seat's kind at one boundary for send and capture (#2932)
Five places in session-transport.ts (send, its wait path, capture, the activity probe and target resolution) each compared a seat's attachment type with external_cli on their own. A new seat-kinds.ts resolves the kind once: tmux (also for a legacy row with no value), external_cli, or unknown with the stored value, so an unknown value is named rather than silently read as tmux, while keeping today's tmux path. The five branches now ask it. No result, error text or refusal changes. It's the first step of one seat boundary for delivery evidence, and it builds on rlch's SeatKind facade and attachment key proposed in #1429, with credit.
2026-11-10 18:16:14 -08:00
Mike Schwarz 1f30867775 docs: say how queueCounts treats a row tagged with several slices (#2929)
For #2902's queueCounts. The slice-list section of the daemon HTTP clients reference now says how a row tagged with several slices or missions is counted. Its tags count only when they all name the same physical slice or mission (a slice's folder name and its frontmatter id, for example). A row that names two distinct slices, missions or projects counts nowhere in queueCounts, while the older qitemCount can include it under several tagged slices, subject to the existing project filter and the newest 500 rows per slice. Docs only.
2026-11-10 18:13:32 -08:00
Mike Schwarz e89031d211 test(refocus): skip mode-000 denial cases as root (#2928)
Follow-up to #2916. Two refocus-record test cases rely on chmod 000 causing EACCES, which root doesn't get. They now skip when process.getuid?.() === 0, with the reason in their names, following the existing root skip in refocus-notes-local.test.ts. The other eight cases still run, and non-root runs keep both. Test only.
2026-11-10 17:56:14 -08:00
29 changed files with 342 additions and 651 deletions

No files matched your search

+12 -22
View File
@@ -372,25 +372,15 @@ approval says how long and until when, in UTC. A target older than this CLI igno
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 receiving agent** (the one chosen in `agents.operator_session`, else its 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 agent isn't running, the request and that receipt say
so, and they name the direct controls (below). The request stays pending, never granted or denied, until a decision or
expiry. If the agent doesn't pick up the answer, the person is told at expiry that it didn't.
- **When the target has no receiving agent** (or the queue can't send rows to it, or OpenRig couldn't read which; the
request says which, and names a chosen agent it couldn't use), the person's answer, a click or their words, is kept on
the request and decides nothing. (A Steering Deny or Drop, which closes the request, still refuses it.) The person
gets one receipt naming the direct controls. If a receiving agent is set
before the request expires, it gets their answer once and decides. (On 0.7.11, Approve and Deny decided at once here,
and words were asked again.)
- **The direct controls:** the target's TUI pairing settings (`rig tui`, then Settings, Instance & work, Pairing
requests; the Feed's request opens it there), or `rig host approve|deny` below. The TUI lists the open requests from
`GET /api/hosts/pair-requests` (code, requester, expiry, status, who reads the answer, and the person's kept answer,
so an Approve over a kept Deny is made knowingly; no credential), reads the
request again before Approve or Deny, and decides through `POST /api/hosts/pair-decision` with its operator bearer
token. A decision it can't confirm (a timeout, a lost connection or a server error) reads "outcome unknown" and is
read again, never sent twice.
- **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
@@ -398,11 +388,11 @@ approval row id), run on the target from an agent seat or a plain terminal, deci
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 (an agent
seat's close naming Approve, or a refusing close). A close never approves over the person's recorded Deny. It records who decided: the seat, or `human@host` from a plain terminal. The person
`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, use Deny on the target's pairing settings or on Steering, run
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.
+12 -10
View File
@@ -428,22 +428,24 @@ running seat only through the action in the last column.
### New guidance for a running seat
These reach a running seat without a restart, in the order they reach the agent:
There are three ways for a seat to pick up new guidance:
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. It's in the
conversation at the seat's next turn. The seat's reply is the only evidence that it read the file.
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. It
reaches the agent at the next prompt after a compaction, and for Claude also once the conversation has grown past the
hook's size threshold.
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
conversation grows past the hook's size threshold.
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
A pointer or a message names a file; the seat still reads it itself, under its own permission settings. A seat whose
harness asks before reading a file outside its project (its own `LEARNED.md` under the topology root, a plugin's skill)
waits at that prompt until someone answers it, even though a hook could deliver an excerpt of the same file. Finding a
file, a hook picking up, and the agent reading and using it are separate facts.
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
separate facts.
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
+6 -2
View File
@@ -218,5 +218,9 @@ an open FYI, or a closed ask is not that receipt. The rig-wide reads and their l
and every catalog project's missions root. A row may omit `project:` or `mission:` only when its remaining
tags identify exactly one physical slice with a resolved project and mission. Ambiguous matches count nowhere,
even if first-wins discovery or a list filter leaves only one row visible; an explicit mismatched project or
mission never counts. Counts are recomputed on every list request. `?refresh=1|true` invalidates the separate
listing cache, not a stored queue-count snapshot. This resolution does not change the returned slice list or filters.
mission never counts. Several `slice:` or `mission:` tags on one row count only when they all name the same
physical slice or mission (a slice's folder name and its frontmatter `id:`, say); a row that names two distinct
slices, missions or projects counts nowhere, while `qitemCount` can include it under several tagged slices,
subject to the existing project filter and the newest 500 rows per slice. Counts are recomputed on every list
request. `?refresh=1|true` invalidates the separate listing cache, not a stored queue-count snapshot. This
resolution does not change the returned slice list or filters.
-3
View File
@@ -22,9 +22,6 @@ export interface AttentionItem {
/** The row's questions and the answers recorded so far, as the queue row holds them. */
humanQuestions?: HumanQuestion[];
humanAnswers?: HumanAnswers;
/** The settings page where the person decides this request directly, without an agent (a pairing request's Approve and
* Deny): a TUI settings category, and the request it opens on there. */
settingsLink?: { category: string; focus: string; label: string };
}
export interface AttentionDetail {
item: AttentionItem;
+25
View File
@@ -0,0 +1,25 @@
// The seat boundary. A seat's kind comes from its binding's `attachment_type`, the key the seat-kinds facade in #1429
// proposed (by rlch, credited). This first boundary covers today's kinds only: tmux, which is also the column's default
// and what a legacy row with no value means, and external_cli. Each seat's three responsibilities stay where they are:
// the runtime that keeps the process alive (RuntimeAdapter), the session that carries messages and turns to the agent
// (SessionTransport, which asks this resolver), and the view a person watches and types into (TerminalProvider).
/** Today's seat kinds. */
export type SeatKind = "tmux" | "external_cli";
/** A binding's kind as stored. `unknown` names a stored value that isn't one of today's kinds, and is never read as tmux. */
export type ResolvedSeatKind = { kind: SeatKind } | { kind: "unknown"; stored: string };
export function resolveSeatKind(stored: string | null | undefined): ResolvedSeatKind {
if (stored === null || stored === undefined || stored === "tmux") return { kind: "tmux" };
if (stored === "external_cli") return { kind: "external_cli" };
return { kind: "unknown", stored };
}
/** How OpenRig's inbound transport (send, capture and the activity probe) reaches a seat of this kind. An external CLI
* seat runs inside someone else's program, so OpenRig can't type into it or read it: `none`. An unknown stored kind
* keeps the path it takes today, the tmux one, so nothing changes for it here; whether it should is an open question
* for its owner, not something this boundary settles. */
export function seatInboundTransport(seat: ResolvedSeatKind): "tmux" | "none" {
return seat.kind === "external_cli" ? "none" : "tmux";
}
+26 -14
View File
@@ -51,6 +51,7 @@ import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
import { isAttentionRequiredReadinessCode, type NodeBinding, type ReadinessOptions, type ReadinessResult } from "./runtime-adapter.js";
import { getLatestStartupFailedEvent, hasClaudeResumeIdentityWork, isRecoverableStartupFailure } from "./startup-orchestrator.js";
import { resolveSeatKind, seatInboundTransport } from "./seat-kinds.js";
// OPR.0.4.1.10 — send-readiness freshness. The runtime-hook store keeps a 5min freshness for activity
// DISPLAY, but "safe to send NOW" needs a tight window: a stale "idle" read must not authorize a send
@@ -448,14 +449,18 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
const codexFooter = (line: string): boolean => isComposerFooter(line)
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
/** Preserve faint styling so autocomplete ghosts can be separated from authored
* text. Color channel values must never be interpreted as style codes. */
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
* Color channel values must never be interpreted as style codes. */
function styledTextLines(screen: string) {
let faint = false;
let faint = false, inverse = false;
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
let text = "", offset = 0;
const faintCells: boolean[] = [];
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
const faintCells: boolean[] = [], inverseCells: boolean[] = [];
const append = (value: string) => {
text += value;
faintCells.push(...Array<boolean>(value.length).fill(faint));
inverseCells.push(...Array<boolean>(value.length).fill(inverse));
};
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
append(raw.slice(offset, match.index));
const codes = match[1]!.split(";");
@@ -466,15 +471,18 @@ function styledTextLines(screen: string) {
const skip = codes[i + 1] === "5" ? 2 : codes[i + 1] === "2" ? (codes[i + 2] === "" ? 5 : 4) : 0;
// Color channels are not style codes. Ambiguous compound sequences
// provide no faint-style proof, rather than interpreting an RGB 2 as dim.
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
i += skip;
} else if (code === "" || code === "0" || code === "22") faint = false;
} else if (code === "" || code === "0") faint = inverse = false;
else if (code === "22") faint = false;
else if (code === "2") faint = true;
else if (code === "7") inverse = true;
else if (code === "27") inverse = false;
}
offset = match.index! + match[0].length;
}
append(raw.slice(offset));
return { text, faintCells };
return { text, faintCells, inverseCells };
});
}
@@ -569,7 +577,11 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
else if (cursorOffset >= 0 && !rows[cursorRow]!.slice(cursorOffset).trim()) visible[cursorRow] = rows[cursorRow]!.slice(0, cursorOffset);
}
const atStart = y === start && x === prefix;
const empty = atStart && rows.length === 1 && rows[0] === "";
// Claude can draw an inverse-video blank in the cursor cell (#2925). Only
// that single cell at the start is padding; ordinary/preceding spaces stay input.
const inverseCells = styled[start]!.inverseCells;
const cursorBlank = prompt.glyph === "❯" && rows[0] === " " && inverseCells[prefix] && !inverseCells[prefix - 1];
const empty = atStart && rows.length === 1 && (rows[0] === "" || cursorBlank);
const last = visible.length - 1;
const cursorAtEnd = y === start + last && x === prefix + stringWidth(visible[last] ?? "");
const label = visible.length === 1 && COMPOSER_COLLAPSED_PASTE_PATTERN.test(visible[0]!) ? visible[0]! : null;
@@ -969,7 +981,7 @@ export async function probeSessionActivity(input: {
};
}
const readTarget = binding?.pane ?? sessionName;
if (attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(attachmentType)) === "none") {
return {
state: "unknown",
reason: "unsupported_attachment",
@@ -2569,7 +2581,7 @@ export class SessionTransport {
const binding = this.sessionRegistry.getBindingForNode(node.id);
const latestSession = latestByNode.get(node.id);
if (binding?.attachmentType === "external_cli" && binding.externalSessionName) {
if (resolveSeatKind(binding?.attachmentType).kind === "external_cli" && binding?.externalSessionName) {
targets.push({
sessionName: binding.externalSessionName,
rigName,
@@ -2900,7 +2912,7 @@ export class SessionTransport {
// External CLI seats never wait: main refuses them before anything else,
// and a probe or wait would only delay that refusal (or mistake it for
// an absent session).
if (meta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(meta.attachmentType)) === "none") {
return { ok: false, failure: {
ok: false,
sessionName,
@@ -3094,7 +3106,7 @@ export class SessionTransport {
const waitMode = waitForIdleMs !== undefined;
let waitEvidence: Pick<SendResult, "activity" | "waitedMs" | "attempts"> = {};
if (sessionMeta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
return {
ok: false,
sessionName,
@@ -4360,7 +4372,7 @@ export class SessionTransport {
readable?: { width: number; height: number }; readableBudget?: ReadableBudget }): Promise<CaptureResult> {
sessionName = physicalSessionName(this.db, sessionName);
const sessionMeta = this.getSessionMeta(sessionName);
if (sessionMeta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
return {
ok: false,
sessionName,
-2
View File
@@ -84,8 +84,6 @@ export function attentionRoutes(): Hono {
...(!toPerson && q.state === "blocked" && q.blockedOn && isHumanSeatSessionRef(q.blockedOn) ? { answer: { verb: "resolve" as const, actorSession: actorFor(q.blockedOn) } } : {}),
...(toPerson ? { asker: q.sourceSession } : {}),
...(questions ? { humanQuestions: questions, humanAnswers: q.humanAnswers ?? {} } : {}),
// A pairing request is also decided directly on this host's settings page, opened on this request (its row).
...(q.evidenceRef?.startsWith("pair-request:") ? { settingsLink: { category: "instance", focus: q.qitemId, label: "Open pairing settings (Approve or Deny directly)" } } : {}),
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
const unbound = noConnector(toPerson ? q.destinationSession : q.blockedOn);
const notSent = unbound || quiet.has(q.qitemId);
+34 -113
View File
@@ -92,9 +92,9 @@ function describePairTtl(ms: number): string {
}
const PAIR_HTTP_TIMEOUT_MS = 10_000;
/** The pairing request's one question. A person answers it with a click or in their own words. Both are context for the
* receiving agent (the request's owner), which acts on them with `rig host approve|deny`; where none reads answers yet,
* they're kept for one, and neither decides by itself. */
/** 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[] {
@@ -129,9 +129,8 @@ interface IssuedPair {
expiresAt: number;
/** The person asked. */
person: string;
/** The seat that reads the person's answer and decides (the host's receiving agent); null when no agent reads answers
* here yet, where the answer is kept and a direct decision (`rig host approve|deny`, or the TUI's pairing settings)
* decides. */
/** 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 };
@@ -139,7 +138,7 @@ interface IssuedPair {
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 receiving agent, the queue can't take rows to it, or
/** 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";
/** The receiving agent the queue couldn't take rows to (`unroutable`). */
@@ -148,10 +147,6 @@ interface IssuedPair {
freeNote?: string;
/** Why the owner is a receiver used instead of the chosen one (an unusable agents.operator_session). */
ownerNote?: string;
/** The owner was resolved after the request was made (a receiving agent set meanwhile), so the person's ask still
* comes from the synthetic source and their answer is passed on to it once (`forwarded`). */
lateOwner?: boolean;
forwarded?: boolean;
}
interface ClientPair {
@@ -216,12 +211,6 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
: `no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no ${KERNEL_OPERATOR_SESSION})`;
}
/** The direct controls on this host, apart from a Needs-you answer: the TUI's pairing settings, or the CLI with the
* operator bearer token. */
function directDecision(code: string): string {
return `open the TUI's pairing settings (rig tui, then Settings, Instance & work, Pairing requests), or in a terminal with its operator bearer token run rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>)`;
}
/** 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); }
@@ -237,13 +226,12 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
}
/** What the request's own row has decided, read the same way by the poll and by `rig host approve|deny`: a refusing
* close, or an agent seat's close naming Approve. Null while it's undecided: a person's click or words are an answer
* for the receiving agent, never a decision by themselves, with or without an agent here. */
* 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))) return false;
// A close never approves over the person's own recorded Deny: it would hand the token over against what they chose.
if (pairClosedApproving(item) && answer !== "deny") return true;
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;
}
@@ -268,11 +256,11 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
`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.${rec.ownerNote ? ` (${rec.ownerNote}, so ${owner} reads it instead.)` : ""}`,
...((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 on this host: ${directDecision(code)}.`])(ownerRunning(c, owner)),
...((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.`,
] : [
`No agent reads answers here yet: ${noReader(rec)}. Your answer, a click or your words, is kept, and goes to the receiving agent once one is set; nothing is granted or denied meanwhile.`,
`To decide now on this host: ${directDecision(code)}.`,
`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.`,
@@ -454,44 +442,6 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
return c.json({ pairId, code, approvalQitemId: qitemId });
});
// The requests on this host that haven't expired, for its pairing settings: who asks, the code, when it expires, its
// status, and who reads the person's answer. No credential is listed; the code decides nothing without the operator
// bearer token (POST /pair-decision).
router.get("/pair-requests", (c) => {
const now = Date.now();
// Decided means what `rig host approve|deny` would find: its own decision, or one the request's row records.
// A row that can't be read is "unknown", never "pending".
const decidedOf = (rec: IssuedPair): boolean | null | undefined => {
if (rec.decision) return rec.decision.approve;
try { return rowDecision(c, rec, rec.qitemId ? getRepo(c).getById(rec.qitemId) : null); } catch { return undefined; }
};
// The person's answer kept on the request (their choice, or the start of their words, and who), shown where a direct
// decision is made, so an Approve over a kept Deny is made knowingly. Unreadable: not shown.
const keptOf = (rec: IssuedPair): { by: string; choice?: string; words?: string } | null => {
try {
const item = rec.qitemId ? getRepo(c).getById(rec.qitemId) : null;
const kept = item?.humanAnswers?.[PAIR_QUESTION_ID];
if (kept == null && !closedByReply(c, item)) return null;
const by = (item && item.state !== "pending" ? getRepo(c).closingTransition(item.qitemId, item.state)?.actorSession : null) ?? rec.person;
return typeof kept === "string" ? { by, choice: kept === PAIR_APPROVE ? "approve" : kept }
: kept != null && typeof kept === "object" ? { by, words: kept.text.replace(/\s+/g, " ").trim().slice(0, 200) } : { by, words: "" };
} catch { return null; }
};
const requests = [...issued.values()].filter((rec) => now <= rec.expiresAt).map((rec) => ({
code: rec.code,
requester: rec.requester,
createdAt: new Date(rec.createdAt).toISOString(),
expiresAt: new Date(rec.expiresAt).toISOString(),
status: ((d) => d === undefined ? "unknown" : d === null ? "pending" : d ? "approved" : "denied")(decidedOf(rec)),
person: rec.person,
owner: rec.owner ?? null,
...(rec.owner ? {} : { noReader: noReader(rec) }),
...((kept) => kept ? { answer: kept } : {})(keptOf(rec)),
approvalQitemId: rec.qitemId || null,
}));
return c.json({ requests });
});
router.get("/pair-request/:pairId", async (c) => {
const pairId = c.req.param("pairId");
const rec = issued.get(pairId);
@@ -502,65 +452,36 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
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` (or the TUI's
// pairing settings), or an agent seat's close naming Approve. A person's answer arriving decides nothing by itself:
// it's for the receiving agent, and where there's none yet it's kept for one. The request stays pending until a
// decision or expiry.
// 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 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));
const answered = answer != null || replied;
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";
if (!rec.owner) {
// A receiving agent set since the request was made takes it from here, with what the person already answered.
const now = pairOwner(c);
if (now.owner) {
rec.owner = now.owner;
rec.lateOwner = true;
if (now.ownerNote) rec.ownerNote = now.ownerNote;
delete rec.freeReason;
delete rec.freeSession;
delete rec.freeNote;
}
}
if (rec.owner && rec.lateOwner && answered && !rec.forwarded) {
rec.forwarded = true;
// One forward per request, by an id the request names: a create that failed after it committed isn't sent again.
const forwardId = `qitem-pair-forward-${(rec.qitemId || pairId).replace(/^qitem-/, "")}`;
// The person's whole answer for the agent to act on (receipts keep a short preview), and where it's recorded.
const whole = typeof answer === "string" ? `their ${answer === PAIR_APPROVE ? "Approve" : answer === "deny" ? "Deny" : answer} choice`
: answer != null && typeof answer === "object" ? `these words: "${answer.text.trim()}"` : "a reply with no words in it (such as a file)";
// 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 {
await getRepo(c).create({ qitemId: forwardId, sourceSession: PAIR_SOURCE_SESSION, destinationSession: rec.owner,
summary: `Pairing ${rec.code} from ${rec.requester}: ${rec.person} answered before you were its receiving agent`,
body: [`${rec.person} answered pairing request ${rec.code} from ${rec.requester} with ${whole}, while no agent read answers on this host. You're its receiving agent now.`,
...(rec.qitemId ? [`Their answer is recorded on the request's approval row: rig queue show ${rec.qitemId} --full (its question, the options and everything they sent).`] : []),
`Act on what they meant: a clear yes or an Approve click, rig host approve ${rec.code}; a clear no or a Deny click, rig host deny ${rec.code}. If it isn't clear, ask them back, naming pairing code ${rec.code}.`,
`The request expires at ${new Date(rec.expiresAt).toISOString().slice(0, 19).replace("T", " ")} UTC.`].join("\n") });
} catch {
// Only a forward that isn't there is passed on at the next poll.
try { rec.forwarded = !!getRepo(c).getById(forwardId); } catch { rec.forwarded = false; }
}
}
// No agent reads answers here: tell the person once that their answer is kept, and how to decide directly.
if (!rec.owner && answered && !rec.receipted) {
rec.receipted = true;
try {
await getRepo(c).create({ sourceSession: PAIR_SOURCE_SESSION, destinationSession: rec.person, humanIntent: "update",
summary: `Pairing ${rec.code} from ${rec.requester}: answer kept`,
body: `Received ${said} on pairing request ${rec.code} from ${rec.requester}. No agent reads answers on this host yet (${noReader(rec)}), so it's kept for the receiving agent once one is set, and nothing is granted or denied meanwhile. To decide now: ${directDecision(rec.code)}.` });
} catch { /* the answer is kept either way */ }
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 && answered) {
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 on this host: ${directDecision(rec.code)}.`;
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}`;
@@ -574,7 +495,7 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
}
// Single-shot: the first decided read consumes the pairing.
issued.delete(pairId);
// A decision from a close (an agent seat's close, or a refusing close) is told to the person
// 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) {
@@ -607,8 +528,8 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
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 refusing close, or
// an agent seat's close naming Approve), even before the requester's next poll.
// 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);
-11
View File
@@ -81,17 +81,6 @@ it("uses real human obligations, source summaries and dependents; excludes agent
} finally { f.db.close(); }
});
it("a pairing request links to the settings page that decides it directly, opened on that request; other asks don't", async () => {
const f = fixture();
try {
f.row("pair-ask", "human-founder@external"); f.row("other-ask", "human-founder@external");
f.db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run("pair-request:fixture-pair-id", "pair-ask");
const items = (await f.read()).items;
expect(items.find(i => i.id === "queue:pair-ask")?.settingsLink).toEqual({ category: "instance", focus: "pair-ask", label: "Open pairing settings (Approve or Deny directly)" });
expect(items.find(i => i.id === "queue:other-ask")?.settingsLink).toBeUndefined();
} finally { f.db.close(); }
});
it("keeps current proof corrections and exact project sources, retained health clears and unavailable states", async () => {
const f = fixture();
try {
@@ -162,6 +162,15 @@ describe("cursor-bound composer input", () => {
expect(inspectComposerInput(claude("\x1b[2;38;5;245mAsk Codex to do anything\x1b[0m", { x: 2, y: 2 })).state).toBe("empty");
});
it("recognizes only a single inverse-video Claude cursor blank at the prompt start", () => {
const cursor = { x: 2, y: 2 };
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", cursor))).toMatchObject({ state: "empty", rows: [""], cursorAtEnd: true });
for (const body of [" ", "\x1b[38;5;7m \x1b[0m", "\x1b[7m\x1b[27m ", "\x1b[7m \x1b[0mdraft"]) {
expect(inspectComposerInput(claude(body, cursor)).state).toBe("text");
}
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", { x: 3, y: 2 }))).toMatchObject({ state: "text", rows: [" "] });
});
it("requires the complete owned text and preserves significant spaces", () => {
const input = inspectComposerInput(claude("Run two commands\nthen report."));
expect(composerContainsOwnedText(input, "Run two commands\nthen report.")).toBe(true);
@@ -10,7 +10,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import { inspectComposerInput } from "../src/domain/session-transport.js";
import { annotateInputBox, inspectComposerInput } from "../src/domain/session-transport.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
@@ -133,7 +133,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
}
}, 30_000);
it("submits a multiline paste under a combined update footer through real tmux", async () => {
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
const socket = join(scratch, "s");
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
@@ -143,8 +143,9 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const db = new Database(":memory:");
let transport: SessionTransport | undefined;
try {
const command = [process.execPath, composer, "claude", stateFile, "update-banner"].map(shellQuote).join(" ");
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", "worker@test", command])).trim();
const command = [process.execPath, composer, "claude", stateFile, variant].map(shellQuote).join(" ");
const [width, height] = variant === "cursor-block" ? ["68", "31"] : ["120", "40"];
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", width, "-y", height, "-s", "worker@test", command])).trim();
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
@@ -157,7 +158,14 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db) });
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect fixture input");
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
if (variant === "cursor-block") {
await vi.waitFor(async () => {
const snapshot = await adapter.captureComposerSnapshot(pane);
expect(snapshot?.cursor).toEqual({ x: 2, y: 26, width: 68, height: 31 });
expect(snapshot!.screen.split("\n")[26]).toContain("❯\u00a0\x1b[7m ");
expect(annotateInputBox(snapshot!.screen, snapshot!.cursor, "claude-code")).toMatchObject({ status: "empty", confidence: "high" });
});
} else await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
const text = "first line\nsecond line\nlast line";
const result = await transport.send("worker@test", text, { deliveryId: "banner", actorSession: "sender@test", verify: false });
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
@@ -165,7 +173,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
await tmux(["send-keys", "-t", pane, "-l", "person's draft"]);
await vi.waitFor(() => expect(state().input).toBe("person's draft"));
expect(await transport.send("worker@test", "another message", { deliveryId: "draft", verify: false }))
.toMatchObject({ outcome: "retained", sent: false });
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
} finally {
await transport?.guardedDelivery?.stop();
+7 -3
View File
@@ -5,19 +5,23 @@ import { StringDecoder } from "node:string_decoder";
import stringWidth from "string-width";
const [runtime, stateFile, footerVariant] = process.argv.slice(2);
const cursorBlock = footerVariant === "cursor-block";
const rule = cursorBlock ? `\x1b[38;5;244m${"─".repeat(68)}\x1b[39m` : "────────────────────────────────────────";
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
const submissions = [];
const decoder = new StringDecoder("utf8");
function render() {
const rows = ["Editable delivery fixture", ...submissions.slice(-3).flatMap(text => text.split("\n"))];
if (runtime === "claude") rows.push("────────────────────────────────────────");
if (cursorBlock) while (rows.length < 25) rows.push("");
if (runtime === "claude") rows.push(rule);
const start = rows.length;
const displayed = ghost && cursor === input.length ? `${input}\x1b[2m suggested follow-up\x1b[22m`
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
const body = displayed.split("\n");
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
rows.push(...(runtime === "claude" ? [rule, cursorBlock
? " \x1b[38;5;211m⏵⏵ bypass permissions on\x1b[38;5;246m (shift+tab to cycle)\x1b[0m" : "? for shortcuts"] : ["", "fixture · 90% context left"]));
if (runtime === "claude" && ghost) {
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
@@ -103,117 +103,151 @@ async function fixture({ kernel = true, operator, ownershipUnreadable = false }:
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");
/** The pairing settings' read: the requests on this host, as text, so a leaked token would show. */
const requests = async () => { const res = await app.request("/api/hosts/pair-requests"); return { http: res.status, text: await res.text() }; };
return { repo, contract, ask, issued, poll, answer, decide, toPerson, slackButton, slackThreadReply, replyRows, requests };
return { repo, contract, ask, issued, poll, answer, decide, toPerson, slackButton, slackThreadReply, replyRows };
}
// With no receiving agent (no agents.operator_session, no kernel operator), a person's click or words are kept for the
// receiving agent once one is set, never a decision by themselves; the direct controls are the TUI's pairing settings and
// rig host approve|deny on this host.
const NO_READER = `no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no ${OPERATOR})`;
const direct = (code: string) => `open the TUI's pairing settings (rig tui, then Settings, Instance & work, Pairing requests), or in a terminal with its operator bearer token run rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>)`;
const kept = (code: string, said: string) => `Received ${said} on pairing request ${code} from requester@far-host. No agent reads answers on this host yet (${NO_READER}), so it's kept for the receiving agent once one is set, and nothing is granted or denied meanwhile. To decide now: ${direct(code)}.`;
describe("host pairing with no receiving agent: answers are kept for one, and the person decides directly", () => {
it("the request carries one question, says no agent reads answers yet, and names the direct controls", async () => {
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(`No agent reads answers here yet: ${NO_READER}. Your answer, a click or your words, is kept, and goes to the receiving agent once one is set; nothing is granted or denied meanwhile.`);
expect(item.body).toContain(`To decide now on this host: ${direct(f.issued.code)}.`);
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 (no receiving agent is set in agents.operator_session, and 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 });
});
it.each([
["a typed reply \"no\" (Steering or the TUI)", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ decision: "no" }), "\"no\""],
["a typed reply \"yes\" (Steering or the TUI)", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ decision: "yes" }), "\"yes\""],
["a typed Slack thread reply", (f: Awaited<ReturnType<typeof fixture>>) => f.slackThreadReply("who is this?"), "\"who is this?\""],
["Steering's Approve choice", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "approve" }), "your Approve choice"],
["the TUI's Approve act (primary user)", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "approve", authority: "primary-user" }), "your Approve choice"],
["Slack's Approve button", (f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("approve"), "your Approve choice"],
["Steering's Deny choice", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "deny" }), "your Deny choice"],
["Slack's Deny button", (f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("deny"), "your Deny choice"],
] as const)("%s is kept on the request and decides nothing: one receipt names the direct controls, and no token", async (_label, act, said) => {
// 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();
await act(f);
expect(f.repo.getById(f.ask)?.humanAnswers?.pair).toBeDefined();
expect(f.replyRows()).toEqual([]);
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.toPerson()).toEqual([expect.objectContaining({ humanIntent: "update", sourceSession: "host-pair@kernel",
summary: `Pairing ${f.issued.code} from requester@far-host: answer kept`, body: kept(f.issued.code, said) })]);
// Polling again sends nothing more.
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([true, false])("kernel rig registered: %s. Answers close the request in place, with no reply rows, and decide nothing", async (kernel) => {
for (const act of [
(f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "approve" }),
(f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("deny"),
(f: Awaited<ReturnType<typeof fixture>>) => f.answer({ decision: "yes" }),
]) {
const f = await fixture({ kernel });
await act(f);
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done" });
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.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" } });
expect(f.toPerson().filter((item) => item.summary === `Pairing ${f.issued.code} from requester@far-host: approved`)).toEqual([expect.objectContaining({
// 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("a receiving agent set after the person answered gets their answer once, and the person is told it reads it", async () => {
it("Slack's Approve button hands the token over once", async () => {
const f = await fixture();
await f.slackButton("approve");
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "lead@fixture-rig");
try {
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
await f.poll();
const toAgent = f.repo.list({ limit: 1000 }).filter((item) => item.destinationSession === "lead@fixture-rig");
expect(toAgent).toEqual([expect.objectContaining({ sourceSession: "host-pair@kernel",
summary: `Pairing ${f.issued.code} from requester@far-host: ${PERSON} answered before you were its receiving agent` })]);
expect(toAgent[0]!.body).toContain(`${PERSON} answered pairing request ${f.issued.code} from requester@far-host with their Approve choice`);
expect(toAgent[0]!.body).toContain(`rig host approve ${f.issued.code}`);
// It decides with the explicit operation, as any receiving agent does.
expect((await f.decide(f.issued.code, "approve", "lead@fixture-rig")).status).toBe(200);
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 });
} finally { vi.unstubAllEnvs(); }
expect((await f.poll()).http).toBe(404);
}
});
it("the pairing settings list the request with who reads it, show a decision's status, and never carry the token", async () => {
const f = await fixture();
const before = await f.requests();
expect(before.http).toBe(200);
expect(JSON.parse(before.text)).toEqual({ requests: [expect.objectContaining({ code: f.issued.code, requester: "requester@far-host",
status: "pending", person: PERSON, owner: null, noReader: NO_READER, approvalQitemId: f.ask })] });
expect((await f.decide(f.issued.code, "approve")).status).toBe(200);
const after = await f.requests();
expect(JSON.parse(after.text).requests).toEqual([expect.objectContaining({ code: f.issued.code, status: "approved" })]);
expect(before.text.includes(BEARER) || after.text.includes(BEARER)).toBe(false);
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("the pairing settings show a decision the request's own row records (an agent's close), before the requester collects it", async () => {
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.repo.update({ qitemId: f.ask, actorSession: AGENT, state: "done", closureReason: "no-follow-on", resolution: "approve" });
expect(JSON.parse((await f.requests()).text).requests).toEqual([expect.objectContaining({ code: f.issued.code, status: "approved" })]);
const g = await fixture();
await g.repo.update({ qitemId: g.ask, actorSession: AGENT, state: "done", closureReason: "no-follow-on", resolution: "deny" });
expect(JSON.parse((await g.requests()).text).requests).toEqual([expect.objectContaining({ code: g.issued.code, status: "denied" })]);
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 () => {
@@ -236,20 +270,25 @@ describe("host pairing with no receiving agent: answers are kept for one, and th
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
});
it("a person's own Deny is never overridden: a later agent close naming Approve hands nothing over", async () => {
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: "pending", code: f.issued.code }, leaksToken: false });
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
});
it("a recorded Approve that didn't close the request decides nothing; a later Steering Deny verb denies", async () => {
const f = await fixture();
f.repo.recordHumanAnswer({ qitemId: f.ask, actorSession: PERSON, questionId: "pair", optionId: "approve" });
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
await f.contract.act({ verb: "deny", qitemId: f.ask, actorSession: PERSON });
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 });
});
});
@@ -282,14 +321,14 @@ describe("host pairing with the kernel operator running: the person's answer goe
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 on this host: open the TUI's pairing settings (rig tui, then Settings, Instance & work, Pairing requests), or in a terminal with its operator bearer token run rig host approve ${f.issued.code}, or rig host deny ${f.issued.code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
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 on this host: open the TUI's pairing settings (rig tui, then Settings, Instance & work, Pairing requests), or in a terminal with its operator bearer token run rig host approve ${f.issued.code}, or rig host deny ${f.issued.code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
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 });
@@ -352,11 +391,10 @@ describe("host pairing with the kernel operator running: the person's answer goe
});
describe("host pairing: one decision per request, and no reply is denied on a technicality", () => {
it("a kept Deny click decides nothing by itself; the direct decision after it is the one decision, and a second is refused", async () => {
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, "deny")).status).toBe(200);
expect(await f.decide(f.issued.code, "approve")).toMatchObject({ status: 409, body: { error: "pair_request_decided", decision: "deny", by: "human@host" } });
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 });
});
@@ -377,11 +415,12 @@ describe("host pairing: one decision per request, and no reply is denied on a te
expect(f.toPerson()).toEqual([expect.objectContaining({ body: expect.stringContaining(`Received your reply on pairing request ${f.issued.code}`) })]);
});
it("an attachment-only Slack reply with no receiving agent is never denied: it's kept, and the person gets one receipt", async () => {
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({ humanIntent: "update", sourceSession: "host-pair@kernel", body: kept(f.issued.code, "your reply") })]);
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
@@ -389,12 +428,12 @@ describe("host pairing: one decision per request, and no reply is denied on a te
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: kept with a receipt, with or without a receiving agent", async (_label, act) => {
] 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({ humanIntent: "update", body: kept(free.issued.code, "your reply") })]);
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);
@@ -423,8 +462,7 @@ describe("host pairing: one decision per request, and no reply is denied on a te
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();
// An agent's close naming Approve: the poll decides it, and tells the person without awaiting the update.
await f.repo.update({ qitemId: f.ask, actorSession: AGENT, state: "done", closureReason: "no-follow-on", resolution: "approve" });
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);
@@ -447,7 +485,7 @@ describe("host pairing: exact expiry, and an agent-free request says why nobody
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(`No agent reads answers here yet: ${OPERATOR} can't receive answers on this host. Your answer, a click or your words, is kept`);
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("a receiving agent chosen in agents.operator_session owns the request, with no kernel operator here", async () => {
@@ -466,24 +504,19 @@ describe("host pairing: exact expiry, and an agent-free request says why nobody
const f = await fixture();
const item = f.repo.getById(f.ask)!;
expect(item.sourceSession).toBe("host-pair@kernel");
expect(item.body).toContain(`No agent reads answers here yet: no agent reads answers on this host (agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored, and it has no ${OPERATOR}). Your answer`);
expect(item.body).toContain(`you're asked again: no agent reads answers on this host (agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored, and it has no ${OPERATOR}).`);
expect(item.body).not.toContain("no receiving agent is set");
} finally { vi.unstubAllEnvs(); }
});
it("when OpenRig can't read who owns the request, it says it couldn't tell; once it can, the receiving agent takes the kept answer", async () => {
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("No agent reads answers here yet: OpenRig couldn't tell whether an agent reads answers on this host. Your answer, a click or your words, is kept");
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" });
// The ownership read works again at the poll: the running kernel operator is the receiving agent now, and gets the
// person's kept answer once; the person is told it reads it.
await f.poll();
await f.poll();
expect(f.repo.list({ limit: 1000 }).filter((row) => row.destinationSession === OPERATOR && row.sourceSession === "host-pair@kernel"))
.toEqual([expect.objectContaining({ summary: `Pairing ${f.issued.code} from requester@far-host: ${PERSON} answered before you were its receiving agent` })]);
expect(f.toPerson()).toEqual([expect.objectContaining({ body: expect.stringContaining(`Received "yes" on pairing request ${f.issued.code} from requester@far-host. ${OPERATOR} reads it and acts on it.`) })]);
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");
});
});
@@ -507,37 +540,3 @@ describe("host pairing: expiry when the requester stopped waiting", () => {
} finally { vi.useRealTimers(); }
});
});
describe("host pairing, round 1: the kept answer where it's decided, and one whole forward", () => {
it("the pairing list carries the person's kept answer: a Deny click, or their words, with who", async () => {
const f = await fixture();
expect((await f.answer({ questionId: "pair", optionId: "deny" })).status).toBe(200);
expect(JSON.parse((await f.requests()).text).requests).toEqual([expect.objectContaining({ status: "pending", answer: { by: PERSON, choice: "deny" } })]);
const g = await fixture();
expect((await g.answer({ decision: "only after lunch" })).status).toBe(200);
expect(JSON.parse((await g.requests()).text).requests).toEqual([expect.objectContaining({ status: "pending", answer: { by: PERSON, words: "only after lunch" } })]);
});
it("a late receiving agent gets the person's whole words and the approval row, once, even when the forward's create throws after it committed", async () => {
const f = await fixture({ operator: "running", ownershipUnreadable: true });
const words = `please pair it, ${"the build host needs it for the release ".repeat(8)}and tell me when it's done`;
expect(words.length).toBeGreaterThan(200);
await f.answer({ decision: words });
const realCreate = f.repo.create.bind(f.repo);
let attempts = 0; // forward creates tried: the first commits and then throws
const spy = vi.spyOn(f.repo, "create").mockImplementation(async (input) => {
const made = await realCreate(input);
if (input.qitemId?.startsWith("qitem-pair-forward-") && attempts++ === 0) throw new Error("read back failed after commit");
return made;
});
try {
await f.poll();
await f.poll();
} finally { spy.mockRestore(); }
expect(attempts).toBe(1); // the next poll found the committed forward and didn't send it again
const forwards = f.repo.list({ limit: 1000 }).filter((row) => row.destinationSession === OPERATOR && row.sourceSession === "host-pair@kernel");
expect(forwards).toHaveLength(1);
expect(forwards[0]!.body).toContain(`with these words: "${words}"`);
expect(forwards[0]!.body).toContain(`rig queue show ${f.ask} --full`);
});
});
@@ -471,11 +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 receiving agent, so the request says so: the person's answer is kept for one, and the direct
// controls are the TUI's pairing settings and rig host approve|deny on this host.
// 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("No agent reads answers here yet: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel). Your answer, a click or your words, is kept, and goes to the receiving agent once one is set; nothing is granted or denied meanwhile.");
expect(item.body).toContain(`To decide now on this host: open the TUI's pairing settings (rig tui, then Settings, Instance & work, Pairing requests), or in a terminal with its operator bearer token run rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
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 (no receiving agent is set in agents.operator_session, and 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 () => {
+2 -2
View File
@@ -70,7 +70,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
});
});
it("an earlier record that can't be read makes the history unknown, never 'no record' or an older time", () => {
it.skipIf(process.getuid?.() === 0)("an earlier record that can't be read makes the history unknown, never 'no record' or an older time (root bypasses mode 000)", () => {
withHome((home) => {
// Alone: an unreadable earlier record, and the seat now has another conversation (or no resume token).
prompt(home, "earlier");
@@ -90,7 +90,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
});
});
it("a state folder that can't be read reads unknown, not 'no record'", () => {
it.skipIf(process.getuid?.() === 0)("a state folder that can't be read reads unknown, not 'no record' (root bypasses mode 000)", () => {
withHome((home) => {
prompt(home, conversation);
const dir = join(home, "refocus");
+24
View File
@@ -0,0 +1,24 @@
// The seat boundary's resolver: today's two kinds, and a stored value that's neither read as unknown, never as tmux.
import { describe, it, expect } from "vitest";
import { resolveSeatKind, seatInboundTransport } from "../src/domain/seat-kinds.js";
describe("resolveSeatKind", () => {
it.each([
[null, { kind: "tmux" }],
[undefined, { kind: "tmux" }],
["tmux", { kind: "tmux" }],
["external_cli", { kind: "external_cli" }],
["herdr", { kind: "unknown", stored: "herdr" }],
["", { kind: "unknown", stored: "" }],
["TMUX", { kind: "unknown", stored: "TMUX" }],
] as const)("reads %j as %j", (stored, expected) => {
expect(resolveSeatKind(stored)).toEqual(expected);
});
it("gives an external CLI seat no inbound transport, and every other kind today's tmux path", () => {
expect(seatInboundTransport({ kind: "external_cli" })).toBe("none");
expect(seatInboundTransport({ kind: "tmux" })).toBe("tmux");
// An unknown stored kind keeps the path it takes today; the resolver still names it unknown.
expect(seatInboundTransport({ kind: "unknown", stored: "herdr" })).toBe("tmux");
});
});
@@ -2741,6 +2741,27 @@ describe("SessionTransport", () => {
expect(hasSessionSpy).not.toHaveBeenCalled();
});
// A stored kind that isn't one of today's (only reachable by writing the column directly) takes today's path, the tmux
// one, for send and capture: the seat boundary adds no refusal and changes nothing for it.
it("a binding whose stored kind isn't tmux or external_cli sends and captures through tmux, as before the boundary", async () => {
const { node } = seedCanonicalRig();
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(node.id);
const callOrder: string[] = [];
const tmux = mockTmux({
sendText: async () => { callOrder.push("sendText"); return { ok: true }; },
sendKeys: async (_t, keys) => { callOrder.push(`sendKeys:${keys.join(",")}`); return { ok: true }; },
capturePaneContent: async () => "line1",
});
const transport = createTransport(tmux);
const sent = await transport.send("dev-impl@my-rig", "hello");
expect(sent.reason).not.toBe("transport_unavailable");
expect(callOrder).toContain("sendText");
const captured = await transport.capture("dev-impl@my-rig");
expect(captured.ok).toBe(true);
expect(captured.content).toContain("line1");
});
// Test 11: resolveSessions by rig returns running sessions
it("resolveSessions by rig returns running sessions", async () => {
seedCanonicalRig();
@@ -100,8 +100,6 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
lines.push({ text: `Or answer in your own words: reply <text>, ${as}${verb === "answer" ? " (it fills the first unanswered question and closes the request)" : ""}.` });
}
}
// The settings page that decides this request directly (a pairing request's Approve and Deny), opened on it.
if (d.item.settingsLink) lines.push(listItem(d.item.settingsLink.label, { type: "config-category", category: d.item.settingsLink.category, focus: d.item.settingsLink.focus }));
// Chat, as on the Feed card: open the asker's own terminal to answer in your own words.
if (d.item.asker) {
const view = seatViewOf(snap, d.item.asker);
-3
View File
@@ -1,6 +1,5 @@
import { fieldLine, sectionRule, sliceWhole, wrapDetailLines, type ContentLine } from "../detail.js";
import type { FleetSnapshot, ViewState } from "../types.js";
import { pairingLines } from "./pairing.js";
export interface ConfigEntry {
key: string;
@@ -233,8 +232,6 @@ function configContentLines(state: ViewState, snap: FleetSnapshot, width: number
{ text: `Next: ${snap.connections?.nextAction ?? "rig gateway status"}` },
{ text: "Guidance only; verification contacts Slack explicitly." },
], width));
// A pairing request is decided directly here (the Feed's pairing item opens this page on it).
if (category === "instance") lines.push(...pairingLines(snap.pairRequests, state.configFocus, width), { text: "" });
if (category === "display") lines.push(...wrapDetailLines([{ text: `Client timezone: ${state.timeZone} (selected at TUI start)` }], width));
lines.push({ text: `${entries.length} settings${state.filter ? ` matching “${state.filter}”` : ""} · Enter opens full value/source` }, { text: "" });
lines.push({ text: " SETTING / VALUE / SOURCE · env > file > default" });
-113
View File
@@ -1,113 +0,0 @@
import { escapeControlChars, listItem, sectionRule, wrapDetailLines, type ContentLine } from "../detail.js";
import type { Action } from "../types.js";
/** One open pairing request on this host, as GET /api/hosts/pair-requests serves it. */
export interface PairRequest {
code: string;
requester: string;
createdAt: string;
expiresAt: string;
status: "pending" | "approved" | "denied" | "unknown";
person: string;
/** The agent that reads the person's answer; null when none does here (`noReader` says why). */
owner: string | null;
noReader?: string;
/** The person's answer kept on the request, which decided nothing by itself: their choice, or the start of their words. */
answer?: { by: string; choice?: string; words?: string };
/** The request's approval row, which names it for a decision. */
approvalQitemId: string | null;
}
export interface PairRequestsRead { requests: PairRequest[]; readAt: string }
type PairDecisionAct = Extract<Action, { type: "act"; act: "pair-decision" }>;
function hhmm(iso: string): string {
return /T(\d\d:\d\d)/.exec(iso)?.[1] ?? iso;
}
/** Instance & work's pairing requests: each open request, who reads the person's answer, and Approve and Deny as direct
* controls (as `rig host approve|deny`). `focus` (an approval row) marks the request the Feed opened this page on; when
* that request is no longer listed, the page says so above the list. */
export function pairingLines(read: PairRequestsRead | null | undefined, focus: string | null | undefined, width: number): ContentLine[] {
const lines: ContentLine[] = [sectionRule("Pairing requests", width)];
if (!read) {
return [...lines, ...wrapDetailLines([{ text: "Pairing requests unavailable: this host's list couldn't be read. Refresh, or run rig host approve <code> or rig host deny <code> here." }], width)];
}
const focused = focus ? read.requests.find((r) => r.approvalQitemId === focus) : undefined;
const text: ContentLine[] = [];
if (focus && !focused) text.push({ text: "The request you opened isn't open here anymore: it expired, was collected or decided, or the daemon restarted. Open requests are below." });
if (!read.requests.length) text.push({ text: "No open pairing requests on this host." });
for (const r of read.requests) {
const mark = r === focused ? "> " : " ";
text.push({ text: `${mark}${r.code} · ${r.requester} · expires ${hhmm(r.expiresAt)} UTC · ${r.status}` });
text.push({ text: ` ${r.owner ? `${r.owner} reads ${r.person}'s answer and decides` : `No agent reads answers here: ${r.noReader ?? "reason unreported"}`}` });
if (r.answer) {
const a = r.answer;
text.push({ text: a.choice === "deny" ? ` ${a.by} answered Deny (kept). Approve overrides it.`
: a.choice === "approve" ? ` ${a.by} answered Approve (kept). Deny overrides it.`
: a.choice ? ` ${a.by} chose ${a.choice} (kept).`
: a.words ? ` ${a.by} wrote "${a.words}" (kept).` : ` ${a.by} replied with no words (kept).` });
}
if (r.status === "pending" && r.approvalQitemId) {
const act = (decision: "approve" | "deny"): Action => ({ type: "act", act: "pair-decision", request: r.approvalQitemId!, decision, code: r.code, requester: r.requester });
text.push(listItem(`Approve ${r.code}: hand this host's bearer token to ${r.requester}`, act("approve"), 4),
listItem(`Deny ${r.code}`, act("deny"), 4));
} else if (r.status === "unknown") {
text.push({ text: " Its decision couldn't be read here: refresh before deciding." });
}
}
text.push({ text: `Read at ${read.readAt}. Approve and Deny decide directly, as rig host approve|deny does; the request is read again first.` });
return [...lines, ...wrapDetailLines(text, width)];
}
export interface PairDecisionClient {
pairRequests(): Promise<{ requests: PairRequest[] }>;
pairDecision(request: string, decision: "approve" | "deny"): Promise<{ status: number; body: Record<string, unknown> | null }>;
}
/** Decide one pairing request from the settings page: read it again first, so a request that's no longer open, or already
* decided, is reported and nothing is sent; then decide. When the decision can't be confirmed (a timeout or a lost
* connection), the outcome is unknown: it reads the request again and says what that shows, never deciding a second time. */
export async function decidePairRequest(client: PairDecisionClient, act: PairDecisionAct): Promise<string> {
// The notice is printed on the status line, which nothing else cleans: the requester is a host not yet paired, and the
// daemon's own messages can quote it, so the whole notice is escaped as the list's lines are.
return escapeControlChars(await decideOnce(client, act));
}
async function decideOnce(client: PairDecisionClient, act: PairDecisionAct): Promise<string> {
const what = `Pairing request ${act.code} from ${act.requester}`;
const find = async () => (await client.pairRequests()).requests.find((r) => r.approvalQitemId === act.request);
let before: PairRequest | undefined;
try { before = await find(); } catch (error) {
return `${what}: it couldn't be read again first (${(error as Error).message}), so nothing was sent. Refresh and try again.`;
}
if (!before) return `${what} isn't open here anymore (expired, collected or decided, or the daemon restarted). Nothing was sent.`;
if (before.status === "unknown") return `${what}: its decision couldn't be read, so nothing was sent. Refresh and try again.`;
if (before.status !== "pending") return `${what} was already ${before.status}; that stands. Nothing was sent.`;
// Not confirmed either way: the decision may have been recorded. Read it again and say what that shows; never resend.
const unknown = async (why: string): Promise<string> => {
const head = `${what}: the ${act.decision === "approve" ? "Approve" : "Deny"} wasn't confirmed (${why}), so its outcome is unknown.`;
try {
const after = await find();
return `${head} Read again: ${!after ? "it's no longer open here." : after.status === "pending" ? "still pending, with no decision recorded. Check again before deciding again." : `${after.status}.`}`;
} catch (again) {
return `${head} Reading it again failed too (${(again as Error).message}): refresh before deciding again.`;
}
};
let result: Awaited<ReturnType<PairDecisionClient["pairDecision"]>>;
try { result = await client.pairDecision(act.request, act.decision); } catch (error) {
return unknown((error as Error).message);
}
// The daemon's plain refusals decide nothing: no bearer (401), a bad request (400), gone (404), decided or expired (409).
// Any other failure, such as a server error, can follow a recorded decision, so its outcome is unknown.
if (result.status !== 401 && ![400, 404, 409].includes(result.status) && (result.status < 200 || result.status >= 300)) {
return unknown(`HTTP ${result.status}${typeof result.body?.error === "string" ? ` ${result.body.error}` : ""}`);
}
if (result.status === 401) return `${what}: this daemon refused the decision without its operator bearer token, so nothing was decided. Start the TUI with OPENRIG_AUTH_BEARER_TOKEN set to the token this daemon runs with, or run rig host ${act.decision} ${act.code} here.`;
if (result.status < 200 || result.status >= 300) {
const message = typeof result.body?.message === "string" ? result.body.message : `HTTP ${result.status}${typeof result.body?.error === "string" ? ` (${result.body.error})` : ""}`;
return `${what}: not decided here. ${message}`;
}
return act.decision === "approve"
? `Approved ${what}: the requester collects this host's bearer token at its next check.`
: `Denied ${what}: nothing is handed over.`;
}
-9
View File
@@ -428,15 +428,6 @@ export class DaemonClient {
reviewFleet() {
return this.get(`/api/review/fleet`);
}
/** This host's open pairing requests (read-only; nothing in it is a credential). */
pairRequests() { return this.get("/api/hosts/pair-requests") as Promise<{ requests: import("./config/pairing.js").PairRequest[] }>; }
/** Decide a pairing request directly, as `rig host approve|deny` does (POST /api/hosts/pair-decision), with the operator's
* action headers. Returns the HTTP outcome; a timeout or a lost connection throws, and then the outcome is unknown. */
async pairDecision(request: string, decision: "approve" | "deny"): Promise<{ status: number; body: Record<string, unknown> | null }> {
const res = await this.fetchImpl(`${this.baseUrl}/api/hosts/pair-decision`, { method: "POST",
headers: { ...this.actionHeaders, "Content-Type": "application/json" }, body: JSON.stringify({ request, decision }), signal: AbortSignal.timeout(10_000) });
return { status: res.status, body: await res.json().catch(() => null) as Record<string, unknown> | null };
}
/** 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, { headers: this.actionHeaders, unauthorized: ACTION_UNAUTHORIZED }) as Promise<{ verb: string; qitemId: string; closedQitem: unknown;
+1 -7
View File
@@ -26,18 +26,12 @@ export interface ContentLine {
/** Wrap prose and full references without losing a link's target. Graph/table rows
* retain their own layout; callers opt in only for detail pages. */
/** Text from outside shown in the terminal: tabs as spaces, and every other control character as a visible \xNN, so it
* can't move the cursor, clear the screen or start an escape sequence. */
export function escapeControlChars(text: string): string {
return text.replace(/\t/g, " ").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`);
}
export function wrapDetailLines(lines: ContentLine[], width: number): ContentLine[] {
const room = Math.max(8, width);
// A ContentLine is one logical terminal row. YAML block scalars and current
// source files may contain CR/LF; never emit those inside a physical row.
const logical = lines.flatMap((line) => line.text.split(/\r\n|\r|\n/).map((text, i) => ({
...line, text: escapeControlChars(text),
...line, text: text.replace(/\t/g, " ").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`),
...(i ? { action: undefined, zones: undefined, segs: undefined } : {}),
})));
return logical.flatMap((line) => {
+2 -5
View File
@@ -16,7 +16,6 @@ import { fileTargetForPath } from "./reading.js";
// - A failed read leaves its portion honest-empty and records a NAMED error.
import { emptySnapshot } from "./state.js";
import type { ConfigRead } from "./config/config-model.js";
import type { PairRequestsRead } from "./config/pairing.js";
import type { ConnectionsRead, ControlPlaneRead, SlackManifestRead } from "./connections/connections-model.js";
import type { UsageRead } from "./usage/usage-model.js";
import { DaemonClient } from "./daemon-client.js";
@@ -486,19 +485,17 @@ export async function hydrateSnapshot(
const passive = async <T>(label: string, read: () => Promise<unknown>): Promise<T | null> => {
try { return await read() as T; } catch { readErrors.push(`${label}: unavailable`); return null; }
};
const [config, controlPlane, connections, pairRequests] = await Promise.all([
const [config, controlPlane, connections] = await Promise.all([
passive<ConfigRead>("CONFIG", () => client.configBrowser().catch(error => {
configError = error instanceof Error ? error.message : "Read failed; cause not identified.";
throw error;
})),
passive<ControlPlaneRead>("control plane", () => client.health()),
passive<ConnectionsRead>("Slack observation", () => client.connections()),
// Instance & work's pairing requests: an in-memory, read-only list on this host.
passive<PairRequestsRead>("pairing requests", async () => ({ requests: (await client.pairRequests()).requests, readAt: new Date().toISOString() })),
]);
let daemonTarget = "unreported";
try { daemonTarget = new URL(client.baseUrl).origin; } catch { /* no raw invalid target */ }
return { ...emptySnapshot(), config, configError, controlPlane, connections, pairRequests, daemonTarget, hydratedAt: new Date().toISOString(), readErrors };
return { ...emptySnapshot(), config, configError, controlPlane, connections, daemonTarget, hydratedAt: new Date().toISOString(), readErrors };
}
const readingOnly = viewContext?.section === "specs";
-3
View File
@@ -34,7 +34,6 @@ import { evaluateOneClickGate, restoreConfirmMessage } from "./crash-cart/one-cl
import { daemonStartArgs } from "./crash-cart/start-daemon.js";
import { readLocal } from "./local-reading.js";
import { replyAct, opensOnDefaultFeed, actingAs, answerRequest } from "./attention/attention-model.js";
import { decidePairRequest } from "./config/pairing.js";
import type { AttentionRead } from "@openrig/daemon/attention";
import { StartupController } from "./startup.js";
import { dirname, join } from "node:path";
@@ -464,8 +463,6 @@ async function run(): Promise<void> {
} else if (action.act === "run") {
const result = await client.launchNode(action.rigId, action.agent);
view.dispatch({ type: "notice", message: launchNodeNotice(action.agent, result) });
} else if (action.act === "pair-decision") {
view.dispatch({ type: "notice", message: await decidePairRequest(client, action) });
} else if (action.act === "answer") {
const result = await client.missionControlAction(answerRequest(action));
const actor = actingAs(snapshot.attentionRead, action.actorSession);
+3 -4
View File
@@ -184,7 +184,7 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
return { ...next, lastError: action.message };
case "config-category": {
if (!CONFIG_CATEGORIES.some((c) => c.id === action.category)) return { ...next, lastError: "Unknown CONFIG category" };
return syncSelection(resetContent({ ...next, section: "config", drill: [], viewTab: "table", configCategory: action.category, configKey: null, configFocus: action.focus ?? null, filter: "", healthOpen: null }), snap);
return syncSelection(resetContent({ ...next, section: "config", drill: [], viewTab: "table", configCategory: action.category, configKey: null, filter: "", healthOpen: null }), snap);
}
case "config-setting":
return resetContent({ ...next, section: "config", drill: [], viewTab: "table", configKey: action.key, healthOpen: null });
@@ -201,7 +201,6 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
next.healthOpen = null;
next.configCategory = null;
next.configKey = null;
next.configFocus = null;
if (!state.sections.some((s) => s.name === action.section))
return { ...next, lastError: `unknown section "${action.section}"` };
return syncSelection(
@@ -368,8 +367,8 @@ function location(s: ViewState): string {
}
function navigationFrame(s: ViewState): NavigationFrame {
const { attentionCategory, attentionOpen, project, terminalView, terminalPage, file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey, configFocus } = s;
return { attentionCategory, attentionOpen, project, terminalView, terminalPage, file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey, configFocus };
const { attentionCategory, attentionOpen, project, terminalView, terminalPage, file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey } = s;
return { attentionCategory, attentionOpen, project, terminalView, terminalPage, file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey };
}
function applyDrill(state: ViewState, next: ViewState, action: Extract<Action, { type: "drill" }>, snap: FleetSnapshot): ViewState {
+2 -9
View File
@@ -413,8 +413,6 @@ export interface FleetSnapshot {
specsLoaded?: boolean;
config?: import("./config/config-model.js").ConfigRead | null;
configError?: string;
/** This host's open pairing requests, read with Settings; null when unavailable. */
pairRequests?: import("./config/pairing.js").PairRequestsRead | null;
connections?: import("./connections/connections-model.js").ConnectionsRead | null;
controlPlane?: import("./connections/connections-model.js").ControlPlaneRead | null;
/** OPR.0.6.0.5 — the shipped Slack app manifest (same object as `rig slack manifest`); null when unavailable. */
@@ -535,8 +533,7 @@ export type Action =
| { type: "timezone" }
| { type: "recent-open"; transitionId: number }
| { type: "back" }
/** `focus`: an item the category opens on, such as a pairing request's approval row in Instance & work. */
| { type: "config-category"; category: string; focus?: string }
| { type: "config-category"; category: string }
| { type: "config-setting"; key: string }
| { type: "noop" }
| { type: "error"; message: string }
@@ -584,8 +581,6 @@ export type Action =
/** Answer a request on its own row, as the person the Feed served: Steering's Approve, or a choice
* (questionId + optionId) or their own words (decision) through mission control's answer. */
| { type: "act"; act: "answer"; qitemId: string; verb: "approve" | "answer" | "resolve"; actorSession: string; questionId?: string; optionId?: string; decision?: string }
/** Decide a pairing request directly on this host, as `rig host approve|deny` does, by its approval row. */
| { type: "act"; act: "pair-decision"; request: string; decision: "approve" | "deny"; code: string; requester: string }
/** Answer the open request in your own words (the `reply` command); main resolves it to the served answer act. */
| { type: "attention-reply"; text: string }
| { type: "notice"; message: string };
@@ -617,8 +612,6 @@ export interface ViewState {
history?: NavigationFrame[];
configCategory?: string | null;
configKey?: string | null;
/** The item an opened category is focused on (a pairing request's approval row). */
configFocus?: string | null;
pendingDrill?: PendingDrill | null;
/** Last PendingDrill seq handed out; never reused while this view lives. */
pendingSeq?: number;
@@ -662,7 +655,7 @@ export interface ViewState {
lastError: string | null;
}
export type NavigationFrame = Pick<ViewState, "attentionCategory" | "attentionOpen" | "project" | "terminalView" | "terminalPage" | "file" | "externalUrl" | "section" | "drill" | "filter" | "selection" | "runningOf" | "viewTab" | "contentOffset" | "contentMaxOffset" | "contentTargetCount" | "contentSelection" | "focusedPane" | "scopesMission" | "scopesSelected" | "scopesCollapseReqs" | "scopesNarrative" | "executionOpen" | "expanded" | "timeZoneHelp" | "recentOpen" | "configCategory" | "configKey" | "configFocus">;
export type NavigationFrame = Pick<ViewState, "attentionCategory" | "attentionOpen" | "project" | "terminalView" | "terminalPage" | "file" | "externalUrl" | "section" | "drill" | "filter" | "selection" | "runningOf" | "viewTab" | "contentOffset" | "contentMaxOffset" | "contentTargetCount" | "contentSelection" | "focusedPane" | "scopesMission" | "scopesSelected" | "scopesCollapseReqs" | "scopesNarrative" | "executionOpen" | "expanded" | "timeZoneHelp" | "recentOpen" | "configCategory" | "configKey">;
export interface ViewStateStore {
instanceId: string;
+1 -11
View File
@@ -118,20 +118,10 @@ describe("against the daemon's real routes, a pairing answer without the operato
expect(await d.poll()).toMatchObject({ status: "pending" });
});
it("with the operator bearer, the person's Approve is recorded and kept for a receiving agent; it doesn't decide the agent-free request", async () => {
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: "pending" });
});
it("the settings page's direct decision takes the operator bearer: without it nothing is decided, with it the request is approved", async () => {
const d = await daemon();
const refused = await d.tui({ Authorization: `Bearer ${TERMINAL}` }).pairDecision(d.issued.approvalQitemId, "approve");
expect(refused.status).toBe(401);
expect(await d.poll()).toMatchObject({ status: "pending" });
const decided = await d.tui({ Authorization: `Bearer ${ACTION}` }).pairDecision(d.issued.approvalQitemId, "approve");
expect(decided).toMatchObject({ status: 200, body: { ok: true, decision: "approve" } });
expect(await d.poll()).toMatchObject({ status: "approved" });
});
});
+1 -1
View File
@@ -86,7 +86,7 @@ describe("CONFIG in the normal TUI journey", () => {
expect(snap.config!.entries.filter((e) => e.group === "general").map((e) => e.key)).toEqual(expect.arrayContaining([...SETTINGS_VALID_KEYS]));
while (["config", "system"].includes(view.get().section)) escape();
expect(view.get()).toMatchObject({ section: work.section, selection: work.selection, filter: work.filter, contentOffset: work.contentOffset });
expect(new Set(trace)).toEqual(new Set(["GET /api/config?view=browser", "GET /healthz", "GET /api/gateway/connections", "GET /api/hosts/pair-requests"]));
expect(new Set(trace)).toEqual(new Set(["GET /api/config?view=browser", "GET /healthz", "GET /api/gateway/connections"]));
expect(sources.map((p) => readFileSync(p, "utf8"))).toEqual(before);
expect(external).not.toHaveBeenCalled();
expect(JSON.stringify(snap)).not.toContain(secret);
-155
View File
@@ -1,155 +0,0 @@
// A pairing request is decided directly on this host's settings page: the Feed's pairing item opens Settings > Instance &
// work on that request, Back returns to the item, and Approve and Deny there read the request again before deciding, as
// `rig host approve|deny` does. A decision that can't be confirmed reads "outcome unknown" and re-reads; it never retries.
import { describe, expect, it, vi } from "vitest";
import { DaemonClient } from "../src/daemon-client.js";
import { hydrateSnapshot } from "../src/hydrate.js";
import { createViewState, emptySnapshot } from "../src/state.js";
import { attentionLines } from "../src/attention/attention-model.js";
import { configLines } from "../src/config/config-model.js";
import { decidePairRequest, pairingLines, type PairDecisionClient, type PairRequest } from "../src/config/pairing.js";
import { parseCommand } from "../src/grammar.js";
import type { Action, FleetSnapshot } from "../src/types.js";
const request = (over: Partial<PairRequest> = {}): PairRequest => ({ code: "482913", requester: "requester@far-host", createdAt: "2026-11-11T01:00:00.000Z",
expiresAt: "2026-11-11T01:10:00.000Z", status: "pending", person: "alex@external", owner: null,
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)",
approvalQitemId: "pair-ask", ...over });
type DecideAct = Extract<Action, { type: "act"; act: "pair-decision" }>;
const approveAct: DecideAct = { type: "act", act: "pair-decision", request: "pair-ask", decision: "approve", code: "482913", requester: "requester@far-host" };
describe("pairing requests on Settings > Instance & work", () => {
it("the Feed's pairing item opens the settings page on that request, with Approve and Deny there; Back returns to the item", async () => {
const ask = { id: "queue:pair-ask", kind: "action" as const, summary: "Pair requester@far-host with this host?", unblocks: null, urgency: "routine",
at: "2026-11-11T01:00:00Z", scope: "instance · project unknown", project: null, source: "/api/queue/pair-ask", recipient: "alex@external",
settingsLink: { category: "instance", focus: "pair-ask", label: "Open pairing settings (Approve or Deny directly)" } };
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: (async (url) => {
const u = new URL(String(url));
if (u.pathname === "/api/attention") return Response.json({ scope: "instance", readAt: ask.at, items: [ask], sources: [{ source: "queue", state: "available", detail: "fixture" }],
detail: u.searchParams.get("item") ? { item: ask, lines: [], files: [] } : null, detailError: null });
if (u.pathname === "/api/queue/human-updates") return Response.json({ items: [], limit: 20, truncated: false });
if (u.pathname === "/healthz") return Response.json({ status: "ok", selfHostId: "fixture-host" });
if (u.pathname === "/api/config") return Response.json({ observedAt: ask.at, home: "/fixture", readOnly: true, sources: [], entries: [], exclusions: [] });
if (u.pathname === "/api/gateway/connections") return Response.json({ error: "fixture" }, { status: 404 });
if (u.pathname === "/api/hosts/pair-requests") return Response.json({ requests: [request(), request({ code: "777111", requester: "other@host", approvalQitemId: "other-ask" })] });
throw new Error(`Unexpected request: ${u}`);
}) as typeof fetch });
let snap: FleetSnapshot = emptySnapshot();
const view = createViewState({ instanceId: "pairing-settings", getSnapshot: () => snap });
const refresh = async () => { snap = await hydrateSnapshot(client, undefined, null, null, null, view.get()); };
view.dispatch(parseCommand("attention")); await refresh();
view.dispatch({ type: "attention-open", id: ask.id }); await refresh();
const open = attentionLines(view.get(), snap, 140).find((l) => l.text.includes("Open pairing settings"));
expect(open?.action).toEqual({ type: "config-category", category: "instance", focus: "pair-ask" });
view.dispatch(open!.action!); await refresh();
expect(view.get()).toMatchObject({ section: "config", configCategory: "instance", configFocus: "pair-ask" });
const page = configLines(view.get(), snap, 140);
const text = page.map((l) => l.text).join("\n");
expect(text).toContain("> 482913 · requester@far-host · expires 01:10 UTC · pending");
expect(text).toContain(" 777111 · other@host");
expect(text).toContain("No agent reads answers here: no agent reads answers on this host");
expect(page.filter((l) => l.action?.type === "act").map((l) => l.action)).toEqual([
approveAct, { ...approveAct, decision: "deny" },
{ ...approveAct, request: "other-ask", code: "777111", requester: "other@host" }, { ...approveAct, request: "other-ask", code: "777111", requester: "other@host", decision: "deny" },
]);
view.dispatch({ type: "back" }); await refresh();
expect(view.get()).toMatchObject({ attentionOpen: ask.id });
expect(view.get().section).not.toBe("config");
});
it("a request the Feed opened on that's no longer listed says so above the open ones; a decided or unreadable one offers no controls", () => {
const text = (focus: string | null, requests: PairRequest[]) => pairingLines({ requests, readAt: "2026-11-11T01:01:00Z" }, focus, 140);
const gone = text("gone-ask", [request()]);
expect(gone.map((l) => l.text).join("\n")).toContain("The request you opened isn't open here anymore");
expect(gone.some((l) => l.text.startsWith("> "))).toBe(false);
const decided = text("pair-ask", [request({ status: "approved", owner: "lead@rig", noReader: undefined })]);
expect(decided.map((l) => l.text).join("\n")).toContain("lead@rig reads alex@external's answer and decides");
expect(decided.some((l) => l.action?.type === "act")).toBe(false);
const unknown = text(null, [request({ status: "unknown" })]);
expect(unknown.some((l) => l.action?.type === "act")).toBe(false);
expect(unknown.map((l) => l.text).join("\n")).toContain("Its decision couldn't be read here: refresh before deciding.");
expect(pairingLines(null, "pair-ask", 140).map((l) => l.text).join("\n")).toContain("Pairing requests unavailable");
});
});
describe("deciding a pairing request from the settings page", () => {
const fake = (lists: PairRequest[][], decide: PairDecisionClient["pairDecision"]) => {
const pairDecision = vi.fn(decide);
let i = 0;
const client: PairDecisionClient = { pairRequests: async () => ({ requests: lists[Math.min(i++, lists.length - 1)]! }), pairDecision };
return { client, pairDecision };
};
it("reads the request again first: one already decided, or gone, is reported and nothing is sent", async () => {
const decided = fake([[request({ status: "denied" })]], async () => ({ status: 200, body: { ok: true } }));
expect(await decidePairRequest(decided.client, approveAct)).toBe("Pairing request 482913 from requester@far-host was already denied; that stands. Nothing was sent.");
const gone = fake([[]], async () => ({ status: 200, body: { ok: true } }));
expect(await decidePairRequest(gone.client, approveAct)).toContain("isn't open here anymore");
expect(decided.pairDecision).not.toHaveBeenCalled();
expect(gone.pairDecision).not.toHaveBeenCalled();
});
it("decides a pending request by its approval row, and passes the daemon's own refusal through", async () => {
const ok = fake([[request()]], async () => ({ status: 200, body: { ok: true, decision: "approve" } }));
expect(await decidePairRequest(ok.client, approveAct)).toBe("Approved Pairing request 482913 from requester@far-host: the requester collects this host's bearer token at its next check.");
expect(ok.pairDecision).toHaveBeenCalledWith("pair-ask", "approve");
const late = fake([[request()]], async () => ({ status: 409, body: { error: "pair_request_decided", message: "Pairing request 482913 was already denied by lead@rig; that stands." } }));
expect(await decidePairRequest(late.client, { ...approveAct, decision: "deny" })).toBe("Pairing request 482913 from requester@far-host: not decided here. Pairing request 482913 was already denied by lead@rig; that stands.");
const noToken = fake([[request()]], async () => ({ status: 401, body: { error: "unauthorized" } }));
expect(await decidePairRequest(noToken.client, approveAct)).toContain("refused the decision without its operator bearer token, so nothing was decided");
});
it("a decision that times out reads \"outcome unknown\", then reads the request again and says what it shows, never sending twice", async () => {
// As fetch does: the timeout rejects the promise, it doesn't throw synchronously.
const timeout = async (): Promise<never> => { throw Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" }); };
const landed = fake([[request()], [request({ status: "approved" })]], timeout);
expect(await decidePairRequest(landed.client, approveAct)).toBe("Pairing request 482913 from requester@far-host: the Approve wasn't confirmed (The operation was aborted due to timeout), so its outcome is unknown. Read again: approved.");
const notYet = fake([[request()], [request()]], timeout);
expect(await decidePairRequest(notYet.client, approveAct)).toContain("its outcome is unknown. Read again: still pending, with no decision recorded. Check again before deciding again.");
const blind = fake([[request()]], timeout);
blind.client.pairRequests = vi.fn().mockResolvedValueOnce({ requests: [request()] }).mockRejectedValueOnce(new Error("connection refused"));
expect(await decidePairRequest(blind.client, approveAct)).toContain("its outcome is unknown. Reading it again failed too (connection refused): refresh before deciding again.");
for (const f of [landed, notYet, blind]) expect(f.pairDecision).toHaveBeenCalledTimes(1);
});
});
describe("pairing settings, round 1", () => {
it("shows the person's kept answer above Approve and Deny, so an Approve over a kept Deny is made knowingly", () => {
const lines = pairingLines({ requests: [request({ answer: { by: "alex@external", choice: "deny" } })], readAt: "2026-11-11T01:01:00Z" }, "pair-ask", 140);
const text = lines.map((l) => l.text).join("\n");
expect(text).toContain("alex@external answered Deny (kept). Approve overrides it.");
expect(lines.filter((l) => l.action?.type === "act")).toHaveLength(2);
const words = pairingLines({ requests: [request({ answer: { by: "alex@external", words: "only after lunch" } })], readAt: "2026-11-11T01:01:00Z" }, null, 140);
expect(words.map((l) => l.text).join("\n")).toContain('alex@external wrote "only after lunch" (kept).');
});
it("a server error from the decision reads \"outcome unknown\" and reads again, never sending twice; the plain refusals stay plain", async () => {
const decideOnce = vi.fn(async () => ({ status: 500, body: { error: "internal_error" } }));
let i = 0;
const lists = [[request()], [request({ status: "approved" })]];
const client: PairDecisionClient = { pairRequests: async () => ({ requests: lists[Math.min(i++, 1)]! }), pairDecision: decideOnce };
expect(await decidePairRequest(client, approveAct)).toBe("Pairing request 482913 from requester@far-host: the Approve wasn't confirmed (HTTP 500 internal_error), so its outcome is unknown. Read again: approved.");
expect(decideOnce).toHaveBeenCalledTimes(1);
for (const status of [400, 404, 409]) {
const plain: PairDecisionClient = { pairRequests: async () => ({ requests: [request()] }), pairDecision: async () => ({ status, body: { message: `refused ${status}` } }) };
expect(await decidePairRequest(plain, approveAct)).toBe(`Pairing request 482913 from requester@far-host: not decided here. refused ${status}`);
}
});
});
describe("the decide notice escapes what the request carries", () => {
it("a requester with control characters or an escape sequence is shown escaped, never written to the terminal raw", async () => {
const hostile = "evil\x1b[2J\x1b]0;owned\x07@far-host";
const act = { ...approveAct, requester: hostile };
const gone: PairDecisionClient = { pairRequests: async () => ({ requests: [] }), pairDecision: async () => ({ status: 200, body: {} }) };
const notice = await decidePairRequest(gone, act);
expect(notice).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/);
expect(notice).toContain("evil\\x1b[2J\\x1b]0;owned\\x07@far-host");
// The daemon's own refusal can quote the requester too: escaped the same way.
const refusing: PairDecisionClient = { pairRequests: async () => ({ requests: [request({ requester: hostile })] }),
pairDecision: async () => ({ status: 409, body: { error: "pair_request_decided", message: `Pairing request 482913 from ${hostile} was already denied` } }) };
expect(await decidePairRequest(refusing, act)).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/);
});
});