mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 17:30:37 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17c7b1e62f | ||
|
|
7b454bf0f0 | ||
|
|
cda1c2ab79 | ||
|
|
b9e81a7ac6 | ||
|
|
e6be9ae38d | ||
|
|
6f28eb0353 | ||
|
|
95c9abee5f | ||
|
|
b7c3b9240d |
No files matched your search
@@ -372,15 +372,25 @@ 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 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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -388,11 +398,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 (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
|
||||
`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
|
||||
gets one update with the result. An agent seat's `rig queue update <id> --state done --closure-reason no-follow-on
|
||||
--resolution approve` on an unanswered request still approves, and any other close that carries no answer and isn't
|
||||
the person's own reply, or `--state denied`, refuses. To refuse a pairing, answer Deny on a target with no kernel operator, run
|
||||
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
|
||||
`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.
|
||||
|
||||
|
||||
@@ -428,24 +428,22 @@ running seat only through the action in the last column.
|
||||
|
||||
### New guidance for a running seat
|
||||
|
||||
There are three ways for a seat to pick up new guidance:
|
||||
These reach a running seat without a restart, in the order they reach the agent:
|
||||
|
||||
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`.
|
||||
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.
|
||||
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. 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.
|
||||
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.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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),
|
||||
|
||||
@@ -218,9 +218,5 @@ 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. 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.
|
||||
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.
|
||||
@@ -22,6 +22,9 @@ 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;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -51,7 +51,6 @@ 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
|
||||
@@ -449,18 +448,14 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
|
||||
const codexFooter = (line: string): boolean => isComposerFooter(line)
|
||||
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
|
||||
|
||||
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
|
||||
* Color channel values must never be interpreted as style codes. */
|
||||
/** Preserve faint styling so autocomplete ghosts can be separated from authored
|
||||
* text. Color channel values must never be interpreted as style codes. */
|
||||
function styledTextLines(screen: string) {
|
||||
let faint = false, inverse = false;
|
||||
let faint = false;
|
||||
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
|
||||
let text = "", offset = 0;
|
||||
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));
|
||||
};
|
||||
const faintCells: boolean[] = [];
|
||||
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
|
||||
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
|
||||
append(raw.slice(offset, match.index));
|
||||
const codes = match[1]!.split(";");
|
||||
@@ -471,18 +466,15 @@ 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 = inverse = false; break; }
|
||||
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
|
||||
i += skip;
|
||||
} else if (code === "" || code === "0") faint = inverse = false;
|
||||
else if (code === "22") faint = false;
|
||||
} else if (code === "" || code === "0" || 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, inverseCells };
|
||||
return { text, faintCells };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -577,11 +569,7 @@ 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;
|
||||
// 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 empty = atStart && rows.length === 1 && rows[0] === "";
|
||||
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;
|
||||
@@ -981,7 +969,7 @@ export async function probeSessionActivity(input: {
|
||||
};
|
||||
}
|
||||
const readTarget = binding?.pane ?? sessionName;
|
||||
if (seatInboundTransport(resolveSeatKind(attachmentType)) === "none") {
|
||||
if (attachmentType === "external_cli") {
|
||||
return {
|
||||
state: "unknown",
|
||||
reason: "unsupported_attachment",
|
||||
@@ -2581,7 +2569,7 @@ export class SessionTransport {
|
||||
const binding = this.sessionRegistry.getBindingForNode(node.id);
|
||||
const latestSession = latestByNode.get(node.id);
|
||||
|
||||
if (resolveSeatKind(binding?.attachmentType).kind === "external_cli" && binding?.externalSessionName) {
|
||||
if (binding?.attachmentType === "external_cli" && binding.externalSessionName) {
|
||||
targets.push({
|
||||
sessionName: binding.externalSessionName,
|
||||
rigName,
|
||||
@@ -2912,7 +2900,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 (seatInboundTransport(resolveSeatKind(meta.attachmentType)) === "none") {
|
||||
if (meta.attachmentType === "external_cli") {
|
||||
return { ok: false, failure: {
|
||||
ok: false,
|
||||
sessionName,
|
||||
@@ -3106,7 +3094,7 @@ export class SessionTransport {
|
||||
const waitMode = waitForIdleMs !== undefined;
|
||||
let waitEvidence: Pick<SendResult, "activity" | "waitedMs" | "attempts"> = {};
|
||||
|
||||
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
|
||||
if (sessionMeta.attachmentType === "external_cli") {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
@@ -4372,7 +4360,7 @@ export class SessionTransport {
|
||||
readable?: { width: number; height: number }; readableBudget?: ReadableBudget }): Promise<CaptureResult> {
|
||||
sessionName = physicalSessionName(this.db, sessionName);
|
||||
const sessionMeta = this.getSessionMeta(sessionName);
|
||||
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
|
||||
if (sessionMeta.attachmentType === "external_cli") {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
|
||||
@@ -84,6 +84,8 @@ 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);
|
||||
|
||||
@@ -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. 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. */
|
||||
/** 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. */
|
||||
const PAIR_QUESTION_ID = "pair";
|
||||
const PAIR_APPROVE = "approve";
|
||||
function pairQuestion(requester: string, code: string): HumanQuestion[] {
|
||||
@@ -129,8 +129,9 @@ interface IssuedPair {
|
||||
expiresAt: number;
|
||||
/** The person asked. */
|
||||
person: string;
|
||||
/** The seat that reads the person's answer and decides (the kernel operator); null when this host is agent-free, where
|
||||
* the person's own click, or `rig host approve|deny` in a terminal, decides. */
|
||||
/** 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. */
|
||||
owner: string | null;
|
||||
/** The explicit decision (`rig host approve|deny`), made once. */
|
||||
decision?: { approve: boolean; by: string };
|
||||
@@ -138,7 +139,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 kernel operator, the queue can't take rows to it, or
|
||||
/** Why an agent-free request has no owner: this host has no receiving agent, 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`). */
|
||||
@@ -147,6 +148,10 @@ 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 {
|
||||
@@ -211,6 +216,12 @@ 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); }
|
||||
@@ -226,12 +237,13 @@ 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, 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. */
|
||||
* 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. */
|
||||
function rowDecision(c: { get: (key: string) => unknown }, rec: IssuedPair, item: QueueItem | null): boolean | null {
|
||||
const answer = item?.humanAnswers?.[PAIR_QUESTION_ID];
|
||||
if (pairClosedRefusing(item, answer != null || closedByReply(c, item)) || (!rec.owner && answer === "deny")) return false;
|
||||
if (pairClosedApproving(item) || (!rec.owner && answer === PAIR_APPROVE)) return true;
|
||||
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;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -256,11 +268,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, 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)),
|
||||
...((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)),
|
||||
`For ${owner}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming pairing code ${code}, and act on what they answer.`,
|
||||
] : [
|
||||
`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: ${noReader(rec)}.`,
|
||||
`To decide in a terminal on this host with its operator bearer token: rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`,
|
||||
`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)}.`,
|
||||
]),
|
||||
`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.`,
|
||||
@@ -442,6 +454,44 @@ 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);
|
||||
@@ -452,36 +502,65 @@ 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`, 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.
|
||||
// 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.
|
||||
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));
|
||||
// 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() : "";
|
||||
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)";
|
||||
try {
|
||||
rec.qitemId = await askPerson(c, pairId, rec, [words
|
||||
? `You wrote "${words.length > 200 ? `${words.slice(0, 200)}…` : words}" in reply to this request. Your words are kept, and they didn't deny anything: ${noReader(rec)}, so please decide with Approve or Deny here.`
|
||||
: `Your reply to this request had no words to keep here, and it didn't deny anything: ${noReader(rec)}, so please decide with Approve or Deny here.`]);
|
||||
} catch { rec.qitemId = closed; } // the next poll asks again
|
||||
return c.json({ status: "pending", code: rec.code });
|
||||
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 */ }
|
||||
}
|
||||
// Where the owner reads answers, tell the person once that their answer was received, and whether the owner is
|
||||
// running to read it.
|
||||
if (rec.owner && !rec.receipted && (answer != null || replied)) {
|
||||
if (rec.owner && !rec.receipted && answered) {
|
||||
rec.receipted = true;
|
||||
const said = typeof answer === "string" ? `your ${answer === PAIR_APPROVE ? "Approve" : answer === "deny" ? "Deny" : answer} choice`
|
||||
: answer != null && typeof answer === "object" ? `"${answer.text.replace(/\s+/g, " ").trim().slice(0, 200)}"` : "your reply";
|
||||
const running = ownerRunning(c, rec.owner);
|
||||
const waiting = `Nothing is granted or denied until it acts, or the request expires at ${new Date(rec.expiresAt).toISOString().slice(0, 19).replace("T", " ")} UTC. To decide now, in a terminal on this host with its operator bearer token: rig host approve ${rec.code}, or rig host deny ${rec.code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`;
|
||||
const 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 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}`;
|
||||
@@ -495,7 +574,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 (the person's click on an agent-free host, an agent seat's close) is told to the person
|
||||
// A decision from a close (an agent seat's close, or a refusing 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) {
|
||||
@@ -528,8 +607,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 click on an
|
||||
// agent-free host, a refusing close, 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 refusing close, or
|
||||
// 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);
|
||||
|
||||
@@ -81,6 +81,17 @@ 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,15 +162,6 @@ 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 { annotateInputBox, inspectComposerInput } from "../src/domain/session-transport.js";
|
||||
import { 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.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
|
||||
it("submits a multiline paste under a combined update footer through real tmux", async () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
|
||||
const socket = join(scratch, "s");
|
||||
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
|
||||
@@ -143,9 +143,8 @@ 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, 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();
|
||||
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();
|
||||
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
|
||||
@@ -158,14 +157,7 @@ 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");
|
||||
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"));
|
||||
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 });
|
||||
@@ -173,7 +165,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, reason: "draft_input_busy" });
|
||||
.toMatchObject({ outcome: "retained", sent: false });
|
||||
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
|
||||
} finally {
|
||||
await transport?.guardedDelivery?.stop();
|
||||
|
||||
+3
-7
@@ -5,23 +5,19 @@ 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 (cursorBlock) while (rows.length < 25) rows.push("");
|
||||
if (runtime === "claude") rows.push(rule);
|
||||
if (runtime === "claude") rows.push("────────────────────────────────────────");
|
||||
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" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
|
||||
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
|
||||
const body = displayed.split("\n");
|
||||
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
|
||||
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"]));
|
||||
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? 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,151 +103,117 @@ 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");
|
||||
return { repo, contract, ask, issued, poll, answer, decide, toPerson, slackButton, slackThreadReply, replyRows };
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
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 () => {
|
||||
// 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 () => {
|
||||
const f = await fixture();
|
||||
const item = f.repo.getById(f.ask)!;
|
||||
expect(item.sourceSession).toBe("host-pair@kernel");
|
||||
expect(item.humanQuestions).toEqual([{ id: "pair", question: `Pair requester@far-host (code ${f.issued.code}) with this host?`, options: [
|
||||
{ id: "approve", label: "Approve: hand this host's bearer token to the requester" }, { id: "deny", label: "Deny" }] }]);
|
||||
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (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(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(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
});
|
||||
|
||||
// Steering's and the TUI's "reply <text>": the person's words close the request and are kept under its question.
|
||||
// Nothing on this host reads them, so they never deny: the person is asked again, and their choice there decides.
|
||||
it.each(["no", "yes", "who is this?"])("a typed reply %j (Steering or the TUI) never denies: words kept, the person is asked again, and Approve there pairs", async (words) => {
|
||||
const f = await fixture();
|
||||
expect((await f.answer({ decision: words })).status).toBe(200);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", closureReason: "no-follow-on",
|
||||
humanAnswers: { pair: { kind: "typed-reply", text: words } } });
|
||||
const [again, ...more] = f.toPerson();
|
||||
expect(more).toEqual([]);
|
||||
expect(again).toMatchObject({ state: "pending", sourceSession: "host-pair@kernel", humanQuestions: f.repo.getById(f.ask)!.humanQuestions });
|
||||
expect(again!.body).toContain(`You wrote "${words}" in reply to this request. Your words are kept, and they didn't deny anything`);
|
||||
// Polling again asks nothing more.
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.toPerson()).toHaveLength(1);
|
||||
expect((await f.answer({ questionId: "pair", optionId: "approve" }, again!.qitemId)).status).toBe(200);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
});
|
||||
|
||||
it.each(["yes", "no"])("a typed Slack thread reply %j never denies: words kept, the person is asked again", async (words) => {
|
||||
const f = await fixture();
|
||||
await f.slackThreadReply(words);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: { kind: "typed-reply", text: words } } });
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ state: "pending", sourceSession: "host-pair@kernel" })]);
|
||||
});
|
||||
|
||||
it("a plain terminal on this host decides with rig host approve, recorded as a direct human action, and the person is told", async () => {
|
||||
const f = await fixture();
|
||||
await f.answer({ decision: "yes please" });
|
||||
await f.poll();
|
||||
const again = f.toPerson()[0]!;
|
||||
expect(await f.decide(f.issued.code, "approve")).toEqual({ status: 200,
|
||||
body: { ok: true, decision: "approve", code: f.issued.code, requester: "requester@far-host", by: "human@host" } });
|
||||
// The open re-ask is closed with the result, and one update tells the person.
|
||||
expect(f.repo.getById(again.qitemId)).toMatchObject({ state: "done", closureReason: "no-follow-on", resolution: "pairing approved by human@host" });
|
||||
expect(f.toPerson().filter((item) => item.humanIntent === "update")).toEqual([expect.objectContaining({
|
||||
body: `Pairing request ${f.issued.code} from requester@far-host: approved by human@host. This host's token is handed over when the requesting machine next checks, if it's still waiting.` })]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Steering's question card", { questionId: "pair", optionId: "approve" }],
|
||||
["the TUI's Approve act (primary user)", { questionId: "pair", optionId: "approve", authority: "primary-user" }],
|
||||
] as const)("%s: Approve hands the token over once, then the pairing dies", async (_label, input) => {
|
||||
const f = await fixture();
|
||||
expect((await f.answer(input)).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: "approve" } });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
});
|
||||
|
||||
it("Slack's Approve button hands the token over once", async () => {
|
||||
const f = await fixture();
|
||||
await f.slackButton("approve");
|
||||
expect(f.repo.getById(f.ask)?.humanAnswers).toEqual({ pair: "approve" });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Steering's Deny choice", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "deny" })],
|
||||
["Slack's Deny button", (f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("deny")],
|
||||
] as const)("%s denies, with no token", async (_label, act) => {
|
||||
["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) => {
|
||||
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 });
|
||||
expect(f.repo.getById(f.ask)?.humanAnswers?.pair).toBeDefined();
|
||||
expect(f.replyRows()).toEqual([]);
|
||||
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(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.toPerson()).toHaveLength(1);
|
||||
});
|
||||
|
||||
// An answer is kept on the request, like a runner's permission request's: no reply row goes to host-pair@kernel, so it
|
||||
// works the same on a daemon without a kernel rig, and nothing is left for a seat that doesn't exist.
|
||||
it.each([true, false])("kernel rig registered: %s. An Approve choice (Steering, the TUI, Slack) closes the request in place and approves once", async (kernel) => {
|
||||
for (const [actor, act] of [
|
||||
[PERSON, (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "approve" })],
|
||||
// The terminal names no responder: the TUI's act is recorded unattributed.
|
||||
["human@host", (f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "approve", authority: "primary-user" })],
|
||||
[PERSON, (f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("approve")],
|
||||
] as const) {
|
||||
const f = await fixture({ kernel });
|
||||
const result = await act(f);
|
||||
// Steering and the TUI answer over HTTP; Slack's router returns its own disposition.
|
||||
if (typeof (result as { status?: unknown }).status === "number") expect((result as { status: number }).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", closureReason: "no-follow-on", humanAnswers: { pair: "approve" } });
|
||||
expect(f.repo.closingTransition(f.ask, "done")?.actorSession).toBe(actor);
|
||||
expect(f.replyRows()).toEqual([]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])("kernel rig registered: %s. A Deny choice (Steering, Slack) closes the request in place and denies once", async (kernel) => {
|
||||
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: "deny" }),
|
||||
(f: Awaited<ReturnType<typeof fixture>>) => f.answer({ questionId: "pair", optionId: "approve" }),
|
||||
(f: Awaited<ReturnType<typeof fixture>>) => f.slackButton("deny"),
|
||||
]) {
|
||||
const f = await fixture({ kernel });
|
||||
const result = await act(f);
|
||||
if (typeof (result as { status?: unknown }).status === "number") expect((result as { status: number }).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: "deny" } });
|
||||
expect(f.repo.closingTransition(f.ask, "done")?.actorSession).toBe(PERSON);
|
||||
expect(f.replyRows()).toEqual([]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
expect((await f.poll()).http).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])("kernel rig registered: %s. A typed reply (Steering, Slack) closes the request in place, words kept, never denied", async (kernel) => {
|
||||
for (const act of [
|
||||
(f: Awaited<ReturnType<typeof fixture>>) => f.answer({ decision: "yes" }),
|
||||
(f: Awaited<ReturnType<typeof fixture>>) => f.slackThreadReply("yes"),
|
||||
]) {
|
||||
const f = await fixture({ kernel });
|
||||
const result = await act(f);
|
||||
// Steering and the TUI answer over HTTP; Slack's router returns its own disposition.
|
||||
if (typeof (result as { status?: unknown }).status === "number") expect((result as { status: number }).status).toBe(200);
|
||||
expect(f.repo.getById(f.ask)).toMatchObject({ state: "done", humanAnswers: { pair: { kind: "typed-reply", text: "yes" } } });
|
||||
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("a click that decides is told to the person as the outcome, apart from their click", async () => {
|
||||
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();
|
||||
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({
|
||||
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 });
|
||||
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 () => {
|
||||
const f = await fixture();
|
||||
await f.slackButton("approve");
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ humanIntent: "update", summary: `Pairing ${f.issued.code} from requester@far-host: approved`,
|
||||
body: `Pairing request ${f.issued.code} from requester@far-host: approved by ${PERSON}. This host's token was handed to the requesting machine.` })]);
|
||||
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(await f.poll()).toEqual({ http: 200, body: { status: "approved", token: BEARER }, leaksToken: true });
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
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("the pairing settings show a decision the request's own row records (an agent's close), before the requester collects it", 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" })]);
|
||||
});
|
||||
|
||||
it("an agent seat approves only by naming the choice: rig queue update --state done --closure-reason no-follow-on --resolution approve", async () => {
|
||||
@@ -270,25 +236,20 @@ describe("host pairing with no kernel operator running: Approve and Deny decide,
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
|
||||
it("an explicit Deny wins over a later agent close that names Approve", async () => {
|
||||
it("a person's own Deny is never overridden: a later agent close naming Approve hands nothing over", async () => {
|
||||
const f = await fixture();
|
||||
// A recorded Deny whose return hasn't closed the request yet (its close failed), then an agent's approving close.
|
||||
f.repo.recordHumanAnswer({ qitemId: f.ask, actorSession: PERSON, questionId: "pair", optionId: "deny" });
|
||||
expect(f.repo.getById(f.ask)?.state).toBe("pending");
|
||||
await f.repo.update({ qitemId: f.ask, actorSession: AGENT, state: "done", closureReason: "no-follow-on", resolution: "approve" });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, 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 });
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,14 +282,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, 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>).`);
|
||||
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>).`);
|
||||
await f.slackButton("approve");
|
||||
expect(f.repo.list({ limit: 1000 }).filter((row) => row.replyTo === f.ask)).toEqual([expect.objectContaining({ destinationSession: OPERATOR, state: "pending" })]);
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
const [receipt, ...more] = f.toPerson();
|
||||
expect(more).toEqual([]);
|
||||
expect(receipt!.body).toContain(`Received your Approve choice on pairing request ${f.issued.code} from requester@far-host. ${OPERATOR} isn't running, so it may not have read it yet. Nothing is granted or denied until it acts, or the request expires at `);
|
||||
expect(receipt!.body).toContain(`To decide now, in a terminal on this host with its operator bearer token: rig host approve ${f.issued.code}, or rig host deny ${f.issued.code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
|
||||
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>).`);
|
||||
// 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 });
|
||||
@@ -391,10 +352,11 @@ 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 Deny click the requester hasn't polled yet stands: rig host approve is refused, and the poll denies", async () => {
|
||||
it("a kept Deny click decides nothing by itself; the direct decision after it is the one decision, and a second is refused", async () => {
|
||||
const f = await fixture();
|
||||
await f.slackButton("deny");
|
||||
expect(await f.decide(f.issued.code, "approve")).toMatchObject({ status: 409, body: { error: "pair_request_decided", decision: "deny", by: PERSON } });
|
||||
expect((await f.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.poll()).toEqual({ http: 200, body: { status: "denied" }, leaksToken: false });
|
||||
});
|
||||
|
||||
@@ -415,12 +377,11 @@ 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 on an agent-free host is never denied: the person is asked again", async () => {
|
||||
it("an attachment-only Slack reply with no receiving agent is never denied: it's kept, and the person gets one receipt", async () => {
|
||||
const f = await fixture();
|
||||
await f.slackThreadReply("");
|
||||
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ state: "pending", sourceSession: "host-pair@kernel",
|
||||
body: expect.stringContaining("Your reply to this request had no words to keep here, and it didn't deny anything") })]);
|
||||
expect(f.toPerson()).toEqual([expect.objectContaining({ humanIntent: "update", sourceSession: "host-pair@kernel", body: kept(f.issued.code, "your reply") })]);
|
||||
});
|
||||
|
||||
// The queue keeps no words for the literal text "[file reply]" (the text a Slack file reply carries), so these closes
|
||||
@@ -428,12 +389,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: asked again with no operator, a receipt with one", async (_label, act) => {
|
||||
] 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) => {
|
||||
const free = await fixture();
|
||||
await act(free);
|
||||
expect(free.repo.getById(free.ask)?.humanAnswers?.pair).toBeUndefined();
|
||||
expect(await free.poll()).toEqual({ http: 200, body: { status: "pending", code: free.issued.code }, leaksToken: false });
|
||||
expect(free.toPerson()).toEqual([expect.objectContaining({ state: "pending", body: expect.stringContaining("Your reply to this request had no words to keep here") })]);
|
||||
expect(free.toPerson()).toEqual([expect.objectContaining({ humanIntent: "update", body: kept(free.issued.code, "your reply") })]);
|
||||
|
||||
const owned = await fixture({ operator: "running" });
|
||||
await act(owned);
|
||||
@@ -462,7 +423,8 @@ 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();
|
||||
await f.slackButton("approve");
|
||||
// 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" });
|
||||
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);
|
||||
@@ -485,7 +447,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(`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.`);
|
||||
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`);
|
||||
});
|
||||
|
||||
it("a receiving agent chosen in agents.operator_session owns the request, with no kernel operator here", async () => {
|
||||
@@ -504,19 +466,24 @@ 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(`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).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).not.toContain("no receiving agent is set");
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
it("when OpenRig can't read who owns the request, it's agent-free and says it couldn't tell", async () => {
|
||||
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 () => {
|
||||
const f = await fixture({ operator: "running", ownershipUnreadable: true });
|
||||
const item = f.repo.getById(f.ask)!;
|
||||
expect(item.sourceSession).toBe("host-pair@kernel");
|
||||
expect(item.body).toContain("Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: OpenRig couldn't tell whether an agent reads answers on this host.");
|
||||
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");
|
||||
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();
|
||||
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");
|
||||
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.`) })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,3 +507,37 @@ 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 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.
|
||||
// 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.
|
||||
expect(item.sourceSession).toBe("host-pair@kernel");
|
||||
expect(item.body).toContain("Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (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>).`);
|
||||
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>).`);
|
||||
});
|
||||
|
||||
it("a request stays open as long as its requester waits, and its approval says how long and until when", async () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
it("an earlier record that can't be read makes the history unknown, never 'no record' or an older time", () => {
|
||||
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.skipIf(process.getuid?.() === 0)("a state folder that can't be read reads unknown, not 'no record' (root bypasses mode 000)", () => {
|
||||
it("a state folder that can't be read reads unknown, not 'no record'", () => {
|
||||
withHome((home) => {
|
||||
prompt(home, conversation);
|
||||
const dir = join(home, "refocus");
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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,27 +2741,6 @@ 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,6 +100,8 @@ 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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -232,6 +233,8 @@ 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" });
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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.`;
|
||||
}
|
||||
@@ -428,6 +428,15 @@ 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;
|
||||
|
||||
@@ -26,12 +26,18 @@ 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: text.replace(/\t/g, " ").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`),
|
||||
...line, text: escapeControlChars(text),
|
||||
...(i ? { action: undefined, zones: undefined, segs: undefined } : {}),
|
||||
})));
|
||||
return logical.flatMap((line) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -485,17 +486,19 @@ 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] = await Promise.all([
|
||||
const [config, controlPlane, connections, pairRequests] = 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, daemonTarget, hydratedAt: new Date().toISOString(), readErrors };
|
||||
return { ...emptySnapshot(), config, configError, controlPlane, connections, pairRequests, daemonTarget, hydratedAt: new Date().toISOString(), readErrors };
|
||||
}
|
||||
|
||||
const readingOnly = viewContext?.section === "specs";
|
||||
|
||||
@@ -34,6 +34,7 @@ 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";
|
||||
@@ -463,6 +464,8 @@ 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);
|
||||
|
||||
@@ -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, filter: "", healthOpen: null }), snap);
|
||||
return syncSelection(resetContent({ ...next, section: "config", drill: [], viewTab: "table", configCategory: action.category, configKey: null, configFocus: action.focus ?? null, filter: "", healthOpen: null }), snap);
|
||||
}
|
||||
case "config-setting":
|
||||
return resetContent({ ...next, section: "config", drill: [], viewTab: "table", configKey: action.key, healthOpen: null });
|
||||
@@ -201,6 +201,7 @@ 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(
|
||||
@@ -367,8 +368,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 } = 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 };
|
||||
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 };
|
||||
}
|
||||
|
||||
function applyDrill(state: ViewState, next: ViewState, action: Extract<Action, { type: "drill" }>, snap: FleetSnapshot): ViewState {
|
||||
|
||||
@@ -413,6 +413,8 @@ 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. */
|
||||
@@ -533,7 +535,8 @@ export type Action =
|
||||
| { type: "timezone" }
|
||||
| { type: "recent-open"; transitionId: number }
|
||||
| { type: "back" }
|
||||
| { type: "config-category"; category: string }
|
||||
/** `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-setting"; key: string }
|
||||
| { type: "noop" }
|
||||
| { type: "error"; message: string }
|
||||
@@ -581,6 +584,8 @@ 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 };
|
||||
@@ -612,6 +617,8 @@ 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;
|
||||
@@ -655,7 +662,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">;
|
||||
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 interface ViewStateStore {
|
||||
instanceId: string;
|
||||
|
||||
@@ -118,10 +118,20 @@ 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 decides the agent-free request", async () => {
|
||||
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 () => {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -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"]));
|
||||
expect(new Set(trace)).toEqual(new Set(["GET /api/config?view=browser", "GET /healthz", "GET /api/gateway/connections", "GET /api/hosts/pair-requests"]));
|
||||
expect(sources.map((p) => readFileSync(p, "utf8"))).toEqual(before);
|
||||
expect(external).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(snap)).not.toContain(secret);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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]/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user