Compare commits

...
Author SHA1 Message Date
dev-driver 0107d6e788 test(cli): expect a note's transition read before update, and give a stub its sender
An update that carries a note first reads the row's transitions, so its
read-back can prove the note is new. Two not-found tests pinned the update as
the first request. They now expect that read-only request before it, and the
not-found output they check is unchanged.

The pane delivery test's stub client had no senderIdentity, which the update's
read-back now calls to check the recorded actor. It reports the session the stub
already sends as its sender header.
2026-11-13 23:52:28 +00:00
dev-driver 5a52230e95 fix(cli): keep the full sent actor unless the daemon proves the suffix is its own, and pin create's timeout recovery ID
- A queue update's note counts under the bare seat@rig form of a
  host-qualified sender only when the selected daemon's own /healthz id is
  that host, since a daemon drops only its own id. The absence of --host no
  longer stands for locality: a direct endpoint on another host keeps the full
  actor, so its own same-named seat's note isn't this write's. Where the id
  can't be read, only the full actor counts; the write is still sent.
- A test pins that rig queue create's final timeout error itself carries the
  same-ID retry instruction, as the connection and response errors do.
2026-11-13 22:13:41 +00:00
dev-driver 50f58dfaca fix(cli): give each run its own exit status, tie an update's note to its write, and confirm a pane's seat is bound
- runProgram starts each run with a clean exit status and returns an action's
  failure instead of calling process.exit(), which can drop output still being
  written to a pipe. The process ends with process.exitCode as before.
- A queue update's note counts only from a newer transition recorded under the
  actor the write was sent as, carrying the note as written or with only what
  the daemon adds for that request: the reopen prefix, the summary suffix, or a
  revised deadline's receipt. Another writer's note, or one that only starts the
  same way, reads as not verified.
- A verified update prints the canonical row with the write's own advisories,
  so a park with no wake still says so.
- A managed pane's @rigged_session_name is the seat only while this home's
  database still binds its node to that session. Unclaim deletes the binding
  even when clearing the pane's metadata fails, so a released session reads
  unknown, as an unresolved pane always has. The seat environment still wins.
- The pane seat is read once per run and shared by the preflight and every
  request's header, instead of a tmux call per client.
2026-11-13 22:00:24 +00:00
dev-driver f6ba977092 fix(cli): send a note-bearing queue update even when its cursor read fails, and bound the tmux seat lookup
Two corrections to the port before it lands:

- A note-bearing update read the row's transition cursor before writing. When
  that read failed or came back malformed, it refused to send the update
  (queue_update_preflight_failed), though the update itself was fine and the
  daemon would have taken it. That was a new refusal: before this port the
  update was simply sent. Now the update is always sent. Without a cursor, the
  read-back can't prove that the note's transition is new. It says so with
  queue_update_readback_failed and exits non-zero, and it never accepts an
  older transition carrying the same note. The post-write checks, mismatch and
  unknown diagnostics, and exact-ID reconciliation are unchanged, with no retry.
- The tmux lookup of a managed pane's seat had no time bound, so a busy or
  wedged tmux server could hold every queue request. It now gives up after one
  second and leaves the seat unresolved, which is the existing honest fallback.

The send suite stubs TMUX_PANE like the queue suite, so its external-caller
cases stay external when the runner itself is in tmux. The openrig-user skill
and its digest say the same.
2026-11-13 20:03:38 +00:00
dev-driver 12ca636227 fix(cli): resolve a recovered terminal's seat for queue requests, and report what a queue write persisted
Ported unchanged onto current main from the patch as it was written and first
reviewed (reviewed base c6b89b1a; it applied cleanly 35 commits later). Credit
for the change and its first review stays with its original authors.

- A managed terminal whose seat environment is gone resolves its seat from the
  current tmux pane for queue requests' sender and headers. The seat
  environment still takes precedence, and an unresolved pane stays unknown.
- A queue update reads the row back from the daemon and reports success only
  when the requested state, closure, deadline and note were persisted. The
  daemon's appended closing resolution and equivalent deadline instants are
  accepted; anything else is reported with queue_update_readback_failed and a
  nonzero exit, with no retry.
- Domain-composed tests exercise real QueueRepository/SQLite behaviour,
  including dropped resolution, dropped note and wrong deadline controls. The
  queue suite stubs TMUX_PANE so its external-caller cases stay external.
- The openrig-user skill and its digest describe the read-back.
2026-11-13 19:43:32 +00:00
Wu Shuwen 3b32f76d12 fix(daemon): warn when a saved Codex profile was cleared (#3155)
When a snapshot records a named Codex profile but the current Codex seat has cleared that selection, restore-check warns that restore uses the current selection, and a full restore carries the same warning before it validates. Nothing is refused and neither the selected profile nor the launch policy changes. The snapshot shows a recorded selection, not what an earlier process enforced, so a profile chosen by hand and absent from both the snapshot and OpenRig's current records can't be named; missing current data, a current named profile and a non-Codex seat give no warning.
2026-11-13 11:11:45 -08:00
Wu Shuwen 8476bf03d5 docs(watch): correct undelivered wake retry behavior (#3072)
The watch guide said a failed delivery uses up a one-shot park timer. A wake known not to have reached the seat stays owed and is retried until delivery or the park ends; a wake whose input may have reached the seat records an unconfirmed wake and ends without automatic replay. The retry section and the shared-timer clause now say the same. Documentation only.
2026-11-13 11:11:03 -08:00
Wu Shuwen 2c24b49e4f docs: clarify daemon status discovery fallback (#3169)
The daemon client reference's status discovery order stopped at the state file. When no daemon state is read, both rig status and rig daemon status probe the configured daemon host and port (127.0.0.1:7433 by default); the reference now lists that step. Documentation only.
2026-11-13 11:10:19 -08:00
Wu Shuwen 921e09cce3 docs(reference): describe skipped installer check (#3159)
The setup reference said the installer's Node and SQLite check always runs. When the installed checker is missing, the installer reports the check as skipped and continues; the reference now says so. Documentation only.
2026-11-13 11:09:34 -08:00
Wu Shuwen 675af15159 docs(reference): correct first boot provider selection (#3158)
The setup reference said first kernel boot always used both authenticated providers. It now says first boot considers the saved provider selection, notes the daemon environment override, and keeps the project's own choice separate. Documentation only.
2026-11-13 11:08:41 -08:00
Wu Shuwen e6e0702045 fix(daemon): share snapshot Claude process observations (#3154)
Periodic and manual snapshot refreshes check Claude sidecar freshness for several seats without walking the process table twice per seat: the refresher now takes the existing per-pass process observation, shared only within one refresh. Each seat still gets two ordered observations, a later process doesn't restamp an older sidecar, the next refresh lists afresh, and the existing bounded freshness and failure fallback are unchanged. It addresses a separate recurring source of process listings from the readiness poll; the reduction is source-derived, not a measured share of the reported listing volume, and it isn't the cross-caller cache the parent issue asks for.
2026-11-13 11:05:58 -08:00
Wu Shuwen ac921ac5d1 feat(slack): control previews on human row posts (#3157)
Adds a connector-wide setting for previews on Slack posts to a person: rig slack setup --unfurl on|off, validated when written, sent as unfurl_links and unfurl_media on every human-row post, including each part of a multi-part post. Previews are off unless the setting is on; Slack previews links by default, so an existing install stops showing link and media previews on asks after its next connector rewire or daemon restart, until someone runs rig slack setup --unfurl on. It doesn't remove earlier previews. Message text, links, authored blocks and attached images are unchanged, and readiness shows whether human-row previews are on or off.
2026-11-13 08:33:22 -08:00
Edward Bowie bfd3a7f596 fix(daemon): renew OpenRig's own Codex hook declarations from its record (#17) (#3143)
When a new openrig-core bundle changes one of its own six Codex hook declarations (a command, timeout or matcher), the first launch after the upgrade renews those approvals the way OpenRig already renews a content change, instead of stopping at Codex's hook review. It renews only when the installed plugin matches the shipped bytes and every current approval, declaration and content, equals OpenRig's saved record; a home that still relies on the older fallback with no record, or an approval without an explicit content hash, keeps the review once. A person's choices keep the review: a different or disabled approval, a choice in another readable layer or a profile table, a nested plugin entry, or a change made while the renewal runs, since the user config and every layer the plan read are re-read before writing. The approval is recorded with the existing writer, and other plugins are untouched. Profile files passed with -p aren't read, the existing cross-process timing limits are unchanged, and no real Codex seat ran in the tests, so they show what OpenRig writes, not Codex's own review screen.
2026-11-13 08:23:12 -08:00
Wu Shuwen 240598c11d Accept CRLF skill frontmatter during discovery (#3153)
A SKILL.md with CRLF line endings was skipped during discovery: the frontmatter capture kept a trailing carriage return, so valid YAML failed to parse and the skill never reached a seat's managed plugin loadout. The parser now drops that delimiter-adjacent carriage return before parsing; the skill file's bytes aren't rewritten. The regression cases exercise the real discovery and projection functions with a shipped skill in a temporary plugin, not a running agent session.
2026-11-13 07:37:49 -08:00
Mike Schwarz aefbf29938 docs(delivery): say which suite cases are registered and which are planned (#3145)
The delivery suite's README said it covered 25 cases. It now says only the cases cases/index.mjs registers are runnable today, on both programs, and lists the cases whose expected results are planned but not yet fixtures. The full case list is kept outside this repository.
2026-11-13 07:37:22 -08:00
46 changed files with 1367 additions and 101 deletions

No files matched your search

+2 -1
View File
@@ -20,7 +20,8 @@ The CLI itself finds the daemon in these orders:
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
the state file below.
the state file below. If no state is read, probe the configured `daemon.host` and `daemon.port` instead (defaulting
to `127.0.0.1:7433`).
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
`~/.rigged/daemon.json`.
+8 -7
View File
@@ -208,8 +208,8 @@ curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/i
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check if the installed checker is
present (otherwise it reports the check skipped), then runs `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
`FAILED [n/4] <command or check> (exit <code>)` for steps 1–3. If step 4 is not ready, it reports
`INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any Fix, then rerun
@@ -308,11 +308,12 @@ team. `rig daemon start --no-kernel` is for automation or an explicit request to
omit the kernel; it leaves you without that operator.
On a fresh instance, explicit daemon startup chooses the kernel variant from
successful native auth probes: Claude alone, Codex alone, or both. It starts the
kernel in the background without a project starter. An absent unused provider is
fine; if both accounts are authenticated, the kernel uses both even when your
later project uses just one. A request to use only one provider for everything
is a separate choice.
the providers selected by `rig setup --providers` that pass native auth probes:
Claude alone, Codex alone, or both. If no choice was recorded, it considers every
signed-in provider. `OPENRIG_PROVIDER_SELECTED` in the daemon's environment
overrides the saved choice. It starts the kernel in the background without a
project starter. An absent unused provider is fine. The kernel choice and a
later project's provider choice are separate.
`/api/kernel/status` retains the chosen template (`rig.yaml`,
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
+14 -9
View File
@@ -124,10 +124,12 @@ manual replay. A recurring reminder keeps its next normal cycle under a new occu
finishing its accounting does not type the same occurrence twice.
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
correct the destination and register a replacement reminder. A one-shot park timer records its
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
there while the timer stays armed. Deliberately held
reminder keeps its next normal cycle, and a permanent transport failure leaves it a terminal job:
correct the destination and register a replacement reminder. A one-shot park timer (`--wake-after`)
has no later cycle, so neither the retry limit nor a permanent transport failure ends it while its
task is still set aside: each failure before input is recorded on the parked row while the timer
stays armed, and it retries until it's delivered or the park ends. If input may have reached the
seat, it records the unconfirmed wake on the parked row and ends. Deliberately held
input keeps its existing retained-message custody. Condition watches and parked-owner policy
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
@@ -289,11 +291,14 @@ Unknown: not said yet.
stopped (the first fire comes one interval after you register it), and it stops when the agent that registered it
is replaced. It's fine for agent-to-agent reminders. Your own watch timers also show as `periodic-reminder` in
`rig watchdog list`: that's the one-shot timer `--wake-after` arms, not a registered repeating reminder. It's retired
when it fires or when the task stops being set aside. A wake held back because you were typing (a retained
delivery) wasn't delivered, so it doesn't use the timer up: it's tried again at each interval until an attempt goes
through (delivered or failed), which retires it. A timer another task shares through `--wake-watchdog` stays live
for that task when this task is handed off or stopped, and the same rule applies to its wake. None of this is a
delivery guarantee.
after delivery, when the occurrence ends, or when the task stops being set aside. A wake held back because you were typing (a retained
delivery) wasn't delivered, so it doesn't use the timer up. A failure known to have happened before input reached
the seat also keeps the same wake owed and retries on the existing cadence until delivery or the park ends. If input
may have reached the seat, the occurrence ends without automatic replay; inspect the receipt before retrying.
A timer another task shares through `--wake-watchdog` stays live for that task when this task is
handed off or stopped, and a held-back wake doesn't use it up. Once this task's park has ended, though,
a failure before input to that task is retried only up to `queue.wake_retry_cap` times (a permanent
transport failure ends it at once), and then the timer ends. None of this is a delivery guarantee.
- **Transcript bytes as "usage".** `context-usage-threshold` measures an agent's transcript, not an account's
allowance. For an allowance, see "A usage warning" above.
+4
View File
@@ -132,6 +132,10 @@ reaction on any other message, including a person's reply in the thread, is igno
`reaction_added` event: add both under **OAuth & Permissions** and **Event Subscriptions**, then
reinstall the app to the workspace.
Human-row posts suppress link and media previews by default. Run `rig slack setup --unfurl on` to
allow Slack previews, or `rig slack setup --unfurl off` to suppress them again. The selection reaches
the running connector on its next rewire or daemon restart; it does not remove already posted previews.
To have reactions ignored instead, run `rig slack setup --reactions off`. The agent that asked
never hears of a reaction, including one still waiting to be retried, and a person answers an ask by
typing a reply in its thread. Slack still sends the event and the manifest keeps it, so switching
+7 -1
View File
@@ -14,6 +14,7 @@ import type { Command } from "commander";
import { InvalidArgumentError } from "commander";
import { DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "./client.js";
import { blockedConnectionGuidance, isBlockedConnectionCode } from "./daemon-reachability.js";
import { resetSenderSession } from "./sender-identity.js";
/**
* Commander option parser — a positive integer (>= 1). Rejects negative, zero,
@@ -175,9 +176,14 @@ export async function runProgram(program: Command, argv: string[], io: RunProgra
};
suppressErr(program);
// Each run starts clean: an earlier in-process run's failure isn't this run's status, and its sender isn't this run's.
process.exitCode = undefined;
resetSenderSession();
try {
await program.parseAsync(argv);
return 0;
// An action's own failure sets process.exitCode. It's returned, not passed to process.exit(), which can drop output
// still being written to a pipe; the process ends with process.exitCode.
return typeof process.exitCode === "number" ? process.exitCode : 0;
} catch (e) {
if (isCleanCommanderExit(e)) return 0;
// Response-integrity: render daemon transport failures honestly (3-part, both
+5 -2
View File
@@ -6,6 +6,7 @@ import { ConfigStore } from "./config-store.js";
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
import { resolveSenderSession, type BindingCheck, type TmuxIdentityReader } from "./sender-identity.js";
export function terminalAuthHeaders(): Record<string, string> {
const token = resolveTerminalToken();
@@ -35,8 +36,10 @@ export const OCCUPANT_GENERATION_HEADER = "X-OpenRig-Occupant-Generation";
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand. A client built for one
* authenticated caller (`sessionName`) carries that caller's seat instead of the process env.
*/
export function senderIdentityHeaders(originSelfHostId?: string, sessionName?: string): Record<string, string> {
const session = (sessionName ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"))?.trim();
export function senderIdentityHeaders(
originSelfHostId?: string, sessionName?: string, tmuxReader?: TmuxIdentityReader, isBound?: BindingCheck,
): Record<string, string> {
const session = (sessionName ?? resolveSenderSession(tmuxReader, isBound))?.trim();
if (!session) return {};
// Preserve qualified senders; append only a locally derived instance id.
const value =
+197 -13
View File
@@ -17,6 +17,7 @@ import { omittedReadField, readView } from "../read-view.js";
import { buildWriteReceipt, compactOutputEnabled, type ReceiptContext } from "../queue-receipt.js";
import { TRANSITIONS_SHOWN, compactShow, compactTransitions, showEmptyNotice, transitionsNotice } from "../queue-read-compact.js";
import { codeResultOf, planItemArtifact, type FileRead, type SharedRoot } from "../queue-artifact.js";
import { resolveSenderSession } from "../sender-identity.js";
/**
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
@@ -694,6 +695,167 @@ async function printQueueItemResult(
await printWriteResult(client, json, body, status, receipt && remoteHost ? { ...receipt, remoteHost } : receipt, remoteHost !== undefined);
}
const UPDATE_READ_BACK_FIELDS = [
"state", "closureReason", "closureTarget", "closureRequiredAt", "resolution", "blockedOn", "summary", "evidenceRef",
] as const;
interface QueueTransitionReadBack {
transitionId: number;
transitionNote: string | null;
actorSession: string;
}
function queueUpdateFieldMatches(field: (typeof UPDATE_READ_BACK_FIELDS)[number], requested: unknown, actual: unknown): boolean {
if (field === "closureRequiredAt" && typeof requested === "string" && typeof actual === "string") {
const requestedTime = Date.parse(requested);
const actualTime = Date.parse(actual);
return Number.isFinite(requestedTime) && Number.isFinite(actualTime) && requestedTime === actualTime;
}
if (field === "resolution" && typeof requested === "string" && typeof actual === "string") {
return actual === requested || actual.endsWith(`\n${requested}`);
}
return Object.is(actual, requested);
}
/** Whether a recorded note is this update's note as the daemon records it (QueueRepository.update): as written, or with
* only what the daemon adds for this request, the reopen prefix, the summary suffix, or a revised deadline's receipt
* naming the new time and the accepting actor. Another writer's note that merely starts the same way isn't. */
function noteRecordedAs(actual: string, note: string, requested: Record<string, unknown>, actor: string, deadline: unknown): boolean {
const bases = [note];
if (requested.reopen === true) bases.push(`reopen acknowledged: ${note}`);
if (typeof requested.summary === "string" && requested.summary.trim() && !note.includes("(summary updated)")) {
bases.push(`${note} (summary updated)`);
}
if (requested.closureRequiredAt === undefined) return bases.includes(actual);
// The receipt's earlier time isn't known here: it's one line between the fixed parts.
const tail = ` -> ${String(deadline)}; accepted by ${actor}`;
return bases.some((base) => {
const head = `${base}\nclosure deadline re-estimated: `;
if (!actual.startsWith(head) || !actual.endsWith(tail) || actual.length <= head.length + tail.length) return false;
return !actual.slice(head.length, actual.length - tail.length).includes("\n");
});
}
/** Who the daemon records as this write's actor: the sender header as sent, else the actor the body declares. Empty when
* neither is known. A daemon drops a sender's host suffix only when it's that daemon's own id, so the bare form is
* this write's only where the selected daemon answers with that id; nothing else (an absent --host included) shows it. */
async function updateActors(client: Pick<DaemonClient, "senderIdentity" | "get">, bodyActor: unknown): Promise<string[]> {
const { session } = await client.senderIdentity();
if (session) {
const parts = session.split("@");
if (parts.length !== 3) return [session];
let daemonId: unknown;
try {
daemonId = (await client.get<{ selfHostId?: unknown }>("/healthz", { timeoutMs: 1_000 })).data?.selfHostId;
} catch { /* Unanswered: the suffix stays unproven, so only the full actor counts. */ }
return daemonId === parts[2] ? [session, `${parts[0]}@${parts[1]}`] : [session];
}
return typeof bodyActor === "string" && bodyActor.trim() ? [bodyActor.trim()] : [];
}
async function readQueueTransitionCursor(
client: Pick<DaemonClient, "get">,
qitemId: string,
options: { headers?: Record<string, string>; timeoutMs?: number },
): Promise<{ ok: true; latestTransitionId: number } | { ok: false; detail: string }> {
let response: DaemonResponse<unknown>;
try {
response = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`, options);
} catch (error) {
return { ok: false, detail: `canonical transition preflight did not answer: ${(error as Error).message}` };
}
if (response.status !== 200 || !Array.isArray(response.data)) {
return { ok: false, detail: `canonical transition preflight returned HTTP ${response.status} without a transition list` };
}
const transitionIds = response.data.flatMap((entry) => {
if (!entry || typeof entry !== "object") return [];
const transitionId = (entry as { transitionId?: unknown }).transitionId;
return typeof transitionId === "number" && Number.isSafeInteger(transitionId) ? [transitionId] : [];
});
if (transitionIds.length !== response.data.length) {
return { ok: false, detail: "canonical transition preflight returned a malformed transition list" };
}
return { ok: true, latestTransitionId: Math.max(0, ...transitionIds) };
}
async function readBackQueueUpdate(
client: Pick<DaemonClient, "get">,
qitemId: string,
requested: Record<string, unknown>,
options: { headers?: Record<string, string>; timeoutMs?: number },
transitionFloor?: number,
/** Why the transition cursor wasn't taken before the write, when it wasn't. */
floorUnknown?: string,
/** Who the daemon records as this write's actor (updateActors). */
actors: string[] = [],
): Promise<{ ok: true; row: Record<string, unknown> } | { ok: false; detail: string }> {
let response: DaemonResponse<Record<string, unknown>>;
try {
response = await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
} catch (error) {
return { ok: false, detail: `canonical read-back did not answer: ${(error as Error).message}` };
}
if (response.status !== 200 || !response.data || response.data.qitemId !== qitemId) {
return { ok: false, detail: `canonical read-back returned HTTP ${response.status} without row ${qitemId}` };
}
const mismatches = UPDATE_READ_BACK_FIELDS.flatMap((field) => {
if (requested[field] === undefined) return [];
return queueUpdateFieldMatches(field, requested[field], response.data[field])
? []
: [`${field}: requested ${JSON.stringify(requested[field])}, read ${JSON.stringify(response.data[field])}`];
});
if (mismatches.length > 0) {
return { ok: false, detail: `canonical read-back mismatch (${mismatches.join("; ")})` };
}
const requestedNote = typeof requested.transitionNote === "string" && requested.transitionNote.trim().length > 0
? requested.transitionNote
: null;
if (requestedNote !== null) {
// Without the cursor, a transition carrying the note can't be shown to be this write's: never accept an older one.
if (transitionFloor === undefined) {
return { ok: false, detail: `the requested note can't be shown to be new, because the transition read before the write failed (${floorUnknown ?? "no cursor"})` };
}
if (actors.length === 0) {
return { ok: false, detail: "the requested note can't be tied to this write, because the actor it was sent as isn't known" };
}
let transitions: DaemonResponse<unknown>;
try {
transitions = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`, options);
} catch (error) {
return { ok: false, detail: `canonical transition read-back did not answer: ${(error as Error).message}` };
}
if (transitions.status !== 200 || !Array.isArray(transitions.data)) {
return { ok: false, detail: `canonical transition read-back returned HTTP ${transitions.status} without a transition list` };
}
const matching = transitions.data.some((entry) => {
if (!entry || typeof entry !== "object") return false;
const transition = entry as Partial<QueueTransitionReadBack>;
return typeof transition.transitionId === "number"
&& transition.transitionId > transitionFloor
&& typeof transition.transitionNote === "string"
&& typeof transition.actorSession === "string"
&& actors.includes(transition.actorSession)
&& noteRecordedAs(transition.transitionNote, requestedNote, requested, transition.actorSession, response.data.closureRequiredAt);
});
if (!matching) {
return {
ok: false,
detail: `no newer canonical transition carried requested note ${JSON.stringify(requestedNote)} from ${actors.map((actor) => JSON.stringify(actor)).join(" or ")}`,
};
}
}
return { ok: true, row: response.data };
}
function emitQueueUpdateReadBackFailure(qitemId: string, detail: string, json: boolean): void {
const message = `Queue update response was not accepted as success: ${detail}. The write may have committed, but its requested state was not verified.`;
const action = `Run rig queue show ${shellQuote(qitemId)} --full --json against the same daemon before retrying.`;
if (json) console.error(JSON.stringify({ error: "queue_update_readback_failed", message, action }));
else console.error(`${message}\n${action}`);
process.exitCode = 1;
}
// OPR.0.4.3.03 — `rig queue show` body preview.
//
// Default `show` renders a BOUNDED body preview instead of dumping the whole
@@ -874,21 +1036,21 @@ function emitBodyResolveError(err: Error & { fact?: string; consequence?: string
}
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
const session = explicit ?? resolveSenderSession();
if (session) return session;
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set`);
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set and reconciled tmux pane metadata does not resolve a session`);
process.exitCode = 1;
return undefined;
}
/** For a verb whose actor is the seat's own identity (the X-OpenRig-Session header the CLI sets from OPENRIG_SESSION_NAME),
/** For a verb whose actor is the seat's own identity (the X-OpenRig-Session header the CLI sets from managed identity),
* where the named flag is deprecated and ignored: the error names what actually sets the actor, never the flag. */
function requireSeatSession(ignoredFlag: string): string | undefined {
const session = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
const session = resolveSenderSession();
if (session) return session;
console.error(`OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set ` +
console.error(`OPENRIG_SESSION_NAME is not set, so there's no seat to act as, and reconciled tmux pane metadata does not resolve one. Run this from a managed seat, or set ` +
`OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --${ignoredFlag} is ignored: the ` +
"actor is the seat's own identity.");
process.exitCode = 1;
@@ -912,8 +1074,8 @@ const UPDATE_STATE_WRITE_FLAGS = [
*/
function resolveUpdateActor(source: string | undefined, opts: Record<string, unknown>): { actorSession?: string } | false {
const refuse = (message: string): false => { console.error(message); process.exitCode = 1; return false; };
// Trimmed like the DaemonClient's header (senderIdentityHeaders): a blank env is no seat, so the two never disagree.
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim() || undefined;
// The same resolver the DaemonClient header uses, so the preflight and transport can never disagree.
const seat = resolveSenderSession();
if (source !== undefined) {
const trimmed = source.trim();
if (parseSessionName(trimmed).kind !== "external") {
@@ -933,10 +1095,10 @@ function resolveUpdateActor(source: string | undefined, opts: Record<string, unk
if (seat) return {};
const noteOnly = opts.note !== undefined && UPDATE_STATE_WRITE_FLAGS.every(([key]) => opts[key] === undefined);
return refuse(noteOnly
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set, so " +
"there's no seat to act as: run this from a managed seat, or add --source <name>@external to record the note as " +
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set and reconciled " +
"tmux pane metadata does not resolve a seat: run this from a managed seat, or add --source <name>@external to record the note as " +
"that external routine (claimed, never a seat). --actor is ignored: the actor is the seat's own identity."
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set " +
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as, and reconciled tmux pane metadata does not resolve one. Run this from a managed seat, or set " +
"OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --actor is ignored: the actor is " +
"the seat's own identity. Changing a row needs a seat, and notes need a seat; an external routine can pass " +
"--source <name>@external for a note only. A person answering an ask addressed to them (a host pairing approval, " +
@@ -1166,7 +1328,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
}
// A managed seat keeps transport-derived identity; an external caller can
// name the existing claimed:v1 body actor without forging a transport header.
const managedSource = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
const managedSource = resolveSenderSession();
const source = resolveCurrentSession(managedSource ?? opts.source, "source");
if (!source) return;
const deps = getDeps();
@@ -1468,7 +1630,7 @@ For a new cross-host transfer, prefer rig queue handoff --host <registered-host>
process.exitCode = 1;
return;
}
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
const requested = {
state: opts.state,
reopen: opts.reopen,
closureReason: opts.closureReason,
@@ -1482,7 +1644,29 @@ For a new cross-host transfer, prefer rig queue handoff --host <registered-host>
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
...actor,
}, target.options);
};
let transitionFloor: number | undefined;
let floorUnknown: string | undefined;
if (typeof requested.transitionNote === "string" && requested.transitionNote.trim().length > 0) {
// The cursor proves the note is new afterwards. Without it the write still goes; the receipt says what's unproven.
const cursor = await readQueueTransitionCursor(target.client, qitemId, target.options);
if (cursor.ok) transitionFloor = cursor.latestTransitionId;
else floorUnknown = cursor.detail;
}
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, requested, target.options);
if (res.status < 400) {
const actors = await updateActors(target.client, requested.actorSession);
const verified = await readBackQueueUpdate(target.client, qitemId, requested, target.options, transitionFloor, floorUnknown, actors);
if (!verified.ok) {
emitQueueUpdateReadBackFailure(qitemId, verified.detail, opts.json ?? false);
return;
}
// The canonical row's fields win; the write's own advisories (a park with no wake) come only with its response.
const advisories = (res.data as { advisories?: unknown } | null)?.advisories;
const row = Array.isArray(advisories) ? { ...verified.row, advisories } : verified.row;
await printQueueItemResult(target.client, qitemId, opts.json ?? false, row, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
return;
}
await printQueueItemResult(target.client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
});
+2
View File
@@ -188,6 +188,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
.option("--secrets-env-file <path>", "path to the 0600 env file with SLACK_BOT_TOKEN / SLACK_APP_TOKEN")
.option("--required-scopes <csv>", "comma-separated bot scopes to require at verify time")
.option("--reactions <mode>", "what a person's emoji reaction on an OpenRig post does: route (default) makes a row for the seat that asked; off ignores reactions, so an acknowledging emoji costs no agent turn")
.option("--unfurl <mode>", "link and media previews on human-row posts: off (default) or on; takes effect when the connector rewires")
.option("--reason <reason>", "Reason recorded with the configuration change", "configure human delivery")
.option("--actor <actor>", "Named operator when outside a managed seat")
.action(async (opts) => {
@@ -203,6 +204,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
secretsEnvFile: opts.secretsEnvFile ?? cur.secretsEnvFile,
requiredScopes: opts.requiredScopes ? String(opts.requiredScopes).split(",").map((s: string) => s.trim()).filter(Boolean) : cur.requiredScopes,
...(opts.reactions !== undefined ? { reactions: opts.reactions } : {}),
...(opts.unfurl !== undefined ? { unfurl: opts.unfurl } : {}),
};
const { runChannelOperation, channelStateDigest } = await import("@openrig/daemon/gateway-slack");
const result = await runChannelOperation({
+22
View File
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import type Database from "better-sqlite3";
import { ConfigStore } from "./config-store.js";
import { getOpenRigHome } from "./openrig-compat.js";
/** This home's database, opened read-only without requiring the local daemon. better-sqlite3 loads only when this is
* called, so importing this module costs a command nothing. Throws when there's no database. */
export function openLocalDbReadonly(): Database.Database {
const configured = new ConfigStore().resolveWithSource("db.path");
let dbPath = configured.value as string;
// An explicit configured DB wins. Otherwise retain the last launch's --db selection.
if (configured.source === "default") {
try {
const state = JSON.parse(readFileSync(join(getOpenRigHome(), "daemon.json"), "utf8"));
if (typeof state.db === "string" && state.db.length > 0) dbPath = state.db;
} catch { /* No launch record: use the configured default, never search other homes. */ }
}
const Sqlite = createRequire(import.meta.url)("better-sqlite3") as typeof Database;
return new Sqlite(dbPath, { readonly: true, fileMustExist: true, timeout: 100 });
}
+3 -15
View File
@@ -1,24 +1,12 @@
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "./config-store.js";
import { getOpenRigHome } from "./openrig-compat.js";
import type Database from "better-sqlite3";
import { validateHostRegistry } from "./host-registry.js";
import { openLocalDbReadonly } from "./local-db.js";
/** Read the boot-minted identity without requiring the local daemon or minting a replacement. */
export function readLocalOrigin(): string | undefined {
let db: Database.Database | undefined;
try {
const configured = new ConfigStore().resolveWithSource("db.path");
let dbPath = configured.value as string;
// An explicit configured DB wins. Otherwise retain the last launch's --db selection.
if (configured.source === "default") {
try {
const state = JSON.parse(readFileSync(join(getOpenRigHome(), "daemon.json"), "utf8"));
if (typeof state.db === "string" && state.db.length > 0) dbPath = state.db;
} catch { /* No launch record: use the configured default, never search other homes. */ }
}
db = new Database(dbPath, { readonly: true, fileMustExist: true, timeout: 100 });
db = openLocalDbReadonly();
const row = db.prepare("SELECT host_id FROM self_host_identity WHERE singleton = 1").get() as { host_id?: unknown } | undefined;
const id = row?.host_id;
if (typeof id !== "string" || id === "localhost") return undefined;
+77 -7
View File
@@ -1,20 +1,90 @@
import { readOpenRigEnv } from "./openrig-compat.js";
import { execFileSync } from "node:child_process";
import type Database from "better-sqlite3";
import { openLocalDbReadonly } from "./local-db.js";
export type TmuxIdentityReader = (pane: string, option: string) => string | undefined;
/** A busy or wedged tmux server must not hold every queue request: past this, the pane's seat is unresolved. */
export const TMUX_IDENTITY_TIMEOUT_MS = 1_000;
const readTmuxIdentity: TmuxIdentityReader = (pane, option) => {
try {
const value = execFileSync("tmux", ["show-option", "-v", "-t", pane, option], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: TMUX_IDENTITY_TIMEOUT_MS,
}).trim();
return value || undefined;
} catch {
return undefined;
}
};
/** Whether the daemon still binds a node to a tmux session. Claim records the binding beside the session's metadata;
* unclaim deletes it in its own transaction even when clearing the metadata fails. */
export type BindingCheck = (nodeId: string, tmuxSession: string) => boolean;
export function bindingIsCurrent(db: Database.Database, nodeId: string, tmuxSession: string): boolean {
return db.prepare("SELECT 1 FROM bindings WHERE node_id = ? AND tmux_session = ?").get(nodeId, tmuxSession) !== undefined;
}
/** The binding as this home's database records it; no database, or no answer, isn't evidence of a binding. */
const localBindingIsCurrent: BindingCheck = (nodeId, tmuxSession) => {
let db: Database.Database | undefined;
try {
db = openLocalDbReadonly();
return bindingIsCurrent(db, nodeId, tmuxSession);
} catch {
return false;
} finally {
db?.close();
}
};
// The pane's seat, read once per run (runProgram resets it), so a command's preflight and every request it sends share one
// value and one tmux read. Keyed by what produced it, so a different pane or reader reads again.
let paneSeat: { pane: string; tmuxReader: TmuxIdentityReader; isBound: BindingCheck; session: string | undefined } | undefined;
/** Forget the pane seat read for the previous run. */
export function resetSenderSession(): void {
paneSeat = undefined;
}
/**
* The seat identity a CLI dispatch is attributed to — the SAME value the
* DaemonClient stamps as `X-OpenRig-Session` (the P18 chokepoint), from which
* the daemon DERIVES the actor / rendered `From:`. `--from` is deprecated and
* ignored: origin is the seat env, never a caller-supplied body claim. Returns
* `undefined` when the seat is unresolvable — the caller then delivers-and-labels
* with `SENDER_FALLBACK` (never a refusal, never a forged actor).
* the daemon DERIVES the actor / rendered `From:`. The process seat env wins;
* a reconciled managed pane's `@rigged_session_name` repairs a live process
* that predates its env binding, but only while the daemon still binds that
* session's node to it: metadata left behind on a released session isn't a
* current seat. Caller-supplied body claims never become a seat. Returns
* `undefined` when neither managed source resolves.
*/
export function resolveSenderSession(): string | undefined {
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
export function resolveSenderSession(
tmuxReader: TmuxIdentityReader = readTmuxIdentity,
isBound: BindingCheck = localBindingIsCurrent,
): string | undefined {
const env = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
if (env) return env;
const pane = process.env.TMUX_PANE?.trim();
if (!pane) return undefined;
if (paneSeat?.pane === pane && paneSeat.tmuxReader === tmuxReader && paneSeat.isBound === isBound) return paneSeat.session;
const session = boundPaneSeat(pane, tmuxReader, isBound);
paneSeat = { pane, tmuxReader, isBound, session };
return session;
}
function boundPaneSeat(pane: string, tmuxReader: TmuxIdentityReader, isBound: BindingCheck): string | undefined {
const session = tmuxReader(pane, "@rigged_session_name")?.trim();
if (!session) return undefined;
const nodeId = tmuxReader(pane, "@rigged_node_id")?.trim();
return nodeId && isBound(nodeId, session) ? session : undefined;
}
/**
* The session-less fall-open marker for an unattributable CLI dispatch. P18 (deletion atom) REVERSES A1's
* seat-boundary refusal: an env-less `rig send`/`broadcast` now DELIVERS carrying this HONEST label rather
* seat-boundary refusal: an unresolved `rig send`/`broadcast` now DELIVERS carrying this HONEST label rather
* than refusing, because the daemon half delivers-and-labels the header-absent write (no downstream 401) —
* unverified is labelled unknown, never laundered to verified.
*
+11
View File
@@ -513,6 +513,17 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
describe("script-safety integration (the compiled front door)", () => {
const cliEntry = join(__dirname, "..", "dist", "index.js");
it.skipIf(!existsSync(cliEntry))("an unresolved queue actor stays nonzero through a pipe with pipefail", () => {
const result = spawnSync("/bin/bash", ["--noprofile", "--norc", "-o", "pipefail", "-c",
'env -u OPENRIG_SESSION_NAME -u RIGGED_SESSION_NAME -u TMUX_PANE "$NODE" "$CLI" queue update qitem-pipe-proof --state in-progress --json 2>&1 | cat'], {
timeout: 5000,
encoding: "utf-8",
env: { ...process.env, NODE: process.execPath, CLI: cliEntry },
});
expect(result.status).toBe(1);
expect(result.stdout).toContain("OPENRIG_SESSION_NAME is not set");
});
it.skipIf(!existsSync(cliEntry))("`echo x | rig` returns usage fast — no hang (exit code preserves today's clean-help baseline)", () => {
const started = Date.now();
const result = spawnSync(process.execPath, [cliEntry], {
@@ -92,12 +92,17 @@ describe("cross-host queue writes wait for the daemon's answer", () => {
}
it("a create that gets no answer in time reads as unknown, never as a failure, with the ID to reconcile by", async () => {
await expect(run(["create", "--destination", "dev@rig-b@vps-b", "--body", "b", "--summary", "s"],
[new DaemonTimeoutError("Request to http://localhost:7433/api/queue/create timed out after 15000ms")])).rejects.toThrow();
let thrown: unknown;
await run(["create", "--destination", "dev@rig-b@vps-b", "--body", "b", "--summary", "s"],
[new DaemonTimeoutError("Request to http://localhost:7433/api/queue/create timed out after 15000ms")]).catch((e: unknown) => { thrown = e; });
const all = stderr.join("\n");
expect(all).toContain("queue request got no answer in time (routing to host 'vps-b')");
expect(all).not.toContain("queue transport failure");
expect(all).toContain("The write outcome is INDETERMINATE");
expect(all).toMatch(/repeat the same create with --id 'qitem-\d{14}-[0-9a-f]{16}'/);
// The final timeout error carries the same-ID instruction itself, not only the request line printed before the write.
const id = /Queue create request ID: (qitem-\d{14}-[0-9a-f]{16})/.exec(all)?.[1];
expect(id).toBeDefined();
expect(thrown).toBeInstanceOf(DaemonTimeoutError);
expect((thrown as Error).message).toContain(`repeat the same create with --id '${id}' and unchanged source, destination, body and options`);
});
});
@@ -52,6 +52,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
let local: Awaited<ReturnType<typeof serve>>;
let remote: Awaited<ReturnType<typeof serve>>;
let remoteAnswer: Answer;
let remoteRow: Record<string, unknown>;
let home: string;
let out: string[];
let err: string[];
@@ -79,11 +80,22 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
? { status: 200, data: { healthy: true, selfHostId: "vm-a" } }
: { status: 500, data: { error: "the selected daemon must not be asked for the row" } });
localPort = Number(new URL(local.url).port);
remoteRow = { ...ROW };
const remoteTransitions: Array<{ transitionId: number; state: string; transitionNote: string | null }> = [
{ transitionId: 1, state: "pending", transitionNote: null },
];
remoteAnswer = (req) => {
if (req.path === "/healthz") return { status: 200, data: { healthy: true, selfHostId: "devbox-id" } };
if (req.path.endsWith("/transitions")) return { status: 200, data: [{ transitionId: 1, state: "pending" }] };
if (req.path.endsWith("/transitions")) return { status: 200, data: remoteTransitions };
if (req.path.startsWith("/api/queue/list")) return { status: 200, data: [ROW] };
return { status: 200, data: ROW };
if (req.method === "POST" && req.path.endsWith("/update")) {
remoteRow = { ...remoteRow, ...(req.body as Record<string, unknown>) };
const transitionNote = (req.body as { transitionNote?: unknown }).transitionNote;
if (typeof transitionNote === "string") {
remoteTransitions.push({ transitionId: remoteTransitions.length + 1, state: String(remoteRow.state), transitionNote });
}
}
return { status: 200, data: remoteRow };
};
remote = await serve((req) => remoteAnswer(req));
hosts = [
@@ -121,7 +133,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
it("transitions --host reads the remote log", async () => {
await run("transitions", "qitem-remote-1", "--host", "devbox", "--json", "--full");
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/transitions"]);
expect(JSON.parse(out.join("\n"))).toEqual([{ transitionId: 1, state: "pending" }]);
expect(JSON.parse(out.join("\n"))).toEqual([{ transitionId: 1, state: "pending", transitionNote: null }]);
});
it("list --host lists the remote rows, its filters kept and no rig derived from this host's seat", async () => {
@@ -143,7 +155,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
it("update --host writes on the remote daemon, the actor stamped with this host's id", async () => {
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "done", "--closure-reason", "no-follow-on", "--note", "settled from vm-a", "--json");
const [call] = remoteQueueCalls();
const call = remoteQueueCalls().find((entry) => entry.method === "POST");
expect(call).toMatchObject({ method: "POST", path: "/api/queue/qitem-remote-1/update" });
expect(call!.headers["x-openrig-session"]).toBe("dev@fabrica@vm-a");
expect(call!.headers.authorization).toBe("Bearer s3cret");
@@ -208,7 +220,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
it("a self-id learned at the entry's current URL routes to that entry", async () => {
bind({ observedUrl: remote.url });
await run("update", "qitem-remote-1", "--host", "devbox-self-id", "--state", "done", "--json");
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/update"]);
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/update", "/api/queue/qitem-remote-1"]);
expect(process.exitCode).toBeUndefined();
});
@@ -263,7 +275,9 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
const { compactOutputEnabled } = await import("../src/queue-receipt.js");
expect(compactOutputEnabled()).toBe(true);
// The remote daemon's own id equals the alias: the receipt must still call the row remote.
remoteAnswer = (req) => req.path === "/healthz" ? { status: 200, data: { selfHostId: "devbox" } } : { status: 200, data: ROW };
remoteAnswer = (req) => req.path === "/healthz"
? { status: 200, data: { selfHostId: "devbox" } }
: { status: 200, data: { ...ROW, state: "in-progress" } };
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "in-progress", "--json");
const receipt = JSON.parse(out.join("\n"));
expect(receipt.host).toBe("devbox");
@@ -274,13 +288,15 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
local.close();
local = await serve((req) => req.path === "/healthz"
? { status: 200, data: { selfHostId: "vm-a" } }
: { status: 200, data: req.path.startsWith("/api/queue/list") ? [] : ROW });
: { status: 200, data: req.path.startsWith("/api/queue/list") ? [] : { ...ROW, state: "done" } });
localPort = Number(new URL(local.url).port);
await run("show", "qitem-remote-1", "--json");
await run("list", "--json");
await run("update", "qitem-remote-1", "--state", "done", "--json");
expect(remote.seen).toEqual([]);
expect(localQueueCalls().map((c) => c.path.split("?")[0])).toEqual(["/api/queue/qitem-remote-1", "/api/queue/list", "/api/queue/qitem-remote-1/update"]);
expect(localQueueCalls().map((c) => c.path.split("?")[0])).toEqual([
"/api/queue/qitem-remote-1", "/api/queue/list", "/api/queue/qitem-remote-1/update", "/api/queue/qitem-remote-1",
]);
// The local list still derives the rig from the seat, and no sender is stamped with a host id.
expect(new URL(localQueueCalls()[1]!.path, "http://x").searchParams.get("rig")).toBe("fabrica");
expect(localQueueCalls()[2]!.headers["x-openrig-session"]).toBe("dev@fabrica");
@@ -68,8 +68,11 @@ for (const json of [false, true]) {
expect(body.hint).toBe(`Queue item "${id}" was not found on daemon "selected-host".\n`
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(id)} --full --json\n`
+ `Or name that host: rig queue show ${shellQuote(id)} --host <host-id> --full --json`);
expect(calls).toHaveLength(2);
expect(calls[1]).toEqual({ path: "/healthz", options: { timeoutMs: 1_000 } });
// A note's update first reads the row's transitions (read-only), so its read-back can prove the note is new.
const preflight = verb === "update" ? 1 : 0;
expect(calls).toHaveLength(2 + preflight);
if (preflight) expect(calls[0]!.path).toBe(`/api/queue/${id}/transitions`);
expect(calls[1 + preflight]).toEqual({ path: "/healthz", options: { timeoutMs: 1_000 } });
expect(calls.some(c => c.path.includes("/hosts"))).toBe(false);
});
});
@@ -106,6 +109,8 @@ it("quotes the supplied ID without guessing a host or suggesting a write replay"
const body = JSON.parse(output.join("\n"));
expect(body.hint).toContain(`rig queue show ${shellQuote(supplied)} --full --json`);
expect(body.hint).not.toContain("queue update");
expect(calls[0]?.path).toBe(`/api/queue/${encodeURIComponent(supplied)}/update`);
expect(calls.slice(0, 2).map((c) => c.path)).toEqual([
`/api/queue/${encodeURIComponent(supplied)}/transitions`, `/api/queue/${encodeURIComponent(supplied)}/update`,
]);
expect(process.exitCode).toBe(1);
});
@@ -42,6 +42,8 @@ describe("an agent's pane task: woken, claimed and closed (#165)", () => {
return { status: response.status, data: await response.json() };
};
const client = {
// The sender it stamps, as the real client reports it: the update's read-back checks the recorded actor against it.
senderIdentity: async () => ({ session: process.env.OPENRIG_SESSION_NAME, originUnknown: false }),
get: async (url: string) => request(url, { method: "GET" }),
post: async (url: string, body: unknown) => request(url, { method: "POST", body: JSON.stringify(body) }),
};
@@ -0,0 +1,300 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import type Database from "better-sqlite3";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { runProgram } from "../src/cli-error.js";
import { queueCommand } from "../src/commands/queue.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
type UpdateTransform = (body: Record<string, unknown>) => Record<string, unknown> | null;
/** The nth read of the row's transitions (0 is the cursor read before the write): a response, or a throw for no answer. */
type TransitionsRead = (call: number) => { status: number; data: unknown } | undefined;
let database: Database.Database;
let repo: QueueRepository;
/** The selected daemon as the CLI sees it: the sender header it stamps, and the id the daemon's /healthz answers with. */
interface Endpoint { sender?: string; selfHostId?: string }
async function runUpdate(qitemId: string, args: string[], transform: UpdateTransform = (body) => body, transitionsRead?: TransitionsRead,
endpoint: Endpoint = {}) {
let transitionReads = 0;
let posts = 0;
let exits = 0;
const output: string[] = [];
const errors: string[] = [];
const program = new Command();
program.addCommand(queueCommand({
lifecycleDeps: {
spawn: () => ({ pid: 1, unref() {} }),
fetch: async () => ({ ok: true }),
kill: () => true,
readFile: (file: string) => file === STATE_FILE
? JSON.stringify({ pid: 1, port: 7433, host: "127.0.0.1", db: "test", startedAt: "test" })
: null,
writeFile() {},
removeFile() {},
exists: (file: string) => file === STATE_FILE,
mkdirp() {},
openForAppend: () => 3,
isProcessAlive: () => true,
},
clientFactory: () => ({
senderIdentity: async () => ({ session: endpoint.sender ?? "reviewer@fixture", originUnknown: false }),
get: async (route: string) => route === "/healthz"
? { status: 200, data: { selfHostId: endpoint.selfHostId } }
: route.endsWith("/transitions")
? transitionsRead?.(transitionReads++) ?? { status: 200, data: repo.listTransitions(qitemId) }
: { status: 200, data: repo.getById(qitemId) },
post: async (_route: string, rawBody: unknown) => {
posts += 1;
const body = transform(structuredClone(rawBody as Record<string, unknown>));
return {
status: 200,
data: body === null
? repo.getById(qitemId)
: repo.update({ ...body, qitemId, actorSession: "reviewer@fixture" }),
};
},
}) as never,
}));
// No exit status is reset here: each run's status is runProgram's to start clean.
const originalLog = console.log;
const originalError = console.error;
console.log = (...values: unknown[]) => output.push(values.join(" "));
console.error = (...values: unknown[]) => errors.push(values.join(" "));
try {
const code = await runProgram(program, ["node", "rig", "queue", "update", qitemId, ...args, "--json"], {
out: (value) => output.push(value),
err: (value) => errors.push(value),
exit: () => { exits += 1; },
});
return { code, output, errors, row: repo.getByIdOrThrow(qitemId), transitions: repo.listTransitions(qitemId), posts, exits };
} finally {
console.log = originalLog;
console.error = originalError;
}
}
async function claimedRow(): Promise<string> {
const row = await repo.create({
sourceSession: "reviewer@fixture",
destinationSession: "reviewer@fixture",
body: "work",
nudge: false,
});
repo.claim({ qitemId: row.qitemId, destinationSession: "reviewer@fixture" });
return row.qitemId;
}
describe("queue update canonical read-back against daemon domain semantics", () => {
beforeAll(() => {
database = createDb();
migrate(database, ALL_MIGRATIONS);
const bus = new EventBus(database);
repo = new QueueRepository(database, bus, {
validateRig: () => true,
loadHumanRegistry: () => ({ ok: true, entities: [] }),
});
repo.attachOutbox(new OutboxHandler(database));
});
beforeEach(() => {
vi.stubEnv("OPENRIG_SESSION_NAME", "reviewer@fixture");
process.exitCode = undefined;
});
afterEach(() => {
vi.unstubAllEnvs();
process.exitCode = undefined;
});
afterAll(() => database.close());
it("accepts a closing resolution appended after an existing fallback resolution", async () => {
const row = await repo.create({
sourceSession: "reviewer@fixture",
destinationSession: "worker@fixture",
body: "work",
nudge: false,
});
repo.routeToFallback(row.qitemId, "reviewer@fixture", "worker unavailable");
repo.claim({ qitemId: row.qitemId, destinationSession: "reviewer@fixture" });
const result = await runUpdate(row.qitemId, [
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "Handled by reviewer",
]);
expect(result.code, [...result.errors, ...result.output].join("\n")).toBe(0);
expect(result.row.resolution).toBe("fallback: worker unavailable\nHandled by reviewer");
});
it("accepts close, reopen, and reclose while preserving each resolution line", async () => {
const qitemId = await claimedRow();
const first = await runUpdate(qitemId, [
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "first outcome",
]);
const reopened = await runUpdate(qitemId, ["--state", "pending", "--reopen", "--note", "repair requested"]);
const second = await runUpdate(qitemId, [
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "second outcome",
]);
expect([first.code, reopened.code, second.code], [first.errors, reopened.errors, second.errors].flat().join("\n"))
.toEqual([0, 0, 0]);
expect(second.row.resolution).toBe("first outcome\nsecond outcome");
});
it.each([
"2026-11-14T10:00:00Z",
"2026-11-14T12:00:00+02:00",
])("accepts equivalent deadline spelling %s after daemon normalization", async (deadline) => {
const qitemId = await claimedRow();
const result = await runUpdate(qitemId, [
"--closure-required-at", deadline, "--note", "Accepted revised deadline",
]);
expect(result.code, result.errors.join("\n")).toBe(0);
expect(result.row.closureRequiredAt).toBe("2026-11-14T10:00:00.000Z");
});
it("rejects a genuinely different stored deadline", async () => {
const qitemId = await claimedRow();
repo.update({
qitemId,
actorSession: "reviewer@fixture",
closureRequiredAt: "2026-11-14T09:00:00Z",
transitionNote: "initial deadline",
});
const result = await runUpdate(qitemId, [
"--closure-required-at", "2026-11-14T10:00:00Z", "--note", "Accepted revised deadline",
], () => null);
expect(result.code).toBe(1);
expect(result.errors.join("\n")).toContain("closureRequiredAt");
});
it("rejects a close whose requested resolution was dropped", async () => {
const qitemId = await claimedRow();
const result = await runUpdate(qitemId, [
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "must persist",
], (body) => {
delete body.resolution;
return body;
});
expect(result.code).toBe(1);
expect(result.row.state).toBe("done");
expect(result.row.resolution).toBeNull();
expect(result.errors.join("\n")).toContain("resolution");
});
it("requires a newer canonical transition carrying the requested note", async () => {
const qitemId = await claimedRow();
const before = repo.listTransitions(qitemId).length;
const result = await runUpdate(qitemId, ["--note", "must persist"], () => null);
expect(result.code).toBe(1);
expect(result.transitions).toHaveLength(before);
expect(result.errors.join("\n")).toContain("no newer canonical transition carried requested note");
});
// The cursor read before the write only proves a note is new afterwards. When it fails, the write is still sent and
// persists; the receipt says the note can't be shown to be new and exits non-zero, even with an older transition
// already carrying the same note.
it.each([
["doesn't answer", () => { throw new Error("socket hang up (fixture)"); }],
["answers HTTP 503", () => ({ status: 503, data: { error: "busy" } })],
["returns a malformed list", () => ({ status: 200, data: [{ transitionId: "seven" }] })],
] as const)("sends a note-bearing update when the cursor read %s, and says the note is unproven", async (_label, fail) => {
const qitemId = await claimedRow();
repo.update({ qitemId, actorSession: "reviewer@fixture", transitionNote: "must persist" }); // an older matching note
const before = repo.listTransitions(qitemId).length;
const result = await runUpdate(qitemId, ["--note", "must persist", "--summary", "kept summary"], undefined,
(call) => call === 0 ? fail() : undefined);
expect(result.posts).toBe(1);
expect(result.transitions).toHaveLength(before + 1);
expect(result.transitions.at(-1)?.transitionNote).toMatch(/^must persist/);
expect(result.row.summary).toBe("kept summary");
expect(result.code).toBe(1);
const said = result.errors.join("\n");
expect(said).toContain("queue_update_readback_failed");
expect(said).toContain("can't be shown to be new");
expect(said).not.toContain("queue_update_preflight_failed");
});
it("gives each run its own exit status: a failed update, then a successful one in the same process", async () => {
const qitemId = await claimedRow();
const failed = await runUpdate(qitemId, ["--note", "dropped"], () => null);
const succeeded = await runUpdate(qitemId, ["--note", "kept"]);
expect(failed.code).toBe(1);
expect(succeeded.code, succeeded.errors.join("\n")).toBe(0);
// The action's failure is returned, never passed to process.exit(), which can cut off piped output.
expect([failed.exits, succeeded.exits]).toEqual([0, 0]);
});
// A note is this write's only when this write's actor recorded it, as written or with what the daemon adds for it.
it.each([
["another writer's identical note", "other@fixture", "must persist"],
["a lookalike note that starts the same way", "reviewer@fixture", "must persist (correction: still blocked)"],
["a lookalike note on a new line", "reviewer@fixture", "must persist\nbut still blocked"],
] as const)("doesn't take %s for a dropped note", async (_label, actor, note) => {
const qitemId = await claimedRow();
const result = await runUpdate(qitemId, ["--note", "must persist"], () => {
repo.update({ qitemId, actorSession: actor, transitionNote: note });
return null;
});
expect(result.transitions.at(-1)?.transitionNote).toBe(note);
expect(result.code).toBe(1);
const said = JSON.parse(result.errors.join("\n")) as { error: string; message: string };
expect(said.error).toBe("queue_update_readback_failed");
expect(said.message).toContain('no newer canonical transition carried requested note "must persist" from "reviewer@fixture"');
});
// A seat on host-a writing to a daemon at a direct endpoint sends reviewer@fixture@host-a. Only a daemon whose own id is
// host-a drops that suffix; any other daemon keeps it, and its own reviewer@fixture is a different seat.
it("doesn't take the selected daemon's own same-named seat for a dropped note's writer", async () => {
const qitemId = await claimedRow();
const result = await runUpdate(qitemId, ["--note", "must persist"], () => {
repo.update({ qitemId, actorSession: "reviewer@fixture", transitionNote: "must persist" });
return null;
}, undefined, { sender: "reviewer@fixture@host-a", selfHostId: "host-b" });
expect(result.code).toBe(1);
const said = JSON.parse(result.errors.join("\n")) as { error: string; message: string };
expect(said.message).toContain('from "reviewer@fixture@host-a"');
expect(said.message).not.toContain('or "reviewer@fixture"');
});
it("accepts the bare actor when the selected daemon answers with the sender's own host id", async () => {
const qitemId = await claimedRow();
const result = await runUpdate(qitemId, ["--note", "must persist"], undefined, undefined,
{ sender: "reviewer@fixture@host-a", selfHostId: "host-a" });
expect(result.code, result.errors.join("\n")).toBe(0);
expect(result.transitions.at(-1)).toMatchObject({ actorSession: "reviewer@fixture", transitionNote: "must persist" });
});
it("accepts the summary suffix the daemon adds to a note", async () => {
const qitemId = await claimedRow();
const result = await runUpdate(qitemId, ["--note", "must persist", "--summary", "new subject"]);
expect(result.code, result.errors.join("\n")).toBe(0);
expect(result.transitions.at(-1)?.transitionNote).toBe("must persist (summary updated)");
});
it("accepts a note only after its canonical transition appears", async () => {
const qitemId = await claimedRow();
const result = await runUpdate(qitemId, ["--note", "must persist"]);
expect(result.code, result.errors.join("\n")).toBe(0);
expect(result.transitions.at(-1)?.transitionNote).toBe("must persist");
});
});
@@ -0,0 +1,117 @@
// A verified `rig queue update` prints the canonical row it read back, and keeps what only the write's response carries:
// the park advisory (#2739). A real `queueCommand` talks over loopback HTTP to the real queueRoutes and QueueRepository,
// so the POST and the GET that reads it back are the daemon's own.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import type Database from "better-sqlite3";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { queueRoutes } from "../../daemon/src/routes/queue.js";
import { DaemonClient } from "../src/client.js";
import { queueCommand } from "../src/commands/queue.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
let db: Database.Database;
let repo: QueueRepository;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let server: any;
let port = 0;
beforeAll(async () => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
const bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true });
const app = new Hono();
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); await next(); });
app.route("/api/queue", queueRoutes());
await new Promise<void>((resolve) => {
server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); });
});
});
afterAll(async () => {
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
db?.close();
});
beforeEach(() => {
// A seat, on this host's own daemon: the CLI stamps it as the sender. Ambient endpoints would win over the state file.
vi.stubEnv("OPENRIG_SESSION_NAME", "dev-x@rig");
vi.stubEnv("TMUX_PANE", "");
for (const key of ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT"]) vi.stubEnv(key, "");
});
afterEach(() => vi.unstubAllEnvs());
async function runCli(args: string[]): Promise<{ out: string; err: string; exit: number | undefined }> {
const out: string[] = [];
const err: string[] = [];
const ol = console.log, oe = console.error;
console.log = (...a: unknown[]) => { out.push(a.join(" ")); };
console.error = (...a: unknown[]) => { err.push(a.join(" ")); };
process.exitCode = undefined;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await queueCommand({
lifecycleDeps: {
spawn: () => ({ pid: 1, unref() {} }), fetch: async () => ({ ok: true }), kill: () => true,
readFile: (p: string) => (p === STATE_FILE ? JSON.stringify({ pid: 1, port, host: "127.0.0.1", db: "x", startedAt: "x" }) : null),
writeFile() {}, removeFile() {}, exists: (p: string) => p === STATE_FILE, mkdirp() {}, openForAppend: () => 3, isProcessAlive: () => true,
},
clientFactory: (url: string) => new DaemonClient(url),
} as any).parseAsync(["node", "rig", ...args]);
} finally {
console.log = ol;
console.error = oe;
}
const exit = process.exitCode;
process.exitCode = undefined;
return { out: out.join("\n"), err: err.join("\n"), exit };
}
async function claimedRow(): Promise<string> {
const row = await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "work", nudge: false });
repo.claim({ qitemId: row.qitemId, destinationSession: "dev-x@rig" });
return row.qitemId;
}
const park = (id: string) => ["update", id, "--state", "blocked", "--blocked-on", "external:vendor-fix", "--note", "waiting on the vendor"];
describe("a verified queue update keeps the write's park advisory", () => {
it.each([
["the compact JSON receipt", "true", ["--json"]],
["the full JSON row", "false", ["--json"]],
["the full row with --full", "true", ["--full", "--json"]],
] as const)("in %s", async (_label, compact, flags) => {
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", compact);
const id = await claimedRow();
const { out, err, exit } = await runCli([...park(id), ...flags]);
expect(exit, err).toBeFalsy();
const printed = JSON.parse(out) as { state?: string; advisories?: Array<{ code?: string }> };
expect(printed.advisories).toEqual([expect.objectContaining({ code: "park_without_wake" })]);
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "blocked", blockedOn: "external:vendor-fix" });
});
it("in the compact text receipt", async () => {
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "true");
const id = await claimedRow();
const { out, err, exit } = await runCli(park(id));
expect(exit, err).toBeFalsy();
expect(out).toMatch(/advisory: No wake is set for this park/);
});
it("and a park with a wake carries none", async () => {
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "true");
const id = await claimedRow();
const { out, err, exit } = await runCli([...park(id), "--wake-after", "10m", "--json"]);
expect(exit, err).toBeFalsy();
expect((JSON.parse(out) as { advisories?: unknown }).advisories).toBeUndefined();
});
});
+23 -2
View File
@@ -49,6 +49,8 @@ function row(over: Row = {}, waiting: Row = {}): Row {
}
function makeDeps(routes: Record<string, { status: number; data: unknown } | Array<{ status: number; data: unknown }>>): QueueDeps {
const transitions: Array<{ transitionId: number; transitionNote: string | null; actorSession: string }> =
[{ transitionId: 1, transitionNote: null, actorSession: "stub@rig" }];
const pick = (key: string, fallback: { status: number; data: unknown }) => {
const r = routes[key];
return (Array.isArray(r) ? r.shift() : r) ?? fallback;
@@ -56,8 +58,27 @@ function makeDeps(routes: Record<string, { status: number; data: unknown } | Arr
return {
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (p: string) => pick(`GET ${p}`, { status: 200, data: {} })),
post: vi.fn(async (p: string) => pick(`POST ${p}`, { status: 500, data: { error: "unrouted" } })),
// The sender this stub stamps; the transitions it records carry it as their actor, as the daemon does.
senderIdentity: async () => ({ session: "stub@rig", originUnknown: false }),
get: vi.fn(async (p: string) => {
const explicit = routes[`GET ${p}`];
if (explicit) return Array.isArray(explicit) ? explicit.shift()! : explicit;
if (p.endsWith("/transitions")) return { status: 200, data: transitions };
const update = routes[`POST ${p}/update`];
if (update) {
const response = Array.isArray(update) ? update[0] : update;
return { status: 200, data: response?.data ?? {} };
}
return { status: 200, data: {} };
}),
post: vi.fn(async (p: string, body: unknown) => {
const response = pick(`POST ${p}`, { status: 500, data: { error: "unrouted" } });
const transitionNote = body && typeof body === "object" ? (body as { transitionNote?: unknown }).transitionNote : undefined;
if (response.status < 400 && typeof transitionNote === "string") {
transitions.push({ transitionId: transitions.length + 1, transitionNote, actorSession: "stub@rig" });
}
return response;
}),
}) as unknown as ReturnType<QueueDeps["clientFactory"]>,
};
}
+62 -1
View File
@@ -31,6 +31,8 @@ interface StubResponse {
data: unknown;
}
const STUB_SENDER = "stub@rig";
function makeDeps(opts?: {
routes?: Record<string, StubResponse | StubResponse[]>;
}): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: unknown }> } {
@@ -41,10 +43,37 @@ function makeDeps(opts?: {
deps: {
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
clientFactory: () => ({
// The sender this stub stamps; the transitions it synthesizes record it as their actor, as the daemon does.
senderIdentity: async () => ({ session: STUB_SENDER, originUnknown: false }),
get: vi.fn(async (path: string) => {
calls.push({ method: "GET", path });
const route = routes[`GET ${path}`];
return (Array.isArray(route) ? route.shift() : route) ?? { status: 200, data: {} };
if (route) return Array.isArray(route) ? route.shift() : route;
const transitionMatch = /^\/api\/queue\/([^/]+)\/transitions$/.exec(path);
if (transitionMatch) {
const qitemId = decodeURIComponent(transitionMatch[1]!);
const update = [...calls].reverse().find((call) => call.method === "POST"
&& call.path === `/api/queue/${encodeURIComponent(qitemId)}/update`);
const transitionNote = update?.body && typeof update.body === "object"
? (update.body as { transitionNote?: unknown }).transitionNote
: undefined;
return { status: 200, data: [
{ transitionId: 1, transitionNote: null, actorSession: STUB_SENDER },
...(typeof transitionNote === "string" ? [{ transitionId: 2, transitionNote, actorSession: STUB_SENDER }] : []),
] };
}
const match = /^\/api\/queue\/([^/]+)$/.exec(path);
if (match) {
const qitemId = decodeURIComponent(match[1]!);
const update = [...calls].reverse().find((call) => call.method === "POST"
&& call.path === `/api/queue/${encodeURIComponent(qitemId)}/update`);
if (update?.body && typeof update.body === "object") {
const body = Object.fromEntries(Object.entries(update.body as Record<string, unknown>)
.filter(([key, value]) => value !== undefined && !["transitionNote", "actorSession", "reopen"].includes(key)));
return { status: 200, data: { qitemId, ...body } };
}
}
return { status: 200, data: {} };
}),
getText: vi.fn(async (path: string) => {
calls.push({ method: "GET", path });
@@ -83,6 +112,7 @@ describe("rig queue CLI", () => {
// AMBIENT OPENRIG_SESSION_NAME (which masked the env-less break in a managed runner). Per-test stubs
// that need a specific seat override this.
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig");
vi.stubEnv("TMUX_PANE", "");
logs = [];
errors = [];
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
@@ -1203,6 +1233,37 @@ describe("rig queue CLI", () => {
evidenceRef: undefined,
transitionNote: "audit bytes",
});
expect(calls.some((c) => c.method === "GET" && c.path === "/api/queue/qitem-note")).toBe(true);
});
it("update reports success only after canonical read-back matches the requested state", async () => {
const row = { qitemId: "qitem-verified", state: "in-progress", summary: "working" };
const { deps, calls } = makeDeps({ routes: {
"POST /api/queue/qitem-verified/update": { status: 200, data: { qitemId: "qitem-verified", state: "in-progress" } },
"GET /api/queue/qitem-verified": { status: 200, data: row },
} });
await createProgram({ queueDeps: deps }).parseAsync([
"node", "rig", "queue", "update", "qitem-verified", "--state", "in-progress", "--summary", "working", "--json",
]);
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([
"POST /api/queue/qitem-verified/update",
"GET /api/queue/qitem-verified",
]);
expect(JSON.parse(logs.at(-1)!)).toMatchObject(row);
expect(process.exitCode).toBeUndefined();
});
it("update fails closed when canonical read-back does not contain the requested state", async () => {
const { deps } = makeDeps({ routes: {
"POST /api/queue/qitem-mismatch/update": { status: 200, data: { qitemId: "qitem-mismatch", state: "in-progress" } },
"GET /api/queue/qitem-mismatch": { status: 200, data: { qitemId: "qitem-mismatch", state: "pending" } },
} });
await createProgram({ queueDeps: deps }).parseAsync([
"node", "rig", "queue", "update", "qitem-mismatch", "--state", "in-progress", "--json",
]);
expect(process.exitCode).toBe(1);
expect(JSON.parse(errors.at(-1)!)).toMatchObject({ error: "queue_update_readback_failed" });
expect(logs).toEqual([]);
});
it("update forwards an explicitly accepted closure deadline without a state change", async () => {
+14 -3
View File
@@ -62,12 +62,12 @@ function depsThrowing(err: Error): { queueDeps: QueueDeps } {
async function runCli(args: string[], deps?: Parameters<typeof createProgram>[0]) {
const out: string[] = [];
const err: string[] = [];
let exitCode = 0;
const program = createProgram(deps);
await runProgram(program, ["node", "rig", ...args], {
// The run's status is what runProgram returns; it calls exit only on its own error paths, with the same code.
const exitCode = await runProgram(program, ["node", "rig", ...args], {
out: (l) => out.push(l),
err: (l) => err.push(l),
exit: (c) => { exitCode = c; },
exit: () => undefined,
});
return { out: out.join("\n"), err: err.join("\n"), exitCode };
}
@@ -122,6 +122,17 @@ describe("render response integrity — 3-part error + honest exit, never daemon
beforeEach(() => { vi.stubEnv("OPENRIG_SESSION_NAME", "harness@rig"); process.exitCode = undefined; });
afterEach(() => { vi.unstubAllEnvs(); });
it("an unresolved queue actor stays nonzero when a wrapper pipes stdout", async () => {
vi.stubEnv("OPENRIG_SESSION_NAME", "");
vi.stubEnv("RIGGED_SESSION_NAME", "");
vi.stubEnv("TMUX_PANE", "");
const { exitCode } = await runCli(
["queue", "update", "qitem-x", "--state", "in-progress", "--json"],
depsThrowing(new Error("daemon must not be contacted")),
);
expect(exitCode).toBe(1);
});
it("bad-response renders fact/consequence/action JSON with nonzero exit (json)", async () => {
const err = new DaemonResponseError(502, "<html>502 Bad Gateway</html>");
const { out, exitCode } = await runCli(
+2
View File
@@ -241,6 +241,8 @@ describe("Send CLI", () => {
// (Hermetic-gate default is env-UNSET, so without this env-less delivery would render the unknown
// marker.) Nested describes that stub their own envs re-run after this; the block afterEach restores.
vi.stubEnv("OPENRIG_SESSION_NAME", "sender@my-rig");
// A runner inside tmux would otherwise let the managed-pane fallback recover the runner's own seat.
vi.stubEnv("TMUX_PANE", "");
vi.stubEnv("RIGGED_SESSION_NAME", "");
});
afterEach(() => { vi.unstubAllEnvs(); });
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
import { runProgram } from "../src/cli-error.js";
import { senderIdentityHeaders } from "../src/client.js";
import { bindingIsCurrent, resetSenderSession, resolveSenderSession, TMUX_IDENTITY_TIMEOUT_MS } from "../src/sender-identity.js";
const execFileSync = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async (original) => ({ ...(await original<typeof import("node:child_process")>()), execFileSync }));
afterEach(() => {
vi.unstubAllEnvs();
execFileSync.mockReset();
resetSenderSession();
});
function outsideASeatEnv(pane: string): void {
vi.stubEnv("OPENRIG_SESSION_NAME", "");
vi.stubEnv("RIGGED_SESSION_NAME", "");
vi.stubEnv("TMUX_PANE", pane);
}
describe("managed sender identity recovery", () => {
it("uses reconciled pane metadata when the live process missed the seat env", () => {
outsideASeatEnv("%149");
const metadata: Record<string, string> = { "@rigged_session_name": "producer-lead@studio", "@rigged_node_id": "node-1" };
const read = (pane: string, option: string) => {
expect(pane).toBe("%149");
return metadata[option];
};
const bound = (nodeId: string, session: string) => nodeId === "node-1" && session === "producer-lead@studio";
expect(resolveSenderSession(read, bound)).toBe("producer-lead@studio");
expect(senderIdentityHeaders(undefined, undefined, read, bound)).toEqual({
"X-OpenRig-Session": "producer-lead@studio",
});
});
it("keeps the process seat env authoritative over pane metadata", () => {
vi.stubEnv("OPENRIG_SESSION_NAME", "dev-coder@studio");
vi.stubEnv("TMUX_PANE", "%149");
expect(resolveSenderSession(() => "producer-lead@studio", () => true)).toBe("dev-coder@studio");
});
it("bounds the tmux read, and a read that times out leaves the seat unresolved", () => {
outsideASeatEnv("%149");
// The reader catches what the call throws, so the options are checked here, not inside the mock.
execFileSync.mockImplementationOnce(() => {
throw Object.assign(new Error("spawnSync tmux ETIMEDOUT"), { code: "ETIMEDOUT" });
});
expect(resolveSenderSession()).toBeUndefined();
expect(execFileSync).toHaveBeenCalledTimes(1);
expect(execFileSync.mock.calls[0]![2]).toMatchObject({ timeout: TMUX_IDENTITY_TIMEOUT_MS });
expect(TMUX_IDENTITY_TIMEOUT_MS).toBeLessThanOrEqual(2_000);
});
it("stays unresolved when neither env nor managed pane metadata exists", () => {
outsideASeatEnv("%404");
expect(resolveSenderSession(() => undefined, () => true)).toBeUndefined();
});
it("reads the pane once per run: a command's preflight and its requests share it, and the next run reads again", async () => {
outsideASeatEnv("%149");
execFileSync.mockImplementation((_tmux: string, args: string[]) => args.at(-1) === "@rigged_session_name" ? "producer-lead@studio\n" : "node-1\n");
const bound = () => true;
const run = () => runProgram(new Command().action(() => {
expect(resolveSenderSession(undefined, bound)).toBe("producer-lead@studio"); // the preflight
expect(senderIdentityHeaders(undefined, undefined, undefined, bound)).toEqual({ "X-OpenRig-Session": "producer-lead@studio" });
expect(senderIdentityHeaders("origin-a", undefined, undefined, bound)).toEqual({ "X-OpenRig-Session": "producer-lead@studio@origin-a" });
}), ["node", "rig"], { exit: () => undefined });
expect(await run()).toBe(0);
expect(execFileSync).toHaveBeenCalledTimes(2); // the seat and its node, once
expect(await run()).toBe(0);
expect(execFileSync).toHaveBeenCalledTimes(4);
});
});
describe("a pane's seat holds only while the daemon binds it", () => {
// A real claim sets the session's metadata and the daemon's binding. A real unclaim deletes the binding in its own
// transaction even when clearing the metadata fails, which leaves the pane naming its old seat.
async function claimedPane() {
const db = createDb();
migrate(db, ALL_MIGRATIONS);
const setup = createTestApp(db);
const metadata = new Map<string, string>();
vi.mocked(setup.tmuxAdapter.setSessionOption).mockImplementation(async (_session: string, key: string, value: string) => {
metadata.set(key, value);
return { ok: true as const };
});
const rig = setup.rigRepo.createRig("adoption-fixture");
const node = setup.rigRepo.addNode(rig.id, "external.helper", { runtime: "terminal" });
const discovered = setup.discoveryRepo.upsertDiscoveredSession({
tmuxSession: "manual-adoption", tmuxPane: "%1", cwd: "/tmp",
activeCommand: "bash", runtimeHint: "terminal", confidence: "high",
});
const claim = await setup.claimService.bind({ discoveredId: discovered.id, rigId: rig.id, logicalId: node.logicalId });
if (!claim.ok) throw new Error(claim.error);
const read = (_pane: string, option: string) => metadata.get(option) || undefined;
const bound = (nodeId: string, session: string) => bindingIsCurrent(db, nodeId, session);
return { db, setup, claim, read, bound };
}
it("reads a claimed session the daemon binds as its seat", async () => {
outsideASeatEnv("%1");
const { db, read, bound } = await claimedPane();
expect(resolveSenderSession(read, bound)).toBe("manual-adoption");
db.close();
});
it("reads a released session as unknown when clearing its metadata failed", async () => {
outsideASeatEnv("%1");
const { db, setup, claim, read, bound } = await claimedPane();
vi.mocked(setup.tmuxAdapter.setSessionOption).mockImplementation(async () => { throw new Error("tmux: no server"); });
expect((await setup.rigLifecycleService.unclaimSession(claim.sessionId)).ok).toBe(true);
expect(read("%1", "@rigged_session_name")).toBe("manual-adoption");
expect(resolveSenderSession(read, bound)).toBeUndefined();
db.close();
});
});
@@ -121,6 +121,19 @@ describe("S10 CLI cutover — admin verbs route to the daemon", () => {
});
describe("S10 CLI cutover — config surfaces survive on the daemon-homed modules", () => {
it("saves and reports the human-row preview choice", async () => {
const home = mkdtempSync(join(tmpdir(), "slack-preview-"));
homes.push(home);
const logs: string[] = [];
const deps: SlackDeps = { home, log: message => logs.push(message), clientFactory: () => ({ get: async () => { throw new Error("offline"); } }) };
await run(slackCommand(deps), ["setup", "--unfurl", "on"]);
expect(JSON.parse(readFileSync(join(home, "slack-connector.json"), "utf8")).unfurl).toBe("on");
logs.length = 0;
await run(slackCommand(deps), ["status"]);
expect(logs.join("\n")).toContain("human-row previews");
expect(logs.join("\n")).toContain("on");
});
it("retires the legacy alert-tag setup knob", () => {
const { deps } = makeDeps();
const setup = slackCommand(deps).commands.find((command) => command.name() === "setup");
@@ -95,6 +95,17 @@ rig queue handoff <qitem-id> \
A queue write (`create`, `claim`, `update`, `handoff`, `handoff-and-complete`) answers with the row it wrote; a
handoff answers with the closed row and its successor. When that answer shows what you need to establish, use it.
`queue update` is stricter: before it reports success, the CLI reads the row back from the canonical queue and checks
every requested row field using daemon semantics (including normalized deadlines and appended closing resolutions).
When `--note` is present, it first records the transition cursor and then requires a newer canonical transition,
recorded under the actor the write was sent as, that carries those note bytes as written or with only what the daemon
adds for that request (the reopen prefix, the summary suffix, a revised deadline's receipt). Another writer's note, or
a note that only starts the same way, doesn't count. If that cursor can't be read, the write is still sent, and the
note can't be shown to be new: the command says so and exits non-zero. A failed post-write check exits non-zero and
says the write may have committed. Reconcile with `rig queue show <id> --full --json` before retrying. Preserve that
exit status in wrappers, and use `set -o pipefail` when piping its output so a downstream formatter cannot turn
failure into success.
`create` also prints `Queue create request ID: <id> (not proof of persistence)` on stderr *before* it sends the write,
so an interrupted create can be reconciled. That ID names a request, not a row. The row is the `qitemId` on stdout
when the command exits 0. A non-zero exit means no row was confirmed, even when stdout carries a `qitemId`: an
@@ -465,7 +465,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// Codex's "continue without trusting" choice writes no state, so an existing install with no hooks.state entry for any
// of OpenRig's hooks may be an unrecorded skip, or an older install that never granted them. OpenRig trusts its own
// exact shipped hooks there as on a first install (#17). Any recorded choice for one of them, or a layer it can't read,
// leaves native review in place. A proven owned grant may renew its content approval.
// leaves native review in place. A proven owned grant may renew its content approval, and its declarations when a new
// shipped bundle changes them and every approval still matches OpenRig's own record.
const firstInstall = !this.fs.exists(cacheRoot) && !layers.some(layer => layer.state === "read"
&& Object.hasOwn((parseToml(layer.content).plugins ?? {}) as object, CODEX_CORE_PLUGIN_KEY));
const noOwnHookChoice = (read: CodexConfigLayerRead[]) => {
@@ -482,14 +483,20 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
let content = before;
const planned = planOwnedCodexContentApproval({ configPath, content, layers: currentLayers, shipped, installed: cachedDir, freshGrant });
if (!planned) return;
for (const [key, hash] of Object.entries(planned.trustedHashes)) content = upsertCodexHookTrust(content, key, hash);
// The declaration writer ends the file with one newline; keep the owner's own ending.
if (content !== before) content = content.replace(/\n*$/, /\n*$/.exec(before)?.[0] ?? "");
for (const [key, hash] of Object.entries(planned.hashes)) content = upsertCodexHookTrust(content, key, hash, "trusted_content_hash");
// The line writer must not alter examples in strings or duplicate quoted keys.
try {
const expected = parseToml(before) as { hooks: { state: Record<string, Record<string, unknown>> } };
for (const [key, hash] of Object.entries(planned.trustedHashes)) expected.hooks.state[key]!.trusted_hash = hash;
for (const [key, hash] of Object.entries(planned.hashes)) expected.hooks.state[key]!.trusted_content_hash = hash;
if (!isDeepStrictEqual(parseToml(content), expected)) return;
} catch { return; } // Leave native review and owner bytes intact for unsupported TOML spellings.
if (this.fs.readFile(configPath) !== before) return; // A concurrent native choice wins.
// A concurrent native choice wins: in this file, or in any layer the plan read (a layer appearing, changing,
// disappearing or becoming unreadable counts).
if (this.fs.readFile(configPath) !== before || !isDeepStrictEqual(readLayers(), currentLayers)) return;
if (content !== before) this.fs.writeFile(configPath, content);
recordOwnedCodexApproval(configPath, planned.approval);
};
@@ -79,10 +79,14 @@ export function codexLegacyPluginTrustPath(configPath: string): string {
interface OwnedApproval { schema: 1; plugin: string; contentHash: string; declarations: Record<string, string> }
export const ownedCodexApprovalPath = (configPath: string) => path.join(path.dirname(configPath), ".openrig-core-hook-trust.json");
/** No native baseline is changed. Existing declarations must already be approved; only content approval may advance. */
/**
* No native baseline is changed. Content approval advances for approved declarations. A declaration changed by a new
* shipped bundle is renewed only from OpenRig's own record (#17): every approval must still equal the recorded
* declaration and content. `trustedHashes` names the declarations to rewrite; `hashes` the content approvals.
*/
export function planOwnedCodexContentApproval(input: {
configPath: string; content: string; layers: CodexConfigLayerRead[]; shipped: string; installed: string; freshGrant?: boolean;
}): { hashes: Record<string, string>; approval: OwnedApproval } | undefined {
}): { hashes: Record<string, string>; trustedHashes: Record<string, string>; approval: OwnedApproval } | undefined {
try {
if (codexPluginContentHash(input.shipped, true) !== codexPluginContentHash(input.installed, true)) return;
// The trees just matched byte for byte, so approve the shipped bytes rather than a second read of the cache.
@@ -111,26 +115,41 @@ export function planOwnedCodexContentApproval(input: {
const states = doc.hooks?.state ?? {};
for (const [key, hash] of Object.entries(declarations)) {
const state = states[key];
if (!state || (state.enabled !== undefined && state.enabled !== true) || state.trusted_hash !== hash
if (!state || (state.enabled !== undefined && state.enabled !== true) || typeof state.trusted_hash !== "string"
|| Object.keys(state).some(field => !["enabled", "trusted_hash", "trusted_content_hash"].includes(field))
|| findTomlTable(input.content.split("\n"), ["hooks", "state", key]) < 0
|| docs.some(layer => otherChoice(layer.doc, key, layer.path !== input.configPath))) return;
}
const approval: OwnedApproval = { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY, contentHash, declarations };
const hashes = Object.fromEntries(Object.keys(declarations).map(key => [key, contentHash]));
if (input.freshGrant) return { hashes, approval };
if (Object.keys(declarations).every(key => states[key].trusted_content_hash === contentHash)) return { hashes: {}, approval };
const changed = Object.entries(declarations).filter(([key, hash]) => states[key].trusted_hash !== hash);
const parseRecord = (saved: string): OwnedApproval | undefined => {
const previous: OwnedApproval = JSON.parse(saved);
return previous.schema === 1 && previous.plugin === CODEX_CORE_PLUGIN_KEY && /^sha256:[a-f0-9]{64}$/.test(previous.contentHash)
&& Object.keys(previous.declarations).length === 6 ? previous : undefined;
};
if (changed.length > 0) {
// Only OpenRig's own saved record proves these approvals are its earlier grant; the legacy fallback below doesn't.
const saved = readRecord(ownedCodexApprovalPath(input.configPath));
const previous = saved === undefined ? undefined : parseRecord(saved);
if (input.freshGrant || !previous) return;
for (const key of Object.keys(declarations)) {
if (states[key].trusted_hash !== previous.declarations[key] || states[key].trusted_content_hash !== previous.contentHash) return;
}
return { hashes, trustedHashes: Object.fromEntries(changed), approval };
}
if (input.freshGrant) return { hashes, trustedHashes: {}, approval };
if (Object.keys(declarations).every(key => states[key].trusted_content_hash === contentHash)) return { hashes: {}, trustedHashes: {}, approval };
const baseline = readRecord(codexLegacyPluginTrustPath(input.configPath));
if (Object.keys(declarations).every(key => (states[key].trusted_content_hash ?? baseline) === contentHash)) return { hashes, approval };
if (Object.keys(declarations).every(key => (states[key].trusted_content_hash ?? baseline) === contentHash)) return { hashes, trustedHashes: {}, approval };
const saved = readRecord(ownedCodexApprovalPath(input.configPath));
const previous: OwnedApproval = saved === undefined ? { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY,
contentHash: LEGACY_CONTENT, declarations: legacyDeclarations } : JSON.parse(saved);
if (previous.schema !== 1 || previous.plugin !== CODEX_CORE_PLUGIN_KEY || !/^sha256:[a-f0-9]{64}$/.test(previous.contentHash)
|| Object.keys(previous.declarations).length !== 6) return;
const previous = saved === undefined ? { schema: 1 as const, plugin: CODEX_CORE_PLUGIN_KEY,
contentHash: LEGACY_CONTENT, declarations: legacyDeclarations } : parseRecord(saved);
if (!previous) return;
for (const [key, hash] of Object.entries(declarations)) {
if (previous.declarations[key] !== hash || (states[key].trusted_content_hash ?? baseline) !== previous.contentHash) return;
}
return { hashes, approval };
return { hashes, trustedHashes: {}, approval };
} catch { return undefined; } // Unknown ownership or config leaves native review in place.
}
@@ -61,6 +61,8 @@ export interface SlackConnectorConfig {
* "off" = reactions are ignored, so an acknowledging emoji costs no agent turn. Not in DEFAULT_CONFIG, like `channelMap`.
*/
reactions?: SlackReactionsMode;
/** Link and media previews on human-row posts; absent defaults to off. */
unfurl?: "on" | "off";
}
export const SLACK_REACTIONS_MODES = ["route", "off"] as const;
@@ -119,6 +121,9 @@ function validateConfig(cfg: SlackConnectorConfig, opts: { reading?: boolean } =
if (!opts.reading && cfg.reactions !== undefined && !SLACK_REACTIONS_MODES.includes(cfg.reactions)) {
throw new Error(`reactions must be one of ${SLACK_REACTIONS_MODES.join(", ")} (got ${String(cfg.reactions)})`);
}
if (!opts.reading && cfg.unfurl !== undefined && cfg.unfurl !== "on" && cfg.unfurl !== "off") {
throw new Error(`unfurl must be on or off (got ${String(cfg.unfurl)})`);
}
}
export function configPathFor(home?: string): string {
@@ -176,6 +181,7 @@ export function staticReadiness(cfg: SlackConnectorConfig, hasBotToken: boolean,
{ ok: Boolean(cfg.inboundDestination), label: "inbound-destination",
detail: cfg.inboundDestination === INHERIT_INBOUND_DESTINATION ? `${INHERIT_INBOUND_DESTINATION} (the host's default receiving agent)` : cfg.inboundDestination },
{ ok: cfg.enabled, label: "enabled", detail: cfg.enabled ? "yes" : "no (run `rig slack enable`)" },
{ ok: true, label: "human-row previews", detail: cfg.unfurl === "on" ? "on" : "off" },
...channelMapReadiness(cfg),
...botInboxReadiness(cfg),
...forwardReadiness(cfg),
@@ -474,6 +474,8 @@ export interface PostChatMessageInput {
blocks?: unknown[];
/** Thread reply: the PARENT message's ts (never a reply's ts — the affordance discriminator). */
thread_ts?: string;
unfurl_links?: boolean;
unfurl_media?: boolean;
}
export type PostChatMessageResult =
@@ -505,6 +507,8 @@ export async function postChatMessage(
const body: Record<string, unknown> = { channel: input.channel, text: input.text };
if (input.blocks?.length) body.blocks = input.blocks;
if (input.thread_ts) body.thread_ts = input.thread_ts;
if (input.unfurl_links !== undefined) body.unfurl_links = input.unfurl_links;
if (input.unfurl_media !== undefined) body.unfurl_media = input.unfurl_media;
let r = await callWebApi("chat.postMessage", token, body, fetchImpl, timeoutMs);
if (!r.ok && r.status === 429) {
const waitMs = (r.retryAfterSeconds ?? 0) * 1000;
@@ -41,6 +41,8 @@ export interface SubsystemSlackDeliveryOpts {
* (no map) nothing is recorded and the channel is resolved as before. */
pinChannel?: boolean;
sourceLabel: string; // host/box/rig — from config, never hardcoded (item 7)
/** Human-row previews; absent defaults off. */
unfurl?: "on" | "off";
/** This daemon's machine name, read per post, shown after a seat sender (message.ts `machine`). */
machine?: () => string | null;
bodyExcerpt?: number;
@@ -332,7 +334,8 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
};
const postInput = (possibleRepeat = false) => {
const shown = render(possibleRepeat, q.stateUnconfirmed === true, q.quietUnreadable === true);
return { channel, text: shown.text, blocks: shown.blocks, thread_ts: threadTs };
const unfurl = opts.unfurl === "on";
return { channel, text: shown.text, blocks: shown.blocks, thread_ts: threadTs, unfurl_links: unfurl, unfurl_media: unfurl };
};
const payloadHash = () => createHash("sha256").update(JSON.stringify({ channel, text: render().text, blocks: render().blocks, thread_ts: threadTs })).digest("hex");
const failurePrefix = `${decision.decisionId}${TRANSPORT_FAILURE_RECEIPT_PREFIX}`;
@@ -638,6 +638,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
},
pinChannel: Boolean(cfg.channelMap?.length),
sourceLabel: cfg.sourceLabel,
unfurl: cfg.unfurl === "on" ? "on" : "off",
// P6: each post names the machine it comes from, the daemon's own host id.
machine: () => getSelfHostId(),
fetchImpl: opts.fetchImpl,
@@ -176,6 +176,7 @@ export interface RestoreCheckOpts {
// --- Deps (framework-free per ADR-0001; reads from existing projections per ADR-0002) ---
export interface NodeInventoryEntry {
codexConfigProfile?: string | null;
nodeId?: string | null;
rigId: string;
rigName: string;
@@ -455,6 +456,8 @@ export class RestoreCheckService {
fsOps: { exists: this.deps.exists },
servicesRecord: input.servicesRecord,
currentRuntimes: Object.fromEntries(inventory.map((current) => [current.logicalId, current.runtime])),
currentCodexProfiles: Object.fromEntries(inventory.filter((current) => current.codexConfigProfile !== undefined)
.map((current) => [current.logicalId, current.codexConfigProfile!])),
});
const { blockers, warnings } = validation;
return {
@@ -440,6 +440,9 @@ export class RestoreOrchestrator {
servicesRecord: this.rigRepo.getServicesRecord(rigId),
freshLogicalIds: opts?.freshLogicalIds,
currentRuntimes: Object.fromEntries((this.rigRepo.getRig(rigId)?.nodes ?? []).map((current) => [current.logicalId, current.runtime])),
currentCodexProfiles: Object.fromEntries((this.rigRepo.getRig(rigId)?.nodes ?? [])
.filter((current) => current.codexConfigProfile !== undefined)
.map((current) => [current.logicalId, current.codexConfigProfile!])),
});
const topology = resolveSnapshotRestoreTopology(snapshot.data);
// A required startup file that is missing holds only its own seat. That seat isn't launched, because it would start
@@ -13,6 +13,8 @@ export function validatePreRestore(
freshLogicalIds?: string[];
/** The runtime each seat has now, by logical id. A seat not listed is read as its saved runtime. */
currentRuntimes?: Record<string, string | null | undefined>;
/** Current explicit seat selections; a missing key is unknown, null is an explicit absence. */
currentCodexProfiles?: Record<string, string | null>;
},
): { blockers: RestoreValidationBlocker[]; warnings: string[] } {
const blockers: RestoreValidationBlocker[] = [];
@@ -88,6 +90,12 @@ export function validatePreRestore(
}
for (const node of topology.intendedNodes) {
if (node.runtime === "codex" && node.codexConfigProfile
&& opts.currentRuntimes?.[node.logicalId] === "codex"
&& opts.currentCodexProfiles && Object.hasOwn(opts.currentCodexProfiles, node.logicalId)
&& opts.currentCodexProfiles[node.logicalId] === null) {
warnings.push(`Restore pre-validation: ${node.logicalId} had Codex profile ${node.codexConfigProfile} in this snapshot, but its current seat selection has no named profile. Restore uses the current selection; an ordinary floor launch uses workspace-write instead. Review rig seat set-permissions before restoring if this change is unintended. The snapshot does not prove what a prior process enforced.`);
}
const checkpoint = checkpoints[node.id] ?? null;
if (checkpoint && !node.cwd) {
add({
@@ -23,6 +23,7 @@ import { validateResumeToken } from "./resume-token-validation.js";
import { readProcessTable } from "./process-table.js";
import type { HomeRuntime } from "./seat-runtime-home.js";
import { readSeatClaudeMcp, type ClaudeMcp } from "./claude-mcp.js";
import { ObservationPass } from "./process-observation-pass.js";
export interface ResumeRefreshSession {
/** The seat, when known, so its own runtime home is read. */
@@ -58,6 +59,8 @@ interface ResumeMetadataRefresherDeps {
};
/** #421 — start time of the pane's current Claude process; a sidecar sampled earlier is not used. */
claudeProcessStartedAt?: ResumeTokenCaptureDeps["claudeProcessStartedAt"];
/** One fresh identity lister per seat, shared only within a snapshot refresh. */
claudeStartedAtForPass?: (pass: ObservationPass) => NonNullable<ResumeTokenCaptureDeps["claudeProcessStartedAt"]>;
/** Provider root used by the daemon's Claude launches; no scan of other homes. */
claudeConfigDir?: string;
listClaudeProcesses?: NativeProcessLister;
@@ -77,6 +80,7 @@ export class ResumeMetadataRefresher {
private homeDir: string;
private contextUsageStore: ResumeMetadataRefresherDeps["contextUsageStore"] | null;
private claudeProcessStartedAt: ResumeMetadataRefresherDeps["claudeProcessStartedAt"] | null;
private claudeStartedAtForPass: ResumeMetadataRefresherDeps["claudeStartedAtForPass"];
private claudeConfigDir: string;
private seatHome?: (nodeId: string, runtime: HomeRuntime) => string | undefined;
private listClaudeProcesses: NativeProcessLister | undefined;
@@ -112,6 +116,7 @@ export class ResumeMetadataRefresher {
this.homeDir = deps.homeDir ?? os.homedir();
this.contextUsageStore = deps.contextUsageStore ?? null;
this.claudeProcessStartedAt = deps.claudeProcessStartedAt ?? null;
this.claudeStartedAtForPass = deps.claudeStartedAtForPass;
this.claudeConfigDir = deps.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? nodePath.join(this.homeDir, ".claude");
this.seatHome = deps.seatHome;
this.listClaudeProcesses = deps.listClaudeProcesses;
@@ -151,6 +156,8 @@ export class ResumeMetadataRefresher {
},
): Promise<void> {
const fillNullOnly = opts?.fillNullOnly === true;
const claudeStartedAt = fillNullOnly && this.claudeStartedAtForPass
? this.claudeStartedAtForPass(new ObservationPass()) : this.claudeProcessStartedAt;
// Snapshot-time discovery is SINGLE-ATTEMPT (mini-req 2): the 8-attempt
// 250ms-sleep loop exists for the adoption boundary racing a booting codex;
// on a recurring tick it multiplied `ps` spawns per seat, forever.
@@ -207,7 +214,7 @@ export class ResumeMetadataRefresher {
if (sidecar?.ok) {
const token = sidecar.data.session_id;
if (typeof token === "string" && token.trim().length > 0
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, this.claudeProcessStartedAt))) {
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, claudeStartedAt))) {
this.sessionRegistry.updateResumeToken(session.sessionId, "claude_id", token.trim(), "scrape");
}
}
@@ -231,7 +238,7 @@ export class ResumeMetadataRefresher {
if (sidecar?.ok) {
const derived = sidecar.data.session_id;
if (typeof derived === "string" && derived.trim().length > 0 && derived.trim() === session.resumeToken
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, this.claudeProcessStartedAt))) {
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, claudeStartedAt))) {
this.sessionRegistry.markResumeProbeResult(session.sessionId, "resumable");
}
}
@@ -93,7 +93,8 @@ export function parseSkillFrontmatter(content: string): ParseResult {
let parsed: unknown;
try {
parsed = parseYaml(fmText!);
// The delimiter's preceding LF is outside the capture; on CRLF files its CR is not.
parsed = parseYaml(fmText!.replace(/\r$/, ""));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `frontmatter YAML parse error: ${msg}` };
+4 -2
View File
@@ -116,7 +116,7 @@ import { setSelfHostId, setSelfHostIdSource } from "./domain/hosts/fanout-contra
import { UpCommandRouter } from "./domain/up-command-router.js";
import { RigTeardownOrchestrator } from "./domain/rig-teardown.js";
import { ResumeMetadataRefresher } from "./domain/resume-metadata-refresher.js";
import { observeClaudePaneStartedAt } from "./domain/native-process-lineage.js";
import { observeClaudePaneStartedAt, listNativeProcessesOrThrow, listNativeProcesses } from "./domain/native-process-lineage.js";
import { TranscriptStore } from "./domain/transcript-store.js";
import { resumeRunningTranscriptCaptures, wireTranscriptCaptureStops } from "./domain/transcript-capture.js";
import { probeSessionActivity, SessionTransport, inspectComposerInput, draftDeliveryAllows, readDraftDeliverySetting } from "./domain/session-transport.js";
@@ -1417,7 +1417,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// #421 — the pane's current Claude process start time; a sidecar sampled earlier is not this
// process's, so capture and null-fill skip it.
const claudeProcessStartedAt = (sessionName: string) => observeClaudePaneStartedAt({ target: sessionName, tmux: tmuxAdapter });
const resumeMetadataRefresher = new ResumeMetadataRefresher({ sessionRegistry, tmuxAdapter, contextUsageStore, claudeProcessStartedAt, codexHome: configuredCodexHome,
const resumeMetadataRefresher = new ResumeMetadataRefresher({ sessionRegistry, tmuxAdapter, contextUsageStore, claudeProcessStartedAt,
claudeStartedAtForPass: (pass) => (sessionName) => observeClaudePaneStartedAt({ target: sessionName, tmux: tmuxAdapter,
listProcesses: pass.lister(listNativeProcessesOrThrow, listNativeProcesses) }), codexHome: configuredCodexHome,
seatHome: (nodeId, runtime) => seatRuntimeHome(db, nodeId, runtime).home });
const claimService = new ClaimService({
db, rigRepo, sessionRegistry, discoveryRepo, eventBus, tmuxAdapter, transcriptStore,
@@ -9,12 +9,12 @@ import { createHash } from "node:crypto";
import { parse } from "smol-toml";
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { codexPluginContentHash, codexLegacyPluginTrustPath, planOwnedCodexContentApproval } from "../src/domain/codex-owned-plugin-trust.js";
import { codexPluginContentHash, codexLegacyPluginTrustPath, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../src/domain/codex-owned-plugin-trust.js";
const shipped = path.resolve(import.meta.dirname, "../assets/plugins/openrig-core");
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void) {
function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void, onConfigRead?: () => void) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "owned-codex-hooks-")); roots.push(root);
const plugin = path.join(root, "plugins/openrig-core"), home = path.join(root, "codex");
fs.cpSync(shipped, plugin, { recursive: true }); fs.mkdirSync(home);
@@ -23,7 +23,7 @@ function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void
const cache = path.join(home, "plugins/cache/openrig/openrig-core", version);
const calls: string[][] = [];
const adapter = () => new CodexRuntimeAdapter({ tmux: {} as TmuxAdapter, codexHome: home,
fsOps: { readFile: p => fs.readFileSync(p, "utf8"), writeFile: (p,c) => fs.writeFileSync(p,c), exists: fs.existsSync, mkdirp: p => fs.mkdirSync(p,{recursive:true}) },
fsOps: { readFile: p => { if (p === file) onConfigRead?.(); return fs.readFileSync(p, "utf8"); }, writeFile: (p,c) => fs.writeFileSync(p,c), exists: fs.existsSync, mkdirp: p => fs.mkdirSync(p,{recursive:true}) },
resolveCorePluginDir: () => plugin, codexSystemConfigDir: path.join(root, "etc"),
runCodexCli: async args => {
calls.push(args);
@@ -361,3 +361,126 @@ it("R2 oa fresh nonmatching bundle gains no declaration-only legacy approval", a
expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
expect(Object.keys(f.read().hooks?.state ?? {})).toHaveLength(0);
});
// #17: a home OpenRig granted for an earlier release whose Stop hook declaration differed (its timeout), recorded by
// OpenRig's own planner and writer, then upgraded to the current shipped bundle.
const stopKey = "openrig-core@openrig:hooks/codex.json:stop:0:0";
const recordPath = (f: ReturnType<typeof fixture>) => path.join(f.home, ".openrig-core-hook-trust.json");
function earlierReleaseGrant(f: ReturnType<typeof fixture>, extra = "") {
const earlier = path.join(path.dirname(f.home), "earlier-shipped");
fs.cpSync(shipped, earlier, { recursive: true });
const manifestPath = path.join(earlier, "hooks/codex.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
manifest.hooks.Stop[0].hooks[0].timeout += 5;
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const earlierCache = path.join(path.dirname(f.cache), "earlier");
fs.cpSync(earlier, earlierCache, { recursive: true });
const { computeCodexHookTrust } = createRequire(import.meta.url)(path.join(shipped, "hooks/scripts/codex-hook-trust.cjs"));
const content = codexPluginContentHash(earlier);
let config = `[marketplaces.openrig]\nsource = ${JSON.stringify(path.dirname(f.plugin))}\n\n[plugins."openrig-core@openrig"]\nenabled = true\n`;
for (const [event, groups] of Object.entries(manifest.hooks) as Array<[string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout: number }> }>]>) {
groups.forEach((group, groupIndex) => group.hooks.forEach((handler, handlerIndex) => {
const { key, hash } = computeCodexHookTrust(event, { ...handler, matcher: group.matcher, timeoutSec: handler.timeout,
keySource: "openrig-core@openrig:hooks/codex.json", groupIndex, handlerIndex });
config += `\n[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(hash)}\ntrusted_content_hash = ${JSON.stringify(content)}\n`;
}));
}
config += extra;
fs.writeFileSync(f.file, config);
const plan = planOwnedCodexContentApproval({ configPath: f.file, content: config,
layers: [{ name: "user config", path: f.file, state: "read", content: config }], shipped: earlier, installed: earlierCache, freshGrant: true });
expect(plan).toBeDefined();
recordOwnedCodexApproval(f.file, plan!.approval);
const stop = (parse(config) as any).hooks.state[stopKey];
return { config, record: fs.readFileSync(recordPath(f), "utf8"), earlierStop: stop.trusted_hash as string, earlierContent: content };
}
const outside = '\n[hooks.state."other-plugin@market:hooks/codex.json:stop:0:0"]\ntrusted_hash = "sha256:outside"\ntrusted_content_hash = "sha256:outside-content"\n\n\n';
it("the first launch after an upgrade that changed a declaration renews OpenRig's own recorded approvals and its record", async () => {
const f = fixture(), earlier = earlierReleaseGrant(f, outside);
expect(earlier.earlierStop).not.toBe((await freshInstallStates())[stopKey].trusted_hash);
expect(await f.adapter().ensureCodexCorePlugin()).toMatchObject({ outcome: "installed" });
const states = f.read().hooks.state;
const fresh = await freshInstallStates();
expect(Object.fromEntries(Object.entries(states).filter(([key]) => key.startsWith("openrig-core@openrig:")))).toEqual(fresh);
expect(states["other-plugin@market:hooks/codex.json:stop:0:0"]).toEqual({ trusted_hash: "sha256:outside", trusted_content_hash: "sha256:outside-content" });
const text = fs.readFileSync(f.file, "utf8");
expect(text.endsWith(outside)).toBe(true);
expect(text.replace(/^trusted(_content)?_hash = .*\n/gm, "")).toBe(earlier.config.replace(/^trusted(_content)?_hash = .*\n/gm, ""));
const record = JSON.parse(fs.readFileSync(recordPath(f), "utf8"));
expect(record).toEqual({ schema: 1, plugin: "openrig-core@openrig", contentHash: codexPluginContentHash(shipped),
declarations: Object.fromEntries(Object.entries(fresh).map(([key, state]: [string, any]) => [key, state.trusted_hash])) });
const renewed = fs.readFileSync(f.file, "utf8");
await f.adapter().ensureCodexCorePlugin();
expect(fs.readFileSync(f.file, "utf8")).toBe(renewed);
});
it("a restart after the new bundle was installed but before renewal renews the same way, without reinstalling", async () => {
const f = fixture(); earlierReleaseGrant(f);
fs.cpSync(f.plugin, f.cache, { recursive: true });
expect(await f.adapter().ensureCodexCorePlugin()).toMatchObject({ outcome: "current" });
expect(f.calls).toEqual([]);
expect(f.read().hooks.state).toEqual(await freshInstallStates());
});
it.each(["mismatched declaration", "mismatched content", "disabled", "other layer", "profile choice", "nested plugin entry", "record removed", "record of another plugin"])(
"a changed declaration keeps Codex's review for %s", async condition => {
const f = fixture(), earlier = earlierReleaseGrant(f);
const header = `[hooks.state.${JSON.stringify(stopKey)}]`;
const edit = (change: (text: string) => string) => fs.writeFileSync(f.file, change(fs.readFileSync(f.file, "utf8")));
if (condition === "mismatched declaration") edit(text => text.replace(earlier.earlierStop, `sha256:${"0".repeat(64)}`));
if (condition === "mismatched content") edit(text => text.replace(`${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\ntrusted_content_hash = "${earlier.earlierContent}"`,
`${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\ntrusted_content_hash = "sha256:${"0".repeat(64)}"`));
if (condition === "disabled") edit(text => text.replace(header, `${header}\nenabled = false`));
if (condition === "other layer") {
fs.mkdirSync(path.join(path.dirname(f.home), "etc"));
fs.writeFileSync(path.join(path.dirname(f.home), "etc/config.toml"), `${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\n`);
}
if (condition === "profile choice") edit(text => `${text}\n[profiles.special.hooks.state.${JSON.stringify(stopKey)}]\nenabled = false\n`);
if (condition === "nested plugin entry") edit(text => `${text}\n[profiles.special.plugins."openrig-core@openrig"]\nenabled = true\n`);
if (condition === "record removed") fs.rmSync(recordPath(f));
if (condition === "record of another plugin") fs.writeFileSync(recordPath(f), earlier.record.replace('"openrig-core@openrig"', '"other-plugin@market"'));
const before = fs.readFileSync(f.file, "utf8"), record = fs.existsSync(recordPath(f)) ? fs.readFileSync(recordPath(f), "utf8") : undefined;
expect(f.read().hooks.state[stopKey]).toBeDefined();
await f.adapter().ensureCodexCorePlugin();
expect(fs.readFileSync(f.file, "utf8")).toBe(before);
expect(fs.existsSync(recordPath(f)) ? fs.readFileSync(recordPath(f), "utf8") : undefined).toBe(record);
});
it("the planner names exactly the changed declaration to rewrite, from the earlier grant's record", async () => {
const f = fixture(), earlier = earlierReleaseGrant(f);
fs.cpSync(f.plugin, f.cache, { recursive: true });
const plan = planOwnedCodexContentApproval({ configPath: f.file, content: earlier.config,
layers: [{ name: "user config", path: f.file, state: "read", content: earlier.config }], shipped, installed: f.cache });
expect(plan?.trustedHashes).toEqual({ [stopKey]: (await freshInstallStates())[stopKey].trusted_hash });
expect(Object.values(plan!.hashes)).toEqual(Array(6).fill(codexPluginContentHash(shipped)));
});
// Codex records a person's decline of another OpenRig hook after the planner fingerprinted the installed bundle and
// before OpenRig writes: on the first read of config.toml that follows, in the user config or in the system layer.
it.each(["user config", "other layer"])("a choice recorded in the %s while the renewal is being prepared wins: no renewal and no new record", async where => {
const header = '[hooks.state."openrig-core@openrig:hooks/codex.json:session_start:0:0"]';
const decline = (text: string) => text.replace(header, `${header}\nenabled = false`);
let planned = false, injected = false;
const f: ReturnType<typeof fixture> = fixture("", false, undefined, () => {
if (!planned || injected) return;
injected = true;
if (where === "user config") fs.writeFileSync(f.file, decline(fs.readFileSync(f.file, "utf8")));
else {
fs.mkdirSync(path.join(path.dirname(f.home), "etc"), { recursive: true });
fs.writeFileSync(path.join(path.dirname(f.home), "etc/config.toml"), `${header}\nenabled = false\n`);
}
});
const earlier = earlierReleaseGrant(f);
const readdir = fs.readdirSync;
const spy = vi.spyOn(fs, "readdirSync").mockImplementation(((dir: fs.PathLike, ...rest: unknown[]) => {
if (String(dir).startsWith(f.cache)) planned = true;
return (readdir as (...args: unknown[]) => unknown)(dir, ...rest);
}) as typeof fs.readdirSync);
try {
await f.adapter().ensureCodexCorePlugin();
} finally { spy.mockRestore(); }
expect(injected).toBe(true);
expect(fs.readFileSync(f.file, "utf8")).toBe(where === "user config" ? decline(earlier.config) : earlier.config);
expect(fs.readFileSync(recordPath(f), "utf8")).toBe(earlier.record);
});
@@ -48,6 +48,24 @@ const cases = [
] as const;
describe("shared restore preconditions", () => {
it("reports a saved profile cleared on the current Codex seat through restore-check", () => {
const snapshot = data();
snapshot.nodes[0]!.runtime = "codex";
snapshot.nodes[0]!.codexConfigProfile = "limited";
const current = { ...node(), runtime: "codex", codexConfigProfile: null };
const service = new RestoreCheckService(deps({
getNodeInventory: () => [current],
getRestoreInputs: () => ({ snapshot: { id: "snapshot-1", kind: "manual", data: snapshot }, servicesRecord: null }),
}));
const check = service.check({ noHooks: true, noQueue: true }).checks.find((entry) => entry.check === "rig.test-rig.restore-preconditions");
expect(check?.status).toBe("yellow");
expect(check?.evidence).toContain("had Codex profile limited in this snapshot");
expect(check?.evidence).toContain("does not prove what a prior process enforced");
for (const profiles of [{ seat: "limited" }, {}, { seat: "other" }]) {
expect(validatePreRestore(snapshot, { currentRuntimes: { seat: "codex" }, currentCodexProfiles: profiles }).warnings).toEqual([]);
}
});
it.each(cases)("preserves the exact refusal for missing %s", (missing, code, message, remediation) => {
const result = validatePreRestore(data(), { fsOps: { exists: (path) => path !== missing }, servicesRecord: services() });
expect(result).toEqual({
@@ -5,6 +5,7 @@ import Database from "better-sqlite3";
import { describe, it, expect, vi } from "vitest";
import { ResumeMetadataRefresher } from "../src/domain/resume-metadata-refresher.js";
import { deriveResumeToken } from "../src/domain/resume-token-capture.js";
import { observeClaudePaneStartedAt } from "../src/domain/native-process-lineage.js";
import { seedCodexThreads } from "./helpers/codex-state.js";
import type { SessionRegistry } from "../src/domain/session-registry.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
@@ -55,6 +56,30 @@ function createCodexLogsDb(homeDir: string, pid: number, threadId: string, dbNam
}
describe("ResumeMetadataRefresher", () => {
it("shares snapshot observations across Claude seats while retaining two samples per proof", async () => {
const stamp = vi.fn();
const sessionRegistry = { markResumeProbeResult: stamp } as unknown as SessionRegistry;
const start = "Sun Oct 11 12:00:00 2026";
const later = "Sun Oct 11 12:01:00 2026";
const tmux = mockTmux({ getPanePid: vi.fn(async (name: string) => Number(name)) });
const rows = [10, 20, 30].flatMap(pid => [
{ pid, ppid: 1, pgid: pid, tpgid: pid + 1, executableName: "bash", command: "bash", startedAt: start },
{ pid: pid + 1, ppid: pid, pgid: pid + 1, tpgid: pid + 1, executableName: "claude", command: "claude", startedAt: pid === 30 ? later : start },
]);
const list = vi.fn(async () => rows);
const refresher = new ResumeMetadataRefresher({ sessionRegistry, tmuxAdapter: tmux,
contextUsageStore: { readSidecar: () => ({ ok: true as const, data: { session_id: "token", sampled_at: new Date(Date.parse(start) + 1000).toISOString() } }) },
claudeStartedAtForPass: pass => name => observeClaudePaneStartedAt({ target: name, tmux, listProcesses: pass.lister(list) }),
});
const seats = [10, 20, 30].map(pid => ({ sessionId: String(pid), sessionName: String(pid), runtime: "claude-code", resumeType: "claude_id", resumeToken: "token" }));
await refresher.refresh(seats, { fillNullOnly: true });
expect(stamp).toHaveBeenCalledTimes(2);
expect(stamp).not.toHaveBeenCalledWith("30", "resumable");
expect(list).toHaveBeenCalledTimes(4);
await refresher.refresh(seats, { fillNullOnly: true });
expect(list).toHaveBeenCalledTimes(8);
});
it("PERF FIX 2 — the periodic snapshot refresh is SINGLE-ATTEMPT per codex seat: no x8 retry burst under failing discovery", async () => {
const sessionRegistry = {
isForkClaimingHeldToken: vi.fn(() => false),
@@ -914,6 +914,24 @@ describe("plugin skills in the managed loadout", () => {
expect(resolvePluginSkills({ pluginId: "core", pluginRoot: plugin, runtime: "claude-code", pluginType: "codex" }).entries).toEqual([]);
});
it.each(["claude-code", "codex"] as const)("loads a CRLF plugin skill with realistic multiline frontmatter for %s", (runtime) => {
const f = fixture([]);
const plugin = writePlugin(f.root, [".claude-plugin", ".codex-plugin"], ["applying-a-permission-policy"]);
const file = join(plugin, "skills", "applying-a-permission-policy", "SKILL.md");
const original = readFileSync(new URL("../assets/plugins/openrig-core/skills/applying-a-permission-policy/SKILL.md", import.meta.url), "utf8").replace(/\r\n/g, "\n");
const resolve = () => resolvePluginSkills({ pluginId: "core", pluginRoot: plugin, runtime });
writeFileSync(file, original, "utf8");
expect(resolve().entries.map((entry) => entry.id)).toEqual(["applying-a-permission-policy"]);
writeFileSync(file, original.replace(/\n/g, "\r\n"), "utf8");
const crlf = resolve();
expect(crlf.warnings).toEqual([]);
expect(crlf.entries.map((entry) => entry.id)).toEqual(["applying-a-permission-policy"]);
const result = reconcileSkillLoadout({ loadout: loadoutOf(crlf.entries, f.root), runtime, cwd: f.project, topologyOwner: "seat-a", apply: true });
expect(result).toMatchObject({ ok: true, applied: true });
const harness = runtime === "codex" ? ".agents" : ".claude";
expect(readFileSync(join(f.project, harness, "skills", "applying-a-permission-policy", "SKILL.md"), "utf8")).toBe(original.replace(/\n/g, "\r\n"));
});
it("skips a plugin skill it cannot copy exactly and says why", () => {
const f = fixture([]);
const plugin = writePlugin(f.root, [".claude-plugin"], ["good", "linked"]);
+14
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
postWebhook,
postChatMessage,
callWebApi,
downloadPrivateFile,
verifyScopes,
@@ -9,6 +10,19 @@ import {
type FetchImpl,
} from "../src/domain/gateway/slack/slack-api.js";
it("preserves preview controls and thread on a rate-limit retry", async () => {
const bodies: unknown[] = [];
const fetchImpl: FetchImpl = async (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return bodies.length === 1
? new Response("rate limited", { status: 429, headers: { "Retry-After": "1" } })
: new Response(JSON.stringify({ ok: true, ts: "2.0" }), { status: 200 });
};
const result = await postChatMessage("synthetic", { channel: "C", text: "link", thread_ts: "1.0", unfurl_links: false, unfurl_media: false }, fetchImpl, 1000, async () => {});
expect(result.ok).toBe(true);
expect(bodies).toEqual([expect.objectContaining({ thread_ts: "1.0", unfurl_links: false, unfurl_media: false }), bodies[0]]);
});
// Fake Slack: routes by URL, returns real Response objects (headers + json).
function fakeSlack(spec: {
webhookStatus?: number;
@@ -14,6 +14,15 @@ describe("Slice-11 config — first-class + honest unconfigured (item 5)", () =>
fs.rmSync(home, { recursive: true, force: true });
});
it("keeps human-row previews off by default and accepts an explicit choice", () => {
expect(staticReadiness(loadConfig(home), true, true).find(r => r.label === "human-row previews")?.detail).toBe("off");
saveConfig({ ...DEFAULT_CONFIG, unfurl: "on" }, home);
expect(loadConfig(home).unfurl).toBe("on");
expect(staticReadiness(loadConfig(home), true, true).find(r => r.label === "human-row previews")?.detail).toBe("on");
expect(() => saveConfig({ ...DEFAULT_CONFIG, unfurl: "bad" } as never, home)).toThrow(/unfurl must be on or off/);
expect(loadConfig(home).unfurl).toBe("on");
});
it("loadConfig returns defaults when absent (no throw); inbound-dest first-class default", () => {
expect(configFileExists(home)).toBe(false);
const cfg = loadConfig(home);
@@ -71,7 +71,7 @@ function capturingFetch(status = 200): { fetchImpl: FetchImpl; calls: { url: str
}
/** Compose the REAL subsystem outbound path over a temp home: driver → wire → delivery. */
function composeOutbound(home: string, port: OutboundQueuePort, fetchImpl: FetchImpl, fsx = memFs()) {
function composeOutbound(home: string, port: OutboundQueuePort, fetchImpl: FetchImpl, fsx = memFs(), unfurl?: "on" | "off", thread?: string) {
const outboundSeen = new SeenStore("/s/outbound-seen.jsonl", fsx, clock);
const delivered = new SeenStore("/s/delivered.jsonl", fsx, clock);
const attempted = new SeenStore("/s/attempted.jsonl", fsx, clock);
@@ -80,6 +80,8 @@ function composeOutbound(home: string, port: OutboundQueuePort, fetchImpl: Fetch
botToken: "xoxb-EXAMPLE-fake",
channel: "C-TEST",
sourceLabel: "vm",
unfurl,
resolveThreadTs: () => thread,
fetchImpl,
delivered,
attempted,
@@ -115,6 +117,15 @@ describe("S10 cutover — outbound classes ride the SUBSYSTEM path (slice-11 ite
expect(calls[0]!.url).toBe("https://slack.com/api/chat.postMessage");
expect(calls.some((c) => c.url.includes("hooks.slack.com"))).toBe(false);
expect(calls[0]!.body.channel).toBe("C-TEST");
expect(calls[0]!.body).toMatchObject({ unfurl_links: false, unfurl_media: false });
});
it("applies enabled previews to a threaded human row", async () => {
const { fetchImpl, calls } = capturingFetch(200);
const { driver } = composeOutbound(home, fakePort([ALERT]), fetchImpl, memFs(), "on", "100.1");
await driver.sweepOnce();
await flush();
expect(calls[0]!.body).toMatchObject({ thread_ts: "100.1", unfurl_links: true, unfurl_media: true });
});
it("item 2 idempotent: a second sweep posts nothing", async () => {
+4 -2
View File
@@ -1,8 +1,10 @@
# Delivery conformance suite
This kit proves OpenRig's typed delivery against the real Claude Code and Codex terminal programs. It covers 25 cases, from idle and busy seats to drafts, dialogs, copy mode, ordering, `/clear`, compaction and restart. Quality owns the expected result for each case. This directory holds the code that drives the cases.
This kit proves OpenRig's typed delivery against the real Claude Code and Codex terminal programs. The delivery measurement case list (kept outside this repository) plans 25 cases, from idle and busy seats to drafts, dialogs, copy mode, ordering, `/clear`, compaction and restart. Quality owns the expected result for each case. This directory holds the code that drives the cases.
**Built so far:** the stand-in model servers, the case harness, the release-run entry point, and the first cases (D01 idle, D02 busy, D03 and D04 drafts, D08 copy mode, D09 order, D15 multi-line paste) on both programs. Dialogs and guards, lifecycle, and fault injection come next.
**Registered and runnable today:** only the cases `cases/index.mjs` lists, on both programs. Those are D01 idle, D02 busy, D03 and D04 drafts, D05 permission prompt, D06 other dialog (the model picker), D08 copy mode, D09 order, D15 multi-line paste, and issue #3076's two late-gap cells (`R3076-notice`, `R3076-send`). `run.mjs` runs nothing else, and `--cases` refuses an id that isn't registered (`no case D12 in this suite yet`).
**Planned, not fixtures yet:** `cases/expected.yaml` already states the expected result for D07, D14, D18 and D19, and its `later` list names D10, D12 (a managed `/compact` with a typed draft), D16 and D25. Compaction and restart have no fixture yet.
## Running a release
+1 -1
View File
@@ -93,7 +93,7 @@
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
"openrig-skills/SKILL.md": "6d7bf933ac40531f11757a31a93d26dcbda617f1b85f48a21865b3a5c5e2ddaa",
"openrig-user/SKILL.md": "6176761014c9ecd61675cb47d4de991de6dd126a92be7e06c3ef31aa0573f82e",
"openrig-user/SKILL.md": "ef22c218348bdf06d0f56d9de86a3ac8a5f954eae161a212d1e1010a19e464b9",
"orienting-to-an-inherited-seat/CHANGELOG.md": "a91f117e88034d8bae9b723916235e858fbc151983cc7c32784f5d4ba186274a",
"orienting-to-an-inherited-seat/SKILL.md": "278c554b3bb4c481f2928499f02442d947ffd4751d60f830610b652e24cb0468",
"queue-handoff/SKILL.md": "96251f6830006bb9d8ede2e5826ab936df4a9fb91090187a23ccaf860ab76336",