feat(multihost): optional HTTP host-registry bearer + SSH sender provenance

Make the http host-registry bearer pointer optional so a URL-only entry
loads and cross-host send/capture/queue reach a tokenless daemon with no
Authorization header (host+VM are one founder-owned trust domain; the mesh
is the auth boundary). Configured bearer_env/bearer_file behavior is
preserved exactly: it still resolves and sends Authorization, a
configured-but-unresolvable pointer still fails before any request
(permission-gate), and both pointers together remain invalid. The CLI and
daemon validation twins move together (parity-pinned).

Add surgical SSH sender provenance: `rig send --from <session>` names the
originating session in the envelope and actorSession; the cross-host ssh
relay reconstructs `--from <origin>` (explicit, else $OPENRIG_SESSION_NAME)
so the remote envelope names the origin instead of degrading to the relay's
own session. Plumbing only — not a trust gate.

Docs/help updated to the loosened contract (as-built cli-reference, host
add help, loader/placement hints, unsupported-transport strings). No
changes to the daemon auth middleware / public-bind protection; queue-over-
SSH stays deferred; no trust/allowlist/telemetry/lifecycle rework.

qitem-20260715052304-7d5b52dc + addendum qitem-20260715052600-d4ea3f8b

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-07-15 05:59:23 +00:00
1 parent 65ade59462
commit a0c1730577
25 files changed
+303 -66

No files matched your search

+2 -2
View File
@@ -824,7 +824,7 @@ Validation rules:
- `hosts` is required and must be a non-null array.
- Each entry: `id` required (non-empty, unique), `transport` required (`ssh` or `http`).
- SSH entries require `target` (non-empty — DNS name, SSH config alias, or IP); `user` and `notes` are optional.
- HTTP entries require `url` plus exactly one bearer pointer (`bearer_env` or `bearer_file`); pointers are config names/paths, never resolved token values.
- HTTP entries require `url`; a bearer pointer (`bearer_env` or `bearer_file`) is OPTIONAL — omit both for an anonymous/tokenless daemon (no `Authorization` header is sent; host+VM are one founder-owned trust domain and the mesh is the auth boundary). At most one bearer pointer may be set, never both. Pointers are config names/paths, never resolved token values; a configured-but-unresolvable pointer is a permission failure before any request.
- `rig host add/list/doctor` covers the standard path; hand-editing remains the path for exotica.
- A missing or invalid file returns a clear error pointing at the canonical path.
@@ -946,7 +946,7 @@ exactly these three — no edit/remove/tunnel/bootstrap verbs; hand-editing `hos
the path for exotica, and the factory bootstrap ships as script + runbook at
`docs/reference/product-factory-vps-runbook.md`).
- `add --id <id> --transport <ssh|http> [--target <t> --user <u> | --url <u> --bearer-env <n>|--bearer-file <p>] [--notes <text>] [--json]` — writes the entry validated by the registry loader's OWN rules (add-time errors are load-time errors, verbatim; duplicate ids refused). Rewrites `hosts.yaml` canonically (hand-authored comments are not preserved).
- `add --id <id> --transport <ssh|http> [--target <t> --user <u> | --url <u> [--bearer-env <n>|--bearer-file <p>]] [--notes <text>] [--json]` — writes the entry validated by the registry loader's OWN rules (add-time errors are load-time errors, verbatim; duplicate ids refused; the http bearer pointer is optional — omit both for a tokenless daemon, never both). Rewrites `hosts.yaml` canonically (hand-authored comments are not preserved).
- `list [--json]` — id/transport/target plus AUTH as a config POINTER (`env:NAME` / `file:PATH` / `ssh-key`); never a resolved secret value.
- `doctor <id> [--posture product-factory-vps] [--public-addr <ip>] [--json]` — stepwise, honest verification: transport reachability → remote `rig` binary (+version) → remote daemon health → remote identity; each failing step is a DISTINCT actionable error, and unknown host ids surface as the registry error class. `--posture` runs the ONE built-in baseline (`product-factory-vps`): every item reports pass/fail/**unknown** individually with a fix per non-pass — UNKNOWN is never pass; the public `:7433`/`:22` probes need `--public-addr` (outside vantage) and a reachable public daemon port FAILS loudly. Exit `1` on any fail.
+9 -7
View File
@@ -18,7 +18,7 @@ import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { DaemonClient } from "../client.js";
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
@@ -188,9 +188,11 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
return rows;
}
try {
const health = await deps.httpGet(`${(host as HttpHostEntry).url}/healthz`, { Authorization: `Bearer ${bearer.token}` });
const health = await deps.httpGet(`${(host as HttpHostEntry).url}/healthz`, bearerAuthHeaders(bearer.token));
if (health.status === 401 || health.status === 403) {
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon reachable but rejected the bearer (HTTP ${health.status})`, fix: "registry is reachable but the token is wrong — rotate/set the bearer the entry points at" });
rows.push(bearer.token
? { step: "transport-reachability", status: "fail", detail: `daemon reachable but rejected the bearer (HTTP ${health.status})`, fix: "registry is reachable but the token is wrong — rotate/set the bearer the entry points at" }
: { step: "transport-reachability", status: "fail", detail: `daemon reachable but requires Authorization (HTTP ${health.status}); this host entry is anonymous (no bearer configured)`, fix: "the remote daemon enforces a bearer — add a bearer_env or bearer_file pointer to this host's entry in ~/.openrig/hosts.yaml, then set that env var / file" });
return rows;
}
if (health.status < 200 || health.status >= 300) {
@@ -210,9 +212,9 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
fix: "verify via an ssh-transport entry for this host, or on the host directly: rig --version",
});
try {
const ps = await deps.httpGet(`${(host as HttpHostEntry).url}/api/ps`, { Authorization: `Bearer ${bearer.token}` });
const ps = await deps.httpGet(`${(host as HttpHostEntry).url}/api/ps`, bearerAuthHeaders(bearer.token));
rows.push(ps.status >= 200 && ps.status < 300
? { step: "remote-identity", status: "pass", detail: "authenticated daemon API answers (/api/ps)" }
? { step: "remote-identity", status: "pass", detail: bearer.token ? "authenticated daemon API answers (/api/ps)" : "daemon API answers (/api/ps; anonymous — no bearer configured)" }
: { step: "remote-identity", status: "fail", detail: `/api/ps returned HTTP ${ps.status}`, fix: "daemon healthz is up but the API is unhealthy — check remote daemon logs" });
} catch (err) {
rows.push({ step: "remote-identity", status: "fail", detail: `/api/ps unreachable: ${(err as Error).message}`, fix: "check remote daemon logs" });
@@ -417,8 +419,8 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.option("--target <target>", "SSH target (DNS name, ssh-config alias, or IP) — ssh transport")
.option("--user <user>", "SSH user — ssh transport")
.option("--url <url>", "Remote daemon base URL — http transport")
.option("--bearer-env <name>", "Env var NAME holding the bearer token — http transport (pointer, never a value)")
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (pointer, never a value)")
.option("--bearer-env <name>", "Env var NAME holding the bearer token — http transport (optional pointer, never a value; omit both bearer flags for a tokenless daemon)")
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (optional pointer, never a value; at most one bearer flag)")
.option("--notes <text>", "Free-form operator note")
.option("--json", "JSON output")
.action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => {
+2 -2
View File
@@ -1300,8 +1300,8 @@ async function runCrossHostPs(
emitCrossHostFailure(host.id, hostDisplayTarget(host), result, false);
}
function buildRemoteHeaders(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
function buildRemoteHeaders(token: string | undefined): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function runHttpPs(
+12 -5
View File
@@ -92,6 +92,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
.option("--dangerously-interact", "DANGEROUS: deliberately drive an interactive prompt/permission block (implies --raw; requires --reason). The ONLY override of the prompt/permission guard.")
.option("--reason <text>", "Why the prompt is being driven (required with --dangerously-interact; recorded in the audit log)")
.option("--host <id>", "Send on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
.option("--from <session>", "Originating session for the envelope sender/actor (provenance; defaults to $OPENRIG_SESSION_NAME). Plumbed through cross-host ssh sends so the remote envelope names the origin, not the relay.")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -137,7 +138,7 @@ result and verify verdict are the REMOTE's, verbatim. A target of the form
agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
(explicit --host > target sugar > persisted selection; a conflict between
--host and the sugar is an error).`)
.action(async (session: string | undefined, text: string | undefined, opts: { to?: string[]; pod?: string; rig?: string; verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; host?: string; json?: boolean }) => {
.action(async (session: string | undefined, text: string | undefined, opts: { to?: string[]; pod?: string; rig?: string; verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; host?: string; from?: string; json?: boolean }) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly. OPR.0.4.6.MH4 §4: the raw flag is kept
@@ -246,7 +247,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
}
const client = deps.clientFactory(getDaemonUrl(status));
const senderSession = resolveSenderSession();
const senderSession = opts.from ?? resolveSenderSession();
// --raw (and --dangerously-interact, which implies it) send EXACT text with no messaging envelope.
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
const outboundText = raw ? text : wrapSendBody(senderSession, session, text);
@@ -301,7 +302,7 @@ async function runCrossHostSend(
hostId: string,
session: string,
text: string,
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; json?: boolean },
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; from?: string; json?: boolean },
deps: SendDeps,
waitForIdleMs?: number,
hint?: string,
@@ -340,6 +341,12 @@ async function runCrossHostSend(
if (opts.raw) argv.push("--raw");
if (opts.dangerouslyInteract) argv.push("--dangerously-interact");
if (opts.reason !== undefined) argv.push("--reason", opts.reason);
// Sender provenance: the ssh relay re-runs `rig send` on the remote, which
// would otherwise resolve ITS OWN session and degrade the envelope sender
// to "unknown". Carry the origin (explicit --from, else $OPENRIG_SESSION_NAME)
// so the remote envelope names the originating session. Plumbing, not a gate.
const originSender = opts.from ?? resolveSenderSession();
if (originSender) argv.push("--from", originSender);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
@@ -384,12 +391,12 @@ async function runHttpHostSend(
host: HttpHostEntry,
session: string,
text: string,
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; json?: boolean },
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; from?: string; json?: boolean },
deps: SendDeps,
waitForIdleMs?: number,
hint?: string,
): Promise<void> {
const senderSession = resolveSenderSession();
const senderSession = opts.from ?? resolveSenderSession();
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
const outboundText = raw ? text : wrapSendBody(senderSession, session, text);
+3 -3
View File
@@ -6,7 +6,7 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost, hostDisplayTarget, resolveRemoteBearer, classifyHttpFailedStep, classifyHttpError, type HttpHostEntry } from "../host-registry.js";
import { loadHostRegistry, resolveHost, hostDisplayTarget, resolveRemoteBearer, bearerAuthHeaders, classifyHttpFailedStep, classifyHttpError, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
@@ -427,7 +427,7 @@ async function runHttpWhoami(
const { classifyHttpFailedStep: classifyStatus } = await import("../host-registry.js");
const client = deps.clientFactory(host.url);
const headers = { Authorization: `Bearer ${bearerResult.token}` };
const headers = bearerAuthHeaders(bearerResult.token);
try {
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { headers });
@@ -511,7 +511,7 @@ async function runFanOutWhoami(opts: WhoamiCliOptions, deps: WhoamiDeps): Promis
return { host: id, ok: false, failedStep: bearerResult.failedStep, error: bearerResult.error };
}
const client = deps.clientFactory(httpHost.url);
const headers = { Authorization: `Bearer ${bearerResult.token}` };
const headers = bearerAuthHeaders(bearerResult.token);
try {
const { classifyHttpFailedStep: classifyStatus } = await import("../host-registry.js");
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { headers });
+21 -8
View File
@@ -8,7 +8,9 @@ export type { FailedStep };
export interface RemoteBearerResolution {
ok: true;
token: string;
/** Absent for an anonymous (URL-only) host — a tokenless daemon, no
* Authorization header is sent. Present when bearer_env/bearer_file resolves. */
token?: string;
}
export interface RemoteBearerFailure {
@@ -32,7 +34,17 @@ export function resolveRemoteBearer(host: HttpHostEntry): RemoteBearerResolution
return { ok: false, failedStep: "permission-gate", error: `bearer file ${host.bearer_file} not readable for host ${host.id}` };
}
}
return { ok: false, failedStep: "permission-gate", error: `host ${host.id} has no bearer_env or bearer_file configured` };
// No bearer configured is now a VALID anonymous host — a tokenless daemon
// (host+VM are one founder-owned trust domain; the mesh is the auth
// boundary). No token means no Authorization header downstream. A
// configured-but-unresolvable pointer still fails above (fail-closed).
return { ok: true };
}
/** Build the Authorization header for a resolved remote bearer. An anonymous
* (URL-only) host resolves to no token → no header (tokenless daemon). */
export function bearerAuthHeaders(token: string | undefined): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
export function classifyHttpFailedStep(status: number, body?: { error?: string }): FailedStep {
@@ -111,7 +123,7 @@ export function loadHostRegistry(path: string = defaultHostRegistryPath()): Host
if (!existsSync(path)) {
return {
ok: false,
error: `host registry not found at ${path}. Create it with a 'hosts:' array; transport: ssh (target + user) or http (url + bearer_env/bearer_file).`,
error: `host registry not found at ${path}. Create it with a 'hosts:' array; transport: ssh (target + user) or http (url; optional bearer_env or bearer_file — omit both for a tokenless daemon).`,
};
}
let raw: string;
@@ -208,11 +220,11 @@ export function validateHostRegistry(parsed: unknown, sourcePath: string): HostR
const bearerFile = entry["bearer_file"];
const hasEnv = bearerEnv !== undefined;
const hasFile = bearerFile !== undefined;
if (!hasEnv && !hasFile) {
return { ok: false, error: `${prefix}: http transport requires exactly one of bearer_env or bearer_file` };
}
// bearer_env / bearer_file are OPTIONAL: omit both for an
// anonymous/tokenless daemon (no Authorization sent). At most one may
// be set — never both.
if (hasEnv && hasFile) {
return { ok: false, error: `${prefix}: specify exactly one of bearer_env or bearer_file, not both` };
return { ok: false, error: `${prefix}: specify at most one of bearer_env or bearer_file, not both (omit both for an anonymous/tokenless daemon)` };
}
if (hasEnv && (typeof bearerEnv !== "string" || bearerEnv.trim() === "")) {
return { ok: false, error: `${prefix}.bearer_env: must be a non-empty env var name` };
@@ -260,7 +272,8 @@ export function resolveHost(registry: HostRegistry, id: string): HostResolution
// ONE validation source: the candidate registry (existing entries + the new
// raw entry) is validated by the SAME validateHostRegistry the loader uses —
// add-time errors are load-time errors, verbatim (incl. duplicate ids,
// transport-appropriate fields, exactly-one-bearer). The standard path never
// transport-appropriate fields, at-most-one-bearer — bearer optional for a
// tokenless daemon, never both). The standard path never
// hand-edits YAML; note: add REWRITES the file canonically (hand-authored
// comments are not preserved — hand-editing remains the path for exotica).
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -1,5 +1,5 @@
import { DaemonClient } from "./client.js";
import { loadHostRegistry, resolveHost, resolveRemoteBearer, classifyHttpFailedStep, classifyHttpError, type HttpHostEntry } from "./host-registry.js";
import { loadHostRegistry, resolveHost, resolveRemoteBearer, bearerAuthHeaders, classifyHttpFailedStep, classifyHttpError, type HttpHostEntry } from "./host-registry.js";
import type { FailedStep } from "./cross-host-types.js";
export interface RemoteHostDeps {
@@ -46,7 +46,7 @@ export async function runRemoteHttpOp(
}
const client = deps.clientFactory(httpHost.url);
const headers = { Authorization: `Bearer ${bearerResult.token}` };
const headers = bearerAuthHeaders(bearerResult.token);
const requestOptions = opts.timeoutMs !== undefined ? { headers, timeoutMs: opts.timeoutMs } : { headers };
try {
@@ -22,6 +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", "");
vi.stubEnv("RIGGED_SESSION_NAME", "");
captured = { stdoutLines: [], stderrLines: [], stdoutWrites: [], stderrWrites: [], exitCode: undefined };
originalLog = console.log;
originalError = console.error;
@@ -41,6 +47,7 @@ afterEach(() => {
process.stderr.write = originalStderrWrite;
captured.exitCode = process.exitCode;
process.exitCode = undefined;
vi.unstubAllEnvs();
});
const KNOWN_REGISTRY: HostRegistryLoadResult = {
@@ -82,6 +89,29 @@ describe("send --host (cross-host short-circuit)", () => {
expect(process.exitCode).toBeUndefined();
});
it("sender provenance: SSH reconstruction appends --from <origin> when an origin session resolves", async () => {
vi.stubEnv("OPENRIG_SESSION_NAME", "orch-lead@rig-a");
const captureCalls: { argv?: readonly string[] } = {};
const cmd = sendCommand(deps({
run: async (_h, argv) => { captureCalls.argv = argv; return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 }; },
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hi"], { from: "user" });
const argv = captureCalls.argv!;
expect(argv).toContain("--from");
expect(argv[argv.indexOf("--from") + 1]).toBe("orch-lead@rig-a");
});
it("explicit --from wins over the ambient origin in the reconstructed argv", async () => {
vi.stubEnv("OPENRIG_SESSION_NAME", "orch-lead@rig-a");
const captureCalls: { argv?: readonly string[] } = {};
const cmd = sendCommand(deps({
run: async (_h, argv) => { captureCalls.argv = argv; return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 }; },
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hi", "--from", "worker@rig-a"], { from: "user" });
const argv = captureCalls.argv!;
expect(argv[argv.indexOf("--from") + 1]).toBe("worker@rig-a");
});
it("--verify honesty: SSH success + remote 'Verified: no' surfaces in output, NOT collapsed into success+silence", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: no\n", stderr: "", remoteExitCode: 0 }),
+22
View File
@@ -55,6 +55,7 @@ const REGISTRY: HostRegistryLoadResult = {
hosts: [
{ id: "vps-b", transport: "http", url: "http://vps-b:7433", bearer_env: "MH4_TEST_BEARER" },
{ id: "vps-c", transport: "http", url: "http://vps-c:7433", bearer_env: "MH4_TEST_BEARER" },
{ id: "anon-b", transport: "http", url: "http://anon-b:7433" },
{ id: "vm-ssh", transport: "ssh", target: "vm-ssh.local" },
],
},
@@ -208,6 +209,17 @@ describe("send --host (http branch)", () => {
expect(body.text).toBe("/compact");
});
it("anonymous (URL-only) http host: POSTs with NO Authorization header; still succeeds", async () => {
const h = mockClient(() => ({ status: 200, data: { ok: true } }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "anon-b", "dev-impl@my-rig", "hello"], { from: "user" });
expect(h.calls.length).toBe(1);
const headers = h.calls[0]!.options?.headers ?? {};
expect("Authorization" in headers).toBe(false);
expect(captured.stdoutLines).toContain("Sent to dev-impl@my-rig");
expect(process.exitCode).toBeUndefined();
});
it("sugar target dev-impl@my-rig@vps-b routes http with the STRIPPED session", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
@@ -352,6 +364,16 @@ describe("capture --host (http branch)", () => {
expect(captured.stdoutLines).toContain("pane content here");
});
it("anonymous (URL-only) http host: capture POSTs with NO Authorization header; still succeeds", async () => {
const h = mockClient(() => ({ status: 200, data: { content: "pane content here" } }));
const cmd = captureCommand(httpDeps(h));
await cmd.parseAsync(["--host", "anon-b", "dev-impl@my-rig"], { from: "user" });
expect(h.calls.length).toBe(1);
const headers = h.calls[0]!.options?.headers ?? {};
expect("Authorization" in headers).toBe(false);
expect(captured.stdoutLines).toContain("pane content here");
});
it("multi-target (--rig/--pod) rides the http branch with per-target rendering", async () => {
const h = mockClient(() => ({
status: 200,
+12 -8
View File
@@ -26,20 +26,24 @@ describe("host-registry HTTP transport validation", () => {
expect(r.ok).toBe(true);
});
it("rejects http entry with no bearer source", () => {
it("accepts http entry with NO bearer source (anonymous/tokenless daemon)", () => {
const r = validateHostRegistry({
hosts: [{ id: "host-b", transport: "http", url: "http://192.168.64.97:7433" }],
}, "test.yaml");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("exactly one of bearer_env or bearer_file");
expect(r.ok).toBe(true);
if (r.ok) {
const h = r.registry.hosts[0] as HttpHostEntry;
expect(h.bearer_env).toBeUndefined();
expect(h.bearer_file).toBeUndefined();
}
});
it("rejects http entry with both bearer sources", () => {
it("rejects http entry with both bearer sources (never both)", () => {
const r = validateHostRegistry({
hosts: [{ id: "host-b", transport: "http", url: "http://192.168.64.97:7433", bearer_env: "TOK", bearer_file: "/tmp/tok" }],
}, "test.yaml");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("exactly one of bearer_env or bearer_file");
if (!r.ok) expect(r.error).toContain("not both");
});
it("rejects http entry with empty url", () => {
@@ -89,10 +93,10 @@ describe("resolveRemoteBearer", () => {
}
});
it("returns permission-gate when no source configured", () => {
it("returns anonymous ok (no token) when no source configured", () => {
const r = resolveRemoteBearer({ id: "b", transport: "http", url: "http://x" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.failedStep).toBe("permission-gate");
expect(r.ok).toBe(true);
if (r.ok) expect(r.token).toBeUndefined();
});
it("no token appears in error output", () => {
+33 -1
View File
@@ -55,7 +55,7 @@ describe("rig host add/list", () => {
it("add surfaces the loader's own validation error at add-time (both bearers)", async () => {
const { err, exitCode } = await capture(() => run(["add", "--id", "h1", "--transport", "http", "--url", "http://x", "--bearer-env", "A", "--bearer-file", "/b"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("exactly one of bearer_env or bearer_file");
expect(err.join("\n")).toContain("not both");
});
it("list renders pointers and NEVER a resolved secret value (qa1 hygiene guard)", async () => {
@@ -266,6 +266,38 @@ describe("rig host doctor — stepwise distinct errors + three-valued posture",
expect(rows.every((r: CheckRow) => r.status === "unknown")).toBe(true);
});
it("doctor over http: anonymous (URL-only) host with an answering daemon passes auth-neutrally (no 'authenticated' claim)", async () => {
const anonHost: HostEntry = { id: "anon-h", transport: "http", url: "http://x" };
const rows = await doctorLegs(anonHost, { run: async () => okRun(""), httpGet: async () => ({ status: 200, body: "ok" }), tcpProbe: async () => "closed" });
const reach = rows.find((r) => r.step === "transport-reachability")!;
expect(reach.status).toBe("pass");
const identity = rows.find((r) => r.step === "remote-identity")!;
expect(identity.status).toBe("pass");
expect(identity.detail).toContain("anonymous");
expect(identity.detail).not.toContain("authenticated");
});
it("doctor over http: anonymous host that gets 401 is told to add a bearer to the hosts.yaml entry, NOT that a token is wrong", async () => {
const anonHost: HostEntry = { id: "anon-h", transport: "http", url: "http://x" };
const rows = await doctorLegs(anonHost, { run: async () => okRun(""), httpGet: async () => ({ status: 401, body: "no" }), tcpProbe: async () => "closed" });
const reach = rows.find((r) => r.step === "transport-reachability")!;
expect(reach.status).toBe("fail");
expect(reach.detail).toContain("requires Authorization");
expect(reach.fix).toContain("hosts.yaml");
expect(reach.fix).not.toContain("token is wrong");
expect(reach.fix).not.toContain("rig host add");
});
it("doctor over http: a CONFIGURED-token host that gets 401 keeps the wrong-token guidance (unchanged branch)", async () => {
vi.stubEnv("DOCTOR_TOK", "tok-1");
const cfgHost: HostEntry = { id: "cfg-h", transport: "http", url: "http://x", bearer_env: "DOCTOR_TOK" };
const rows = await doctorLegs(cfgHost, { run: async () => okRun(""), httpGet: async () => ({ status: 401, body: "no" }), tcpProbe: async () => "closed" });
const reach = rows.find((r) => r.step === "transport-reachability")!;
expect(reach.status).toBe("fail");
expect(reach.detail).toContain("rejected the bearer");
expect(reach.fix).toContain("token is wrong");
});
it("doctor CLI: unknown host id is the DISTINCT registry error", async () => {
const prog = new Command();
prog.exitOverride();
+39
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { Command } from "commander";
import type { DaemonClient, DaemonResponse } from "../src/client.js";
import { runRemoteHttpOp } from "../src/remote-host-ops.js";
function mockClient(responses: Record<string, { status: number; data: unknown }>): DaemonClient & { _calls: Array<{ method: string; path: string; body?: unknown; headers?: Record<string, string>; timeoutMs?: number }> } {
const calls: Array<{ method: string; path: string; body?: unknown; headers?: Record<string, string>; timeoutMs?: number }> = [];
@@ -494,3 +495,41 @@ describe("SSH fallback", () => {
expect(result.failedStep).toBe("ssh-unreachable");
});
});
describe("runRemoteHttpOp — anonymous (URL-only) host omits Authorization", () => {
it("URL-only http host: request carries NO Authorization header; still succeeds", async () => {
const client = mockClient({ "/api/ps": { status: 200, data: [] } });
const res = await runRemoteHttpOp("anon-b", "GET", "/api/ps", undefined, {
clientFactory: () => client,
hostRegistryLoader: mockRegistry([{ id: "anon-b", transport: "http", url: "http://anon-b:7433" }]),
}, {});
expect(res.ok).toBe(true);
const call = client._calls.find((c) => c.path === "/api/ps");
expect(call).toBeDefined();
expect(call!.headers && "Authorization" in call!.headers).toBeFalsy();
});
it("configured bearer_env host: still sends Authorization", async () => {
vi.stubEnv("ANON_CFG_TOKEN", "tok-xyz");
const client = mockClient({ "/api/ps": { status: 200, data: [] } });
const res = await runRemoteHttpOp("cfg-b", "GET", "/api/ps", undefined, {
clientFactory: () => client,
hostRegistryLoader: mockRegistry([{ id: "cfg-b", transport: "http", url: "http://cfg-b:7433", bearer_env: "ANON_CFG_TOKEN" }]),
}, {});
expect(res.ok).toBe(true);
const call = client._calls.find((c) => c.path === "/api/ps");
expect(call!.headers?.Authorization).toBe("Bearer tok-xyz");
});
it("configured-but-missing bearer_env: fails BEFORE any request (fail-closed)", async () => {
delete process.env.ANON_MISSING_TOKEN;
const client = mockClient({ "/api/ps": { status: 200, data: [] } });
const res = await runRemoteHttpOp("cfg-miss", "GET", "/api/ps", undefined, {
clientFactory: () => client,
hostRegistryLoader: mockRegistry([{ id: "cfg-miss", transport: "http", url: "http://cfg-miss:7433", bearer_env: "ANON_MISSING_TOKEN" }]),
}, {});
expect(res.ok).toBe(false);
expect(res.failedStep).toBe("permission-gate");
expect(client._calls.length).toBe(0);
});
});
+11
View File
@@ -297,6 +297,17 @@ describe("Send CLI", () => {
expect(String(lastSendBody?.text)).toContain("---\nhello\n---");
});
// Sender provenance (addendum): an explicit --from names the originating
// session in BOTH the wrapped envelope and the actorSession field, so a
// reconstructed cross-host send does not degrade the sender to "unknown".
it("send --from <origin> sets From: <origin> in the envelope and actorSession=<origin>", async () => {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello", "--from", "orch-lead@rig-a"]);
});
expect(String(lastSendBody?.text)).toContain("From: orch-lead@rig-a");
expect(lastSendBody?.actorSession).toBe("orch-lead@rig-a");
});
it("send --dangerously-interact --reason posts the override fields with raw (exact) text", async () => {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "1", "--dangerously-interact", "--reason", "unblock stuck prompt"]);
@@ -69,7 +69,7 @@ async function readHostAttention(hostId: string, reg: HostRegistryLoadResult, de
// R15-2 (the whoami --all-hosts precedent): SSH-declared hosts are a
// structured per-host status, never a silently thinner feed.
return {
status: { hostId, status: "unsupported-transport", error: `host '${hostId}' is SSH-declared; the aggregated feed read requires an http-transport registry entry (url + bearer)` },
status: { hostId, status: "unsupported-transport", error: `host '${hostId}' is SSH-declared; the aggregated feed read requires an http-transport registry entry (url; bearer optional)` },
items: [],
};
}
@@ -4,10 +4,12 @@
// SAME module. Land once, here; P4 must not re-implement it.
//
// READ-ONLY FOREVER (arch ruling 3): the registry is operator-managed YAML at
// ~/.openrig/hosts.yaml; nothing in the daemon ever writes it. This module
// MIRRORS the CLI's packages/cli/src/host-registry.ts schema + validation
// (incl. the exactly-one-bearer rule) — the CLI copy is deliberately
// untouched this slice (no-unification boundary); the twin is held in sync by
// ~/.openrig/hosts.yaml; nothing in the daemon ever writes it. This module is
// a deliberate SEPARATE TWIN of the CLI's packages/cli/src/host-registry.ts
// schema + validation (incl. the at-most-one-bearer rule — bearer optional
// for a tokenless daemon, never both): the two copies are NOT unified, and
// any schema/validation change must land in BOTH twins together. They are
// held verdict-for-verdict in sync by
// packages/daemon/test/hosts-registry-parity.test.ts, the same discipline as
// the scope-audit CLI/daemon twins.
@@ -63,7 +65,7 @@ export function loadHostRegistry(path: string = defaultHostRegistryPath()): Host
if (!existsSync(path)) {
return {
ok: false,
error: `host registry not found at ${path}. Create it with a 'hosts:' array; transport: ssh (target + user) or http (url + bearer_env/bearer_file).`,
error: `host registry not found at ${path}. Create it with a 'hosts:' array; transport: ssh (target + user) or http (url; optional bearer_env or bearer_file — omit both for a tokenless daemon).`,
};
}
let raw: string;
@@ -160,11 +162,11 @@ export function validateHostRegistry(parsed: unknown, sourcePath: string): HostR
const bearerFile = entry["bearer_file"];
const hasEnv = bearerEnv !== undefined;
const hasFile = bearerFile !== undefined;
if (!hasEnv && !hasFile) {
return { ok: false, error: `${prefix}: http transport requires exactly one of bearer_env or bearer_file` };
}
// bearer_env / bearer_file are OPTIONAL: omit both for an
// anonymous/tokenless daemon (no Authorization sent). At most one may
// be set — never both. MIRRORS the CLI twin (host-registry.ts).
if (hasEnv && hasFile) {
return { ok: false, error: `${prefix}: specify exactly one of bearer_env or bearer_file, not both` };
return { ok: false, error: `${prefix}: specify at most one of bearer_env or bearer_file, not both (omit both for an anonymous/tokenless daemon)` };
}
if (hasEnv && (typeof bearerEnv !== "string" || bearerEnv.trim() === "")) {
return { ok: false, error: `${prefix}.bearer_env: must be a non-empty env var name` };
@@ -212,7 +214,7 @@ export function resolvePlacementHost(registry: HostRegistry, id: string): HostRe
if (res.host.transport !== "http") {
return {
ok: false,
error: `host '${id}' uses transport '${res.host.transport}', which cannot carry remote rig-up. Placement requires an http-transport host (url + bearer_env/bearer_file) — update the registry entry or launch that rig locally.`,
error: `host '${id}' uses transport '${res.host.transport}', which cannot carry remote rig-up. Placement requires an http-transport host (url; optional bearer_env or bearer_file) — update the registry entry or launch that rig locally.`,
};
}
return res;
@@ -6,8 +6,9 @@
// of packages/cli/src/host-registry.ts addHostEntry, byte-parity-pinned
// by packages/daemon/test/hosts-add-pair-routes.test.ts (same entries in
// → identical yaml bytes out; P3) and sharing the reader twin's
// validateHostRegistry (identical validation: exactly-one-bearer,
// reserved ids, duplicate ids — P3). The reader module stays READ-ONLY
// validateHostRegistry (identical validation: at-most-one-bearer — bearer
// optional for a tokenless daemon, never both — reserved ids, duplicate ids
// — P3). The reader module stays READ-ONLY
// FOREVER; writes flow ONLY through the single parity-pinned write
// contract (CLI addHostEntry + this twin), reachable daemon-side solely
// via the narrow named add/pair routes (P1 — no generic registry-write
@@ -121,7 +121,7 @@ export function hostReadThrough() {
const resolved = resolveHost(reg.registry, hostId);
if (!resolved.ok) return fail(resolved.error, "unknown-host");
if (resolved.host.transport !== "http") {
return fail(`host '${hostId}' is SSH-declared; the read-through requires an http-transport registry entry (url + bearer)`, "unsupported-transport");
return fail(`host '${hostId}' is SSH-declared; the read-through requires an http-transport registry entry (url; bearer optional)`, "unsupported-transport");
}
// Arch P2 leg 2: STRIP IS TOTAL — the forwarded request carries no host
@@ -69,9 +69,12 @@ export type RemoteRawResult =
| { ok: true; status: number; contentType: string; bodyText: string }
| { ok: false; kind: Exclude<RemoteJsonFailureKind, "http">; phase?: "request" | "body"; status?: number; detail: string };
/** Mirrors the CLI's resolveRemoteBearer (host-registry.ts): exactly-one of
* bearer_env / bearer_file, resolved at call time. */
function resolveBearer(host: HttpHostEntry, deps: RemoteJsonDeps): { ok: true; token: string } | { ok: false; detail: string } {
/** Mirrors the CLI's resolveRemoteBearer (host-registry.ts): at-most-one of
* bearer_env / bearer_file, resolved at call time. Neither configured is an
* anonymous (URL-only) host — a tokenless daemon; ok with no token, so no
* Authorization header is sent. A configured-but-unresolvable pointer still
* fails (fail-closed). */
function resolveBearer(host: HttpHostEntry, deps: RemoteJsonDeps): { ok: true; token?: string } | { ok: false; detail: string } {
const env = deps.env ?? process.env;
const readFile = deps.readFile ?? ((p: string) => readFileSync(p, "utf-8"));
if (host.bearer_env) {
@@ -88,7 +91,7 @@ function resolveBearer(host: HttpHostEntry, deps: RemoteJsonDeps): { ok: true; t
return { ok: false, detail: `bearer file ${host.bearer_file} not readable for host ${host.id}` };
}
}
return { ok: false, detail: `host ${host.id} has no bearer_env or bearer_file configured` };
return { ok: true };
}
/** One bounded daemon→daemon JSON exchange. The single deadline covers
@@ -109,7 +112,7 @@ export async function remoteJsonRequest(host: HttpHostEntry, path: string, opts:
method: opts.method,
headers: {
...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}),
Authorization: `Bearer ${bearer.token}`,
...(bearer.token ? { Authorization: `Bearer ${bearer.token}` } : {}),
},
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
signal: controller.signal,
@@ -164,7 +167,7 @@ export async function remoteRawRequest(host: HttpHostEntry, path: string, opts:
try {
res = await fetchImpl(url, {
method: "GET",
headers: { Authorization: `Bearer ${bearer.token}` },
headers: bearer.token ? { Authorization: `Bearer ${bearer.token}` } : {},
signal: controller.signal,
});
} catch (err) {
@@ -262,7 +262,7 @@ async function readHostComposedRig(hostId: string, reg: HostRegistryLoadResult,
}
if (resolved.host.transport !== "http") {
return {
status: { hostId, status: "unsupported-transport", error: `host '${hostId}' is SSH-declared; the fleet composed read requires an http-transport registry entry (url + bearer)` },
status: { hostId, status: "unsupported-transport", error: `host '${hostId}' is SSH-declared; the fleet composed read requires an http-transport registry entry (url; bearer optional)` },
input: null,
};
}
@@ -353,7 +353,7 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
const resolved = resolveHost(reg.registry, hostId);
if (!resolved.ok) return fail(resolved.error, "unknown-host");
if (resolved.host.transport !== "http") {
return fail(`host '${hostId}' is SSH-declared; remote actions require an http-transport registry entry (url + bearer)`, "unsupported-transport");
return fail(`host '${hostId}' is SSH-declared; remote actions require an http-transport registry entry (url; bearer optional)`, "unsupported-transport");
}
const { hostId: _dropped, ...forwardBody } = body as Record<string, unknown>;
const res = await remoteJsonRequest(resolved.host, "/api/mission-control/action", {
+1 -1
View File
@@ -213,7 +213,7 @@ export function queueRoutes(): Hono {
if (!resolved.ok) return fail(resolved.error, "unknown-host");
if (resolved.host.transport !== "http") {
return fail(
`host '${hostId}' is SSH-declared; cross-host queue writes require an http-transport registry entry (url + bearer)`,
`host '${hostId}' is SSH-declared; cross-host queue writes require an http-transport registry entry (url; bearer optional)`,
"unsupported-transport",
);
}
@@ -76,13 +76,14 @@ describe("hosts-registry writer twin — P3 byte parity with the CLI addHostEntr
expect(readFileSync(pathB, "utf8")).toBe(readFileSync(pathA, "utf8"));
});
it("identical validation verdicts: reserved id + exactly-one-bearer rejected by BOTH, same error text", () => {
it("identical validation verdicts: reserved id + both-bearer rejected by BOTH, same error text", () => {
const pathA = join(dirA, "hosts.yaml");
const pathB = join(dirB, "hosts.yaml");
for (const bad of [
{ id: "local", transport: "ssh", target: "a" },
{ id: "../escape", transport: "ssh", target: "a" },
{ id: "x", transport: "http", url: "http://x" },
// NOTE: a URL-only http entry (no bearer) is now VALID (anonymous/
// tokenless daemon) — only BOTH pointers together is rejected.
{ id: "x", transport: "http", url: "http://x", bearer_env: "T", bearer_file: "/f" },
]) {
const a = cliAddHostEntry(bad, pathA);
@@ -38,8 +38,8 @@ const SHARED_FIXTURES: Array<{ label: string; parsed: unknown; ok: boolean }> =
{ label: "invalid: unknown transport", parsed: { hosts: [{ id: "x", transport: "carrier-pigeon", target: "a" }] }, ok: false },
{ label: "invalid: ssh missing target", parsed: { hosts: [{ id: "x", transport: "ssh" }] }, ok: false },
{ label: "invalid: http missing url", parsed: { hosts: [{ id: "x", transport: "http", bearer_env: "T" }] }, ok: false },
{ label: "invalid: http with NO bearer (exactly-one rule)", parsed: { hosts: [{ id: "x", transport: "http", url: "http://x" }] }, ok: false },
{ label: "invalid: http with BOTH bearers (exactly-one rule)", parsed: { hosts: [{ id: "x", transport: "http", url: "http://x", bearer_env: "T", bearer_file: "/f" }] }, ok: false },
{ label: "valid: http with NO bearer (anonymous/tokenless daemon)", parsed: { hosts: [{ id: "x", transport: "http", url: "http://x" }] }, ok: true },
{ label: "invalid: http with BOTH bearers (never both)", parsed: { hosts: [{ id: "x", transport: "http", url: "http://x", bearer_env: "T", bearer_file: "/f" }] }, ok: false },
{ label: "invalid: empty id", parsed: { hosts: [{ id: " ", transport: "ssh", target: "a" }] }, ok: false },
// OPR.0.4.6.MH1 FR-7 — reserved host ids rejected in BOTH twins.
{ label: "invalid: reserved id 'kernel' (human-seat collision)", parsed: { hosts: [{ id: "kernel", transport: "ssh", target: "a" }] }, ok: false },
@@ -32,6 +32,7 @@ import type { HostRegistry } from "../src/domain/hosts/hosts-registry-reader.js"
const REGISTRY: HostRegistry = {
hosts: [
{ id: "vps-b", transport: "http", url: "http://vps-b:7433", bearer_env: "MH3B" },
{ id: "anon-b", transport: "http", url: "http://anon-b:7433" },
{ id: "ssh-1", transport: "ssh", target: "x.local" },
],
};
@@ -137,6 +138,34 @@ describe("MH-3 C1 — cross-host queue create (route)", () => {
expect(rowCount(db)).toBe(0);
});
it("cross-host create to a URL-only (anonymous) host: forward omits Authorization; bearer host still sends it", async () => {
const anonHeaders: { value?: HeadersInit } = {};
const anonApp = makeApp({
db,
bus,
fetchImpl: (async (_url: string | URL | Request, init?: RequestInit) => {
anonHeaders.value = init?.headers;
return jsonResponse({ qitemId: "qitem-anon-1", destinationSession: "dev@rig-b", state: "pending" }, 201);
}) as unknown as typeof fetch,
});
const anonRes = await post(anonApp, { ...BASE, hostId: "anon-b" });
expect(anonRes.status).toBe(201);
expect("Authorization" in ((anonHeaders.value ?? {}) as Record<string, string>)).toBe(false);
const bearerHeaders: { value?: HeadersInit } = {};
const bearerApp = makeApp({
db,
bus,
fetchImpl: (async (_url: string | URL | Request, init?: RequestInit) => {
bearerHeaders.value = init?.headers;
return jsonResponse({ qitemId: "qitem-b-1", destinationSession: "dev@rig-b", state: "pending" }, 201);
}) as unknown as typeof fetch,
});
const bearerRes = await post(bearerApp, { ...BASE, hostId: "vps-b" });
expect(bearerRes.status).toBe(201);
expect((bearerHeaders.value as Record<string, string>)["Authorization"]).toBe("Bearer remote-token");
});
it("cross-host create with a caller --id: forwards THAT id (mint only if absent)", async () => {
const capture: { body?: Record<string, unknown> } = {};
const app = makeApp({
@@ -6,7 +6,7 @@
// proves the consumer formatting; these tests pin the structured result.
import { describe, it, expect } from "vitest";
import { remoteJsonRequest } from "../src/domain/hosts/remote-daemon-http.js";
import { remoteJsonRequest, remoteRawRequest } from "../src/domain/hosts/remote-daemon-http.js";
import { LOCAL_HOST_ID, hostsCovered } from "../src/domain/hosts/fanout-contract.js";
import type { AggregatedPayload } from "../src/domain/hosts/fanout-contract.js";
import type { HttpHostEntry } from "../src/domain/hosts/hosts-registry-reader.js";
@@ -130,3 +130,44 @@ describe("fanout-contract — the shared intra-P4 payload (arch adjudication: 15
expect(hostsCovered({ items: [], hosts: [...payload.hosts, { hostId: "vps-b", status: "ok" }] }, [LOCAL_HOST_ID, "vps-b", "ssh-1"])).toBe(false); // duplicates caught
});
});
describe("anonymous (URL-only) http host — no Authorization header, no request short-circuit", () => {
const ANON: HttpHostEntry = { id: "anon-b", transport: "http", url: "http://anon-b:7433/" };
it("remoteJsonRequest: URL-only host sends NO Authorization header; 2xx still returns payload", async () => {
const capture: { url?: string; init?: RequestInit } = {};
const res = await remoteJsonRequest(ANON, "/api/queue/list", {
method: "GET",
timeoutMs: 1000,
env: {},
fetchImpl: fetchStub(200, { items: [] }, capture),
});
expect(res).toMatchObject({ ok: true, status: 200 });
const headers = capture.init?.headers as Record<string, string>;
expect("Authorization" in headers).toBe(false);
});
it("remoteRawRequest: URL-only host sends NO Authorization header; origin answer is ok:true", async () => {
const capture: { url?: string; init?: RequestInit } = {};
const res = await remoteRawRequest(ANON, "/api/ps", {
timeoutMs: 1000,
env: {},
fetchImpl: fetchStub(200, { rigs: [] }, capture),
});
expect(res).toMatchObject({ ok: true, status: 200 });
const headers = capture.init?.headers as Record<string, string>;
expect("Authorization" in headers).toBe(false);
});
it("configured bearer_env host still carries Authorization (fail-closed path unchanged)", async () => {
const capture: { url?: string; init?: RequestInit } = {};
await remoteJsonRequest(HOST, "/api/x", {
method: "GET",
timeoutMs: 1000,
env: ENV,
fetchImpl: fetchStub(200, {}, capture),
});
const headers = capture.init?.headers as Record<string, string>;
expect(headers["Authorization"]).toBe("Bearer tok-1");
});
});