mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
* fix(restore): retain inconclusive legacy Claude launches (#346) Co-authored-by: v-openrig-build <[email protected]> (cherry picked from commiteef01465a3) * 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 commit7d77d73b9c) * 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 commita3cc6c8a10) * 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]>
79 lines
3.5 KiB
TypeScript
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");
|
|
});
|
|
});
|