Files
openrig/packages/cli/test/browser-boundary-cli-render.test.ts
008c9faada fix(daemon): bring 0.6.4's browser boundary, legacy-launch retention and browser docs to main (#407)
* fix(restore): retain inconclusive legacy Claude launches (#346)

Co-authored-by: v-openrig-build <[email protected]>
(cherry picked from commit eef01465a3)

* fix(daemon): check target name and browser origin once on /api (#358)

The daemon now checks every /api request once, before any route or cross-host read-through runs, including WebSocket upgrades.

- Target names: localhost, IP literals, this machine's own names and its own MagicDNS name are accepted without setup; other names are refused with a remedy (OPENRIG_ALLOWED_HOSTS, or a configured bearer token, which waives only the name check).
- Browser origins: when present, an Origin must be the built-in UI's own origin (with the web UI enabled) or listed in OPENRIG_ALLOWED_ORIGINS; a token never overrides an origin refusal.
- Host and Origin are read from the raw request headers, so duplicate or empty values are refused rather than collapsed.
- Refusals are a 403 with a one-sentence remedy and code; rig ps and the TUI now show it. Refusal logging is sanitized, bounded and deduplicated.
- Route-level authorization is unchanged.

Adapted for main: the existing /api Origin guard (apiOriginProtection) and the
terminal WebSocket Origin check stay in place. The boundary is mounted before the
Origin guard, so its refusal codes and remedies are what callers see; the terminal
route and its tests are unchanged from main.

(cherry picked from commit 7d77d73b9c)

* docs: add browser access reference (#372)

Adds docs/reference/browser-access.md (which addresses and browser pages the
daemon accepts, the two allowances, refusal remedies and limits) and links it
from the help guide's daemon-access section.

Co-authored-by: dev50-planner <[email protected]>
(cherry picked from commit a3cc6c8a10)

* test(daemon): expect the browser boundary's refusal from the real app

On the real daemon app the /api browser boundary now runs before the Origin
guard, so a cross-origin browser request is refused with the boundary's code
(browser_origin_refused) and remedy. The status stays 403; the middleware-level
cases of the Origin guard are unchanged.

* docs(reference): name the allowed origin the web UI needs at other names

On main the terminal WebSocket check and the /api Origin guard stay in place
alongside the browser boundary, so the web UI opened at a non-loopback name or
address needs that page's exact origin in OPENRIG_ALLOWED_ORIGINS.

---------

Co-authored-by: v-openrig-build <[email protected]>
Co-authored-by: dev50-planner <[email protected]>
2026-10-01 21:19:03 -07:00

79 lines
3.5 KiB
TypeScript

// The real `rig ps` / `rig send` code paths, real DaemonClient and real HTTP, rendering the exact
// refusal body produced by the daemon's browser boundary. (A real CLI request cannot present an
// unlisted target name without DNS: Node fetch sends the URL's host, not an explicit Host header.)
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { Command } from "commander";
import { Hono } from "hono";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import { psCommand } from "../src/commands/ps.js";
import { sendCommand } from "../src/commands/send.js";
import { browserBoundary } from "../../daemon/src/middleware/browser-boundary.js";
let refusalBody = "";
let server: http.Server;
let port = 0;
beforeAll(async () => {
const daemon = new Hono();
daemon.use("/api/*", browserBoundary({ webUiEnabled: false, bearerTokens: [], warn: () => {} }));
daemon.all("/api/*", (c) => c.json({ ok: true }));
const res = await daemon.request("/api/ps", { headers: { Host: "unlisted.example:7433" } });
refusalBody = await res.text();
server = http.createServer((_req, res2) => { res2.writeHead(403, { "content-type": "application/json" }); res2.end(refusalBody); });
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
port = (server.address() as AddressInfo).port;
});
afterAll(async () => { await new Promise<void>((r) => server.close(() => r())); });
afterEach(() => { process.exitCode = undefined; });
function deps() {
const lifecycleDeps: LifecycleDeps = {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn((p: string) => p === STATE_FILE
? JSON.stringify({ pid: process.pid, port, db: "test.sqlite", startedAt: "2026-10-01T00:00:00Z" } as DaemonState)
: null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn((p: string) => p === STATE_FILE),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
};
return { lifecycleDeps, clientFactory: (url: string) => new DaemonClient(url) };
}
async function stderrOf(cmd: Command, argv: string[]): Promise<string> {
const lines: string[] = [];
const orig = console.error;
console.error = (...a: unknown[]) => { lines.push(a.map(String).join(" ")); };
try { await cmd.parseAsync(["node", "rig", ...argv]); } catch { /* exitOverride */ } finally { console.error = orig; }
return lines.join("\n");
}
describe("CLI shows the boundary's remedy sentence", () => {
it("the fixture body is the real boundary refusal", () => {
expect(JSON.parse(refusalBody)).toMatchObject({ code: "untrusted_host" });
});
it("rig ps prints the daemon's sentence before its generic line", async () => {
const prog = new Command().exitOverride();
prog.addCommand(psCommand(deps() as never));
const err = await stderrOf(prog, ["ps"]);
expect(err).toContain("OPENRIG_ALLOWED_HOSTS");
expect(err).toContain("Failed to fetch rig list from daemon (HTTP 403)");
expect(process.exitCode).toBe(2);
});
it("rig send already prints the daemon's error sentence (no change needed)", async () => {
const prog = new Command().exitOverride();
prog.addCommand(sendCommand(deps() as never));
const err = await stderrOf(prog, ["send", "dev-impl@rig", "fixture"]);
expect(err).toContain("OPENRIG_ALLOWED_HOSTS");
});
});