mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
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:
1 parent
8350fd1026
commit
6634b28522
10 files changed
+261
-120
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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> };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user