From ccd438bf87c47feb4112bce1f3f25bc7eb61fbf3 Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Thu, 20 Aug 2026 03:29:48 -0700 Subject: [PATCH 1/2] feat(coding-agent): add persistent subagent foundations --- .../coding-agent/src/core/session-manager.ts | 20 + .../src/core/subagents/persistence.ts | 388 ++++++++++++++++++ .../test/suite/subagent-persistence.test.ts | 217 ++++++++++ 3 files changed, 625 insertions(+) create mode 100644 packages/coding-agent/src/core/subagents/persistence.ts create mode 100644 packages/coding-agent/test/suite/subagent-persistence.test.ts diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e75f1f957..8755fb5ba 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -6,6 +6,7 @@ import { closeSync, createReadStream, existsSync, + fsyncSync, mkdirSync, openSync, readdirSync, @@ -1034,6 +1035,25 @@ export class SessionManager { return this.sessionFile; } + /** Persist a newly created session header before admitting dependent work. */ + _persistHeader(): void { + if (!this.persist || !this.sessionFile) { + throw new Error("Cannot persist an in-memory session header"); + } + if (this.fileEntries.length !== 1 || this.fileEntries[0]?.type !== "session") { + throw new Error("Session header must be persisted before appending entries"); + } + + const fd = openSync(this.sessionFile, "wx"); + try { + writeFileSync(fd, `${JSON.stringify(this.fileEntries[0])}\n`); + fsyncSync(fd); + } finally { + closeSync(fd); + } + this.flushed = true; + } + _persist(entry: SessionEntry): void { if (!this.persist || !this.sessionFile) return; diff --git a/packages/coding-agent/src/core/subagents/persistence.ts b/packages/coding-agent/src/core/subagents/persistence.ts new file mode 100644 index 000000000..290ae004e --- /dev/null +++ b/packages/coding-agent/src/core/subagents/persistence.ts @@ -0,0 +1,388 @@ +import { existsSync, lstatSync, mkdirSync, readdirSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { ThinkingLevel } from "@earendil-works/diffex-agent-core"; +import { uuidv7 } from "@earendil-works/diffex-ai"; +import { + assertValidSessionId, + type CustomEntry, + loadEntriesFromFile, + type SessionEntry, + SessionManager, +} from "../session-manager.ts"; +import { allToolNames, type ToolName } from "../tools/index.ts"; +import { SUBAGENT_NAME_PATTERN, type SubagentStatus } from "./types.ts"; + +export const SUBAGENT_ROSTER_CUSTOM_TYPE = "diffex.subagents"; + +const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const HARNESS_VERSION_PATTERN = /^sha256:[0-9a-f]{64}$/; +const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); +const SUBAGENT_STATUSES = new Set(["queued", "running", "idle", "failed", "interrupted"]); + +export interface ChildSnapshotV1 { + readonly name: string; + readonly task: string; + readonly status: SubagentStatus; + readonly createdAt: number; + readonly updatedAt: number; + readonly startedAt?: number; + readonly completedAt?: number; + readonly result?: string; + readonly error?: string; + readonly childSessionId: string; + readonly childLeafId: string | null; + readonly config: { + readonly model: { readonly provider: string; readonly id: string }; + readonly thinkingLevel: ThinkingLevel; + readonly tools: readonly ToolName[]; + readonly harnessRevision: string | null; + }; +} + +export interface RosterSnapshotV1 { + readonly schemaVersion: 1; + readonly agents: readonly ChildSnapshotV1[]; +} + +export type DecodedChildSnapshotV1 = + | { readonly kind: "restorable"; readonly snapshot: ChildSnapshotV1 } + | { readonly kind: "isolated"; readonly name: string; readonly error: string }; + +export interface DecodedRosterSnapshotV1 { + readonly recognized: boolean; + readonly children: readonly DecodedChildSnapshotV1[]; + readonly warnings: readonly string[]; +} + +export interface SelectedBranchRosterSnapshot { + readonly found: boolean; + readonly entryId?: string; + readonly children: readonly DecodedChildSnapshotV1[]; + readonly warnings: readonly string[]; +} + +interface ChildCandidate { + readonly name: string; + readonly childSessionId?: string; + readonly snapshot?: ChildSnapshotV1; + readonly errors: readonly string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isEpochMilliseconds(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function validateOptionalEpoch( + record: Record, + key: "startedAt" | "completedAt", + errors: string[], +): void { + if (record[key] !== undefined && !isEpochMilliseconds(record[key])) + errors.push(`${key} must be a non-negative integer`); +} + +function validateOptionalString(record: Record, key: "result" | "error", errors: string[]): void { + if (record[key] !== undefined && typeof record[key] !== "string") errors.push(`${key} must be a string`); +} + +export function assertValidChildSessionId(id: string): void { + assertValidSessionId(id); + if (!UUID_V7_PATTERN.test(id)) throw new Error(`Invalid subagent child session id: ${id}`); +} + +function decodeChild(value: unknown): ChildCandidate | undefined { + if (!isRecord(value)) return undefined; + const name = value.name; + if (typeof name !== "string" || !SUBAGENT_NAME_PATTERN.test(name)) return undefined; + + const errors: string[] = []; + if (!isNonEmptyString(value.task)) errors.push("task must be a non-empty string"); + if (typeof value.status !== "string" || !SUBAGENT_STATUSES.has(value.status as SubagentStatus)) { + errors.push("status is invalid"); + } + if (!isEpochMilliseconds(value.createdAt)) errors.push("createdAt must be a non-negative integer"); + if (!isEpochMilliseconds(value.updatedAt)) errors.push("updatedAt must be a non-negative integer"); + validateOptionalEpoch(value, "startedAt", errors); + validateOptionalEpoch(value, "completedAt", errors); + validateOptionalString(value, "result", errors); + validateOptionalString(value, "error", errors); + + const childSessionId = value.childSessionId; + if (typeof childSessionId !== "string" || !UUID_V7_PATTERN.test(childSessionId)) { + errors.push("childSessionId must be a UUIDv7 string"); + } + if (value.childLeafId !== null && !isNonEmptyString(value.childLeafId)) { + errors.push("childLeafId must be a non-empty string or null"); + } + + const config = value.config; + if (!isRecord(config)) { + errors.push("config must be an object"); + } else { + const model = config.model; + if (!isRecord(model) || !isNonEmptyString(model.provider) || !isNonEmptyString(model.id)) { + errors.push("config.model must contain non-empty provider and id strings"); + } + if (typeof config.thinkingLevel !== "string" || !THINKING_LEVELS.has(config.thinkingLevel as ThinkingLevel)) { + errors.push("config.thinkingLevel is invalid"); + } + if (!Array.isArray(config.tools)) { + errors.push("config.tools must be an array"); + } else { + const tools = config.tools; + if ( + tools.some((tool) => typeof tool !== "string" || !allToolNames.has(tool as ToolName)) || + new Set(tools).size !== tools.length + ) { + errors.push("config.tools must contain unique built-in tool names"); + } + } + if ( + config.harnessRevision !== null && + (typeof config.harnessRevision !== "string" || !HARNESS_VERSION_PATTERN.test(config.harnessRevision)) + ) { + errors.push("config.harnessRevision is invalid"); + } + } + + if (errors.length > 0) { + return { + name, + ...(typeof childSessionId === "string" && UUID_V7_PATTERN.test(childSessionId) ? { childSessionId } : {}), + errors, + }; + } + + return { + name, + childSessionId: childSessionId as string, + errors, + snapshot: { + name, + task: value.task as string, + status: value.status as SubagentStatus, + createdAt: value.createdAt as number, + updatedAt: value.updatedAt as number, + ...(value.startedAt === undefined ? {} : { startedAt: value.startedAt as number }), + ...(value.completedAt === undefined ? {} : { completedAt: value.completedAt as number }), + ...(value.result === undefined ? {} : { result: value.result as string }), + ...(value.error === undefined ? {} : { error: value.error as string }), + childSessionId: childSessionId as string, + childLeafId: value.childLeafId as string | null, + config: { + model: { + provider: (config as Record).model + ? (((config as Record).model as Record).provider as string) + : "", + id: (config as Record).model + ? (((config as Record).model as Record).id as string) + : "", + }, + thinkingLevel: (config as Record).thinkingLevel as ThinkingLevel, + tools: [...((config as Record).tools as ToolName[])], + harnessRevision: (config as Record).harnessRevision as string | null, + }, + }, + }; +} + +export function decodeSubagentRosterSnapshot(data: unknown): DecodedRosterSnapshotV1 { + if (!isRecord(data) || data.schemaVersion !== 1 || !Array.isArray(data.agents)) { + return { + recognized: false, + children: [], + warnings: ["Latest subagent roster snapshot is malformed or uses an unsupported schema version"], + }; + } + + const warnings: string[] = []; + const candidates: ChildCandidate[] = []; + for (const [index, value] of data.agents.entries()) { + const candidate = decodeChild(value); + if (candidate) candidates.push(candidate); + else warnings.push(`Omitted subagent roster entry ${index} because its name cannot be trusted`); + } + + const nameCounts = new Map(); + const idOwners = new Map>(); + for (const candidate of candidates) { + nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1); + if (candidate.childSessionId) { + const owners = idOwners.get(candidate.childSessionId) ?? new Set(); + owners.add(candidate.name); + idOwners.set(candidate.childSessionId, owners); + } + } + + const emittedNames = new Set(); + const children: DecodedChildSnapshotV1[] = []; + for (const candidate of candidates) { + if (emittedNames.has(candidate.name)) continue; + emittedNames.add(candidate.name); + + let error: string | undefined; + if ((nameCounts.get(candidate.name) ?? 0) > 1) { + error = `Duplicate persisted subagent name: ${candidate.name}`; + } else if (candidate.childSessionId && (idOwners.get(candidate.childSessionId)?.size ?? 0) > 1) { + error = `Persisted child session ${candidate.childSessionId} is claimed by multiple subagents`; + } else if (candidate.errors.length > 0) { + error = `Invalid persisted subagent ${candidate.name}: ${candidate.errors.join("; ")}`; + } + + if (error) { + children.push({ kind: "isolated", name: candidate.name, error }); + warnings.push(error); + } else if (candidate.snapshot) { + children.push({ kind: "restorable", snapshot: candidate.snapshot }); + } + } + + return { recognized: true, children, warnings }; +} + +export function encodeSubagentRosterSnapshot(agents: readonly ChildSnapshotV1[]): RosterSnapshotV1 { + const data = { schemaVersion: 1 as const, agents: structuredClone(agents) }; + const decoded = decodeSubagentRosterSnapshot(data); + if ( + !decoded.recognized || + decoded.warnings.length > 0 || + decoded.children.some((child) => child.kind === "isolated") + ) { + throw new Error(decoded.warnings[0] ?? "Invalid subagent roster snapshot"); + } + return data; +} + +function isRosterEntry(entry: SessionEntry): entry is CustomEntry { + return entry.type === "custom" && entry.customType === SUBAGENT_ROSTER_CUSTOM_TYPE; +} + +export function readSelectedBranchSubagentRoster(sessionManager: SessionManager): SelectedBranchRosterSnapshot { + const branch = sessionManager.getBranch(); + let entry: CustomEntry | undefined; + for (let index = branch.length - 1; index >= 0; index--) { + const candidate = branch[index]; + if (isRosterEntry(candidate)) { + entry = candidate; + break; + } + } + if (!entry) return { found: false, children: [], warnings: [] }; + const decoded = decodeSubagentRosterSnapshot(entry.data); + return { + found: true, + entryId: entry.id, + children: decoded.children, + warnings: decoded.warnings, + }; +} + +function assertContainedPath(parentDirectory: string, targetPath: string): void { + const childPath = relative(parentDirectory, targetPath); + if (childPath === "" || childPath === ".." || childPath.startsWith(`..${sep}`) || isAbsolute(childPath)) { + throw new Error(`Subagent path escapes the parent session directory: ${targetPath}`); + } +} + +function assertSafeDirectory(path: string, description: string): void { + const stats = lstatSync(path); + if (stats.isSymbolicLink() || !stats.isDirectory()) + throw new Error(`${description} is not a safe directory: ${path}`); +} + +function ensureSafeDirectory(path: string, parentDirectory: string, description: string): void { + assertContainedPath(parentDirectory, path); + if (!existsSync(path)) mkdirSync(path); + assertSafeDirectory(path, description); +} + +export class ParentOwnedSubagentSessionStore { + readonly #parent: SessionManager; + readonly #parentDirectory: string; + readonly #namespaceDirectory: string; + readonly #ownedDirectory: string; + + constructor(parent: SessionManager) { + if (!parent.isPersisted()) throw new Error("Parent-owned subagent storage requires a persisted parent session"); + const parentFile = parent.getSessionFile(); + if (!parentFile) throw new Error("Persisted parent session file is unavailable"); + assertValidSessionId(parent.getSessionId()); + + this.#parent = parent; + this.#parentDirectory = dirname(resolve(parentFile)); + this.#namespaceDirectory = resolve(this.#parentDirectory, ".subagents"); + this.#ownedDirectory = resolve(this.#namespaceDirectory, parent.getSessionId()); + assertContainedPath(this.#parentDirectory, this.#namespaceDirectory); + assertContainedPath(this.#parentDirectory, this.#ownedDirectory); + } + + getOwnedDirectory(): string { + return this.#ownedDirectory; + } + + createChild(childName: string, childSessionId: string = uuidv7()): SessionManager { + if (!SUBAGENT_NAME_PATTERN.test(childName)) throw new Error(`Invalid subagent name: ${childName}`); + assertValidChildSessionId(childSessionId); + ensureSafeDirectory(this.#namespaceDirectory, this.#parentDirectory, "Subagent namespace"); + ensureSafeDirectory(this.#ownedDirectory, this.#parentDirectory, "Parent-owned subagent directory"); + + const child = SessionManager.create(this.#parent.getCwd(), this.#ownedDirectory, { + id: childSessionId, + parentSession: this.#parent.getSessionId(), + }); + const childFile = child.getSessionFile(); + if (!childFile) throw new Error("Child session file is unavailable"); + assertContainedPath(this.#ownedDirectory, resolve(childFile)); + child._persistHeader(); + return child; + } + + openChild(childName: string, childSessionId: string, childLeafId: string | null): SessionManager { + if (!SUBAGENT_NAME_PATTERN.test(childName)) throw new Error(`Invalid subagent name: ${childName}`); + assertValidChildSessionId(childSessionId); + if (childLeafId !== null && !isNonEmptyString(childLeafId)) { + throw new Error("Subagent child leaf id must be a non-empty string or null"); + } + assertSafeDirectory(this.#namespaceDirectory, "Subagent namespace"); + assertSafeDirectory(this.#ownedDirectory, "Parent-owned subagent directory"); + + const suffix = `_${childSessionId}.jsonl`; + const matches = readdirSync(this.#ownedDirectory, { withFileTypes: true }).filter((entry) => + entry.name.endsWith(suffix), + ); + if (matches.length !== 1) { + throw new Error(`Expected one persisted child session for ${childName}, found ${matches.length}`); + } + const match = matches[0]; + const childFile = resolve(this.#ownedDirectory, match.name); + assertContainedPath(this.#ownedDirectory, childFile); + if (match.isSymbolicLink() || !match.isFile()) + throw new Error(`Persisted child session is not a safe file: ${childFile}`); + + const entries = loadEntriesFromFile(childFile); + const header = entries[0]; + if ( + header?.type !== "session" || + header.id !== childSessionId || + header.parentSession !== this.#parent.getSessionId() + ) { + throw new Error(`Persisted child session identity mismatch for ${childName}`); + } + if (childLeafId !== null && !entries.some((entry) => entry.type !== "session" && entry.id === childLeafId)) { + throw new Error(`Persisted child leaf ${childLeafId} is unavailable for ${childName}`); + } + + const child = SessionManager.open(childFile, this.#ownedDirectory, this.#parent.getCwd()); + if (childLeafId === null) child.resetLeaf(); + else child.branch(childLeafId); + return child; + } +} diff --git a/packages/coding-agent/test/suite/subagent-persistence.test.ts b/packages/coding-agent/test/suite/subagent-persistence.test.ts new file mode 100644 index 000000000..13fe4de77 --- /dev/null +++ b/packages/coding-agent/test/suite/subagent-persistence.test.ts @@ -0,0 +1,217 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { + type ChildSnapshotV1, + decodeSubagentRosterSnapshot, + encodeSubagentRosterSnapshot, + ParentOwnedSubagentSessionStore, + readSelectedBranchSubagentRoster, + SUBAGENT_ROSTER_CUSTOM_TYPE, +} from "../../src/core/subagents/persistence.ts"; + +const CHILD_ID_A = "018f4f7c-43b7-7abc-8def-0123456789ab"; +const CHILD_ID_B = "018f4f7c-43b7-7abc-8def-0123456789ac"; + +function childSnapshot(overrides: Partial = {}): ChildSnapshotV1 { + return { + name: "alpha", + task: "Inspect the change", + status: "idle", + createdAt: 1, + updatedAt: 2, + completedAt: 2, + result: "done", + childSessionId: CHILD_ID_A, + childLeafId: null, + config: { + model: { provider: "test", id: "test-model" }, + thinkingLevel: "medium", + tools: ["read", "grep"], + harnessRevision: null, + }, + ...overrides, + }; +} + +describe("subagent roster persistence codec", () => { + it("uses only the latest selected-branch snapshot and treats an empty roster as a reset barrier", () => { + const session = SessionManager.inMemory(); + const original = session.appendCustomEntry( + SUBAGENT_ROSTER_CUSTOM_TYPE, + encodeSubagentRosterSnapshot([childSnapshot()]), + ); + session.appendCustomEntry(SUBAGENT_ROSTER_CUSTOM_TYPE, { schemaVersion: 2, agents: [] }); + + const masked = readSelectedBranchSubagentRoster(session); + expect(masked.found).toBe(true); + expect(masked.children).toEqual([]); + expect(masked.warnings).toEqual([ + "Latest subagent roster snapshot is malformed or uses an unsupported schema version", + ]); + + session.branch(original); + session.appendCustomEntry(SUBAGENT_ROSTER_CUSTOM_TYPE, encodeSubagentRosterSnapshot([])); + const reset = readSelectedBranchSubagentRoster(session); + expect(reset.children).toEqual([]); + expect(reset.warnings).toEqual([]); + }); + + it("validates children independently and isolates identifiable invalid records", () => { + const invalidTask = { ...childSnapshot({ name: "bad-task", childSessionId: CHILD_ID_B }), task: "" }; + const invalidName = { ...childSnapshot({ childSessionId: CHILD_ID_B }), name: "../escape" }; + const decoded = decodeSubagentRosterSnapshot({ + schemaVersion: 1, + agents: [childSnapshot(), invalidTask, invalidName], + futureField: true, + }); + + expect(decoded.recognized).toBe(true); + expect(decoded.children).toEqual([ + { kind: "restorable", snapshot: childSnapshot() }, + { + kind: "isolated", + name: "bad-task", + error: "Invalid persisted subagent bad-task: task must be a non-empty string", + }, + ]); + expect(decoded.warnings).toContain("Omitted subagent roster entry 2 because its name cannot be trusted"); + }); + + it("collapses duplicate names and isolates every distinct name claiming one child id", () => { + const decoded = decodeSubagentRosterSnapshot({ + schemaVersion: 1, + agents: [ + childSnapshot(), + childSnapshot({ name: "alpha", childSessionId: CHILD_ID_B }), + childSnapshot({ name: "beta" }), + ], + }); + + expect(decoded.children).toEqual([ + { kind: "isolated", name: "alpha", error: "Duplicate persisted subagent name: alpha" }, + { + kind: "isolated", + name: "beta", + error: `Persisted child session ${CHILD_ID_A} is claimed by multiple subagents`, + }, + ]); + }); + + it("rejects invalid values when encoding a snapshot", () => { + expect(() => + encodeSubagentRosterSnapshot([ + childSnapshot({ + config: { ...childSnapshot().config, tools: ["read", "read"] }, + }), + ]), + ).toThrow("config.tools must contain unique built-in tool names"); + }); +}); + +describe("parent-owned subagent session storage", () => { + const tempDirectories: string[] = []; + + afterEach(() => { + for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); + }); + + function createParent(): { parent: SessionManager; root: string } { + const root = mkdtempSync(join(tmpdir(), "diffex-subagent-persistence-")); + tempDirectories.push(root); + const parent = SessionManager.create(root, root, { id: "parent-session" }); + parent._persistHeader(); + return { parent, root }; + } + + it("creates the child header synchronously in the validated owned namespace", async () => { + const { parent, root } = createParent(); + const store = new ParentOwnedSubagentSessionStore(parent); + const child = store.createChild("alpha", CHILD_ID_A); + const childFile = child.getSessionFile(); + + expect(store.getOwnedDirectory()).toBe(resolve(root, ".subagents", "parent-session")); + expect(childFile).toBeDefined(); + expect(existsSync(childFile!)).toBe(true); + expect(readFileSync(childFile!, "utf8").trim().split("\n")).toHaveLength(1); + expect(JSON.parse(readFileSync(childFile!, "utf8"))).toMatchObject({ + type: "session", + id: CHILD_ID_A, + parentSession: "parent-session", + }); + + const listed = await SessionManager.list(root, root); + expect(listed.map((session) => session.id)).toEqual(["parent-session"]); + }); + + it("rejects invalid names and child ids before creating namespace content", () => { + const { parent } = createParent(); + const store = new ParentOwnedSubagentSessionStore(parent); + + expect(() => store.createChild("../escape", CHILD_ID_A)).toThrow("Invalid subagent name"); + expect(() => store.createChild("alpha", "../escape")).toThrow("Session id must be non-empty"); + expect(existsSync(store.getOwnedDirectory())).toBe(false); + }); + + it.each(["namespace", "owned directory"])("rejects a linked %s", (component) => { + const { parent, root } = createParent(); + const outside = mkdtempSync(join(tmpdir(), "diffex-subagent-outside-")); + tempDirectories.push(outside); + const namespace = join(root, ".subagents"); + if (component === "namespace") { + symlinkSync(outside, namespace, process.platform === "win32" ? "junction" : "dir"); + } else { + const ownedTarget = join(outside, "owned"); + mkdirSync(ownedTarget); + mkdirSync(namespace); + symlinkSync(ownedTarget, join(namespace, "parent-session"), process.platform === "win32" ? "junction" : "dir"); + } + + const store = new ParentOwnedSubagentSessionStore(parent); + expect(() => store.createChild("alpha", CHILD_ID_A)).toThrow("safe directory"); + expect(readdirSync(outside)).toEqual(component === "namespace" ? [] : ["owned"]); + }); + + it("validates child identity and leaf membership before exposing history", () => { + const { parent } = createParent(); + const store = new ParentOwnedSubagentSessionStore(parent); + const child = store.createChild("alpha", CHILD_ID_A); + const leaf = child.appendMessage({ role: "user", content: "child history", timestamp: 1 }); + + expect(store.openChild("alpha", CHILD_ID_A, leaf).getLeafId()).toBe(leaf); + expect(store.openChild("alpha", CHILD_ID_A, null).getLeafId()).toBeNull(); + expect(() => store.openChild("alpha", CHILD_ID_A, "missing-leaf")).toThrow("is unavailable"); + + const childFile = child.getSessionFile(); + if (!childFile) throw new Error("Expected child session file"); + const lines = readFileSync(childFile, "utf8").trimEnd().split("\n"); + const header = JSON.parse(lines[0]) as Record; + header.parentSession = "different-parent"; + lines[0] = JSON.stringify(header); + writeFileSync(childFile, `${lines.join("\n")}\n`); + expect(() => store.openChild("alpha", CHILD_ID_A, leaf)).toThrow("identity mismatch"); + }); + + it("rejects a linked child path even when its name claims the expected id", () => { + const { parent } = createParent(); + const store = new ParentOwnedSubagentSessionStore(parent); + store.createChild("alpha", CHILD_ID_A); + const outsideDirectory = join(dirname(store.getOwnedDirectory()), "outside-child"); + mkdirSync(outsideDirectory); + const claimedFile = join(store.getOwnedDirectory(), `fake_${CHILD_ID_B}.jsonl`); + symlinkSync(outsideDirectory, claimedFile, process.platform === "win32" ? "junction" : "dir"); + + expect(() => store.openChild("beta", CHILD_ID_B, null)).toThrow("not a safe file"); + }); +}); From 3b34cb790856456307397b9ec5e8790d959065dc Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Thu, 20 Aug 2026 03:35:28 -0700 Subject: [PATCH 2/2] fix(coding-agent): keep header persistence internal --- packages/coding-agent/src/core/session-manager.ts | 9 ++++++++- packages/coding-agent/src/core/subagents/persistence.ts | 3 ++- .../coding-agent/test/suite/subagent-persistence.test.ts | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 8755fb5ba..e687b8614 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -228,6 +228,8 @@ function createSessionId(): string { return uuidv7(); } +const PERSIST_SESSION_HEADER = Symbol("persistSessionHeader"); + export function assertValidSessionId(id: string): void { if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) { throw new Error( @@ -1036,7 +1038,7 @@ export class SessionManager { } /** Persist a newly created session header before admitting dependent work. */ - _persistHeader(): void { + [PERSIST_SESSION_HEADER](): void { if (!this.persist || !this.sessionFile) { throw new Error("Cannot persist an in-memory session header"); } @@ -1807,3 +1809,8 @@ export class SessionManager { } } } + +/** Internal seam for durably admitting a newly created dependent session. */ +export function persistSessionHeader(sessionManager: SessionManager): void { + sessionManager[PERSIST_SESSION_HEADER](); +} diff --git a/packages/coding-agent/src/core/subagents/persistence.ts b/packages/coding-agent/src/core/subagents/persistence.ts index 290ae004e..35b4cfb47 100644 --- a/packages/coding-agent/src/core/subagents/persistence.ts +++ b/packages/coding-agent/src/core/subagents/persistence.ts @@ -6,6 +6,7 @@ import { assertValidSessionId, type CustomEntry, loadEntriesFromFile, + persistSessionHeader, type SessionEntry, SessionManager, } from "../session-manager.ts"; @@ -341,7 +342,7 @@ export class ParentOwnedSubagentSessionStore { const childFile = child.getSessionFile(); if (!childFile) throw new Error("Child session file is unavailable"); assertContainedPath(this.#ownedDirectory, resolve(childFile)); - child._persistHeader(); + persistSessionHeader(child); return child; } diff --git a/packages/coding-agent/test/suite/subagent-persistence.test.ts b/packages/coding-agent/test/suite/subagent-persistence.test.ts index 13fe4de77..4ace05414 100644 --- a/packages/coding-agent/test/suite/subagent-persistence.test.ts +++ b/packages/coding-agent/test/suite/subagent-persistence.test.ts @@ -11,7 +11,7 @@ import { import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { SessionManager } from "../../src/core/session-manager.ts"; +import { persistSessionHeader, SessionManager } from "../../src/core/session-manager.ts"; import { type ChildSnapshotV1, decodeSubagentRosterSnapshot, @@ -131,7 +131,7 @@ describe("parent-owned subagent session storage", () => { const root = mkdtempSync(join(tmpdir(), "diffex-subagent-persistence-")); tempDirectories.push(root); const parent = SessionManager.create(root, root, { id: "parent-session" }); - parent._persistHeader(); + persistSessionHeader(parent); return { parent, root }; }