import { constants } from "node:fs"; import { open, lstat, realpath } from "node:fs/promises"; import { isAbsolute, relative, sep } from "node:path"; import { createHash } from "node:crypto"; import { parseRetentionJson } from "./retention-json.ts"; import { ENV_NATIVE_SESSION_ROOT } from "../kernel/env-names.ts"; export { ENV_NATIVE_SESSION_ROOT } from "../kernel/env-names.ts"; export const MAX_NATIVE_SESSION_BYTES = 1024 * 1024; export type NativeSessionSource = "herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager"; export interface NativeSessionObservation { source: NativeSessionSource | null; status: "missing" | "verified" | "invalid" | "changed" | "truncated" | "unsupported"; sessionId: string | null; sessionPath: string | null; parentSessionPath: string | null; branchLeafId: string | null; branchState: "unknown" | "observed"; lastPersistedEntryId: string | null; sha256: string | null; reason: string | null; } export interface NativeSessionCapture { observation: NativeSessionObservation; bytes?: Buffer; fileIdentity?: { device: string; inode: string }; } /** Read-only methods of pi 0.84.2's public SessionManager; no runtime/session creation or model work. */ export interface NativeSessionManager { getSessionFile(): string | undefined; getSessionId(): string; getLeafId(): string | null; } export const missingNativeSession = (): NativeSessionObservation => ({ source: null, status: "missing", sessionId: null, sessionPath: null, parentSessionPath: null, branchLeafId: null, branchState: "unknown", lastPersistedEntryId: null, sha256: null, reason: "native-session-unavailable", }); const id = (x: unknown): x is string => typeof x === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(x); const uuid = (x: unknown): x is string => typeof x === "string" && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(x); const obj = (x: unknown): x is Record => x !== null && typeof x === "object" && !Array.isArray(x); const filePath = (x: unknown): x is string => typeof x === "string" && x.length <= 4096 && isAbsolute(x) && !x.includes("\0"); /** Parse the retained native v3 bytes, never load/migrate/rewrite a session via SessionManager.open(). */ export function parseNativeSessionBytes( bytes: Uint8Array, input: { source: NativeSessionSource; path: string; expectedSessionId?: string; truncated?: boolean; liveLeaf?: { sessionId: string; leafId: string | null }; }, ): NativeSessionCapture { const observation = { ...missingNativeSession(), source: input.source, sessionPath: input.path }; const result: NativeSessionCapture = { observation }; const fail = (status: NativeSessionObservation["status"], reason: string) => { observation.status = status; observation.reason = reason; observation.branchLeafId = null; observation.branchState = "unknown"; return result; }; if (bytes.length > MAX_NATIVE_SESSION_BYTES) return fail("truncated", "native-session-size-limit"); const raw = Buffer.from(bytes), newline = raw.indexOf(10); let header: unknown; try { header = parseRetentionJson( new TextDecoder("utf-8", { fatal: true }).decode(raw.subarray(0, newline < 0 ? raw.length : newline)), MAX_NATIVE_SESSION_BYTES, ); } catch { return fail("invalid", "native-session-header-invalid"); } if ( !obj(header) || header.type !== "session" || header.version !== 3 || !uuid(header.id) || !filePath(header.cwd) || typeof header.timestamp !== "string" || !Number.isFinite(Date.parse(header.timestamp)) || (header.parentSession !== undefined && !filePath(header.parentSession)) ) return fail("invalid", "native-session-header-invalid"); if (input.expectedSessionId !== undefined && header.id !== input.expectedSessionId) return fail("changed", "native-session-id-changed"); // Only validated session headers admit transcript bytes; arbitrary files are never archived here. result.bytes = Buffer.from(bytes); observation.sessionId = header.id; observation.parentSessionPath = (header.parentSession as string | undefined) ?? null; observation.sha256 = createHash("sha256").update(bytes).digest("hex"); if (input.truncated || raw.at(-1) !== 10) return fail("truncated", "native-session-incomplete-bytes"); let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(raw); } catch { return fail("invalid", "native-session-utf8-invalid"); } const lines = text.split("\n"); const ids = new Set(); if (lines.length > 10002) return fail("invalid", "native-session-entry-limit"); for (const line of lines.slice(1, -1)) { let entry: unknown; try { entry = parseRetentionJson(line, MAX_NATIVE_SESSION_BYTES); } catch { return fail("invalid", "native-session-entry-invalid"); } if ( !obj(entry) || typeof entry.type !== "string" || entry.type === "session" || !id(entry.id) || ids.has(entry.id) || !(entry.parentId === null || (id(entry.parentId) && ids.has(entry.parentId))) || typeof entry.timestamp !== "string" || !Number.isFinite(Date.parse(entry.timestamp)) ) return fail("invalid", "native-session-parent-link-invalid"); ids.add(entry.id); observation.lastPersistedEntryId = entry.id; } if (input.liveLeaf) { if (input.liveLeaf.sessionId !== header.id || (input.liveLeaf.leafId !== null && !ids.has(input.liveLeaf.leafId))) { return fail("changed", "native-session-live-leaf-mismatch"); } observation.branchLeafId = input.liveLeaf.leafId; observation.branchState = "observed"; } observation.status = "verified"; observation.reason = observation.branchState === "unknown" ? "active-branch-unknown" : null; return result; } /** Private allowlisted files only, bounded non-following descriptor reads, no scan of session/auth dirs. */ export async function readNativeSession(input: { path: string; source: NativeSessionSource; expectedSessionId?: string; allowedRoot?: string; manager?: NativeSessionManager; expectedFile?: { device: string; inode: string }; }): Promise { const gap = (status: NativeSessionObservation["status"], reason: string): NativeSessionCapture => ({ observation: { ...missingNativeSession(), source: input.source, status, reason, sessionPath: filePath(input.path) ? input.path : null, }, }); const root = input.allowedRoot ?? process.env[ENV_NATIVE_SESSION_ROOT]; if (!filePath(input.path) || !input.path.endsWith(".jsonl") || !root || !filePath(root)) return gap("unsupported", "native-session-private-root-required"); const within = (base: string, candidate: string) => { const p = relative(base, candidate); return p !== "" && p !== ".." && !p.startsWith(`..${sep}`) && !isAbsolute(p); }; if (!within(root, input.path)) return gap("unsupported", "native-session-outside-private-root"); let handle: Awaited> | undefined; try { const canonicalRoot = await realpath(root), canonicalFile = await realpath(input.path); const rootStat = await lstat(canonicalRoot); if ( !rootStat.isDirectory() || (rootStat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && rootStat.uid !== process.getuid()) ) return gap("unsupported", "native-session-root-not-private"); if (!within(canonicalRoot, canonicalFile) || (await lstat(input.path)).isSymbolicLink()) return gap("unsupported", "native-session-path-alias"); handle = await open(input.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); const before = await handle.stat(); if ( !before.isFile() || before.nlink !== 1 || (typeof process.getuid === "function" && before.uid !== process.getuid()) ) { return gap("unsupported", "native-session-not-private-regular-file"); } const fileIdentity = { device: String(before.dev), inode: String(before.ino) }; if ( input.expectedFile && (input.expectedFile.device !== fileIdentity.device || input.expectedFile.inode !== fileIdentity.inode) ) return gap("changed", "native-session-file-replaced"); const live = input.manager ? { sessionId: input.manager.getSessionId(), leafId: input.manager.getLeafId(), path: input.manager.getSessionFile(), } : undefined; if (live && live.path !== input.path) return gap("changed", "native-session-manager-path-changed"); const bytes = Buffer.alloc(Math.min(before.size, MAX_NATIVE_SESSION_BYTES)); let offset = 0; while (offset < bytes.length) { const read = await handle.read(bytes, offset, bytes.length - offset, offset); if (!read.bytesRead) break; offset += read.bytesRead; } const after = await handle.stat(), location = await lstat(input.path); if ( before.dev !== location.dev || before.ino !== location.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || location.isSymbolicLink() || after.size !== location.size ) return gap("changed", "native-session-file-changed"); if ( live && (input.manager!.getSessionId() !== live.sessionId || input.manager!.getLeafId() !== live.leafId || input.manager!.getSessionFile() !== live.path) ) { return gap("changed", "native-session-live-state-changed"); } return { ...parseNativeSessionBytes(bytes.subarray(0, offset), { ...input, liveLeaf: live, truncated: before.size > MAX_NATIVE_SESSION_BYTES || offset !== before.size, }), fileIdentity, }; } catch (error) { return gap( (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "invalid", "native-session-read-failed", ); } finally { await handle?.close().catch(() => undefined); } } /** Herdr 0.8.2 protocol 20 AgentInfo.agent_session; never scrape terminal text, PID or names. */ export function herdrSessionReference( value: unknown, expectedPane: string, ): { source: "herdr-id" | "herdr-path"; value: string } | null { if (!obj(value) || value.pane_id !== expectedPane || value.agent !== "pi" || !obj(value.agent_session)) return null; const s = value.agent_session; if (s.agent !== "pi" || typeof s.source !== "string" || s.source.length > 128) return null; if (s.kind === "id" && uuid(s.value)) return { source: "herdr-id", value: s.value }; if (s.kind === "path" && filePath(s.value)) return { source: "herdr-path", value: s.value }; return null; }