feat(identity): A1 refuse-loud — CLI refuses unattributable sends at the seat boundary

Identity/audit family atom 1 (design dc3a0719). A `rig send`/`broadcast` is
attributable-only: an unresolvable seat (no OPENRIG_SESSION_NAME/RIGGED_SESSION_NAME;
--from deprecated + ignored) REFUSES at the seat boundary — nothing rendered, nothing
dispatched — mirroring the shipped daemon 401 unattributable_sender (P21-I4).

Because the boundary refuses, the CLI can never render the <unknown sender> marker, so
the two CLI fallback definitions are DELETED (not centralized): the daemon's
pane-envelope.ts is now the SOLE definition in the tree (canonicity by structure, no
import). queue-repository.ts:553 is a comment reword only (the one non-refusable queue
nudge legitimately keeps the daemon-side fallback).

wrapSendBody now REQUIRES a resolved sender; it intentionally diverges from its
byte-identical twin wrapPaneEnvelope ONLY at the undefined input (the CLI can no longer
emit it). Lockstep comment updated.

- NEW packages/cli/src/sender-identity.ts: resolveSenderSession + requireAttributableSender
- send.ts/broadcast.ts: seat-boundary guard; seatSender threaded through every dispatch path
- tests: refuse RED->GREEN, resolvable-send, negative-control grep-guard (exactly ONE
  literal definition in src); 3 tests that pinned the old marker render rewritten; seat
  stubs added to the four dispatch-invoking test files

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-08 00:01:28 +00:00
1 parent 8350fd1026
commit 6634b28522
10 files changed
+261 -120

No files matched your search

+22 -27
View File
@@ -8,7 +8,7 @@ import { loadHostRegistry, resolveHost, hostDisplayTarget } from "../host-regist
import { emitCrossHostError, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
import { runRemoteHttpOp } from "../remote-host-ops.js";
import { resolveContextRef, walkSizedWarning } from "../context-resolve.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { requireAttributableSender } from "../sender-identity.js";
/**
* OPR.0.4.6.MH4 C3 — the cross-host broadcast deadline, named at the call
@@ -18,20 +18,6 @@ import { readOpenRigEnv } from "../openrig-compat.js";
*/
const BROADCAST_REMOTE_TIMEOUT_MS = 30_000;
/**
* The enveloped-fan-out MARKER for a broadcast body — ONE origin, called by BOTH the local and cross-host
* paths. Its PRESENCE tells the daemon /broadcast to WRAP each recipient (the anti-storm scale header); its
* VALUE is IGNORED by the daemon, which derives the rendered From: from the transport identity
* (X-OpenRig-Session, stamped LAST on every DaemonClient request). Extracting it here keeps the session-less
* fall-open literal ("<unknown sender>") to a SINGLE origin — never duplicated across the host boundary.
* (When the seat env is unset, no header is stamped and the daemon refuses the marked-but-unattributed
* send 401 — the cross-host path inherits that refuse-loud guard for free, no local check needed.)
*/
function resolveEnvelopeMarker(): string {
const sender = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
return sender && sender.trim().length > 0 ? sender : "<unknown sender>";
}
export interface BroadcastDeps extends StatusDeps {
/** Test seam: inject a registry loader so no real ~/.openrig is touched. */
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
@@ -91,13 +77,21 @@ selection, not the agent@rig@host sugar.`)
return;
}
// A1 REFUSE-LOUD (identity/audit family) — the seat boundary. A broadcast is attributable-only: if
// the seat identity is unresolvable, REFUSE here before either dispatch path (cross-host or local) —
// nothing on the wire. Replaces the prior session-less `<unknown sender>` fall-open marker (which
// relied on the daemon's downstream 401): the CLI now refuses at the boundary, so no unattributable
// broadcast is ever dispatched. Mirrors `rig send`'s guard and the daemon 401 unattributable_sender.
const seatSender = requireAttributableSender();
if (seatSender === null) return;
// --- Cross-host path (CLI-direct POST to the remote daemon's shipped
// /api/transport/broadcast; its own fan-out engine resolves the
// TargetSpec on ITS topology; daemon untouched). ---
if (opts.host) {
// text is validated-defined here: --context is rejected with --host, and
// the (no text && no context) guard above requires it on the host path.
await runCrossHostBroadcast(opts.host, text!, opts, deps);
await runCrossHostBroadcast(opts.host, text!, opts, deps, seatSender);
return;
}
@@ -130,11 +124,11 @@ selection, not the agent@rig@host sugar.`)
// Send/broadcast header (ruling 03c35295): identify the broadcasting seat so the daemon fan-out
// WRAPS each recipient with the scale header ("broadcast to <rig> (N seats)" / topology) instead
// of delivering raw — a recipient tells it was a broadcast header-alone (the anti-storm teeth).
// Session-less falls open to the <unknown sender> WRAP (symmetric with the fan-out send path's
// SENDER_FALLBACK) so a session-less broadcast still carries the scale — no session-less storm.
// (review-r1 flagged the prior raw fall-open as a founder call; closed symmetric with send per the
// founder root-cause intent — trivially revert to raw-when-session-less if the founder prefers.)
body.envelopeSender = resolveEnvelopeMarker();
// A1: `seatSender` is the RESOLVED broadcasting seat (the seat-boundary guard refused an
// unattributable broadcast), so the marker is always the real sender — never a session-less
// fall-open literal. Its VALUE is ignored daemon-side (the fan-out derives From: from the transport
// header); its PRESENCE is the anti-storm signal to WRAP each recipient with the scale header.
body.envelopeSender = seatSender;
const res = await client.post<Record<string, unknown>>("/api/transport/broadcast", body);
@@ -173,6 +167,7 @@ async function runCrossHostBroadcast(
text: string,
opts: { rig?: string; pod?: string; force?: boolean; json?: boolean },
deps: BroadcastDeps,
seatSender: string = "",
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const registry = loader();
@@ -190,12 +185,12 @@ async function runCrossHostBroadcast(
const body: Record<string, unknown> = { text, force: opts.force };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
// P21 cross-host broadcast fix: ADD the enveloped-fan-out marker (this rebuild dropped it, so the remote
// rendered RAW while the local path — same helper — wrapped). The DaemonClient auto-stamps
// X-OpenRig-Session=origin env on this POST (client.ts:171, P18 chokepoint), so the remote derives the
// From: from the transport; the marker VALUE is ignored. No extra-headers stamp (that would be a second
// origin of an already-stamped header).
body.envelopeSender = resolveEnvelopeMarker();
// P21 cross-host broadcast fix + A1: ADD the enveloped-fan-out marker (a rebuild once dropped it, so the
// remote rendered RAW while the local path wrapped). `seatSender` is the RESOLVED origin seat (the
// seat-boundary guard refused otherwise). The DaemonClient auto-stamps X-OpenRig-Session=origin env on
// this POST (client.ts:171, P18 chokepoint), so the remote derives From: from the transport; the marker
// VALUE is ignored. No extra-headers stamp (that would be a second origin of an already-stamped header).
body.envelopeSender = seatSender;
const result = await runRemoteHttpOp(hostId, "POST", "/api/transport/broadcast", body, deps, {
timeoutMs: BROADCAST_REMOTE_TIMEOUT_MS,
+49 -35
View File
@@ -10,12 +10,11 @@ import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from
import { resolveCrossHostTarget } from "../cross-host-target.js";
import { runRemoteHttpOp } from "../remote-host-ops.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { requireAttributableSender } from "../sender-identity.js";
import { resolveContextRef, walkSizedWarning } from "../context-resolve.js";
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
const SENDER_FALLBACK = "<unknown sender>";
/**
* Wrap a `rig send` body with an email-style envelope so the recipient
* pane has both the sender's identity and a copy-pasteable reply hint.
@@ -23,12 +22,15 @@ const SENDER_FALLBACK = "<unknown sender>";
* runs the same command, and double-wrapping would nest envelopes.
*
* V0.3.1 slice 23 parity contract: `packages/daemon/src/lib/pane-envelope.ts`
* exports `wrapPaneEnvelope` with BYTE-IDENTICAL output for the same
* inputs. The two implementations live in separate packages because
* cli + daemon don't cross-import today. Daemon-side nudges from
* `rig queue create|handoff` use the daemon helper so queue nudges
* render the same envelope as peer-to-peer `rig send`. If you update
* this function, update wrapPaneEnvelope in lockstep.
* exports `wrapPaneEnvelope` with BYTE-IDENTICAL output for every RESOLVED
* sender — the only input domain this CLI twin produces. A1 (identity/audit
* family) refuses an unattributable `rig send`/`broadcast` at the seat boundary,
* so this twin now REQUIRES a resolved sender and drops the `<unknown sender>`
* fallback; the daemon twin RETAINS it for the non-refusable queue-nudge sender.
* The two intentionally diverge ONLY at the undefined sender the CLI can no
* longer emit. The implementations live in separate packages because cli +
* daemon don't cross-import today. If you update this function, update
* wrapPaneEnvelope in lockstep (for resolved senders — the shared domain).
*/
/** Send/broadcast header envelope metadata (ruling 03c35295) — the BYTE-IDENTICAL twin of the daemon's
* pane-envelope EnvelopeMeta. Envelope = machine truth; rendered header = projection. */
@@ -61,25 +63,27 @@ export function renderShortStamp(stampISO: string): string {
}
export function wrapSendBody(
sender: string | undefined,
sender: string,
recipient: string,
body: string,
selfHostId?: string | null,
meta?: EnvelopeMeta,
): string {
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
// 51-09 increment 3 (ruling cb19867f Q2 always-suffix + 2e1b737f C1 fail-open):
// when the origin's boot-reconciled self-host id is known, the sender renders
// as the <member>@<rig>@<selfHostId> triple ALWAYS (local included) so the
// signature is self-describing and the reply hint is verbatim-usable. When it
// is absent (daemon pre-reconcile / unknown sender), fall open to today's exact
// two-part form — no new failure mode. A sender that ALREADY carries a host (a
// --from relay passing the ORIGIN's full triple) is preserved verbatim, never
// A1 REFUSE-LOUD: `sender` is a RESOLVED seat identity — the seat-boundary guard
// (sender-identity.ts :: requireAttributableSender) refuses an unattributable send BEFORE this
// renders, so there is NO `<unknown sender>` fallback branch here (the CLI can never put that marker
// on the wire). The daemon twin wrapPaneEnvelope keeps the fallback for its non-refusable nudge; the
// twins are byte-identical for every resolved sender and diverge only at the undefined input above.
//
// 51-09 increment 3 (ruling cb19867f Q2 always-suffix): when the origin's boot-reconciled self-host
// id is known, the sender renders as the <member>@<rig>@<selfHostId> triple ALWAYS (local included)
// so the signature is self-describing and the reply hint is verbatim-usable. A sender that ALREADY
// carries a host (a --from relay passing the ORIGIN's full triple) is preserved verbatim, never
// re-stamped with THIS host's id (which would forge the origin).
const senderTriple =
selfHostId && selfHostId.length > 0 && senderLabel !== SENDER_FALLBACK && senderLabel.split("@").length < 3
? `${senderLabel}@${selfHostId}`
: senderLabel;
selfHostId && selfHostId.length > 0 && sender.split("@").length < 3
? `${sender}@${selfHostId}`
: sender;
const header = [`From: ${senderTriple}`, renderToLine(recipient, meta?.scope)];
if (meta?.stampISO) {
// GHOST-STAGE (g): the sender's occupant generation rides the Sent: line as a short suffix
@@ -93,10 +97,6 @@ export function wrapSendBody(
return [...header, "---", body, "---", `↩ Reply: rig send ${senderTriple} "..."`].join("\n");
}
function resolveSenderSession(): string | undefined {
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
}
/**
* 1b45cf21 — remediation after an ACTUAL transport failure, in the repo's
* fact / consequence / action shape (`daemon-lifecycle.ts:61-73`).
@@ -322,6 +322,14 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
const deps = getDeps();
// A1 REFUSE-LOUD (identity/audit family) — the seat boundary. A `rig send` is attributable-only:
// if the seat identity is unresolvable (no OPENRIG_SESSION_NAME/RIGGED_SESSION_NAME; --from is
// deprecated + ignored), REFUSE here before ANY dispatch path (fan-out, cross-host, single-seat) —
// nothing rendered, nothing on the wire. Mirrors the shipped daemon 401 unattributable_sender
// (routes/transport.ts, P21-I4): both surfaces record only a DERIVED sender, never a body claim.
const seatSender = requireAttributableSender();
if (seatSender === null) return;
if (isFanOut) {
// With a targeting flag the FIRST positional IS the message; a second positional (or a
// bare seat name) means the caller mixed a single-seat and a fan-out target — reject.
@@ -346,7 +354,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
process.exitCode = 1;
return;
}
await runFanOutSend({ toList, pod: opts.pod, rig: opts.rig, message, opts, deps });
await runFanOutSend({ toList, pod: opts.pod, rig: opts.rig, message, opts, deps, seatSender });
return;
}
@@ -400,7 +408,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
// text is validated-defined here: --context is rejected with --host (above,
// for both explicit and sugar forms), and the single-seat (no text && no
// context) guard requires it on this path.
await runCrossHostSend(opts.host, session, text!, opts, deps, waitForIdleMs, crossHostHint, selfHostId);
await runCrossHostSend(opts.host, session, text!, opts, deps, waitForIdleMs, crossHostHint, selfHostId, seatSender);
return;
}
@@ -414,7 +422,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
// --from. --from is deprecated + ignored here (its cross-host origin-carry is superseded by the
// relay re-stamping from its authenticated context). Env == the X-OpenRig-Session the DaemonClient
// stamps, so the client-side From: + the body actor stay consistent with the daemon's derived actor.
const senderSession = resolveSenderSession();
const senderSession = seatSender; // A1: the seat-boundary guard above already refused if unresolvable.
// --raw (and --dangerously-interact, which implies it) send EXACT text with no messaging envelope.
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
@@ -509,6 +517,7 @@ async function runCrossHostSend(
waitForIdleMs?: number,
hint?: string,
selfHostId?: string,
seatSender: string = "",
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
@@ -531,7 +540,7 @@ async function runCrossHostSend(
// byte-verbatim for ssh hosts (transport is dictated by the host entry —
// ssh XOR http, never a fallback).
if (host.transport === "http") {
await runHttpHostSend(host, session, text, opts, deps, waitForIdleMs, hint, selfHostId);
await runHttpHostSend(host, session, text, opts, deps, waitForIdleMs, hint, selfHostId, seatSender);
return;
}
@@ -553,7 +562,7 @@ async function runCrossHostSend(
// From:/actor from ITS X-OpenRig-Session header, so the proper cross-host re-stamp is to set the
// remote env (OPENRIG_SESSION_NAME=originTriple) rather than the --from argv below — the argv is a
// deprecated no-op on the shipped remote pending that runner env-plumbing (flagged to orch).
const originSender = resolveSenderSession();
const originSender = seatSender; // A1: resolved at the seat boundary (guard refused otherwise).
// 51-09 increment 3: carry the ORIGIN's full <member>@<rig>@<selfHostId> triple
// so the remote envelope names the ORIGIN host, not the relay's. Append this
// host's self-id only when the origin isn't already a triple (a --from already
@@ -614,10 +623,11 @@ async function runHttpHostSend(
waitForIdleMs?: number,
hint?: string,
selfHostId?: string,
seatSender: string = "",
): Promise<void> {
// P21 I4: cross-host send — the origin is the LOCAL seat env (this daemon's authenticated context per
// rail 2), never a caller --from string. The remote renders From: = env; --from is deprecated + ignored.
const senderSession = resolveSenderSession();
const senderSession = seatSender; // A1: resolved at the seat boundary (guard refused otherwise).
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
const outboundText = raw ? text : wrapSendBody(senderSession, session, text, selfHostId, { stampISO: new Date().toISOString() });
@@ -680,8 +690,10 @@ async function runFanOutSend(params: {
// (single-seat + both cross-host paths always honored it).
opts: { verify?: boolean; force?: boolean; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; from?: string; json?: boolean };
deps: SendDeps;
/** A1: the RESOLVED seat identity from the seat-boundary guard (never undefined here). */
seatSender: string;
}): Promise<void> {
const { toList, pod, rig, message, opts, deps } = params;
const { toList, pod, rig, message, opts, deps, seatSender } = params;
// qitem-c113bd41 — same advisory-probe/transport-authoritative contract
// as the single-seat path, including ff13bcdf's lazy probe (see
@@ -691,7 +703,7 @@ async function runFanOutSend(params: {
// DERIVED daemon-side from the transport header — the body envelopeSender is now only the enveloped
// MARKER (its presence, not its value). So the origin is the seat env, never --from (deprecated +
// ignored). Env == the stamped X-OpenRig-Session, so the body actor matches the derived actor.
const senderSession = resolveSenderSession();
const senderSession = seatSender; // A1: resolved at the seat boundary (guard refused otherwise).
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
const body: Record<string, unknown> = {
@@ -705,10 +717,12 @@ async function runFanOutSend(params: {
if (toList) body.sessions = toList;
else if (pod) body.pod = pod;
else if (rig) body.rig = rig;
// Per-recipient envelope daemon-side unless raw/danger. Always pass a truthy sender (falling
// back to the same "<unknown sender>" marker single-send uses) so the wrap fires for parity.
// Per-recipient envelope daemon-side unless raw/danger. `senderSession` is the RESOLVED seat (the
// seat-boundary guard refused an unattributable send), so the marker is always the real sender —
// never the deleted `<unknown sender>` fallback. Its VALUE is ignored daemon-side (I4); presence
// signals the enveloped fan-out so the daemon derives From: from the transport header.
if (!raw) {
body.envelopeSender = senderSession && senderSession.trim().length > 0 ? senderSession : SENDER_FALLBACK;
body.envelopeSender = senderSession;
}
let res: { status: number; data: Record<string, unknown> };
+40
View File
@@ -0,0 +1,40 @@
import { readOpenRigEnv } from "./openrig-compat.js";
/**
* 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.
*/
export function resolveSenderSession(): string | undefined {
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
}
/**
* A1 REFUSE-LOUD (identity/audit family). The CLI channel of record records only
* a DERIVED sender. An unattributable dispatch — no resolvable seat identity —
* must REFUSE at the seat boundary rather than render a fallback marker.
*
* This is the CLI-side twin of the shipped daemon precedent
* (`routes/transport.ts` → `401 unattributable_sender`, P21-I4): both surfaces
* teach the same lesson — *the sender derives from an authenticated context,
* never a request-body claim*. Refusing HERE (before dispatch) means the CLI can
* never put an unattributable marker on the wire, which is why the CLI-side
* `<unknown sender>` fallbacks are DELETED rather than centralized: the seat
* boundary makes them unreachable by construction.
*
* Returns the resolved seat identity, or `null` AFTER printing the named error
* and setting a non-zero exit code. Callers MUST `return` on `null` — nothing is
* rendered and nothing is dispatched.
*/
export function requireAttributableSender(): string | null {
const sender = resolveSenderSession();
if (sender && sender.trim().length > 0) return sender;
console.error(
"Refusing to send: no resolvable seat identity (unattributable_sender). The sender derives from " +
"OPENRIG_SESSION_NAME (the managed seat), never a request-body claim; --from is deprecated and " +
"ignored. Nothing was sent.",
);
process.exitCode = 1;
return null;
}
+40 -34
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { broadcastCommand } from "../src/commands/broadcast.js";
@@ -70,6 +70,16 @@ describe("Broadcast CLI", () => {
afterAll(() => { server.close(); });
// A1: a broadcast is attributable-only — the seat-boundary guard refuses when the seat env is
// unresolvable. Establish a resolvable seat for every test so the dispatch paths run; the
// refuse-loud test below overrides it to empty. (Hermetic-gate default is env-UNSET, so without
// this every broadcast test would refuse.) Restored by afterEach so no stub leaks across tests.
beforeEach(() => {
vi.stubEnv("OPENRIG_SESSION_NAME", "broadcaster@my-rig");
vi.stubEnv("RIGGED_SESSION_NAME", "");
});
afterEach(() => { vi.unstubAllEnvs(); });
function makeCmd(): Command {
const prog = new Command(); prog.exitOverride();
prog.addCommand(broadcastCommand(runningDeps(port)));
@@ -146,41 +156,37 @@ describe("Broadcast CLI", () => {
}
it("P21: cross-host broadcast CARRIES the enveloped marker (was dropped → remote rendered raw); value = the seat, but the daemon derives the From: from the auto-stamped X-OpenRig-Session", async () => {
const saved = process.env["OPENRIG_SESSION_NAME"];
process.env["OPENRIG_SESSION_NAME"] = "orch@rig-a";
try {
const { posts, deps } = crossHostBcast();
const prog = new Command(); prog.exitOverride();
prog.addCommand(broadcastCommand(deps));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "broadcast", "--host", "vm-a", "--rig", "my-rig", "hi team"]);
});
const bcast = posts.find((p) => p.path.includes("/api/transport/broadcast"));
expect(bcast).toBeDefined();
expect(bcast!.body["envelopeSender"]).toBe("orch@rig-a"); // marker PRESENT (the fix) — value ignored daemon-side
expect(bcast!.body["text"]).toBe("hi team");
} finally {
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"]; else process.env["OPENRIG_SESSION_NAME"] = saved;
}
vi.stubEnv("OPENRIG_SESSION_NAME", "orch@rig-a");
vi.stubEnv("RIGGED_SESSION_NAME", "");
const { posts, deps } = crossHostBcast();
const prog = new Command(); prog.exitOverride();
prog.addCommand(broadcastCommand(deps));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "broadcast", "--host", "vm-a", "--rig", "my-rig", "hi team"]);
});
const bcast = posts.find((p) => p.path.includes("/api/transport/broadcast"));
expect(bcast).toBeDefined();
expect(bcast!.body["envelopeSender"]).toBe("orch@rig-a"); // marker PRESENT (the fix) — value ignored daemon-side
expect(bcast!.body["text"]).toBe("hi team");
});
it("P21: session-less cross-host broadcast still SETS the marker as the SINGLE-ORIGIN '<unknown sender>' (never duplicated across the host boundary); marker-present + no header ⇒ the daemon's existing I4 401 fires — refuse-loud inherited, not a silent raw send", async () => {
const savedS = process.env["OPENRIG_SESSION_NAME"]; const savedR = process.env["RIGGED_SESSION_NAME"];
delete process.env["OPENRIG_SESSION_NAME"]; delete process.env["RIGGED_SESSION_NAME"];
try {
const { posts, deps } = crossHostBcast();
const prog = new Command(); prog.exitOverride();
prog.addCommand(broadcastCommand(deps));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "broadcast", "--host", "vm-a", "--rig", "my-rig", "hi team"]);
});
const bcast = posts.find((p) => p.path.includes("/api/transport/broadcast"));
expect(bcast).toBeDefined();
expect(bcast!.body["envelopeSender"]).toBe("<unknown sender>"); // marker present (enveloped) — daemon 401s (no header)
} finally {
if (savedS === undefined) delete process.env["OPENRIG_SESSION_NAME"]; else process.env["OPENRIG_SESSION_NAME"] = savedS;
if (savedR === undefined) delete process.env["RIGGED_SESSION_NAME"]; else process.env["RIGGED_SESSION_NAME"] = savedR;
}
// A1 REFUSE-LOUD (supersedes the P21 session-less pin): an unattributable cross-host broadcast now
// REFUSES at the CLI seat boundary — nothing is dispatched. The prior behavior (SET the marker to
// "<unknown sender>" and lean on the remote daemon's 401) put a marked-but-unattributed body on the
// wire; A1 refuses BEFORE the POST, so the daemon 401 is a backstop the CLI no longer reaches. The
// load-bearing assertion is the ABSENCE OF A DISPATCH (no broadcast POST), not just the exit code.
it("A1: a session-less cross-host broadcast REFUSES LOUD at the seat boundary — non-zero exit, NOTHING dispatched (no marker on the wire)", async () => {
vi.stubEnv("OPENRIG_SESSION_NAME", "");
vi.stubEnv("RIGGED_SESSION_NAME", "");
const { posts, deps } = crossHostBcast();
const prog = new Command(); prog.exitOverride();
prog.addCommand(broadcastCommand(deps));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "broadcast", "--host", "vm-a", "--rig", "my-rig", "hi team"]);
});
expect(exitCode).toBe(1);
expect(posts.find((p) => p.path.includes("/api/transport/broadcast"))).toBeUndefined(); // ABSENCE OF DISPATCH
expect(logs.join("\n")).toMatch(/unattributable|no resolvable seat|nothing was sent/i);
});
it("broadcast --json prints raw JSON", async () => {
@@ -22,11 +22,12 @@ let originalStdoutWrite: typeof process.stdout.write;
let originalStderrWrite: typeof process.stderr.write;
beforeEach(() => {
// Hermetic sender provenance: the ambient OPENRIG_SESSION_NAME (set when
// the suite runs inside a rig seat) would otherwise make the SSH send
// reconstruction auto-append `--from <origin>`. Clear it so the exact-argv
// assertions below are deterministic; the provenance test stubs it explicitly.
vi.stubEnv("OPENRIG_SESSION_NAME", "");
// A1 REFUSE-LOUD: a send is attributable-only — an unresolvable seat REFUSES at the boundary, so the
// prior empty-seat default now means "refuse" (no dispatch). Stub a DETERMINISTIC origin seat instead:
// the SSH reconstruction always appends `--from <origin>` from it (the origin is now always resolved),
// so the exact-argv assertions stay deterministic — they just include the derived `--from`. Individual
// provenance tests override the seat explicitly.
vi.stubEnv("OPENRIG_SESSION_NAME", "origin@rig-a");
vi.stubEnv("RIGGED_SESSION_NAME", "");
captured = { stdoutLines: [], stderrLines: [], stdoutWrites: [], stderrWrites: [], exitCode: undefined };
originalLog = console.log;
@@ -82,7 +83,9 @@ describe("send --host (cross-host short-circuit)", () => {
},
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hello world", "--verify"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "send", "dev-impl@my-rig", "hello world", "--verify"]);
// A1: the origin seat is always resolved (refuse otherwise), so the reconstruction deterministically
// appends `--from <derived origin>` (the seat env, never a caller override).
expect(captureCalls.argv).toEqual(["rig", "send", "dev-impl@my-rig", "hello world", "--verify", "--from", "origin@rig-a"]);
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
const stdoutText = captured.stdoutWrites.join("");
expect(stdoutText).toContain("Verified: yes");
+10 -4
View File
@@ -36,6 +36,10 @@ beforeEach(() => {
console.error = (...args: unknown[]) => { captured.stderrLines.push(args.map(String).join(" ")); };
process.exitCode = undefined;
delete process.env.OPENRIG_HOST_SELECTED;
// A1: send/broadcast are attributable-only — a resolvable seat is required to dispatch (the
// seat-boundary guard refuses otherwise). Establish one so the cross-host dispatch paths run.
vi.stubEnv("OPENRIG_SESSION_NAME", "relay@my-rig");
vi.stubEnv("RIGGED_SESSION_NAME", "");
});
afterEach(() => {
@@ -46,6 +50,7 @@ afterEach(() => {
// and made the exit-code checks vacuous). This hook only resets state.
process.exitCode = undefined;
delete process.env.OPENRIG_HOST_SELECTED;
vi.unstubAllEnvs();
});
// A registry with BOTH transports + a second http host for precedence cases.
@@ -513,10 +518,11 @@ describe("broadcast --host (net-new, CLI-direct POST)", () => {
await cmd.parseAsync(["--host", "vps-b", "--rig", "remote-rig", "coordinate"], { from: "user" });
const call = h.calls[0]!;
expect(call.path).toBe("/api/transport/broadcast");
// P21 fix (203078d7): the cross-host broadcast now CARRIES the enveloped-fan-out marker (it was dropped
// → the remote rendered raw while the local path wrapped). Its PRESENCE is the signal; its VALUE is
// env-derived (the seat, or the single-origin "<unknown sender>" fallback) and IGNORED by the daemon,
// which derives the From: from the auto-stamped X-OpenRig-Session. Strict body + any-string marker.
// P21 fix (203078d7) + A1: the cross-host broadcast CARRIES the enveloped-fan-out marker (it was dropped
// → the remote rendered raw while the local path wrapped). Its PRESENCE is the signal; its VALUE is the
// RESOLVED seat (A1 refuses an unattributable broadcast at the boundary, so the deleted "<unknown
// sender>" fallback can no longer appear) and IGNORED by the daemon, which derives the From: from the
// auto-stamped X-OpenRig-Session. Strict body + any-string marker.
expect(call.body).toEqual({ text: "coordinate", force: undefined, rig: "remote-rig", envelopeSender: expect.any(String) });
expect(captured.stdoutLines).toContain("[via host=vps-b (http://vps-b:7433)]");
expect(captured.stdoutLines).toContain("a@remote-rig: sent");
+4 -5
View File
@@ -37,11 +37,10 @@ describe("wrapSendBody — always-suffix sender triple", () => {
expect(a1.split("host-aaaa").join("host-bbbb")).toBe(b);
});
it("never suffixes the <unknown sender> fallback", () => {
const out = wrapSendBody(undefined, "peer@rig", "hi", SELF);
expect(out).toContain("From: <unknown sender>");
expect(out).not.toContain(`@${SELF}`);
});
// A1 REFUSE-LOUD: `wrapSendBody` requires a resolved sender (the seat-boundary guard refuses an
// unattributable send), so the undefined-sender fallback this asserted is gone from the CLI twin —
// it survives only on the daemon `wrapPaneEnvelope` for the non-refusable nudge (never suffixed
// there either; tested in packages/daemon/test/pane-envelope.test.ts).
it("--from relay: a sender ALREADY carrying an origin triple is preserved verbatim, never re-stamped with THIS host", () => {
const originTriple = `${SENDER}@origin-host`;
+5 -7
View File
@@ -26,13 +26,11 @@ describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)"
expect(out).toContain('↩ Reply: rig send a@r "..."');
});
it("falls back to a marker when the sender is undefined or empty", () => {
const undef = wrapSendBody(undefined, "b@r", "hi");
expect(undef).toContain("From: <unknown sender>");
expect(undef).toContain('↩ Reply: rig send <unknown sender> "..."');
const blank = wrapSendBody(" ", "b@r", "hi");
expect(blank).toContain("From: <unknown sender>");
});
// A1 REFUSE-LOUD removed the CLI `<unknown sender>` fallback: `wrapSendBody` now REQUIRES a resolved
// sender (the seat-boundary guard refuses an unattributable send before this renders), so the
// undefined/blank input this test exercised is unreachable AND a type error. The fallback-render
// behavior legitimately survives ONLY on the daemon twin `wrapPaneEnvelope` (the non-refusable
// queue-nudge sender) and is tested there: packages/daemon/test/pane-envelope.test.ts.
it("uses the literal recipient string in the To header so cross-rig addresses survive", () => {
const out = wrapSendBody("from@a", "to@b", "x");
+75
View File
@@ -155,7 +155,14 @@ describe("Send CLI", () => {
beforeEach(() => {
lastSendBody = null;
lastBroadcastBody = null;
// A1: a send is attributable-only — the seat-boundary guard refuses when the seat env is
// unresolvable. Establish a resolvable seat for every test so the dispatch paths run; the
// refuse-loud test overrides it to empty. (Hermetic-gate default is env-UNSET.) Nested describes
// that stub their own envs re-run after this; the block afterEach restores so no stub leaks.
vi.stubEnv("OPENRIG_SESSION_NAME", "sender@my-rig");
vi.stubEnv("RIGGED_SESSION_NAME", "");
});
afterEach(() => { vi.unstubAllEnvs(); });
it("send prints success output", async () => {
const { logs } = await captureLogs(async () => {
@@ -164,6 +171,74 @@ describe("Send CLI", () => {
expect(logs.join("\n")).toContain("Sent to dev-impl@my-rig");
});
// A1 REFUSE-LOUD (identity/audit family, atom 1): an unattributable send — no --from (deprecated +
// ignored) AND no resolvable OPENRIG_SESSION_NAME/RIGGED_SESSION_NAME — must REFUSE at the seat boundary:
// non-zero exit, and NOTHING dispatched. The load-bearing assertion is the ABSENCE OF A DISPATCH
// (lastSendBody stays null), not merely the message — the channel of record records only a derived sender.
it("A1: an unattributable send refuses LOUD — non-zero exit, NOTHING rendered or dispatched", async () => {
vi.stubEnv("OPENRIG_SESSION_NAME", ""); // override the block seat-stub → unresolvable
vi.stubEnv("RIGGED_SESSION_NAME", "");
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello world"]);
});
expect(exitCode).toBe(1);
expect(lastSendBody).toBeNull(); // ABSENCE OF DISPATCH — nothing reached the wire
expect(logs.join("\n")).toMatch(/unattributable|no resolvable seat|nothing was sent/i);
});
it("A1: a resolvable seat still SENDS (the guard admits an attributable send) — dispatch reaches the wire", async () => {
vi.stubEnv("OPENRIG_SESSION_NAME", "driver@my-rig");
vi.stubEnv("RIGGED_SESSION_NAME", "");
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello world"]);
});
expect(exitCode).toBeFalsy(); // sent (not refused)
expect(lastSendBody).not.toBeNull(); // dispatch reached the wire
expect((lastSendBody as Record<string, unknown>)["actorSession"]).toBe("driver@my-rig");
});
// A1 NEGATIVE CONTROL — canonicity asserted as an EFFECT, not a prose comment. After A1 the CLI
// `<unknown sender>` fallbacks are DELETED, so the literal has exactly ONE definition in the whole
// src tree: the daemon's pane-envelope.ts (the non-refusable queue-nudge sender). This guard fails
// the moment a SECOND definition reappears ANYWHERE in src — the drift a lockstep comment could not
// prevent. Comment mentions are excluded (they document, they don't define).
it("A1: the '<unknown sender>' literal is DEFINED exactly once in the src tree — pane-envelope.ts", () => {
const repoRoot = path.resolve(process.cwd(), "../..");
const packagesDir = path.join(repoRoot, "packages");
const LITERAL = '"<unknown sender>"'; // the double-quoted string-literal token (a definition, not prose)
const srcFiles: string[] = [];
const walk = (dir: string): void => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "test") continue;
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && full.includes(`${path.sep}src${path.sep}`)) {
srcFiles.push(full);
}
}
};
for (const pkg of fs.readdirSync(packagesDir)) {
const srcDir = path.join(packagesDir, pkg, "src");
if (fs.existsSync(srcDir)) walk(srcDir);
}
const hits: string[] = [];
for (const file of srcFiles) {
const lines = fs.readFileSync(file, "utf8").split("\n");
lines.forEach((line, i) => {
const trimmed = line.trim();
// Skip comment lines (line comments, JSDoc/block-comment bodies) — prose mentions don't count.
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) return;
if (line.includes(LITERAL)) hits.push(`${path.relative(repoRoot, file)}:${i + 1}`);
});
}
expect(hits.length, `expected exactly ONE definition of ${LITERAL}, found: ${hits.join(", ")}`).toBe(1);
expect(hits[0]).toMatch(/pane-envelope\.ts:/);
});
it("send with 409 mid-work prints error and exits non-zero", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "busy-session", "hello"]);
@@ -549,8 +549,13 @@ export class QueueRepository {
* is now wrapped with the same From/To/---/body/---/↩ Reply envelope
* that `rig send` uses. `sourceSession` is the seat that triggered
* the create/handoff so the recipient pane shows where the nudge
* came from + a reply hint. When undefined, the envelope falls back
* to the canonical "<unknown sender>" marker (matches wrapPaneEnvelope).
* came from + a reply hint. A queue nudge is the ONE non-refusable
* sender — it has no seat to send an error back to — so when
* `sourceSession` is undefined `wrapPaneEnvelope` applies its own
* `<unknown sender>` fallback internally (`pane-envelope.ts`). After
* A1 that is the SOLE definition of the marker in the tree (the CLI
* copies were deleted, refused at the seat boundary instead); this
* site holds no copy of its own.
*/
async maybeNudge(
qitemId: string,