Merge OPR.0.4.0.22 resume token set

This commit is contained in:
mvschwarz committed 2026-06-20 00:09:58 -07:00
commit e10e33406f
9 files changed
+612 -5

No files matched your search

+66 -2
View File
@@ -1,11 +1,19 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { DaemonClient, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export type SeatDeps = StatusDeps;
/** Read all of STDIN to EOF as a UTF-8 string. Used by set-resume-token so the
* credential never appears in argv / shell history / ps. Injectable for tests. */
async function defaultReadStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}
interface SeatStatusResponse {
seat_ref: string;
rig_id: string;
@@ -170,13 +178,14 @@ function printSeatError(error: SeatStatusError, fallback: string): void {
}
}
export function seatCommand(depsOverride?: SeatDeps): Command {
export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promise<string> }): Command {
const cmd = new Command("seat")
.description("Inspect OpenRig seat observability state");
const getDeps = (): SeatDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
const readStdin = depsOverride?.readStdin ?? defaultReadStdin;
cmd
.command("status")
@@ -327,5 +336,60 @@ Examples:
console.log(`Cleared ${session}: ${from} -> ready (${clearedBy})`);
});
// OPR.0.4.0.22 — set a managed seat's durable resume token (attested + audited).
// The token is read from STDIN ONLY (never a positional argv, which would leak
// via shell history + argv/ps) and is NEVER echoed back.
cmd
.command("set-resume-token")
.argument("<session>", "Canonical session name (e.g. dev-impl@my-rig)")
.option("--token-stdin", "Read the resume token from STDIN (the only supported input path)")
.requiredOption("--reason <text>", "Operator attestation recorded in the append-only audit event")
.option("--json", "JSON output for agents")
.description("Set a managed seat's durable resume token (token read from stdin; attested + audited)")
.addHelpText("after", `
The token is read from STDIN only (never an argument). Examples:
printf '%s' "$RESUME_TOKEN" | rig seat set-resume-token dev-impl@my-rig --token-stdin --reason "founder re-authed"
pbpaste | rig seat set-resume-token dev-qa@my-rig --token-stdin --reason "manual codex thread id" --json
`)
.action(async (session: string, opts: { tokenStdin?: boolean; reason: string; json?: boolean }) => {
const deps = getDeps();
if (!opts.tokenStdin) {
console.error("set-resume-token requires --token-stdin: the token is read from stdin, never passed as an argument (it would leak via shell history / ps). Pipe it in, e.g. printf '%s' \"$TOKEN\" | rig seat set-resume-token <session> --token-stdin --reason \"...\".");
process.exitCode = 2;
return;
}
const token = (await readStdin()).trim();
if (!token) {
console.error("No resume token received on stdin.");
process.exitCode = 2;
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running") {
console.error("Daemon not running.");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(status));
const res = await client.post<Record<string, unknown>>(
`/api/sessions/${encodeURIComponent(session)}/resume-token`,
{ token, reason: opts.reason },
{ headers: terminalAuthHeaders() },
);
// The token is NEVER echoed in either output mode (the daemon response is
// already redacted).
if (opts.json) {
console.log(JSON.stringify(res.data));
if (res.status >= 400) process.exitCode = 1;
return;
}
if (res.status >= 400) {
console.error(`error: ${String(res.data["message"] ?? res.data["error"] ?? "unknown")}`);
process.exitCode = 1;
return;
}
console.log(`Set resume token for ${session}: ${String(res.data["resumeType"] ?? "")} (provenance: operator). Token redacted.`);
});
return cmd;
}
@@ -0,0 +1,93 @@
// OPR.0.4.0.22 FR-1 CLI — rig seat set-resume-token. Token is read from STDIN
// only (never argv), carried to the authed route, and never echoed.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { seatCommand, type SeatDeps } from "../src/commands/seat.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
interface PostCall { path: string; body: unknown; options: unknown; }
function makeDeps(opts: {
stdin: string;
response?: { status: number; data: Record<string, unknown> };
postCalls: PostCall[];
}): SeatDeps & { readStdin?: () => Promise<string> } {
const response = opts.response ?? { status: 200, data: { ok: true, resumeType: "claude_id", provenance: "operator", redacted: true } };
return {
lifecycleDeps: {} as SeatDeps["lifecycleDeps"],
clientFactory: () => ({
post: vi.fn(async (path: string, body: unknown, options: unknown) => {
opts.postCalls.push({ path, body, options });
return response;
}),
}) as unknown as ReturnType<SeatDeps["clientFactory"]>,
readStdin: async () => opts.stdin,
};
}
describe("rig seat set-resume-token", () => {
let logs: string[];
let errors: string[];
beforeEach(() => {
logs = [];
errors = [];
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
process.exitCode = undefined;
});
it("reads the token from stdin, posts it in the body with auth headers, and never echoes it", async () => {
const postCalls: PostCall[] = [];
const deps = makeDeps({ stdin: "claude-stdin-tok-789\n", postCalls });
await seatCommand(deps).parseAsync(["node", "rig", "set-resume-token", "dev-impl@my-rig", "--token-stdin", "--reason", "founder re-authed"]);
expect(postCalls.length).toBe(1);
expect(postCalls[0]!.path).toBe("/api/sessions/dev-impl%40my-rig/resume-token");
expect(postCalls[0]!.body).toEqual({ token: "claude-stdin-tok-789", reason: "founder re-authed" });
// Auth headers were passed (terminalAuthHeaders()).
expect(postCalls[0]!.options).toHaveProperty("headers");
// The token never appears in stdout.
expect(logs.join("\n")).not.toContain("claude-stdin-tok-789");
expect(logs.join("\n")).toContain("Token redacted");
expect(process.exitCode).toBeUndefined();
});
it("refuses without --token-stdin (no positional/argv token path) and does NOT call the daemon", async () => {
const postCalls: PostCall[] = [];
const deps = makeDeps({ stdin: "unused", postCalls });
await seatCommand(deps).parseAsync(["node", "rig", "set-resume-token", "dev-impl@my-rig", "--reason", "x"]);
expect(process.exitCode).toBe(2);
expect(postCalls.length).toBe(0);
expect(errors.join(" ")).toMatch(/--token-stdin/);
});
it("errors when stdin is empty", async () => {
const postCalls: PostCall[] = [];
const deps = makeDeps({ stdin: " \n", postCalls });
await seatCommand(deps).parseAsync(["node", "rig", "set-resume-token", "dev-impl@my-rig", "--token-stdin", "--reason", "x"]);
expect(process.exitCode).toBe(2);
expect(postCalls.length).toBe(0);
});
it("surfaces a daemon rejection (422) without echoing the token", async () => {
const postCalls: PostCall[] = [];
const deps = makeDeps({
stdin: "bad tok",
response: { status: 422, data: { error: "invalid_token", message: "Resume token contains disallowed characters (allowed: letters, digits, '.', '_', '-')." } },
postCalls,
});
await seatCommand(deps).parseAsync(["node", "rig", "set-resume-token", "dev-impl@my-rig", "--token-stdin", "--reason", "x"]);
expect(process.exitCode).toBe(1);
expect(errors.join("\n")).toContain("disallowed characters");
expect(errors.join("\n")).not.toContain("bad tok");
});
});
@@ -0,0 +1,63 @@
// OPR.0.4.0.22 FR-2 — per-runtime resume-token validation.
//
// The floor is FORMAT validation: reject malformed input, never fabricate a
// token, and NEVER quote the raw token in an error message (it is
// credential-class — the redaction contract spans CLI output, route
// responses, route errors, logs, and the audit event). A deep "does it
// actually resume" probe is intentionally out of scope (heavy + must not
// mutate live state); format validation is the safe, side-effect-free floor.
export type ResumeType = "claude_id" | "codex_id";
export interface ResumeTokenValidationOk {
ok: true;
resumeType: ResumeType;
/** The trimmed token to persist. Internal value — never logged/echoed. */
token: string;
}
export interface ResumeTokenValidationErr {
ok: false;
/** Describes the FORMAT problem; NEVER contains the token value. */
error: string;
}
const SAFE_TOKEN_RE = /^[A-Za-z0-9._-]+$/;
const MAX_TOKEN_LEN = 200;
/** Resume-id type for a runtime, or null when the runtime has no resume token
* (terminal / unknown). */
export function resumeTypeForRuntime(runtime: string | null): ResumeType | null {
if (runtime === "claude-code") return "claude_id";
if (runtime === "codex") return "codex_id";
return null;
}
export function validateResumeToken(
runtime: string | null,
rawToken: unknown,
): ResumeTokenValidationOk | ResumeTokenValidationErr {
const resumeType = resumeTypeForRuntime(runtime);
if (!resumeType) {
return {
ok: false,
error: `set-resume-token is not supported for runtime "${runtime ?? "unknown"}" (only claude-code and codex have resume tokens).`,
};
}
if (typeof rawToken !== "string") {
return { ok: false, error: "Resume token is missing or not a string." };
}
const token = rawToken.trim();
if (token.length === 0) {
return { ok: false, error: "Resume token is empty." };
}
if (token.length > MAX_TOKEN_LEN) {
return { ok: false, error: `Resume token is too long (max ${MAX_TOKEN_LEN} characters).` };
}
if (!SAFE_TOKEN_RE.test(token)) {
return {
ok: false,
error: "Resume token contains disallowed characters (allowed: letters, digits, '.', '_', '-').",
};
}
return { ok: true, resumeType, token };
}
+54 -3
View File
@@ -15,6 +15,15 @@ interface BindingFields {
cmuxSurface?: string;
}
/** Resume-token provenance precedence (OPR.0.4.0.22). Higher rank wins;
* a lower-rank write never overwrites a higher-rank persisted token.
* operator/attested (deliberate set) > hook (runtime) > scrape (pane). */
export const RESUME_PROVENANCE_RANK: Record<string, number> = {
scrape: 0,
hook: 1,
operator: 2,
};
export class SessionRegistry {
readonly db: Database.Database;
constructor(db: Database.Database) {
@@ -71,12 +80,21 @@ export class SessionRegistry {
}
}
updateResumeToken(sessionId: string, type: string, token: string, provenance?: "hook" | "scrape"): void {
if (provenance === "scrape") {
// OPR.0.4.0.22 — resume-token provenance precedence. A deliberate
// operator/attested set is authoritative and OUTRANKS both the runtime
// hook and the pane scrape; hook outranks scrape (the pre-existing rule).
// A lower-rank write must never clobber a higher-rank persisted token.
updateResumeToken(sessionId: string, type: string, token: string, provenance?: "hook" | "scrape" | "operator"): void {
if (provenance) {
const existing = this.db.prepare(
"SELECT resume_provenance FROM sessions WHERE id = ?"
).get(sessionId) as { resume_provenance: string | null } | undefined;
if (existing?.resume_provenance === "hook") return;
const existingProv = existing?.resume_provenance ?? null;
if (existingProv) {
const existingRank = RESUME_PROVENANCE_RANK[existingProv] ?? -1;
const newRank = RESUME_PROVENANCE_RANK[provenance] ?? -1;
if (newRank < existingRank) return; // lower-rank cannot overwrite higher-rank
}
}
const prov = provenance ?? null;
this.db
@@ -84,6 +102,39 @@ export class SessionRegistry {
.run(type, token, prov, sessionId);
}
/** OPR.0.4.0.22 — resolve a canonical session name to the context needed to
* set its resume token: the latest session row + its node's runtime + the
* current resume provenance. Returns null when no session matches. */
findResumeContextByName(sessionName: string): {
sessionId: string;
nodeId: string;
rigId: string;
runtime: string | null;
currentProvenance: string | null;
} | null {
const row = this.db.prepare(
`SELECT s.id as session_id, s.node_id, n.rig_id, n.runtime, s.resume_provenance
FROM sessions s
JOIN nodes n ON n.id = s.node_id
WHERE s.session_name = ?
ORDER BY s.created_at DESC, s.id DESC LIMIT 1`
).get(sessionName) as {
session_id: string;
node_id: string;
rig_id: string;
runtime: string | null;
resume_provenance: string | null;
} | undefined;
if (!row) return null;
return {
sessionId: row.session_id,
nodeId: row.node_id,
rigId: row.rig_id,
runtime: row.runtime,
currentProvenance: row.resume_provenance ?? null,
};
}
clearResumeToken(sessionId: string): void {
this.db
.prepare("UPDATE sessions SET resume_type = NULL, resume_token = NULL, resume_provenance = NULL WHERE id = ?")
+5
View File
@@ -112,6 +112,11 @@ export type RigEvent =
| { type: "restore.outcome_reconciled"; rigId: string; nodeId: string; attemptId: number; from: "failed" | "attention_required"; to: "operator_recovered"; evidence: { tmux: boolean; fgProcess: "claude" | "codex" | string; resumeTokenUsed: boolean; paneState: "usable" } | { source: string; reason?: string; kind?: string; state?: string; runtimeCwdVerified?: boolean } }
| { type: "agent.activity"; rigId: string; nodeId: string; sessionName: string; runtime: string | null; activity: AgentActivity }
| { type: "agent.session_identity"; rigId: string; nodeId: string; sessionName: string; runtime: string; sessionId: string; provenance: "hook" | "scrape" }
// OPR.0.4.0.22 — append-only audit of a managed resume-token write. Emitted
// on an operator set (`operator_set`) and on reconcile capture-on-adopt
// (`reconcile_capture`). NEVER carries the raw token (credential-class);
// `redacted: true` marks that the token value is intentionally omitted.
| { type: "session.resume_token_set"; rigId: string; nodeId: string; sessionName: string; sessionId: string; resumeType: string; previousProvenance: "hook" | "scrape" | "operator" | null; newProvenance: "operator" | "scrape"; source: "operator_set" | "reconcile_capture"; reason?: string; operator?: string; redacted: true }
| { type: "seat.attention_cleared"; rigId: string; nodeId: string; sessionName: string; from: string; to: "ready"; clearedBy: "evidence" | "operator_attestation"; evidence?: { kind: string; state?: string; reason?: string }; reason?: string; previousError: string | null }
| { type: "rig.imported"; rigId: string; specName: string; specVersion: string }
// Package events (cross-rig, no rigId)
+64
View File
@@ -26,6 +26,8 @@ import { convergeOp } from "../domain/topology-converge.js";
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
import type { MiddlewareHandler } from "hono";
import type { EventBus } from "../domain/event-bus.js";
import { validateResumeToken } from "../domain/resume-token-validation.js";
function terminalAuthGuard(): MiddlewareHandler {
return async (c, next) => {
@@ -475,6 +477,68 @@ sessionAdminRoutes.post("/:sessionName/clear-attention", async (c) => {
return c.json(result, 200);
});
// POST /api/sessions/:sessionName/resume-token — OPR.0.4.0.22.
// Managed, attested, audited SET of a seat's durable resume token (the
// host-upgrade de-risk gate; replaces the manual-SQLite-edit anti-pattern).
// GUARDED by terminalAuthGuard() (credential write). The raw token arrives in
// the request BODY (the CLI reads it from stdin, never argv) and is NEVER
// echoed back, placed in an error message, logged, or written to the audit
// event — it is credential-class.
sessionAdminRoutes.post("/:sessionName/resume-token", terminalAuthGuard(), async (c) => {
const sessionName = decodeURIComponent(c.req.param("sessionName")!);
const { sessionRegistry } = getDeps(c);
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
const body: Record<string, unknown> = await c.req.json().catch(() => ({}));
const reason = typeof body["reason"] === "string" ? body["reason"].trim() : "";
if (!reason) {
return c.json({ error: "missing_reason", message: "set-resume-token requires --reason (operator attestation)." }, 400);
}
const ctx = sessionRegistry.findResumeContextByName(sessionName);
if (!ctx) {
return c.json({ error: "session_not_found", message: `Session '${sessionName}' not found.` }, 404);
}
// FR-2: validate per runtime; reject malformed. The error is redacted (it
// never contains the token value).
const validation = validateResumeToken(ctx.runtime, body["token"]);
if (!validation.ok) {
return c.json({ error: "invalid_token", message: validation.error }, 422);
}
// FR-1: operator/attested provenance OUTRANKS hook/scrape.
sessionRegistry.updateResumeToken(ctx.sessionId, validation.resumeType, validation.token, "operator");
// FR-5: append-only audit event — NO raw token.
if (eventBus) {
eventBus.emit({
type: "session.resume_token_set",
rigId: ctx.rigId,
nodeId: ctx.nodeId,
sessionName,
sessionId: ctx.sessionId,
resumeType: validation.resumeType,
previousProvenance: (ctx.currentProvenance as "hook" | "scrape" | "operator" | null) ?? null,
newProvenance: "operator",
source: "operator_set",
reason,
redacted: true,
});
}
// Response carries NO token (FR-2 redaction).
return c.json({
ok: true,
sessionName,
resumeType: validation.resumeType,
provenance: "operator",
previousProvenance: ctx.currentProvenance ?? null,
reason,
redacted: true,
}, 200);
});
// POST /api/sessions/:sessionRef/unclaim
sessionAdminRoutes.post("/:sessionRef/unclaim", async (c) => {
const sessionRef = decodeURIComponent(c.req.param("sessionRef")!);
@@ -0,0 +1,142 @@
// OPR.0.4.0.22 FR-1b/FR-2/FR-5 — POST /api/sessions/:sessionName/resume-token.
// Managed/attested/audited resume-token set, with per-runtime validation,
// credential redaction across response/error/event, and terminalAuthGuard.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { Hono } from "hono";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { sessionAdminRoutes } from "../src/routes/sessions.js";
function seedRigNodes(db: Database.Database) {
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-1", "test-rig");
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, role, runtime) VALUES (?, ?, ?, ?, ?)").run("node-1", "rig-1", "dev1.impl", "worker", "claude-code");
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, role, runtime) VALUES (?, ?, ?, ?, ?)").run("node-2", "rig-1", "dev1.qa", "qa", "codex");
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, role, runtime) VALUES (?, ?, ?, ?, ?)").run("node-3", "rig-1", "infra.term", "infra", "terminal");
}
function post(app: { request: (p: string, init?: RequestInit) => Promise<Response> }, name: string, body: unknown, headers?: Record<string, string>) {
return app.request(`/api/sessions/${encodeURIComponent(name)}/resume-token`, {
method: "POST",
headers: { "Content-Type": "application/json", ...(headers ?? {}) },
body: JSON.stringify(body),
});
}
function resumeTokenEvents(db: Database.Database): Array<Record<string, unknown>> {
return (db.prepare("SELECT payload FROM events WHERE type = 'session.resume_token_set'").all() as { payload: string }[])
.map((r) => JSON.parse(r.payload) as Record<string, unknown>);
}
describe("POST /api/sessions/:sessionName/resume-token", () => {
let db: Database.Database;
beforeEach(() => {
db = createFullTestDb();
seedRigNodes(db);
});
afterEach(() => { db.close(); });
it("sets a claude token (operator provenance), redacts the token, emits an audit event", async () => {
const { app, sessionRegistry } = createTestApp(db);
sessionRegistry.registerSession("node-1", "dev1-impl@test-rig");
const res = await post(app, "dev1-impl@test-rig", { token: "claude-sess-abc123", reason: "founder re-authed, confirmed live" });
expect(res.status).toBe(200);
const json = await res.json() as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.resumeType).toBe("claude_id");
expect(json.provenance).toBe("operator");
// Redaction: the response NEVER carries the raw token.
expect(JSON.stringify(json)).not.toContain("claude-sess-abc123");
// Persisted with operator provenance.
const ctx = sessionRegistry.findResumeContextByName("dev1-impl@test-rig");
expect(ctx?.currentProvenance).toBe("operator");
const row = db.prepare("SELECT resume_token, resume_type FROM sessions WHERE id = ?").get(ctx!.sessionId) as { resume_token: string; resume_type: string };
expect(row.resume_token).toBe("claude-sess-abc123");
expect(row.resume_type).toBe("claude_id");
// Audit event present, with required fields and NO raw token.
const events = resumeTokenEvents(db);
expect(events.length).toBe(1);
const ev = events[0]!;
expect(ev.source).toBe("operator_set");
expect(ev.newProvenance).toBe("operator");
expect(ev.resumeType).toBe("claude_id");
expect(ev.reason).toBe("founder re-authed, confirmed live");
expect(ev.redacted).toBe(true);
expect(JSON.stringify(ev)).not.toContain("claude-sess-abc123");
});
it("operator set OUTRANKS an existing hook token", async () => {
const { app, sessionRegistry } = createTestApp(db);
const s = sessionRegistry.registerSession("node-1", "dev1-impl@test-rig");
sessionRegistry.updateResumeToken(s.id, "claude_id", "hook-token", "hook");
const res = await post(app, "dev1-impl@test-rig", { token: "operator-token", reason: "manual set" });
expect(res.status).toBe(200);
const row = db.prepare("SELECT resume_token, resume_provenance FROM sessions WHERE id = ?").get(s.id) as { resume_token: string; resume_provenance: string };
expect(row.resume_token).toBe("operator-token");
expect(row.resume_provenance).toBe("operator");
});
it("rejects a malformed token (422) WITHOUT echoing it, and does not persist", async () => {
const { app, sessionRegistry } = createTestApp(db);
const s = sessionRegistry.registerSession("node-1", "dev1-impl@test-rig");
const evil = "tok; rm -rf / #";
const res = await post(app, "dev1-impl@test-rig", { token: evil, reason: "x" });
expect(res.status).toBe(422);
const json = await res.json() as Record<string, unknown>;
expect(JSON.stringify(json)).not.toContain("rm -rf");
expect(JSON.stringify(json)).not.toContain(evil);
// Not persisted.
const row = db.prepare("SELECT resume_token FROM sessions WHERE id = ?").get(s.id) as { resume_token: string | null };
expect(row.resume_token).toBeNull();
expect(resumeTokenEvents(db).length).toBe(0);
});
it("requires --reason (400 when missing)", async () => {
const { app, sessionRegistry } = createTestApp(db);
sessionRegistry.registerSession("node-1", "dev1-impl@test-rig");
const res = await post(app, "dev1-impl@test-rig", { token: "claude-tok" });
expect(res.status).toBe(400);
});
it("404 when the session is not found", async () => {
const { app } = createTestApp(db);
const res = await post(app, "ghost@test-rig", { token: "claude-tok", reason: "x" });
expect(res.status).toBe(404);
});
it("422 for a runtime with no resume token (terminal)", async () => {
const { app, sessionRegistry } = createTestApp(db);
sessionRegistry.registerSession("node-3", "infra-term@test-rig");
const res = await post(app, "infra-term@test-rig", { token: "anything", reason: "x" });
expect(res.status).toBe(422);
});
it("AC-6: the route REQUIRES terminalAuthGuard (401 without the bearer, OK with it)", async () => {
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
sessionRegistry.registerSession("node-1", "dev1-impl@test-rig");
const authApp = new Hono();
authApp.use("*", async (c, next) => {
c.set("terminalBearerToken" as never, "secret-terminal-token");
c.set("sessionRegistry" as never, sessionRegistry);
c.set("eventBus" as never, eventBus);
await next();
});
authApp.route("/api/sessions", sessionAdminRoutes);
const noAuth = await post(authApp, "dev1-impl@test-rig", { token: "claude-tok", reason: "x" });
expect(noAuth.status).toBe(401);
const withAuth = await post(authApp, "dev1-impl@test-rig", { token: "claude-tok", reason: "x" }, { Authorization: "Bearer secret-terminal-token" });
expect(withAuth.status).toBe(200);
});
});
@@ -0,0 +1,75 @@
// OPR.0.4.0.22 FR-2 — per-runtime resume-token validation. Format-validate
// (the floor), reject malformed, NEVER fabricate, and NEVER echo the raw token
// in an error message (credential-class redaction).
import { describe, it, expect } from "vitest";
import { validateResumeToken, resumeTypeForRuntime } from "../src/domain/resume-token-validation.js";
describe("resumeTypeForRuntime", () => {
it("maps runtimes to their resume-id type", () => {
expect(resumeTypeForRuntime("claude-code")).toBe("claude_id");
expect(resumeTypeForRuntime("codex")).toBe("codex_id");
expect(resumeTypeForRuntime("terminal")).toBeNull();
expect(resumeTypeForRuntime(null)).toBeNull();
});
});
describe("validateResumeToken", () => {
it("accepts a well-formed claude token and returns claude_id", () => {
const r = validateResumeToken("claude-code", "abc-123-def-456");
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.resumeType).toBe("claude_id");
expect(r.token).toBe("abc-123-def-456");
}
});
it("accepts a well-formed codex token and returns codex_id", () => {
const r = validateResumeToken("codex", "0199abcd_thread.id");
expect(r.ok).toBe(true);
if (r.ok) expect(r.resumeType).toBe("codex_id");
});
it("trims surrounding whitespace before validating/persisting", () => {
const r = validateResumeToken("claude-code", " tok-123 ");
expect(r.ok).toBe(true);
if (r.ok) expect(r.token).toBe("tok-123");
});
it("rejects an unsupported runtime without fabricating", () => {
const r = validateResumeToken("terminal", "anything");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/not supported/i);
});
it("rejects an empty token", () => {
const r = validateResumeToken("claude-code", " ");
expect(r.ok).toBe(false);
});
it("rejects a token with disallowed characters WITHOUT echoing the token (redaction)", () => {
const secret = "tok with spaces; rm -rf /";
const r = validateResumeToken("claude-code", secret);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).not.toContain(secret);
expect(r.error).not.toContain("rm -rf");
expect(r.error).toMatch(/disallowed characters/i);
}
});
it("rejects a non-string token", () => {
const r = validateResumeToken("claude-code", undefined);
expect(r.ok).toBe(false);
});
it("rejects an over-long token without echoing it", () => {
const huge = "a".repeat(5000);
const r = validateResumeToken("codex", huge);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).not.toContain(huge);
expect(r.error).toMatch(/too long/i);
}
});
});
@@ -94,6 +94,56 @@ describe("SessionRegistry", () => {
expect(updated!.resumeToken).toBe("abc-123-def");
});
// OPR.0.4.0.22 — operator/attested provenance OUTRANKS hook + scrape.
function provenanceOf(sessionId: string): string | null {
const row = db.prepare("SELECT resume_provenance FROM sessions WHERE id = ?").get(sessionId) as { resume_provenance: string | null } | undefined;
return row?.resume_provenance ?? null;
}
function tokenOf(sessionId: string): string | null {
const row = db.prepare("SELECT resume_token FROM sessions WHERE id = ?").get(sessionId) as { resume_token: string | null } | undefined;
return row?.resume_token ?? null;
}
it("operator provenance overwrites an existing hook token", () => {
const s = registry.registerSession("node-1", "dev-impl@test-rig");
registry.updateResumeToken(s.id, "claude_id", "hook-tok", "hook");
registry.updateResumeToken(s.id, "claude_id", "operator-tok", "operator");
expect(tokenOf(s.id)).toBe("operator-tok");
expect(provenanceOf(s.id)).toBe("operator");
});
it("operator provenance overwrites an existing scrape token", () => {
const s = registry.registerSession("node-1", "dev-impl@test-rig");
registry.updateResumeToken(s.id, "claude_id", "scrape-tok", "scrape");
registry.updateResumeToken(s.id, "claude_id", "operator-tok", "operator");
expect(tokenOf(s.id)).toBe("operator-tok");
expect(provenanceOf(s.id)).toBe("operator");
});
it("a hook write does NOT clobber an existing operator token (operator outranks hook)", () => {
const s = registry.registerSession("node-1", "dev-impl@test-rig");
registry.updateResumeToken(s.id, "claude_id", "operator-tok", "operator");
registry.updateResumeToken(s.id, "claude_id", "hook-tok", "hook");
expect(tokenOf(s.id)).toBe("operator-tok");
expect(provenanceOf(s.id)).toBe("operator");
});
it("a scrape write does NOT clobber an existing operator token", () => {
const s = registry.registerSession("node-1", "dev-impl@test-rig");
registry.updateResumeToken(s.id, "claude_id", "operator-tok", "operator");
registry.updateResumeToken(s.id, "claude_id", "scrape-tok", "scrape");
expect(tokenOf(s.id)).toBe("operator-tok");
expect(provenanceOf(s.id)).toBe("operator");
});
it("preserves the existing rule: scrape does NOT clobber hook", () => {
const s = registry.registerSession("node-1", "dev-impl@test-rig");
registry.updateResumeToken(s.id, "claude_id", "hook-tok", "hook");
registry.updateResumeToken(s.id, "claude_id", "scrape-tok", "scrape");
expect(tokenOf(s.id)).toBe("hook-tok");
expect(provenanceOf(s.id)).toBe("hook");
});
it("clearResumeToken clears stored resume metadata", () => {
const session = registry.registerSession("node-1", "r01-dev1-impl");
registry.updateResumeToken(session.id, "claude_id", "abc-123-def");