mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
feat(agent): make coordination routes opt-in and address review
Gate inspect, stage and submit behind the synced agentCoordinationEnabled setting (default OFF, App Settings > Cross-session features, zh-CN strings). While off, all three routes answer 403 FORBIDDEN naming the setting, checked before session lookup; the CLI verbs print it verbatim. Remember the attributed Codex rollout per session and re-verify only its head on later calls, rescanning only date directories not older than it for a /new conflict. The first walk reads 1 KiB prefixes first, so a store of 2,100+ unrelated rollouts still attributes. Answer unexpected inspection errors with 422 OPERATION_FAILED and log the session id and error code only. Read the guarded composer glyph and footer from the CLI registry: codex workDetect.promptGlyph and a new workDetect.composerFooterLine capability, compiled through compileVersionRegex. Codex footer effort words are one shared list with modelDetect.screenLine. Fix the wiki API reference link, frame the versioning policy entry as a case of the experimental rule, join the CLAUDE.md Agent CLI paragraph, and update the changeset, skill reference and architecture notes.
This commit is contained in:
1 parent
1a2c3be0ea
commit
c78635a3d3
32 files changed
+848
-124
No files matched your search
+152
-36
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses
|
||||
* newest-mtime, prompt timestamps or cwd alone as proof of session ownership.
|
||||
* 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`.
|
||||
*/
|
||||
import { open, opendir, realpath } from 'node:fs/promises';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
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;
|
||||
@@ -67,6 +72,42 @@ function strictMetadata(bytes: Buffer): Metadata | undefined {
|
||||
}
|
||||
return found;
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
@@ -81,9 +122,92 @@ export async function findExactCodexConversation(
|
||||
if (!workspace || !home) return undefined;
|
||||
const sessions = join(home, 'sessions');
|
||||
if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined;
|
||||
const directories = [{ path: sessions, depth: 0 }];
|
||||
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 }];
|
||||
const candidates: Array<{ id: string; path: string; meta: Metadata }> = [];
|
||||
const originator = `codeman_${options.sessionId}`;
|
||||
const originatorJson = JSON.stringify(originator);
|
||||
const explicit = options.resumeSessionId;
|
||||
let entries = 0;
|
||||
let prefixes = 0;
|
||||
@@ -98,32 +222,43 @@ export async function findExactCodexConversation(
|
||||
const path = join(dir.path, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (dir.depth >= 8) return undefined;
|
||||
directories.push({ path, depth: dir.depth + 1 });
|
||||
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 });
|
||||
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||
const id = entry.name.match(UUID_AT_END)?.[1];
|
||||
if (!id) continue; // no rollout id in the name: never a candidate, read nothing
|
||||
const file = await open(path, 'r').catch(() => undefined);
|
||||
if (!file) return undefined;
|
||||
try {
|
||||
const info = await file.stat();
|
||||
if (!info.isFile()) return undefined;
|
||||
const id = entry.name.match(UUID_AT_END)?.[1];
|
||||
const prefixBytes = Math.min(info.size, 4096);
|
||||
if (prefixes + prefixBytes > prefixLimit) return undefined;
|
||||
prefixes += prefixBytes;
|
||||
const prefix = Buffer.alloc(prefixBytes);
|
||||
const first = await file.read(prefix, 0, prefixBytes, 0);
|
||||
// Cheap negative filter only; a match still needs full parsed metadata.
|
||||
if (
|
||||
id !== explicit &&
|
||||
!prefix.subarray(0, first.bytesRead).toString('utf8').includes(JSON.stringify(originator))
|
||||
)
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
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));
|
||||
if (!meta || !id || meta.id !== id) continue;
|
||||
if (!meta || meta.id !== id) continue;
|
||||
if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta });
|
||||
} finally {
|
||||
await file.close();
|
||||
@@ -131,24 +266,5 @@ export async function findExactCodexConversation(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (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 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;
|
||||
return { id: candidate.id, path: candidate.path };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
Reference in new issue
Block a user