From 052b3074e9a7e2024cf55e341670a699a4d973a9 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 4 Oct 2026 13:27:52 +0900 Subject: [PATCH] feat(session): decode task owner locators from transcript replay Journal target binding needs the effective locator after strict v4 header patch replay, without importing storage effects or a future async scanner. Expose the complete pure parser as a standalone prerequisite and reject malformed owner patches instead of falling back to an earlier locator. Lore-id: c0248b61 Constraint: decoded locators never confer filesystem or deletion authority Constraint: no scanner stub, compatibility alias or automatic owner activation Tested: 125 tests, 8 existing skips and 514 assertions across three suites Tested: coding-agent vendor, Biome and TypeScript checks Confidence: high Scope-risk: narrow Reversibility: revert --- .../task-artifact-owner-transcript.ts | 68 +++++++++++++++ .../task-artifact-owner-transcript.test.ts | 85 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 packages/coding-agent/src/session/internal/task-artifact-owner-transcript.ts create mode 100644 packages/coding-agent/test/task-artifact-owner-transcript.test.ts diff --git a/packages/coding-agent/src/session/internal/task-artifact-owner-transcript.ts b/packages/coding-agent/src/session/internal/task-artifact-owner-transcript.ts new file mode 100644 index 00000000000..2998e28c1f2 --- /dev/null +++ b/packages/coding-agent/src/session/internal/task-artifact-owner-transcript.ts @@ -0,0 +1,68 @@ +import { parseFirstJsonlLine } from "../session-transcript-header"; +import { parseTaskArtifactOwnerLocator, type TaskArtifactOwnerLocator } from "../task-artifact-owner-codec"; + +const transcriptDecoder = new TextDecoder("utf-8", { fatal: true }); + +/** Resolve the path-free owner locator using transcript replay's header-patch semantics. */ +export function taskArtifactOwnerLocatorFromTranscriptBytes( + bytes: Uint8Array, + expectedSessionId: string, +): TaskArtifactOwnerLocator | undefined { + const header = parseFirstJsonlLine(bytes); + if (header?.type !== "session" || header.id !== expectedSessionId) + throw new Error("task_artifact_owner_transcript_header_invalid"); + let locator = parseTaskArtifactOwnerLocator(header.taskArtifactOwner); + if (typeof header.version !== "number" || header.version < 4) return locator; + const firstEnd = bytes.indexOf(0x0a); + let start = firstEnd < 0 ? bytes.byteLength : firstEnd + 1; + while (start < bytes.byteLength) { + const newline = bytes.indexOf(0x0a, start); + const end = newline < 0 ? bytes.byteLength : newline; + const line = bytes.subarray(start, end); + let record: Record | undefined; + try { + if (line.byteLength > 0) { + const parsed: unknown = JSON.parse(transcriptDecoder.decode(line)); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) + record = parsed as Record; + } + } catch { + const text = Buffer.from(line).toString("utf8"); + if (text.includes("header_patch") && text.includes("taskArtifactOwner")) + throw new Error("task_artifact_owner_patch_invalid"); + } + if ( + record?.type === "header_patch" && + typeof record.patch === "object" && + record.patch !== null && + !Array.isArray(record.patch) && + Object.hasOwn(record.patch, "taskArtifactOwner") + ) { + const patch = record.patch as Record; + if ( + !Object.keys(record).every(key => key === "type" || key === "patch") || + !Object.keys(patch).every( + key => + key === "cwd" || + key === "title" || + key === "titleSource" || + key === "starred" || + key === "taskArtifactOwner", + ) || + (patch.cwd !== undefined && typeof patch.cwd !== "string") || + (patch.title !== undefined && typeof patch.title !== "string") || + (patch.titleSource !== undefined && patch.titleSource !== "auto" && patch.titleSource !== "user") || + (patch.starred !== undefined && typeof patch.starred !== "boolean") + ) + throw new Error("task_artifact_owner_patch_invalid"); + try { + locator = parseTaskArtifactOwnerLocator(patch.taskArtifactOwner); + } catch { + throw new Error("task_artifact_owner_patch_invalid"); + } + } + if (newline < 0) break; + start = newline + 1; + } + return locator; +} diff --git a/packages/coding-agent/test/task-artifact-owner-transcript.test.ts b/packages/coding-agent/test/task-artifact-owner-transcript.test.ts new file mode 100644 index 00000000000..f80ea67e8e4 --- /dev/null +++ b/packages/coding-agent/test/task-artifact-owner-transcript.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; +import { taskArtifactOwnerLocatorFromTranscriptBytes } from "../src/session/internal/task-artifact-owner-transcript"; +import { ownerIdForSession, type TaskArtifactOwnerLocator } from "../src/session/task-artifact-owner-codec"; + +const sessionId = "transcript-owner-session"; +const initial: TaskArtifactOwnerLocator = { + schemaVersion: 1, + ownerId: ownerIdForSession(sessionId), + directoryDev: "12", + directoryIno: "34", +}; +const updated: TaskArtifactOwnerLocator = { ...initial, directoryIno: "56" }; +const encode = (records: readonly unknown[], trailingNewline = true): Uint8Array => + new TextEncoder().encode(records.map(record => JSON.stringify(record)).join("\n") + (trailingNewline ? "\n" : "")); +const header = (version = 4) => ({ type: "session", version, id: sessionId, taskArtifactOwner: initial }); + +describe("task owner transcript locator replay", () => { + it("replays the latest v4 locator patch without requiring a trailing newline", () => { + const bytes = encode( + [ + header(), + { type: "message", role: "assistant", content: "unrelated" }, + { type: "header_patch", patch: { title: "retained title" } }, + { type: "header_patch", patch: { taskArtifactOwner: updated, cwd: "/workspace", starred: true } }, + ], + false, + ); + expect(taskArtifactOwnerLocatorFromTranscriptBytes(bytes, sessionId)).toEqual(updated); + }); + + it("introduces a locator through a valid v4 patch and leaves v3 replay unchanged", () => { + const patch = { type: "header_patch", patch: { taskArtifactOwner: updated, titleSource: "auto" } }; + expect( + taskArtifactOwnerLocatorFromTranscriptBytes( + encode([{ type: "session", version: 4, id: sessionId }, patch]), + sessionId, + ), + ).toEqual(updated); + expect(taskArtifactOwnerLocatorFromTranscriptBytes(encode([header(3), patch]), sessionId)).toEqual(initial); + }); + + it("rejects malformed later owner patches rather than falling back to the header locator", () => { + for (const patch of [ + { type: "header_patch", patch: { taskArtifactOwner: updated }, unexpected: true }, + { type: "header_patch", patch: { taskArtifactOwner: updated, unexpected: true } }, + { type: "header_patch", patch: { taskArtifactOwner: updated, titleSource: "manual" } }, + { type: "header_patch", patch: { taskArtifactOwner: updated, starred: "yes" } }, + { type: "header_patch", patch: { taskArtifactOwner: { ...updated, directoryIno: "056" } } }, + { type: "header_patch", patch: { taskArtifactOwner: null } }, + ]) { + expect(() => taskArtifactOwnerLocatorFromTranscriptBytes(encode([header(), patch]), sessionId)).toThrow( + "task_artifact_owner_patch_invalid", + ); + } + }); + + it("refuses truncated and invalid UTF-8 owner patch records", () => { + const prefix = encode([header()]); + const truncated = Buffer.concat([prefix, Buffer.from('{"type":"header_patch","patch":{"taskArtifactOwner":')]); + expect(() => taskArtifactOwnerLocatorFromTranscriptBytes(truncated, sessionId)).toThrow( + "task_artifact_owner_patch_invalid", + ); + const invalidUtf8 = Buffer.concat([ + prefix, + Buffer.from('{"type":"header_patch","patch":{"taskArtifactOwner":"'), + Buffer.from([0xff]), + Buffer.from('"}}\n'), + ]); + expect(() => taskArtifactOwnerLocatorFromTranscriptBytes(invalidUtf8, sessionId)).toThrow( + "task_artifact_owner_patch_invalid", + ); + }); + + it("rejects a different logical session and an invalid initial locator", () => { + expect(() => taskArtifactOwnerLocatorFromTranscriptBytes(encode([header()]), "another-session")).toThrow( + "task_artifact_owner_transcript_header_invalid", + ); + expect(() => + taskArtifactOwnerLocatorFromTranscriptBytes( + encode([{ ...header(), taskArtifactOwner: { ...initial, ownerId: "bad" } }]), + sessionId, + ), + ).toThrow("task_artifact_owner_locator_invalid"); + }); +});