2026-10-23 14:15:14 -04:00
|
|
|
/**
|
|
|
|
|
* @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses
|
|
|
|
|
* newest-mtime, prompt timestamps or cwd alone as proof of session ownership.
|
2026-10-24 22:54:17 -04:00
|
|
|
* The first call walks the store under explicit budgets; the attributed file is
|
|
|
|
|
* then remembered per session and only re-verified (plus a scan of the date
|
|
|
|
|
* directories not older than it) on later calls, since codex never prunes
|
|
|
|
|
* `~/.codex/sessions`.
|
2026-10-23 14:15:14 -04:00
|
|
|
*/
|
2026-10-23 16:22:46 -04:00
|
|
|
import { open, opendir, realpath } from 'node:fs/promises';
|
|
|
|
|
import { probePathKind } from './utils/bounded-path-probe.js';
|
2026-10-23 14:15:14 -04:00
|
|
|
import { tmpdir } from 'node:os';
|
2026-10-24 22:54:17 -04:00
|
|
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
|
|
import { LRUMap } from './utils/lru-map.js';
|
2026-10-23 14:15:14 -04:00
|
|
|
|
|
|
|
|
const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
|
|
|
const HEAD_BYTES = 131072;
|
2026-10-23 16:22:46 -04:00
|
|
|
const PREFIX_SCAN_BYTES = 8 * 1024 * 1024;
|
2026-10-23 14:15:14 -04:00
|
|
|
export interface CodexAttributionOptions {
|
|
|
|
|
codexHome: string;
|
|
|
|
|
sessionId: string;
|
|
|
|
|
workingDir: string;
|
|
|
|
|
resumeSessionId?: string;
|
|
|
|
|
}
|
|
|
|
|
export interface CodexAttributionBudget {
|
|
|
|
|
maxEntries: number;
|
|
|
|
|
maxBytes: number;
|
2026-10-23 16:22:46 -04:00
|
|
|
/** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */
|
|
|
|
|
maxPrefixBytes?: number;
|
2026-10-23 14:15:14 -04:00
|
|
|
}
|
|
|
|
|
interface Metadata {
|
|
|
|
|
id?: string;
|
|
|
|
|
cwd?: string;
|
|
|
|
|
originator?: string;
|
|
|
|
|
}
|
2026-10-23 16:22:46 -04:00
|
|
|
async function canonical(path: string): Promise<string | undefined> {
|
|
|
|
|
if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined;
|
2026-10-23 14:15:14 -04:00
|
|
|
try {
|
2026-10-23 16:22:46 -04:00
|
|
|
return await realpath(path);
|
2026-10-23 14:15:14 -04:00
|
|
|
} catch {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/** Match the CLI's relative CODEX_HOME interpretation without exposing env credentials. */
|
|
|
|
|
export function resolveEffectiveCodexHome(
|
|
|
|
|
sessionHome: string | undefined,
|
|
|
|
|
workingDir: string,
|
|
|
|
|
env: Readonly<Record<string, string | undefined>> = process.env
|
|
|
|
|
): string {
|
|
|
|
|
const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex'));
|
2026-10-23 16:22:46 -04:00
|
|
|
return path;
|
2026-10-23 14:15:14 -04:00
|
|
|
}
|
|
|
|
|
/** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */
|
|
|
|
|
function strictMetadata(bytes: Buffer): Metadata | undefined {
|
|
|
|
|
const rows = bytes.toString('utf8').split('\n');
|
|
|
|
|
rows.pop();
|
|
|
|
|
let found: Metadata | undefined;
|
|
|
|
|
for (const line of rows) {
|
|
|
|
|
try {
|
|
|
|
|
const row = JSON.parse(line) as { type?: string; payload?: Metadata };
|
|
|
|
|
if (row.type !== 'session_meta') continue;
|
|
|
|
|
if (
|
|
|
|
|
found ||
|
|
|
|
|
typeof row.payload?.id !== 'string' ||
|
|
|
|
|
typeof row.payload.cwd !== 'string' ||
|
|
|
|
|
(row.payload.originator !== undefined && typeof row.payload.originator !== 'string')
|
|
|
|
|
)
|
|
|
|
|
return undefined;
|
|
|
|
|
found = row.payload;
|
|
|
|
|
} catch {
|
|
|
|
|
/* Incomplete or malformed rows are not identity evidence. */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return found;
|
|
|
|
|
}
|
2026-10-24 22:54:17 -04:00
|
|
|
/**
|
|
|
|
|
* The rollout a previous call attributed, per session. A later call re-verifies only
|
|
|
|
|
* this file's head (same `dev:ino`, same strict metadata) and then scans just the
|
|
|
|
|
* directories not older than its own for a second candidate (codex `/new`, or a new
|
|
|
|
|
* owned rollout beside a configured resume), instead of re-reading the whole store,
|
|
|
|
|
* which codex never prunes. Any failed check drops the entry and falls back to the
|
|
|
|
|
* full walk. Bounded: one entry per live session is the expected size.
|
|
|
|
|
*/
|
|
|
|
|
interface AttributionMemo {
|
|
|
|
|
id: string;
|
|
|
|
|
path: string;
|
|
|
|
|
dev: number;
|
|
|
|
|
ino: number;
|
|
|
|
|
meta: Metadata;
|
|
|
|
|
/** Directory components of `path` below `sessions/`, used to prune older siblings. */
|
|
|
|
|
dirParts: string[];
|
|
|
|
|
}
|
|
|
|
|
const attributionMemo = new LRUMap<string, AttributionMemo>({ maxSize: 1024 });
|
|
|
|
|
/** Test-only: forget every remembered attribution. */
|
|
|
|
|
export function __clearCodexAttributionMemo(): void {
|
|
|
|
|
attributionMemo.clear();
|
|
|
|
|
}
|
|
|
|
|
/** Short first read: `originator` sits right after `id`, `timestamp` and `cwd`. */
|
|
|
|
|
const PREFIX_FIRST_BYTES = 1024;
|
|
|
|
|
/** Fallback read when the first window holds no complete `originator` field. */
|
|
|
|
|
const PREFIX_MAX_BYTES = 4096;
|
|
|
|
|
const ORIGINATOR_FIELD = /"originator"\s*:\s*"(?:[^"\\]|\\.)*"/;
|
|
|
|
|
/**
|
|
|
|
|
* Is a date-shaped directory name older than the remembered one at the same depth?
|
|
|
|
|
* Only all-digit names compare (codex writes `sessions/YYYY/MM/DD/`); anything else
|
|
|
|
|
* is never pruned, so an unexpected layout costs a wider scan, not a missed rollout.
|
|
|
|
|
*/
|
|
|
|
|
function olderDatePart(name: string, memo: string | undefined): boolean {
|
|
|
|
|
if (memo === undefined || !/^\d+$/.test(name) || !/^\d+$/.test(memo)) return false;
|
|
|
|
|
return Number(name) < Number(memo);
|
|
|
|
|
}
|
2026-10-23 14:15:14 -04:00
|
|
|
/**
|
|
|
|
|
* A configured resume and a newly owned rollout conflict rather than pinning an
|
|
|
|
|
* old launch forever after /new. TUI resumes of foreign-originator threads cannot
|
|
|
|
|
* be authoritatively tracked from this metadata; this returns attributed evidence.
|
|
|
|
|
*/
|
|
|
|
|
export async function findExactCodexConversation(
|
|
|
|
|
options: CodexAttributionOptions,
|
|
|
|
|
budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 }
|
|
|
|
|
): Promise<{ id: string; path: string } | undefined> {
|
2026-10-23 16:22:46 -04:00
|
|
|
const workspace = await canonical(options.workingDir);
|
|
|
|
|
const home = await canonical(options.codexHome);
|
|
|
|
|
if (!workspace || !home) return undefined;
|
|
|
|
|
const sessions = join(home, 'sessions');
|
|
|
|
|
if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined;
|
2026-10-24 22:54:17 -04:00
|
|
|
const key = [home, options.sessionId, options.resumeSessionId ?? '', workspace].join('\0');
|
|
|
|
|
const remembered = attributionMemo.get(key);
|
|
|
|
|
if (remembered) {
|
|
|
|
|
const verified = await verifyRemembered(remembered, workspace, budget);
|
|
|
|
|
if (verified) {
|
|
|
|
|
const others = await scanCandidates(sessions, options, budget, remembered.dirParts);
|
|
|
|
|
if (others && others.length === 1 && others[0].path === remembered.path) {
|
|
|
|
|
return { id: remembered.id, path: remembered.path };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
attributionMemo.delete(key);
|
|
|
|
|
// A second candidate is a conflict a full walk would report too; anything
|
|
|
|
|
// else (a replaced file, an exhausted budget) gets one full walk now.
|
|
|
|
|
if (verified) return undefined;
|
|
|
|
|
}
|
|
|
|
|
const candidates = await scanCandidates(sessions, options, budget);
|
|
|
|
|
if (!candidates || candidates.length !== 1) return undefined;
|
|
|
|
|
const candidate = candidates[0];
|
|
|
|
|
if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined;
|
|
|
|
|
const handle = await open(candidate.path, 'r').catch(() => undefined);
|
|
|
|
|
if (!handle) return undefined;
|
|
|
|
|
try {
|
|
|
|
|
const info = await handle.stat();
|
|
|
|
|
const bytes = Buffer.alloc(HEAD_BYTES);
|
|
|
|
|
const read = await handle.read(bytes, 0, bytes.length, 0);
|
|
|
|
|
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
|
|
|
|
|
if (
|
|
|
|
|
!meta ||
|
|
|
|
|
meta.id !== candidate.id ||
|
|
|
|
|
meta.cwd !== candidate.meta.cwd ||
|
|
|
|
|
meta.originator !== candidate.meta.originator
|
|
|
|
|
)
|
|
|
|
|
return undefined;
|
|
|
|
|
attributionMemo.set(key, {
|
|
|
|
|
id: candidate.id,
|
|
|
|
|
path: candidate.path,
|
|
|
|
|
dev: info.dev,
|
|
|
|
|
ino: info.ino,
|
|
|
|
|
meta,
|
|
|
|
|
dirParts: relative(sessions, dirname(candidate.path)).split(sep).filter(Boolean),
|
|
|
|
|
});
|
|
|
|
|
return { id: candidate.id, path: candidate.path };
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/** Same file (`dev:ino`), same strict metadata, same canonical workspace: nothing else. */
|
|
|
|
|
async function verifyRemembered(
|
|
|
|
|
memo: AttributionMemo,
|
|
|
|
|
workspace: string,
|
|
|
|
|
budget: CodexAttributionBudget
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
if (HEAD_BYTES > budget.maxBytes) return false;
|
|
|
|
|
if (!memo.meta.cwd || (await canonical(memo.meta.cwd)) !== workspace) return false;
|
|
|
|
|
const handle = await open(memo.path, 'r').catch(() => undefined);
|
|
|
|
|
if (!handle) return false;
|
|
|
|
|
try {
|
|
|
|
|
const info = await handle.stat();
|
|
|
|
|
if (!info.isFile() || info.dev !== memo.dev || info.ino !== memo.ino) return false;
|
|
|
|
|
const bytes = Buffer.alloc(HEAD_BYTES);
|
|
|
|
|
const read = await handle.read(bytes, 0, bytes.length, 0);
|
|
|
|
|
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
|
|
|
|
|
return !!meta && meta.id === memo.id && meta.cwd === memo.meta.cwd && meta.originator === memo.meta.originator;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Every rollout under `sessions` whose strict metadata names this session (or the
|
|
|
|
|
* configured resume id), or undefined when the walk hit a bound or an unreadable
|
|
|
|
|
* entry (an incomplete inventory proves nothing). With `notOlderThan`, date-shaped
|
|
|
|
|
* directories older than those components are skipped.
|
|
|
|
|
*/
|
|
|
|
|
async function scanCandidates(
|
|
|
|
|
sessions: string,
|
|
|
|
|
options: CodexAttributionOptions,
|
|
|
|
|
budget: CodexAttributionBudget,
|
|
|
|
|
notOlderThan?: string[]
|
|
|
|
|
): Promise<Array<{ id: string; path: string; meta: Metadata }> | undefined> {
|
|
|
|
|
// `onBoundary`: every ancestor so far equals the remembered path, so pruning applies.
|
|
|
|
|
const directories = [{ path: sessions, depth: 0, onBoundary: notOlderThan !== undefined }];
|
2026-10-23 14:15:14 -04:00
|
|
|
const candidates: Array<{ id: string; path: string; meta: Metadata }> = [];
|
|
|
|
|
const originator = `codeman_${options.sessionId}`;
|
2026-10-24 22:54:17 -04:00
|
|
|
const originatorJson = JSON.stringify(originator);
|
2026-10-23 14:15:14 -04:00
|
|
|
const explicit = options.resumeSessionId;
|
|
|
|
|
let entries = 0;
|
2026-10-23 16:22:46 -04:00
|
|
|
let prefixes = 0;
|
|
|
|
|
const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES);
|
2026-10-23 14:15:14 -04:00
|
|
|
let scanned = HEAD_BYTES; // Reserve an independent final verification.
|
|
|
|
|
while (directories.length) {
|
|
|
|
|
const dir = directories.pop()!;
|
|
|
|
|
const handle = await opendir(dir.path).catch(() => undefined);
|
|
|
|
|
if (!handle) return undefined;
|
|
|
|
|
for await (const entry of handle) {
|
|
|
|
|
if (++entries > budget.maxEntries) return undefined;
|
|
|
|
|
const path = join(dir.path, entry.name);
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
if (dir.depth >= 8) return undefined;
|
2026-10-24 22:54:17 -04:00
|
|
|
const part = notOlderThan?.[dir.depth];
|
|
|
|
|
if (dir.onBoundary && olderDatePart(entry.name, part)) continue;
|
|
|
|
|
directories.push({ path, depth: dir.depth + 1, onBoundary: dir.onBoundary && entry.name === part });
|
2026-10-23 14:15:14 -04:00
|
|
|
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
2026-10-24 22:54:17 -04:00
|
|
|
const id = entry.name.match(UUID_AT_END)?.[1];
|
|
|
|
|
if (!id) continue; // no rollout id in the name: never a candidate, read nothing
|
2026-10-23 14:15:14 -04:00
|
|
|
const file = await open(path, 'r').catch(() => undefined);
|
|
|
|
|
if (!file) return undefined;
|
|
|
|
|
try {
|
|
|
|
|
const info = await file.stat();
|
|
|
|
|
if (!info.isFile()) return undefined;
|
2026-10-24 22:54:17 -04:00
|
|
|
if (id !== explicit) {
|
|
|
|
|
// Cheap negative filter only; a match still needs full parsed metadata.
|
|
|
|
|
// A short first window decides when it already holds the whole
|
|
|
|
|
// `originator` field; only otherwise is the wider window read.
|
|
|
|
|
let window = Math.min(info.size, PREFIX_FIRST_BYTES);
|
|
|
|
|
let prefix = '';
|
|
|
|
|
for (;;) {
|
|
|
|
|
if (prefixes + window > prefixLimit) return undefined;
|
|
|
|
|
prefixes += window;
|
|
|
|
|
const buffer = Buffer.alloc(window);
|
|
|
|
|
const first = await file.read(buffer, 0, window, 0);
|
|
|
|
|
prefix = buffer.subarray(0, first.bytesRead).toString('utf8');
|
|
|
|
|
if (prefix.includes(originatorJson) || ORIGINATOR_FIELD.test(prefix)) break;
|
|
|
|
|
const wider = Math.min(info.size, PREFIX_MAX_BYTES);
|
|
|
|
|
if (wider <= window) break;
|
|
|
|
|
window = wider;
|
|
|
|
|
}
|
|
|
|
|
if (!prefix.includes(originatorJson)) continue;
|
|
|
|
|
}
|
2026-10-23 14:15:14 -04:00
|
|
|
const size = Math.min(info.size, HEAD_BYTES);
|
|
|
|
|
if (scanned + size > budget.maxBytes) return undefined;
|
|
|
|
|
scanned += size;
|
|
|
|
|
const bytes = Buffer.alloc(size);
|
|
|
|
|
const read = await file.read(bytes, 0, size, 0);
|
|
|
|
|
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
|
2026-10-24 22:54:17 -04:00
|
|
|
if (!meta || meta.id !== id) continue;
|
2026-10-23 14:15:14 -04:00
|
|
|
if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta });
|
|
|
|
|
} finally {
|
|
|
|
|
await file.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 22:54:17 -04:00
|
|
|
return candidates;
|
2026-10-23 14:15:14 -04:00
|
|
|
}
|