mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Merge OPR.0.4.0.22 resume token set
This commit is contained in:
commit
e10e33406f
9 files changed
+612
-5
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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 = ?")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user