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..e2d1a05e2 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1013,12 +1013,80 @@ export async function readSessionInfo(filePath: string): Promise>): Promise { +/** + * Parse only descriptor-authorized session-header bytes. Unlike readSessionInfo, + * this deliberately never follows a legacy parent path or scans the body. + */ +export function readSessionHeaderInfoFromBuffer( + filePath: string, + contents: Buffer, + metadata: { mtimeMs: number }, +): SessionInfo | null { + try { + const newline = contents.indexOf(0x0a); + const line = contents + .subarray(0, newline === -1 ? contents.length : newline) + .toString("utf8") + .trim(); + const header = JSON.parse(line) as Partial; + 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, + 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 +1097,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,1203 @@ 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"); +// 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 = 64 * 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; + +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 { + lexical: string; + fd: number; +} + +interface ManagedRoots { + session: ManagedRoot; + artifacts: ManagedRoot | undefined; +} + +interface TrustedFile { + path: string; + contents: Buffer; + mtimeMs: number; + dev: string; + ino: string; + /** A header exceeded the bounded descriptor read; only untrusted roots may skip it. */ + truncated?: boolean; +} + +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; + persistedParentPath?: string; + persistedVersion?: number; +} + +/** 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 { + // 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 { + 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; +} + +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. + 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); + openAuthorityFdCountForTest--; + 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 +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): + # 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=[] + # 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: + 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,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 + 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 + 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": + # 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") + 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 + 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","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) + 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): reject() + if mode=="read" and before.st_size>limit: reject() + payload={} + 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 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: + # 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() + 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)}) + return payload + finally: os.close(fd) + finally: os.close(current) +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) + 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 + 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")} + # 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 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() +`; + +/** 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; +} + +/** 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; +} + +/** 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" | "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; + 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" || + ((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" || + typeof (metadata.agentStatus as { summary?: unknown }).summary !== "string" || + !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; + } + return true; +} + +/** + * 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(); + private stdinEnded = false; + private exit: { code: number | null; signal: NodeJS.Signals | null } | undefined; + private readonly stdoutFinished: Promise; + private resolveStdoutFinished!: () => 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]; + if (roots.artifacts) stdio.push(roots.artifacts.fd); + this.stdoutFinished = new Promise((resolveFinished) => { + this.resolveStdoutFinished = resolveFinished; + }); + this.terminated = new Promise((resolveTerminated) => { + this.resolveTerminated = resolveTerminated; + }); + 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)}`)), + ); + 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) => { + // 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.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: 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; + if (this.failure) { + await this.awaitCleanup(); + throw this.closeError(this.failure); + } + if (this.pending || this.stdoutBuffer !== "") { + this.fail(new Error("catalog openat helper has residual output")); + await this.awaitCleanup(); + throw this.closeError(this.failure!); + } + this.stdinEnded = true; + this.child.stdin?.end(); + 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 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 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( + (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 requestId = randomUUID(); + const line = await this.exchange( + JSON.stringify({ id: requestId, parts, limit: maxBytes, mode, root: rootFd }), + mode === "read" ? maxBytes * 2 + 64 * 1024 : MAX_METADATA_RESPONSE_BYTES, + ); + let wire: { + id?: unknown; + error?: unknown; + data?: unknown; + truncated?: unknown; + mtimeMs?: unknown; + dev?: unknown; + ino?: unknown; + }; + 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"); + } + 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"); + const truncatedHeader = mode === "classify" && wire.truncated === true; + if ( + wire.error !== undefined || + (wire.truncated !== undefined && !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" + ) { + throw invalidFamilyTopology("descriptor-relative artifact read failed"); + } + return { + path: rawPath, + contents: typeof wire.data === "string" ? Buffer.from(wire.data, "base64") : Buffer.alloc(0), + mtimeMs: wire.mtimeMs, + dev: wire.dev, + ino: wire.ino, + ...(truncatedHeader ? { truncated: true } : {}), + ...(mode === "metadata" ? { metadata: wire.data as TrustedSessionMetadata } : {}), + }; + } + + private exchange(request: string, responseCap: number): Promise { + if (this.failure || this.stdinEnded) + return Promise.reject( + 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, + ); + 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); + const suffix = this.stdoutBuffer.slice(end + 1); + this.stdoutBuffer = ""; + this.pending = undefined; + clearTimeout(pending.timeout); + 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; + } + pending.resolve(line); + } + + 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; + if (pending) { + clearTimeout(pending.timeout); + pending.reject(invalidFamilyTopology(`descriptor-relative artifact read failed: ${error.message}`)); + } + } +} + +/** + * Topology claims (id, parent, depth) come from descriptor-bound header bytes. + * Display metadata (name, timestamps, previews) is not part of the trust + * 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): 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; + id?: unknown; + parentSession?: unknown; + rlmDepth?: unknown; + version?: unknown; + cwd?: unknown; + timestamp?: 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; + 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.version !== undefined && !hasVersion) + ) + throw invalidFamilyTopology("session header lacks trustworthy topology claims"); + const persistedDepth = hasDepth ? (header.rlmDepth as number) : undefined; + afterTrustedHeaderForTest?.(path); + // 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"); + 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, + rlmDepth: persistedDepth ?? 0, + parentSessionPath: hasParent ? (header.parentSession as string) : undefined, + ...(persistedDepth !== undefined ? { persistedDepth } : {}), + ...(hasParent ? { persistedParentPath: header.parentSession as string } : {}), + ...(hasVersion ? { persistedVersion: header.version as number } : {}), + }; +} + +/** + * 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. + */ +interface JsonStringPrefix { + value: string; + end: number; + terminated: boolean; + partialEscape: boolean; + invalidEscape: boolean; +} + +/** + * 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, invalidEscape }; + if (char !== "\\") { + value += char; + continue; + } + if (index === text.length) return { value, end: index, terminated: false, partialEscape: true, invalidEscape }; + 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") { + 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, invalidEscape }; +} + +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; + // 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++; + // 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 === "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 + // downgrade malformed or partial values to unrelated junk. + 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++; + } + return false; +} + +async function readTrustedRootCandidate( + path: string, + listed: { dev: string; ino: string }, + reader: TrustedReadSession, +): Promise { + 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) + throw invalidFamilyTopology("root candidate changed after directory enumeration"); + const line = header.contents.toString("utf8").split(/\r?\n/, 1)[0] ?? ""; + if (!line.trim()) return undefined; + 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 + // 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; + } + 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; + // 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); +} + +/** + * /fork and lineage-carrying /new save sessions directly into the sessions dir + * with a parentSession claim naming their source. That claim is fork ancestry, + * not rlm topology: the session is a family root and the claim is ignored. + * Parent claims that leave the sessions dir still fail closed. + */ +function asTrustedFamilyRoot(trusted: TrustedSession, roots: ManagedRoots): FamilySession { + if (trusted.persistedParentPath !== undefined) { + const claimedParent = resolve(dirname(trusted.path), trusted.persistedParentPath); + if (dirname(trusted.path) !== roots.session.lexical || !isWithin(roots.session.lexical, claimedParent)) { + throw invalidFamilyTopology("managed session seed claims a parent"); + } + const { persistedParentPath: _forkSource, parentSessionPath: _forkLineage, ...root } = trusted; + return { ...root, rlmDepth: 0, persistedDepth: 0 }; + } + if (trusted.persistedDepth !== undefined && trusted.persistedDepth !== 0) { + throw invalidFamilyTopology("managed session seed claims a nonzero depth"); + } + return { ...trusted, rlmDepth: 0, persistedDepth: 0 }; +} + +async function readLatestRegistry( + path: string, + reader: TrustedReadSession, +): Promise { let contents: string; try { - contents = await readFile(registryPath, "utf8"); + contents = (await reader.read(path, MAX_RLM_REGISTRY_BYTES, "read")).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" || + !/^sub-[0-9a-f]{8}$/.test(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()]; +} + +function listSessionCandidates( + sessionDir: string, + limit: number, +): Array<{ path: string; dev: string; ino: string }> | undefined { + let directory: ReturnType; + try { + directory = opendirSync(sessionDir); + } catch (error) { + // 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; + } + + 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); + try { + 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. + continue; + } + throw error; + } + // 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; + } + } finally { + directory.closeSync(); + } + return candidates; +} + +async function listCatalogFamilySessionsWithLimit( + sessionDir: string | undefined, + limit: number, +): 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; + let traversalFailure: unknown; + try { + const sessions = new Map(); + const ids = new Map(); + for (const root of roots) { + 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"); + ids.set(trusted.id, trusted.path); + sessions.set(trusted.path, trusted); + } + let edges = 0; + const visited = new Set(); + 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"); + if (visited.has(parentPath)) return; + visited.add(parentPath); + const registryPath = rlmSubagentRegistryPath(parent, authority); + if (!registryPath) return; + const entries = await readLatestRegistry(registryPath, reader); + 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"); + // 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"); + // Registry reachability is not enough: bind the registry key to the + // directory the writer allocates directly below this parent's managed + // artifact root. This rejects arbitrary sessions-root descendants and + // prevents rekeying a real child under another sub-* id. + const childId = entry.childId as string; + const childSessionDir = dirname(childPath); + const parentSessionDir = dirname(parent.path); + const writerChildrenRoot = + parentSessionDir === authority.session.lexical + ? join(authority.artifacts?.lexical ?? "", parent.id) + : parentSessionDir; + // The actual writer uses /sub-*/.jsonl. + // Registry keys name that immediate child directory, so neither a + // nested sessions root nor a re-keyed sibling is reachable. + const modernWriterPath = + /^sub-[0-9a-f]{8}$/.test(childId) && + childId === basename(childSessionDir) && + childSessionDir === join(writerChildrenRoot, childId); + if (!modernWriterPath) { + 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 + // derived from the traversed edge, but a contradicting claim is corrupt. + if (trustedChild.persistedDepth !== undefined && trustedChild.persistedDepth !== parent.persistedDepth + 1) + throw invalidFamilyTopology("child depth does not equal parent depth plus one"); + const child: FamilySession = { + ...trustedChild, + persistedDepth: parent.persistedDepth + 1, + rlmDepth: parent.persistedDepth + 1, + }; + const claimedParentPath = resolve(dirname(child.path), trustedChild.persistedParentPath); + await reader.read(claimedParentPath, 0, "stat"); + if (claimedParentPath !== parentPath) + throw invalidFamilyTopology("child parent path does not match traversed parent"); + 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"); + } + 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()); + result = [...sessions.values()]; + } catch (error) { + traversalFailure = error; + } + let cleanupFailure: unknown; + try { + await reader.close(); + } catch (error) { + cleanupFailure = error; + } + try { + closeManagedRoots(authority); + } catch (error) { + cleanupFailure ??= error; } - 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, + // The traversal failure is authoritative; cleanup must not replace it. + if (traversalFailure !== undefined) throw traversalFailure; + if (cleanupFailure !== undefined) throw cleanupFailure; + return result!; +} +/** @internal Deterministic per-call limit seam for root-discovery tests. */ +export function listCatalogFamilySessionsWithLimitForTest( + sessionDir: string | undefined, + limit: number, +): 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); + 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 +1309,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 +1363,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 +1407,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 +1511,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..5bd6d6f13 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, @@ -752,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 3069dd4af..ccec2ce5b 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,104 @@ 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 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 + // 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 +3167,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 +3181,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 +3211,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 +3323,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 { @@ -3200,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/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-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 }); + } + }); +}); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 53bc162f1..c46cc0b2f 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -1,9 +1,29 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { + appendFileSync, + 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 { + getCatalogHelperSpawnCountForTest, + getOpenCatalogAuthorityFdCountForTest, + listCatalogFamilySessions, + listCatalogFamilySessionsWithLimitForTest, + listSavedSessionSiblings, + resolveCatalogSessionMatch, + setCatalogAfterHelperResponseForTest, + setCatalogAfterTrustedHeaderForTest, + setCatalogBeforeTrustedOpenForTest, + setCatalogHelperLaunchForTest, +} from "../src/modes/daemon/daemon-catalog-process.js"; function session(id: string, name: string | undefined, path: string): SessionInfo { return { @@ -20,49 +40,152 @@ 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-11111111")); + first.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); + first.appendSessionInfo("first"); + const second = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-22222222")); + 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 }); + // Registries key children by rlm child id ("sub-*"), never by session id. + writeFileSync( + registry, + [ + { + type: "rlm_subagent", + childId: "sub-11111111", + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: "sub-22222222", + 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-11111111")); first.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); first.appendSessionInfo("first"); - const second = SessionManager.create(root, join(root, "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: + // 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/); + writeFileSync(file, `${JSON.stringify({ ...JSON.parse(header!), version: 2 })}\n${entries.join("\n")}\n`); + } 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: "sub-11111111", + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: "sub-22222222", + sessionFile: second.getSessionFile(), + status: "completed", + }, ] .map((entry) => JSON.stringify(entry)) .join("\n"), ); - await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ - expect.objectContaining({ id: first.getSessionId(), name: "first" }), - expect.objectContaining({ id: second.getSessionId(), name: "second" }), + await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ + expect.objectContaining({ id: first.getSessionId(), name: "first", state: { status: "archived" } }), + expect.objectContaining({ id: second.getSessionId(), name: "second", state: { status: "active" } }), ]); }); + 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() }), + expect.objectContaining({ id: second.getSessionId() }), + ]); + }); + } 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-11111111"); 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-22222222"); const second = SessionManager.create(root, secondDir); second.newSession({ parentSession: relative(secondDir, parentFile), rlmDepth: 1 }); second.appendSessionInfo("second"); @@ -71,17 +194,1146 @@ 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: "sub-11111111", + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: "sub-22222222", + sessionFile: second.getSessionFile(), + status: "completed", + }, ] .map((entry) => JSON.stringify(entry)) .join("\n"), ); - await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ - expect.objectContaining({ id: first.getSessionId(), name: "first" }), - expect.objectContaining({ id: second.getSessionId(), name: "second" }), + await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ + expect.objectContaining({ id: first.getSessionId() }), + expect.objectContaining({ id: second.getSessionId() }), + ]); + }); + + 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"); + // 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 + ? join(dirname(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-33333333"), + rootSession.getSessionFile(), + 1, + ); + const first = create( + "first", + join(root, "session-artifacts", "root", "sub-33333333", "sub-11111111"), + parent.getSessionFile(), + 2, + ); + const second = create( + "second", + join(root, "session-artifacts", "root", "sub-33333333", "sub-22222222"), + parent.getSessionFile(), + 2, + ); + writeRegistry("root", [ + { + type: "rlm_subagent", + childId: "sub-33333333", + sessionFile: parent.getSessionFile(), + status: "completed", + }, + ]); + writeRegistry("parent", [ + { 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 }; + }; + 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]> = [ + [ + "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: "sub-11111111", sessionFile: alias, status: "completed" }, + ]); + }, + ], + [ + "parent mismatch", + (fixture) => { + const evil = SessionManager.create( + fixture.root, + 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: "sub-66666666", + sessionFile: evil.getSessionFile(), + status: "completed", + }, + ]); + }, + ], + [ + "depth mismatch", + (fixture) => { + const evil = SessionManager.create( + fixture.root, + 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: "sub-66666666", + sessionFile: evil.getSessionFile(), + status: "completed", + }, + ]); + }, + ], + [ + "external path", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "sub-66666666", + sessionFile: join(tmpdir(), "outside.jsonl"), + status: "completed", + }, + ]), + ], + [ + "path alias", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "sub-11111111", + sessionFile: `${dirname(fixture.first.getSessionFile()!)}/../sub-11111111/first.jsonl`, + status: "completed", + }, + ]), + ], + [ + "cycle", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + 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) => + fixture.writeRegistry( + "parent", + Array.from({ length: 10_001 }, (_, index) => ({ + type: "rlm_subagent", + childId: `sub-${index.toString(16).padStart(8, "0")}`, + 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-77777777", "first.jsonl"); + mkdirSync(dirname(alias), { recursive: true }); + symlinkSync(symlink.first.getSessionFile()!, alias); + symlink.writeRegistry("parent", [ + { type: "rlm_subagent", childId: "sub-11111111", 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-44444444"); + 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: "sub-44444444", + 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("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); + 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"); + }); + // 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 }); + }); + + 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: join(root, "outside.jsonl"), 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("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-44444444")); + 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: "sub-44444444", + sessionFile: childFile, + status: "completed", + }), + ); + return sessionDir; + }; + // Old writers omitted child rlmDepth entirely: the edge supplies it. + await expect( + listCatalogFamilySessions( + make((header) => { + delete header.rlmDepth; + }), + ), + ).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) => { + delete header.parentSession; + }), + ), + ).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); + }); + + 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: join(root, "outside.jsonl"), 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("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("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("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(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); + 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: "session_state", state: { status: "archived" } }), + JSON.stringify({ + type: "agent_status", + status: { summary: payload, taskState: "completed", basedOnMessageCount: 1 }, + }), + ].join("\n")}\n`, + ); + 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?.state).toEqual({ status: "archived" }); + expect(session?.agentStatus?.taskState).toBe("completed"); + expect(responses).toHaveLength(1); + const response = responses[0]!; + 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); + 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"); + 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'); + 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. + 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(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(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(200 * 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")); + writeFileSync(join(sessionDir, "duplicate.jsonl"), readFileSync(parent.getSessionFile()!)); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("duplicate session id"); + 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 { + 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([ + expect.objectContaining({ id: "parent" }), + ]); + expect(trustedOpens).toBeGreaterThan(0); + trustedOpens = 0; + writeFileSync(join(sessionDir, "unrelated.jsonl"), "not json\n"); + 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 { + setCatalogBeforeTrustedOpenForTest(undefined); + rmSync(root, { recursive: true, force: true }); + } + }); + + 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"); + 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); + + // 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); + + 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. + 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 [ + ["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`, + ); + 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 { + 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"); + 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-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"); + mkdirSync(dirname(registry), { recursive: true }); + writeFileSync( + registry, + JSON.stringify({ + type: "rlm_subagent", + childId: "sub-44444444", + sessionFile: child.getSessionFile(), + status: "completed", + }), + ); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + 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("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" || 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); + } +});`; + 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-55555555")); + 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: "sub-66666666", 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"); + 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("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-55555555"); + 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-55555555"); + return { root, sessionDir, child, childDir, writeRegistry }; + }; + const spoofed = make(); + 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-55555555", "child.jsonl"); + mkdirSync(dirname(nestedPath), { recursive: true }); + writeFileSync(nestedPath, readFileSync(nested.child.getSessionFile()!)); + nested.writeRegistry("sub-55555555", 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", () => { 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..d26404efc 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -93,6 +93,23 @@ describe("daemon protocol helpers", () => { expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("queue_message_mutation"); }); + it("schema-gates only authority-aware saved-session renames", () => { + const detachedLegacy: DaemonCommand = { + type: "rename_saved_session", + sessionPath: "/tmp/session.jsonl", + name: "renamed", + }; + 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, + }); + }); + 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..bdc63962d 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,363 @@ 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("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(); + 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]); + target.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; + 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" }, ]); });