mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
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:
1 parent
65ade59462
commit
a0c1730577
25 files changed
+303
-66
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user