mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(daemon,cli): Restore-Packet M2c-Daemon — full-read transcript route + final M2 regression
Closes M2c-Daemon dispatch qitem-20260502020626-8cd2b7a6. Final sub-chunk of M2; M2 marshal-acceptance gate evaluated as a whole at this commit. Predecessors: - M2a R2 ACCEPT: openrig 5e3a... (schema-validator + JSON schema asset). - M2b ACCEPT: openrigbdf4cf6(runtime parsers + redaction + omitted-records). - M2b R2 ACCEPT: openrig5c87145(Claude parser nested tool_use/tool_result). - M2c-CLI ACCEPT: openrige5de3ab(packet-writer atomic emission + write action). - M2c-CLI R2 ACCEPT: openriga8401f6(--source-jsonl provenance fix; honest fallback). AUTH PRIMITIVE (NAMED): The new GET /api/transcripts/:session/full route uses NO session-scoped authorization. Per orch decision approved-option-a-open-local-transcript-full-read-with-redaction (escalation qitem-20260502020833-68e4eca3), the route adopts the existing tail/grep posture: open route within the daemon's local-trust boundary (127.0.0.1:7433 default), with route-level redaction as the protective primitive. Audit confirmed the daemon middleware stack (server.ts:207-254) has no authentication layer; the only auth-bearing route in the daemon is activity.ts:8-27 (bearer/header token). No caller-identity primitive exists for transcripts today. ORCH-REFRAMED TEST PLAN: the originally-requested session-scoped denial test (case b in dispatch) was reframed by orch to session-resolution failure (404 unknown session), since no caller-identity primitive exists. A future Product Lab signal should propose a coherent transcript-read auth policy across tail/grep/full — that work is OUT OF SCOPE for M2c-Daemon and is filed as a follow-up signal in this closeout. ROUTE PATH: GET /api/transcripts/:session/full (consistent with /:session/tail and /:session/grep naming). DAEMON SIDE (packages/daemon/): 1. NEW packages/daemon/src/domain/transcript-redaction.ts (35 lines): - TRANSCRIPT_SECRET_PATTERNS array: 5 regexes mirroring CLI's redaction.ts:31-37 (sk-*, gh[pousr]_*, github_pat_*, Bearer *, base64≥40). Patterns duplicated rather than imported because @openrig/cli is not a daemon dependency (and adding it would invert the package graph). Drift between these arrays is a defect; reviewers should diff them on any change. - redactTranscriptContent(text): pure function applying the v0 policy. 2. EXTEND packages/daemon/src/domain/transcript-store.ts: - readFull(rigName, sessionName): string | null — returns raw file contents (no terminal-cleanup heuristics; callers handle as needed). Returns "" for empty, null for missing/error. Imports readFileSync from node:fs. 3. EXTEND packages/daemon/src/routes/transcripts.ts: - GET /:session/full route at L222-244. Resolves session via existing resolveSessionToRig (inherits 404 unknown / 409 ambiguity). Calls transcriptStore.readFull → applies redactTranscriptContent BEFORE c.json serialization. Per Quality Lesson v9: the wire payload MUST be already redacted; client-side redaction is not relied upon. - Disabled-store check matches tail/grep behavior (404 with explicit "Transcripts are disabled" guidance). - Missing-file response: 404 with explicit "No transcript for '<session>'" guidance (mirrors tail/grep wording). DAEMON ROUTE TESTS (packages/daemon/test/transcript-routes.test.ts): 6 new test cases (4 dispatch-required + 1 ambiguity bonus per orch's "if easy" + 1 missing-file companion): - transcript-routes.test.ts:270-283 — (a) authorized success: existing session returns 200 with full content. - transcript-routes.test.ts:285-296 — (b) session-resolution failure (orch reframe of denial): unknown session returns 404 with "not found" + "rig ps" guidance. - transcript-routes.test.ts:298-325 — (c) route-level redaction: 4 synthetic credential patterns (sk-*, ghp_*, github_pat_*, Bearer *) planted in fixture; assertion that none appear in wire payload AND [REDACTED] marker present AND surrounding context preserved. - transcript-routes.test.ts:327-336 — (d-disabled) transcripts disabled: 404 with "disabled" guidance. - transcript-routes.test.ts:338-351 — (d-missing) known session but no file: 404 with "No transcript" guidance. - transcript-routes.test.ts:353-372 — ambiguity 409 across rigs (matches tail/grep precedent; orch easy-bonus case). CLI FINAL M2 REGRESSION (packages/cli/test/restore-packet.test.ts): 6 new test cases: - M2c-Daemon final regression — Velocity 4-pack round-trip via CLI: 4 cases, one per Velocity prior-art role (driver, guard, planner, tester). Loads each role's restore-summary.json from field-notes/2026-04-27-velocity-claude-from-codex-restore/, locates the original Codex JSONL via source_jsonl field, runs --source-jsonl through createProgram().parseAsync (Quality Lesson v12 surface), passes --source-session-id-override + --source-rig-override matching the reference packet's source_session (Codex JSONLs carry UUID-shaped rollout ids, not <seat>@<rig> shape — the operator-supplied canonical name in the prior-art .mjs is mirrored via overrides). Asserts: schema validation, source_session_id == reference source_session, source_rig from @ split, source_cwd matches reference, redaction_policy_id == "openrig-v0", omitted_classes non-empty (Velocity packets have many reasoning_records and function_call_output records). Driver case parses 16,455 JSONL lines → 1,218 extracted messages, matching the original .mjs run exactly. Tests gated on file presence; skip with console.warn when source JSONLs absent (other-host portability). - M2c-Daemon final regression — CLI-surface redaction + omitted round-trips: 2 cases. - --source-jsonl with credential-pattern fixture: synthetic sk-* in assistant message → emitted transcript-latest.md does NOT contain the secret AND contains [REDACTED]; summary.omitted_classes contains "redacted_secrets"; redaction_policy_id == "openrig-v0". - --source-jsonl with M2b R2 nested Claude content (tool_use + tool_result inside top-level assistant/user records — Quality Lesson v11 reproducer): emitted summary.omitted_classes contains both function_call_output and raw_tool_outputs; provenance fields preserved from per-record sessionId; touched-files inventory captures path from omitted tool_result. Exercises Quality Lesson v11 + v12 simultaneously via CLI surface. PROVENANCE PARITY WITH VELOCITY PRIOR ART: Driver: 1218 extracted messages (matches original .mjs exactly). Guard: 1031, Planner: 602, Tester: 854 (no original-run reference cached but counts realistic). All four packets validate against v0 schema; all four preserve source_session_id + source_rig + source_cwd from operator overrides matching the reference packets. RE-VERIFICATION COMMANDS (all green): cd packages/cli: - npx vitest run test/restore-packet.test.ts → 86/86 PASS (75 baseline + 5 R2 + 6 M2c-Daemon final regression). - npx tsc --noEmit → exit 0. - npm run build → exit 0; dist/schemas/*.json present. cd packages/daemon: - npx vitest run test/transcript-routes.test.ts → 17/17 PASS (11 baseline + 6 new full-read). - npx tsc --noEmit → exit 0. - npm run build → exit 0. Compiled-output check (Quality Lesson v10 + v12): - node -e 'await import("./dist/routes/transcripts.js")' → transcriptRoutes: function ✓ - node -e 'await import("./dist/domain/transcript-redaction.js")' → redactTranscriptContent: function; sample input "token sk-FakeAbCdEfGhIjKlMnOpQr seen" → "token [REDACTED] seen" ✓ Per-package honest baselines (Quality Lesson v8): - packages/cli (npx vitest run --root packages/cli): 730 total, 726 PASS / 4 FAIL named flakes (chatroom wait timing, config-store env pollution, two preflight host-port). - packages/daemon (npx vitest run --root packages/daemon): 2242 total, 2242 PASS / 0 FAIL. No regression vs M2c-CLI R2 baseline (CLI: 720→726 = +6 new; daemon: 2236→2242 = +6 new). M2 MARSHAL-ACCEPTANCE GATE (per IMPL § M2 line 142-152, evaluated end of M2c-Daemon): - M1 contract approved → DONE. - restore-packet write emits valid packets matching contract § 1 shape (4 required + optional transcript.md) for BOTH adapters → DONE. - Velocity JSONL round-trip green for ALL FOUR role packets at provenance + omitted + redaction + schema fields → DONE (4 CLI tests). - Mock-daemon --source-session round-trip green (exercising new full-read route shape) → DONE (preserved at restore-packet.test.ts:1088-1148; updated for R2 with --source-rig-override). - Mutual-exclusion + auto-detect tests still green → DONE (M2a regression preserved). - Atomic emission verified (validation failure → no partial directory) → DONE (preserved at restore-packet.test.ts:943-963 + 928-941). - Redaction + omitted-record filtering verified end-to-end → DONE (new CLI redaction test + Velocity 4-pack + daemon route test c). - Daemon route tests for full-read route landed and green (4 cases: auth + redaction + disabled/missing) → DONE (6 cases shipped — 4 required + 2 bonus per orch). - Closeout names auth primitive + cites daemon route tests by file:line → DONE (above). - No regression in existing CLI / daemon test suites → DONE. - Posture declared full QA/testing window → DONE. BOUNDARY DISCIPLINE: - ONLY 5 files changed (verified via git diff --cached --stat): packages/cli/test/restore-packet.test.ts (+235) packages/daemon/src/domain/transcript-redaction.ts (NEW, +35) packages/daemon/src/domain/transcript-store.ts (+25/-1; readFull addition) packages/daemon/src/routes/transcripts.ts (+49) packages/daemon/test/transcript-routes.test.ts (+121) - M3+ surfaces UNTOUCHED (read/validate stubs remain M3-pending; live host walk M4-pending; VM proof M5-pending). - Velocity prior-art .mjs at field-notes/2026-04-27-velocity-claude-from-codex-restore/tools/codex-jsonl-to-restore-packet.mjs UNCHANGED. - Existing tail/grep routes UNCHANGED (per orch — no retrofit in this chunk). - Codex/Claude parser, redaction (CLI), runtime-detect, omitted-records, types, schema-validator, M2a CLI shell mutual-exclusion, M2c-CLI packet-writer, R2 deriveProvenance: ALL PRESERVED. - Unrelated dirty work (docs/*, ps.ts, codex-runtime-adapter, daemon SKILL.md) NOT staged. - No auth tokens, device codes, or real credentials in fixtures (Quality Lesson v9). Synthetic patterns in redaction tests use "Fake" prefix to make non-credential intent unambiguous. - No transcript content in daemon stdout/stderr (route returns JSON via c.json; no console.log of content). PRODUCT LAB FOLLOW-UP SIGNAL (per orch directive): A future slice should propose a coherent transcript-read auth policy across /:session/tail, /:session/grep, and /:session/full. Today these are all open routes within the local-trust boundary; route-level redaction is the only protective primitive. Discovery should examine: (1) whether to add session-token bearing at peer-handshake time, (2) whether to repurpose the activity-hook bearer-token primitive for transcript reads, (3) whether to factor a daemon-wide auth middleware that gates by route classification. This signal will be filed as a durable artifact in the dogfood-ready or work-discovery pool by the velocity-driver after this commit lands. CUMULATIVE QUALITY-LESSON CARRY-FORWARD (v1-v9 + v10-v12 candidates): - v6, v7 refined, v8 (honest baseline match per-package), v9 (no transcript content / credentials in logs / fixtures). - v10 (asset packaging): packet-writer + daemon route handler + daemon redaction module ALL importable from compiled dist/. - v11 (provider transcript parser nested-content coverage): exercised in regression suite (CLI-surface omitted-record round-trip with nested Claude shape). - v12 (writer-internal round trips not enough for CLI source adapters): exercised in regression suite (Velocity 4-pack round-trip + redaction round-trip + nested-content round-trip ALL via createProgram().parseAsync). Posture: full QA/testing window (matches dispatch + IMPL § M2 line 152). Dispatch: qitem-20260502020626-8cd2b7a6. Stop-and-flag: qitem-20260502020833-68e4eca3 (orch decision approved-option-a). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
a8401f6181
commit
c7b74fa08f
5 files changed
+465
-1
No files matched your search
@@ -1450,3 +1450,238 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
|
||||
expect(summary.source_rig).toBe("operator-rig");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// M2c-Daemon final M2 regression — Velocity 4-pack round-trip via CLI.
|
||||
//
|
||||
// Per dispatch qitem-20260502020626-8cd2b7a6 (item 4): load each of the
|
||||
// four Velocity prior-art role packets, locate their original Codex
|
||||
// JSONL, run --source-jsonl through the v0 generator, and assert
|
||||
// provenance fields preserved + schema valid + redaction policy applied.
|
||||
//
|
||||
// Tests are gated on file presence: the original JSONL files live in
|
||||
// ~/.codex/sessions/2026/04/23/ on the host. When absent (other devs /
|
||||
// CI), the cases skip with a guard message — the synthetic Velocity
|
||||
// round-trip describe above already exercises the structural shape; this
|
||||
// suite adds REAL data validation when available (dispatch-condition).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("M2c-Daemon final regression — Velocity 4-pack round-trip via CLI", () => {
|
||||
let tmpRoot: string;
|
||||
const VELOCITY_ROOT = "/Users/wrandom/code/substrate/shared-docs/openrig-work/field-notes/2026-04-27-velocity-claude-from-codex-restore";
|
||||
|
||||
beforeEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "restore-packet-velocity-cli-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
for (const role of ["driver", "guard", "planner", "tester"]) {
|
||||
it(`Velocity ${role} packet: --source-jsonl round-trip yields v0 packet with preserved provenance`, async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const refSummaryPath = path.join(VELOCITY_ROOT, role, "restore-summary.json");
|
||||
if (!fs.existsSync(refSummaryPath)) {
|
||||
console.warn(`SKIP: Velocity reference packet absent at ${refSummaryPath}`);
|
||||
return;
|
||||
}
|
||||
const refSummary = JSON.parse(fs.readFileSync(refSummaryPath, "utf-8")) as {
|
||||
source_jsonl?: string;
|
||||
source_session?: string;
|
||||
source_cwd?: string;
|
||||
};
|
||||
const sourceJsonl = refSummary.source_jsonl ?? "";
|
||||
if (!sourceJsonl || !fs.existsSync(sourceJsonl)) {
|
||||
console.warn(`SKIP: Velocity ${role} source JSONL absent at ${sourceJsonl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const target = path.join(tmpRoot, `packet-${role}`);
|
||||
|
||||
// Codex JSONL session_meta carries a UUID-shaped rollout id, not a
|
||||
// <seat>@<rig> name. The Velocity prior-art .mjs got the canonical
|
||||
// name from a CLI arg; for the v0 round-trip we mirror that by
|
||||
// passing --source-session-id-override + --source-rig-override
|
||||
// matching the reference packet's source_session value.
|
||||
const canonicalSessionId = refSummary.source_session ?? "";
|
||||
const canonicalRig = canonicalSessionId.split("@")[1] ?? "openrig-velocity-code";
|
||||
|
||||
const program = createProgram();
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "restore-packet", "write",
|
||||
"--source-jsonl", sourceJsonl,
|
||||
"--target", target,
|
||||
"--target-rig", "openrig-velocity-claude",
|
||||
"--target-runtime", "claude-code",
|
||||
"--source-session-id-override", canonicalSessionId,
|
||||
"--source-rig-override", canonicalRig,
|
||||
"--current-work-summary", `Velocity ${role} round-trip via M2c-Daemon final regression.`,
|
||||
"--authority-boundaries", `Restored ${role} authority for Velocity-replay context.`,
|
||||
]);
|
||||
|
||||
// v0 packet structure check.
|
||||
expect(fs.existsSync(target)).toBe(true);
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(target, "restore-summary.json"), "utf-8"));
|
||||
const validation = validateRestoreSummary(summary);
|
||||
expect(validation.valid, JSON.stringify(validation.errors)).toBe(true);
|
||||
|
||||
// Provenance fields preserved (Velocity prior-art -> v0 mapping).
|
||||
expect(summary.source_session_id).toBe(refSummary.source_session);
|
||||
// source_rig derived from <seat>@<rig> split of source_session.
|
||||
const expectedRig = refSummary.source_session?.split("@")[1];
|
||||
expect(summary.source_rig).toBe(expectedRig);
|
||||
expect(summary.source_cwd).toBe(refSummary.source_cwd);
|
||||
|
||||
// Redaction policy applied.
|
||||
expect(summary.redaction_policy_id).toBe("openrig-v0");
|
||||
|
||||
// Omitted-record counts are reasonable (Velocity packets should have
|
||||
// many reasoning_records and function_call_output records since they
|
||||
// were generated from full Codex sessions).
|
||||
expect(Array.isArray(summary.omitted_classes)).toBe(true);
|
||||
expect(summary.omitted_classes.length).toBeGreaterThan(0);
|
||||
|
||||
// bounded_latest_transcript file was written.
|
||||
expect(fs.existsSync(path.join(target, "transcript-latest.md"))).toBe(true);
|
||||
}, 90000); // Velocity JSONLs are 22-37MB; allow generous timeout.
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// M2c-Daemon final M2 regression — CLI-surface redaction + omitted
|
||||
// round-trips. Per dispatch items 7 + 8: exercise the parse-to-write
|
||||
// path through createProgram() (Quality Lesson v12 carry-forward) for
|
||||
// (a) credential-pattern redaction via --source-jsonl, (b) M2b R2
|
||||
// nested-content omitted-record counting (Quality Lesson v11
|
||||
// carry-forward).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("M2c-Daemon final regression — CLI-surface redaction + omitted round-trips", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "restore-packet-final-cli-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("--source-jsonl with credential-pattern fixture: emitted transcript is redacted at the wire", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
// Synthetic credentials. NOT real tokens. Per Quality Lesson v9.
|
||||
const src = path.join(tmpRoot, "with-creds.jsonl");
|
||||
fs.writeFileSync(src, [
|
||||
JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: { cwd: "/x", id: "creds-driver@creds-rig" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "saw token sk-FakeAbCdEfGhIjKlMnOpQr in logs" }],
|
||||
},
|
||||
}),
|
||||
].join("\n"), "utf8");
|
||||
|
||||
const target = path.join(tmpRoot, "packet-creds");
|
||||
const program = createProgram();
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "restore-packet", "write",
|
||||
"--source-jsonl", src,
|
||||
"--target", target,
|
||||
"--target-rig", "openrig-velocity-claude",
|
||||
"--target-runtime", "claude-code",
|
||||
"--current-work-summary", "redaction CLI round-trip.",
|
||||
"--authority-boundaries", "redaction proof.",
|
||||
]);
|
||||
|
||||
const transcriptLatest = fs.readFileSync(path.join(target, "transcript-latest.md"), "utf-8");
|
||||
expect(transcriptLatest).not.toContain("sk-FakeAbCdEfGhIjKlMnOpQr");
|
||||
expect(transcriptLatest).toContain("[REDACTED]");
|
||||
|
||||
// Summary's omitted_classes records the redaction occurrence.
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(target, "restore-summary.json"), "utf-8"));
|
||||
expect(summary.omitted_classes).toContain("redacted_secrets");
|
||||
expect(summary.redaction_policy_id).toBe("openrig-v0");
|
||||
});
|
||||
|
||||
it("--source-jsonl with M2b R2 nested Claude content (tool_use + tool_result): omitted counts populated via CLI surface", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
// Claude transcript with nested tool_use + tool_result inside top-level
|
||||
// assistant/user records (the M2b R2 reproducer shape per Quality
|
||||
// Lesson v11 carry-forward).
|
||||
const src = path.join(tmpRoot, "claude-nested.jsonl");
|
||||
fs.writeFileSync(src, [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_1", name: "Bash", input: { cmd: "pwd" } },
|
||||
{ type: "text", text: "checking now" },
|
||||
],
|
||||
},
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
sessionId: "claude-final@openrig-velocity-claude",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/wrandom/code/projects/openrig-hub" },
|
||||
],
|
||||
},
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
sessionId: "claude-final@openrig-velocity-claude",
|
||||
}),
|
||||
].join("\n"), "utf8");
|
||||
|
||||
const target = path.join(tmpRoot, "packet-claude-nested");
|
||||
const program = createProgram();
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "restore-packet", "write",
|
||||
"--source-jsonl", src,
|
||||
"--target", target,
|
||||
"--target-rig", "openrig-velocity-claude",
|
||||
"--target-runtime", "claude-code",
|
||||
"--source-runtime", "claude-code",
|
||||
"--current-work-summary", "M2b R2 nested-content via CLI surface.",
|
||||
"--authority-boundaries", "Quality Lesson v11 carry-forward.",
|
||||
]);
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(target, "restore-summary.json"), "utf-8"));
|
||||
const validation = validateRestoreSummary(summary);
|
||||
expect(validation.valid, JSON.stringify(validation.errors)).toBe(true);
|
||||
expect(summary.omitted_classes).toContain("function_call_output");
|
||||
expect(summary.omitted_classes).toContain("raw_tool_outputs");
|
||||
// Provenance from session_meta-equivalent (Claude per-record sessionId).
|
||||
expect(summary.source_session_id).toBe("claude-final@openrig-velocity-claude");
|
||||
expect(summary.source_rig).toBe("openrig-velocity-claude");
|
||||
expect(summary.source_cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
|
||||
// Touched-files inventory captured the path from the omitted tool_result.
|
||||
expect(summary.touched_files.top_paths.some((p: { path: string }) =>
|
||||
p.path === "/Users/wrandom/code/projects/openrig-hub")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// transcript-redaction.ts — daemon-side mirror of the v0 redaction policy.
|
||||
//
|
||||
// Mirrors packages/cli/src/restore-packet/redaction.ts SECRET_PATTERNS
|
||||
// (M1 contract § 4 / openrig-v0 policy). Patterns duplicated rather than
|
||||
// imported because @openrig/cli is not a daemon dependency (and adding
|
||||
// it would invert the package graph). For M2c-Daemon scope: keep this
|
||||
// list byte-equivalent to the CLI list. A future slice may factor a
|
||||
// shared @openrig/redaction package; until then, drift between these
|
||||
// two arrays is a defect — reviewers should diff them on any change.
|
||||
|
||||
/** v0 secret-pattern list. Mirrors packages/cli/src/restore-packet/redaction.ts:31-37. */
|
||||
export const TRANSCRIPT_SECRET_PATTERNS: readonly RegExp[] = [
|
||||
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
|
||||
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
|
||||
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
||||
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/g,
|
||||
/\b[A-Za-z0-9+/]{40,}={0,2}\b/g,
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply the openrig-v0 redaction policy to transcript content. Returns
|
||||
* the input with each match of the secret patterns replaced by the
|
||||
* literal string `[REDACTED]`. Pure function; no side effects.
|
||||
*
|
||||
* Used by GET /api/transcripts/:session/full to redact the wire payload
|
||||
* BEFORE serialization, so credential patterns never reach the response
|
||||
* body even when a transcript file contains them.
|
||||
*/
|
||||
export function redactTranscriptContent(text: string): string {
|
||||
let out = text;
|
||||
for (const pattern of TRANSCRIPT_SECRET_PATTERNS) {
|
||||
out = out.replace(pattern, "[REDACTED]");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, appendFileSync, existsSync, openSync, readSync, closeSync, statSync } from "node:fs";
|
||||
import { mkdirSync, appendFileSync, existsSync, openSync, readSync, closeSync, statSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { getCompatibleOpenRigPath } from "../openrig-compat.js";
|
||||
@@ -286,6 +286,30 @@ export class TranscriptStore {
|
||||
.filter((line, index) => !isPromptRedrawDuplicate(line, filtered[index + 1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the entire transcript file as a single string. Returns the raw
|
||||
* file contents (callers handle ANSI/cleanup as needed for their use
|
||||
* case). Returns null if the file is missing OR any I/O error occurs.
|
||||
* Returns "" for an existing-but-empty file.
|
||||
*
|
||||
* Used by GET /api/transcripts/:session/full (M2c-Daemon). Unlike
|
||||
* `readTail`, this does not apply terminal-cleanup heuristics — the
|
||||
* route's caller (e.g., the restore-packet generator) needs the
|
||||
* unfiltered content the runtime emitted. Route-level redaction is
|
||||
* applied at the route layer via `redactTranscriptContent` BEFORE
|
||||
* serialization (per orch decision approved-option-a:
|
||||
* open-route-with-redaction-as-protective-primitive).
|
||||
*/
|
||||
readFull(rigName: string, sessionName: string): string | null {
|
||||
try {
|
||||
const filePath = this.getTranscriptPath(rigName, sessionName);
|
||||
if (!existsSync(filePath)) return null;
|
||||
return readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
grep(rigName: string, sessionName: string, pattern: string): string[] | null {
|
||||
try {
|
||||
const filePath = this.getTranscriptPath(rigName, sessionName);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RigRepository } from "../domain/rig-repository.js";
|
||||
import type { TranscriptStore } from "../domain/transcript-store.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { startTmuxTranscriptCapture } from "../domain/transcript-capture.js";
|
||||
import { redactTranscriptContent } from "../domain/transcript-redaction.js";
|
||||
|
||||
interface SessionRow {
|
||||
node_id: string;
|
||||
@@ -205,5 +206,53 @@ export function transcriptRoutes(): Hono {
|
||||
return c.json({ session: sessionName, pattern, matches });
|
||||
});
|
||||
|
||||
// GET /:session/full — return the full transcript content for a session.
|
||||
//
|
||||
// Per orch decision approved-option-a (escalation
|
||||
// qitem-20260502020833-68e4eca3): this route adopts the existing
|
||||
// tail/grep posture (open route, daemon-local trust boundary). No
|
||||
// session-scoped auth is enforced because no caller-identity primitive
|
||||
// exists in the daemon today. Route-level redaction (M1 contract § 4 /
|
||||
// openrig-v0 policy) is the protective primitive — credential-shaped
|
||||
// patterns are scrubbed from the wire payload BEFORE serialization.
|
||||
//
|
||||
// A future slice may layer a coherent transcript-read auth policy
|
||||
// across tail/grep/full; that work is out of scope for M2c-Daemon and
|
||||
// tracked as a Product Lab follow-up signal.
|
||||
router.get("/:session/full", async (c) => {
|
||||
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore;
|
||||
const db = c.get("db" as never) as Database.Database;
|
||||
const rigRepo = c.get("rigRepo" as never) as RigRepository;
|
||||
const sessionName = c.req.param("session");
|
||||
|
||||
if (!transcriptStore?.enabled) {
|
||||
return c.json(
|
||||
{ error: "Transcripts are disabled. Enable with: rig config set transcripts.enabled true" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const resolution = resolveSessionToRig(db, rigRepo, sessionName);
|
||||
if ("error" in resolution) {
|
||||
return c.json({ error: resolution.error }, resolution.status as 404);
|
||||
}
|
||||
|
||||
const raw = transcriptStore.readFull(resolution.rigName, sessionName);
|
||||
if (raw === null) {
|
||||
return c.json(
|
||||
{
|
||||
error: `No transcript for '${sessionName}'. Transcripts start automatically on next rig up.`,
|
||||
},
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply route-level redaction BEFORE serialization. Per Quality Lesson
|
||||
// v9 + orch decision approved-option-a: the wire payload MUST be
|
||||
// already redacted; do NOT rely on client-side redaction.
|
||||
const content = redactTranscriptContent(raw);
|
||||
return c.json({ session: sessionName, content });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -247,4 +247,125 @@ describe("transcript routes", () => {
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("Invalid grep pattern");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// M2c-Daemon — GET /:session/full route tests.
|
||||
//
|
||||
// Per orch decision approved-option-a-open-local-transcript-full-read-with-redaction
|
||||
// (qitem-20260502020833-68e4eca3): full-read route adopts the existing
|
||||
// tail/grep posture (open route, daemon-local trust boundary). Redaction
|
||||
// is the protective primitive in this slice. The originally-requested
|
||||
// session-scoped denial test was reframed by orch to session-resolution
|
||||
// failure (404 unknown session), since no caller-identity / session-scope
|
||||
// primitive exists in the daemon today.
|
||||
//
|
||||
// Test plan (4 cases per dispatch + 1 ambiguity bonus per orch easy-case):
|
||||
// (a) authorized success → full transcript content in response
|
||||
// (b) session-resolution failure → 404 unknown session
|
||||
// (c) route-level redaction → synthetic credential patterns absent from wire payload
|
||||
// (d) disabled/missing transcript → explicit 404
|
||||
// (extra) ambiguity → 409 (matches tail/grep precedent)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
it("GET /full returns 200 with full transcript content for existing session (success case)", async () => {
|
||||
const fixture = "first line\nsecond line\nthird line\n";
|
||||
const { store } = seedRigWithTranscript(fixture);
|
||||
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });
|
||||
|
||||
const res = await app.request("/api/transcripts/dev-impl@my-rig/full");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.session).toBe("dev-impl@my-rig");
|
||||
expect(typeof body.content).toBe("string");
|
||||
expect(body.content).toContain("first line");
|
||||
expect(body.content).toContain("second line");
|
||||
expect(body.content).toContain("third line");
|
||||
});
|
||||
|
||||
it("GET /full with unknown session returns 404 (session-resolution failure; reframe per orch)", async () => {
|
||||
// Orch reframe: session-scoped denial replaced with session-resolution
|
||||
// failure since no caller-identity/session-scope primitive exists.
|
||||
const store = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
|
||||
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });
|
||||
|
||||
const res = await app.request("/api/transcripts/nonexistent-session/full");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("not found");
|
||||
expect(body.error).toContain("rig ps");
|
||||
});
|
||||
|
||||
it("GET /full applies route-level redaction: synthetic credential patterns absent from wire payload", async () => {
|
||||
// Synthetic credentials covering 4 of the 5 v0 patterns (sk-*, gh*_*,
|
||||
// github_pat_*, Bearer *). NOT real credentials. Per Quality Lesson v9.
|
||||
const fixture = [
|
||||
"INFO startup",
|
||||
"user pasted token sk-FakeAbCdEfGhIjKlMnOpQr in chat",
|
||||
"another sample token ghp_FakeAbCdEfGhIjKlMnOpQrSt was used",
|
||||
"fine-grained pat: github_pat_FakeAbCdEfGhIjKlMnOpQrSt",
|
||||
"auth header: Bearer FakeAbCdEfGhIjKlMnOpQrStUvWxYz123456",
|
||||
"trailing line",
|
||||
].join("\n");
|
||||
const { store } = seedRigWithTranscript(fixture);
|
||||
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });
|
||||
|
||||
const res = await app.request("/api/transcripts/dev-impl@my-rig/full");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
// Wire payload MUST NOT contain any of the planted credential patterns.
|
||||
expect(body.content).not.toContain("sk-FakeAbCdEfGhIjKlMnOpQr");
|
||||
expect(body.content).not.toContain("ghp_FakeAbCdEfGhIjKlMnOpQrSt");
|
||||
expect(body.content).not.toContain("github_pat_FakeAbCdEfGhIjKlMnOpQrSt");
|
||||
expect(body.content).not.toContain("Bearer FakeAbCdEfGhIjKlMnOpQrStUvWxYz123456");
|
||||
// Route-level redaction left a [REDACTED] marker in place of each.
|
||||
expect(body.content).toContain("[REDACTED]");
|
||||
// Surrounding context preserved (redaction is non-destructive at line level).
|
||||
expect(body.content).toContain("INFO startup");
|
||||
expect(body.content).toContain("trailing line");
|
||||
});
|
||||
|
||||
it("GET /full with transcripts disabled returns 404 with guidance (disabled case)", async () => {
|
||||
seedRigWithTranscript("content");
|
||||
const disabledStore = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: false });
|
||||
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: disabledStore });
|
||||
|
||||
const res = await app.request("/api/transcripts/dev-impl@my-rig/full");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("disabled");
|
||||
});
|
||||
|
||||
it("GET /full with known session but no transcript file returns 404 with guidance (missing case)", async () => {
|
||||
// Create rig + session but NO transcript file.
|
||||
const rig = rigRepo.createRig("my-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev-impl", { role: "worker", runtime: "claude-code" });
|
||||
sessionRegistry.registerSession(node.id, "dev-impl@my-rig");
|
||||
|
||||
const store = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
|
||||
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });
|
||||
|
||||
const res = await app.request("/api/transcripts/dev-impl@my-rig/full");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("No transcript");
|
||||
});
|
||||
|
||||
it("GET /full with ambiguous session name across rigs returns 409 (precedent match with tail/grep)", async () => {
|
||||
// Two rigs, same session name → ambiguity 409 (mirrors tail/grep behavior).
|
||||
const rig1 = rigRepo.createRig("my-rig");
|
||||
const node1 = rigRepo.addNode(rig1.id, "dev-impl", { role: "worker", runtime: "claude-code" });
|
||||
sessionRegistry.registerSession(node1.id, "dev-impl@my-rig");
|
||||
|
||||
const rig2 = rigRepo.createRig("other-rig");
|
||||
const node2 = rigRepo.addNode(rig2.id, "dev-impl2", { role: "worker", runtime: "claude-code" });
|
||||
sessionRegistry.registerSession(node2.id, "dev-impl@my-rig");
|
||||
|
||||
const store = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
|
||||
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });
|
||||
|
||||
const res = await app.request("/api/transcripts/dev-impl@my-rig/full");
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("ambiguous");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user