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: openrig bdf4cf6 (runtime parsers + redaction + omitted-records).
- M2b R2 ACCEPT: openrig 5c87145 (Claude parser nested tool_use/tool_result).
- M2c-CLI ACCEPT: openrig e5de3ab (packet-writer atomic emission + write action).
- M2c-CLI R2 ACCEPT: openrig a8401f6 (--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:
mvschwarzandClaude Opus 4.7 committed 2026-05-01 19:20:32 -07:00
1 parent a8401f6181
commit c7b74fa08f
5 files changed
+465 -1

No files matched your search

+235
View File
@@ -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;
}
+25 -1
View File
@@ -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);
+49
View File
@@ -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");
});
});