From b37fc9895f105341355e680a81097e674bd159f8 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 22:02:52 -0700 Subject: [PATCH 01/22] feat(daemon): reconstruct managed session catalog Reconstruct the unique net delta from PR #1261, excluding propagation merges. --- .../coding-agent/src/core/agent-messages.ts | 81 ++- .../coding-agent/src/core/session-manager.ts | 31 +- .../modes/daemon/daemon-catalog-process.ts | 390 ++++++++++- .../src/modes/daemon/daemon-mode.ts | 438 ++++++++++-- .../src/modes/daemon/daemon-protocol.ts | 16 +- .../src/modes/daemon/daemon-supervisor.ts | 204 +++++- .../src/modes/daemon/saved-session-catalog.ts | 2 +- .../test/agent-session-bus.test.ts | 71 +- .../test/daemon-catalog-process.test.ts | 450 +++++++++++- .../coding-agent/test/daemon-mode.test.ts | 661 +++++++++++++++++- .../coding-agent/test/daemon-protocol.test.ts | 13 + .../test/daemon-supervisor-eviction.test.ts | 398 ++++++++++- .../daemon-supervisor-lazy-subagents.test.ts | 170 ++++- .../test/saved-session-catalog.test.ts | 7 +- 14 files changed, 2708 insertions(+), 224 deletions(-) diff --git a/packages/coding-agent/src/core/agent-messages.ts b/packages/coding-agent/src/core/agent-messages.ts index 25d5fb337..1241e8efa 100644 --- a/packages/coding-agent/src/core/agent-messages.ts +++ b/packages/coding-agent/src/core/agent-messages.ts @@ -257,7 +257,20 @@ function sameAgentSessionNameParent( if (left.depth === 0 && right.depth === 0) { return true; } - return sameAgentFamilyParent(left, right, catalog); + if (sameAgentFamilyParent(left, right, catalog)) return true; + + // A passive child can outlive the active parent row that would normally + // resolve its family edge. Name reservation must still protect that parent's + // sibling namespace, but this weaker direct-claim fallback is deliberately + // not used for family reach: reach continues to require an unambiguous, + // catalog-resolved parent. + if (left.depth !== right.depth || left.depth === 0) return false; + return ( + (left.parentSessionId !== undefined && left.parentSessionId === right.parentSessionId) || + (left.parentSessionPath !== undefined && + right.parentSessionPath !== undefined && + canonicalSessionPath(left.parentSessionPath) === canonicalSessionPath(right.parentSessionPath)) + ); } function sameAgentFamilyParent( @@ -265,44 +278,38 @@ function sameAgentFamilyParent( right: AgentSessionNameScope, catalog: readonly AgentFamilyCatalogEntry[], ): boolean { - if (left.parentSessionPath !== undefined && left.parentSessionPath === right.parentSessionPath) { - return true; - } - if (left.parentSessionId !== undefined && left.parentSessionId === right.parentSessionId) { - return true; - } - const hasCatalogParentPair = (parentSessionId: string | undefined, parentSessionPath: string | undefined) => - parentSessionId !== undefined && - parentSessionPath !== undefined && - catalog.some( - (entry) => - (entry.id === parentSessionId && entry.sessionPath === parentSessionPath) || - (entry.parentSessionId === parentSessionId && entry.parentSessionPath === parentSessionPath), + if (left.depth === 0 && right.depth === 0) { + return ( + left.parentSessionId === undefined && + left.parentSessionPath === undefined && + right.parentSessionId === undefined && + right.parentSessionPath === undefined ); - if ( - hasCatalogParentPair(left.parentSessionId, right.parentSessionPath) || - hasCatalogParentPair(right.parentSessionId, left.parentSessionPath) - ) { - return true; - } - if ( - left.depth === 0 && - right.depth === 0 && - left.parentSessionPath === undefined && - right.parentSessionPath === undefined && - left.parentSessionId === undefined && - right.parentSessionId === undefined - ) { - return true; } - // Unresolved mixed identifiers stay unrelated to avoid false name conflicts across families. - return false; + if (left.depth !== right.depth || left.depth === 0) return false; + const parentFor = (child: AgentSessionNameScope) => { + const parents = catalog.filter((entry) => isAgentFamilyParent(entry, child)); + return parents.length === 1 ? parents[0] : undefined; + }; + const leftParent = parentFor(left); + const rightParent = parentFor(right); + return leftParent !== undefined && leftParent.id === rightParent?.id; } -function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentFamilyCatalogEntry): boolean { +/** + * Validates one persisted parent edge. A child may supply either durable + * identifier, but when it supplies both they must identify this same direct + * parent. This keeps contradictory records from becoming relatives through + * whichever identifier happens to match. + */ +function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentSessionNameScope): boolean { + if (child.depth <= 0 || parent.depth !== child.depth - 1) return false; + const claimsId = child.parentSessionId !== undefined; + const claimsPath = child.parentSessionPath !== undefined; return ( - (child.parentSessionPath !== undefined && child.parentSessionPath === parent.sessionPath) || - (child.parentSessionId !== undefined && child.parentSessionId === parent.id) + (claimsId || claimsPath) && + (!claimsId || child.parentSessionId === parent.id) && + (!claimsPath || child.parentSessionPath === parent.sessionPath) ); } @@ -310,19 +317,21 @@ function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentFamily export function agentFamilyRelationship( current: AgentFamilyCatalogEntry, target: AgentFamilyCatalogEntry, + catalog: readonly AgentFamilyCatalogEntry[] = [current, target], ): AgentFamilyRelationship | undefined { if (current.id === target.id) return undefined; if (isAgentFamilyParent(target, current)) return "parent"; if (isAgentFamilyParent(current, target)) return "child"; - if (current.depth === target.depth && sameAgentFamilyParent(current, target, [current, target])) return "sibling"; + if (sameAgentFamilyParent(current, target, catalog)) return "sibling"; return undefined; } export function assertAgentFamilyReach( current: AgentFamilyCatalogEntry, target: AgentFamilyCatalogEntry, + catalog?: readonly AgentFamilyCatalogEntry[], ): AgentFamilyRelationship { - const relationship = agentFamilyRelationship(current, target); + const relationship = agentFamilyRelationship(current, target, catalog); if (!relationship) throw new Error(AGENT_FAMILY_REACH_ERROR); return relationship; } diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index f9161168e..e4075e774 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1013,12 +1013,37 @@ export async function readSessionInfo(filePath: string): Promise>): Promise { +/** Parse catalog-authorized bytes without reopening their pathname. */ +export async function readSessionInfoFromBuffer( + filePath: string, + contents: Buffer, + metadata: { mtimeMs: number }, +): Promise { + async function* lines(): AsyncGenerator { + let start = 0; + while (start < contents.length) { + const end = contents.indexOf(0x0a, start); + if (end === -1) { + yield contents.subarray(start); + return; + } + yield contents.subarray(start, end); + start = end + 1; + } + } + return scanSessionInfo(filePath, { mtime: new Date(metadata.mtimeMs) } as Awaited>, lines()); +} + +async function scanSessionInfo( + filePath: string, + stats: Awaited>, + lines: AsyncIterable, +): Promise { try { let header: SessionHeader | undefined; let messageCount = 0; @@ -1029,7 +1054,7 @@ async function scanSessionInfo(filePath: string, stats: Awaited { type CatalogRequest = | { type: "request"; id: string; command: "list"; cwd?: string; sessionDir?: string } + | { type: "request"; id: string; command: "family"; sessionDir?: string } | { type: "request"; id: string; command: "resolve"; selector: string; cwd: string; sessionDir?: string } - | { type: "request"; id: string; command: "siblings"; sessionPath: string } + | { type: "request"; id: string; command: "siblings"; sessionPath: string; sessionDir?: string } | { type: "request"; id: string; command: "rename"; sessionPath: string; name: string } | { type: "request"; id: string; command: "delete"; sessionPath: string } | { type: "request"; id: string; command: "archive"; sessionPath: string; sessionId: string } @@ -66,42 +73,339 @@ interface SavedRlmSubagentRegistryEntry { status?: unknown; } -export async function listSavedSessionSiblings(sessionPath: string): Promise { - const target = await readSessionInfo(sessionPath); - if (!target) throw new Error(`Session not found: ${sessionPath}`); - if (!target.parentSessionPath) return [target]; - const parentPath = resolve(dirname(target.path), target.parentSessionPath); - const parent = await readSessionInfo(parentPath); - if (!parent) return [target]; - const registryPath = join(dirname(dirname(parent.path)), "session-artifacts", parent.id, "rlm-subagents.jsonl"); +const MAX_RLM_REGISTRY_BYTES = 1024 * 1024; +const MAX_RLM_REGISTRY_RECORDS = 10_000; +const MAX_RLM_FAMILY_EDGES = 10_000; +const MAX_RLM_FAMILY_NODES = 10_000; +const MAX_RLM_FAMILY_DEPTH = 64; + +interface ManagedRoot { + lexical: string; + fd: number; +} + +interface ManagedRoots { + session: ManagedRoot; + artifacts: ManagedRoot | undefined; +} + +interface TrustedFile { + path: string; + contents: Buffer; + mtimeMs: number; + dev: string; + ino: string; +} + +interface TrustedSession extends SessionInfo { + /** The header claim is intentionally separate from SessionInfo's legacy fallback. */ + persistedDepth: number; + persistedParentPath?: string; +} + +function rlmSubagentRegistryPath(parent: SessionInfo, roots: ManagedRoots): string | undefined { + const parentDir = dirname(parent.path); + const artifactDir = + parentDir === roots.session.lexical ? roots.artifacts?.lexical : join(parentDir, "session-artifacts"); + return artifactDir ? join(artifactDir, parent.id, "rlm-subagents.jsonl") : undefined; +} + +function isWithin(root: string, target: string): boolean { + const path = relative(root, target); + return path === "" || (!path.startsWith("..") && !isAbsolute(path)); +} + +function invalidFamilyTopology(reason: string): Error { + return new Error(`Invalid RLM artifact family topology: ${reason}`); +} + +let openAuthorityFdCountForTest = 0; +/** @internal */ +export function getOpenCatalogAuthorityFdCountForTest(): number { + return openAuthorityFdCountForTest; +} + +function openAuthorityRoot(path: string, optional = false): ManagedRoot | undefined { + try { + // O_NOFOLLOW binds authority to the directory itself, never a pathname target. + const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + openAuthorityFdCountForTest++; + return { lexical: path, fd }; + } catch (error) { + if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw invalidFamilyTopology(`managed authority root is unavailable: ${String(error)}`); + } +} + +function managedRoots(sessionDir: string | undefined): ManagedRoots { + const sessionLexical = resolve(sessionDir ?? ""); + const session = openAuthorityRoot(sessionLexical)!; + try { + const artifacts = openAuthorityRoot(join(dirname(sessionLexical), "session-artifacts"), true); + return { session, artifacts }; + } catch (error) { + closeSync(session.fd); + throw error; + } +} + +function closeManagedRoots(roots: ManagedRoots): void { + closeSync(roots.session.fd); + openAuthorityFdCountForTest--; + if (roots.artifacts) { + closeSync(roots.artifacts.fd); + openAuthorityFdCountForTest--; + } +} + +const OPENAT_READ_HELPER = String.raw`import base64,json,os,stat,sys +MAX=134217728 +def reject(): raise ValueError("invalid") +def flags(directory=False): + value=os.O_RDONLY|os.O_NOFOLLOW + if directory: value|=os.O_DIRECTORY + return value +def main(): + req=json.loads(sys.stdin.buffer.read(131073)) + parts=req.get("parts"); limit=req.get("limit") + if not isinstance(parts,list) or not parts or not isinstance(limit,int) or limit<0 or limit>MAX: reject() + if any(not isinstance(p,str) or not p or p in (".","..") or "/" in p or "\\" in p for p in parts): reject() + current=os.dup(3) + try: + for part in parts[:-1]: + nxt=os.open(part,flags(True),dir_fd=current); os.close(current); current=nxt + fd=os.open(parts[-1],flags(False),dir_fd=current) + try: + before=os.fstat(fd) + if not stat.S_ISREG(before.st_mode) or before.st_size>limit: reject() + chunks=[]; total=0 + while True: + chunk=os.read(fd,min(65536,limit+1-total)) + if not chunk: break + chunks.append(chunk); total+=len(chunk) + if total>limit: reject() + after=os.fstat(fd) + if (before.st_dev,before.st_ino,before.st_mode)!=(after.st_dev,after.st_ino,after.st_mode): reject() + print(json.dumps({"data":base64.b64encode(b"".join(chunks)).decode("ascii"),"mtimeMs":after.st_mtime_ns/1000000,"dev":str(after.st_dev),"ino":str(after.st_ino)},separators=(",",":"))) + finally: os.close(fd) + finally: os.close(current) +try: main() +except FileNotFoundError: sys.exit(44) +except Exception: sys.stderr.write("catalog openat helper failed\n"); sys.exit(1) +`; + +/** Test-only seam runs after authority selection but before descriptor-relative traversal. */ +let beforeTrustedOpenForTest: ((path: string) => void) | undefined; +/** @internal */ +export function setCatalogBeforeTrustedOpenForTest(hook: ((path: string) => void) | undefined): void { + beforeTrustedOpenForTest = hook; +} + +function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number): TrustedFile { + if (!isAbsolute(rawPath) || rawPath !== resolve(rawPath)) + throw invalidFamilyTopology("session path is not canonical"); + const root = [roots.session, roots.artifacts].find((candidate) => candidate && isWithin(candidate.lexical, rawPath)); + if (!root) throw invalidFamilyTopology("session path escapes managed roots"); + const suffix = relative(root.lexical, rawPath); + const parts = suffix.split(sep); + if (!suffix || parts.some((part) => !part || part === "." || part === "..")) { + throw invalidFamilyTopology("session path lacks a trusted file component"); + } + beforeTrustedOpenForTest?.(rawPath); + const result = spawnSync( + process.execPath === process.env.PRIME_AGENT_KERNEL_PYTHON + ? process.execPath + : (process.env.PRIME_AGENT_KERNEL_PYTHON ?? "python3"), + ["-I", "-c", OPENAT_READ_HELPER], + { + input: JSON.stringify({ parts, limit: maxBytes }), + encoding: "utf8", + timeout: 5_000, + maxBuffer: maxBytes * 2 + 64 * 1024, + stdio: ["pipe", "pipe", "pipe", root.fd], + shell: false, + }, + ); + if (result.status === 44) throw invalidFamilyTopology("descriptor-relative artifact is absent"); + if (result.error || result.status !== 0 || typeof result.stdout !== "string") { + throw invalidFamilyTopology("descriptor-relative artifact read failed"); + } + try { + const wire = JSON.parse(result.stdout) as { data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; + if ( + typeof wire.data !== "string" || + typeof wire.mtimeMs !== "number" || + typeof wire.dev !== "string" || + typeof wire.ino !== "string" + ) + throw new Error("invalid"); + return { + path: rawPath, + contents: Buffer.from(wire.data, "base64"), + mtimeMs: wire.mtimeMs, + dev: wire.dev, + ino: wire.ino, + }; + } catch { + throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); + } +} + +async function readTrustedSession(path: string, roots: ManagedRoots): Promise { + const trusted = readTrustedFile(path, roots, 128 * 1024 * 1024); + const headerLine = trusted.contents + .toString("utf8", 0, Math.min(trusted.contents.length, 256 * 1024)) + .split(/\r?\n/, 1)[0]; + let header: { type?: unknown; id?: unknown; parentSession?: unknown; rlmDepth?: unknown }; + try { + header = JSON.parse(headerLine ?? "") as typeof header; + } catch { + throw invalidFamilyTopology("session header is malformed"); + } + const hasParent = header.parentSession !== undefined; + const hasDepth = Number.isSafeInteger(header.rlmDepth) && (header.rlmDepth as number) >= 0; + if ( + header.type !== "session" || + typeof header.id !== "string" || + header.id === "" || + (hasParent && typeof header.parentSession !== "string") || + (hasParent && header.parentSession === "") || + (hasParent && !hasDepth) || + (!hasParent && header.rlmDepth !== undefined && !hasDepth) + ) + throw invalidFamilyTopology("session header lacks trustworthy topology claims"); + const persistedDepth = hasDepth ? (header.rlmDepth as number) : 0; + const info = await readSessionInfoFromBuffer(path, trusted.contents, { mtimeMs: trusted.mtimeMs }); + if (!info || info.id !== header.id) throw invalidFamilyTopology("session metadata does not match its header"); + return { + ...info, + path, + rlmDepth: persistedDepth, + persistedDepth, + ...(hasParent ? { persistedParentPath: header.parentSession as string } : {}), + }; +} + +async function readLatestRegistry( + path: string, + roots: ManagedRoots, +): Promise { let contents: string; try { - contents = await readFile(registryPath, "utf8"); + contents = readTrustedFile(path, roots, MAX_RLM_REGISTRY_BYTES).contents.toString("utf8"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return [target]; + if ((error as Error).message.includes("descriptor-relative artifact is absent")) return undefined; throw error; } const latest = new Map(); + let records = 0; for (const line of contents.split(/\r?\n/)) { if (!line.trim()) continue; + if (++records > MAX_RLM_REGISTRY_RECORDS) throw invalidFamilyTopology("registry record limit exhausted"); + let entry: SavedRlmSubagentRegistryEntry; try { - const entry = JSON.parse(line) as SavedRlmSubagentRegistryEntry; - if (entry.type === "rlm_subagent" && typeof entry.childId === "string") latest.set(entry.childId, entry); + entry = JSON.parse(line) as SavedRlmSubagentRegistryEntry; } catch { - // Ignore malformed registry history just like the owning worker does. + throw invalidFamilyTopology("registry contains malformed JSON"); } + if ( + entry.type !== "rlm_subagent" || + typeof entry.childId !== "string" || + entry.childId === "" || + typeof entry.sessionFile !== "string" || + entry.sessionFile === "" || + (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") + ) { + throw invalidFamilyTopology("registry contains an invalid edge"); + } + latest.set(entry.childId, entry); } - const siblingPaths = new Set([resolve(target.path)]); - for (const entry of latest.values()) { - if (entry.status !== "deleted" && typeof entry.sessionFile === "string") - siblingPaths.add(resolve(entry.sessionFile)); + return [...latest.values()]; +} + +export async function listCatalogFamilySessions(sessionDir?: string): Promise { + const effectiveSessionDir = sessionDir ?? getSessionsDir(); + const roots = await SessionManager.listAll(undefined, effectiveSessionDir); + const authority = managedRoots(effectiveSessionDir); + try { + const sessions = new Map(); + const ids = new Map(); + for (const root of roots) { + const trusted = await readTrustedSession(root.path, authority); + if (trusted.persistedDepth !== 0 || trusted.persistedParentPath !== undefined) { + throw invalidFamilyTopology("managed session seed claims a parent"); + } + const existingPath = ids.get(trusted.id); + if (existingPath && existingPath !== trusted.path) + throw invalidFamilyTopology("family contains a duplicate session id"); + ids.set(trusted.id, trusted.path); + sessions.set(trusted.path, trusted); + } + let edges = 0; + const visited = new Set(); + const visit = async (parent: TrustedSession, depth: number, ancestors: ReadonlySet): Promise => { + if (depth > MAX_RLM_FAMILY_DEPTH) throw invalidFamilyTopology("family depth limit exhausted"); + const parentPath = parent.path; + if (ancestors.has(parentPath)) throw invalidFamilyTopology("family contains a cycle"); + if (visited.has(parentPath)) return; + visited.add(parentPath); + const registryPath = rlmSubagentRegistryPath(parent, authority); + if (!registryPath) return; + const entries = await readLatestRegistry(registryPath, authority); + if (!entries) return; + const childAncestors = new Set(ancestors); + childAncestors.add(parentPath); + for (const entry of entries) { + if (entry.status === "deleted") continue; + if (++edges > MAX_RLM_FAMILY_EDGES) throw invalidFamilyTopology("family edge limit exhausted"); + const childPath = entry.sessionFile as string; + if (childAncestors.has(childPath)) throw invalidFamilyTopology("family contains a cycle"); + const child = await readTrustedSession(childPath, authority); + if (child.id !== entry.childId) throw invalidFamilyTopology("registry child id does not match session id"); + if (child.persistedParentPath === undefined) + throw invalidFamilyTopology("child lacks a persisted parent path"); + const claimedParentPath = resolve(dirname(child.path), child.persistedParentPath); + readTrustedFile(claimedParentPath, authority, 128 * 1024 * 1024); + if (claimedParentPath !== parentPath) + throw invalidFamilyTopology("child parent path does not match traversed parent"); + if (child.persistedDepth !== parent.persistedDepth + 1) + throw invalidFamilyTopology("child depth does not equal parent depth plus one"); + const existingPath = ids.get(child.id); + if (existingPath && existingPath !== child.path) + throw invalidFamilyTopology("family contains a duplicate session id"); + const existing = sessions.get(child.path); + if ( + existing && + (existing.id !== child.id || + existing.persistedParentPath !== child.persistedParentPath || + existing.persistedDepth !== child.persistedDepth) + ) { + throw invalidFamilyTopology("family contains a conflicting duplicate"); + } + if (!existing && sessions.size >= MAX_RLM_FAMILY_NODES) + throw invalidFamilyTopology("family node limit exhausted"); + ids.set(child.id, child.path); + sessions.set(child.path, child); + await visit(child, depth + 1, childAncestors); + } + }; + for (const root of [...sessions.values()]) await visit(root, 0, new Set()); + return [...sessions.values()]; + } finally { + closeManagedRoots(authority); } - const siblings = await Promise.all([...siblingPaths].map((path) => readSessionInfo(path))); - return siblings.filter( - (info): info is SessionInfo => - info !== null && - info.parentSessionPath !== undefined && - resolve(dirname(info.path), info.parentSessionPath) === parentPath, +} +export async function listSavedSessionSiblings(sessionPath: string, sessionDir?: string): Promise { + const family = await listCatalogFamilySessions(sessionDir); + const targetPath = resolve(sessionPath); + const target = family.find((session) => session.path === targetPath); + if (!target) throw new Error(`Session not found: ${sessionPath}`); + if (!target.parentSessionPath) return [target]; + const parentPath = resolve(dirname(target.path), target.parentSessionPath); + return family.filter( + (session) => + session.parentSessionPath !== undefined && + resolve(dirname(session.path), session.parentSessionPath) === parentPath, ); } @@ -141,6 +445,7 @@ function isCatalogRequest(value: unknown): value is CatalogRequest { candidate.type === "request" && typeof candidate.id === "string" && (candidate.command === "list" || + candidate.command === "family" || candidate.command === "resolve" || candidate.command === "siblings" || candidate.command === "rename" || @@ -194,6 +499,16 @@ async function handleCatalogRequest(request: CatalogRequest): Promise { }); return; } + case "family": { + const sessions = await listCatalogFamilySessions(request.sessionDir); + sendCatalogMessage({ + type: "response", + id: request.id, + success: true, + data: { sessions: sessions.map(serializeSessionInfo) }, + }); + return; + } case "resolve": { const localMatch = resolveCatalogSessionMatch( await SessionManager.list(request.cwd, request.sessionDir), @@ -228,7 +543,11 @@ async function handleCatalogRequest(request: CatalogRequest): Promise { type: "response", id: request.id, success: true, - data: { sessions: (await listSavedSessionSiblings(request.sessionPath)).map(serializeSessionInfo) }, + data: { + sessions: (await listSavedSessionSiblings(request.sessionPath, request.sessionDir)).map( + serializeSessionInfo, + ), + }, }); return; case "rename": @@ -328,12 +647,23 @@ export class DaemonCatalogClient { return data.sessions.map(deserializeSessionInfo); } - async siblings(sessionPath: string): Promise { + async family(sessionDir?: string): Promise { + const data = await this.request<{ sessions: SessionInfoWire[] }>({ + type: "request", + id: randomUUID(), + command: "family", + sessionDir, + }); + return data.sessions.map(deserializeSessionInfo); + } + + async siblings(sessionPath: string, sessionDir?: string): Promise { const data = await this.request<{ sessions: SessionInfoWire[] }>({ type: "request", id: randomUUID(), command: "siblings", sessionPath, + sessionDir, }); return data.sessions.map(deserializeSessionInfo); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 3443cd3f8..164085ce0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -404,6 +404,21 @@ type PassiveRlmSubagent = PassiveRlmRoot & { chain: PersistedRlmSubagentRegistryEntry[]; }; +type AgentFamilyCatalogSource = "saved" | "passive" | "resident" | "remote"; + +/** + * The public catalog has to retain a usable depth for legacy callers, while + * authorization must distinguish a persisted claim from a depth inferred by a + * legacy reader. These claim fields are deliberately private to catalog + * construction and never escape into agent-messages' public roster. + */ +type AgentFamilyCatalogCandidate = AgentFamilyCatalogEntry & { + source: AgentFamilyCatalogSource; + depthClaim?: number; + parentSessionIdClaim?: string; + parentSessionPathClaim?: string; +}; + class RuntimeOpenCancelledError extends Error {} class BoundSessionUnavailableError extends Error {} @@ -2860,18 +2875,30 @@ export class AgentDaemon { } private async createAgentObserveListResult(currentState: ActiveSessionState): Promise { + const catalog = await this.agentFamilyCatalogEntries(); + this.authoritativeAgentFamilyEntry(currentState, catalog); const agents = this.listTargetableSessionStates(currentState) - .filter( - (state) => - state.activeSessionId === currentState.activeSessionId || - this.isAgentFamilyReachable(currentState, state), - ) - .map((state) => this.createAgentObserveSummary(state, currentState)); + .filter((state) => { + if (state.activeSessionId === currentState.activeSessionId) return true; + try { + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(state, catalog), + catalog, + ); + return true; + } catch (error) { + if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) return false; + throw error; + } + }) + .map((state) => this.createAgentObserveSummary(state, currentState, catalog)); const residentIds = new Set(agents.map((agent) => agent.activeSessionId)); for (const passive of await this.listPassiveRlmSubagents()) { if (residentIds.has(passive.info.id)) continue; try { - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.passiveAgentFamilyEntry(passive)); + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId(passive.info.id, catalog); + assertAgentFamilyReach(this.authoritativeAgentFamilyEntry(currentState, catalog), passiveEntry, catalog); } catch (error) { if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) continue; throw error; @@ -2901,7 +2928,7 @@ export class AgentDaemon { residentIds.add(passive.info.id); } return { - current: this.createAgentObserveSummary(currentState, currentState), + current: this.createAgentObserveSummary(currentState, currentState, catalog), agents, }; } @@ -2910,10 +2937,12 @@ export class AgentDaemon { currentState: ActiveSessionState, target: string, ): Promise { - const targetState = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, target); - this.assertAgentFamilyReachable(currentState, targetState); + const { targetState, catalog } = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, target); + // Hydration may mutate live endpoint fields. Re-authorize and label only from the + // captured catalog that authorized the wake, never from a post-hydration rescan. + this.assertAgentFamilyReachable(currentState, targetState, catalog); return { - agent: this.createAgentObserveSummary(targetState, currentState), + agent: this.createAgentObserveSummary(targetState, currentState, catalog), }; } @@ -2921,14 +2950,15 @@ export class AgentDaemon { currentState: ActiveSessionState, input: AgentObserveRecentMessagesInput, ): Promise { - const targetState = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, input.target); - this.assertAgentFamilyReachable(currentState, targetState); + const { targetState, catalog } = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, input.target); + // See getAgent: an observation must use its original authorization snapshot. + this.assertAgentFamilyReachable(currentState, targetState, catalog); const limit = normalizeObserveLimit(input.limit); const maxChars = normalizeObserveMaxChars(input.maxChars); const messages = targetState.runtime.session.messages; const startIndex = Math.max(0, messages.length - limit); return { - agent: this.createAgentObserveSummary(targetState, currentState), + agent: this.createAgentObserveSummary(targetState, currentState, catalog), messages: messages .slice(startIndex) .map((message, offset) => createAgentObserveMessagePreview(message, startIndex + offset, maxChars)), @@ -2941,8 +2971,17 @@ export class AgentDaemon { private createAgentObserveSummary( state: ActiveSessionState, currentState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[], ): AgentObserveAgentSummary { const summary = summaryForActiveSession(state); + // The catalog is the authorization snapshot, so relationship fields must not + // be re-derived from a runtime that may have changed while a passive target woke. + const topology = this.authoritativeAgentFamilyEntry(state, catalog); + const parentState = topology.parentSessionId + ? [...this.sessions.values()].find( + (candidate) => candidate.runtime.session.sessionId === topology.parentSessionId, + ) + : undefined; const session = state.runtime.session; const messages = session.messages; const latest = messages.at(-1); @@ -2971,8 +3010,8 @@ export class AgentDaemon { messageCount: summary.messageCount, queuedCount: summary.sessionActions.queuedCount, isSessionActive: summary.isSessionActive, - ...(summary.parentActiveSessionId ? { parentActiveSessionId: summary.parentActiveSessionId } : {}), - ...(summary.parentSessionId ? { parentSessionId: summary.parentSessionId } : {}), + ...(parentState ? { parentActiveSessionId: parentState.activeSessionId } : {}), + ...(topology.parentSessionId ? { parentSessionId: topology.parentSessionId } : {}), ...(summary.rlmChildId ? { rlmChildId: summary.rlmChildId } : {}), ...(summary.rlmParentNodeId ? { rlmParentNodeId: summary.rlmParentNodeId } : {}), ...(summary.firstMessage ? { firstMessage: summary.firstMessage } : {}), @@ -4950,6 +4989,8 @@ export class AgentDaemon { passive.rootParentState?.runtime.session.sessionFile ?? passive.rootInfo?.path, rlmDepth: info.rlmDepth ?? entry.rlmDepth, + // Passive rows are not resident daemon runtimes. Registry state is + // retained separately below and must not change roster lifecycle. status: "inactive", rlmChildId: entry.childId, rlmChildRegistryStatus: entry.status, @@ -5035,9 +5076,11 @@ export class AgentDaemon { } private async createAgentFamilyRoster(currentState: ActiveSessionState): Promise { - const catalog = await this.createAgentFamilyCatalog(currentState); - const current = catalog.find((entry) => entry.id === currentState.runtime.session.sessionId); - if (!current) throw new Error("Current agent is missing from the family catalog"); + // Roster membership is an authorization surface: capture the same immutable + // authoritative topology used by message delivery, never the legacy Map + // catalog whose duplicate IDs overwrite one another. + const catalog = await this.agentFamilyCatalogEntries(); + const current = this.authoritativeAgentFamilyEntry(currentState, catalog); return buildAgentFamilyRoster(current, catalog); } @@ -5255,54 +5298,255 @@ export class AgentDaemon { }; } - private passiveAgentFamilyEntry(passive: PassiveRlmSubagent): AgentFamilyCatalogEntry { - const entry = passive.entry; - const depth = passive.info.rlmDepth ?? entry.rlmDepth ?? passive.chain.length; + /** Capture persisted topology once for an authorization decision. */ + private async agentFamilyCatalogEntries(): Promise { + const saved = await SessionManager.listAll(undefined, this.options.defaultSessionConfig.sessionDir); + const entries: AgentFamilyCatalogCandidate[] = await Promise.all( + saved.map((info) => this.savedAgentFamilyCandidate(info)), + ); + // Artifact-resident descendants are absent from the saved-session scan. + for (const passive of await this.listPassiveRlmSubagents(saved, true)) { + entries.push(await this.passiveAgentFamilyCandidate(passive)); + } + for (const state of this.sessions.values()) entries.push(this.residentAgentFamilyCandidate(state)); + for (const peer of this.remoteAgentPeers.values()) entries.push(this.remoteAgentFamilyCandidate(peer)); + return Object.freeze(this.mergeEquivalentAgentFamilyCatalogEntries(entries)); + } + + /** The header is the only durable evidence that a saved depth/path was explicit. */ + private async persistedTopologyClaims(sessionPath: string): Promise<{ depth?: number; parentSessionPath?: string }> { + try { + const firstLine = (await readFile(sessionPath, "utf8")).split("\n", 1)[0]; + if (!firstLine) return {}; + const header = JSON.parse(firstLine) as { rlmDepth?: unknown; parentSession?: unknown }; + const depth = + typeof header.rlmDepth === "number" && Number.isSafeInteger(header.rlmDepth) && header.rlmDepth >= 0 + ? header.rlmDepth + : undefined; + const parentSessionPath = + typeof header.parentSession === "string" && header.parentSession + ? canonicalSessionPath( + isAbsolute(header.parentSession) + ? header.parentSession + : resolve(dirname(sessionPath), header.parentSession), + ) + : undefined; + return { ...(depth !== undefined ? { depth } : {}), ...(parentSessionPath ? { parentSessionPath } : {}) }; + } catch { + return {}; + } + } + + private async savedAgentFamilyCandidate(info: SessionInfo): Promise { + const claims = await this.persistedTopologyClaims(info.path); + return { + id: info.id, + ...(info.name ? { name: info.name } : {}), + // resolveSessionRlmDepth is retained only as a usable legacy fallback. + // It is deliberately not a claim and therefore cannot contradict an overlay. + depth: info.rlmDepth, + status: "inactive", + sessionPath: canonicalSessionPath(info.path), + source: "saved", + ...(claims.depth !== undefined ? { depthClaim: claims.depth } : {}), + ...(claims.parentSessionPath + ? { parentSessionPath: claims.parentSessionPath, parentSessionPathClaim: claims.parentSessionPath } + : {}), + }; + } + + private remoteAgentFamilyCandidate(peer: AgentSessionMessageAgentSummary): AgentFamilyCatalogCandidate { + // Remote peer state is untrusted topology input. In particular, never let a + // malformed subagent be silently coerced into a sibling root by `?? 0`. + const depth = peer.rlmDepth; + const hasParentSessionId = peer.parentSessionId !== undefined; + const hasParentSessionPath = peer.parentSessionPath !== undefined; + const parentSessionId = + typeof peer.parentSessionId === "string" && peer.parentSessionId.trim() ? peer.parentSessionId : undefined; const parentSessionPath = - depth > 0 - ? (entry.parentSessionFile ?? - passive.chain.at(-2)?.sessionFile ?? - passive.rootParentState?.runtime.session.sessionFile ?? - passive.rootInfo?.path) + typeof peer.parentSessionPath === "string" && peer.parentSessionPath.trim() + ? canonicalSessionPath(peer.parentSessionPath) : undefined; + if ( + typeof depth !== "number" || + !Number.isSafeInteger(depth) || + depth < 0 || + (depth === 0 && (hasParentSessionId || hasParentSessionPath)) || + (depth > 0 && !parentSessionId && !parentSessionPath) + ) { + throw new Error(AGENT_FAMILY_REACH_ERROR); + } return { - id: passive.info.id, - name: passive.info.name ?? entry.sessionName, + id: peer.sessionId, + ...(peer.sessionName ? { name: peer.sessionName } : {}), depth, - status: "idle", - ...(depth > 0 && entry.parentSessionId ? { parentSessionId: entry.parentSessionId } : {}), - ...(parentSessionPath ? { parentSessionPath: canonicalSessionPath(parentSessionPath) } : {}), + status: peer.status ?? "idle", + ...(peer.sessionPath ? { sessionPath: canonicalSessionPath(peer.sessionPath) } : {}), + source: "remote", + depthClaim: depth, + ...(parentSessionId ? { parentSessionId, parentSessionIdClaim: parentSessionId } : {}), + ...(parentSessionPath ? { parentSessionPath, parentSessionPathClaim: parentSessionPath } : {}), + }; + } + + private residentAgentFamilyCandidate(state: ActiveSessionState): AgentFamilyCatalogCandidate { + const entry = this.agentFamilyEntry(state); + const session = state.runtime.session; + const metadata = state.runtime.metadata; + const headerParent = this.resolveHeaderParentSessionPath(state); + const parentSessionPath = headerParent ?? metadata.parentSessionFile; + // A root has no parent claim. Do not let a stale runtime rlmDepth turn it + // into a depth-N orphan when its durable/root metadata still says root. + const isUnparentedTopLevel = + metadata.kind === "top-level" && !headerParent && !metadata.parentSessionId && !metadata.parentSessionFile; + const depthClaim = + !isUnparentedTopLevel && + typeof session.rlmDepth === "number" && + Number.isSafeInteger(session.rlmDepth) && + session.rlmDepth >= 0 + ? session.rlmDepth + : undefined; + return { + ...entry, + ...(isUnparentedTopLevel ? { depth: 0 } : {}), + source: "resident", + ...(depthClaim !== undefined ? { depthClaim } : {}), + ...(metadata.parentSessionId + ? { parentSessionId: metadata.parentSessionId, parentSessionIdClaim: metadata.parentSessionId } + : {}), + ...(parentSessionPath + ? { + parentSessionPath: canonicalSessionPath(parentSessionPath), + parentSessionPathClaim: canonicalSessionPath(parentSessionPath), + } + : {}), + }; + } + + /** + * Merge a durable row with a passive/resident view only if all *present* + * topology claims agree. In particular, the depth produced for legacy saved + * files is a fallback, not evidence against a newer explicit overlay. + */ + private mergeEquivalentAgentFamilyCatalogEntries( + entries: readonly AgentFamilyCatalogCandidate[], + ): AgentFamilyCatalogEntry[] { + const canonical = entries.map((entry) => ({ + ...entry, + ...(entry.sessionPath ? { sessionPath: canonicalSessionPath(entry.sessionPath) } : {}), + ...(entry.parentSessionPath ? { parentSessionPath: canonicalSessionPath(entry.parentSessionPath) } : {}), + ...(entry.parentSessionPathClaim + ? { parentSessionPathClaim: canonicalSessionPath(entry.parentSessionPathClaim) } + : {}), + })); + const compatible = (left: AgentFamilyCatalogCandidate, right: AgentFamilyCatalogCandidate) => + left.id === right.id && + left.sessionPath === right.sessionPath && + (left.depthClaim === undefined || right.depthClaim === undefined || left.depthClaim === right.depthClaim) && + (left.parentSessionPathClaim === undefined || + right.parentSessionPathClaim === undefined || + left.parentSessionPathClaim === right.parentSessionPathClaim) && + (left.parentSessionIdClaim === undefined || + right.parentSessionIdClaim === undefined || + left.parentSessionIdClaim === right.parentSessionIdClaim); + const groups: AgentFamilyCatalogCandidate[][] = []; + for (const entry of canonical) { + const group = groups.find((candidate) => candidate.every((member) => compatible(member, entry))); + if (group) group.push(entry); + else groups.push([entry]); + } + const sourceRank: Record = { + saved: 3, + passive: 2, + resident: 1, + remote: 0, + }; + const statusRank: Record = { + inactive: 0, + idle: 1, + running: 2, + }; + const stable = (values: readonly (string | undefined)[]) => + values.filter((value): value is string => value !== undefined).sort()[0]; + const preferred = ( + rows: readonly AgentFamilyCatalogCandidate[], + get: (row: AgentFamilyCatalogCandidate) => T | undefined, + ) => + [...rows] + .sort((left, right) => sourceRank[right.source] - sourceRank[left.source]) + .map(get) + .find((value): value is T => value !== undefined); + return groups.map((rows) => { + const status = rows.reduce( + (best, row) => (statusRank[row.status] > statusRank[best] ? row.status : best), + "inactive", + ); + const depth = preferred(rows, (row) => row.depthClaim) ?? preferred(rows, (row) => row.depth)!; + const parentSessionId = preferred(rows, (row) => row.parentSessionIdClaim); + const parentSessionPath = preferred(rows, (row) => row.parentSessionPathClaim); + return { + id: rows[0]!.id, + depth, + status, + ...(stable(rows.map((row) => row.name)) ? { name: stable(rows.map((row) => row.name)) } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + ...(parentSessionPath ? { parentSessionPath } : {}), + ...(rows[0]!.sessionPath ? { sessionPath: rows[0]!.sessionPath } : {}), + }; + }); + } + + private async passiveAgentFamilyCandidate(passive: PassiveRlmSubagent): Promise { + const entry = passive.entry; + const claims = await this.persistedTopologyClaims(entry.sessionFile); + const registryParentPath = entry.parentSessionFile ? canonicalSessionPath(entry.parentSessionFile) : undefined; + const parentSessionPath = claims.parentSessionPath ?? registryParentPath; + const depthClaim = claims.depth ?? entry.rlmDepth; + return { + id: passive.info.id, + ...((passive.info.name ?? entry.sessionName) ? { name: passive.info.name ?? entry.sessionName } : {}), + depth: depthClaim ?? passive.info.rlmDepth, + status: "inactive", sessionPath: canonicalSessionPath(entry.sessionFile), + source: "passive", + ...(depthClaim !== undefined ? { depthClaim } : {}), + ...(entry.parentSessionId + ? { parentSessionId: entry.parentSessionId, parentSessionIdClaim: entry.parentSessionId } + : {}), + ...(parentSessionPath ? { parentSessionPath, parentSessionPathClaim: parentSessionPath } : {}), }; } private async getOrHydrateAuthorizedAgentFamilyTarget( currentState: ActiveSessionState, target: string, - ): Promise { + ): Promise<{ targetState: ActiveSessionState; catalog: readonly AgentFamilyCatalogEntry[] }> { + const catalog = await this.agentFamilyCatalogEntries(); try { - return this.getBoundSessionState(target); + return { targetState: this.getBoundSessionState(target), catalog }; } catch (error) { if (error instanceof BoundSessionUnavailableError) { const targetState = this.getSessionState(target); - this.assertAgentFamilyReachable(currentState, targetState); - return this.getOrHydrateBoundSessionState(target); + this.assertAgentFamilyReachable(currentState, targetState, catalog); + return { targetState: await this.getOrHydrateBoundSessionState(target), catalog }; } if (error instanceof AmbiguousActiveSessionError) { - const targetState = this.resolveAgentFamilySessionName(currentState, target, error); - return this.getOrHydrateBoundSessionState(targetState.activeSessionId); + const targetState = this.resolveAgentFamilySessionName(currentState, target, error, catalog); + return { targetState: await this.getOrHydrateBoundSessionState(targetState.activeSessionId), catalog }; } } const passive = await this.findPassiveRlmSubagent(target); - if (!passive) return this.getOrHydrateBoundSessionState(target); - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.passiveAgentFamilyEntry(passive)); - return this.hydratePassiveRlmSubagent(passive); + if (!passive) return { targetState: await this.getOrHydrateBoundSessionState(target), catalog }; + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId(passive.info.id, catalog); + assertAgentFamilyReach(this.authoritativeAgentFamilyEntry(currentState, catalog), passiveEntry, catalog); + return { targetState: await this.hydratePassiveRlmSubagent(passive), catalog }; } private resolveAgentFamilySessionName( currentState: ActiveSessionState, target: string, ambiguity: AmbiguousActiveSessionError, + catalog: readonly AgentFamilyCatalogEntry[], ): ActiveSessionState { const reachableMatches = new Map( [...this.sessions.values()] @@ -5311,7 +5555,7 @@ export class AgentDaemon { return ( (session.sessionId === target || session.sessionName === target) && (state.activeSessionId === currentState.activeSessionId || - this.isAgentFamilyReachable(currentState, state)) + this.isAgentFamilyReachable(currentState, state, catalog)) ); }) .map((state) => [state.activeSessionId, state]), @@ -5321,9 +5565,36 @@ export class AgentDaemon { return matches[0]!; } - private isAgentFamilyReachable(currentState: ActiveSessionState, targetState: ActiveSessionState): boolean { + private authoritativeAgentFamilyEntry( + state: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[], + ): AgentFamilyCatalogEntry { + return this.authoritativeAgentFamilyEntryForSessionId(state.runtime.session.sessionId, catalog); + } + + private authoritativeAgentFamilyEntryForSessionId( + sessionId: string, + catalog: readonly AgentFamilyCatalogEntry[], + ): AgentFamilyCatalogEntry { + const entries = catalog.filter((candidate) => candidate.id === sessionId); + if (entries.length !== 1) throw new Error(AGENT_FAMILY_REACH_ERROR); + return entries[0]!; + } + + private isAgentFamilyReachable( + currentState: ActiveSessionState, + targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(currentState), + this.agentFamilyEntry(targetState), + ], + ): boolean { try { - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.agentFamilyEntry(targetState)); + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(targetState, catalog), + catalog, + ); return true; } catch (error) { if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) return false; @@ -5331,17 +5602,49 @@ export class AgentDaemon { } } - private assertAgentFamilyReachable(currentState: ActiveSessionState, targetState: ActiveSessionState): void { + private assertAgentFamilyReachable( + currentState: ActiveSessionState, + targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(currentState), + this.agentFamilyEntry(targetState), + ], + ): void { if (currentState.activeSessionId === targetState.activeSessionId) return; - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.agentFamilyEntry(targetState)); + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(targetState, catalog), + catalog, + ); } private agentMessageRelationship( fromState: ActiveSessionState | undefined, targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(targetState), + ...(fromState ? [this.agentFamilyEntry(fromState)] : []), + ], ): AgentFamilyRelationship | undefined { if (!fromState) return undefined; - return agentFamilyRelationship(this.agentFamilyEntry(targetState), this.agentFamilyEntry(fromState)); + return agentFamilyRelationship( + this.authoritativeAgentFamilyEntry(targetState, catalog), + this.authoritativeAgentFamilyEntry(fromState, catalog), + catalog, + ); + } + + private cliAgentMessageRelationship( + fromState: ActiveSessionState, + targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[], + ): AgentFamilyRelationship | undefined { + try { + return this.agentMessageRelationship(fromState, targetState, catalog); + } catch (error) { + if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) return undefined; + throw error; + } } private async sendAgentSessionMessage(options: { @@ -5358,27 +5661,48 @@ export class AgentDaemon { } const targetSelector = assertDirectAgentMessageTarget(options.targetSelector); const message = normalizeAgentSessionMessage(options.message, DEFAULT_AGENT_MESSAGE_MAX_CHARS); + // Agent-origin authorization uses one immutable persisted topology through + // selector resolution, wake, and delivery. CLI-origin topology is advisory + // label metadata and must not make an otherwise valid delivery unavailable. + let catalog: readonly AgentFamilyCatalogEntry[] | undefined; + if (options.fromState) { + try { + catalog = await this.agentFamilyCatalogEntries(); + } catch (error) { + if (options.origin === "agent") throw error; + this.log( + `Agent family catalog unavailable for CLI message relationship: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } let targetState: ActiveSessionState; try { targetState = this.getBoundSessionState(targetSelector); } catch (error) { if (error instanceof BoundSessionUnavailableError) { + const unavailableTarget = this.getSessionState(targetSelector); + if (this.closingSessions.has(unavailableTarget.activeSessionId)) throw error; if (options.origin === "agent" && options.fromState) { - this.assertAgentFamilyReachable(options.fromState, this.getSessionState(targetSelector)); + this.assertAgentFamilyReachable(options.fromState, unavailableTarget, catalog!); } targetState = await this.getOrHydrateBoundSessionState(targetSelector); } else { if (error instanceof AmbiguousActiveSessionError) { if (options.origin !== "agent" || !options.fromState) throw error; - const resolved = this.resolveAgentFamilySessionName(options.fromState, targetSelector, error); + const resolved = this.resolveAgentFamilySessionName(options.fromState, targetSelector, error, catalog!); targetState = await this.getOrHydrateBoundSessionState(resolved.activeSessionId); } else { const passiveSubagent = await this.findPassiveRlmSubagent(targetSelector); if (passiveSubagent) { if (options.origin === "agent" && options.fromState) { + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId( + passiveSubagent.info.id, + catalog!, + ); assertAgentFamilyReach( - this.agentFamilyEntry(options.fromState), - this.passiveAgentFamilyEntry(passiveSubagent), + this.authoritativeAgentFamilyEntry(options.fromState, catalog!), + passiveEntry, + catalog!, ); } targetState = await this.hydratePassiveRlmSubagent(passiveSubagent); @@ -5401,11 +5725,14 @@ export class AgentDaemon { } } } + if (this.closingSessions.has(targetState.activeSessionId)) { + throw new Error(`Active session ${targetState.activeSessionId} is closing`); + } if (options.fromState?.activeSessionId === targetState.activeSessionId) { throw new Error("Agent messaging cannot target the sending session"); } if (options.origin === "agent" && options.fromState) { - this.assertAgentFamilyReachable(options.fromState, targetState); + this.assertAgentFamilyReachable(options.fromState, targetState, catalog!); } const releaseQueueSlot = this.reserveAgentMessageQueueSlot(targetState); const senderKey = @@ -5423,7 +5750,12 @@ export class AgentDaemon { from: options.sender ?? this.createAgentSessionMessageSender(options.fromState, options.clientId ?? options.origin), - fromRelationship: this.agentMessageRelationship(options.fromState, targetState), + fromRelationship: + options.origin === "cli" && options.fromState + ? catalog + ? this.cliAgentMessageRelationship(options.fromState, targetState, catalog) + : undefined + : this.agentMessageRelationship(options.fromState, targetState, catalog), target: this.createAgentSessionMessageEndpoint(targetState), }; try { diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index b26eb1bdc..bfe126f4e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -60,8 +60,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 14 carries the client's monotonic telemetry opt-out on attach and reattach. // Revision 15 adds the mutate_queued_message command and queue_message_mutation capability. // Revision 16 adds the "stopping" workerState and stops reporting disconnected workers as "ready". -export const DAEMON_SCHEMA_REVISION = 16; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-16-1bcb9e7f1a49"; +// Revision 17 carries the catalog authority root for inactive saved-session renames. +export const DAEMON_SCHEMA_REVISION = 17; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-17-ea658e1e8208"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -597,7 +598,14 @@ export type DaemonCommand = | { id?: string; type: "set_session_name"; activeSessionId: string; name: string; workerToken?: string } | { id?: string; type: "get_rlm_max_depth_status"; activeSessionId: string } | { id?: string; type: "set_rlm_max_depth"; activeSessionId: string; maxDepth: number; global?: boolean } - | { id?: string; type: "rename_saved_session"; activeSessionId?: string; sessionPath: string; name: string } + | { + id?: string; + type: "rename_saved_session"; + activeSessionId?: string; + sessionPath: string; + name: string; + sessionDir?: string; + } | { id?: string; type: "delete_saved_session"; activeSessionId?: string; sessionPath: string } | { id?: string; type: "get_session_context"; activeSessionId: string } | { id?: string; type: "get_session_tree"; activeSessionId: string } @@ -735,7 +743,7 @@ export const DAEMON_COMMAND_COMPATIBILITY = { set_session_name: LEGACY_DAEMON_COMMAND, get_rlm_max_depth_status: RLM_MAX_DEPTH_COMMAND, set_rlm_max_depth: RLM_MAX_DEPTH_COMMAND, - rename_saved_session: LEGACY_DAEMON_COMMAND, + rename_saved_session: { minProtocol: 7, minSchemaRevision: 17 }, delete_saved_session: LEGACY_DAEMON_COMMAND, get_session_context: LEGACY_DAEMON_COMMAND, get_session_tree: FLAT_SESSION_TREE_COMMAND, diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 3069dd4af..c972d1a86 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -253,6 +253,12 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "shutdown", ]); +type FamilyCatalogSource = "persisted" | "artifact" | "live"; + +type FamilyCatalogCandidate = AgentFamilyCatalogEntry & { + source: FamilyCatalogSource; +}; + interface ResidentWorker { descriptor: DaemonWorkerDescriptor; descriptorPath: string; @@ -1759,14 +1765,24 @@ export class DaemonSupervisor { return this.forwardToWorker(match.worker, command); } case "rename_saved_session": { - const target = await this.savedSessionNameReservationInput(command.sessionPath, command.name.trim()); + const match = command.activeSessionId + ? await this.findWorkerForClient(client, command.activeSessionId) + : undefined; + const sessionDir = + match?.worker.descriptor.createCommand.config?.sessionDir ?? + command.sessionDir ?? + this.defaultSessionConfig.sessionDir; + const target = await this.savedSessionNameReservationInput( + command.sessionPath, + command.name.trim(), + sessionDir, + ); return await this.withSessionNameReservation(target, async () => { - await this.assertSupervisorSavedSessionNameAvailable(command.sessionPath, target.name); - if (!command.activeSessionId) { + await this.assertSupervisorSavedSessionNameAvailable(command.sessionPath, target.name, sessionDir); + if (!match) { await this.catalog.rename(command.sessionPath, command.name); return success(command.id, command.type); } - const match = await this.findWorkerForClient(client, command.activeSessionId); return await this.forwardToWorker(match.worker, { ...command, activeSessionId: match.summary.activeSessionId ?? match.summary.id, @@ -1790,19 +1806,25 @@ export class DaemonSupervisor { const source = command.fromActiveSessionId ? await this.findWorkerForClient(client, command.fromActiveSessionId) : undefined; + // Hold this persisted topology through pre-wake and post-wake checks. + const sourceSessionDir = + source?.worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir; + const familyCatalog = + source && command.agentOrigin === true ? await this.familyCatalogEntries(sourceSessionDir) : undefined; + // Session IDs are the stable identities for this authorization snapshot. Do not + // rebuild either endpoint from worker summaries after the snapshot is captured. + const sourceSessionId = source?.summary.sessionId; + let targetSessionId: string; let target: WorkerMatch; try { target = await this.findWorkerForClient(client, command.targetActiveSessionId); + targetSessionId = target.summary.sessionId; } catch (error) { if (!(error instanceof Error) || !error.message.startsWith("Unknown active session:")) throw error; const cwd = source?.summary.cwd ?? this.defaultSessionConfig.cwd ?? process.cwd(); let sessionPath: string; try { - sessionPath = await this.catalog.resolve( - command.targetActiveSessionId, - cwd, - source?.worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir, - ); + sessionPath = await this.catalog.resolve(command.targetActiveSessionId, cwd, sourceSessionDir); } catch (catalogError) { // Preserve selector ambiguity so a2a senders can distinguish it from // the original unknown-active-session lookup failure. @@ -1811,18 +1833,21 @@ export class DaemonSupervisor { } throw error; } + const targetInfo = await readSessionInfo(sessionPath); + if (!targetInfo) throw new Error(`Unknown active session: ${command.targetActiveSessionId}`); + targetSessionId = targetInfo.id; if (source && command.agentOrigin === true) { - const targetInfo = await readSessionInfo(sessionPath); - if (!targetInfo) throw new Error(`Unknown active session: ${command.targetActiveSessionId}`); assertAgentFamilyReach( - this.familyCatalogEntry(source.summary), - this.familyCatalogEntry(summaryForInactiveSession(targetInfo)), + this.authoritativeFamilyCatalogEntry(familyCatalog!, sourceSessionId!), + this.authoritativeFamilyCatalogEntry(familyCatalog!, targetSessionId), + familyCatalog!, ); } const worker = await this.createOrReuseWorker(this.protocolClientId(client), { type: "create", sessionPath, continueRecent: false, + config: { sessionDir: sourceSessionDir }, }); const summary = this.findSummaryInWorker(worker, sessionPath) ?? @@ -1832,7 +1857,16 @@ export class DaemonSupervisor { } const targetActiveSessionId = target.summary.activeSessionId ?? target.summary.id; if (source && command.agentOrigin === true) { - assertAgentFamilyReach(this.familyCatalogEntry(source.summary), this.familyCatalogEntry(target.summary)); + // Waking must not substitute a different live session for the target that + // was authorized by the captured topology. + if (target.summary.sessionId !== targetSessionId) { + throw new Error("Agent reach is limited to parent, siblings, and children"); + } + assertAgentFamilyReach( + this.authoritativeFamilyCatalogEntry(familyCatalog!, sourceSessionId!), + this.authoritativeFamilyCatalogEntry(familyCatalog!, targetSessionId), + familyCatalog!, + ); } if (source) { if ((source.summary.activeSessionId ?? source.summary.id) === targetActiveSessionId) { @@ -1899,7 +1933,11 @@ export class DaemonSupervisor { if (command.type === "rename" || command.type === "set_session_name") { const reservation = this.summaryNameReservationInput(match.summary, command.name.trim()); return await this.withSessionNameReservation(reservation, async () => { - await this.assertSupervisorSessionNameAvailable(match.summary, reservation.name); + await this.assertSupervisorSessionNameAvailable( + match.summary, + reservation.name, + match.worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir, + ); return forward(); }); } @@ -1968,7 +2006,7 @@ export class DaemonSupervisor { if ("activeSessionId" in command) { const match = await this.findWorkerForClient(client, command.activeSessionId); cwd = match.summary.cwd; - sessionDir = this.defaultSessionConfig.sessionDir; + sessionDir = match.worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir; activeSessionId = match.summary.activeSessionId ?? match.summary.id; } else { cwd = resolve(command.cwd); @@ -2009,6 +2047,7 @@ export class DaemonSupervisor { createCommand = { ...command, name: normalizedName }; } const ownerClientId = command.lifecycle === "client_owned" ? clientId : undefined; + const config = mergeAgentSessionRuntimeConfig(this.defaultSessionConfig, command.config); if (command.sessionPath) { const activeMatches = this.matchWorkers(command.sessionPath); if (activeMatches.length === 1 && !(await this.reclaimStaleWorkerRegistration(activeMatches[0]!.worker))) { @@ -2017,7 +2056,6 @@ export class DaemonSupervisor { if (activeMatches.length > 1) { throw new Error(`Ambiguous active session "${command.sessionPath}"`); } - const config = mergeAgentSessionRuntimeConfig(this.defaultSessionConfig, command.config); const sessionPath = looksLikeSessionPath(command.sessionPath) ? resolve(command.sessionPath) : await this.catalog.resolve(command.sessionPath, config.cwd ?? process.cwd(), config.sessionDir); @@ -2038,7 +2076,9 @@ export class DaemonSupervisor { } const opening = (async () => { if (!createCommand.name) return this.launchWorker(createCommand, undefined, ownerClientId); - const savedSiblings = createCommand.sessionPath ? await this.catalog.siblings(createCommand.sessionPath) : []; + const savedSiblings = createCommand.sessionPath + ? await this.catalog.siblings(createCommand.sessionPath, config.sessionDir) + : []; const target = savedSiblings.find( (session) => canonicalSessionPath(session.path) === canonicalSessionPath(createCommand.sessionPath!), ); @@ -2048,7 +2088,7 @@ export class DaemonSupervisor { if (target?.parentSessionPath && (target.rlmDepth ?? 0) > 0) { this.assertSavedSiblingNameAvailable(savedSiblings, target, createCommand.name!); } else { - await this.assertSupervisorSessionNameAvailable(targetSummary, createCommand.name!); + await this.assertSupervisorSessionNameAvailable(targetSummary, createCommand.name!, config.sessionDir); } return this.launchWorker(createCommand, undefined, ownerClientId); }); @@ -3008,19 +3048,93 @@ export class DaemonSupervisor { } } - private async familyCatalogEntries(): Promise { - const active = [...this.workers.values()].flatMap((worker) => [...worker.summaries.values()]); - const activePaths = new Set( - active.flatMap((summary) => (summary.sessionFile ? [canonicalSessionPath(summary.sessionFile)] : [])), - ); - const savedRoots = (await this.catalog.list()).filter( - (info) => - (info.rlmDepth ?? (info.parentSessionPath ? -1 : 0)) === 0 && - !activePaths.has(canonicalSessionPath(info.path)), - ); - return [...active, ...savedRoots.map((info) => summaryForInactiveSession(info))].map((summary) => - this.familyCatalogEntry(summary), + private async familyCatalogEntries(sessionDir?: string): Promise { + const live = [...this.workers.values()] + .flatMap((worker) => [...worker.summaries.values()]) + .map((summary): FamilyCatalogCandidate => ({ ...this.familyCatalogEntry(summary), source: "live" })); + // Keep the persisted row even when its path is currently active. A live + // overlay may fill in missing claims, but it may not silently replace a + // conflicting durable topology claim. + const saved = await this.catalog.list(undefined, sessionDir); + const savedPaths = new Set(saved.map((info) => canonicalSessionPath(info.path))); + const persisted = saved.map( + (info): FamilyCatalogCandidate => ({ + ...this.familyCatalogEntry(summaryForInactiveSession(info)), + source: "persisted", + }), ); + // The catalog's bounded registry walk supplies artifact-resident parents + // and descendants missing from list(). Preserve their durable rows in the + // immutable authorization snapshot; live overlays still cannot replace a + // conflicting topology claim. + const artifactSessions = this.catalog.family ? await this.catalog.family(sessionDir) : saved; + const artifacts = artifactSessions + .filter((info) => !savedPaths.has(canonicalSessionPath(info.path))) + .map( + (info): FamilyCatalogCandidate => ({ + ...this.familyCatalogEntry(summaryForInactiveSession(info)), + source: "artifact", + }), + ); + return Object.freeze(this.mergeEquivalentFamilyCatalogEntries([...persisted, ...artifacts, ...live])); + } + + /** + * Collapse durable/live duplicates only when their stable identity and every + * jointly-present topology claim agree. Incompatible candidates intentionally + * remain duplicated: authoritative endpoint lookup then fails closed. + */ + private mergeEquivalentFamilyCatalogEntries(entries: readonly FamilyCatalogCandidate[]): AgentFamilyCatalogEntry[] { + const compatible = (left: FamilyCatalogCandidate, right: FamilyCatalogCandidate) => + left.id === right.id && + (left.sessionPath === undefined || + right.sessionPath === undefined || + left.sessionPath === right.sessionPath) && + left.depth === right.depth && + (left.parentSessionId === undefined || + right.parentSessionId === undefined || + left.parentSessionId === right.parentSessionId) && + (left.parentSessionPath === undefined || + right.parentSessionPath === undefined || + left.parentSessionPath === right.parentSessionPath); + const groups: FamilyCatalogCandidate[][] = []; + for (const entry of entries) { + const group = groups.find((candidate) => candidate.every((member) => compatible(member, entry))); + if (group) group.push(entry); + else groups.push([entry]); + } + const statusRank: Record = { inactive: 0, idle: 1, running: 2 }; + const preferred = ( + rows: readonly FamilyCatalogCandidate[], + get: (row: FamilyCatalogCandidate) => T | undefined, + ): T | undefined => + [...rows] + .sort( + (left, right) => + (({ persisted: 0, artifact: 1, live: 2 })[right.source] ?? 0) - + ({ persisted: 0, artifact: 1, live: 2 }[left.source] ?? 0), + ) + .map(get) + .find((value): value is T => value !== undefined); + return groups.map((rows) => { + const exemplar = rows[0]!; + const name = preferred(rows, (row) => row.name); + const parentSessionId = preferred(rows, (row) => row.parentSessionId); + const parentSessionPath = preferred(rows, (row) => row.parentSessionPath); + const sessionPath = preferred(rows, (row) => row.sessionPath); + return { + id: exemplar.id, + depth: exemplar.depth, + status: rows.reduce( + (best, row) => (statusRank[row.status] > statusRank[best] ? row.status : best), + "inactive", + ), + ...(name ? { name } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + ...(parentSessionPath ? { parentSessionPath } : {}), + ...(sessionPath ? { sessionPath } : {}), + }; + }); } private async withSessionNameReservation( @@ -3042,8 +3156,9 @@ export class DaemonSupervisor { private async assertSupervisorSessionNameAvailable( target: Pick, name: string, + sessionDir?: string, ): Promise { - assertAgentSessionNameAvailable(await this.familyCatalogEntries(), { + assertAgentSessionNameAvailable(await this.familyCatalogEntries(sessionDir), { name, depth: target.rlmDepth ?? 0, parentSessionId: target.parentSessionId, @@ -3055,13 +3170,14 @@ export class DaemonSupervisor { private async savedSessionNameReservationInput( sessionPath: string, name: string, + sessionDir?: string, ): Promise<{ name: string; depth: number; parentSessionId?: string; parentSessionPath?: string }> { const targetPath = canonicalSessionPath(sessionPath); const active = [...this.workers.values()] .flatMap((worker) => [...worker.summaries.values()]) .find((summary) => summary.sessionFile && canonicalSessionPath(summary.sessionFile) === targetPath); if (active) return this.summaryNameReservationInput(active, name); - const siblings = await this.catalog.siblings(sessionPath); + const siblings = await this.catalog.siblings(sessionPath, sessionDir ?? this.defaultSessionConfig.sessionDir); const saved = siblings.find((info) => canonicalSessionPath(info.path) === targetPath); if (!saved) throw new Error(`Session not found: ${sessionPath}`); return { @@ -3084,19 +3200,23 @@ export class DaemonSupervisor { }; } - private async assertSupervisorSavedSessionNameAvailable(sessionPath: string, name: string): Promise { + private async assertSupervisorSavedSessionNameAvailable( + sessionPath: string, + name: string, + sessionDir?: string, + ): Promise { const targetPath = canonicalSessionPath(sessionPath); const active = [...this.workers.values()] .flatMap((worker) => [...worker.summaries.values()]) .find((summary) => summary.sessionFile && canonicalSessionPath(summary.sessionFile) === targetPath); - if (active) return this.assertSupervisorSessionNameAvailable(active, name); - const siblings = await this.catalog.siblings(sessionPath); + if (active) return this.assertSupervisorSessionNameAvailable(active, name, sessionDir); + const siblings = await this.catalog.siblings(sessionPath, sessionDir ?? this.defaultSessionConfig.sessionDir); const saved = siblings.find((info) => canonicalSessionPath(info.path) === targetPath); if (!saved) throw new Error(`Session not found: ${sessionPath}`); if (saved.parentSessionPath && (saved.rlmDepth ?? 0) > 0) { this.assertSavedSiblingNameAvailable(siblings, saved, name); } else { - await this.assertSupervisorSessionNameAvailable(summaryForInactiveSession(saved), name); + await this.assertSupervisorSessionNameAvailable(summaryForInactiveSession(saved), name, sessionDir); } } @@ -3192,6 +3312,16 @@ export class DaemonSupervisor { return worker.client; } + /** Resolve both authorization endpoints exclusively from one captured topology snapshot. */ + private authoritativeFamilyCatalogEntry( + catalog: readonly AgentFamilyCatalogEntry[], + sessionId: string, + ): AgentFamilyCatalogEntry { + const matches = catalog.filter((entry) => entry.id === sessionId); + if (matches.length !== 1) throw new Error("Agent reach is limited to parent, siblings, and children"); + return matches[0]!; + } + private familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry { const depth = summary.rlmDepth ?? (summary.parentSessionPath ? 1 : 0); return { diff --git a/packages/coding-agent/src/modes/daemon/saved-session-catalog.ts b/packages/coding-agent/src/modes/daemon/saved-session-catalog.ts index 6b22b1143..e3e42a12f 100644 --- a/packages/coding-agent/src/modes/daemon/saved-session-catalog.ts +++ b/packages/coding-agent/src/modes/daemon/saved-session-catalog.ts @@ -53,7 +53,7 @@ export async function renameDaemonSavedSession( const command: Extract = "activeSessionId" in context ? { type: "rename_saved_session", activeSessionId: context.activeSessionId, sessionPath, name } - : { type: "rename_saved_session", sessionPath, name }; + : { type: "rename_saved_session", sessionPath, name, sessionDir: context.sessionDir }; const response = await client.request(command); if (!response.success) { throw deserializeDaemonError(response); diff --git a/packages/coding-agent/test/agent-session-bus.test.ts b/packages/coding-agent/test/agent-session-bus.test.ts index 56e808503..75b530131 100644 --- a/packages/coding-agent/test/agent-session-bus.test.ts +++ b/packages/coding-agent/test/agent-session-bus.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + AGENT_FAMILY_REACH_ERROR, AGENT_MESSAGE_SOURCE, AgentSessionMessageRateLimiter, assertAgentFamilyReach, @@ -308,10 +309,11 @@ describe("agent session bus", () => { { id: "orphan-b", depth: 3, status: "inactive" }, ), ).toThrow("Agent reach is limited to parent, siblings, and children"); - expect(assertAgentFamilyReach(root, child)).toBe("child"); - expect(assertAgentFamilyReach(child, root)).toBe("parent"); - expect(assertAgentFamilyReach(child, sibling)).toBe("sibling"); - expect(assertAgentFamilyReach(sibling, idOnlySibling)).toBe("sibling"); + const catalog = [root, child, sibling, idOnlySibling, grandchild]; + expect(assertAgentFamilyReach(root, child, catalog)).toBe("child"); + expect(assertAgentFamilyReach(child, root, catalog)).toBe("parent"); + expect(assertAgentFamilyReach(child, sibling, catalog)).toBe("sibling"); + expect(assertAgentFamilyReach(sibling, idOnlySibling, catalog)).toBe("sibling"); expect(() => assertAgentFamilyReach(root, grandchild)).toThrow( "Agent reach is limited to parent, siblings, and children", ); @@ -400,6 +402,67 @@ describe("agent session bus", () => { ]); }); + it("reserves passive sibling names from direct canonical parent claims without broadening family reach", () => { + const catalog = [ + { id: "passive-id", name: "worker", depth: 1, status: "inactive" as const, parentSessionId: "parent" }, + { + id: "passive-path", + name: "path-worker", + depth: 1, + status: "inactive" as const, + parentSessionPath: "/tmp/prime-agent-parent/../parent.jsonl", + }, + ]; + + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "worker", depth: 1, parentSessionId: "parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + expect(() => + assertAgentSessionNameAvailable(catalog, { + name: "path-worker", + depth: 1, + parentSessionPath: "/tmp/parent.jsonl", + }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + // Direct claims reserve names only. They cannot synthesize a relationship + // while the parent record is unavailable from the catalog. + expect(() => assertAgentFamilyReach(catalog[0]!, catalog[1]!, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + }); + it("resolves id-only and path-only catalog claims but rejects contradictory claims", () => { + const parent = { id: "parent", depth: 0, status: "running" as const, sessionPath: "/parent" }; + const idOnly = { + id: "id-only", + name: "id-worker", + depth: 1, + status: "inactive" as const, + parentSessionId: "parent", + }; + const pathOnly = { + id: "path-only", + name: "path-worker", + depth: 1, + status: "inactive" as const, + parentSessionPath: "/parent", + }; + const contradictory = { + id: "contradictory", + depth: 1, + status: "inactive" as const, + parentSessionId: "parent", + parentSessionPath: "/other", + }; + const catalog = [parent, idOnly, pathOnly, contradictory]; + expect(assertAgentFamilyReach(parent, idOnly, catalog)).toBe("child"); + expect(assertAgentFamilyReach(parent, pathOnly, catalog)).toBe("child"); + expect(() => assertAgentFamilyReach(parent, contradictory, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "id-worker", depth: 1, parentSessionId: "parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "path-worker", depth: 1, parentSessionPath: "/parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + }); + it("builds a sorted nuclear-family roster with inactive members", () => { const catalog = [ { id: "root", name: "orchestrator", depth: 0, status: "running" as const, sessionPath: "/root" }, diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 53bc162f1..4aa7ed5bc 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -1,9 +1,15 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { describe, expect, it } from "vitest"; import { type SessionInfo, SessionManager } from "../src/core/session-manager.js"; -import { listSavedSessionSiblings, resolveCatalogSessionMatch } from "../src/modes/daemon/daemon-catalog-process.js"; +import { + getOpenCatalogAuthorityFdCountForTest, + listCatalogFamilySessions, + listSavedSessionSiblings, + resolveCatalogSessionMatch, + setCatalogBeforeTrustedOpenForTest, +} from "../src/modes/daemon/daemon-catalog-process.js"; function session(id: string, name: string | undefined, path: string): SessionInfo { return { @@ -20,17 +26,64 @@ function session(id: string, name: string | undefined, path: string): SessionInf }; } +function createCatalogFamilyFixture() { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-default-session-dir-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const first = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-first")); + first.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); + first.appendSessionInfo("first"); + const second = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-second")); + second.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); + second.appendSessionInfo("second"); + const registry = join(dirname(sessionDir), "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); + mkdirSync(dirname(registry), { recursive: true }); + writeFileSync( + registry, + [ + { + type: "rlm_subagent", + childId: first.getSessionId(), + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: second.getSessionId(), + sessionFile: second.getSessionFile(), + status: "completed", + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + return { root, sessionDir, parent, first, second }; +} + +async function withDefaultSessionDir(sessionDir: string, callback: () => Promise): Promise { + const previousSessionDir = process.env.PRIME_AGENT_SESSION_DIR; + process.env.PRIME_AGENT_SESSION_DIR = sessionDir; + try { + return await callback(); + } finally { + if (previousSessionDir === undefined) delete process.env.PRIME_AGENT_SESSION_DIR; + else process.env.PRIME_AGENT_SESSION_DIR = previousSessionDir; + } +} + describe("daemon catalog selector resolution", () => { it("reads only a saved child's persisted sibling set", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-siblings-")); const sessionDir = join(root, "sessions"); const parent = SessionManager.create(root, sessionDir); - parent.newSession(); + parent.newSession({ rlmDepth: 0 }); parent.appendSessionInfo("parent"); - const first = SessionManager.create(root, join(root, "first")); + const first = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-first")); first.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); first.appendSessionInfo("first"); - const second = SessionManager.create(root, join(root, "second")); + const second = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-second")); second.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); second.appendSessionInfo("second"); const registry = join(dirname(sessionDir), "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); @@ -38,31 +91,74 @@ describe("daemon catalog selector resolution", () => { writeFileSync( registry, [ - { type: "rlm_subagent", childId: "first", sessionFile: first.getSessionFile(), status: "completed" }, - { type: "rlm_subagent", childId: "second", sessionFile: second.getSessionFile(), status: "completed" }, + { + type: "rlm_subagent", + childId: first.getSessionId(), + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: second.getSessionId(), + sessionFile: second.getSessionFile(), + status: "completed", + }, ] .map((entry) => JSON.stringify(entry)) .join("\n"), ); - await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ + await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ expect.objectContaining({ id: first.getSessionId(), name: "first" }), expect.objectContaining({ id: second.getSessionId(), name: "second" }), ]); }); + it("uses the configured default session directory for family catalogs", async () => { + const { root, sessionDir, parent, first, second } = createCatalogFamilyFixture(); + try { + await withDefaultSessionDir(sessionDir, async () => { + await expect(listCatalogFamilySessions()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: parent.getSessionId(), name: "parent" }), + expect.objectContaining({ id: first.getSessionId(), name: "first" }), + expect.objectContaining({ id: second.getSessionId(), name: "second" }), + ]), + ); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + + it("uses the configured default session directory for saved siblings", async () => { + const { root, sessionDir, first, second } = createCatalogFamilyFixture(); + try { + await withDefaultSessionDir(sessionDir, async () => { + await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ + expect.objectContaining({ id: first.getSessionId(), name: "first" }), + expect.objectContaining({ id: second.getSessionId(), name: "second" }), + ]); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + it("resolves relative parent headers from each child session directory", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-relative-siblings-")); const sessionDir = join(root, "sessions"); const parent = SessionManager.create(root, sessionDir); - parent.newSession(); + parent.newSession({ rlmDepth: 0 }); parent.appendSessionInfo("parent"); const parentFile = parent.getSessionFile()!; - const firstDir = join(root, "first"); + const firstDir = join(root, "session-artifacts", parent.getSessionId(), "sub-first"); const first = SessionManager.create(root, firstDir); first.newSession({ parentSession: relative(firstDir, parentFile), rlmDepth: 1 }); first.appendSessionInfo("first"); - const secondDir = join(root, "second"); + const secondDir = join(root, "session-artifacts", parent.getSessionId(), "sub-second"); const second = SessionManager.create(root, secondDir); second.newSession({ parentSession: relative(secondDir, parentFile), rlmDepth: 1 }); second.appendSessionInfo("second"); @@ -71,19 +167,345 @@ describe("daemon catalog selector resolution", () => { writeFileSync( registry, [ - { type: "rlm_subagent", childId: "first", sessionFile: first.getSessionFile(), status: "completed" }, - { type: "rlm_subagent", childId: "second", sessionFile: second.getSessionFile(), status: "completed" }, + { + type: "rlm_subagent", + childId: first.getSessionId(), + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: second.getSessionId(), + sessionFile: second.getSessionFile(), + status: "completed", + }, ] .map((entry) => JSON.stringify(entry)) .join("\n"), ); - await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ + await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ expect.objectContaining({ id: first.getSessionId(), name: "first" }), expect.objectContaining({ id: second.getSessionId(), name: "second" }), ]); }); + it("treats an absent session-artifacts directory as an empty family registry", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-no-artifacts-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([ + expect.objectContaining({ id: "parent", rlmDepth: 0 }), + ]); + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + + it("walks a trusted depth-two artifact family and fails closed for hostile artifacts", async () => { + const makeFixture = (name: string, omitRootDepth = false) => { + const root = mkdtempSync(join(tmpdir(), name)); + const sessionDir = join(root, "sessions"); + const registryPath = (parentId: string) => { + const parentFile = [rootSession, parent, first, second] + .find((manager) => manager.getSessionId() === parentId) + ?.getSessionFile(); + return parentFile && dirname(parentFile) !== sessionDir + ? join(dirname(parentFile), "session-artifacts", parentId, "rlm-subagents.jsonl") + : join(root, "session-artifacts", parentId, "rlm-subagents.jsonl"); + }; + const writeRegistry = (parentId: string, entries: unknown[]) => { + const path = registryPath(parentId); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + entries.map((entry) => (typeof entry === "string" ? entry : JSON.stringify(entry))).join("\n"), + ); + }; + const create = (id: string, dir: string, parentSession?: string, depth = 0) => { + const manager = SessionManager.create(root, dir); + manager.newSession({ id, parentSession, rlmDepth: depth }); + manager.appendSessionInfo(id); + return manager; + }; + const rootSession = create("root", sessionDir); + if (omitRootDepth) { + const rootFile = rootSession.getSessionFile()!; + const [header, ...entries] = readFileSync(rootFile, "utf8").trimEnd().split(/\r?\n/); + const persistedHeader = JSON.parse(header!) as Record; + delete persistedHeader.rlmDepth; + writeFileSync(rootFile, `${[JSON.stringify(persistedHeader), ...entries].join("\n")}\n`); + } + const parent = create( + "parent", + join(root, "session-artifacts", "root", "sub-parent"), + rootSession.getSessionFile(), + 1, + ); + const first = create( + "first", + join(root, "session-artifacts", "parent", "sub-first"), + parent.getSessionFile(), + 2, + ); + const second = create( + "second", + join(root, "session-artifacts", "parent", "sub-second"), + parent.getSessionFile(), + 2, + ); + writeRegistry("root", [ + { type: "rlm_subagent", childId: "parent", sessionFile: parent.getSessionFile(), status: "completed" }, + ]); + writeRegistry("parent", [ + { type: "rlm_subagent", childId: "first", sessionFile: first.getSessionFile(), status: "completed" }, + { type: "rlm_subagent", childId: "second", sessionFile: second.getSessionFile(), status: "completed" }, + ]); + return { root, sessionDir, rootSession, parent, first, second, registryPath, writeRegistry }; + }; + const valid = makeFixture("prime-catalog-family-valid-", true); + await expect(listCatalogFamilySessions(valid.sessionDir)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "root", rlmDepth: 0 }), + expect.objectContaining({ id: "parent", rlmDepth: 1 }), + expect.objectContaining({ id: "first", rlmDepth: 2 }), + expect.objectContaining({ id: "second", rlmDepth: 2 }), + ]), + ); + await expect(listSavedSessionSiblings(valid.first.getSessionFile()!, valid.sessionDir)).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ id: "first" }), expect.objectContaining({ id: "second" })]), + ); + + const cases: Array<[string, (fixture: ReturnType) => void]> = [ + [ + "id mismatch", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "wrong", + sessionFile: fixture.first.getSessionFile(), + status: "completed", + }, + ]), + ], + [ + "parent mismatch", + (fixture) => { + const evil = SessionManager.create( + fixture.root, + join(fixture.root, "session-artifacts", "parent", "sub-evil"), + ); + evil.newSession({ id: "evil", parentSession: fixture.rootSession.getSessionFile(), rlmDepth: 2 }); + evil.appendSessionInfo("evil"); + fixture.writeRegistry("parent", [ + { type: "rlm_subagent", childId: "evil", sessionFile: evil.getSessionFile(), status: "completed" }, + ]); + }, + ], + [ + "depth mismatch", + (fixture) => { + const evil = SessionManager.create( + fixture.root, + join(fixture.root, "session-artifacts", "parent", "sub-evil"), + ); + evil.newSession({ id: "evil", parentSession: fixture.parent.getSessionFile(), rlmDepth: 7 }); + evil.appendSessionInfo("evil"); + fixture.writeRegistry("parent", [ + { type: "rlm_subagent", childId: "evil", sessionFile: evil.getSessionFile(), status: "completed" }, + ]); + }, + ], + [ + "external path", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "evil", + sessionFile: join(tmpdir(), "outside.jsonl"), + status: "completed", + }, + ]), + ], + [ + "path alias", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "first", + sessionFile: `${dirname(fixture.first.getSessionFile()!)}/../sub-first/first.jsonl`, + status: "completed", + }, + ]), + ], + [ + "cycle", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "root", + sessionFile: fixture.rootSession.getSessionFile(), + status: "completed", + }, + ]), + ], + ["malformed", (fixture) => fixture.writeRegistry("parent", ["{not json"])], + [ + "record limit", + (fixture) => + fixture.writeRegistry( + "parent", + Array.from({ length: 10_001 }, (_, index) => ({ + type: "rlm_subagent", + childId: `bad-${index}`, + sessionFile: fixture.first.getSessionFile(), + status: "completed", + })), + ), + ], + ]; + for (const [label, mutate] of cases) { + const fixture = makeFixture(`prime-catalog-family-${label.replace(/\s/g, "-")}-`); + mutate(fixture); + await expect(listCatalogFamilySessions(fixture.sessionDir), label).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + } + const symlink = makeFixture("prime-catalog-family-symlink-"); + const alias = join(symlink.root, "session-artifacts", "parent", "sub-alias", "first.jsonl"); + mkdirSync(dirname(alias), { recursive: true }); + symlinkSync(symlink.first.getSessionFile()!, alias); + symlink.writeRegistry("parent", [ + { type: "rlm_subagent", childId: "first", sessionFile: alias, status: "completed" }, + ]); + await expect(listCatalogFamilySessions(symlink.sessionDir)).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + }); + + it("rejects symlinked roots and deterministic intermediate/final replacement races", async () => { + const make = (name: string) => { + const root = mkdtempSync(join(tmpdir(), name)); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const childDir = join(root, "session-artifacts", "parent", "sub-child"); + const child = SessionManager.create(root, childDir); + child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + child.appendSessionInfo("child"); + const registry = join(root, "session-artifacts", "parent", "rlm-subagents.jsonl"); + mkdirSync(dirname(registry), { recursive: true }); + writeFileSync( + registry, + JSON.stringify({ + type: "rlm_subagent", + childId: "child", + sessionFile: child.getSessionFile(), + status: "completed", + }), + ); + return { root, sessionDir, parent, child, childDir, registry }; + }; + + const rootLink = make("prime-catalog-root-link-"); + const movedSessions = `${rootLink.sessionDir}-real`; + renameSync(rootLink.sessionDir, movedSessions); + symlinkSync(movedSessions, rootLink.sessionDir); + await expect(listCatalogFamilySessions(rootLink.sessionDir)).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + + const intermediate = make("prime-catalog-intermediate-swap-"); + let swappedIntermediate = false; + setCatalogBeforeTrustedOpenForTest((path) => { + if (swappedIntermediate || path !== intermediate.child.getSessionFile()) return; + swappedIntermediate = true; + const moved = `${intermediate.childDir}-real`; + renameSync(intermediate.childDir, moved); + symlinkSync(moved, intermediate.childDir); + }); + await expect(listCatalogFamilySessions(intermediate.sessionDir)).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + + const finalSwap = make("prime-catalog-final-swap-"); + let swappedFinal = false; + setCatalogBeforeTrustedOpenForTest((path) => { + if (swappedFinal || path !== finalSwap.child.getSessionFile()) return; + swappedFinal = true; + const moved = `${path}.real`; + renameSync(path, moved); + symlinkSync(moved, path); + }); + await expect(listCatalogFamilySessions(finalSwap.sessionDir)).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + setCatalogBeforeTrustedOpenForTest(undefined); + }); + + it("parses session metadata from the same descriptor-bound bytes without a pathname reopen", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-no-reopen-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("bound-name"); + let removed = false; + setCatalogBeforeTrustedOpenForTest((path) => { + if (removed || path !== parent.getSessionFile()) return; + removed = true; + const original = `${path}.opened`; + renameSync(path, original); + writeFileSync(path, "not a session\n"); + }); + // The helper opens after the hook, so it must reject the replacement instead of + // authorizing stale bytes. This pins the absence of a later readSessionInfo reopen. + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + setCatalogBeforeTrustedOpenForTest(undefined); + rmSync(root, { recursive: true, force: true }); + }); + + it("closes authority descriptors after repeated hostile seed failures", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-fd-release-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const hostile = SessionManager.create(root, sessionDir); + hostile.newSession({ id: "hostile", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + hostile.appendSessionInfo("hostile"); + const baseline = getOpenCatalogAuthorityFdCountForTest(); + for (let attempt = 0; attempt < 64; attempt++) { + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(baseline); + } + }); + + it("rejects an unregistered parent-claiming seed and conflicting duplicate identity", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-orphan-seed-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const orphan = SessionManager.create(root, sessionDir); + orphan.newSession({ id: "evil", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + orphan.appendSessionInfo("evil"); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("managed session seed claims a parent"); + + const duplicateRoot = mkdtempSync(join(tmpdir(), "prime-catalog-duplicate-id-")); + const duplicateDir = join(duplicateRoot, "sessions"); + const duplicate = SessionManager.create(duplicateRoot, duplicateDir); + duplicate.newSession({ id: "duplicate", rlmDepth: 0 }); + duplicate.appendSessionInfo("one"); + writeFileSync(join(duplicateDir, "alias.jsonl"), readFileSync(duplicate.getSessionFile()!)); + await expect(listCatalogFamilySessions(duplicateDir)).rejects.toThrow("duplicate session id"); + }); + it("treats an exact name colliding with another session id prefix as ambiguous", () => { const sessions = [ session("named-session-id", "target", "/tmp/by-name.jsonl"), diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index fe278efdb..045d63ab4 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from "vitest"; import { AGENT_FAMILY_REACH_ERROR, type AgentSessionMessageController, + assertAgentFamilyReach, DEFAULT_AGENT_MESSAGE_MAX_CHARS, sessionNameReservationKey, } from "../src/core/agent-messages.js"; @@ -46,6 +47,53 @@ import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js" import { DAEMON_WORKER_SUPERVISOR_SOCKET_ENV } from "../src/modes/daemon/daemon-worker-protocol.js"; describe("daemon mode helpers", () => { + const useResidentCatalog = (daemon: AgentDaemon) => { + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries?: () => Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + }; + internals.agentFamilyCatalogEntries = async () => + Object.freeze( + [...internals.sessions.values()].map((state) => { + const session = state.runtime.session; + const metadata = state.runtime.metadata; + const parentSessionId = + metadata.parentSessionId ?? + (metadata.parentActiveSessionId + ? internals.sessions.get(metadata.parentActiveSessionId)?.runtime.session.sessionId + : undefined); + return { + id: session.sessionId, + depth: session.rlmDepth ?? 0, + status: "running" as const, + ...(parentSessionId ? { parentSessionId } : {}), + ...(session.sessionFile ? { sessionPath: session.sessionFile } : {}), + }; + }), + ); + }; + + const installDeterministicAgentFamilyCatalog = (internals: object, states: readonly ActiveSessionState[]) => { + const catalogTarget = internals as { + agentFamilyCatalogEntries?: () => Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + }; + catalogTarget.agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze( + states.map((state) => ({ + id: state.runtime.session.sessionId, + depth: state.runtime.session.rlmDepth ?? 0, + status: "running" as const, + ...(state.runtime.metadata.parentSessionId + ? { parentSessionId: state.runtime.metadata.parentSessionId } + : {}), + })), + ), + ); + }; it("preserves envelope client identity while registering prompt admission", () => { const daemon = new AgentDaemon("/tmp/unused-daemon.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, @@ -241,6 +289,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const send = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -248,8 +297,9 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && acceptAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); resolvePrompt(); @@ -485,6 +535,69 @@ describe("daemon mode helpers", () => { } }); + it("fails closed for malformed remote peer topology on daemon ACL surfaces", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-malformed-remote-family.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const root = makeState("root"); + root.runtime = { + ...root.runtime, + cwd: "/tmp", + metadata: { kind: "top-level", createdAt: 1 }, + session: { + sessionId: "session-root", + sessionName: "root", + rlmDepth: 0, + isStreaming: false, + isSessionActive: false, + unfinishedActionCount: 0, + messages: [], + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + hasRunningRlmChildren: () => false, + sessionManager: { getSessionArtifactDir: () => undefined }, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + remoteAgentPeers: Map>; + createAgentMessageController( + getCurrentState: () => ActiveSessionState | undefined, + ): AgentSessionMessageController; + createAgentObserveController(getCurrentState: () => ActiveSessionState): AgentObserveController; + }; + internals.sessions.set(root.activeSessionId, root); + const listAll = vi.spyOn(SessionManager, "listAll").mockResolvedValue([]); + try { + for (const malformed of [ + { rlmDepth: 0, parentSessionId: "session-root" }, + { rlmDepth: -1 }, + { rlmDepth: 1 }, + ]) { + internals.remoteAgentPeers.clear(); + internals.remoteAgentPeers.set("malformed-peer", { + activeSessionId: "malformed-peer", + sessionId: "session-malformed-peer", + sessionName: "malformed-peer", + runtimeKind: "subagent", + cwd: "/tmp/remote", + isStreaming: false, + unfinishedActionCount: 0, + ...malformed, + }); + const messaging = internals.createAgentMessageController(() => root); + const observe = internals.createAgentObserveController(() => root); + await expect(messaging.roster!()).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + await expect(messaging.sendAgentMessage({ target: "malformed-peer", message: "no" })).rejects.toThrow( + AGENT_FAMILY_REACH_ERROR, + ); + await expect(observe.listAgents()).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + } + } finally { + listAll.mockRestore(); + } + }); + it("canonicalizes symlinked paths in the family catalog and name reservations", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-family-catalog-paths-")); try { @@ -621,6 +734,7 @@ describe("daemon mode helpers", () => { internals.sessions.set(parentState.activeSessionId, parentState); // A successfully completed RLM child remains idle in this daemon registry. internals.sessions.set(subagentState.activeSessionId, subagentState); + installDeterministicAgentFamilyCatalog(internals, [parentState, subagentState]); const controller = internals.createAgentMessageController(() => parentState); const subagentSummary = (await controller.listAgents()).agents.find( @@ -1204,6 +1318,7 @@ describe("daemon mode helpers", () => { }, worker: { authenticationToken: "worker-token" }, }); + useResidentCatalog(daemon); const parentState = makeState("parent"); const childState = makeState("child", parentState.activeSessionId); const sessionPrompt = vi.fn(async () => {}); @@ -1333,6 +1448,9 @@ describe("daemon mode helpers", () => { session: { sessionId: "session-source", sessionName: "Source", + sessionFile: "/tmp/source.jsonl", + rlmDepth: 0, + sessionManager: { getSessionArtifactDir: () => undefined }, isStreaming: false, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, }, @@ -1371,6 +1489,9 @@ describe("daemon mode helpers", () => { cwd: "/tmp/remote", isStreaming: false, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + rlmDepth: 1, + parentSessionId: "session-source", + parentSessionPath: "/tmp/source.jsonl", }); internals.sendRemoteAgentSessionMessage = sendRemoteAgentSessionMessage; @@ -1833,6 +1954,7 @@ describe("daemon mode helpers", () => { internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetA.activeSessionId, targetA); internals.sessions.set(targetB.activeSessionId, targetB); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetA, targetB]); for (let i = 0; i < 3; i++) { await expect( @@ -2057,6 +2179,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); for (let i = 0; i < 3; i++) { await expect( @@ -2124,6 +2247,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -2201,18 +2325,22 @@ describe("daemon mode helpers", () => { return fromState; }); + installDeterministicAgentFamilyCatalog(internals, [...senders, targetState]); + const sends: Promise[] = []; const errors: unknown[] = []; for (const [i, fromState] of senders.entries()) { - void internals - .sendAgentSessionMessage({ - targetSelector: targetState.activeSessionId, - message: `message ${i}`, - fromState, - origin: "agent", - }) - .catch((error) => { - errors.push(error); - }); + sends.push( + internals + .sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: `message ${i}`, + fromState, + origin: "agent", + }) + .catch((error) => { + errors.push(error); + }), + ); } for (let attempt = 0; attempt < 200 && queueAgentMessagePrompt.mock.calls.length < 12; attempt++) { await Promise.resolve(); @@ -2220,6 +2348,7 @@ describe("daemon mode helpers", () => { // With reservations held past queue time, 12 concurrent senders would // count as 24 against the 20-slot cap and the tail would reject. + await Promise.all(sends); expect(errors).toEqual([]); expect(queueAgentMessagePrompt).toHaveBeenCalledTimes(12); }); @@ -2264,6 +2393,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); await expect( internals.sendAgentSessionMessage({ @@ -2287,6 +2417,7 @@ describe("daemon mode helpers", () => { throw new Error("unexpected runtime creation"); }, }); + useResidentCatalog(daemon); const makeBusyState = (name: string) => { const state = makeState(name); state.runtime = { @@ -2633,6 +2764,7 @@ describe("daemon mode helpers", () => { throw new Error("unexpected runtime creation"); }, }); + useResidentCatalog(daemon); const states = [ makeState("root"), makeState("child", "root"), @@ -2714,11 +2846,169 @@ describe("daemon mode helpers", () => { ); }); + it("authorizes depth-two passive siblings only from the persisted daemon topology", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-deep-passive-acl-")); + try { + const fixture = makePersistedRlmDaemonFixture(tempDir); + const secondGrandchildDir = join(fixture.childSessionDir, "sibling-grandchild"); + const secondGrandchild = SessionManager.create(tempDir, secondGrandchildDir); + secondGrandchild.newSession({ parentSession: fixture.childSessionFile, rlmDepth: 2 }); + secondGrandchild.flushNow(); + const secondGrandchildFile = secondGrandchild.getSessionFile(); + if (!secondGrandchildFile) throw new Error("Missing sibling grandchild session"); + const childRegistry = join(fixture.childArtifactDir, "rlm-subagents.jsonl"); + writeFileSync( + childRegistry, + `${readFileSync(childRegistry, "utf8")}${JSON.stringify({ + type: "rlm_subagent", + childId: "sibling-grandchild", + sessionName: "sibling-grandchild", + sessionDir: secondGrandchildDir, + sessionFile: secondGrandchildFile, + parentSessionId: fixture.childSessionId, + parentSessionFile: fixture.childSessionFile, + rlmDepth: 2, + status: "completed", + createdAt: 2, + updatedAt: "2026-01-01T00:00:02.000Z", + })}\n`, + ); + const internals = fixture.daemon as unknown as { + createRuntime(command: Extract): Promise; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + }; + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + const catalog = await internals.agentFamilyCatalogEntries(); + const first = catalog.find((entry) => entry.id === fixture.grandchildSessionId); + const second = catalog.find((entry) => entry.id === secondGrandchild.getSessionId()); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(catalog).toContainEqual(expect.objectContaining({ id: fixture.childSessionId, depth: 1 })); + expect(assertAgentFamilyReach(first!, second!, catalog)).toBe("sibling"); + + // A depth-two pair claiming the root cannot manufacture the missing depth-one edge. + expect(() => + assertAgentFamilyReach( + { ...first!, parentSessionId: fixture.parentSessionId, parentSessionPath: fixture.parentSessionFile }, + { ...second!, parentSessionId: fixture.parentSessionId, parentSessionPath: fixture.parentSessionFile }, + catalog, + ), + ).toThrow(AGENT_FAMILY_REACH_ERROR); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("observes residents from the captured family catalog rather than live endpoint fields", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-observe-captured-family.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const source = makeState("source"); + const target = makeState("target"); + for (const state of [source, target]) { + state.runtime = { + ...state.runtime, + metadata: { kind: "top-level", createdAt: 1 }, + diagnostics: [], + session: { + ...state.runtime.session, + messages: [], + hasRunningRlmChildren: vi.fn(() => false), + sessionManager: { + getHeader: vi.fn(() => ({})), + getCwd: vi.fn(() => "/tmp"), + getSessionArtifactDir: vi.fn(() => undefined), + }, + getSessionActionSnapshot: vi.fn(() => ({ queuedCount: 0, steering: [], followUps: [] })), + state: { streamingMessage: undefined, pendingToolCalls: new Map() }, + sessionId: `session-${state.activeSessionId}`, + sessionFile: `/tmp/${state.activeSessionId}.jsonl`, + rlmDepth: 0, + }, + } as never; + } + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + createAgentObserveController(getCurrentState: () => ActiveSessionState): AgentObserveController; + }; + internals.sessions.set(source.activeSessionId, source); + internals.sessions.set(target.activeSessionId, target); + Object.assign(internals, { + agentFamilyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: "left-parent", depth: 0, status: "inactive", sessionPath: "/tmp/left.jsonl" }, + { id: "right-parent", depth: 0, status: "inactive", sessionPath: "/tmp/right.jsonl" }, + { id: "session-source", depth: 1, status: "running", parentSessionId: "left-parent" }, + { id: "session-target", depth: 1, status: "running", parentSessionId: "right-parent" }, + ]), + ), + }); + + // The live root summaries would otherwise be sibling roots. Their conflicting + // topology cannot override the captured snapshot's unrelated parent edges. + const observed = await internals.createAgentObserveController(() => source).listAgents(); + expect(observed.agents.map((agent) => agent.activeSessionId)).toEqual(["source"]); + }); + + it("fails closed for every agent ACL surface when the captured catalog duplicates a stable session ID", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-duplicate-captured-family.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const parent = makeAgentFamilyState("parent", "parent"); + const source = makeAgentFamilyState("source", "source", parent.state); + const target = makeAgentFamilyState("target", "target", parent.state); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + createAgentMessageController(getCurrentState: () => ActiveSessionState): AgentSessionMessageController; + createAgentObserveController(getCurrentState: () => ActiveSessionState): AgentObserveController; + }; + for (const fixture of [parent, source, target]) + internals.sessions.set(fixture.state.activeSessionId, fixture.state); + const sourceId = source.state.runtime.session.sessionId; + const parentId = parent.state.runtime.session.sessionId; + const targetId = target.state.runtime.session.sessionId; + const entries = [ + { id: parentId, depth: 0, status: "running" as const }, + { id: sourceId, depth: 1, status: "running" as const, parentSessionId: parentId }, + { id: sourceId, depth: 1, status: "running" as const, parentSessionId: "forged-parent" }, + { id: targetId, depth: 1, status: "running" as const, parentSessionId: parentId }, + ]; + + // The current observer must be resolved from the immutable catalog before + // self inclusion. Either duplicate ordering is ambiguous and must fail closed. + for (const catalog of [entries, [...entries.slice(0, 1), entries[2]!, entries[1]!, entries[3]!]]) { + internals.agentFamilyCatalogEntries = vi.fn(async () => Object.freeze(catalog)); + const observe = internals.createAgentObserveController(() => source.state); + await expect(observe.listAgents()).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + await expect(observe.getAgent(target.state.activeSessionId)).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + await expect(observe.recentMessages({ target: target.state.activeSessionId })).rejects.toThrow( + AGENT_FAMILY_REACH_ERROR, + ); + await expect( + internals + .createAgentMessageController(() => source.state) + .sendAgentMessage({ target: target.state.activeSessionId, message: "must not deliver" }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + } + expect(target.acceptAgentMessagePrompt).not.toHaveBeenCalled(); + }); + it("resolves a duplicate session name to the only family-reachable agent", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-family-name-resolution.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, createRuntime: vi.fn(), }); + useResidentCatalog(daemon); const observer = makeAgentFamilyState("observer", "observer"); const familyHelper = makeAgentFamilyState("family-helper", "helper", observer.state); const otherRoot = makeAgentFamilyState("other-root", "other-root"); @@ -2858,6 +3148,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -2871,15 +3162,17 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); promptResolves[0]?.(); await expect(first).resolves.toMatchObject({ message: "first" }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length < 2; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(2); expect(followUp).not.toHaveBeenCalled(); @@ -3213,6 +3506,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -3226,15 +3520,17 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); (targetState.runtime.session as { isStreaming: boolean }).isStreaming = true; promptResolves[0]?.(); await expect(first).resolves.toMatchObject({ message: "first" }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && queueAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); @@ -3667,6 +3963,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -3680,8 +3977,9 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && acceptAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } (targetState.runtime.session as { unfinishedActionCount: number }).unfinishedActionCount = 20; resolveFirstPrompt(); @@ -4024,6 +4322,7 @@ describe("daemon mode helpers", () => { }): Promise; }; internals.sessions.set(state.activeSessionId, state); + installDeterministicAgentFamilyCatalog(internals, [state]); await expect( internals.sendAgentSessionMessage({ @@ -5745,13 +6044,15 @@ describe("daemon mode helpers", () => { sessionPath: fixture.parentSessionFile, }); - await internals - .createAgentMessageController(() => parentState) - .sendAgentMessage({ target: "renamed-worker", message: "report progress" }); - - // The nested header depth must win over the legacy depth-1 default so the - // woken child does not come up shallower than persisted. - expect(fixture.createRuntime.mock.calls[1]?.[0].sessionOptions?.rlmDepth).toBe(2); + // A root may not directly reach a depth-two child. The persisted header + // is authoritative even when legacy registry metadata omits depth, and denial + // must happen before hydration. + await expect( + internals + .createAgentMessageController(() => parentState) + .sendAgentMessage({ target: "renamed-worker", message: "report progress" }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + expect(fixture.createRuntime).toHaveBeenCalledOnce(); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -9566,6 +9867,301 @@ describe("daemon mode helpers", () => { }), ).rejects.toThrow("Unknown active session: missing"); }); + + it("delivers disjoint CLI sends while preserving agent-origin catalog ACLs", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-cli-from-state.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: fromState } = makeAgentFamilyState("source", "Source"); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target"); + const agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze([ + { + id: fromState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: "source-parent", + }, + { + id: targetState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: "target-parent", + }, + ]), + ); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "CLI delivery ignores the family reach ACL", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ + deliveryStatus: "delivered", + target: { activeSessionId: targetState.activeSessionId }, + }); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: undefined } }, + }); + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent ACL must reject this", + fromState, + origin: "agent", + }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); + }); + + it("labels CLI sibling sends from an authoritative depth-1 catalog", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-cli-sibling-label.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: parentState } = makeAgentFamilyState("parent", "Parent"); + const { state: fromState } = makeAgentFamilyState("source", "Source", parentState); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target", parentState); + const agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze([ + { + id: parentState.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }, + { + id: fromState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: parentState.runtime.session.sessionId, + }, + { + id: targetState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: parentState.runtime.session.sessionId, + }, + ]), + ); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(parentState.activeSessionId, parentState); + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "sent by the CLI sibling", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ deliveryStatus: "delivered" }); + expect(agentFamilyCatalogEntries).toHaveBeenCalledOnce(); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: "sibling" } }, + }); + }); + + it("omits a CLI sibling label when the catalog has ambiguous parents", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-cli-ambiguous-sibling.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: firstParent } = makeAgentFamilyState("first-parent", "First parent"); + const { state: fromState } = makeAgentFamilyState("source", "Source", firstParent); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target", firstParent); + const agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze([ + { + id: firstParent.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }, + { + id: firstParent.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }, + { + id: fromState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: firstParent.runtime.session.sessionId, + }, + { + id: targetState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: firstParent.runtime.session.sessionId, + }, + ]), + ); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "sent with ambiguous parent evidence", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ deliveryStatus: "delivered" }); + expect(agentFamilyCatalogEntries).toHaveBeenCalledOnce(); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: undefined } }, + }); + }); + + it.each(["sender", "target"] as const)( + "delivers an unlabeled CLI message with a duplicate %s endpoint while agent origin rejects it", + async (duplicate) => { + const daemon = new AgentDaemon(`/tmp/prime-agent-cli-duplicate-${duplicate}.sock`, { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: fromState } = makeAgentFamilyState("source", "Source"); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target"); + const fromEntry = { + id: fromState.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }; + const targetEntry = { + id: targetState.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }; + const duplicateEntry = duplicate === "sender" ? fromEntry : targetEntry; + const agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze([fromEntry, targetEntry, { ...duplicateEntry }]), + ); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "CLI delivery treats topology as advisory", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ deliveryStatus: "delivered" }); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: undefined } }, + }); + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent origin rejects ambiguous authority", + fromState, + origin: "agent", + }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); + }, + ); + + it("delivers an unlabeled CLI message when catalog acquisition fails while agent origin rejects it", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-cli-catalog-failure.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: fromState } = makeAgentFamilyState("source", "Source"); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target"); + const catalogError = new Error("catalog unavailable"); + const agentFamilyCatalogEntries = vi.fn(async () => { + throw catalogError; + }); + const log = vi.fn(); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + log: typeof log; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + internals.log = log; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "CLI delivery survives catalog failure", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ deliveryStatus: "delivered" }); + expect(log).toHaveBeenCalledWith( + "Agent family catalog unavailable for CLI message relationship: catalog unavailable", + ); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: undefined } }, + }); + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent origin rejects catalog failure", + fromState, + origin: "agent", + }), + ).rejects.toBe(catalogError); + expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); + }); }); type CronAdmissionActivity = Partial<{ @@ -9694,7 +10290,7 @@ function makePersistedRlmDaemonFixture( const childId = "child-1"; const childSessionDir = join(parentArtifactDir, "sub-1234abcd"); const childManager = SessionManager.create(tempDir, childSessionDir); - childManager.newSession({ parentSession: parentSessionFile }); + childManager.newSession({ parentSession: parentSessionFile, rlmDepth: 1 }); childManager.appendSessionInfo("spawn-worker"); childManager.appendSessionInfo("renamed-worker"); childManager.appendMessage({ role: "user", content: "complete this task", timestamp: 1 }); @@ -9708,7 +10304,7 @@ function makePersistedRlmDaemonFixture( mkdirSync(childArtifactDir, { recursive: true }); const grandchildSessionDir = join(childSessionDir, "sub-deadbeef"); const grandchildManager = SessionManager.create(tempDir, grandchildSessionDir); - grandchildManager.newSession({ parentSession: childSessionFile }); + grandchildManager.newSession({ parentSession: childSessionFile, rlmDepth: 2 }); grandchildManager.appendSessionInfo("nested-worker"); grandchildManager.appendMessage({ role: "user", content: "complete the nested task", timestamp: 2 }); grandchildManager.flushNow(); @@ -9824,9 +10420,12 @@ function makePersistedRlmDaemonFixture( parentArtifactDir, parentSessionId: parentManager.getSessionId(), childId, + childSessionId: childManager.getSessionId(), childSessionFile, childSessionDir, + childArtifactDir, grandchildId, + grandchildSessionId: grandchildManager.getSessionId(), grandchildSessionFile, }; } diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index d7eb8acf4..466df631a 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -93,6 +93,19 @@ describe("daemon protocol helpers", () => { expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("queue_message_mutation"); }); + it("accepts the old-client rename shape but rejects an old daemon for authority-aware renames", () => { + const oldClientCommand: DaemonCommand = { + type: "rename_saved_session", + sessionPath: "/tmp/session.jsonl", + name: "renamed", + }; + expect(getDaemonCommandCompatibilities(oldClientCommand)).toEqual([{ minProtocol: 7, minSchemaRevision: 17 }]); + expect(DAEMON_COMMAND_COMPATIBILITY.rename_saved_session).toEqual({ + minProtocol: 7, + minSchemaRevision: 17, + }); + }); + it("schema-gates the RLM max depth commands at their introducing revision", () => { expect(DAEMON_COMMAND_COMPATIBILITY.get_rlm_max_depth_status).toEqual({ minProtocol: 7, minSchemaRevision: 11 }); expect(DAEMON_COMMAND_COMPATIBILITY.set_rlm_max_depth).toEqual({ minProtocol: 7, minSchemaRevision: 11 }); diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 6b2542444..93543625b 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "../src/core/session-manager.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor, idleEvictionSweepIntervalMs } from "../src/modes/daemon/daemon-supervisor.js"; @@ -31,8 +32,17 @@ interface SupervisorInternals { workers: Map; clients: Set<{ id: string; attachedActiveSessionIds: Set }>; idleEvictionFence?: Promise; - catalog: { resolve: ReturnType; stop: ReturnType }; + catalog: { + resolve: ReturnType; + stop: ReturnType; + list?: ReturnType; + family?: ReturnType; + siblings?: ReturnType; + }; createOrReuseWorker: ReturnType; + familyCatalogEntries( + sessionDir?: string, + ): Promise; stopWorker: ReturnType; log: ReturnType; scheduleIdleEvictionSweep(): void; @@ -304,6 +314,50 @@ describe("daemon supervisor whole-tree eviction", () => { }); }); + it("uses the merged custom session directory for named saved-session siblings", async () => { + const supervisor = makeSupervisor(); + const sessionPath = "/tmp/custom-sessions/saved.jsonl"; + const target = { + id: "saved", + path: sessionPath, + cwd: "/tmp/project", + parentSessionPath: "/tmp/custom-sessions/parent.jsonl", + rlmDepth: 1, + created: new Date("2026-08-01T12:00:00.000Z"), + modified: new Date("2026-08-01T12:00:00.000Z"), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + }; + supervisor.catalog.resolve = vi.fn(async () => sessionPath); + supervisor.catalog.siblings = vi.fn(async () => [target]); + const launched = makeWorker("launched", [makeSummary("launched-active", Date.now())]); + const launchWorker = vi.fn(async () => launched); + Object.assign(supervisor, { launchWorker }); + const createOrReuseWorker = ( + supervisor as unknown as { + createOrReuseWorker(clientId: string, command: object): Promise; + } + ).createOrReuseWorker.bind(supervisor); + + await expect( + createOrReuseWorker("client", { + id: "named-custom", + type: "create", + sessionPath: "saved", + name: "renamed", + config: { sessionDir: "/tmp/custom-sessions" }, + }), + ).resolves.toBe(launched); + expect(supervisor.catalog.resolve).toHaveBeenCalledWith("saved", expect.any(String), "/tmp/custom-sessions"); + expect(supervisor.catalog.siblings).toHaveBeenCalledWith(sessionPath, "/tmp/custom-sessions"); + expect(launchWorker).toHaveBeenCalledWith( + expect.objectContaining({ sessionPath, config: { sessionDir: "/tmp/custom-sessions" } }), + undefined, + undefined, + ); + }); + it("resolves a saved target in the source worker's create-time session directory", async () => { const now = Date.parse("2026-08-01T12:00:00.000Z"); const supervisor = makeSupervisor(); @@ -311,9 +365,19 @@ describe("daemon supervisor whole-tree eviction", () => { const source = makeWorker("source", [sourceSummary]); source.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; source.summaries = new Map([["source-active", sourceSummary]]); + // The wake path reads this row before authorizing it, so model an actual + // saved session rather than a summary whose sessionFile is not readable. + const targetDirectory = mkdtempSync(join(tmpdir(), "prime-supervisor-saved-target-")); + tempDirs.push(targetDirectory); + const targetManager = SessionManager.create(targetDirectory, join(targetDirectory, "sessions")); + targetManager.newSession(); + targetManager.appendSessionInfo("saved target"); + targetManager.flushNow(); + const targetPath = targetManager.getSessionFile(); + if (!targetPath) throw new Error("Missing saved target session path"); const targetSummary = makeSummary("target-active", now, { - sessionId: "target-session", - sessionFile: "/tmp/target.jsonl", + sessionId: targetManager.getSessionId(), + sessionFile: targetPath, }); const target = makeWorker("target", [targetSummary]); target.descriptor.rootActiveSessionId = "target-active"; @@ -324,7 +388,7 @@ describe("daemon supervisor whole-tree eviction", () => { data: { deliveryStatus: "delivered" }, }); supervisor.workers.set("source", source); - supervisor.catalog.resolve = vi.fn(async () => "/tmp/target.jsonl"); + supervisor.catalog.resolve = vi.fn(async () => targetPath); supervisor.createOrReuseWorker = vi.fn(async () => target); const client = { id: "sender", attachedActiveSessionIds: new Set() }; @@ -339,7 +403,12 @@ describe("daemon supervisor whole-tree eviction", () => { expect(supervisor.catalog.resolve).toHaveBeenCalledWith("target-session", "/tmp/project", "/tmp/custom-sessions"); expect(supervisor.createOrReuseWorker).toHaveBeenCalledWith( "sender", - expect.objectContaining({ type: "create", sessionPath: "/tmp/target.jsonl", continueRecent: false }), + expect.objectContaining({ + type: "create", + sessionPath: targetPath, + continueRecent: false, + config: { sessionDir: "/tmp/custom-sessions" }, + }), ); expect(target.client?.requestWorker).toHaveBeenCalledWith( expect.objectContaining({ @@ -441,4 +510,323 @@ describe("daemon supervisor whole-tree eviction", () => { ).rejects.toThrow('Ambiguous session selector "target"'); expect(supervisor.createOrReuseWorker).not.toHaveBeenCalled(); }); + + it("captures every inactive descendant for cross-worker sibling authorization", async () => { + const supervisor = makeSupervisor(); + const timestamp = new Date("2026-08-01T12:00:00.000Z"); + const catalog = [ + { + id: "root", + path: "/tmp/root.jsonl", + cwd: "/tmp", + rlmDepth: 0, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "middle", + path: "/tmp/middle.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/root.jsonl", + rlmDepth: 1, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "first", + path: "/tmp/first.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/middle.jsonl", + rlmDepth: 2, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "second", + path: "/tmp/second.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/middle.jsonl", + rlmDepth: 2, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + ]; + supervisor.catalog.list = vi.fn(async () => catalog); + Object.assign(supervisor.catalog, { family: vi.fn(async () => catalog) }); + const entries = await supervisor.familyCatalogEntries("/tmp/custom-sessions"); + expect(supervisor.catalog.list).toHaveBeenCalledWith(undefined, "/tmp/custom-sessions"); + expect(supervisor.catalog.family).toHaveBeenCalledWith("/tmp/custom-sessions"); + expect(entries.map((entry) => entry.id)).toEqual(["root", "middle", "first", "second"]); + const { assertAgentFamilyReach } = await import("../src/core/agent-messages.js"); + expect( + assertAgentFamilyReach( + entries.find((entry) => entry.id === "first")!, + entries.find((entry) => entry.id === "second")!, + entries, + ), + ).toBe("sibling"); + }); + + it("uses the source worker session directory for agent-origin family snapshots", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const source = makeWorker("source", [makeSummary("source-active", now, { sessionId: "source" })]); + source.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; + const target = makeWorker("target", [makeSummary("target-active", now, { sessionId: "target" })]); + target.client!.requestWorker.mockResolvedValue({ + type: "response", + command: "worker_deliver_message", + success: true, + data: { deliveryStatus: "delivered" }, + }); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + const familyCatalogEntries = vi.fn(async () => + Object.freeze([ + { id: "root", depth: 0, status: "inactive" as const }, + { id: "source", depth: 1, status: "running" as const, parentSessionId: "root" }, + { id: "target", depth: 1, status: "running" as const, parentSessionId: "root" }, + ]), + ); + Object.assign(supervisor, { familyCatalogEntries }); + + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "custom-root", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deliver", + }, + ), + ).resolves.toMatchObject({ success: true }); + expect(familyCatalogEntries).toHaveBeenCalledWith("/tmp/custom-sessions"); + }); + + it("rejects active topology that conflicts with the persisted row before remote delivery", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const sourceSummary = makeSummary("source-active", now, { + sessionId: "source", + rlmDepth: 1, + parentSessionPath: "/tmp/root.jsonl", + }); + const targetSummary = makeSummary("target-active", now, { + sessionId: "target", + sessionFile: "/tmp/target.jsonl", + rlmDepth: 1, + parentSessionPath: "/tmp/forged-parent.jsonl", + }); + const source = makeWorker("source", [sourceSummary]); + const target = makeWorker("target", [targetSummary]); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + const timestamp = new Date(now); + const catalog = [ + { + id: "root", + path: "/tmp/root.jsonl", + cwd: "/tmp", + rlmDepth: 0, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "target", + path: "/tmp/target.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/root.jsonl", + rlmDepth: 1, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + ]; + supervisor.catalog.list = vi.fn(async () => catalog); + Object.assign(supervisor.catalog, { family: vi.fn(async () => catalog) }); + + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "persisted-conflict", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(target.client?.requestWorker).not.toHaveBeenCalled(); + }); + + it("uses only the source custom directory when denying a conflicting family topology", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const sourceSummary = makeSummary("source-active", now, { + sessionId: "source", + rlmDepth: 1, + parentSessionPath: "/tmp/custom-sessions/root.jsonl", + }); + const targetSummary = makeSummary("target-active", now, { + sessionId: "target", + sessionFile: "/tmp/custom-sessions/target.jsonl", + rlmDepth: 1, + parentSessionPath: "/tmp/custom-sessions/forged-parent.jsonl", + }); + const source = makeWorker("source", [sourceSummary]); + source.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; + const target = makeWorker("target", [targetSummary]); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + const timestamp = new Date(now); + const catalog = [ + { + id: "root", + path: "/tmp/custom-sessions/root.jsonl", + cwd: "/tmp", + rlmDepth: 0, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + allMessagesText: "", + }, + { + id: "target", + path: "/tmp/custom-sessions/target.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/custom-sessions/root.jsonl", + rlmDepth: 1, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + allMessagesText: "", + }, + ]; + supervisor.catalog.list = vi.fn(async () => catalog); + supervisor.catalog.family = vi.fn(async () => catalog); + + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "custom-persisted-conflict", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(supervisor.catalog.list).toHaveBeenCalledWith(undefined, "/tmp/custom-sessions"); + expect(supervisor.catalog.family).toHaveBeenCalledWith("/tmp/custom-sessions"); + expect(target.client?.requestWorker).not.toHaveBeenCalled(); + }); + + it("rejects duplicate snapshot identities before cross-worker delivery", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const sourceSummary = makeSummary("source-active", now, { sessionId: "source" }); + const targetSummary = makeSummary("target-active", now, { sessionId: "target" }); + const source = makeWorker("source", [sourceSummary]); + const target = makeWorker("target", [targetSummary]); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + Object.assign(supervisor, { + familyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: "root", depth: 0, status: "inactive" as const }, + { id: "source", depth: 1, status: "running" as const, parentSessionId: "root" }, + { id: "target", depth: 1, status: "running" as const, parentSessionId: "root" }, + { id: "target", depth: 1, status: "running" as const, parentSessionId: "forged" }, + ]), + ), + }); + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "duplicate", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(target.client?.requestWorker).not.toHaveBeenCalled(); + }); + + it("rejects a postwake session substitution without delivery", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-postwake-substitution-")); + tempDirs.push(directory); + const parentManager = SessionManager.create(directory, join(directory, "sessions")); + parentManager.newSession({ rlmDepth: 0 }); + parentManager.flushNow(); + const parentPath = parentManager.getSessionFile(); + if (!parentPath) throw new Error("Missing parent session path"); + const targetManager = SessionManager.create(directory, join(directory, "sessions")); + targetManager.newSession({ parentSession: parentPath, rlmDepth: 1 }); + targetManager.flushNow(); + const targetPath = targetManager.getSessionFile(); + if (!targetPath) throw new Error("Missing target session path"); + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const sourceSummary = makeSummary("source-active", now, { sessionId: "source" }); + const source = makeWorker("source", [sourceSummary]); + const substituted = makeSummary("woken-active", now, { sessionId: "substitute", sessionFile: targetPath }); + const woken = makeWorker("woken", [substituted]); + const supervisor = makeSupervisor(); + supervisor.workers.set("source", source); + supervisor.catalog.resolve = vi.fn(async () => targetPath); + supervisor.createOrReuseWorker = vi.fn(async () => woken); + Object.assign(supervisor, { + familyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: parentManager.getSessionId(), depth: 0, status: "inactive" as const, sessionPath: parentPath }, + { id: "source", depth: 1, status: "running" as const, parentSessionId: parentManager.getSessionId() }, + { + id: targetManager.getSessionId(), + depth: 1, + status: "inactive" as const, + parentSessionPath: parentPath, + sessionPath: targetPath, + }, + ]), + ), + }); + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "substitution", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: targetManager.getSessionId(), + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(supervisor.createOrReuseWorker).toHaveBeenCalledOnce(); + expect(woken.client?.requestWorker).not.toHaveBeenCalled(); + }); }); diff --git a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts index 497c7e017..a7b96197a 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -22,13 +22,14 @@ interface SupervisorInternals { clientId: string, command: { type: "create"; name?: string; sessionPath?: string }, ): Promise; - assertSupervisorSavedSessionNameAvailable(sessionPath: string, name: string): Promise; + assertSupervisorSavedSessionNameAvailable(sessionPath: string, name: string, sessionDir?: string): Promise; assertSavedSiblingNameAvailable( siblings: Array>, target: Record, name: string, ): void; familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry; + familyCatalogEntries(): Promise; handleCommand(client: object, command: Record): Promise; } @@ -41,7 +42,7 @@ interface WorkerFixture { pid: number; authenticationToken: string; ownerClientId?: string; - createCommand: { config: { cwd: string } }; + createCommand: { config: { cwd: string; sessionDir?: string } }; }; client: { request: ReturnType; @@ -491,6 +492,7 @@ describe("daemon supervisor passive subagent topology", () => { releaseRename = resolve; }); const firstWorker = worker("first", [firstSummary]); + firstWorker.descriptor.createCommand.config.sessionDir = join(directory, "custom-sessions"); firstWorker.client.request.mockImplementation(async () => { await renameGate; return success(undefined, "rename_saved_session", firstSummary); @@ -503,10 +505,12 @@ describe("daemon supervisor passive subagent topology", () => { }) as unknown as SupervisorInternals; supervisor.workers.set("first", firstWorker); supervisor.workers.set("second", secondWorker); + const siblings = vi.fn(async () => []); + const list = vi.fn(async () => []); Object.assign(supervisor, { catalog: { - siblings: vi.fn(async () => []), - list: vi.fn(async () => []), + siblings, + list, }, }); const client = { id: "client", attachedActiveSessionIds: new Set() }; @@ -534,6 +538,8 @@ describe("daemon supervisor passive subagent topology", () => { expect(secondWorker.client.request).not.toHaveBeenCalled(); releaseRename(); await expect(first).resolves.toMatchObject({ success: true }); + expect(siblings).not.toHaveBeenCalled(); + expect(list).toHaveBeenCalledWith(undefined, join(directory, "custom-sessions")); }); it("serializes same-scope inactive renames across catalog validation and commit", async () => { @@ -563,9 +569,10 @@ describe("daemon supervisor passive subagent topology", () => { defaultSessionConfig: { agentDir: directory, cwd: directory }, descriptorDir: join(directory, "workers"), }) as unknown as SupervisorInternals; + const siblings = vi.fn(async () => saved); Object.assign(supervisor, { catalog: { - siblings: vi.fn(async () => saved), + siblings, rename, }, }); @@ -575,6 +582,7 @@ describe("daemon supervisor passive subagent topology", () => { type: "rename_saved_session", sessionPath: firstPath, name: "shared", + sessionDir: join(directory, "custom-sessions"), }); await vi.waitFor(() => expect(rename).toHaveBeenCalledOnce()); await expect( @@ -582,10 +590,12 @@ describe("daemon supervisor passive subagent topology", () => { type: "rename_saved_session", sessionPath: secondPath, name: "shared", + sessionDir: join(directory, "custom-sessions"), }), ).rejects.toThrow("an agent of that name already exists at depth 1 under this parent"); releaseRename(); await expect(first).resolves.toMatchObject({ success: true }); + expect(siblings).toHaveBeenCalledWith(firstPath, join(directory, "custom-sessions")); }); it("reserves named child creates by parent scope until worker launch completes", async () => { @@ -644,6 +654,156 @@ describe("daemon supervisor passive subagent topology", () => { await expect(first).resolves.toBe(launched); }); + it("authorizes live depth-two siblings through an artifact-resident parent across workers", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-artifact-family-reach-")); + tempDirs.push(directory); + const parentPath = join(directory, "parent.jsonl"); + const parent = { + id: "artifact-parent", + path: parentPath, + cwd: directory, + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + rlmDepth: 1, + }; + const source = summary({ + id: "source-active", + activeSessionId: "source-active", + sessionId: "source-session", + runtimeKind: "subagent", + rlmDepth: 2, + parentSessionId: parent.id, + }); + const target = summary({ + id: "target-active", + activeSessionId: "target-active", + sessionId: "target-session", + runtimeKind: "subagent", + rlmDepth: 2, + parentSessionId: parent.id, + }); + const sourceWorker = worker("source", [source]); + const targetWorker = worker("target", [target]); + targetWorker.client.requestWorker.mockResolvedValue({ + type: "response", + command: "worker_deliver_message", + success: true, + } as never); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorInternals; + supervisor.workers.set("source", sourceWorker); + supervisor.workers.set("target", targetWorker); + Object.assign(supervisor, { catalog: { list: vi.fn(async () => []), family: vi.fn(async () => [parent]) } }); + + await expect( + supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { + id: "message", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: source.activeSessionId, + targetActiveSessionId: target.activeSessionId, + message: "hello sibling", + }, + ), + ).resolves.toMatchObject({ success: true }); + expect(targetWorker.client.requestWorker).toHaveBeenCalledWith( + expect.objectContaining({ type: "worker_deliver_message", targetActiveSessionId: target.activeSessionId }), + expect.any(Number), + ); + }); + + it.each([ + ["missing", []], + [ + "malformed", + [ + { + id: "artifact-parent", + path: join(tmpdir(), "malformed.jsonl"), + cwd: "", + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + rlmDepth: 0, + }, + ], + ], + [ + "conflicting", + [ + { + id: "artifact-parent", + path: join(tmpdir(), "first-parent.jsonl"), + cwd: "", + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + rlmDepth: 1, + }, + { + id: "artifact-parent", + path: join(tmpdir(), "second-parent.jsonl"), + cwd: "", + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + rlmDepth: 1, + }, + ], + ], + ] as const)("denies live depth-two siblings when the artifact parent is %s", async (_kind, parents) => { + const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-artifact-family-deny-")); + tempDirs.push(directory); + const child = (id: string) => + summary({ + id: `${id}-active`, + activeSessionId: `${id}-active`, + sessionId: `${id}-session`, + runtimeKind: "subagent", + rlmDepth: 2, + parentSessionId: "artifact-parent", + }); + const source = child("source"); + const target = child("target"); + const sourceWorker = worker("source", [source]); + const targetWorker = worker("target", [target]); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorInternals; + supervisor.workers.set("source", sourceWorker); + supervisor.workers.set("target", targetWorker); + Object.assign(supervisor, { catalog: { list: vi.fn(async () => []), family: vi.fn(async () => parents) } }); + + await expect( + supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { + id: "message", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: source.activeSessionId, + targetActiveSessionId: target.activeSessionId, + message: "hello sibling", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(targetWorker.client.requestWorker).not.toHaveBeenCalled(); + }); + it("retains passive worker summaries but syncs only roots to cross-worker peer maps", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-passive-peers-")); tempDirs.push(directory); diff --git a/packages/coding-agent/test/saved-session-catalog.test.ts b/packages/coding-agent/test/saved-session-catalog.test.ts index 30a7f33ac..a7e164fb4 100644 --- a/packages/coding-agent/test/saved-session-catalog.test.ts +++ b/packages/coding-agent/test/saved-session-catalog.test.ts @@ -145,7 +145,12 @@ describe("saved session catalog", () => { }); expect(fakeClient.commands).toEqual([ - { type: "rename_saved_session", sessionPath: "/tmp/sessions/one.jsonl", name: "One" }, + { + type: "rename_saved_session", + sessionPath: "/tmp/sessions/one.jsonl", + sessionDir: "/tmp/sessions", + name: "One", + }, { type: "delete_saved_session", sessionPath: "/tmp/sessions/one.jsonl" }, ]); }); From 8b21a401d974cef7105157eb2d83a9e39d899f59 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 12 Aug 2026 22:49:05 -0700 Subject: [PATCH 02/22] fix(daemon): preserve family authority boundaries Scope live family catalogs, anchor relative parent paths, and retain legacy rename compatibility without weakening authority-aware requests. --- .../src/modes/daemon/daemon-protocol.ts | 5 ++- .../src/modes/daemon/daemon-supervisor.ts | 23 ++++++++++- .../coding-agent/test/daemon-protocol.test.ts | 10 +++-- .../test/daemon-supervisor-eviction.test.ts | 40 +++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index bfe126f4e..5bd6d6f13 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -760,7 +760,10 @@ export const DAEMON_COMMAND_COMPATIBILITY = { } as const satisfies Record; export function getDaemonCommandCompatibilities(command: DaemonCommand): readonly DaemonCommandCompatibility[] { - const compatibility = DAEMON_COMMAND_COMPATIBILITY[command.type]; + const compatibility = + command.type === "rename_saved_session" && command.sessionDir === undefined + ? LEGACY_DAEMON_COMMAND + : DAEMON_COMMAND_COMPATIBILITY[command.type]; const carriesTelemetryPolicy = ((command.type === "attach" || command.type === "reattach") && command.telemetryDisabled !== undefined) || (command.type === "create" && command.config?.telemetryDisabled !== undefined); diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index c972d1a86..ccec2ce5b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3049,7 +3049,18 @@ export class DaemonSupervisor { } private async familyCatalogEntries(sessionDir?: string): Promise { + const effectiveSessionDir = sessionDir ?? this.defaultSessionConfig.sessionDir; const live = [...this.workers.values()] + .filter( + (worker) => + (worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir) === + effectiveSessionDir || + (Boolean(worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir) && + Boolean(effectiveSessionDir) && + canonicalSessionPath( + (worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir)!, + ) === canonicalSessionPath(effectiveSessionDir!)), + ) .flatMap((worker) => [...worker.summaries.values()]) .map((summary): FamilyCatalogCandidate => ({ ...this.familyCatalogEntry(summary), source: "live" })); // Keep the persisted row even when its path is currently active. A live @@ -3330,8 +3341,16 @@ export class DaemonSupervisor { depth, status: classifySessionRosterStatus(summary), ...(depth > 0 && summary.parentSessionId ? { parentSessionId: summary.parentSessionId } : {}), - ...(depth > 0 && summary.parentSessionPath - ? { parentSessionPath: canonicalSessionPath(summary.parentSessionPath) } + ...(depth > 0 && summary.parentSessionPath && (isAbsolute(summary.parentSessionPath) || summary.sessionFile) + ? { + parentSessionPath: canonicalSessionPath( + isAbsolute(summary.parentSessionPath) + ? summary.parentSessionPath + : summary.sessionFile + ? resolve(dirname(summary.sessionFile), summary.parentSessionPath) + : summary.parentSessionPath, + ), + } : {}), ...(summary.sessionFile ? { sessionPath: canonicalSessionPath(summary.sessionFile) } : {}), }; diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index 466df631a..d26404efc 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -93,13 +93,17 @@ describe("daemon protocol helpers", () => { expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("queue_message_mutation"); }); - it("accepts the old-client rename shape but rejects an old daemon for authority-aware renames", () => { - const oldClientCommand: DaemonCommand = { + it("schema-gates only authority-aware saved-session renames", () => { + const detachedLegacy: DaemonCommand = { type: "rename_saved_session", sessionPath: "/tmp/session.jsonl", name: "renamed", }; - expect(getDaemonCommandCompatibilities(oldClientCommand)).toEqual([{ minProtocol: 7, minSchemaRevision: 17 }]); + const activeLegacy: DaemonCommand = { ...detachedLegacy, activeSessionId: "active" }; + const authorityAware: DaemonCommand = { ...detachedLegacy, sessionDir: "/tmp/sessions" }; + expect(getDaemonCommandCompatibilities(detachedLegacy)).toEqual([{ minProtocol: 7 }]); + expect(getDaemonCommandCompatibilities(activeLegacy)).toEqual([{ minProtocol: 7 }]); + expect(getDaemonCommandCompatibilities(authorityAware)).toEqual([{ minProtocol: 7, minSchemaRevision: 17 }]); expect(DAEMON_COMMAND_COMPATIBILITY.rename_saved_session).toEqual({ minProtocol: 7, minSchemaRevision: 17, diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 93543625b..bdc63962d 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -575,6 +575,45 @@ describe("daemon supervisor whole-tree eviction", () => { ).toBe("sibling"); }); + it("scopes live family rows to the requested configured session directory", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const source = makeWorker("source", [makeSummary("source", now)]); + source.descriptor.createCommand.config = { sessionDir: "/tmp/sessions-a" }; + const foreign = makeWorker("foreign", [makeSummary("foreign", now)]); + foreign.descriptor.createCommand.config = { sessionDir: "/tmp/sessions-b" }; + supervisor.workers.set("source", source); + supervisor.workers.set("foreign", foreign); + supervisor.catalog.list = vi.fn(async () => []); + Object.assign(supervisor.catalog, { family: vi.fn(async () => []) }); + + const entries = await supervisor.familyCatalogEntries("/tmp/sessions-a"); + expect(entries.map((entry) => entry.id)).toEqual(["source-session"]); + }); + + it("anchors relative live parent paths to the child session file", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const worker = makeWorker("family", [ + makeSummary("root", now, { sessionId: "root", sessionFile: "/tmp/family/root.jsonl", rlmDepth: 0 }), + makeSummary("child", now, { + sessionId: "child", + sessionFile: "/tmp/family/children/child.jsonl", + rlmDepth: 1, + parentSessionId: "root", + parentSessionPath: "../root.jsonl", + }), + ]); + worker.descriptor.createCommand.config = { sessionDir: "/tmp/sessions" }; + supervisor.workers.set("family", worker); + supervisor.catalog.list = vi.fn(async () => []); + Object.assign(supervisor.catalog, { family: vi.fn(async () => []) }); + + const entries = await supervisor.familyCatalogEntries("/tmp/sessions"); + const { assertAgentFamilyReach } = await import("../src/core/agent-messages.js"); + expect(assertAgentFamilyReach(entries[0]!, entries[1]!, entries)).toBe("child"); + }); + it("uses the source worker session directory for agent-origin family snapshots", async () => { const now = Date.parse("2026-08-01T12:00:00.000Z"); const supervisor = makeSupervisor(); @@ -692,6 +731,7 @@ describe("daemon supervisor whole-tree eviction", () => { const source = makeWorker("source", [sourceSummary]); source.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; const target = makeWorker("target", [targetSummary]); + target.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; supervisor.workers.set("source", source); supervisor.workers.set("target", target); const timestamp = new Date(now); From 05086611e0f9ac451be8a6fce0aca0bd1708270c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 13 Aug 2026 12:50:46 +0200 Subject: [PATCH 03/22] fix(daemon): keep authority fd accounting balanced when the artifacts root open fails managedRoots closed the session root descriptor on the error path but never decremented the test-visible open-descriptor counter, so a failed artifacts open left the count permanently drifted. --- .../src/modes/daemon/daemon-catalog-process.ts | 1 + .../test/daemon-catalog-process.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index b577792a9..fc236084d 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -145,6 +145,7 @@ function managedRoots(sessionDir: string | undefined): ManagedRoots { return { session, artifacts }; } catch (error) { closeSync(session.fd); + openAuthorityFdCountForTest--; throw error; } } diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 4aa7ed5bc..260a58a9e 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -506,6 +506,21 @@ describe("daemon catalog selector resolution", () => { await expect(listCatalogFamilySessions(duplicateDir)).rejects.toThrow("duplicate session id"); }); + it("releases the session authority descriptor when the artifacts root open fails", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-artifacts-open-fail-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + // A symlinked artifacts root fails the O_NOFOLLOW open with a non-ENOENT error. + const realArtifacts = join(root, "session-artifacts-real"); + mkdirSync(realArtifacts); + symlinkSync(realArtifacts, join(root, "session-artifacts")); + const baseline = getOpenCatalogAuthorityFdCountForTest(); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(baseline); + }); + it("treats an exact name colliding with another session id prefix as ambiguous", () => { const sessions = [ session("named-session-id", "target", "/tmp/by-name.jsonl"), From 565b97ec7296016714f7009b31a3b8b3dbab9605 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 13 Aug 2026 13:13:59 +0200 Subject: [PATCH 04/22] fix(daemon): treat fork lineage as family roots instead of topology corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /fork and lineage-carrying /new write parentSession into flat sessions-dir headers as fork ancestry, not rlm topology. The family walk treated every flat-dir session as an rlm seed, so one forked session anywhere in the profile made family() throw invalidFamilyTopology — hard-failing agent messaging, create-with-name, and rename. Two real-world header shapes both triggered it: parentSession with a numeric rlmDepth (current fork writer) and parentSession with no rlmDepth at all (older writer). A flat-dir session whose parent claim resolves inside the same sessions dir is now a depth-0 family root with the fork claim dropped. Parent claims that escape the sessions dir still fail closed, and registry-reached children still enforce the strict parent/depth invariants (absent depth on a child is now an explicit error instead of an accidental one). --- .../modes/daemon/daemon-catalog-process.ts | 59 +++++++++---- .../test/daemon-catalog-process.test.ts | 82 ++++++++++++++++++- 2 files changed, 122 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index fc236084d..fe6923a52 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -98,11 +98,16 @@ interface TrustedFile { } interface TrustedSession extends SessionInfo { - /** The header claim is intentionally separate from SessionInfo's legacy fallback. */ - persistedDepth: number; + /** The header claims are intentionally separate from SessionInfo's legacy fallback. */ + persistedDepth?: number; persistedParentPath?: string; } +/** A family member whose depth has been verified against the walk. */ +interface FamilySession extends TrustedSession { + persistedDepth: number; +} + function rlmSubagentRegistryPath(parent: SessionInfo, roots: ManagedRoots): string | undefined { const parentDir = dirname(parent.path); const artifactDir = @@ -271,22 +276,42 @@ async function readTrustedSession(path: string, roots: ManagedRoots): Promise(); + const sessions = new Map(); const ids = new Map(); for (const root of roots) { - const trusted = await readTrustedSession(root.path, authority); - if (trusted.persistedDepth !== 0 || trusted.persistedParentPath !== undefined) { - throw invalidFamilyTopology("managed session seed claims a parent"); - } + const trusted = asTrustedFamilyRoot(await readTrustedSession(root.path, authority), authority); const existingPath = ids.get(trusted.id); if (existingPath && existingPath !== trusted.path) throw invalidFamilyTopology("family contains a duplicate session id"); @@ -344,7 +366,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise(); - const visit = async (parent: TrustedSession, depth: number, ancestors: ReadonlySet): Promise => { + const visit = async (parent: FamilySession, depth: number, ancestors: ReadonlySet): Promise => { if (depth > MAX_RLM_FAMILY_DEPTH) throw invalidFamilyTopology("family depth limit exhausted"); const parentPath = parent.path; if (ancestors.has(parentPath)) throw invalidFamilyTopology("family contains a cycle"); @@ -361,11 +383,14 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise MAX_RLM_FAMILY_EDGES) throw invalidFamilyTopology("family edge limit exhausted"); const childPath = entry.sessionFile as string; if (childAncestors.has(childPath)) throw invalidFamilyTopology("family contains a cycle"); - const child = await readTrustedSession(childPath, authority); - if (child.id !== entry.childId) throw invalidFamilyTopology("registry child id does not match session id"); - if (child.persistedParentPath === undefined) + const trustedChild = await readTrustedSession(childPath, authority); + if (trustedChild.id !== entry.childId) + throw invalidFamilyTopology("registry child id does not match session id"); + if (trustedChild.persistedParentPath === undefined) throw invalidFamilyTopology("child lacks a persisted parent path"); - const claimedParentPath = resolve(dirname(child.path), child.persistedParentPath); + if (trustedChild.persistedDepth === undefined) throw invalidFamilyTopology("child lacks a persisted depth"); + const child: FamilySession = { ...trustedChild, persistedDepth: trustedChild.persistedDepth }; + const claimedParentPath = resolve(dirname(child.path), trustedChild.persistedParentPath); readTrustedFile(claimedParentPath, authority, 128 * 1024 * 1024); if (claimedParentPath !== parentPath) throw invalidFamilyTopology("child parent path does not match traversed parent"); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 260a58a9e..ef4134039 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -477,7 +477,7 @@ describe("daemon catalog selector resolution", () => { parent.newSession({ id: "parent", rlmDepth: 0 }); parent.appendSessionInfo("parent"); const hostile = SessionManager.create(root, sessionDir); - hostile.newSession({ id: "hostile", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + hostile.newSession({ id: "hostile", parentSession: join(root, "outside.jsonl"), rlmDepth: 1 }); hostile.appendSessionInfo("hostile"); const baseline = getOpenCatalogAuthorityFdCountForTest(); for (let attempt = 0; attempt < 64; attempt++) { @@ -486,6 +486,84 @@ describe("daemon catalog selector resolution", () => { } }); + it("treats forked flat-dir sessions as family roots for both fork header shapes", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-fork-roots-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + // Current fork writer: parentSession plus a copied numeric rlmDepth. + const forkWithDepth = SessionManager.create(root, sessionDir); + forkWithDepth.newSession({ id: "fork-depth", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + forkWithDepth.appendSessionInfo("fork-depth"); + // Older fork writer: parentSession with no rlmDepth claim at all. + const forkNoDepth = SessionManager.create(root, sessionDir); + forkNoDepth.newSession({ id: "fork-nodepth", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + forkNoDepth.appendSessionInfo("fork-nodepth"); + const forkNoDepthFile = forkNoDepth.getSessionFile()!; + const [headerLine, ...rest] = readFileSync(forkNoDepthFile, "utf8").trimEnd().split(/\r?\n/); + const header = JSON.parse(headerLine!) as Record; + delete header.rlmDepth; + writeFileSync(forkNoDepthFile, `${[JSON.stringify(header), ...rest].join("\n")}\n`); + + const family = await listCatalogFamilySessions(sessionDir); + expect(family).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "parent", rlmDepth: 0 }), + expect.objectContaining({ id: "fork-depth", rlmDepth: 0 }), + expect.objectContaining({ id: "fork-nodepth", rlmDepth: 0 }), + ]), + ); + // Fork lineage is not rlm topology: it must not surface as a parent claim. + for (const info of family) expect(info.parentSessionPath).toBeUndefined(); + // A fork root is its own sibling set, not a sibling of the source's rlm children. + await expect(listSavedSessionSiblings(forkWithDepth.getSessionFile()!, sessionDir)).resolves.toEqual([ + expect.objectContaining({ id: "fork-depth" }), + ]); + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + rmSync(root, { recursive: true, force: true }); + }); + + it("keeps strict topology invariants for registry-reached children", async () => { + const make = (mutateHeader: (header: Record) => void) => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-strict-child-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-child")); + child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + child.appendSessionInfo("child"); + const childFile = child.getSessionFile()!; + const [headerLine, ...rest] = readFileSync(childFile, "utf8").trimEnd().split(/\r?\n/); + const header = JSON.parse(headerLine!) as Record; + mutateHeader(header); + writeFileSync(childFile, `${[JSON.stringify(header), ...rest].join("\n")}\n`); + const registry = join(root, "session-artifacts", "parent", "rlm-subagents.jsonl"); + mkdirSync(dirname(registry), { recursive: true }); + writeFileSync( + registry, + JSON.stringify({ type: "rlm_subagent", childId: "child", sessionFile: childFile, status: "completed" }), + ); + return sessionDir; + }; + await expect( + listCatalogFamilySessions( + make((header) => { + delete header.rlmDepth; + }), + ), + ).rejects.toThrow("child lacks a persisted depth"); + await expect( + listCatalogFamilySessions( + make((header) => { + delete header.parentSession; + }), + ), + ).rejects.toThrow("child lacks a persisted parent path"); + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + it("rejects an unregistered parent-claiming seed and conflicting duplicate identity", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-orphan-seed-")); const sessionDir = join(root, "sessions"); @@ -493,7 +571,7 @@ describe("daemon catalog selector resolution", () => { parent.newSession({ id: "parent", rlmDepth: 0 }); parent.appendSessionInfo("parent"); const orphan = SessionManager.create(root, sessionDir); - orphan.newSession({ id: "evil", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + orphan.newSession({ id: "evil", parentSession: join(root, "outside.jsonl"), rlmDepth: 1 }); orphan.appendSessionInfo("evil"); await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("managed session seed claims a parent"); From 340b79c9c882484c5f214c5487dd426b595f1e29 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 13 Aug 2026 13:49:20 +0200 Subject: [PATCH 05/22] perf(daemon): read only session headers and stat parent probes in the family walk The walk transferred every session file whole (128MB limit, base64 over stdout) although only the first header line participates in the trust decision, and it re-read the claimed parent file whole just to discard the bytes. On a real profile (177 sessions, 433MB) that cost ~4.6s and ~570MB of pipe churn per family() call. The openat helper now takes a mode: 'header' stops at the first newline under a 256KB budget, 'stat' verifies existence and identity without transferring content, and 'read' keeps the old behavior for the small bounded registries. Display metadata (names, previews) comes from the caller's listing or the ordinary cached read and is bound to the descriptor-read header by the id cross-check; topology claims always come from the header bytes. --- .../modes/daemon/daemon-catalog-process.ts | 70 ++++++++++------- .../test/daemon-catalog-process.test.ts | 75 ++++++++++++++++++- 2 files changed, 113 insertions(+), 32 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index fe6923a52..6676ecaf1 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -6,12 +6,7 @@ import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli import { getSessionsDir } from "../../config.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import { deleteSessionFile } from "../../core/session-file-actions.js"; -import { - readSessionInfo, - readSessionInfoFromBuffer, - type SessionInfo, - SessionManager, -} from "../../core/session-manager.js"; +import { readSessionInfo, type SessionInfo, SessionManager } from "../../core/session-manager.js"; export const DAEMON_CATALOG_ROLE_ENV = "PRIME_AGENT_INTERNAL_DAEMON_CATALOG"; @@ -74,6 +69,7 @@ interface SavedRlmSubagentRegistryEntry { } const MAX_RLM_REGISTRY_BYTES = 1024 * 1024; +const MAX_SESSION_HEADER_BYTES = 256 * 1024; const MAX_RLM_REGISTRY_RECORDS = 10_000; const MAX_RLM_FAMILY_EDGES = 10_000; const MAX_RLM_FAMILY_NODES = 10_000; @@ -173,7 +169,8 @@ def flags(directory=False): return value def main(): req=json.loads(sys.stdin.buffer.read(131073)) - parts=req.get("parts"); limit=req.get("limit") + parts=req.get("parts"); limit=req.get("limit"); mode=req.get("mode") + if mode not in ("read","header","stat"): reject() if not isinstance(parts,list) or not parts or not isinstance(limit,int) or limit<0 or limit>MAX: reject() if any(not isinstance(p,str) or not p or p in (".","..") or "/" in p or "\\" in p for p in parts): reject() current=os.dup(3) @@ -183,16 +180,24 @@ def main(): fd=os.open(parts[-1],flags(False),dir_fd=current) try: before=os.fstat(fd) - if not stat.S_ISREG(before.st_mode) or before.st_size>limit: reject() - chunks=[]; total=0 - while True: - chunk=os.read(fd,min(65536,limit+1-total)) - if not chunk: break - chunks.append(chunk); total+=len(chunk) - if total>limit: reject() + if not stat.S_ISREG(before.st_mode): reject() + if mode=="read" and before.st_size>limit: reject() + payload={} + if mode!="stat": + chunks=[]; total=0; done=False + while not done: + chunk=os.read(fd,min(65536,limit+1-total)) + if not chunk: break + if mode=="header": + cut=chunk.find(b"\n") + if cut>=0: chunk=chunk[:cut+1]; done=True + chunks.append(chunk); total+=len(chunk) + if total>limit: reject() + payload["data"]=base64.b64encode(b"".join(chunks)).decode("ascii") after=os.fstat(fd) if (before.st_dev,before.st_ino,before.st_mode)!=(after.st_dev,after.st_ino,after.st_mode): reject() - print(json.dumps({"data":base64.b64encode(b"".join(chunks)).decode("ascii"),"mtimeMs":after.st_mtime_ns/1000000,"dev":str(after.st_dev),"ino":str(after.st_ino)},separators=(",",":"))) + payload.update({"mtimeMs":after.st_mtime_ns/1000000,"dev":str(after.st_dev),"ino":str(after.st_ino)}) + print(json.dumps(payload,separators=(",",":"))) finally: os.close(fd) finally: os.close(current) try: main() @@ -207,7 +212,9 @@ export function setCatalogBeforeTrustedOpenForTest(hook: ((path: string) => void beforeTrustedOpenForTest = hook; } -function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number): TrustedFile { +type TrustedReadMode = "read" | "header" | "stat"; + +function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number, mode: TrustedReadMode): TrustedFile { if (!isAbsolute(rawPath) || rawPath !== resolve(rawPath)) throw invalidFamilyTopology("session path is not canonical"); const root = [roots.session, roots.artifacts].find((candidate) => candidate && isWithin(candidate.lexical, rawPath)); @@ -224,7 +231,7 @@ function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number) : (process.env.PRIME_AGENT_KERNEL_PYTHON ?? "python3"), ["-I", "-c", OPENAT_READ_HELPER], { - input: JSON.stringify({ parts, limit: maxBytes }), + input: JSON.stringify({ parts, limit: maxBytes, mode }), encoding: "utf8", timeout: 5_000, maxBuffer: maxBytes * 2 + 64 * 1024, @@ -239,7 +246,7 @@ function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number) try { const wire = JSON.parse(result.stdout) as { data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; if ( - typeof wire.data !== "string" || + (mode === "stat" ? wire.data !== undefined : typeof wire.data !== "string") || typeof wire.mtimeMs !== "number" || typeof wire.dev !== "string" || typeof wire.ino !== "string" @@ -247,7 +254,7 @@ function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number) throw new Error("invalid"); return { path: rawPath, - contents: Buffer.from(wire.data, "base64"), + contents: typeof wire.data === "string" ? Buffer.from(wire.data, "base64") : Buffer.alloc(0), mtimeMs: wire.mtimeMs, dev: wire.dev, ino: wire.ino, @@ -257,11 +264,15 @@ function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number) } } -async function readTrustedSession(path: string, roots: ManagedRoots): Promise { - const trusted = readTrustedFile(path, roots, 128 * 1024 * 1024); - const headerLine = trusted.contents - .toString("utf8", 0, Math.min(trusted.contents.length, 256 * 1024)) - .split(/\r?\n/, 1)[0]; +/** + * Topology claims (id, parent, depth) come from descriptor-bound header bytes. + * Display metadata (name, timestamps, previews) is not part of the trust + * decision: it comes from the caller's listing when available, otherwise from + * an ordinary cached read, and is bound to the header by the id cross-check. + */ +async function readTrustedSession(path: string, roots: ManagedRoots, listed?: SessionInfo): Promise { + const trusted = readTrustedFile(path, roots, MAX_SESSION_HEADER_BYTES, "header"); + const headerLine = trusted.contents.toString("utf8").split(/\r?\n/, 1)[0]; let header: { type?: unknown; id?: unknown; parentSession?: unknown; rlmDepth?: unknown }; try { header = JSON.parse(headerLine ?? "") as typeof header; @@ -280,12 +291,15 @@ async function readTrustedSession(path: string, roots: ManagedRoots): Promise { let contents: string; try { - contents = readTrustedFile(path, roots, MAX_RLM_REGISTRY_BYTES).contents.toString("utf8"); + contents = readTrustedFile(path, roots, MAX_RLM_REGISTRY_BYTES, "read").contents.toString("utf8"); } catch (error) { if ((error as Error).message.includes("descriptor-relative artifact is absent")) return undefined; throw error; @@ -357,7 +371,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise(); const ids = new Map(); for (const root of roots) { - const trusted = asTrustedFamilyRoot(await readTrustedSession(root.path, authority), authority); + const trusted = asTrustedFamilyRoot(await readTrustedSession(root.path, authority, root), authority); const existingPath = ids.get(trusted.id); if (existingPath && existingPath !== trusted.path) throw invalidFamilyTopology("family contains a duplicate session id"); @@ -391,7 +405,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise { setCatalogBeforeTrustedOpenForTest(undefined); }); - it("parses session metadata from the same descriptor-bound bytes without a pathname reopen", async () => { + it("rejects a session replaced between listing and the descriptor-bound header read", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-no-reopen-")); const sessionDir = join(root, "sessions"); const parent = SessionManager.create(root, sessionDir); @@ -463,8 +472,9 @@ describe("daemon catalog selector resolution", () => { renameSync(path, original); writeFileSync(path, "not a session\n"); }); - // The helper opens after the hook, so it must reject the replacement instead of - // authorizing stale bytes. This pins the absence of a later readSessionInfo reopen. + // Topology claims come from the descriptor-bound header read, which opens + // after the hook and must reject the replacement instead of authorizing + // the stale listing. await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); setCatalogBeforeTrustedOpenForTest(undefined); rmSync(root, { recursive: true, force: true }); @@ -599,6 +609,63 @@ describe("daemon catalog selector resolution", () => { expect(getOpenCatalogAuthorityFdCountForTest()).toBe(baseline); }); + it("reads only the session header line even when the body is huge", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-header-only-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + // A body far beyond the old whole-file transfer budget must not slow or + // break the walk: only the first line participates in the trust decision. + appendFileSync( + parent.getSessionFile()!, + `${JSON.stringify({ type: "custom_message", body: "x".repeat(8 * 1024 * 1024) })}\n`, + ); + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([ + expect.objectContaining({ id: "parent", rlmDepth: 0 }), + ]); + rmSync(root, { recursive: true, force: true }); + }); + + it("rejects a session whose header line exceeds the header budget", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-header-overflow-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const file = parent.getSessionFile()!; + const entries = readFileSync(file, "utf8").trimEnd().split(/\r?\n/); + const header = JSON.parse(entries[0]!) as Record; + header.padding = "x".repeat(300 * 1024); + writeFileSync(file, `${[JSON.stringify(header), ...entries.slice(1)].join("\n")}\n`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + rmSync(root, { recursive: true, force: true }); + }); + + it("still requires a registry child's claimed parent file to exist", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-absent-parent-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-child")); + child.newSession({ id: "child", parentSession: join(sessionDir, "gone.jsonl"), rlmDepth: 1 }); + child.appendSessionInfo("child"); + const registry = join(root, "session-artifacts", "parent", "rlm-subagents.jsonl"); + mkdirSync(dirname(registry), { recursive: true }); + writeFileSync( + registry, + JSON.stringify({ + type: "rlm_subagent", + childId: "child", + sessionFile: child.getSessionFile(), + status: "completed", + }), + ); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + rmSync(root, { recursive: true, force: true }); + }); + it("treats an exact name colliding with another session id prefix as ambiguous", () => { const sessions = [ session("named-session-id", "target", "/tmp/by-name.jsonl"), From 218cead0daa9f0b2e951e8c70d86608fece81694 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 13 Aug 2026 13:54:40 +0200 Subject: [PATCH 06/22] perf(daemon): serve each family walk from one looped openat helper Every trusted read spawned a fresh python3 -I process, so a walk over N sessions paid N+ interpreter startups. The helper now loops: newline- delimited JSON requests on stdin, one JSON response line each, exit on stdin EOF. Both authority roots are passed at spawn (session root on fd 3, artifacts root on fd 4 when present) and selected per request, keeping every open descriptor-relative under the O_NOFOLLOW roots. listCatalogFamilySessions creates one TrustedReadSession per walk and closes it in the same finally as the authority roots. The helper is spawned async with a per-request timeout; any protocol violation (unsolicited output, oversized response, write failure, unexpected exit) kills the helper and fails the walk closed. --- .../modes/daemon/daemon-catalog-process.ts | 222 ++++++++++++++---- .../test/daemon-catalog-process.test.ts | 13 + 2 files changed, 183 insertions(+), 52 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 6676ecaf1..8cc191c5e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, spawn, spawnSync } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { closeSync, constants, openSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; @@ -126,6 +126,12 @@ export function getOpenCatalogAuthorityFdCountForTest(): number { return openAuthorityFdCountForTest; } +let helperSpawnCountForTest = 0; +/** @internal */ +export function getCatalogHelperSpawnCountForTest(): number { + return helperSpawnCountForTest; +} + function openAuthorityRoot(path: string, optional = false): ManagedRoot | undefined { try { // O_NOFOLLOW binds authority to the directory itself, never a pathname target. @@ -167,13 +173,12 @@ def flags(directory=False): value=os.O_RDONLY|os.O_NOFOLLOW if directory: value|=os.O_DIRECTORY return value -def main(): - req=json.loads(sys.stdin.buffer.read(131073)) - parts=req.get("parts"); limit=req.get("limit"); mode=req.get("mode") - if mode not in ("read","header","stat"): reject() +def serve(req): + parts=req.get("parts"); limit=req.get("limit"); mode=req.get("mode"); root=req.get("root") + if mode not in ("read","header","stat") or root not in (3,4): reject() if not isinstance(parts,list) or not parts or not isinstance(limit,int) or limit<0 or limit>MAX: reject() if any(not isinstance(p,str) or not p or p in (".","..") or "/" in p or "\\" in p for p in parts): reject() - current=os.dup(3) + current=os.dup(root) try: for part in parts[:-1]: nxt=os.open(part,flags(True),dir_fd=current); os.close(current); current=nxt @@ -197,12 +202,17 @@ def main(): after=os.fstat(fd) if (before.st_dev,before.st_ino,before.st_mode)!=(after.st_dev,after.st_ino,after.st_mode): reject() payload.update({"mtimeMs":after.st_mtime_ns/1000000,"dev":str(after.st_dev),"ino":str(after.st_ino)}) - print(json.dumps(payload,separators=(",",":"))) + return payload finally: os.close(fd) finally: os.close(current) -try: main() -except FileNotFoundError: sys.exit(44) -except Exception: sys.stderr.write("catalog openat helper failed\n"); sys.exit(1) +while True: + line=sys.stdin.buffer.readline(131073) + if not line: break + if len(line)>131072 or not line.endswith(b"\n"): sys.exit(1) + try: response=serve(json.loads(line)) + except FileNotFoundError: response={"error":"absent"} + except Exception: response={"error":"failed"} + sys.stdout.write(json.dumps(response,separators=(",",":"))+"\n"); sys.stdout.flush() `; /** Test-only seam runs after authority selection but before descriptor-relative traversal. */ @@ -214,44 +224,91 @@ export function setCatalogBeforeTrustedOpenForTest(hook: ((path: string) => void type TrustedReadMode = "read" | "header" | "stat"; -function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number, mode: TrustedReadMode): TrustedFile { - if (!isAbsolute(rawPath) || rawPath !== resolve(rawPath)) - throw invalidFamilyTopology("session path is not canonical"); - const root = [roots.session, roots.artifacts].find((candidate) => candidate && isWithin(candidate.lexical, rawPath)); - if (!root) throw invalidFamilyTopology("session path escapes managed roots"); - const suffix = relative(root.lexical, rawPath); - const parts = suffix.split(sep); - if (!suffix || parts.some((part) => !part || part === "." || part === "..")) { - throw invalidFamilyTopology("session path lacks a trusted file component"); - } - beforeTrustedOpenForTest?.(rawPath); - const result = spawnSync( - process.execPath === process.env.PRIME_AGENT_KERNEL_PYTHON - ? process.execPath - : (process.env.PRIME_AGENT_KERNEL_PYTHON ?? "python3"), - ["-I", "-c", OPENAT_READ_HELPER], - { - input: JSON.stringify({ parts, limit: maxBytes, mode }), - encoding: "utf8", - timeout: 5_000, - maxBuffer: maxBytes * 2 + 64 * 1024, - stdio: ["pipe", "pipe", "pipe", root.fd], - shell: false, - }, - ); - if (result.status === 44) throw invalidFamilyTopology("descriptor-relative artifact is absent"); - if (result.error || result.status !== 0 || typeof result.stdout !== "string") { - throw invalidFamilyTopology("descriptor-relative artifact read failed"); +const TRUSTED_READ_TIMEOUT_MS = 5_000; + +/** + * One helper process serves every descriptor-relative read of a family walk. + * Both authority roots are passed at spawn (session on fd 3, artifacts on + * fd 4 when present) and selected per request; requests are newline-delimited + * JSON on stdin with one JSON response line each. Any protocol violation + * kills the helper and fails the walk closed. + */ +class TrustedReadSession { + private readonly child: ChildProcess; + private stdoutBuffer = ""; + private failure: Error | undefined; + private pending: + | { resolve: (line: string) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; cap: number } + | undefined; + private queue: Promise = Promise.resolve(); + + constructor(private readonly roots: ManagedRoots) { + const stdio: Array<"pipe" | "ignore" | number> = ["pipe", "pipe", "ignore", roots.session.fd]; + if (roots.artifacts) stdio.push(roots.artifacts.fd); + this.child = spawn( + process.execPath === process.env.PRIME_AGENT_KERNEL_PYTHON + ? process.execPath + : (process.env.PRIME_AGENT_KERNEL_PYTHON ?? "python3"), + ["-I", "-c", OPENAT_READ_HELPER], + { stdio, shell: false }, + ); + helperSpawnCountForTest++; + this.child.stdin?.on("error", (error) => + this.fail(new Error(`catalog openat helper write failed: ${String(error)}`)), + ); + this.child.stdout?.setEncoding("utf8"); + this.child.stdout?.on("data", (chunk: string) => this.handleStdout(chunk)); + this.child.on("error", (error) => this.fail(new Error(`catalog openat helper failed: ${String(error)}`))); + this.child.on("exit", () => this.fail(new Error("catalog openat helper exited"))); } - try { - const wire = JSON.parse(result.stdout) as { data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; + + async read(rawPath: string, maxBytes: number, mode: TrustedReadMode): Promise { + const run = this.queue.then(() => this.readSerialized(rawPath, maxBytes, mode)); + this.queue = run.catch(() => undefined); + return run; + } + + close(): void { + this.fail(new Error("catalog openat helper closed")); + this.child.stdin?.end(); + this.child.kill("SIGKILL"); + } + + private async readSerialized(rawPath: string, maxBytes: number, mode: TrustedReadMode): Promise { + if (!isAbsolute(rawPath) || rawPath !== resolve(rawPath)) + throw invalidFamilyTopology("session path is not canonical"); + const root = [this.roots.session, this.roots.artifacts].find( + (candidate) => candidate && isWithin(candidate.lexical, rawPath), + ); + if (!root) throw invalidFamilyTopology("session path escapes managed roots"); + const suffix = relative(root.lexical, rawPath); + const parts = suffix.split(sep); + if (!suffix || parts.some((part) => !part || part === "." || part === "..")) { + throw invalidFamilyTopology("session path lacks a trusted file component"); + } + beforeTrustedOpenForTest?.(rawPath); + const rootFd = root === this.roots.session ? 3 : 4; + const line = await this.exchange( + JSON.stringify({ parts, limit: maxBytes, mode, root: rootFd }), + maxBytes * 2 + 64 * 1024, + ); + let wire: { error?: unknown; data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; + try { + wire = JSON.parse(line) as typeof wire; + } catch { + this.fail(new Error("catalog openat helper response is invalid")); + throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); + } + if (wire.error === "absent") throw invalidFamilyTopology("descriptor-relative artifact is absent"); if ( + wire.error !== undefined || (mode === "stat" ? wire.data !== undefined : typeof wire.data !== "string") || typeof wire.mtimeMs !== "number" || typeof wire.dev !== "string" || typeof wire.ino !== "string" - ) - throw new Error("invalid"); + ) { + throw invalidFamilyTopology("descriptor-relative artifact read failed"); + } return { path: rawPath, contents: typeof wire.data === "string" ? Buffer.from(wire.data, "base64") : Buffer.alloc(0), @@ -259,8 +316,63 @@ function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number, dev: wire.dev, ino: wire.ino, }; - } catch { - throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); + } + + private exchange(request: string, responseCap: number): Promise { + if (this.failure) + return Promise.reject( + invalidFamilyTopology(`descriptor-relative artifact read failed: ${this.failure.message}`), + ); + return new Promise((resolvePending, rejectPending) => { + const timeout = setTimeout(() => { + this.fail(new Error("catalog openat helper timed out")); + }, TRUSTED_READ_TIMEOUT_MS); + this.pending = { resolve: resolvePending, reject: rejectPending, timeout, cap: responseCap }; + this.child.stdin?.write(`${request}\n`, (error) => { + if (error) this.fail(new Error(`catalog openat helper write failed: ${String(error)}`)); + }); + this.drainStdout(); + }); + } + + private handleStdout(chunk: string): void { + this.stdoutBuffer += chunk; + this.drainStdout(); + } + + private drainStdout(): void { + const pending = this.pending; + if (!pending) { + if (this.stdoutBuffer !== "") this.fail(new Error("catalog openat helper sent an unsolicited response")); + return; + } + const end = this.stdoutBuffer.indexOf("\n"); + if (end === -1) { + if (this.stdoutBuffer.length > pending.cap) this.fail(new Error("catalog openat helper response overflow")); + return; + } + const line = this.stdoutBuffer.slice(0, end); + this.stdoutBuffer = this.stdoutBuffer.slice(end + 1); + this.pending = undefined; + clearTimeout(pending.timeout); + if (line.length > pending.cap) { + this.fail(new Error("catalog openat helper response overflow")); + pending.reject(invalidFamilyTopology("descriptor-relative artifact response is invalid")); + return; + } + pending.resolve(line); + } + + private fail(error: Error): void { + if (this.failure) return; + this.failure = error; + this.child.kill("SIGKILL"); + const pending = this.pending; + this.pending = undefined; + if (pending) { + clearTimeout(pending.timeout); + pending.reject(invalidFamilyTopology(`descriptor-relative artifact read failed: ${error.message}`)); + } } } @@ -270,8 +382,12 @@ function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number, * decision: it comes from the caller's listing when available, otherwise from * an ordinary cached read, and is bound to the header by the id cross-check. */ -async function readTrustedSession(path: string, roots: ManagedRoots, listed?: SessionInfo): Promise { - const trusted = readTrustedFile(path, roots, MAX_SESSION_HEADER_BYTES, "header"); +async function readTrustedSession( + path: string, + reader: TrustedReadSession, + listed?: SessionInfo, +): Promise { + const trusted = await reader.read(path, MAX_SESSION_HEADER_BYTES, "header"); const headerLine = trusted.contents.toString("utf8").split(/\r?\n/, 1)[0]; let header: { type?: unknown; id?: unknown; parentSession?: unknown; rlmDepth?: unknown }; try { @@ -328,11 +444,11 @@ function asTrustedFamilyRoot(trusted: TrustedSession, roots: ManagedRoots): Fami async function readLatestRegistry( path: string, - roots: ManagedRoots, + reader: TrustedReadSession, ): Promise { let contents: string; try { - contents = readTrustedFile(path, roots, MAX_RLM_REGISTRY_BYTES, "read").contents.toString("utf8"); + contents = (await reader.read(path, MAX_RLM_REGISTRY_BYTES, "read")).contents.toString("utf8"); } catch (error) { if ((error as Error).message.includes("descriptor-relative artifact is absent")) return undefined; throw error; @@ -367,11 +483,12 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise(); const ids = new Map(); for (const root of roots) { - const trusted = asTrustedFamilyRoot(await readTrustedSession(root.path, authority, root), authority); + const trusted = asTrustedFamilyRoot(await readTrustedSession(root.path, reader, root), authority); const existingPath = ids.get(trusted.id); if (existingPath && existingPath !== trusted.path) throw invalidFamilyTopology("family contains a duplicate session id"); @@ -388,7 +505,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise MAX_RLM_FAMILY_EDGES) throw invalidFamilyTopology("family edge limit exhausted"); const childPath = entry.sessionFile as string; if (childAncestors.has(childPath)) throw invalidFamilyTopology("family contains a cycle"); - const trustedChild = await readTrustedSession(childPath, authority); + const trustedChild = await readTrustedSession(childPath, reader); if (trustedChild.id !== entry.childId) throw invalidFamilyTopology("registry child id does not match session id"); if (trustedChild.persistedParentPath === undefined) @@ -405,7 +522,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise { rmSync(root, { recursive: true, force: true }); }); + it("serves an entire family walk from a single helper process", async () => { + const { root, sessionDir } = createCatalogFamilyFixture(); + try { + const spawnsBefore = getCatalogHelperSpawnCountForTest(); + await expect(listCatalogFamilySessions(sessionDir)).resolves.toHaveLength(3); + expect(getCatalogHelperSpawnCountForTest()).toBe(spawnsBefore + 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + it("treats an exact name colliding with another session id prefix as ambiguous", () => { const sessions = [ session("named-session-id", "target", "/tmp/by-name.jsonl"), From 450a311c108612ef1647230d7c04f0aa30ff0321 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 13 Aug 2026 16:49:09 +0200 Subject: [PATCH 07/22] fix(daemon): validate registry children against what the daemon actually writes Three walk assumptions did not survive contact with a real profile: - Registry childId is the rlm child id (e.g. "sub-1a2b3c4d"), never the session id, so the identity check 'child.id !== entry.childId' rejected every real registry edge. The trustworthy identity anchor is the child's filename: the session writer names each child file after its header id, so the walk now requires basename(sessionFile) === header id. Registry keying (latest-wins per childId) is unchanged. - A session's child registry lives beside its session dir (/session-artifacts/), not under dirname(sessionFile). The old derivation looked in the wrong place for every artifact-resident parent, silently truncating families at depth 1. - Old writers persisted no rlmDepth on children (429 of 597 live edges in the measured profile). An absent child depth is now derived from the traversed edge; a present-but-contradicting claim still fails closed. Also raise MAX_RLM_REGISTRY_BYTES to 16MB: registry records carry prompts and spawn code, and real registries exceed the old 1MB cap. Synthetic registries in the tests now mirror reality (sub-* childIds, writer-layout registry paths, nested children under the parent's session dir), one legacy equal-id variant remains, and a new end-to-end fixture covers fork seeds plus a two-level registry family. --- .../modes/daemon/daemon-catalog-process.ts | 34 +++-- .../test/daemon-catalog-process.test.ts | 121 ++++++++++++++---- 2 files changed, 119 insertions(+), 36 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 8cc191c5e..08897927e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1,7 +1,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { closeSync, constants, openSync } from "node:fs"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; import { getSessionsDir } from "../../config.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; @@ -68,7 +68,8 @@ interface SavedRlmSubagentRegistryEntry { status?: unknown; } -const MAX_RLM_REGISTRY_BYTES = 1024 * 1024; +// Registry records carry prompts and spawn code; real profiles reach a few MB. +const MAX_RLM_REGISTRY_BYTES = 16 * 1024 * 1024; const MAX_SESSION_HEADER_BYTES = 256 * 1024; const MAX_RLM_REGISTRY_RECORDS = 10_000; const MAX_RLM_FAMILY_EDGES = 10_000; @@ -105,10 +106,11 @@ interface FamilySession extends TrustedSession { } function rlmSubagentRegistryPath(parent: SessionInfo, roots: ManagedRoots): string | undefined { - const parentDir = dirname(parent.path); - const artifactDir = - parentDir === roots.session.lexical ? roots.artifacts?.lexical : join(parentDir, "session-artifacts"); - return artifactDir ? join(artifactDir, parent.id, "rlm-subagents.jsonl") : undefined; + // The session writer keeps a session's child registry beside its session + // dir: /session-artifacts/
. Every such + // location is inside the profile's top-level artifacts root. + if (!roots.artifacts) return undefined; + return join(dirname(dirname(parent.path)), "session-artifacts", parent.id, "rlm-subagents.jsonl"); } function isWithin(root: string, target: string): boolean { @@ -515,18 +517,26 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise { second.appendSessionInfo("second"); const registry = join(dirname(sessionDir), "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); mkdirSync(dirname(registry), { recursive: true }); + // Legacy registry variant where childId happens to equal the session id. writeFileSync( registry, [ @@ -179,13 +181,13 @@ describe("daemon catalog selector resolution", () => { [ { type: "rlm_subagent", - childId: first.getSessionId(), + childId: "sub-first", sessionFile: first.getSessionFile(), status: "completed", }, { type: "rlm_subagent", - childId: second.getSessionId(), + childId: "sub-second", sessionFile: second.getSessionFile(), status: "completed", }, @@ -217,12 +219,14 @@ describe("daemon catalog selector resolution", () => { const makeFixture = (name: string, omitRootDepth = false) => { const root = mkdtempSync(join(tmpdir(), name)); const sessionDir = join(root, "sessions"); + // The writer keeps a session's child registry beside its session dir: + // /session-artifacts/. const registryPath = (parentId: string) => { const parentFile = [rootSession, parent, first, second] .find((manager) => manager.getSessionId() === parentId) ?.getSessionFile(); - return parentFile && dirname(parentFile) !== sessionDir - ? join(dirname(parentFile), "session-artifacts", parentId, "rlm-subagents.jsonl") + return parentFile + ? join(dirname(dirname(parentFile)), "session-artifacts", parentId, "rlm-subagents.jsonl") : join(root, "session-artifacts", parentId, "rlm-subagents.jsonl"); }; const writeRegistry = (parentId: string, entries: unknown[]) => { @@ -255,22 +259,22 @@ describe("daemon catalog selector resolution", () => { ); const first = create( "first", - join(root, "session-artifacts", "parent", "sub-first"), + join(root, "session-artifacts", "root", "sub-parent", "sub-first"), parent.getSessionFile(), 2, ); const second = create( "second", - join(root, "session-artifacts", "parent", "sub-second"), + join(root, "session-artifacts", "root", "sub-parent", "sub-second"), parent.getSessionFile(), 2, ); writeRegistry("root", [ - { type: "rlm_subagent", childId: "parent", sessionFile: parent.getSessionFile(), status: "completed" }, + { type: "rlm_subagent", childId: "sub-parent", sessionFile: parent.getSessionFile(), status: "completed" }, ]); writeRegistry("parent", [ - { type: "rlm_subagent", childId: "first", sessionFile: first.getSessionFile(), status: "completed" }, - { type: "rlm_subagent", childId: "second", sessionFile: second.getSessionFile(), status: "completed" }, + { type: "rlm_subagent", childId: "sub-first", sessionFile: first.getSessionFile(), status: "completed" }, + { type: "rlm_subagent", childId: "sub-second", sessionFile: second.getSessionFile(), status: "completed" }, ]); return { root, sessionDir, rootSession, parent, first, second, registryPath, writeRegistry }; }; @@ -289,16 +293,16 @@ describe("daemon catalog selector resolution", () => { const cases: Array<[string, (fixture: ReturnType) => void]> = [ [ - "id mismatch", - (fixture) => + "basename mismatch", + (fixture) => { + // A session file whose name does not match its header id is not a + // writer-produced child and must not be authorized. + const alias = join(dirname(fixture.first.getSessionFile()!), "impostor.jsonl"); + writeFileSync(alias, readFileSync(fixture.first.getSessionFile()!)); fixture.writeRegistry("parent", [ - { - type: "rlm_subagent", - childId: "wrong", - sessionFile: fixture.first.getSessionFile(), - status: "completed", - }, - ]), + { type: "rlm_subagent", childId: "sub-first", sessionFile: alias, status: "completed" }, + ]); + }, ], [ "parent mismatch", @@ -415,7 +419,7 @@ describe("daemon catalog selector resolution", () => { registry, JSON.stringify({ type: "rlm_subagent", - childId: "child", + childId: "sub-child", sessionFile: child.getSessionFile(), status: "completed", }), @@ -554,17 +558,26 @@ describe("daemon catalog selector resolution", () => { mkdirSync(dirname(registry), { recursive: true }); writeFileSync( registry, - JSON.stringify({ type: "rlm_subagent", childId: "child", sessionFile: childFile, status: "completed" }), + JSON.stringify({ type: "rlm_subagent", childId: "sub-child", sessionFile: childFile, status: "completed" }), ); return sessionDir; }; + // Old writers omitted child rlmDepth entirely: the edge supplies it. await expect( listCatalogFamilySessions( make((header) => { delete header.rlmDepth; }), ), - ).rejects.toThrow("child lacks a persisted depth"); + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "child", rlmDepth: 1 })])); + // A present-but-contradicting depth claim is still corruption. + await expect( + listCatalogFamilySessions( + make((header) => { + header.rlmDepth = 7; + }), + ), + ).rejects.toThrow("child depth does not equal parent depth plus one"); await expect( listCatalogFamilySessions( make((header) => { @@ -658,7 +671,7 @@ describe("daemon catalog selector resolution", () => { registry, JSON.stringify({ type: "rlm_subagent", - childId: "child", + childId: "sub-child", sessionFile: child.getSessionFile(), status: "completed", }), @@ -679,6 +692,66 @@ describe("daemon catalog selector resolution", () => { expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); }); + it("walks a realistic profile: fork seeds, sub-* registry ids, two nested levels", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-realistic-")); + const sessionDir = join(root, "sessions"); + const write = (path: string, header: Record) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + `${JSON.stringify({ type: "session", version: 10, timestamp: "2026-01-01T00:00:00.000Z", cwd: "/tmp/p", ...header })}\n`, + ); + }; + // The writer stores a session's child registry beside its session dir: + // flat roots use the profile's top-level session-artifacts; artifact-resident + // children get a nested session-artifacts dir beside their sub-* dir. + const writeRegistry = (parentFile: string, parentId: string, entries: Array>) => { + const path = join(dirname(dirname(parentFile)), "session-artifacts", parentId, "rlm-subagents.jsonl"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, entries.map((entry) => JSON.stringify({ type: "rlm_subagent", ...entry })).join("\n")); + }; + // Flat roots: a plain session plus forks in both real-world header shapes. + const rootFile = join(sessionDir, "root-uuid.jsonl"); + write(rootFile, { id: "root-uuid", rlmDepth: 0 }); + write(join(sessionDir, "fork-depth-uuid.jsonl"), { id: "fork-depth-uuid", parentSession: rootFile, rlmDepth: 0 }); + write(join(sessionDir, "fork-nodepth-uuid.jsonl"), { id: "fork-nodepth-uuid", parentSession: rootFile }); + // Depth-1 child in the root's artifact tree; its own registry lives at the + // top-level artifacts dir under its session id, as the writer stores it. + const childFile = join(root, "session-artifacts", "root-uuid", "sub-aaaa1111", "child-uuid.jsonl"); + write(childFile, { id: "child-uuid", parentSession: rootFile, rlmDepth: 1 }); + const grandFile = join( + root, + "session-artifacts", + "root-uuid", + "sub-aaaa1111", + "sub-bbbb2222", + "grand-uuid.jsonl", + ); + write(grandFile, { id: "grand-uuid", parentSession: childFile, rlmDepth: 2 }); + writeRegistry(rootFile, "root-uuid", [{ childId: "sub-aaaa1111", sessionFile: childFile, status: "running" }]); + writeRegistry(childFile, "child-uuid", [ + { childId: "sub-bbbb2222", sessionFile: grandFile, status: "completed" }, + ]); + + const family = await listCatalogFamilySessions(sessionDir); + expect(family).toHaveLength(5); + expect(family).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "root-uuid", rlmDepth: 0 }), + expect.objectContaining({ id: "fork-depth-uuid", rlmDepth: 0 }), + expect.objectContaining({ id: "fork-nodepth-uuid", rlmDepth: 0 }), + expect.objectContaining({ id: "child-uuid", rlmDepth: 1 }), + expect.objectContaining({ id: "grand-uuid", rlmDepth: 2 }), + ]), + ); + // Fork ancestry must not surface as rlm parent edges on the seed rows. + for (const fork of ["fork-depth-uuid", "fork-nodepth-uuid"]) { + expect(family.find((info) => info.id === fork)?.parentSessionPath).toBeUndefined(); + } + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + rmSync(root, { recursive: true, force: true }); + }); + it("treats an exact name colliding with another session id prefix as ambiguous", () => { const sessions = [ session("named-session-id", "target", "/tmp/by-name.jsonl"), From e4b8f9406905b59362194573ab10afded083ddca Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 11:32:24 -0700 Subject: [PATCH 08/22] fix(daemon): harden catalog helper and child topology --- .../coding-agent/src/core/session-manager.ts | 43 +++++ .../modes/daemon/daemon-catalog-process.ts | 155 +++++++++++++++--- .../test/daemon-catalog-process.test.ts | 79 +++++++-- 3 files changed, 244 insertions(+), 33 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e4075e774..e2d1a05e2 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1018,6 +1018,49 @@ export async function readSessionInfo(filePath: string): Promise; + if ( + header.type !== "session" || + typeof header.id !== "string" || + header.id === "" || + (typeof header.cwd !== "string" && header.cwd !== undefined) || + (typeof header.parentSession !== "string" && header.parentSession !== undefined) || + (header.rlmDepth !== undefined && !isValidRlmDepth(header.rlmDepth)) + ) { + return null; + } + return { + path: filePath, + id: header.id, + cwd: header.cwd ?? "", + ...(header.parentSession !== undefined ? { parentSessionPath: header.parentSession } : {}), + rlmDepth: header.rlmDepth ?? 0, + created: new Date(typeof header.timestamp === "string" ? header.timestamp : 0), + modified: new Date(metadata.mtimeMs), + messageCount: 0, + firstMessage: "(no messages)", + allMessagesText: "", + }; + } catch { + return null; + } +} + /** Parse catalog-authorized bytes without reopening their pathname. */ export async function readSessionInfoFromBuffer( filePath: string, diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 08897927e..68bd02ab6 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -6,7 +6,12 @@ import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli import { getSessionsDir } from "../../config.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import { deleteSessionFile } from "../../core/session-file-actions.js"; -import { readSessionInfo, type SessionInfo, SessionManager } from "../../core/session-manager.js"; +import { + readSessionHeaderInfoFromBuffer, + readSessionInfo, + type SessionInfo, + SessionManager, +} from "../../core/session-manager.js"; export const DAEMON_CATALOG_ROLE_ENV = "PRIME_AGENT_INTERNAL_DAEMON_CATALOG"; @@ -98,6 +103,7 @@ interface TrustedSession extends SessionInfo { /** The header claims are intentionally separate from SessionInfo's legacy fallback. */ persistedDepth?: number; persistedParentPath?: string; + persistedVersion?: number; } /** A family member whose depth has been verified against the walk. */ @@ -224,6 +230,13 @@ export function setCatalogBeforeTrustedOpenForTest(hook: ((path: string) => void beforeTrustedOpenForTest = hook; } +/** Test-only seam runs after a descriptor-bound header read, before metadata. */ +let afterTrustedHeaderForTest: ((path: string) => void) | undefined; +/** @internal */ +export function setCatalogAfterTrustedHeaderForTest(hook: ((path: string) => void) | undefined): void { + afterTrustedHeaderForTest = hook; +} + type TrustedReadMode = "read" | "header" | "stat"; const TRUSTED_READ_TIMEOUT_MS = 5_000; @@ -243,10 +256,22 @@ class TrustedReadSession { | { resolve: (line: string) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; cap: number } | undefined; private queue: Promise = Promise.resolve(); + private stdinEnded = false; + private exit: { code: number | null; signal: NodeJS.Signals | null } | undefined; + private readonly stdoutFinished: Promise; + private resolveStdoutFinished!: () => void; + private readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + private resolveExited!: (result: { code: number | null; signal: NodeJS.Signals | null }) => void; constructor(private readonly roots: ManagedRoots) { const stdio: Array<"pipe" | "ignore" | number> = ["pipe", "pipe", "ignore", roots.session.fd]; if (roots.artifacts) stdio.push(roots.artifacts.fd); + this.stdoutFinished = new Promise((resolveFinished) => { + this.resolveStdoutFinished = resolveFinished; + }); + this.exited = new Promise((resolveExited) => { + this.resolveExited = resolveExited; + }); this.child = spawn( process.execPath === process.env.PRIME_AGENT_KERNEL_PYTHON ? process.execPath @@ -260,8 +285,18 @@ class TrustedReadSession { ); this.child.stdout?.setEncoding("utf8"); this.child.stdout?.on("data", (chunk: string) => this.handleStdout(chunk)); + this.child.stdout?.on("end", () => { + this.resolveStdoutFinished(); + if (this.stdoutBuffer !== "") this.fail(new Error("catalog openat helper left residual output")); + }); this.child.on("error", (error) => this.fail(new Error(`catalog openat helper failed: ${String(error)}`))); - this.child.on("exit", () => this.fail(new Error("catalog openat helper exited"))); + this.child.on("exit", (code, signal) => { + this.exit = { code, signal }; + this.resolveExited(this.exit); + if (!this.stdinEnded || code !== 0 || signal !== null) { + this.fail(new Error(`catalog openat helper exited (${signal ?? code ?? "unknown"})`)); + } + }); } async read(rawPath: string, maxBytes: number, mode: TrustedReadMode): Promise { @@ -270,10 +305,22 @@ class TrustedReadSession { return run; } - close(): void { - this.fail(new Error("catalog openat helper closed")); + /** End input and require the helper to finish silently and successfully. */ + async close(): Promise { + await this.queue; + if (this.failure) return; + if (this.pending || this.stdoutBuffer !== "") { + this.fail(new Error("catalog openat helper has residual output")); + return; + } + this.stdinEnded = true; this.child.stdin?.end(); - this.child.kill("SIGKILL"); + const [exit] = await Promise.all([this.exited, this.stdoutFinished]); + if (this.failure || exit.code !== 0 || exit.signal !== null || this.stdoutBuffer !== "") { + const failure = this.failure ?? new Error("catalog openat helper did not exit cleanly"); + if (!this.failure) this.fail(failure); + throw invalidFamilyTopology(`descriptor-relative artifact read failed: ${failure.message}`); + } } private async readSerialized(rawPath: string, maxBytes: number, mode: TrustedReadMode): Promise { @@ -321,14 +368,17 @@ class TrustedReadSession { } private exchange(request: string, responseCap: number): Promise { - if (this.failure) + if (this.failure || this.stdinEnded) return Promise.reject( - invalidFamilyTopology(`descriptor-relative artifact read failed: ${this.failure.message}`), + invalidFamilyTopology( + `descriptor-relative artifact read failed: ${this.failure?.message ?? "helper closed"}`, + ), ); return new Promise((resolvePending, rejectPending) => { - const timeout = setTimeout(() => { - this.fail(new Error("catalog openat helper timed out")); - }, TRUSTED_READ_TIMEOUT_MS); + const timeout = setTimeout( + () => this.fail(new Error("catalog openat helper timed out")), + TRUSTED_READ_TIMEOUT_MS, + ); this.pending = { resolve: resolvePending, reject: rejectPending, timeout, cap: responseCap }; this.child.stdin?.write(`${request}\n`, (error) => { if (error) this.fail(new Error(`catalog openat helper write failed: ${String(error)}`)); @@ -354,11 +404,18 @@ class TrustedReadSession { return; } const line = this.stdoutBuffer.slice(0, end); - this.stdoutBuffer = this.stdoutBuffer.slice(end + 1); + const suffix = this.stdoutBuffer.slice(end + 1); + this.stdoutBuffer = ""; this.pending = undefined; clearTimeout(pending.timeout); - if (line.length > pending.cap) { - this.fail(new Error("catalog openat helper response overflow")); + if (line.length > pending.cap || suffix !== "") { + this.fail( + new Error( + line.length > pending.cap + ? "catalog openat helper response overflow" + : "catalog openat helper sent a shifted response", + ), + ); pending.reject(invalidFamilyTopology("descriptor-relative artifact response is invalid")); return; } @@ -368,6 +425,7 @@ class TrustedReadSession { private fail(error: Error): void { if (this.failure) return; this.failure = error; + // Failure is deliberately terminal. Clean shutdown is handled only by close(). this.child.kill("SIGKILL"); const pending = this.pending; this.pending = undefined; @@ -391,7 +449,15 @@ async function readTrustedSession( ): Promise { const trusted = await reader.read(path, MAX_SESSION_HEADER_BYTES, "header"); const headerLine = trusted.contents.toString("utf8").split(/\r?\n/, 1)[0]; - let header: { type?: unknown; id?: unknown; parentSession?: unknown; rlmDepth?: unknown }; + let header: { + type?: unknown; + id?: unknown; + parentSession?: unknown; + rlmDepth?: unknown; + version?: unknown; + cwd?: unknown; + timestamp?: unknown; + }; try { header = JSON.parse(headerLine ?? "") as typeof header; } catch { @@ -399,20 +465,32 @@ async function readTrustedSession( } const hasParent = header.parentSession !== undefined; const hasDepth = Number.isSafeInteger(header.rlmDepth) && (header.rlmDepth as number) >= 0; + const hasVersion = Number.isSafeInteger(header.version) && (header.version as number) >= 1; if ( header.type !== "session" || typeof header.id !== "string" || header.id === "" || (hasParent && typeof header.parentSession !== "string") || (hasParent && header.parentSession === "") || - (header.rlmDepth !== undefined && !hasDepth) + (header.rlmDepth !== undefined && !hasDepth) || + (header.version !== undefined && !hasVersion) ) throw invalidFamilyTopology("session header lacks trustworthy topology claims"); const persistedDepth = hasDepth ? (header.rlmDepth as number) : undefined; - const info = listed?.path === path ? listed : await readSessionInfo(path); + afterTrustedHeaderForTest?.(path); + // Detect a post-header swap without reading a body or reopening through the + // legacy scanner. The descriptor-relative stat is a second identity binding. + const bound = await reader.read(path, 0, "stat"); + if (bound.dev !== trusted.dev || bound.ino !== trusted.ino) + throw invalidFamilyTopology("session changed after its trusted header read"); + // An unlisted registry child must never reach the legacy pathname scanner: + // it may recursively read parent paths and can observe a replacement after + // this descriptor-bound open. Header metadata is intentionally shallow. + const info = + listed?.path === path + ? listed + : readSessionHeaderInfoFromBuffer(path, trusted.contents, { mtimeMs: trusted.mtimeMs }); if (!info || info.id !== header.id) throw invalidFamilyTopology("session metadata does not match its header"); - // Topology fields are always the header's claims; the display read may not - // contradict them in the returned catalog row. return { ...info, path, @@ -420,6 +498,7 @@ async function readTrustedSession( parentSessionPath: hasParent ? (header.parentSession as string) : undefined, ...(persistedDepth !== undefined ? { persistedDepth } : {}), ...(hasParent ? { persistedParentPath: header.parentSession as string } : {}), + ...(hasVersion ? { persistedVersion: header.version as number } : {}), }; } @@ -517,11 +596,38 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise/sub-*/.jsonl. + // Registry keys name that immediate child directory, so neither a + // nested sessions root nor a re-keyed sibling is reachable. + const modernWriterPath = + childId.startsWith("sub-") && + childId === basename(childSessionDir) && + childSessionDir === join(writerChildrenRoot, childId); + // Versioned v2 registry records used the session id as their key but + // still allocated a direct sub-* writer directory. Require both facts; + // an equal-id synthetic record without that provenance is not trusted. + const legacyWriterPath = + trustedChild.persistedVersion !== undefined && + trustedChild.persistedVersion < 3 && + childId === trustedChild.id && + basename(childSessionDir).startsWith("sub-") && + dirname(childSessionDir) === writerChildrenRoot; + if (!modernWriterPath && !legacyWriterPath) { + throw invalidFamilyTopology("registry child is outside the parent writer artifact layout"); + } if (trustedChild.persistedParentPath === undefined) throw invalidFamilyTopology("child lacks a persisted parent path"); // Old writers persisted no rlmDepth on children: an absent claim is @@ -559,8 +665,11 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise { diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index a9b397f39..df913eb80 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -18,6 +18,7 @@ import { listCatalogFamilySessions, listSavedSessionSiblings, resolveCatalogSessionMatch, + setCatalogAfterTrustedHeaderForTest, setCatalogBeforeTrustedOpenForTest, } from "../src/modes/daemon/daemon-catalog-process.js"; @@ -99,7 +100,14 @@ describe("daemon catalog selector resolution", () => { second.appendSessionInfo("second"); const registry = join(dirname(sessionDir), "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); mkdirSync(dirname(registry), { recursive: true }); - // Legacy registry variant where childId happens to equal the session id. + // Version-2 session headers prove the old registry key shape, where a + // record was keyed by the persisted session id while its directory retained + // a sub-* run id. + for (const child of [first, second]) { + const file = child.getSessionFile()!; + const [header, ...entries] = readFileSync(file, "utf8").trimEnd().split(/\r?\n/); + writeFileSync(file, `${JSON.stringify({ ...JSON.parse(header!), version: 2 })}\n${entries.join("\n")}\n`); + } writeFileSync( registry, [ @@ -121,8 +129,8 @@ describe("daemon catalog selector resolution", () => { ); await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ - expect.objectContaining({ id: first.getSessionId(), name: "first" }), - expect.objectContaining({ id: second.getSessionId(), name: "second" }), + expect.objectContaining({ id: first.getSessionId() }), + expect.objectContaining({ id: second.getSessionId() }), ]); }); @@ -132,9 +140,9 @@ describe("daemon catalog selector resolution", () => { await withDefaultSessionDir(sessionDir, async () => { await expect(listCatalogFamilySessions()).resolves.toEqual( expect.arrayContaining([ - expect.objectContaining({ id: parent.getSessionId(), name: "parent" }), - expect.objectContaining({ id: first.getSessionId(), name: "first" }), - expect.objectContaining({ id: second.getSessionId(), name: "second" }), + expect.objectContaining({ id: parent.getSessionId() }), + expect.objectContaining({ id: first.getSessionId() }), + expect.objectContaining({ id: second.getSessionId() }), ]), ); }); @@ -149,8 +157,8 @@ describe("daemon catalog selector resolution", () => { try { await withDefaultSessionDir(sessionDir, async () => { await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ - expect.objectContaining({ id: first.getSessionId(), name: "first" }), - expect.objectContaining({ id: second.getSessionId(), name: "second" }), + expect.objectContaining({ id: first.getSessionId() }), + expect.objectContaining({ id: second.getSessionId() }), ]); }); } finally { @@ -197,8 +205,8 @@ describe("daemon catalog selector resolution", () => { ); await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ - expect.objectContaining({ id: first.getSessionId(), name: "first" }), - expect.objectContaining({ id: second.getSessionId(), name: "second" }), + expect.objectContaining({ id: first.getSessionId() }), + expect.objectContaining({ id: second.getSessionId() }), ]); }); @@ -752,6 +760,57 @@ describe("daemon catalog selector resolution", () => { rmSync(root, { recursive: true, force: true }); }); + it("binds registry child ids to direct writer directories and detects post-header replacement", async () => { + const make = () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-identity-binding-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const childDir = join(root, "session-artifacts", "parent", "sub-real"); + const child = SessionManager.create(root, childDir); + child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + child.appendSessionInfo("child"); + const registry = join(root, "session-artifacts", "parent", "rlm-subagents.jsonl"); + mkdirSync(dirname(registry), { recursive: true }); + const writeRegistry = (childId: string, sessionFile = child.getSessionFile()!) => + writeFileSync( + registry, + JSON.stringify({ type: "rlm_subagent", childId, sessionFile, status: "completed" }), + ); + writeRegistry("sub-real"); + return { root, sessionDir, child, childDir, writeRegistry }; + }; + const spoofed = make(); + spoofed.writeRegistry("sub-spoofed"); + await expect(listCatalogFamilySessions(spoofed.sessionDir)).rejects.toThrow("writer artifact layout"); + rmSync(spoofed.root, { recursive: true, force: true }); + + const nested = make(); + const nestedPath = join(nested.sessionDir, "sub-real", "child.jsonl"); + mkdirSync(dirname(nestedPath), { recursive: true }); + writeFileSync(nestedPath, readFileSync(nested.child.getSessionFile()!)); + nested.writeRegistry("sub-real", nestedPath); + await expect(listCatalogFamilySessions(nested.sessionDir)).rejects.toThrow("writer artifact layout"); + rmSync(nested.root, { recursive: true, force: true }); + + const replacement = make(); + let swapped = false; + setCatalogAfterTrustedHeaderForTest((path) => { + if (swapped || path !== replacement.child.getSessionFile()) return; + swapped = true; + const moved = `${path}.old`; + renameSync(path, moved); + writeFileSync(path, `${readFileSync(moved, "utf8").split(/\r?\n/, 1)[0]}\n${"x".repeat(2 * 1024 * 1024)}\n`); + }); + await expect(listCatalogFamilySessions(replacement.sessionDir)).rejects.toThrow( + "session changed after its trusted header read", + ); + setCatalogAfterTrustedHeaderForTest(undefined); + rmSync(replacement.root, { recursive: true, force: true }); + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + it("treats an exact name colliding with another session id prefix as ambiguous", () => { const sessions = [ session("named-session-id", "target", "/tmp/by-name.jsonl"), From b729c66911e4260418954f5c67080da848e44fa0 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 11:33:23 -0700 Subject: [PATCH 09/22] fix(daemon): bind catalog helper responses to requests --- .../modes/daemon/daemon-catalog-process.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 68bd02ab6..dcf15b65b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -182,7 +182,8 @@ def flags(directory=False): if directory: value|=os.O_DIRECTORY return value def serve(req): - parts=req.get("parts"); limit=req.get("limit"); mode=req.get("mode"); root=req.get("root") + request_id=req.get("id"); parts=req.get("parts"); limit=req.get("limit"); mode=req.get("mode"); root=req.get("root") + if not isinstance(request_id,str) or not request_id or len(request_id)>128: reject() if mode not in ("read","header","stat") or root not in (3,4): reject() if not isinstance(parts,list) or not parts or not isinstance(limit,int) or limit<0 or limit>MAX: reject() if any(not isinstance(p,str) or not p or p in (".","..") or "/" in p or "\\" in p for p in parts): reject() @@ -217,9 +218,12 @@ while True: line=sys.stdin.buffer.readline(131073) if not line: break if len(line)>131072 or not line.endswith(b"\n"): sys.exit(1) - try: response=serve(json.loads(line)) + request=None + try: + request=json.loads(line); response=serve(request) except FileNotFoundError: response={"error":"absent"} except Exception: response={"error":"failed"} + response["id"]=request.get("id") if isinstance(request,dict) else None sys.stdout.write(json.dumps(response,separators=(",",":"))+"\n"); sys.stdout.flush() `; @@ -308,10 +312,17 @@ class TrustedReadSession { /** End input and require the helper to finish silently and successfully. */ async close(): Promise { await this.queue; - if (this.failure) return; + if (this.failure) { + // fail() sends SIGKILL only; wait before releasing the authority FDs. + await Promise.all([this.exited, this.stdoutFinished]); + return; + } if (this.pending || this.stdoutBuffer !== "") { this.fail(new Error("catalog openat helper has residual output")); - return; + await Promise.all([this.exited, this.stdoutFinished]); + throw invalidFamilyTopology( + "descriptor-relative artifact read failed: catalog openat helper has residual output", + ); } this.stdinEnded = true; this.child.stdin?.end(); @@ -337,17 +348,22 @@ class TrustedReadSession { } beforeTrustedOpenForTest?.(rawPath); const rootFd = root === this.roots.session ? 3 : 4; + const requestId = randomUUID(); const line = await this.exchange( - JSON.stringify({ parts, limit: maxBytes, mode, root: rootFd }), + JSON.stringify({ id: requestId, parts, limit: maxBytes, mode, root: rootFd }), maxBytes * 2 + 64 * 1024, ); - let wire: { error?: unknown; data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; + let wire: { id?: unknown; error?: unknown; data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; try { wire = JSON.parse(line) as typeof wire; } catch { this.fail(new Error("catalog openat helper response is invalid")); throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); } + if (wire.id !== requestId) { + this.fail(new Error("catalog openat helper response id is shifted")); + throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); + } if (wire.error === "absent") throw invalidFamilyTopology("descriptor-relative artifact is absent"); if ( wire.error !== undefined || From 47929b9b6e607d87c8198dccc14800f7512e39cc Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 12:01:31 -0700 Subject: [PATCH 10/22] fix(daemon): fail closed on catalog helper termination --- .../modes/daemon/daemon-catalog-process.ts | 117 ++++++++++++------ .../test/daemon-catalog-process.test.ts | 87 ++++++++++++- 2 files changed, 159 insertions(+), 45 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index dcf15b65b..aa2d15f3f 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -241,6 +241,13 @@ export function setCatalogAfterTrustedHeaderForTest(hook: ((path: string) => voi afterTrustedHeaderForTest = hook; } +/** Test-only helper launch override for terminal-path protocol tests. */ +let catalogHelperLaunchForTest: { command: string; args: string[] } | undefined; +/** @internal */ +export function setCatalogHelperLaunchForTest(launch: { command: string; args: string[] } | undefined): void { + catalogHelperLaunchForTest = launch; +} + type TrustedReadMode = "read" | "header" | "stat"; const TRUSTED_READ_TIMEOUT_MS = 5_000; @@ -264,8 +271,10 @@ class TrustedReadSession { private exit: { code: number | null; signal: NodeJS.Signals | null } | undefined; private readonly stdoutFinished: Promise; private resolveStdoutFinished!: () => void; - private readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; - private resolveExited!: (result: { code: number | null; signal: NodeJS.Signals | null }) => void; + /** Resolves for every terminal child-process path, including failed spawn. */ + private readonly terminated: Promise; + private resolveTerminated!: () => void; + private terminationSettled = false; constructor(private readonly roots: ManagedRoots) { const stdio: Array<"pipe" | "ignore" | number> = ["pipe", "pipe", "ignore", roots.session.fd]; @@ -273,16 +282,17 @@ class TrustedReadSession { this.stdoutFinished = new Promise((resolveFinished) => { this.resolveStdoutFinished = resolveFinished; }); - this.exited = new Promise((resolveExited) => { - this.resolveExited = resolveExited; + this.terminated = new Promise((resolveTerminated) => { + this.resolveTerminated = resolveTerminated; }); - this.child = spawn( - process.execPath === process.env.PRIME_AGENT_KERNEL_PYTHON - ? process.execPath - : (process.env.PRIME_AGENT_KERNEL_PYTHON ?? "python3"), - ["-I", "-c", OPENAT_READ_HELPER], - { stdio, shell: false }, - ); + const launch = catalogHelperLaunchForTest ?? { + command: + process.execPath === process.env.PRIME_AGENT_KERNEL_PYTHON + ? process.execPath + : (process.env.PRIME_AGENT_KERNEL_PYTHON ?? "python3"), + args: ["-I", "-c", OPENAT_READ_HELPER], + }; + this.child = spawn(launch.command, launch.args, { stdio, shell: false }); helperSpawnCountForTest++; this.child.stdin?.on("error", (error) => this.fail(new Error(`catalog openat helper write failed: ${String(error)}`)), @@ -293,14 +303,26 @@ class TrustedReadSession { this.resolveStdoutFinished(); if (this.stdoutBuffer !== "") this.fail(new Error("catalog openat helper left residual output")); }); - this.child.on("error", (error) => this.fail(new Error(`catalog openat helper failed: ${String(error)}`))); + this.child.on("error", (error) => { + // spawn() reports an invalid executable with error and close, but no exit. + // Settle cleanup here so failed spawn cannot strand authority FDs. + this.resolveStdoutFinished(); + this.settleTermination(); + this.fail(new Error(`catalog openat helper failed: ${String(error)}`)); + }); this.child.on("exit", (code, signal) => { this.exit = { code, signal }; - this.resolveExited(this.exit); + this.settleTermination(); if (!this.stdinEnded || code !== 0 || signal !== null) { this.fail(new Error(`catalog openat helper exited (${signal ?? code ?? "unknown"})`)); } }); + this.child.on("close", (code, signal) => { + this.exit ??= { code, signal }; + // close is terminal for failed spawn and confirms stdio is complete. + this.resolveStdoutFinished(); + this.settleTermination(); + }); } async read(rawPath: string, maxBytes: number, mode: TrustedReadMode): Promise { @@ -313,27 +335,38 @@ class TrustedReadSession { async close(): Promise { await this.queue; if (this.failure) { - // fail() sends SIGKILL only; wait before releasing the authority FDs. - await Promise.all([this.exited, this.stdoutFinished]); - return; + await this.awaitCleanup(); + throw this.closeError(this.failure); } if (this.pending || this.stdoutBuffer !== "") { this.fail(new Error("catalog openat helper has residual output")); - await Promise.all([this.exited, this.stdoutFinished]); - throw invalidFamilyTopology( - "descriptor-relative artifact read failed: catalog openat helper has residual output", - ); + await this.awaitCleanup(); + throw this.closeError(this.failure!); } this.stdinEnded = true; this.child.stdin?.end(); - const [exit] = await Promise.all([this.exited, this.stdoutFinished]); - if (this.failure || exit.code !== 0 || exit.signal !== null || this.stdoutBuffer !== "") { + await this.awaitCleanup(); + if (this.failure || this.exit?.code !== 0 || this.exit?.signal !== null || this.stdoutBuffer !== "") { const failure = this.failure ?? new Error("catalog openat helper did not exit cleanly"); if (!this.failure) this.fail(failure); - throw invalidFamilyTopology(`descriptor-relative artifact read failed: ${failure.message}`); + throw this.closeError(failure); } } + private settleTermination(): void { + if (this.terminationSettled) return; + this.terminationSettled = true; + this.resolveTerminated(); + } + + private async awaitCleanup(): Promise { + await Promise.all([this.terminated, this.stdoutFinished]); + } + + private closeError(failure: Error): Error { + return invalidFamilyTopology(`descriptor-relative artifact read failed: ${failure.message}`); + } + private async readSerialized(rawPath: string, maxBytes: number, mode: TrustedReadMode): Promise { if (!isAbsolute(rawPath) || rawPath !== resolve(rawPath)) throw invalidFamilyTopology("session path is not canonical"); @@ -581,6 +614,8 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise(); const ids = new Map(); @@ -632,16 +667,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise { const family = await listCatalogFamilySessions(sessionDir); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index df913eb80..b20f0b6d2 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -20,6 +20,7 @@ import { resolveCatalogSessionMatch, setCatalogAfterTrustedHeaderForTest, setCatalogBeforeTrustedOpenForTest, + setCatalogHelperLaunchForTest, } from "../src/modes/daemon/daemon-catalog-process.js"; function session(id: string, name: string | undefined, path: string): SessionInfo { @@ -100,9 +101,8 @@ describe("daemon catalog selector resolution", () => { second.appendSessionInfo("second"); const registry = join(dirname(sessionDir), "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); mkdirSync(dirname(registry), { recursive: true }); - // Version-2 session headers prove the old registry key shape, where a - // record was keyed by the persisted session id while its directory retained - // a sub-* run id. + // Reader compatibility for v2 headers does not relax registry provenance: + // childId remains the writer's direct sub-* directory name. for (const child of [first, second]) { const file = child.getSessionFile()!; const [header, ...entries] = readFileSync(file, "utf8").trimEnd().split(/\r?\n/); @@ -113,13 +113,13 @@ describe("daemon catalog selector resolution", () => { [ { type: "rlm_subagent", - childId: first.getSessionId(), + childId: "sub-first", sessionFile: first.getSessionFile(), status: "completed", }, { type: "rlm_subagent", - childId: second.getSessionId(), + childId: "sub-second", sessionFile: second.getSessionFile(), status: "completed", }, @@ -700,6 +700,83 @@ describe("daemon catalog selector resolution", () => { expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); }); + it("rejects promptly and releases authority FDs when the helper executable cannot spawn", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-helper-spawn-error-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const baseline = getOpenCatalogAuthorityFdCountForTest(); + setCatalogHelperLaunchForTest({ command: join(root, "does-not-exist"), args: [] }); + try { + await expect( + Promise.race([ + listCatalogFamilySessions(sessionDir), + new Promise((_, reject) => setTimeout(() => reject(new Error("helper spawn hung")), 1_000)), + ]), + ).rejects.toThrow("Invalid RLM artifact family topology"); + } finally { + setCatalogHelperLaunchForTest(undefined); + rmSync(root, { recursive: true, force: true }); + } + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(baseline); + }); + + it("rejects the family when the helper writes delayed unsolicited stdout after its final response", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-helper-delayed-stdout-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const header = readFileSync(parent.getSessionFile()!, "utf8").split(/\r?\n/, 1)[0]!; + const helper = String.raw`let input=""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", chunk => { + input += chunk; + while (true) { + const end=input.indexOf("\n"); if (end<0) break; + const request=JSON.parse(input.slice(0,end)); input=input.slice(end+1); + const payload=request.mode === "header" ? { id:request.id, data:${JSON.stringify(Buffer.from(header).toString("base64"))}, mtimeMs:0, dev:"1", ino:"1" } : { id:request.id, mtimeMs:0, dev:"1", ino:"1" }; + process.stdout.write(JSON.stringify(payload)+"\n"); + if (request.mode === "stat") setTimeout(() => process.stdout.write("{\"unsolicited\":true}\n"), 25); + } +});`; + const baseline = getOpenCatalogAuthorityFdCountForTest(); + setCatalogHelperLaunchForTest({ command: process.execPath, args: ["-e", helper] }); + try { + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + } finally { + setCatalogHelperLaunchForTest(undefined); + rmSync(root, { recursive: true, force: true }); + } + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(baseline); + }); + + it("rejects a v2 header whose registry rekeys the child by session id", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-v2-rekey-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-real")); + child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + child.appendSessionInfo("child"); + const childFile = child.getSessionFile()!; + const [header, ...entries] = readFileSync(childFile, "utf8").trimEnd().split(/\r?\n/); + writeFileSync(childFile, `${JSON.stringify({ ...JSON.parse(header!), version: 2 })}\n${entries.join("\n")}\n`); + const registry = join(root, "session-artifacts", "parent", "rlm-subagents.jsonl"); + mkdirSync(dirname(registry), { recursive: true }); + writeFileSync( + registry, + JSON.stringify({ type: "rlm_subagent", childId: "child", sessionFile: childFile, status: "completed" }), + ); + try { + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("writer artifact layout"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("walks a realistic profile: fork seeds, sub-* registry ids, two nested levels", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-realistic-")); const sessionDir = join(root, "sessions"); From 87a84dae143745e17c4979d101f6a88d9938808e Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 12:24:32 -0700 Subject: [PATCH 11/22] fix(daemon): stream trusted catalog metadata --- .../modes/daemon/daemon-catalog-process.ts | 240 +++++++++++++++--- .../test/daemon-catalog-process.test.ts | 186 ++++++++++---- 2 files changed, 345 insertions(+), 81 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index aa2d15f3f..c4352ed7f 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1,6 +1,6 @@ import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { closeSync, constants, openSync } from "node:fs"; +import { closeSync, constants, openSync, readdirSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; import { getSessionsDir } from "../../config.js"; @@ -99,6 +99,20 @@ interface TrustedFile { ino: string; } +interface TrustedSessionMetadata { + messageCount: number; + firstMessage: string; + allMessagesText: string; + modifiedMs: number; + name?: string; + state?: SessionInfo["state"]; + agentStatus?: SessionInfo["agentStatus"]; +} + +interface TrustedMetadataFile extends TrustedFile { + metadata: TrustedSessionMetadata; +} + interface TrustedSession extends SessionInfo { /** The header claims are intentionally separate from SessionInfo's legacy fallback. */ persistedDepth?: number; @@ -176,15 +190,116 @@ function closeManagedRoots(roots: ManagedRoots): void { const OPENAT_READ_HELPER = String.raw`import base64,json,os,stat,sys MAX=134217728 +SEARCH_MAX=65536 +PARSE_MAX=1048576 +PREVIEW_MAX=256 def reject(): raise ValueError("invalid") def flags(directory=False): value=os.O_RDONLY|os.O_NOFOLLOW if directory: value|=os.O_DIRECTORY return value +def message_text(message): + content=message.get("content") if isinstance(message,dict) else None + if isinstance(content,str): return content + if isinstance(content,list): return " ".join(block.get("text","") for block in content if isinstance(block,dict) and block.get("type")=="text" and isinstance(block.get("text"),str)) + return "" +def append_search(current,text): + if not text or len(current)>=SEARCH_MAX: return current + next_text=(current+" " if current else "")+text + return current+next_text[len(current):len(current)+(SEARCH_MAX-len(current))] +def parse_time(value): + if not isinstance(value,str): return None + try: + text=value.replace("Z","+00:00") + return __import__("datetime").datetime.fromisoformat(text).timestamp()*1000 + except Exception: return None +def string_prefix(text,key,limit,start=0): + index=text.find('"'+key+'"',start) + if index<0: return None + index+=len(key)+2 + while index=len(text) or text[index] != ":": return None + index+=1 + while index=len(text) or text[index] != '"': return None + index+=1; result=[]; escaped=False + while indexPARSE_MAX and not raw.endswith(b"\n") + line=raw.decode("utf-8","replace").rstrip("\n") + if oversized: + if '"type":"message"' in line or '"type": "message"' in line: + count+=1 + timestamp=parse_time(string_prefix(line,"timestamp",64) or "") + message_index=line.find('"message"') + role=string_prefix(line,"role",64,message_index if message_index>=0 else 0) + preview=string_prefix(line,"content",PREVIEW_MAX,message_index) if message_index>=0 else None + if preview is None and message_index>=0: preview=string_prefix(line,"text",PREVIEW_MAX,message_index) + if timestamp is not None and role in ("user","assistant"): activity=max(activity or 0,timestamp) + if role=="user" and not first: first=preview or "(large message)" + while raw and not raw.endswith(b"\n"): + raw=stream.readline(65536) + continue + if not line.strip(): continue + try: entry=json.loads(line) + except Exception: continue + if not isinstance(entry,dict): continue + kind=entry.get("type") + if kind=="session_info": + value=entry.get("name"); name=value.strip() if isinstance(value,str) and value.strip() else None + elif kind=="session_state": + value=entry.get("state"); status=value.get("status") if isinstance(value,dict) else None + if status in ("active","archived","crash"): state={"status":status} + elif status in ("hidden","sleep"): state={"status":"archived"} + elif kind=="agent_status": + value=entry.get("status") + if isinstance(value,dict): + agent_status={key:value[key] for key in ("summary","taskState","basedOnMessageCount") if key in value} + if header is None: + if kind!="session": return {"valid":False} + header=entry + if kind!="message": continue + count+=1 + message=entry.get("message") + if not isinstance(message,dict) or not isinstance(message.get("role"),str) or "content" not in message: continue + role=message["role"] + if role not in ("user","assistant"): continue + timestamp=message.get("timestamp") if isinstance(message.get("timestamp"), (int,float)) and not isinstance(message.get("timestamp"),bool) else parse_time(entry.get("timestamp")) + if timestamp is not None: activity=max(activity or 0,timestamp) + text=message_text(message) + if not text: continue + search=append_search(search,text) + if role=="user" and not first: first=text + if header is None: return {"valid":False} + header_time=parse_time(header.get("timestamp")) + modified=activity if activity is not None and activity>0 else (header_time if header_time is not None else mtime_ms) + data={"valid":True,"messageCount":count,"firstMessage":first or "(no messages)","allMessagesText":search,"modifiedMs":modified} + if name is not None: data["name"]=name + if state is not None: data["state"]=state + if agent_status is not None: data["agentStatus"]=agent_status + return data + finally: stream.close() def serve(req): request_id=req.get("id"); parts=req.get("parts"); limit=req.get("limit"); mode=req.get("mode"); root=req.get("root") if not isinstance(request_id,str) or not request_id or len(request_id)>128: reject() - if mode not in ("read","header","stat") or root not in (3,4): reject() + if mode not in ("read","header","stat","metadata") or root not in (3,4): reject() if not isinstance(parts,list) or not parts or not isinstance(limit,int) or limit<0 or limit>MAX: reject() if any(not isinstance(p,str) or not p or p in (".","..") or "/" in p or "\\" in p for p in parts): reject() current=os.dup(root) @@ -197,7 +312,7 @@ def serve(req): if not stat.S_ISREG(before.st_mode): reject() if mode=="read" and before.st_size>limit: reject() payload={} - if mode!="stat": + if mode in ("read","header"): chunks=[]; total=0; done=False while not done: chunk=os.read(fd,min(65536,limit+1-total)) @@ -208,6 +323,7 @@ def serve(req): chunks.append(chunk); total+=len(chunk) if total>limit: reject() payload["data"]=base64.b64encode(b"".join(chunks)).decode("ascii") + elif mode=="metadata": payload["data"]=scan_metadata(fd,before.st_mtime_ns/1000000) after=os.fstat(fd) if (before.st_dev,before.st_ino,before.st_mode)!=(after.st_dev,after.st_ino,after.st_mode): reject() payload.update({"mtimeMs":after.st_mtime_ns/1000000,"dev":str(after.st_dev),"ino":str(after.st_ino)}) @@ -248,9 +364,38 @@ export function setCatalogHelperLaunchForTest(launch: { command: string; args: s catalogHelperLaunchForTest = launch; } -type TrustedReadMode = "read" | "header" | "stat"; +type TrustedReadMode = "read" | "header" | "stat" | "metadata"; const TRUSTED_READ_TIMEOUT_MS = 5_000; +const MAX_METADATA_RESPONSE_BYTES = 256 * 1024; + +function isTrustedSessionMetadata(value: unknown): value is TrustedSessionMetadata { + if (!value || typeof value !== "object") return false; + const metadata = value as Record; + if ( + metadata.valid !== true || + typeof metadata.messageCount !== "number" || + !Number.isSafeInteger(metadata.messageCount) || + metadata.messageCount < 0 || + typeof metadata.firstMessage !== "string" || + typeof metadata.allMessagesText !== "string" || + typeof metadata.modifiedMs !== "number" || + !Number.isFinite(metadata.modifiedMs) || + (metadata.name !== undefined && typeof metadata.name !== "string") || + (metadata.state !== undefined && + (!metadata.state || + typeof metadata.state !== "object" || + typeof (metadata.state as { status?: unknown }).status !== "string")) || + (metadata.agentStatus !== undefined && + (!metadata.agentStatus || + typeof metadata.agentStatus !== "object" || + typeof (metadata.agentStatus as { summary?: unknown }).summary !== "string" || + typeof (metadata.agentStatus as { basedOnMessageCount?: unknown }).basedOnMessageCount !== "number")) + ) { + return false; + } + return true; +} /** * One helper process serves every descriptor-relative read of a family walk. @@ -325,12 +470,18 @@ class TrustedReadSession { }); } - async read(rawPath: string, maxBytes: number, mode: TrustedReadMode): Promise { + async read(rawPath: string, maxBytes: number, mode: Exclude): Promise { const run = this.queue.then(() => this.readSerialized(rawPath, maxBytes, mode)); this.queue = run.catch(() => undefined); return run; } + async metadata(rawPath: string): Promise { + const run = this.queue.then(() => this.readMetadataSerialized(rawPath)); + this.queue = run.catch(() => undefined); + return run; + } + /** End input and require the helper to finish silently and successfully. */ async close(): Promise { await this.queue; @@ -367,7 +518,17 @@ class TrustedReadSession { return invalidFamilyTopology(`descriptor-relative artifact read failed: ${failure.message}`); } - private async readSerialized(rawPath: string, maxBytes: number, mode: TrustedReadMode): Promise { + private async readMetadataSerialized(rawPath: string): Promise { + const file = await this.readSerialized(rawPath, 0, "metadata"); + if (!file.metadata) throw invalidFamilyTopology("descriptor-relative artifact metadata is invalid"); + return { ...file, metadata: file.metadata }; + } + + private async readSerialized( + rawPath: string, + maxBytes: number, + mode: TrustedReadMode, + ): Promise { if (!isAbsolute(rawPath) || rawPath !== resolve(rawPath)) throw invalidFamilyTopology("session path is not canonical"); const root = [this.roots.session, this.roots.artifacts].find( @@ -384,7 +545,7 @@ class TrustedReadSession { const requestId = randomUUID(); const line = await this.exchange( JSON.stringify({ id: requestId, parts, limit: maxBytes, mode, root: rootFd }), - maxBytes * 2 + 64 * 1024, + mode === "metadata" ? MAX_METADATA_RESPONSE_BYTES : maxBytes * 2 + 64 * 1024, ); let wire: { id?: unknown; error?: unknown; data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; try { @@ -400,7 +561,11 @@ class TrustedReadSession { if (wire.error === "absent") throw invalidFamilyTopology("descriptor-relative artifact is absent"); if ( wire.error !== undefined || - (mode === "stat" ? wire.data !== undefined : typeof wire.data !== "string") || + (mode === "stat" + ? wire.data !== undefined + : mode === "metadata" + ? !isTrustedSessionMetadata(wire.data) + : typeof wire.data !== "string") || typeof wire.mtimeMs !== "number" || typeof wire.dev !== "string" || typeof wire.ino !== "string" @@ -413,6 +578,7 @@ class TrustedReadSession { mtimeMs: wire.mtimeMs, dev: wire.dev, ino: wire.ino, + ...(mode === "metadata" ? { metadata: wire.data as TrustedSessionMetadata } : {}), }; } @@ -488,14 +654,10 @@ class TrustedReadSession { /** * Topology claims (id, parent, depth) come from descriptor-bound header bytes. * Display metadata (name, timestamps, previews) is not part of the trust - * decision: it comes from the caller's listing when available, otherwise from - * an ordinary cached read, and is bound to the header by the id cross-check. + * decision. It is scanned inside the descriptor-bound helper and is bound to + * the trusted header by the identity check below. */ -async function readTrustedSession( - path: string, - reader: TrustedReadSession, - listed?: SessionInfo, -): Promise { +async function readTrustedSession(path: string, reader: TrustedReadSession): Promise { const trusted = await reader.read(path, MAX_SESSION_HEADER_BYTES, "header"); const headerLine = trusted.contents.toString("utf8").split(/\r?\n/, 1)[0]; let header: { @@ -527,19 +689,25 @@ async function readTrustedSession( throw invalidFamilyTopology("session header lacks trustworthy topology claims"); const persistedDepth = hasDepth ? (header.rlmDepth as number) : undefined; afterTrustedHeaderForTest?.(path); - // Detect a post-header swap without reading a body or reopening through the - // legacy scanner. The descriptor-relative stat is a second identity binding. - const bound = await reader.read(path, 0, "stat"); + // The metadata scan is bound to one descriptor, so it can skip giant entries + // without transferring a session body or reopening the pathname in Node. + const bound = await reader.metadata(path); if (bound.dev !== trusted.dev || bound.ino !== trusted.ino) throw invalidFamilyTopology("session changed after its trusted header read"); - // An unlisted registry child must never reach the legacy pathname scanner: - // it may recursively read parent paths and can observe a replacement after - // this descriptor-bound open. Header metadata is intentionally shallow. - const info = - listed?.path === path - ? listed - : readSessionHeaderInfoFromBuffer(path, trusted.contents, { mtimeMs: trusted.mtimeMs }); - if (!info || info.id !== header.id) throw invalidFamilyTopology("session metadata does not match its header"); + const headerInfo = readSessionHeaderInfoFromBuffer(path, trusted.contents, { mtimeMs: trusted.mtimeMs }); + if (!headerInfo || headerInfo.id !== header.id) + throw invalidFamilyTopology("session metadata does not match its header"); + const { metadata } = bound; + const info: SessionInfo = { + ...headerInfo, + modified: new Date(metadata.modifiedMs), + messageCount: metadata.messageCount, + firstMessage: metadata.firstMessage, + allMessagesText: metadata.allMessagesText, + ...(metadata.name !== undefined ? { name: metadata.name } : {}), + ...(metadata.state !== undefined ? { state: metadata.state } : {}), + ...(metadata.agentStatus !== undefined ? { agentStatus: metadata.agentStatus } : {}), + }; return { ...info, path, @@ -597,7 +765,7 @@ async function readLatestRegistry( if ( entry.type !== "rlm_subagent" || typeof entry.childId !== "string" || - entry.childId === "" || + !/^sub-[0-9a-f]{8}$/.test(entry.childId) || typeof entry.sessionFile !== "string" || entry.sessionFile === "" || (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") @@ -609,9 +777,19 @@ async function readLatestRegistry( return [...latest.values()]; } +function listSessionCandidates(sessionDir: string): string[] { + try { + return readdirSync(sessionDir) + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => join(resolve(sessionDir), entry)); + } catch { + return []; + } +} + export async function listCatalogFamilySessions(sessionDir?: string): Promise { const effectiveSessionDir = sessionDir ?? getSessionsDir(); - const roots = await SessionManager.listAll(undefined, effectiveSessionDir); + const roots = listSessionCandidates(effectiveSessionDir); const authority = managedRoots(effectiveSessionDir); const reader = new TrustedReadSession(authority); let result: SessionInfo[] | undefined; @@ -619,8 +797,8 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise(); const ids = new Map(); - for (const root of roots) { - const trusted = asTrustedFamilyRoot(await readTrustedSession(root.path, reader, root), authority); + for (const rootPath of roots) { + const trusted = asTrustedFamilyRoot(await readTrustedSession(rootPath, reader), authority); const existingPath = ids.get(trusted.id); if (existingPath && existingPath !== trusted.path) throw invalidFamilyTopology("family contains a duplicate session id"); @@ -664,7 +842,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise { const parent = SessionManager.create(root, sessionDir); parent.newSession({ rlmDepth: 0 }); parent.appendSessionInfo("parent"); - const first = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-first")); + const first = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-11111111")); first.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); first.appendSessionInfo("first"); - const second = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-second")); + first.appendSessionState({ status: "archived" }); + const second = SessionManager.create( + root, + join(root, "session-artifacts", parent.getSessionId(), "sub-22222222"), + ); second.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); second.appendSessionInfo("second"); + second.appendSessionState({ status: "active" }); const registry = join(dirname(sessionDir), "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); mkdirSync(dirname(registry), { recursive: true }); // Reader compatibility for v2 headers does not relax registry provenance: @@ -113,13 +118,13 @@ describe("daemon catalog selector resolution", () => { [ { type: "rlm_subagent", - childId: "sub-first", + childId: "sub-11111111", sessionFile: first.getSessionFile(), status: "completed", }, { type: "rlm_subagent", - childId: "sub-second", + childId: "sub-22222222", sessionFile: second.getSessionFile(), status: "completed", }, @@ -129,8 +134,8 @@ describe("daemon catalog selector resolution", () => { ); await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ - expect.objectContaining({ id: first.getSessionId() }), - expect.objectContaining({ id: second.getSessionId() }), + expect.objectContaining({ id: first.getSessionId(), name: "first", state: { status: "archived" } }), + expect.objectContaining({ id: second.getSessionId(), name: "second", state: { status: "active" } }), ]); }); @@ -140,9 +145,9 @@ describe("daemon catalog selector resolution", () => { await withDefaultSessionDir(sessionDir, async () => { await expect(listCatalogFamilySessions()).resolves.toEqual( expect.arrayContaining([ - expect.objectContaining({ id: parent.getSessionId() }), - expect.objectContaining({ id: first.getSessionId() }), - expect.objectContaining({ id: second.getSessionId() }), + expect.objectContaining({ id: parent.getSessionId(), name: "parent" }), + expect.objectContaining({ id: first.getSessionId(), name: "first" }), + expect.objectContaining({ id: second.getSessionId(), name: "second" }), ]), ); }); @@ -174,11 +179,11 @@ describe("daemon catalog selector resolution", () => { parent.newSession({ rlmDepth: 0 }); parent.appendSessionInfo("parent"); const parentFile = parent.getSessionFile()!; - const firstDir = join(root, "session-artifacts", parent.getSessionId(), "sub-first"); + const firstDir = join(root, "session-artifacts", parent.getSessionId(), "sub-11111111"); const first = SessionManager.create(root, firstDir); first.newSession({ parentSession: relative(firstDir, parentFile), rlmDepth: 1 }); first.appendSessionInfo("first"); - const secondDir = join(root, "session-artifacts", parent.getSessionId(), "sub-second"); + const secondDir = join(root, "session-artifacts", parent.getSessionId(), "sub-22222222"); const second = SessionManager.create(root, secondDir); second.newSession({ parentSession: relative(secondDir, parentFile), rlmDepth: 1 }); second.appendSessionInfo("second"); @@ -189,13 +194,13 @@ describe("daemon catalog selector resolution", () => { [ { type: "rlm_subagent", - childId: "sub-first", + childId: "sub-11111111", sessionFile: first.getSessionFile(), status: "completed", }, { type: "rlm_subagent", - childId: "sub-second", + childId: "sub-22222222", sessionFile: second.getSessionFile(), status: "completed", }, @@ -261,28 +266,38 @@ describe("daemon catalog selector resolution", () => { } const parent = create( "parent", - join(root, "session-artifacts", "root", "sub-parent"), + join(root, "session-artifacts", "root", "sub-33333333"), rootSession.getSessionFile(), 1, ); const first = create( "first", - join(root, "session-artifacts", "root", "sub-parent", "sub-first"), + join(root, "session-artifacts", "root", "sub-33333333", "sub-11111111"), parent.getSessionFile(), 2, ); const second = create( "second", - join(root, "session-artifacts", "root", "sub-parent", "sub-second"), + join(root, "session-artifacts", "root", "sub-33333333", "sub-22222222"), parent.getSessionFile(), 2, ); writeRegistry("root", [ - { type: "rlm_subagent", childId: "sub-parent", sessionFile: parent.getSessionFile(), status: "completed" }, + { + type: "rlm_subagent", + childId: "sub-33333333", + sessionFile: parent.getSessionFile(), + status: "completed", + }, ]); writeRegistry("parent", [ - { type: "rlm_subagent", childId: "sub-first", sessionFile: first.getSessionFile(), status: "completed" }, - { type: "rlm_subagent", childId: "sub-second", sessionFile: second.getSessionFile(), status: "completed" }, + { type: "rlm_subagent", childId: "sub-11111111", sessionFile: first.getSessionFile(), status: "completed" }, + { + type: "rlm_subagent", + childId: "sub-22222222", + sessionFile: second.getSessionFile(), + status: "completed", + }, ]); return { root, sessionDir, rootSession, parent, first, second, registryPath, writeRegistry }; }; @@ -308,7 +323,7 @@ describe("daemon catalog selector resolution", () => { const alias = join(dirname(fixture.first.getSessionFile()!), "impostor.jsonl"); writeFileSync(alias, readFileSync(fixture.first.getSessionFile()!)); fixture.writeRegistry("parent", [ - { type: "rlm_subagent", childId: "sub-first", sessionFile: alias, status: "completed" }, + { type: "rlm_subagent", childId: "sub-11111111", sessionFile: alias, status: "completed" }, ]); }, ], @@ -317,12 +332,17 @@ describe("daemon catalog selector resolution", () => { (fixture) => { const evil = SessionManager.create( fixture.root, - join(fixture.root, "session-artifacts", "parent", "sub-evil"), + join(fixture.root, "session-artifacts", "parent", "sub-66666666"), ); evil.newSession({ id: "evil", parentSession: fixture.rootSession.getSessionFile(), rlmDepth: 2 }); evil.appendSessionInfo("evil"); fixture.writeRegistry("parent", [ - { type: "rlm_subagent", childId: "evil", sessionFile: evil.getSessionFile(), status: "completed" }, + { + type: "rlm_subagent", + childId: "sub-66666666", + sessionFile: evil.getSessionFile(), + status: "completed", + }, ]); }, ], @@ -331,12 +351,17 @@ describe("daemon catalog selector resolution", () => { (fixture) => { const evil = SessionManager.create( fixture.root, - join(fixture.root, "session-artifacts", "parent", "sub-evil"), + join(fixture.root, "session-artifacts", "parent", "sub-66666666"), ); evil.newSession({ id: "evil", parentSession: fixture.parent.getSessionFile(), rlmDepth: 7 }); evil.appendSessionInfo("evil"); fixture.writeRegistry("parent", [ - { type: "rlm_subagent", childId: "evil", sessionFile: evil.getSessionFile(), status: "completed" }, + { + type: "rlm_subagent", + childId: "sub-66666666", + sessionFile: evil.getSessionFile(), + status: "completed", + }, ]); }, ], @@ -346,7 +371,7 @@ describe("daemon catalog selector resolution", () => { fixture.writeRegistry("parent", [ { type: "rlm_subagent", - childId: "evil", + childId: "sub-66666666", sessionFile: join(tmpdir(), "outside.jsonl"), status: "completed", }, @@ -358,8 +383,8 @@ describe("daemon catalog selector resolution", () => { fixture.writeRegistry("parent", [ { type: "rlm_subagent", - childId: "first", - sessionFile: `${dirname(fixture.first.getSessionFile()!)}/../sub-first/first.jsonl`, + childId: "sub-11111111", + sessionFile: `${dirname(fixture.first.getSessionFile()!)}/../sub-11111111/first.jsonl`, status: "completed", }, ]), @@ -370,13 +395,25 @@ describe("daemon catalog selector resolution", () => { fixture.writeRegistry("parent", [ { type: "rlm_subagent", - childId: "root", + childId: "sub-33333333", sessionFile: fixture.rootSession.getSessionFile(), status: "completed", }, ]), ], ["malformed", (fixture) => fixture.writeRegistry("parent", ["{not json"])], + [ + "malformed child id", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "sub-ABCDEF12", + sessionFile: fixture.first.getSessionFile(), + status: "completed", + }, + ]), + ], [ "record limit", (fixture) => @@ -384,7 +421,7 @@ describe("daemon catalog selector resolution", () => { "parent", Array.from({ length: 10_001 }, (_, index) => ({ type: "rlm_subagent", - childId: `bad-${index}`, + childId: `sub-${index.toString(16).padStart(8, "0")}`, sessionFile: fixture.first.getSessionFile(), status: "completed", })), @@ -399,11 +436,11 @@ describe("daemon catalog selector resolution", () => { ); } const symlink = makeFixture("prime-catalog-family-symlink-"); - const alias = join(symlink.root, "session-artifacts", "parent", "sub-alias", "first.jsonl"); + const alias = join(symlink.root, "session-artifacts", "parent", "sub-77777777", "first.jsonl"); mkdirSync(dirname(alias), { recursive: true }); symlinkSync(symlink.first.getSessionFile()!, alias); symlink.writeRegistry("parent", [ - { type: "rlm_subagent", childId: "first", sessionFile: alias, status: "completed" }, + { type: "rlm_subagent", childId: "sub-11111111", sessionFile: alias, status: "completed" }, ]); await expect(listCatalogFamilySessions(symlink.sessionDir)).rejects.toThrow( "Invalid RLM artifact family topology", @@ -417,7 +454,7 @@ describe("daemon catalog selector resolution", () => { const parent = SessionManager.create(root, sessionDir); parent.newSession({ id: "parent", rlmDepth: 0 }); parent.appendSessionInfo("parent"); - const childDir = join(root, "session-artifacts", "parent", "sub-child"); + const childDir = join(root, "session-artifacts", "parent", "sub-44444444"); const child = SessionManager.create(root, childDir); child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); child.appendSessionInfo("child"); @@ -427,7 +464,7 @@ describe("daemon catalog selector resolution", () => { registry, JSON.stringify({ type: "rlm_subagent", - childId: "sub-child", + childId: "sub-44444444", sessionFile: child.getSessionFile(), status: "completed", }), @@ -554,7 +591,7 @@ describe("daemon catalog selector resolution", () => { const parent = SessionManager.create(root, sessionDir); parent.newSession({ id: "parent", rlmDepth: 0 }); parent.appendSessionInfo("parent"); - const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-child")); + const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-44444444")); child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); child.appendSessionInfo("child"); const childFile = child.getSessionFile()!; @@ -566,7 +603,12 @@ describe("daemon catalog selector resolution", () => { mkdirSync(dirname(registry), { recursive: true }); writeFileSync( registry, - JSON.stringify({ type: "rlm_subagent", childId: "sub-child", sessionFile: childFile, status: "completed" }), + JSON.stringify({ + type: "rlm_subagent", + childId: "sub-44444444", + sessionFile: childFile, + status: "completed", + }), ); return sessionDir; }; @@ -631,6 +673,50 @@ describe("daemon catalog selector resolution", () => { expect(getOpenCatalogAuthorityFdCountForTest()).toBe(baseline); }); + it("returns descriptor-bound metadata after a multi-megabyte entry without reopening the pathname", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-compact-metadata-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("initial name"); + const file = parent.getSessionFile()!; + appendFileSync( + file, + `${[ + JSON.stringify({ + type: "message", + id: "first-message", + parentId: null, + timestamp: "2026-01-02T03:04:05.000Z", + message: { + role: "user", + content: "first searchable prompt", + timestamp: Date.parse("2026-01-02T03:04:05.000Z"), + }, + }), + JSON.stringify({ type: "custom_message", body: "x".repeat(3 * 1024 * 1024) }), + JSON.stringify({ type: "session_info", name: "trailing name" }), + JSON.stringify({ type: "session_state", state: { status: "archived" } }), + JSON.stringify({ + type: "agent_status", + status: { summary: "trailing recap", taskState: "completed", basedOnMessageCount: 1 }, + }), + ].join("\n")}\n`, + ); + const [info] = await listCatalogFamilySessions(sessionDir); + expect(info).toMatchObject({ + id: "parent", + name: "trailing name", + state: { status: "archived" }, + messageCount: 1, + firstMessage: "first searchable prompt", + allMessagesText: "first searchable prompt", + agentStatus: { summary: "trailing recap", taskState: "completed", basedOnMessageCount: 1 }, + }); + expect(info?.modified.toISOString()).toBe("2026-01-02T03:04:05.000Z"); + rmSync(root, { recursive: true, force: true }); + }); + it("reads only the session header line even when the body is huge", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-header-only-")); const sessionDir = join(root, "sessions"); @@ -670,7 +756,7 @@ describe("daemon catalog selector resolution", () => { const parent = SessionManager.create(root, sessionDir); parent.newSession({ id: "parent", rlmDepth: 0 }); parent.appendSessionInfo("parent"); - const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-child")); + const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-44444444")); child.newSession({ id: "child", parentSession: join(sessionDir, "gone.jsonl"), rlmDepth: 1 }); child.appendSessionInfo("child"); const registry = join(root, "session-artifacts", "parent", "rlm-subagents.jsonl"); @@ -679,7 +765,7 @@ describe("daemon catalog selector resolution", () => { registry, JSON.stringify({ type: "rlm_subagent", - childId: "sub-child", + childId: "sub-44444444", sessionFile: child.getSessionFile(), status: "completed", }), @@ -736,9 +822,9 @@ process.stdin.on("data", chunk => { while (true) { const end=input.indexOf("\n"); if (end<0) break; const request=JSON.parse(input.slice(0,end)); input=input.slice(end+1); - const payload=request.mode === "header" ? { id:request.id, data:${JSON.stringify(Buffer.from(header).toString("base64"))}, mtimeMs:0, dev:"1", ino:"1" } : { id:request.id, mtimeMs:0, dev:"1", ino:"1" }; + const payload=request.mode === "header" ? { id:request.id, data:${JSON.stringify(Buffer.from(header).toString("base64"))}, mtimeMs:0, dev:"1", ino:"1" } : request.mode === "metadata" ? { id:request.id, data:{valid:true,messageCount:0,firstMessage:"(no messages)",allMessagesText:"",modifiedMs:0}, mtimeMs:0, dev:"1", ino:"1" } : { id:request.id, mtimeMs:0, dev:"1", ino:"1" }; process.stdout.write(JSON.stringify(payload)+"\n"); - if (request.mode === "stat") setTimeout(() => process.stdout.write("{\"unsolicited\":true}\n"), 25); + if (request.mode === "metadata") setTimeout(() => process.stdout.write("{\"unsolicited\":true}\n"), 25); } });`; const baseline = getOpenCatalogAuthorityFdCountForTest(); @@ -758,7 +844,7 @@ process.stdin.on("data", chunk => { const parent = SessionManager.create(root, sessionDir); parent.newSession({ id: "parent", rlmDepth: 0 }); parent.appendSessionInfo("parent"); - const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-real")); + const child = SessionManager.create(root, join(root, "session-artifacts", "parent", "sub-55555555")); child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); child.appendSessionInfo("child"); const childFile = child.getSessionFile()!; @@ -768,7 +854,7 @@ process.stdin.on("data", chunk => { mkdirSync(dirname(registry), { recursive: true }); writeFileSync( registry, - JSON.stringify({ type: "rlm_subagent", childId: "child", sessionFile: childFile, status: "completed" }), + JSON.stringify({ type: "rlm_subagent", childId: "sub-66666666", sessionFile: childFile, status: "completed" }), ); try { await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("writer artifact layout"); @@ -844,7 +930,7 @@ process.stdin.on("data", chunk => { const parent = SessionManager.create(root, sessionDir); parent.newSession({ id: "parent", rlmDepth: 0 }); parent.appendSessionInfo("parent"); - const childDir = join(root, "session-artifacts", "parent", "sub-real"); + const childDir = join(root, "session-artifacts", "parent", "sub-55555555"); const child = SessionManager.create(root, childDir); child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); child.appendSessionInfo("child"); @@ -855,19 +941,19 @@ process.stdin.on("data", chunk => { registry, JSON.stringify({ type: "rlm_subagent", childId, sessionFile, status: "completed" }), ); - writeRegistry("sub-real"); + writeRegistry("sub-55555555"); return { root, sessionDir, child, childDir, writeRegistry }; }; const spoofed = make(); - spoofed.writeRegistry("sub-spoofed"); + spoofed.writeRegistry("sub-66666666"); await expect(listCatalogFamilySessions(spoofed.sessionDir)).rejects.toThrow("writer artifact layout"); rmSync(spoofed.root, { recursive: true, force: true }); const nested = make(); - const nestedPath = join(nested.sessionDir, "sub-real", "child.jsonl"); + const nestedPath = join(nested.sessionDir, "sub-55555555", "child.jsonl"); mkdirSync(dirname(nestedPath), { recursive: true }); writeFileSync(nestedPath, readFileSync(nested.child.getSessionFile()!)); - nested.writeRegistry("sub-real", nestedPath); + nested.writeRegistry("sub-55555555", nestedPath); await expect(listCatalogFamilySessions(nested.sessionDir)).rejects.toThrow("writer artifact layout"); rmSync(nested.root, { recursive: true, force: true }); From 8ade7b093a12fcc1811df8492c632bca7c85e5f5 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 12:49:30 -0700 Subject: [PATCH 12/22] fix(daemon): cap catalog metadata and classify roots --- .../modes/daemon/daemon-catalog-process.ts | 103 ++++++++++++++---- .../test/daemon-catalog-process.test.ts | 43 ++++++++ 2 files changed, 123 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index c4352ed7f..047e63ed7 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1,6 +1,6 @@ import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { closeSync, constants, openSync, readdirSync } from "node:fs"; +import { closeSync, constants, openSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; import { getSessionsDir } from "../../config.js"; @@ -97,6 +97,8 @@ interface TrustedFile { mtimeMs: number; dev: string; ino: string; + /** A header exceeded the bounded descriptor read; only untrusted roots may skip it. */ + truncated?: boolean; } interface TrustedSessionMetadata { @@ -193,7 +195,45 @@ MAX=134217728 SEARCH_MAX=65536 PARSE_MAX=1048576 PREVIEW_MAX=256 +MAX_METADATA_RESPONSE_BYTES=256*1024 +TRUNCATION_MARKER="... [truncated]" def reject(): raise ValueError("invalid") +def compact_metadata_response(response): + # json.dumps defaults to ensure_ascii=True. Keep the complete wire response, + # rather than just metadata, inside the TypeScript-side response cap. + def encoded_size(): return len(json.dumps(response,separators=(",",":")).encode("ascii")) + if encoded_size()<=MAX_METADATA_RESPONSE_BYTES: return + data=response.get("data") + if not isinstance(data,dict): reject() + targets=[] + def add_target(container,key): + if isinstance(container.get(key),str): + targets.append((container,key,container[key])) + add_target(data,"firstMessage"); add_target(data,"allMessagesText"); add_target(data,"name") + status=data.get("agentStatus") + if isinstance(status,dict): + add_target(status,"summary"); add_target(status,"taskState") + # Clear every unbounded display field before allocating the remaining escaped-byte + # budget in a stable priority order. Fields remain present and string typed. + for container,key,_ in targets: container[key]="" + if encoded_size()>MAX_METADATA_RESPONSE_BYTES: reject() + for container,key,original in targets: + length=len(original) + def candidate(prefix): + return original if prefix==length else original[:prefix]+TRUNCATION_MARKER + # A marker is useful when it fits; otherwise preserve the greatest raw + # prefix. Either search uses the exact ensure_ascii serialized byte count. + use_marker=encoded_size()+len(json.dumps(TRUNCATION_MARKER))<=MAX_METADATA_RESPONSE_BYTES + def fits(prefix): + container[key]=candidate(prefix) if use_marker else original[:prefix] + return encoded_size()<=MAX_METADATA_RESPONSE_BYTES + low=0; high=length + while lowMAX_METADATA_RESPONSE_BYTES: reject() def flags(directory=False): value=os.O_RDONLY|os.O_NOFOLLOW if directory: value|=os.O_DIRECTORY @@ -321,8 +361,13 @@ def serve(req): cut=chunk.find(b"\n") if cut>=0: chunk=chunk[:cut+1]; done=True chunks.append(chunk); total+=len(chunk) - if total>limit: reject() - payload["data"]=base64.b64encode(b"".join(chunks)).decode("ascii") + if total>limit: + # An oversized first record can be an incomplete concurrent write. It is + # distinguishable from an I/O/protocol failure so root classification can + # ignore it, while registry-reached sessions remain strict. + if mode=="header": payload["truncated"]=True; chunks=[]; break + reject() + if not payload.get("truncated"): payload["data"]=base64.b64encode(b"".join(chunks)).decode("ascii") elif mode=="metadata": payload["data"]=scan_metadata(fd,before.st_mtime_ns/1000000) after=os.fstat(fd) if (before.st_dev,before.st_ino,before.st_mode)!=(after.st_dev,after.st_ino,after.st_mode): reject() @@ -340,7 +385,12 @@ while True: except FileNotFoundError: response={"error":"absent"} except Exception: response={"error":"failed"} response["id"]=request.get("id") if isinstance(request,dict) else None - sys.stdout.write(json.dumps(response,separators=(",",":"))+"\n"); sys.stdout.flush() + if isinstance(request,dict) and request.get("mode")=="metadata" and "data" in response: + try: compact_metadata_response(response) + except Exception: response={"error":"failed","id":response.get("id")} + serialized=json.dumps(response,separators=(",",":")) + if len(serialized.encode("ascii"))>MAX_METADATA_RESPONSE_BYTES: sys.exit(1) + sys.stdout.write(serialized+"\n"); sys.stdout.flush() `; /** Test-only seam runs after authority selection but before descriptor-relative traversal. */ @@ -547,7 +597,15 @@ class TrustedReadSession { JSON.stringify({ id: requestId, parts, limit: maxBytes, mode, root: rootFd }), mode === "metadata" ? MAX_METADATA_RESPONSE_BYTES : maxBytes * 2 + 64 * 1024, ); - let wire: { id?: unknown; error?: unknown; data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; + let wire: { + id?: unknown; + error?: unknown; + data?: unknown; + truncated?: unknown; + mtimeMs?: unknown; + dev?: unknown; + ino?: unknown; + }; try { wire = JSON.parse(line) as typeof wire; } catch { @@ -559,13 +617,16 @@ class TrustedReadSession { throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); } if (wire.error === "absent") throw invalidFamilyTopology("descriptor-relative artifact is absent"); + const truncatedHeader = mode === "header" && wire.truncated === true; if ( wire.error !== undefined || - (mode === "stat" - ? wire.data !== undefined - : mode === "metadata" - ? !isTrustedSessionMetadata(wire.data) - : typeof wire.data !== "string") || + (wire.truncated !== undefined && !truncatedHeader) || + (!truncatedHeader && + (mode === "stat" + ? wire.data !== undefined + : mode === "metadata" + ? !isTrustedSessionMetadata(wire.data) + : typeof wire.data !== "string")) || typeof wire.mtimeMs !== "number" || typeof wire.dev !== "string" || typeof wire.ino !== "string" @@ -578,6 +639,7 @@ class TrustedReadSession { mtimeMs: wire.mtimeMs, dev: wire.dev, ino: wire.ino, + ...(truncatedHeader ? { truncated: true } : {}), ...(mode === "metadata" ? { metadata: wire.data as TrustedSessionMetadata } : {}), }; } @@ -659,6 +721,7 @@ class TrustedReadSession { */ async function readTrustedSession(path: string, reader: TrustedReadSession): Promise { const trusted = await reader.read(path, MAX_SESSION_HEADER_BYTES, "header"); + if (trusted.truncated) throw invalidFamilyTopology("session header exceeds the trusted read limit"); const headerLine = trusted.contents.toString("utf8").split(/\r?\n/, 1)[0]; let header: { type?: unknown; @@ -777,19 +840,13 @@ async function readLatestRegistry( return [...latest.values()]; } -function listSessionCandidates(sessionDir: string): string[] { - try { - return readdirSync(sessionDir) - .filter((entry) => entry.endsWith(".jsonl")) - .map((entry) => join(resolve(sessionDir), entry)); - } catch { - return []; - } -} - export async function listCatalogFamilySessions(sessionDir?: string): Promise { const effectiveSessionDir = sessionDir ?? getSessionsDir(); - const roots = listSessionCandidates(effectiveSessionDir); + // SessionManager performs the non-authoritative flat-directory classification: + // interrupted, blank, and unrelated jsonl files are not roots. Topology is + // still re-read descriptor-relatively below and any identified candidate + // that fails validation is fatal. + const roots = await SessionManager.listAll(undefined, effectiveSessionDir); const authority = managedRoots(effectiveSessionDir); const reader = new TrustedReadSession(authority); let result: SessionInfo[] | undefined; @@ -797,8 +854,8 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise(); const ids = new Map(); - for (const rootPath of roots) { - const trusted = asTrustedFamilyRoot(await readTrustedSession(rootPath, reader), authority); + for (const root of roots) { + const trusted = asTrustedFamilyRoot(await readTrustedSession(root.path, reader), authority); const existingPath = ids.get(trusted.id); if (existingPath && existingPath !== trusted.path) throw invalidFamilyTopology("family contains a duplicate session id"); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index e69b363fe..57768c21e 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -717,6 +717,49 @@ describe("daemon catalog selector resolution", () => { rmSync(root, { recursive: true, force: true }); }); + it("caps the exact escaped metadata response while preserving a usable family", async () => { + const payloads = ["a".repeat(1_048_000), "漢😀".repeat(65_536), '"\\\b\f\n\r\t\u0000'.repeat(80_000)]; + for (const [index, payload] of payloads.entries()) { + const root = mkdtempSync(join(tmpdir(), `prime-catalog-escaped-cap-${index}-`)); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: `parent-${index}`, rlmDepth: 0 }); + parent.appendSessionInfo("compact"); + appendFileSync( + parent.getSessionFile()!, + `${[ + JSON.stringify({ type: "message", message: { role: "user", content: payload } }), + JSON.stringify({ type: "session_info", name: payload }), + JSON.stringify({ + type: "agent_status", + status: { summary: payload, taskState: "completed", basedOnMessageCount: 1 }, + }), + ].join("\n")}\n`, + ); + const family = await listCatalogFamilySessions(sessionDir); + expect(family).toHaveLength(1); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("skips junk root candidates but rejects identified malformed and duplicate roots", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-root-classification-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + writeFileSync(join(sessionDir, "blank.jsonl"), "\n"); + writeFileSync(join(sessionDir, "junk.jsonl"), "not json\n"); + writeFileSync(join(sessionDir, "event.jsonl"), '{"type":"message","id":"junk"}\n'); + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([expect.objectContaining({ id: "parent" })]); + writeFileSync(join(sessionDir, "bad.jsonl"), '{"type":"session","id":"bad","rlmDepth":"oops"}\n'); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + rmSync(join(sessionDir, "bad.jsonl")); + writeFileSync(join(sessionDir, "duplicate.jsonl"), readFileSync(parent.getSessionFile()!)); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("duplicate session id"); + rmSync(root, { recursive: true, force: true }); + }); + it("reads only the session header line even when the body is huge", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-header-only-")); const sessionDir = join(root, "sessions"); From 8c7ae5ab376e1a59b5b0109390496ce9218b4044 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 12:51:40 -0700 Subject: [PATCH 13/22] fix(daemon): strictly classify catalog root artifacts --- .../modes/daemon/daemon-catalog-process.ts | 65 ++++++++++++++++--- .../test/daemon-catalog-process.test.ts | 34 +++++++++- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 047e63ed7..1bd0a9caa 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1,6 +1,6 @@ import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { closeSync, constants, openSync } from "node:fs"; +import { closeSync, constants, lstatSync, openSync, readdirSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; import { getSessionsDir } from "../../config.js"; @@ -211,8 +211,7 @@ def compact_metadata_response(response): targets.append((container,key,container[key])) add_target(data,"firstMessage"); add_target(data,"allMessagesText"); add_target(data,"name") status=data.get("agentStatus") - if isinstance(status,dict): - add_target(status,"summary"); add_target(status,"taskState") + if isinstance(status,dict): add_target(status,"summary") # Clear every unbounded display field before allocating the remaining escaped-byte # budget in a stable priority order. Fields remain present and string typed. for container,key,_ in targets: container[key]="" @@ -782,6 +781,37 @@ async function readTrustedSession(path: string, reader: TrustedReadSession): Pro }; } +/** + * A sessions directory can contain interrupted writes and unrelated jsonl files. + * Classification is deliberately narrow: only a bounded, blank/truncated, + * non-JSON, non-session, or identifier-less first record is ignored. Once a + * record purports to be an identified session, the strict descriptor-bound + * reader owns every topology and protocol failure. + */ +async function readTrustedRootCandidate( + path: string, + listed: { dev: string; ino: string }, + reader: TrustedReadSession, +): Promise { + const header = await reader.read(path, MAX_SESSION_HEADER_BYTES, "header"); + // A candidate changing after enumeration cannot safely be reclassified as + // junk: it may have replaced an identified root before descriptor open. + if (header.dev !== listed.dev || header.ino !== listed.ino) + throw invalidFamilyTopology("root candidate changed after directory enumeration"); + if (header.truncated) throw invalidFamilyTopology("session header exceeds the trusted read limit"); + const line = header.contents.toString("utf8").split(/\r?\n/, 1)[0] ?? ""; + if (!line.trim()) return undefined; + let candidate: { type?: unknown; id?: unknown }; + try { + candidate = JSON.parse(line) as { type?: unknown; id?: unknown }; + } catch { + return undefined; + } + if (!candidate || typeof candidate !== "object" || candidate.type !== "session") return undefined; + if (typeof candidate.id !== "string" || candidate.id === "") return undefined; + return readTrustedSession(path, reader); +} + /** * /fork and lineage-carrying /new save sessions directly into the sessions dir * with a parentSession claim naming their source. That claim is fork ancestry, @@ -840,13 +870,28 @@ async function readLatestRegistry( return [...latest.values()]; } +function listSessionCandidates(sessionDir: string): Array<{ path: string; dev: string; ino: string }> { + try { + return readdirSync(sessionDir) + .filter((entry) => entry.endsWith(".jsonl")) + .flatMap((entry) => { + const path = join(resolve(sessionDir), entry); + try { + const stat = lstatSync(path); + return [{ path, dev: String(stat.dev), ino: String(stat.ino) }]; + } catch { + // Removed during enumeration: it was never a stable root candidate. + return []; + } + }); + } catch { + return []; + } +} + export async function listCatalogFamilySessions(sessionDir?: string): Promise { const effectiveSessionDir = sessionDir ?? getSessionsDir(); - // SessionManager performs the non-authoritative flat-directory classification: - // interrupted, blank, and unrelated jsonl files are not roots. Topology is - // still re-read descriptor-relatively below and any identified candidate - // that fails validation is fatal. - const roots = await SessionManager.listAll(undefined, effectiveSessionDir); + const roots = listSessionCandidates(effectiveSessionDir); const authority = managedRoots(effectiveSessionDir); const reader = new TrustedReadSession(authority); let result: SessionInfo[] | undefined; @@ -855,7 +900,9 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise(); const ids = new Map(); for (const root of roots) { - const trusted = asTrustedFamilyRoot(await readTrustedSession(root.path, reader), authority); + const candidate = await readTrustedRootCandidate(root.path, root, reader); + if (!candidate) continue; + const trusted = asTrustedFamilyRoot(candidate, authority); const existingPath = ids.get(trusted.id); if (existingPath && existingPath !== trusted.path) throw invalidFamilyTopology("family contains a duplicate session id"); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 57768c21e..c2e7d9bd2 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -718,7 +718,9 @@ describe("daemon catalog selector resolution", () => { }); it("caps the exact escaped metadata response while preserving a usable family", async () => { - const payloads = ["a".repeat(1_048_000), "漢😀".repeat(65_536), '"\\\b\f\n\r\t\u0000'.repeat(80_000)]; + // Keep each JSONL record below the parser's 1 MiB record bound while its + // combined escaped metadata response still greatly exceeds 256 KiB. + const payloads = ["a".repeat(500_000), "漢😀".repeat(50_000), '"\\\b\f\n\r\t\u0000'.repeat(25_000)]; for (const [index, payload] of payloads.entries()) { const root = mkdtempSync(join(tmpdir(), `prime-catalog-escaped-cap-${index}-`)); const sessionDir = join(root, "sessions"); @@ -738,6 +740,36 @@ describe("daemon catalog selector resolution", () => { ); const family = await listCatalogFamilySessions(sessionDir); expect(family).toHaveLength(1); + const [session] = family; + // A successful descriptor helper exchange proves the exact ASCII-escaped + // response stayed inside its 256 KiB wire cap. The compact response still + // has every catalog field consumers require. + expect(session).toEqual( + expect.objectContaining({ + id: `parent-${index}`, + firstMessage: expect.any(String), + allMessagesText: expect.any(String), + name: expect.any(String), + agentStatus: expect.objectContaining({ + summary: expect.any(String), + taskState: "completed", + basedOnMessageCount: 1, + }), + }), + ); + const serialized = JSON.stringify({ + id: "metadata-response", + data: { + valid: true, + messageCount: session.messageCount, + firstMessage: session.firstMessage, + allMessagesText: session.allMessagesText, + modifiedMs: session.modified.getTime(), + name: session.name, + agentStatus: session.agentStatus, + }, + }); + expect(Buffer.byteLength(serialized, "ascii")).toBeLessThanOrEqual(256 * 1024); rmSync(root, { recursive: true, force: true }); } }); From ad0e0ccc988e53016492ef353b18449736784fd5 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 13:30:04 -0700 Subject: [PATCH 14/22] fix(daemon): bound catalog metadata envelopes --- .../modes/daemon/daemon-catalog-process.ts | 82 +++++++++++++------ .../test/daemon-catalog-process.test.ts | 61 +++++++++++++- 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 1bd0a9caa..3cc17419b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -196,24 +196,28 @@ SEARCH_MAX=65536 PARSE_MAX=1048576 PREVIEW_MAX=256 MAX_METADATA_RESPONSE_BYTES=256*1024 +HEADER_CLASSIFICATION_BYTES=65536 TRUNCATION_MARKER="... [truncated]" def reject(): raise ValueError("invalid") def compact_metadata_response(response): - # json.dumps defaults to ensure_ascii=True. Keep the complete wire response, - # rather than just metadata, inside the TypeScript-side response cap. - def encoded_size(): return len(json.dumps(response,separators=(",",":")).encode("ascii")) + # Budget the complete protocol envelope, not an assumed metadata shape. + # ensure_ascii is explicit because the protocol is newline-delimited ASCII. + def encoded_size(): return len(json.dumps(response,separators=(",",":"),ensure_ascii=True).encode("ascii")) if encoded_size()<=MAX_METADATA_RESPONSE_BYTES: return data=response.get("data") if not isinstance(data,dict): reject() targets=[] - def add_target(container,key): - if isinstance(container.get(key),str): - targets.append((container,key,container[key])) - add_target(data,"firstMessage"); add_target(data,"allMessagesText"); add_target(data,"name") - status=data.get("agentStatus") - if isinstance(status,dict): add_target(status,"summary") - # Clear every unbounded display field before allocating the remaining escaped-byte - # budget in a stable priority order. Fields remain present and string typed. + # Walk every string value recursively, in insertion/index order. The protocol + # currently has a typed metadata shape, but this prevents a future nested + # display field from bypassing the full-envelope budget. + def collect(container): + values=container.items() if isinstance(container,dict) else enumerate(container) if isinstance(container,list) else () + for key,value in values: + if isinstance(value,str): targets.append((container,key,value)) + elif isinstance(value,(dict,list)): collect(value) + collect(response) + # Clear every string before allocating the remaining escaped-byte budget in a + # stable order. Fields remain present and string typed. for container,key,_ in targets: container[key]="" if encoded_size()>MAX_METADATA_RESPONSE_BYTES: reject() for container,key,original in targets: @@ -222,7 +226,7 @@ def compact_metadata_response(response): return original if prefix==length else original[:prefix]+TRUNCATION_MARKER # A marker is useful when it fits; otherwise preserve the greatest raw # prefix. Either search uses the exact ensure_ascii serialized byte count. - use_marker=encoded_size()+len(json.dumps(TRUNCATION_MARKER))<=MAX_METADATA_RESPONSE_BYTES + use_marker=encoded_size()+len(json.dumps(TRUNCATION_MARKER,ensure_ascii=True))<=MAX_METADATA_RESPONSE_BYTES def fits(prefix): container[key]=candidate(prefix) if use_marker else original[:prefix] return encoded_size()<=MAX_METADATA_RESPONSE_BYTES @@ -308,9 +312,15 @@ def scan_metadata(fd,mtime_ms): if status in ("active","archived","crash"): state={"status":status} elif status in ("hidden","sleep"): state={"status":"archived"} elif kind=="agent_status": + # Keep the recap schema typed before it crosses the bounded wire. An + # arbitrary taskState string is neither an AgentTaskState nor compactable. value=entry.get("status") - if isinstance(value,dict): - agent_status={key:value[key] for key in ("summary","taskState","basedOnMessageCount") if key in value} + summary=value.get("summary") if isinstance(value,dict) else None + based=value.get("basedOnMessageCount") if isinstance(value,dict) else None + if isinstance(summary,str) and isinstance(based,int) and not isinstance(based,bool) and based>=0: + agent_status={"summary":summary,"basedOnMessageCount":based} + task_state=value.get("taskState") + if task_state in ("needs_input","completed"): agent_status["taskState"]=task_state if header is None: if kind!="session": return {"valid":False} header=entry @@ -364,7 +374,11 @@ def serve(req): # An oversized first record can be an incomplete concurrent write. It is # distinguishable from an I/O/protocol failure so root classification can # ignore it, while registry-reached sessions remain strict. - if mode=="header": payload["truncated"]=True; chunks=[]; break + if mode=="header": + # Return only a small bounded prefix for root classification. It fits + # inside the outer 256 KiB envelope; registry-reached headers stay + # strict in TypeScript whenever this flag is present. + payload["truncated"]=True; chunks=[b"".join(chunks)[:HEADER_CLASSIFICATION_BYTES]]; break reject() if not payload.get("truncated"): payload["data"]=base64.b64encode(b"".join(chunks)).decode("ascii") elif mode=="metadata": payload["data"]=scan_metadata(fd,before.st_mtime_ns/1000000) @@ -387,7 +401,9 @@ while True: if isinstance(request,dict) and request.get("mode")=="metadata" and "data" in response: try: compact_metadata_response(response) except Exception: response={"error":"failed","id":response.get("id")} - serialized=json.dumps(response,separators=(",",":")) + # This is the exact full response envelope budgeted above. Keep + # ensure_ascii explicit: changing a Python default cannot weaken the cap. + serialized=json.dumps(response,separators=(",",":"),ensure_ascii=True) if len(serialized.encode("ascii"))>MAX_METADATA_RESPONSE_BYTES: sys.exit(1) sys.stdout.write(serialized+"\n"); sys.stdout.flush() `; @@ -439,7 +455,11 @@ function isTrustedSessionMetadata(value: unknown): value is TrustedSessionMetada (!metadata.agentStatus || typeof metadata.agentStatus !== "object" || typeof (metadata.agentStatus as { summary?: unknown }).summary !== "string" || - typeof (metadata.agentStatus as { basedOnMessageCount?: unknown }).basedOnMessageCount !== "number")) + !Number.isSafeInteger((metadata.agentStatus as { basedOnMessageCount?: unknown }).basedOnMessageCount) || + (metadata.agentStatus as { basedOnMessageCount: number }).basedOnMessageCount < 0 || + ((metadata.agentStatus as { taskState?: unknown }).taskState !== undefined && + (metadata.agentStatus as { taskState?: unknown }).taskState !== "needs_input" && + (metadata.agentStatus as { taskState?: unknown }).taskState !== "completed"))) ) { return false; } @@ -620,12 +640,11 @@ class TrustedReadSession { if ( wire.error !== undefined || (wire.truncated !== undefined && !truncatedHeader) || - (!truncatedHeader && - (mode === "stat" - ? wire.data !== undefined - : mode === "metadata" - ? !isTrustedSessionMetadata(wire.data) - : typeof wire.data !== "string")) || + (mode === "stat" + ? wire.data !== undefined + : mode === "metadata" + ? !isTrustedSessionMetadata(wire.data) + : typeof wire.data !== "string") || typeof wire.mtimeMs !== "number" || typeof wire.dev !== "string" || typeof wire.ino !== "string" @@ -798,16 +817,27 @@ async function readTrustedRootCandidate( // junk: it may have replaced an identified root before descriptor open. if (header.dev !== listed.dev || header.ino !== listed.ino) throw invalidFamilyTopology("root candidate changed after directory enumeration"); - if (header.truncated) throw invalidFamilyTopology("session header exceeds the trusted read limit"); const line = header.contents.toString("utf8").split(/\r?\n/, 1)[0] ?? ""; if (!line.trim()) return undefined; - let candidate: { type?: unknown; id?: unknown }; + let candidate: { type?: unknown; id?: unknown } | undefined; try { candidate = JSON.parse(line) as { type?: unknown; id?: unknown }; } catch { + // A bounded prefix may end in a concurrent partial write. Only a prefix + // that already purports to carry a session or identifier claim is part of + // the topology; unrelated incomplete junk remains skippable. + if (/^\s*\{[\s\S]*?"type"\s*:\s*"session(?:"|\\|$)|^\s*\{[\s\S]*?"id"\s*:/u.test(line)) + throw invalidFamilyTopology("session header exceeds the trusted read limit"); return undefined; } - if (!candidate || typeof candidate !== "object" || candidate.type !== "session") return undefined; + if (!candidate || typeof candidate !== "object") return undefined; + // A complete prefix carrying either claim is fatal when truncated. A + // registry-reached child never calls this root-only classifier and remains + // strict unconditionally in readTrustedSession. + if (header.truncated && (candidate.type === "session" || candidate.id !== undefined)) + throw invalidFamilyTopology("session header exceeds the trusted read limit"); + if (header.truncated) return undefined; + if (candidate.type !== "session") return undefined; if (typeof candidate.id !== "string" || candidate.id === "") return undefined; return readTrustedSession(path, reader); } diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index c2e7d9bd2..f045fa547 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -732,9 +732,11 @@ describe("daemon catalog selector resolution", () => { `${[ JSON.stringify({ type: "message", message: { role: "user", content: payload } }), JSON.stringify({ type: "session_info", name: payload }), + // Hostile taskState must be dropped rather than treated as a free-text + // field. The allowed wire enum is tested below. JSON.stringify({ type: "agent_status", - status: { summary: payload, taskState: "completed", basedOnMessageCount: 1 }, + status: { summary: payload, taskState: payload, basedOnMessageCount: 1 }, }), ].join("\n")}\n`, ); @@ -752,11 +754,11 @@ describe("daemon catalog selector resolution", () => { name: expect.any(String), agentStatus: expect.objectContaining({ summary: expect.any(String), - taskState: "completed", basedOnMessageCount: 1, }), }), ); + expect(session?.agentStatus?.taskState).toBeUndefined(); const serialized = JSON.stringify({ id: "metadata-response", data: { @@ -769,11 +771,43 @@ describe("daemon catalog selector resolution", () => { agentStatus: session.agentStatus, }, }); - expect(Buffer.byteLength(serialized, "ascii")).toBeLessThanOrEqual(256 * 1024); + // The helper uses Python json.dumps(..., ensure_ascii=True), including + // surrogate-pair escaping for astral code points. Assert that exact + // envelope representation, not a UTF-8 or lossy Node "ascii" estimate. + const ensureAscii = serialized.replace(/[\u0080-\u{10ffff}]/gu, (character) => { + const codePoint = character.codePointAt(0)!; + if (codePoint <= 0xffff) return `\\u${codePoint.toString(16).padStart(4, "0")}`; + const offset = codePoint - 0x10000; + return `\\u${(0xd800 + (offset >> 10)).toString(16)}\\u${(0xdc00 + (offset & 0x3ff)).toString(16)}`; + }); + expect(Buffer.byteLength(ensureAscii, "ascii")).toBeLessThanOrEqual(256 * 1024); rmSync(root, { recursive: true, force: true }); } }); + it("retains only allowed typed agent task states", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-agent-status-schema-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + appendFileSync( + parent.getSessionFile()!, + `${[ + JSON.stringify({ + type: "agent_status", + status: { summary: "valid", taskState: "completed", basedOnMessageCount: 1 }, + }), + ].join("\n")}\n`, + ); + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([ + expect.objectContaining({ + agentStatus: { summary: "valid", taskState: "completed", basedOnMessageCount: 1 }, + }), + ]); + rmSync(root, { recursive: true, force: true }); + }); + it("skips junk root candidates but rejects identified malformed and duplicate roots", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-root-classification-")); const sessionDir = join(root, "sessions"); @@ -783,7 +817,28 @@ describe("daemon catalog selector resolution", () => { writeFileSync(join(sessionDir, "blank.jsonl"), "\n"); writeFileSync(join(sessionDir, "junk.jsonl"), "not json\n"); writeFileSync(join(sessionDir, "event.jsonl"), '{"type":"message","id":"junk"}\n'); + // An oversized root is skippable only when its bounded prefix is + // demonstrably unrelated, including incomplete unrelated junk. A purported + // session or identifier claim remains topology-relevant and fails closed. + writeFileSync(join(sessionDir, "oversized-junk.jsonl"), `not-json-${"x".repeat(300 * 1024)}`); + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([expect.objectContaining({ id: "parent" })]); + rmSync(join(sessionDir, "oversized-junk.jsonl")); + writeFileSync(join(sessionDir, "oversized-event.jsonl"), `{"type":"message","body":"${"x".repeat(300 * 1024)}`); await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([expect.objectContaining({ id: "parent" })]); + rmSync(join(sessionDir, "oversized-event.jsonl")); + writeFileSync( + join(sessionDir, "oversized-session.jsonl"), + `{"type":"session","id":"claimed","padding":"${"x".repeat(300 * 1024)}`, + ); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(join(sessionDir, "oversized-session.jsonl")); + writeFileSync(join(sessionDir, "oversized-id.jsonl"), `{"id":"claimed","padding":"${"x".repeat(300 * 1024)}`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(join(sessionDir, "oversized-id.jsonl")); writeFileSync(join(sessionDir, "bad.jsonl"), '{"type":"session","id":"bad","rlmDepth":"oops"}\n'); await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); rmSync(join(sessionDir, "bad.jsonl")); From 5fcfd2635fa4eb23050d52b0326c71fc957c5ef0 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 13:50:21 -0700 Subject: [PATCH 15/22] fix(daemon): preserve catalog protocol envelopes --- .../modes/daemon/daemon-catalog-process.ts | 75 +++++++----- .../test/daemon-catalog-process.test.ts | 110 ++++++++++-------- 2 files changed, 103 insertions(+), 82 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 3cc17419b..34aa2a902 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -75,7 +75,7 @@ interface SavedRlmSubagentRegistryEntry { // Registry records carry prompts and spawn code; real profiles reach a few MB. const MAX_RLM_REGISTRY_BYTES = 16 * 1024 * 1024; -const MAX_SESSION_HEADER_BYTES = 256 * 1024; +const MAX_SESSION_HEADER_BYTES = 64 * 1024; const MAX_RLM_REGISTRY_RECORDS = 10_000; const MAX_RLM_FAMILY_EDGES = 10_000; const MAX_RLM_FAMILY_NODES = 10_000; @@ -207,17 +207,15 @@ def compact_metadata_response(response): data=response.get("data") if not isinstance(data,dict): reject() targets=[] - # Walk every string value recursively, in insertion/index order. The protocol - # currently has a typed metadata shape, but this prevents a future nested - # display field from bypassing the full-envelope budget. - def collect(container): - values=container.items() if isinstance(container,dict) else enumerate(container) if isinstance(container,list) else () - for key,value in values: - if isinstance(value,str): targets.append((container,key,value)) - elif isinstance(value,(dict,list)): collect(value) - collect(response) - # Clear every string before allocating the remaining escaped-byte budget in a - # stable order. Fields remain present and string typed. + # Only the approved human-facing metadata strings are compactable. Protocol + # control fields and typed metadata enums remain byte-for-byte unchanged. + def add_target(container,key): + if isinstance(container.get(key),str): targets.append((container,key,container[key])) + add_target(data,"firstMessage"); add_target(data,"allMessagesText"); add_target(data,"name") + status=data.get("agentStatus") + if isinstance(status,dict): add_target(status,"summary") + # Clear every display string before allocating the remaining escaped-byte + # budget in a stable order. Fields remain present and string typed. for container,key,_ in targets: container[key]="" if encoded_size()>MAX_METADATA_RESPONSE_BYTES: reject() for container,key,original in targets: @@ -348,7 +346,7 @@ def scan_metadata(fd,mtime_ms): def serve(req): request_id=req.get("id"); parts=req.get("parts"); limit=req.get("limit"); mode=req.get("mode"); root=req.get("root") if not isinstance(request_id,str) or not request_id or len(request_id)>128: reject() - if mode not in ("read","header","stat","metadata") or root not in (3,4): reject() + if mode not in ("read","header","classify","stat","metadata") or root not in (3,4): reject() if not isinstance(parts,list) or not parts or not isinstance(limit,int) or limit<0 or limit>MAX: reject() if any(not isinstance(p,str) or not p or p in (".","..") or "/" in p or "\\" in p for p in parts): reject() current=os.dup(root) @@ -361,26 +359,23 @@ def serve(req): if not stat.S_ISREG(before.st_mode): reject() if mode=="read" and before.st_size>limit: reject() payload={} - if mode in ("read","header"): + if mode in ("read","header","classify"): chunks=[]; total=0; done=False while not done: chunk=os.read(fd,min(65536,limit+1-total)) if not chunk: break - if mode=="header": + if mode in ("header","classify"): cut=chunk.find(b"\n") if cut>=0: chunk=chunk[:cut+1]; done=True chunks.append(chunk); total+=len(chunk) if total>limit: - # An oversized first record can be an incomplete concurrent write. It is - # distinguishable from an I/O/protocol failure so root classification can - # ignore it, while registry-reached sessions remain strict. - if mode=="header": - # Return only a small bounded prefix for root classification. It fits - # inside the outer 256 KiB envelope; registry-reached headers stay - # strict in TypeScript whenever this flag is present. + # Only root classification may receive an incomplete first record. Its + # raw prefix is independently bounded so base64 plus the full envelope + # remains below the protocol cap. Strict registry/session reads reject. + if mode=="classify": payload["truncated"]=True; chunks=[b"".join(chunks)[:HEADER_CLASSIFICATION_BYTES]]; break reject() - if not payload.get("truncated"): payload["data"]=base64.b64encode(b"".join(chunks)).decode("ascii") + payload["data"]=base64.b64encode(b"".join(chunks)).decode("ascii") elif mode=="metadata": payload["data"]=scan_metadata(fd,before.st_mtime_ns/1000000) after=os.fstat(fd) if (before.st_dev,before.st_ino,before.st_mode)!=(after.st_dev,after.st_ino,after.st_mode): reject() @@ -401,10 +396,11 @@ while True: if isinstance(request,dict) and request.get("mode")=="metadata" and "data" in response: try: compact_metadata_response(response) except Exception: response={"error":"failed","id":response.get("id")} - # This is the exact full response envelope budgeted above. Keep - # ensure_ascii explicit: changing a Python default cannot weaken the cap. + # Metadata is the only compacted response. Its exact full envelope, including + # the request id and every escaped control/display field, is bounded here. + # Registry records deliberately retain their larger strict read budget. serialized=json.dumps(response,separators=(",",":"),ensure_ascii=True) - if len(serialized.encode("ascii"))>MAX_METADATA_RESPONSE_BYTES: sys.exit(1) + if isinstance(request,dict) and request.get("mode")=="metadata" and len(serialized.encode("ascii"))>MAX_METADATA_RESPONSE_BYTES: sys.exit(1) sys.stdout.write(serialized+"\n"); sys.stdout.flush() `; @@ -429,11 +425,22 @@ export function setCatalogHelperLaunchForTest(launch: { command: string; args: s catalogHelperLaunchForTest = launch; } -type TrustedReadMode = "read" | "header" | "stat" | "metadata"; +type TrustedReadMode = "read" | "header" | "classify" | "stat" | "metadata"; const TRUSTED_READ_TIMEOUT_MS = 5_000; const MAX_METADATA_RESPONSE_BYTES = 256 * 1024; +/** Test-only seam observes the exact helper response before protocol validation. */ +let afterCatalogHelperResponseForTest: + | ((mode: TrustedReadMode, requestId: string, responseLine: string) => void) + | undefined; +/** @internal */ +export function setCatalogAfterHelperResponseForTest( + hook: ((mode: TrustedReadMode, requestId: string, responseLine: string) => void) | undefined, +): void { + afterCatalogHelperResponseForTest = hook; +} + function isTrustedSessionMetadata(value: unknown): value is TrustedSessionMetadata { if (!value || typeof value !== "object") return false; const metadata = value as Record; @@ -450,7 +457,9 @@ function isTrustedSessionMetadata(value: unknown): value is TrustedSessionMetada (metadata.state !== undefined && (!metadata.state || typeof metadata.state !== "object" || - typeof (metadata.state as { status?: unknown }).status !== "string")) || + ((metadata.state as { status?: unknown }).status !== "active" && + (metadata.state as { status?: unknown }).status !== "archived" && + (metadata.state as { status?: unknown }).status !== "crash"))) || (metadata.agentStatus !== undefined && (!metadata.agentStatus || typeof metadata.agentStatus !== "object" || @@ -614,7 +623,7 @@ class TrustedReadSession { const requestId = randomUUID(); const line = await this.exchange( JSON.stringify({ id: requestId, parts, limit: maxBytes, mode, root: rootFd }), - mode === "metadata" ? MAX_METADATA_RESPONSE_BYTES : maxBytes * 2 + 64 * 1024, + mode === "read" ? maxBytes * 2 + 64 * 1024 : MAX_METADATA_RESPONSE_BYTES, ); let wire: { id?: unknown; @@ -627,6 +636,7 @@ class TrustedReadSession { }; try { wire = JSON.parse(line) as typeof wire; + afterCatalogHelperResponseForTest?.(mode, requestId, line); } catch { this.fail(new Error("catalog openat helper response is invalid")); throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); @@ -636,7 +646,7 @@ class TrustedReadSession { throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); } if (wire.error === "absent") throw invalidFamilyTopology("descriptor-relative artifact is absent"); - const truncatedHeader = mode === "header" && wire.truncated === true; + const truncatedHeader = mode === "classify" && wire.truncated === true; if ( wire.error !== undefined || (wire.truncated !== undefined && !truncatedHeader) || @@ -812,7 +822,7 @@ async function readTrustedRootCandidate( listed: { dev: string; ino: string }, reader: TrustedReadSession, ): Promise { - const header = await reader.read(path, MAX_SESSION_HEADER_BYTES, "header"); + const header = await reader.read(path, MAX_SESSION_HEADER_BYTES, "classify"); // A candidate changing after enumeration cannot safely be reclassified as // junk: it may have replaced an identified root before descriptor open. if (header.dev !== listed.dev || header.ino !== listed.ino) @@ -838,7 +848,8 @@ async function readTrustedRootCandidate( throw invalidFamilyTopology("session header exceeds the trusted read limit"); if (header.truncated) return undefined; if (candidate.type !== "session") return undefined; - if (typeof candidate.id !== "string" || candidate.id === "") return undefined; + // Once a complete root record purports to be a session, strict parsing owns + // it. A missing/blank id is corruption, not unrelated no-id junk. return readTrustedSession(path, reader); } diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index f045fa547..79d663f1d 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -18,6 +18,7 @@ import { listCatalogFamilySessions, listSavedSessionSiblings, resolveCatalogSessionMatch, + setCatalogAfterHelperResponseForTest, setCatalogAfterTrustedHeaderForTest, setCatalogBeforeTrustedOpenForTest, setCatalogHelperLaunchForTest, @@ -717,11 +718,20 @@ describe("daemon catalog selector resolution", () => { rmSync(root, { recursive: true, force: true }); }); - it("caps the exact escaped metadata response while preserving a usable family", async () => { + it("caps exact escaped metadata envelopes without changing request ids", async () => { // Keep each JSONL record below the parser's 1 MiB record bound while its // combined escaped metadata response still greatly exceeds 256 KiB. - const payloads = ["a".repeat(500_000), "漢😀".repeat(50_000), '"\\\b\f\n\r\t\u0000'.repeat(25_000)]; + const payloads = [ + "a".repeat(500_000), + "漢".repeat(100_000), + "😀".repeat(75_000), + '"\\\b\f\n\r\t\u0000'.repeat(25_000), + ]; for (const [index, payload] of payloads.entries()) { + const responses: Array<{ id: string; line: string }> = []; + setCatalogAfterHelperResponseForTest((mode, id, line) => { + if (mode === "metadata") responses.push({ id, line }); + }); const root = mkdtempSync(join(tmpdir(), `prime-catalog-escaped-cap-${index}-`)); const sessionDir = join(root, "sessions"); const parent = SessionManager.create(root, sessionDir); @@ -732,59 +742,36 @@ describe("daemon catalog selector resolution", () => { `${[ JSON.stringify({ type: "message", message: { role: "user", content: payload } }), JSON.stringify({ type: "session_info", name: payload }), - // Hostile taskState must be dropped rather than treated as a free-text - // field. The allowed wire enum is tested below. JSON.stringify({ type: "agent_status", status: { summary: payload, taskState: payload, basedOnMessageCount: 1 }, }), ].join("\n")}\n`, ); - const family = await listCatalogFamilySessions(sessionDir); - expect(family).toHaveLength(1); - const [session] = family; - // A successful descriptor helper exchange proves the exact ASCII-escaped - // response stayed inside its 256 KiB wire cap. The compact response still - // has every catalog field consumers require. - expect(session).toEqual( - expect.objectContaining({ - id: `parent-${index}`, - firstMessage: expect.any(String), - allMessagesText: expect.any(String), - name: expect.any(String), - agentStatus: expect.objectContaining({ - summary: expect.any(String), - basedOnMessageCount: 1, + try { + const family = await listCatalogFamilySessions(sessionDir); + expect(family).toHaveLength(1); + const [session] = family; + expect(session).toEqual( + expect.objectContaining({ + id: `parent-${index}`, + firstMessage: expect.any(String), + allMessagesText: expect.any(String), + name: expect.any(String), + agentStatus: expect.objectContaining({ summary: expect.any(String), basedOnMessageCount: 1 }), }), - }), - ); - expect(session?.agentStatus?.taskState).toBeUndefined(); - const serialized = JSON.stringify({ - id: "metadata-response", - data: { - valid: true, - messageCount: session.messageCount, - firstMessage: session.firstMessage, - allMessagesText: session.allMessagesText, - modifiedMs: session.modified.getTime(), - name: session.name, - agentStatus: session.agentStatus, - }, - }); - // The helper uses Python json.dumps(..., ensure_ascii=True), including - // surrogate-pair escaping for astral code points. Assert that exact - // envelope representation, not a UTF-8 or lossy Node "ascii" estimate. - const ensureAscii = serialized.replace(/[\u0080-\u{10ffff}]/gu, (character) => { - const codePoint = character.codePointAt(0)!; - if (codePoint <= 0xffff) return `\\u${codePoint.toString(16).padStart(4, "0")}`; - const offset = codePoint - 0x10000; - return `\\u${(0xd800 + (offset >> 10)).toString(16)}\\u${(0xdc00 + (offset & 0x3ff)).toString(16)}`; - }); - expect(Buffer.byteLength(ensureAscii, "ascii")).toBeLessThanOrEqual(256 * 1024); - rmSync(root, { recursive: true, force: true }); + ); + expect(session?.agentStatus?.taskState).toBeUndefined(); + expect(responses).toHaveLength(1); + const response = responses[0]!; + expect(JSON.parse(response.line).id).toBe(response.id); + expect(Buffer.byteLength(response.line, "ascii")).toBeLessThanOrEqual(256 * 1024); + } finally { + setCatalogAfterHelperResponseForTest(undefined); + rmSync(root, { recursive: true, force: true }); + } } }); - it("retains only allowed typed agent task states", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-agent-status-schema-")); const sessionDir = join(root, "sessions"); @@ -817,24 +804,33 @@ describe("daemon catalog selector resolution", () => { writeFileSync(join(sessionDir, "blank.jsonl"), "\n"); writeFileSync(join(sessionDir, "junk.jsonl"), "not json\n"); writeFileSync(join(sessionDir, "event.jsonl"), '{"type":"message","id":"junk"}\n'); + writeFileSync(join(sessionDir, "session-no-id.jsonl"), '{"type":"session","cwd":"/tmp"}\n'); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("trustworthy topology claims"); + rmSync(join(sessionDir, "session-no-id.jsonl")); // An oversized root is skippable only when its bounded prefix is // demonstrably unrelated, including incomplete unrelated junk. A purported // session or identifier claim remains topology-relevant and fails closed. - writeFileSync(join(sessionDir, "oversized-junk.jsonl"), `not-json-${"x".repeat(300 * 1024)}`); + const classificationResponses: string[] = []; + setCatalogAfterHelperResponseForTest((mode, _id, line) => { + if (mode === "classify") classificationResponses.push(line); + }); + writeFileSync(join(sessionDir, "oversized-junk.jsonl"), `not-json-${"x".repeat(200 * 1024)}`); await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([expect.objectContaining({ id: "parent" })]); + expect(classificationResponses.every((line) => Buffer.byteLength(line, "ascii") <= 256 * 1024)).toBe(true); + setCatalogAfterHelperResponseForTest(undefined); rmSync(join(sessionDir, "oversized-junk.jsonl")); - writeFileSync(join(sessionDir, "oversized-event.jsonl"), `{"type":"message","body":"${"x".repeat(300 * 1024)}`); + writeFileSync(join(sessionDir, "oversized-event.jsonl"), `{"type":"message","body":"${"x".repeat(200 * 1024)}`); await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([expect.objectContaining({ id: "parent" })]); rmSync(join(sessionDir, "oversized-event.jsonl")); writeFileSync( join(sessionDir, "oversized-session.jsonl"), - `{"type":"session","id":"claimed","padding":"${"x".repeat(300 * 1024)}`, + `{"type":"session","id":"claimed","padding":"${"x".repeat(200 * 1024)}`, ); await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( "session header exceeds the trusted read limit", ); rmSync(join(sessionDir, "oversized-session.jsonl")); - writeFileSync(join(sessionDir, "oversized-id.jsonl"), `{"id":"claimed","padding":"${"x".repeat(300 * 1024)}`); + writeFileSync(join(sessionDir, "oversized-id.jsonl"), `{"id":"claimed","padding":"${"x".repeat(200 * 1024)}`); await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( "session header exceeds the trusted read limit", ); @@ -847,6 +843,20 @@ describe("daemon catalog selector resolution", () => { rmSync(root, { recursive: true, force: true }); }); + it("keeps registry-reached child headers strict when classification would truncate", async () => { + const { root, sessionDir, first } = createCatalogFamilyFixture(); + try { + const file = first.getSessionFile()!; + const entries = readFileSync(file, "utf8").trimEnd().split(/\r?\n/); + const header = JSON.parse(entries[0]!) as Record; + header.padding = "x".repeat(200 * 1024); + writeFileSync(file, `${[JSON.stringify(header), ...entries.slice(1)].join("\n")}\n`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("reads only the session header line even when the body is huge", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-header-only-")); const sessionDir = join(root, "sessions"); From ec7bc5ce73e8e7aeac796d375715adf7f90bb1f3 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 13:51:52 -0700 Subject: [PATCH 16/22] test(daemon): cover bounded catalog protocol envelopes --- .../test/daemon-catalog-process.test.ts | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 79d663f1d..e9c8b6e73 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -636,6 +636,15 @@ describe("daemon catalog selector resolution", () => { }), ), ).rejects.toThrow("child lacks a persisted parent path"); + // Registry-reached children never use the root-only classifier: a large + // apparent session header is strictly rejected rather than skipped. + await expect( + listCatalogFamilySessions( + make((header) => { + header.padding = "x".repeat(200 * 1024); + }), + ), + ).rejects.toThrow("Invalid RLM artifact family topology"); expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); }); @@ -742,9 +751,10 @@ describe("daemon catalog selector resolution", () => { `${[ JSON.stringify({ type: "message", message: { role: "user", content: payload } }), JSON.stringify({ type: "session_info", name: payload }), + JSON.stringify({ type: "session_state", state: { status: "archived" } }), JSON.stringify({ type: "agent_status", - status: { summary: payload, taskState: payload, basedOnMessageCount: 1 }, + status: { summary: payload, taskState: "completed", basedOnMessageCount: 1 }, }), ].join("\n")}\n`, ); @@ -761,10 +771,29 @@ describe("daemon catalog selector resolution", () => { agentStatus: expect.objectContaining({ summary: expect.any(String), basedOnMessageCount: 1 }), }), ); - expect(session?.agentStatus?.taskState).toBeUndefined(); + expect(session?.state).toEqual({ status: "archived" }); + expect(session?.agentStatus?.taskState).toBe("completed"); expect(responses).toHaveLength(1); const response = responses[0]!; - expect(JSON.parse(response.line).id).toBe(response.id); + const wire = JSON.parse(response.line) as { + id: string; + data: { + valid: boolean; + messageCount: number; + state?: { status: string }; + agentStatus?: { taskState?: string }; + }; + }; + // Compaction may touch display strings only: the protocol UUID and + // typed controls are byte-for-byte stable in the exact wire envelope. + expect(wire.id).toBe(response.id); + expect(wire.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(wire.data).toMatchObject({ + valid: true, + messageCount: 1, + state: { status: "archived" }, + agentStatus: { taskState: "completed" }, + }); expect(Buffer.byteLength(response.line, "ascii")).toBeLessThanOrEqual(256 * 1024); } finally { setCatalogAfterHelperResponseForTest(undefined); @@ -962,7 +991,7 @@ process.stdin.on("data", chunk => { while (true) { const end=input.indexOf("\n"); if (end<0) break; const request=JSON.parse(input.slice(0,end)); input=input.slice(end+1); - const payload=request.mode === "header" ? { id:request.id, data:${JSON.stringify(Buffer.from(header).toString("base64"))}, mtimeMs:0, dev:"1", ino:"1" } : request.mode === "metadata" ? { id:request.id, data:{valid:true,messageCount:0,firstMessage:"(no messages)",allMessagesText:"",modifiedMs:0}, mtimeMs:0, dev:"1", ino:"1" } : { id:request.id, mtimeMs:0, dev:"1", ino:"1" }; + const payload=(request.mode === "header" || request.mode === "classify") ? { id:request.id, data:${JSON.stringify(Buffer.from(header).toString("base64"))}, mtimeMs:0, dev:"1", ino:"1" } : request.mode === "metadata" ? { id:request.id, data:{valid:true,messageCount:0,firstMessage:"(no messages)",allMessagesText:"",modifiedMs:0}, mtimeMs:0, dev:"1", ino:"1" } : { id:request.id, mtimeMs:0, dev:"1", ino:"1" }; process.stdout.write(JSON.stringify(payload)+"\n"); if (request.mode === "metadata") setTimeout(() => process.stdout.write("{\"unsolicited\":true}\n"), 25); } From 070f4976306037c2bbdb971afd20359306dafd7d Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 13:57:12 -0700 Subject: [PATCH 17/22] fix(daemon): recognize escaped catalog claims --- .../modes/daemon/daemon-catalog-process.ts | 123 +++++++++++++++++- .../test/daemon-catalog-process.test.ts | 36 +++++ 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 34aa2a902..343fc7cd1 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -817,6 +817,123 @@ async function readTrustedSession(path: string, reader: TrustedReadSession): Pro * record purports to be an identified session, the strict descriptor-bound * reader owns every topology and protocol failure. */ +interface JsonStringPrefix { + value: string; + end: number; + terminated: boolean; + partialEscape: boolean; +} + +/** Read a JSON string without treating malformed input as executable JSON. */ +function readJsonStringPrefix(text: string, start: number): JsonStringPrefix | undefined { + if (text[start] !== '"') return undefined; + let value = ""; + let index = start + 1; + while (index < text.length) { + const char = text[index++]; + if (char === '"') return { value, end: index, terminated: true, partialEscape: false }; + if (char !== "\\") { + value += char; + continue; + } + if (index === text.length) return { value, end: index, terminated: false, partialEscape: true }; + const escapedChar = text[index++]; + const simpleEscape = + escapedChar === '"' + ? '"' + : escapedChar === "\\" + ? "\\" + : escapedChar === "/" + ? "/" + : escapedChar === "b" + ? "\b" + : escapedChar === "f" + ? "\f" + : escapedChar === "n" + ? "\n" + : escapedChar === "r" + ? "\r" + : escapedChar === "t" + ? "\t" + : undefined; + if (simpleEscape !== undefined) { + value += simpleEscape; + continue; + } + if (escapedChar !== "u") return { value, end: index, terminated: false, partialEscape: false }; + if (index + 4 > text.length) return { value, end: text.length, terminated: false, partialEscape: true }; + const hex = text.slice(index, index + 4); + if (!/^[0-9a-f]{4}$/iu.test(hex)) return { value, end: index + 4, terminated: false, partialEscape: false }; + value += String.fromCharCode(Number.parseInt(hex, 16)); + index += 4; + } + return { value, end: index, terminated: false, partialEscape: false }; +} + +function skipJsonValuePrefix(text: string, start: number): number { + let index = start; + while (/\s/u.test(text[index] ?? "")) index++; + const opening = text[index]; + if (opening === '"') return readJsonStringPrefix(text, index)?.end ?? text.length; + if (opening !== "{" && opening !== "[") { + while (index < text.length && !/[\s,}\]]/u.test(text[index] ?? "")) index++; + return index; + } + const closings = [opening === "{" ? "}" : "]"]; + index++; + while (index < text.length && closings.length > 0) { + const char = text[index]; + if (char === '"') { + const string = readJsonStringPrefix(text, index); + if (!string) return text.length; + index = string.end; + if (!string.terminated) return index; + continue; + } + if (char === "{") closings.push("}"); + else if (char === "[") closings.push("]"); + else if (char === closings.at(-1)) closings.pop(); + index++; + } + return index; +} + +/** + * Recognize only outer-object topology claims in an incomplete JSON prefix. + * JSON string escapes are decoded only while scanning their bounded syntax, so + * escaped keys cannot hide claims and strings in unrelated values cannot create + * one. A cut through a key escape is ambiguous and therefore fails closed. + */ +function hasApparentSessionTopologyClaimPrefix(text: string): boolean { + let index = 0; + while (/\s/u.test(text[index] ?? "")) index++; + if (text[index++] !== "{") return false; + while (index < text.length) { + while (/\s/u.test(text[index] ?? "")) index++; + if (text[index] === "}") return false; + const key = readJsonStringPrefix(text, index); + if (!key) return false; + if (!key.terminated) return key.partialEscape; + index = key.end; + while (/\s/u.test(text[index] ?? "")) index++; + if (text[index] !== ":") return false; + index++; + while (/\s/u.test(text[index] ?? "")) index++; + if (key.value === "id") return true; + if (key.value === "type") { + const value = readJsonStringPrefix(text, index); + if (!value) return false; + if (!value.terminated) return value.partialEscape || "session".startsWith(value.value); + if (value.value === "session") return true; + } + index = skipJsonValuePrefix(text, index); + while (/\s/u.test(text[index] ?? "")) index++; + if (text[index] !== ",") return false; + index++; + } + return false; +} + async function readTrustedRootCandidate( path: string, listed: { dev: string; ino: string }, @@ -834,9 +951,9 @@ async function readTrustedRootCandidate( candidate = JSON.parse(line) as { type?: unknown; id?: unknown }; } catch { // A bounded prefix may end in a concurrent partial write. Only a prefix - // that already purports to carry a session or identifier claim is part of - // the topology; unrelated incomplete junk remains skippable. - if (/^\s*\{[\s\S]*?"type"\s*:\s*"session(?:"|\\|$)|^\s*\{[\s\S]*?"id"\s*:/u.test(line)) + // carrying a safely decoded topology claim is part of the topology; + // unrelated incomplete junk remains skippable. + if (hasApparentSessionTopologyClaimPrefix(line)) throw invalidFamilyTopology("session header exceeds the trusted read limit"); return undefined; } diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index e9c8b6e73..8c619f851 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -872,6 +872,42 @@ describe("daemon catalog selector resolution", () => { rmSync(root, { recursive: true, force: true }); }); + it("classifies escaped and boundary-truncated root claims with the real helper", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-escaped-root-classification-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + + const escapedClaim = join(sessionDir, "escaped-claim.jsonl"); + writeFileSync( + escapedClaim, + `{"\\u0074ype":"\\u0073ession","\\u0069d":"claimed","padding":"${"x".repeat(200 * 1024)}"}\n`, + ); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(escapedClaim); + + const partialEscape = join(sessionDir, "partial-unicode-escape.jsonl"); + const partialClaim = '{"\\u0074ype":"\\u0073ess\\u006'; + writeFileSync( + partialEscape, + `${" ".repeat(64 * 1024 - Buffer.byteLength(partialClaim, "utf8"))}${partialClaim}9-padding-after-limit`, + ); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(partialEscape); + + writeFileSync( + join(sessionDir, "escaped-unrelated-junk.jsonl"), + `{"\\u006aunk":"\\u0073ession","padding":"${"x".repeat(200 * 1024)}"}\n`, + ); + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([expect.objectContaining({ id: "parent" })]); + rmSync(root, { recursive: true, force: true }); + }); + it("keeps registry-reached child headers strict when classification would truncate", async () => { const { root, sessionDir, first } = createCatalogFamilyFixture(); try { From f4283c677ea95a12fab3472ebd8ed8c070b792ba Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 14:04:20 -0700 Subject: [PATCH 18/22] fix(daemon): scan malformed catalog prefixes --- .../modes/daemon/daemon-catalog-process.ts | 51 +++++++++++++------ .../test/daemon-catalog-process.test.ts | 51 +++++++++++++++++++ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 343fc7cd1..a7b45c737 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -822,21 +822,28 @@ interface JsonStringPrefix { end: number; terminated: boolean; partialEscape: boolean; + invalidEscape: boolean; } -/** Read a JSON string without treating malformed input as executable JSON. */ +/** + * Read a JSON string without treating malformed input as executable JSON. + * + * Even after an invalid escape, retain string framing: a malformed unrelated + * value must not hide subsequent outer-object claims behind its closing quote. + */ function readJsonStringPrefix(text: string, start: number): JsonStringPrefix | undefined { if (text[start] !== '"') return undefined; let value = ""; let index = start + 1; + let invalidEscape = false; while (index < text.length) { const char = text[index++]; - if (char === '"') return { value, end: index, terminated: true, partialEscape: false }; + if (char === '"') return { value, end: index, terminated: true, partialEscape: false, invalidEscape }; if (char !== "\\") { value += char; continue; } - if (index === text.length) return { value, end: index, terminated: false, partialEscape: true }; + if (index === text.length) return { value, end: index, terminated: false, partialEscape: true, invalidEscape }; const escapedChar = text[index++]; const simpleEscape = escapedChar === '"' @@ -860,14 +867,26 @@ function readJsonStringPrefix(text: string, start: number): JsonStringPrefix | u value += simpleEscape; continue; } - if (escapedChar !== "u") return { value, end: index, terminated: false, partialEscape: false }; - if (index + 4 > text.length) return { value, end: text.length, terminated: false, partialEscape: true }; - const hex = text.slice(index, index + 4); - if (!/^[0-9a-f]{4}$/iu.test(hex)) return { value, end: index + 4, terminated: false, partialEscape: false }; - value += String.fromCharCode(Number.parseInt(hex, 16)); - index += 4; + if (escapedChar !== "u") { + invalidEscape = true; + continue; + } + const unicodeStart = index; + let hex = ""; + while (hex.length < 4 && index < text.length && /[0-9a-f]/iu.test(text[index] ?? "")) { + hex += text[index++]; + } + if (hex.length === 4) { + value += String.fromCharCode(Number.parseInt(hex, 16)); + continue; + } + invalidEscape = true; + // A truncated sequence is ambiguous. Otherwise leave the first invalid + // byte for normal framing, notably a terminating quote. + if (index === text.length) return { value, end: index, terminated: false, partialEscape: true, invalidEscape }; + index = unicodeStart + hex.length; } - return { value, end: index, terminated: false, partialEscape: false }; + return { value, end: index, terminated: false, partialEscape: false, invalidEscape }; } function skipJsonValuePrefix(text: string, start: number): number { @@ -913,17 +932,19 @@ function hasApparentSessionTopologyClaimPrefix(text: string): boolean { if (text[index] === "}") return false; const key = readJsonStringPrefix(text, index); if (!key) return false; - if (!key.terminated) return key.partialEscape; + // A malformed or partial outer key could itself be a topology key. + if (!key.terminated || key.invalidEscape) return true; index = key.end; while (/\s/u.test(text[index] ?? "")) index++; if (text[index] !== ":") return false; index++; while (/\s/u.test(text[index] ?? "")) index++; if (key.value === "id") return true; - if (key.value === "type") { - const value = readJsonStringPrefix(text, index); - if (!value) return false; - if (!value.terminated) return value.partialEscape || "session".startsWith(value.value); + if (key.value === "type" && text[index] === '"') { + const value = readJsonStringPrefix(text, index)!; + // Type's string value is itself a session claim surface; do not + // downgrade malformed or partial values to unrelated junk. + if (!value.terminated || value.invalidEscape) return true; if (value.value === "session") return true; } index = skipJsonValuePrefix(text, index); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 8c619f851..2f9d020f3 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -900,6 +900,57 @@ describe("daemon catalog selector resolution", () => { ); rmSync(partialEscape); + // Invalid escapes in a terminated unrelated value must not conceal later + // outer claims in the bounded helper prefix. + for (const [name, malformed] of [ + ["invalid-simple-escape", "\\q"], + ["invalid-unicode-escape", "\\u12xz"], + ] as const) { + const file = join(sessionDir, `${name}.jsonl`); + writeFileSync(file, `{"junk":"${malformed}","\\u0069d":"claimed","padding":"${"x".repeat(200 * 1024)}"}`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(file); + } + + const malformedUnicodeBeforeEscapedType = join(sessionDir, "invalid-unicode-before-escaped-type.jsonl"); + writeFileSync( + malformedUnicodeBeforeEscapedType, + `{"junk":"\\u12xz","\\u0074ype":"\\u0073ession","padding":"${"x".repeat(200 * 1024)}"}`, + ); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(malformedUnicodeBeforeEscapedType); + + const malformedNested = join(sessionDir, "malformed-nested-value.jsonl"); + writeFileSync(malformedNested, `{"junk":{"inner":"\\q"},"id":"claimed","padding":"${"x".repeat(200 * 1024)}"}`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(malformedNested); + + for (const [name, typeValue, id] of [ + ["null-type-before-id", "null", '"claimed"'], + ["object-type-before-escaped-id", '{"nested":"value"}', '"claimed"'], + ["array-type-before-id", '["value"]', '"claimed"'], + ] as const) { + const file = join(sessionDir, `${name}.jsonl`); + writeFileSync(file, `{"type":${typeValue},"\\u0069d":${id},"padding":"${"x".repeat(200 * 1024)}"}`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(file); + } + + // Once the bounded scanner reaches the outer close without a claim, an + // otherwise malformed unrelated record remains safely skippable. + const malformedUnrelated = join(sessionDir, "malformed-unrelated-junk.jsonl"); + writeFileSync(malformedUnrelated, `{"junk":"\\q","padding":"${"x".repeat(200 * 1024)}"}`); + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([expect.objectContaining({ id: "parent" })]); + rmSync(malformedUnrelated); + writeFileSync( join(sessionDir, "escaped-unrelated-junk.jsonl"), `{"\\u006aunk":"\\u0073ession","padding":"${"x".repeat(200 * 1024)}"}\n`, From 753d91ccb843b94d701ff71c8ab76cd241773157 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 14:25:42 -0700 Subject: [PATCH 19/22] fix: bound daemon catalog root discovery --- .../modes/daemon/daemon-catalog-process.ts | 68 ++++++++++++++----- .../test/daemon-catalog-process.test.ts | 60 ++++++++++++++++ 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index a7b45c737..f4794513e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -81,6 +81,18 @@ const MAX_RLM_FAMILY_EDGES = 10_000; const MAX_RLM_FAMILY_NODES = 10_000; const MAX_RLM_FAMILY_DEPTH = 64; +// A narrow test seam avoids creating ten thousand filesystem entries merely to +// verify that root enumeration fails before opening any candidate descriptor. +let maxRlmFamilyNodesForTest: number | undefined; +/** @internal */ +export function setCatalogMaxRlmFamilyNodesForTest(limit: number | undefined): void { + maxRlmFamilyNodesForTest = limit; +} + +function maxRlmFamilyNodes(): number { + return maxRlmFamilyNodesForTest ?? MAX_RLM_FAMILY_NODES; +} + interface ManagedRoot { lexical: string; fd: number; @@ -934,12 +946,18 @@ function hasApparentSessionTopologyClaimPrefix(text: string): boolean { if (!key) return false; // A malformed or partial outer key could itself be a topology key. if (!key.terminated || key.invalidEscape) return true; + // A decoded id is topology-relevant before its delimiter or value can + // arrive. In particular, a 64 KiB cut immediately after `"id"` must + // not turn an identified root into harmless junk. + if (key.value === "id") return true; index = key.end; while (/\s/u.test(text[index] ?? "")) index++; - if (text[index] !== ":") return false; + // Likewise a decoded outer type cannot safely be downgraded while its + // colon/value is absent or only whitespace has reached the prefix end. + if (text[index] !== ":") return key.value === "type"; index++; while (/\s/u.test(text[index] ?? "")) index++; - if (key.value === "id") return true; + if (key.value === "type" && index === text.length) return true; if (key.value === "type" && text[index] === '"') { const value = readJsonStringPrefix(text, index)!; // Type's string value is itself a session claim surface; do not @@ -947,7 +965,18 @@ function hasApparentSessionTopologyClaimPrefix(text: string): boolean { if (!value.terminated || value.invalidEscape) return true; if (value.value === "session") return true; } + const valueStart = index; index = skipJsonValuePrefix(text, index); + if (key.value === "type" && text[valueStart] !== '"') { + // A non-string type is harmless only after its complete JSON value is + // demonstrable. A cut through `nul`, `{`, or any nested value remains + // ambiguous rather than becoming a skip at the read boundary. + try { + JSON.parse(text.slice(valueStart, index).trim()); + } catch { + return true; + } + } while (/\s/u.test(text[index] ?? "")) index++; if (text[index] !== ",") return false; index++; @@ -1051,19 +1080,26 @@ async function readLatestRegistry( function listSessionCandidates(sessionDir: string): Array<{ path: string; dev: string; ino: string }> { try { - return readdirSync(sessionDir) - .filter((entry) => entry.endsWith(".jsonl")) - .flatMap((entry) => { - const path = join(resolve(sessionDir), entry); - try { - const stat = lstatSync(path); - return [{ path, dev: String(stat.dev), ino: String(stat.ino) }]; - } catch { - // Removed during enumeration: it was never a stable root candidate. - return []; - } - }); - } catch { + const candidates: Array<{ path: string; dev: string; ino: string }> = []; + const limit = maxRlmFamilyNodes(); + for (const entry of readdirSync(sessionDir)) { + if (!entry.endsWith(".jsonl")) continue; + const path = join(resolve(sessionDir), entry); + try { + const stat = lstatSync(path); + // Count every stable root candidate, including junk and duplicate ids, + // before opening a descriptor or scanning metadata. Removed entries + // retain their historical behavior and never become candidates. + candidates.push({ path, dev: String(stat.dev), ino: String(stat.ino) }); + if (candidates.length > limit) throw invalidFamilyTopology("family node limit exhausted"); + } catch (error) { + if ((error as Error).message.includes("family node limit exhausted")) throw error; + // Removed during enumeration: it was never a stable root candidate. + } + } + return candidates; + } catch (error) { + if ((error as Error).message.includes("family node limit exhausted")) throw error; return []; } } @@ -1158,7 +1194,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise= MAX_RLM_FAMILY_NODES) + if (!existing && sessions.size >= maxRlmFamilyNodes()) throw invalidFamilyTopology("family node limit exhausted"); ids.set(child.id, child.path); sessions.set(child.path, child); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 2f9d020f3..f1a81f711 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -22,6 +22,7 @@ import { setCatalogAfterTrustedHeaderForTest, setCatalogBeforeTrustedOpenForTest, setCatalogHelperLaunchForTest, + setCatalogMaxRlmFamilyNodesForTest, } from "../src/modes/daemon/daemon-catalog-process.js"; function session(id: string, name: string | undefined, path: string): SessionInfo { @@ -872,6 +873,37 @@ describe("daemon catalog selector resolution", () => { rmSync(root, { recursive: true, force: true }); }); + it("bounds stable root discovery before descriptor reads and admits the exact limit", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-root-discovery-bound-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + let trustedOpens = 0; + setCatalogBeforeTrustedOpenForTest(() => { + trustedOpens++; + }); + try { + // The narrow limit hook proves both the exact-limit success case and + // over-limit failure without creating 10,001 filesystem entries. + setCatalogMaxRlmFamilyNodesForTest(1); + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([ + expect.objectContaining({ id: "parent" }), + ]); + expect(trustedOpens).toBeGreaterThan(0); + trustedOpens = 0; + writeFileSync(join(sessionDir, "unrelated.jsonl"), "not json\n"); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("family node limit exhausted"); + // No candidate descriptor may be opened, classified, or metadata-scanned + // after discovery proves the family is over the hard bound. + expect(trustedOpens).toBe(0); + } finally { + setCatalogMaxRlmFamilyNodesForTest(undefined); + setCatalogBeforeTrustedOpenForTest(undefined); + rmSync(root, { recursive: true, force: true }); + } + }); + it("classifies escaped and boundary-truncated root claims with the real helper", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-escaped-root-classification-")); const sessionDir = join(root, "sessions"); @@ -900,6 +932,34 @@ describe("daemon catalog selector resolution", () => { ); rmSync(partialEscape); + // These cross the actual 64 KiB classifier read boundary. A fully decoded + // outer type whose colon/value is still beyond that boundary, and a fully + // decoded outer id with its delimiter/value beyond it, are topology claims. + const boundaryType = join(sessionDir, "boundary-type-whitespace.jsonl"); + const typePrefix = `{"type"${" ".repeat(64 * 1024 - Buffer.byteLength('{"type"', "utf8"))}`; + writeFileSync(boundaryType, `${typePrefix}:"session","id":"claimed","padding":"${"x".repeat(200 * 1024)}"}`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(boundaryType); + + const boundaryId = join(sessionDir, "boundary-id-no-value.jsonl"); + const idPrefix = `{"junk":0,"id"${" ".repeat(64 * 1024 - Buffer.byteLength('{"junk":0,"id"', "utf8"))}`; + writeFileSync(boundaryId, `${idPrefix}:"claimed","padding":"${"x".repeat(200 * 1024)}"}`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(boundaryId); + + // A complete demonstrably non-string type value remains unrelated; the + // following outer id is still scanned and cannot be hidden by that skip. + const nonStringThenId = join(sessionDir, "non-string-type-then-id.jsonl"); + writeFileSync(nonStringThenId, `{"type":null,"id":"claimed","padding":"${"x".repeat(200 * 1024)}"}`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(nonStringThenId); + // Invalid escapes in a terminated unrelated value must not conceal later // outer claims in the bounded helper prefix. for (const [name, malformed] of [ From 8d3f2d3b089b18ff1b7104587bf028f0eefbe21d Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 14:30:47 -0700 Subject: [PATCH 20/22] fix(daemon): bound catalog root enumeration --- .../modes/daemon/daemon-catalog-process.ts | 96 ++++++++++++------- .../test/daemon-catalog-process.test.ts | 14 +-- 2 files changed, 69 insertions(+), 41 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index f4794513e..f5a92348a 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1,6 +1,6 @@ import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { closeSync, constants, lstatSync, openSync, readdirSync } from "node:fs"; +import { closeSync, constants, lstatSync, opendirSync, openSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; import { getSessionsDir } from "../../config.js"; @@ -81,16 +81,10 @@ const MAX_RLM_FAMILY_EDGES = 10_000; const MAX_RLM_FAMILY_NODES = 10_000; const MAX_RLM_FAMILY_DEPTH = 64; -// A narrow test seam avoids creating ten thousand filesystem entries merely to -// verify that root enumeration fails before opening any candidate descriptor. -let maxRlmFamilyNodesForTest: number | undefined; -/** @internal */ -export function setCatalogMaxRlmFamilyNodesForTest(limit: number | undefined): void { - maxRlmFamilyNodesForTest = limit; -} - -function maxRlmFamilyNodes(): number { - return maxRlmFamilyNodesForTest ?? MAX_RLM_FAMILY_NODES; +function validateFamilyNodeLimit(limit: number): void { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RLM_FAMILY_NODES) { + throw new Error("Invalid catalog family node limit"); + } } interface ManagedRoot { @@ -1078,35 +1072,57 @@ async function readLatestRegistry( return [...latest.values()]; } -function listSessionCandidates(sessionDir: string): Array<{ path: string; dev: string; ino: string }> { +function listSessionCandidates(sessionDir: string, limit: number): Array<{ path: string; dev: string; ino: string }> { + let directory: ReturnType; try { - const candidates: Array<{ path: string; dev: string; ino: string }> = []; - const limit = maxRlmFamilyNodes(); - for (const entry of readdirSync(sessionDir)) { - if (!entry.endsWith(".jsonl")) continue; - const path = join(resolve(sessionDir), entry); - try { - const stat = lstatSync(path); - // Count every stable root candidate, including junk and duplicate ids, - // before opening a descriptor or scanning metadata. Removed entries - // retain their historical behavior and never become candidates. + directory = opendirSync(sessionDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + + const candidates: Array<{ path: string; dev: string; ino: string }> = []; + const limitExceeded = Symbol("catalog root discovery limit exceeded"); + try { + try { + while (true) { + const entry = directory.readSync(); + if (!entry) break; + if (!entry.name.endsWith(".jsonl")) continue; + const path = join(resolve(sessionDir), entry.name); + let stat: ReturnType; + try { + stat = lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + // Removed during enumeration: it was never a stable root candidate. + continue; + } + throw error; + } candidates.push({ path, dev: String(stat.dev), ino: String(stat.ino) }); - if (candidates.length > limit) throw invalidFamilyTopology("family node limit exhausted"); - } catch (error) { - if ((error as Error).message.includes("family node limit exhausted")) throw error; - // Removed during enumeration: it was never a stable root candidate. + // Stop immediately at limit + 1. No further directory entries are read, + // and no candidate descriptor is opened from an incomplete root set. + if (candidates.length > limit) throw limitExceeded; } + } catch (error) { + if (error === limitExceeded) { + throw invalidFamilyTopology("family node limit exhausted during root discovery"); + } + throw error; } - return candidates; - } catch (error) { - if ((error as Error).message.includes("family node limit exhausted")) throw error; - return []; + } finally { + directory.closeSync(); } + return candidates; } -export async function listCatalogFamilySessions(sessionDir?: string): Promise { +async function listCatalogFamilySessionsWithLimit( + sessionDir: string | undefined, + limit: number, +): Promise { const effectiveSessionDir = sessionDir ?? getSessionsDir(); - const roots = listSessionCandidates(effectiveSessionDir); + const roots = listSessionCandidates(effectiveSessionDir, limit); const authority = managedRoots(effectiveSessionDir); const reader = new TrustedReadSession(authority); let result: SessionInfo[] | undefined; @@ -1194,8 +1210,7 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise= maxRlmFamilyNodes()) - throw invalidFamilyTopology("family node limit exhausted"); + if (!existing && sessions.size >= limit) throw invalidFamilyTopology("family node limit exhausted"); ids.set(child.id, child.path); sessions.set(child.path, child); await visit(child, depth + 1, childAncestors); @@ -1222,6 +1237,19 @@ export async function listCatalogFamilySessions(sessionDir?: string): Promise { + validateFamilyNodeLimit(limit); + return listCatalogFamilySessionsWithLimit(sessionDir, limit); +} + +export function listCatalogFamilySessions(sessionDir?: string): Promise { + return listCatalogFamilySessionsWithLimit(sessionDir, MAX_RLM_FAMILY_NODES); +} + export async function listSavedSessionSiblings(sessionPath: string, sessionDir?: string): Promise { const family = await listCatalogFamilySessions(sessionDir); const targetPath = resolve(sessionPath); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index f1a81f711..0ab074ca4 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -16,13 +16,13 @@ import { getCatalogHelperSpawnCountForTest, getOpenCatalogAuthorityFdCountForTest, listCatalogFamilySessions, + listCatalogFamilySessionsWithLimitForTest, listSavedSessionSiblings, resolveCatalogSessionMatch, setCatalogAfterHelperResponseForTest, setCatalogAfterTrustedHeaderForTest, setCatalogBeforeTrustedOpenForTest, setCatalogHelperLaunchForTest, - setCatalogMaxRlmFamilyNodesForTest, } from "../src/modes/daemon/daemon-catalog-process.js"; function session(id: string, name: string | undefined, path: string): SessionInfo { @@ -884,21 +884,21 @@ describe("daemon catalog selector resolution", () => { trustedOpens++; }); try { - // The narrow limit hook proves both the exact-limit success case and - // over-limit failure without creating 10,001 filesystem entries. - setCatalogMaxRlmFamilyNodesForTest(1); - await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([ + // The per-call helper proves exact/over behavior without mutable global + // security state or creating 10,001 filesystem entries. + await expect(listCatalogFamilySessionsWithLimitForTest(sessionDir, 1)).resolves.toEqual([ expect.objectContaining({ id: "parent" }), ]); expect(trustedOpens).toBeGreaterThan(0); trustedOpens = 0; writeFileSync(join(sessionDir, "unrelated.jsonl"), "not json\n"); - await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("family node limit exhausted"); + await expect(listCatalogFamilySessionsWithLimitForTest(sessionDir, 1)).rejects.toThrow( + "family node limit exhausted during root discovery", + ); // No candidate descriptor may be opened, classified, or metadata-scanned // after discovery proves the family is over the hard bound. expect(trustedOpens).toBe(0); } finally { - setCatalogMaxRlmFamilyNodesForTest(undefined); setCatalogBeforeTrustedOpenForTest(undefined); rmSync(root, { recursive: true, force: true }); } From 731af01e7bf1ba8c4eb5d38a32eecbbbcc2b996d Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 14:32:22 -0700 Subject: [PATCH 21/22] fix(daemon): harden catalog limit handling --- .../modes/daemon/daemon-catalog-process.ts | 16 ++++- .../test/daemon-catalog-process.test.ts | 60 ++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index f5a92348a..9b80746c8 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1072,12 +1072,17 @@ async function readLatestRegistry( return [...latest.values()]; } -function listSessionCandidates(sessionDir: string, limit: number): Array<{ path: string; dev: string; ino: string }> { +function listSessionCandidates( + sessionDir: string, + limit: number, +): Array<{ path: string; dev: string; ino: string }> | undefined { let directory: ReturnType; try { directory = opendirSync(sessionDir); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + // Preserve the catalog's missing-directory behavior without conflating an + // absent directory with unrelated enumeration failures. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } @@ -1123,6 +1128,7 @@ async function listCatalogFamilySessionsWithLimit( ): Promise { const effectiveSessionDir = sessionDir ?? getSessionsDir(); const roots = listSessionCandidates(effectiveSessionDir, limit); + if (!roots) return []; const authority = managedRoots(effectiveSessionDir); const reader = new TrustedReadSession(authority); let result: SessionInfo[] | undefined; @@ -1159,6 +1165,11 @@ async function listCatalogFamilySessionsWithLimit( if (++edges > MAX_RLM_FAMILY_EDGES) throw invalidFamilyTopology("family edge limit exhausted"); const childPath = entry.sessionFile as string; if (childAncestors.has(childPath)) throw invalidFamilyTopology("family contains a cycle"); + // Registry children share the same hard family-node budget. Fail before + // opening a new child descriptor when the exact limit is already full. + if (!sessions.has(childPath) && sessions.size >= limit) { + throw invalidFamilyTopology("family node limit exhausted"); + } const trustedChild = await readTrustedSession(childPath, reader); if (basename(childPath, ".jsonl") !== trustedChild.id) throw invalidFamilyTopology("registry child session file does not match its header id"); @@ -1210,7 +1221,6 @@ async function listCatalogFamilySessionsWithLimit( ) { throw invalidFamilyTopology("family contains a conflicting duplicate"); } - if (!existing && sessions.size >= limit) throw invalidFamilyTopology("family node limit exhausted"); ids.set(child.id, child.path); sessions.set(child.path, child); await visit(child, depth + 1, childAncestors); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 0ab074ca4..c46cc0b2f 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -884,6 +884,12 @@ describe("daemon catalog selector resolution", () => { trustedOpens++; }); try { + expect(() => listCatalogFamilySessionsWithLimitForTest(sessionDir, 0)).toThrow( + "Invalid catalog family node limit", + ); + expect(() => listCatalogFamilySessionsWithLimitForTest(sessionDir, Number.MAX_SAFE_INTEGER)).toThrow( + "Invalid catalog family node limit", + ); // The per-call helper proves exact/over behavior without mutable global // security state or creating 10,001 filesystem entries. await expect(listCatalogFamilySessionsWithLimitForTest(sessionDir, 1)).resolves.toEqual([ @@ -904,6 +910,41 @@ describe("daemon catalog selector resolution", () => { } }); + it("counts registry children in the exact family-node limit before descriptor reads", async () => { + const { root, sessionDir, parent } = createCatalogFamilyFixture(); + try { + await expect(listCatalogFamilySessionsWithLimitForTest(sessionDir, 3)).resolves.toHaveLength(3); + + const third = SessionManager.create( + root, + join(root, "session-artifacts", parent.getSessionId(), "sub-33333333"), + ); + third.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); + third.appendSessionInfo("third"); + const registry = join(root, "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); + appendFileSync( + registry, + `\n${JSON.stringify({ + type: "rlm_subagent", + childId: "sub-33333333", + sessionFile: third.getSessionFile(), + status: "completed", + })}`, + ); + let thirdOpened = false; + setCatalogBeforeTrustedOpenForTest((path) => { + if (path === third.getSessionFile()) thirdOpened = true; + }); + await expect(listCatalogFamilySessionsWithLimitForTest(sessionDir, 3)).rejects.toThrow( + "family node limit exhausted", + ); + expect(thirdOpened).toBe(false); + } finally { + setCatalogBeforeTrustedOpenForTest(undefined); + rmSync(root, { recursive: true, force: true }); + } + }); + it("classifies escaped and boundary-truncated root claims with the real helper", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-escaped-root-classification-")); const sessionDir = join(root, "sessions"); @@ -943,13 +984,18 @@ describe("daemon catalog selector resolution", () => { ); rmSync(boundaryType); - const boundaryId = join(sessionDir, "boundary-id-no-value.jsonl"); - const idPrefix = `{"junk":0,"id"${" ".repeat(64 * 1024 - Buffer.byteLength('{"junk":0,"id"', "utf8"))}`; - writeFileSync(boundaryId, `${idPrefix}:"claimed","padding":"${"x".repeat(200 * 1024)}"}`); - await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( - "session header exceeds the trusted read limit", - ); - rmSync(boundaryId); + for (const [name, beforeBoundary, suffix] of [ + ["boundary-id-no-colon", '{"junk":0,"id"', ':"claimed","padding":"'], + ["boundary-id-colon-no-value", '{"junk":0,"id":', '"claimed","padding":"'], + ] as const) { + const boundaryId = join(sessionDir, `${name}.jsonl`); + const idPrefix = `${beforeBoundary}${" ".repeat(64 * 1024 - Buffer.byteLength(beforeBoundary, "utf8"))}`; + writeFileSync(boundaryId, `${idPrefix}${suffix}${"x".repeat(200 * 1024)}"}`); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "session header exceeds the trusted read limit", + ); + rmSync(boundaryId); + } // A complete demonstrably non-string type value remains unrelated; the // following outer id is still scanned and cannot be hidden by that skip. From 4e2bbd21504bd9acac5c6dd58b3bd8eff256de94 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 13 Aug 2026 16:14:14 -0700 Subject: [PATCH 22/22] fix(daemon): preserve exact catalog root inode identity --- .../modes/daemon/daemon-catalog-process.ts | 5 +- .../daemon-catalog-inode-precision.test.ts | 113 ++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/daemon-catalog-inode-precision.test.ts diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index 9b80746c8..ce0d945e7 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1095,9 +1095,9 @@ function listSessionCandidates( if (!entry) break; if (!entry.name.endsWith(".jsonl")) continue; const path = join(resolve(sessionDir), entry.name); - let stat: ReturnType; try { - stat = lstatSync(path); + const stat = lstatSync(path, { bigint: true }); + candidates.push({ path, dev: stat.dev.toString(), ino: stat.ino.toString() }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { // Removed during enumeration: it was never a stable root candidate. @@ -1105,7 +1105,6 @@ function listSessionCandidates( } throw error; } - candidates.push({ path, dev: String(stat.dev), ino: String(stat.ino) }); // Stop immediately at limit + 1. No further directory entries are read, // and no candidate descriptor is opened from an incomplete root set. if (candidates.length > limit) throw limitExceeded; diff --git a/packages/coding-agent/test/daemon-catalog-inode-precision.test.ts b/packages/coding-agent/test/daemon-catalog-inode-precision.test.ts new file mode 100644 index 000000000..4328b617d --- /dev/null +++ b/packages/coding-agent/test/daemon-catalog-inode-precision.test.ts @@ -0,0 +1,113 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const fsMocks = vi.hoisted(() => ({ + lstatSync: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + fsMocks.lstatSync.mockImplementation(actual.lstatSync); + return { ...actual, lstatSync: fsMocks.lstatSync }; +}); + +const { listCatalogFamilySessions, setCatalogHelperLaunchForTest } = await import( + "../src/modes/daemon/daemon-catalog-process.js" +); + +const UNSAFE_INODE = 9_007_199_254_740_993n; +const SESSION_HEADER = `${JSON.stringify({ + type: "session", + id: "parent", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/tmp/project", + rlmDepth: 0, +})}\n`; + +function helperForInode(ino: bigint): string { + return ` +let input = ""; +process.stdin.on("data", (chunk) => { + input += chunk; + while (true) { + const newline = input.indexOf("\\n"); + if (newline === -1) return; + const request = JSON.parse(input.slice(0, newline)); + input = input.slice(newline + 1); + const response = { id: request.id, mtimeMs: 0, dev: "1", ino: "${ino}" }; + if (request.mode === "metadata") { + response.data = { + valid: true, + messageCount: 0, + firstMessage: "(no messages)", + allMessagesText: "", + modifiedMs: 0, + }; + } else if (request.mode !== "stat") { + response.data = "${Buffer.from(SESSION_HEADER).toString("base64")}"; + } + process.stdout.write(JSON.stringify(response) + "\\n"); + } +}); +`; +} + +function mockUnsafeInode(sessionFile: string): void { + fsMocks.lstatSync.mockImplementation(((path: string, options?: { bigint?: boolean }) => { + if (path !== sessionFile) throw new Error(`unexpected lstat path: ${path}`); + return options?.bigint === true + ? ({ dev: 1n, ino: UNSAFE_INODE } as ReturnType) + : ({ dev: 1, ino: Number(UNSAFE_INODE) } as ReturnType); + }) as never); +} + +function createFixture() { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-unsafe-inode-")); + const sessionDir = join(root, "sessions"); + const sessionFile = join(sessionDir, "parent.jsonl"); + mkdirSync(sessionDir); + writeFileSync(sessionFile, SESSION_HEADER); + return { root, sessionDir, sessionFile }; +} + +afterEach(() => { + setCatalogHelperLaunchForTest(undefined); + fsMocks.lstatSync.mockReset(); +}); + +describe("daemon catalog root identity", () => { + it("matches an unsafe inode from lstat to the helper's exact fstat decimal", async () => { + const { root, sessionDir, sessionFile } = createFixture(); + mockUnsafeInode(sessionFile); + setCatalogHelperLaunchForTest({ command: process.execPath, args: ["-e", helperForInode(UNSAFE_INODE)] }); + + try { + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([ + expect.objectContaining({ id: "parent" }), + ]); + expect(fsMocks.lstatSync).toHaveBeenCalledWith(sessionFile, { bigint: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects an unsafe inode differing from the helper below Number precision", async () => { + const { root, sessionDir, sessionFile } = createFixture(); + mockUnsafeInode(sessionFile); + setCatalogHelperLaunchForTest({ + command: process.execPath, + args: ["-e", helperForInode(UNSAFE_INODE - 1n)], + }); + + try { + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow( + "root candidate changed after directory enumeration", + ); + expect(fsMocks.lstatSync).toHaveBeenCalledWith(sessionFile, { bigint: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});