fix(agent): address inspection and staging review feedback

This commit is contained in:
Aamer Akhter committed 2026-10-24 22:37:18 -04:00
1 parent a24b0ec7bb
commit 4c2f050a96
24 files changed
+517 -116

No files matched your search

+20 -14
View File
@@ -2,13 +2,14 @@
* @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses
* newest-mtime, prompt timestamps or cwd alone as proof of session ownership.
*/
import { realpathSync } from 'node:fs';
import { open, opendir } from 'node:fs/promises';
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';
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;
const PREFIX_SCAN_BYTES = 8 * 1024 * 1024;
export interface CodexAttributionOptions {
codexHome: string;
sessionId: string;
@@ -18,15 +19,18 @@ export interface CodexAttributionOptions {
export interface CodexAttributionBudget {
maxEntries: number;
maxBytes: number;
/** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */
maxPrefixBytes?: number;
}
interface Metadata {
id?: string;
cwd?: string;
originator?: string;
}
function canonical(path: string): string | undefined {
async function canonical(path: string): Promise<string | undefined> {
if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined;
try {
return realpathSync.native(path);
return await realpath(path);
} catch {
return undefined;
}
@@ -38,7 +42,7 @@ export function resolveEffectiveCodexHome(
env: Readonly<Record<string, string | undefined>> = process.env
): string {
const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex'));
return canonical(path) ?? path;
return path;
}
/** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */
function strictMetadata(bytes: Buffer): Metadata | undefined {
@@ -72,11 +76,18 @@ export async function findExactCodexConversation(
options: CodexAttributionOptions,
budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 }
): Promise<{ id: string; path: string } | undefined> {
const directories = [{ path: join(options.codexHome, 'sessions'), depth: 0 }];
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;
const directories = [{ path: sessions, depth: 0 }];
const candidates: Array<{ id: string; path: string; meta: Metadata }> = [];
const originator = `codeman_${options.sessionId}`;
const explicit = options.resumeSessionId;
let entries = 0;
let prefixes = 0;
const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES);
let scanned = HEAD_BYTES; // Reserve an independent final verification.
while (directories.length) {
const dir = directories.pop()!;
@@ -96,8 +107,8 @@ export async function findExactCodexConversation(
if (!info.isFile()) return undefined;
const id = entry.name.match(UUID_AT_END)?.[1];
const prefixBytes = Math.min(info.size, 4096);
if (scanned + prefixBytes > budget.maxBytes) return undefined;
scanned += prefixBytes;
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.
@@ -122,12 +133,7 @@ export async function findExactCodexConversation(
}
if (candidates.length !== 1) return undefined;
const candidate = candidates[0];
if (
!candidate.meta.cwd ||
canonical(candidate.meta.cwd) !== canonical(options.workingDir) ||
!canonical(options.workingDir)
)
return undefined;
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 {
+1 -1
View File
@@ -134,7 +134,7 @@ export class InspectionReader {
break;
}
if (end + 1 > maxBytes) {
blockedReason = 'record-exceeds-output-budget';
if (consumed === 0) blockedReason = 'record-exceeds-output-budget';
break;
}
consumed = end + 1;
+21 -13
View File
@@ -6,12 +6,18 @@
import { createHash, randomUUID } from 'node:crypto';
import { stripAnsi } from './utils/regex-patterns.js';
import { visibleWidth } from './tui/tui-ansi.js';
import { CODEX_REASONING_EFFORTS } from './types/session.js';
const FOOTER_LINE = new RegExp(
String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · )`
);
export const STAGE_MAX_BYTES = 8192;
export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex');
export interface GuardedInputSnapshot {
generation: string;
revision: number;
composerSha: string;
/** Actual captured pane width minus the rendered composer prefix (including indentation). */
availableColumns: number;
}
export interface GuardedInputTarget {
id: string;
@@ -40,17 +46,7 @@ export function readGuardedCodexComposer(screen: string): string | null {
const last = rows.at(-1);
if (!last) return null;
const below = lines.slice(last.index + 1).filter((line) => line.trim());
if (
below.length === 0 ||
below.length > 3 ||
below.some(
(line) =>
!/^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:default|low|medium|high|xhigh|max) · )/.test(
line
)
)
)
return null;
if (below.length === 0 || below.length > 3 || below.some((line) => !FOOTER_LINE.test(line))) return null;
// Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded.
const raw = screen.split('\n')[last.index];
let dim = false;
@@ -74,6 +70,10 @@ export function readGuardedCodexComposer(screen: string): string | null {
/** Require the capture's caret at the exact end of the unwrapped composer. */
export function readGuardedCodexSnapshot(snapshot: string): string | null {
return readGuardedCodexSnapshotDetails(snapshot)?.composer ?? null;
}
/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */
export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: string; prefixWidth: number } | null {
// formatPaneSnapshot emits one absolute row address per line, then the caret.
// Plain captures lack caret evidence and cannot prove invisible trailing edits.
// eslint-disable-next-line no-control-regex
@@ -98,7 +98,7 @@ export function readGuardedCodexSnapshot(snapshot: string): string | null {
const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/);
if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1)
return null;
return composer;
return { composer, prefixWidth: visibleWidth(prefix[1]) };
}
export class GuardedStageStore {
@@ -129,6 +129,8 @@ export class GuardedStageStore {
const before = target.snapshot();
if (!before || before.composerSha !== promptSha256(''))
throw new GuardedInputConflict('An empty, verifiable composer is required');
if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns)
throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane');
for (const [id, receipt] of this.receipts)
if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id);
// Refuse capacity before typing. Do not evict a caller's still-live receipt.
@@ -139,7 +141,12 @@ export class GuardedStageStore {
if (!(await target.write(message)))
throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again');
const after = target.snapshot();
if (!after || after.generation !== before.generation || after.revision !== before.revision + 1)
if (
!after ||
after.generation !== before.generation ||
after.revision !== before.revision + 1 ||
after.availableColumns !== before.availableColumns
)
throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued');
const stageId = randomUUID();
const expiresAt = this.now() + this.ttlMs;
@@ -166,6 +173,7 @@ export class GuardedStageStore {
!current ||
current.generation !== receipt.generation ||
current.revision !== receipt.revision ||
current.availableColumns !== receipt.availableColumns ||
current.composerSha !== receipt.sha256
)
throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted');
+2 -2
View File
@@ -314,9 +314,9 @@ export interface TerminalMultiplexer extends EventEmitter {
// ========== Input ==========
/**
* Send input to a session via tmux send-keys.
* Send input to a session via tmux send-keys. The optional internal Enter-only
* guard runs synchronously immediately before transport dispatch.
*/
/** Internal conditional Enter guard, checked synchronously before transport dispatch. */
sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise<boolean>;
// ========== Metadata ==========
+35 -25
View File
@@ -129,7 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { getCli } from './config/cli-registry/registry.js';
import { SubmitVerifier } from './session-submit-verifier.js';
import { promptSha256, readGuardedCodexSnapshot, type GuardedInputSnapshot } from './agent-staging.js';
import { promptSha256, readGuardedCodexSnapshotDetails, type GuardedInputSnapshot } from './agent-staging.js';
import { isKeyboardTerminalInput } from './terminal-input-classifier.js';
import { stripAnsi } from './utils/regex-patterns.js';
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
@@ -4727,26 +4727,9 @@ export class Session extends EventEmitter {
// Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps)
/**
* Sends input directly to the PTY process.
*
* For interactive sessions, this is how you send user input to Claude.
* Remember to include `\r` (carriage return) to simulate pressing Enter.
*
* @example
* ```typescript
* session.write('hello world'); // Text only, no Enter
* session.write('\r'); // Enter key
* session.write('ls -la\r'); // Command with Enter
* ```
*
* @param data - The input data to send (text, escape sequences, etc.)
* @returns true if the data reached a PTY. A session whose PTY is gone still
* discards the data, but it used to do so with no signal at all — which is how
* input could disappear while the caller believed it had been delivered.
*/
private readonly _inspectionInstance = uuidv4();
private _guardedPaneRevision = 0;
private _guardedGeometryRevision = 0;
private _guardedInputRevision = 0;
private _guardedPendingWrites = 0;
private _guardedSubmitInFlight = false;
@@ -4754,6 +4737,10 @@ export class Session extends EventEmitter {
get inspectionGeneration(): string {
return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`;
}
/** Staging additionally binds geometry; transcript cursors survive a terminal resize. */
get guardedInputGeneration(): string {
return `${this.inspectionGeneration}:${this._guardedGeometryRevision}`;
}
get guardedInputPendingWrites(): number {
return this._guardedPendingWrites;
}
@@ -4781,7 +4768,8 @@ export class Session extends EventEmitter {
!this._muxSession
)
return null;
const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName);
const capture: import('./mux-interface.js').PaneCaptureOptions = {};
const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture);
if (!screen) return null;
// tmux's replay has CUP addresses rather than line breaks. Read the SAME
// fresh frame for dialog/work chrome and exact composer/caret proof.
@@ -4790,13 +4778,16 @@ export class Session extends EventEmitter {
const working = this._workingLinePattern();
working.lastIndex = 0;
if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null;
const composer = readGuardedCodexSnapshot(screen);
const composer = readGuardedCodexSnapshotDetails(screen);
return composer === null
? null
: {
generation: this.inspectionGeneration,
generation: this.guardedInputGeneration,
revision: this._guardedInputRevision,
composerSha: promptSha256(composer),
composerSha: promptSha256(composer.composer),
availableColumns: Number.isFinite(capture.capturedGeometry?.cols)
? capture.capturedGeometry!.cols - composer.prefixWidth
: 0,
};
}
/** Stage through ordinary delivery, after cancelling any older Enter retry timer. */
@@ -4815,7 +4806,8 @@ export class Session extends EventEmitter {
!!current &&
current.generation === expected.generation &&
current.revision === expected.revision &&
current.composerSha === expected.composerSha;
current.composerSha === expected.composerSha &&
current.availableColumns === expected.availableColumns;
if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false;
this._guardedSubmitInFlight = true;
let dispatched = false;
@@ -4845,6 +4837,24 @@ export class Session extends EventEmitter {
this._guardedPendingWrites--;
}
}
/**
* Sends input directly to the PTY process.
*
* For interactive sessions, this is how you send user input to Claude.
* Remember to include `\r` (carriage return) to simulate pressing Enter.
*
* @example
* ```typescript
* session.write('hello world'); // Text only, no Enter
* session.write('\r'); // Enter key
* session.write('ls -la\r'); // Command with Enter
* ```
*
* @param data - The input data to send (text, escape sequences, etc.)
* @returns true if the data reached a PTY. A session whose PTY is gone still
* discards the data, but it used to do so with no signal at all — which is how
* input could disappear while the caller believed it had been delivered.
*/
write(data: string, options: SessionWriteOptions = {}): boolean {
this._observeGuardedKeyboardInput(data);
if (this._guardedSubmitInFlight) return false;
@@ -5219,7 +5229,7 @@ export class Session extends EventEmitter {
}
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
if (this.ptyProcess && (dimsChanged || options.force)) {
this._guardedPaneRevision++;
this._guardedGeometryRevision++;
this._ptyCols = cols;
this._ptyRows = rows;
if (!IS_TEST_MODE && this._mux && this._muxSession) {
+1 -1
View File
@@ -27,7 +27,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor
snapshot: () => {
if (ctx.sessions.get(session.id) !== session) return null;
const snapshot = session.guardedInputSnapshot?.();
return snapshot ? { ...snapshot, generation: generation(session) } : null;
return snapshot ?? null;
},
write: (text) => session.stageGuardedInput(text),
submit: (expected) => session.submitGuardedInput(expected),
+2
View File
@@ -47,3 +47,5 @@ export {
type CustomModelSwapDisplacement,
} from './custom-model-routes.js';
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
export { registerAgentSessionRoutes } from './agent-session-routes.js';
-3
View File
@@ -5,7 +5,6 @@
*/
import { claudeProjectDirName } from '../../case-rename.js';
import { registerAgentSessionRoutes } from './agent-session-routes.js';
import { FastifyInstance, type FastifyRequest } from 'fastify';
import { z } from 'zod';
import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path';
@@ -1073,8 +1072,6 @@ export function registerSessionRoutes(
// ========== Logout ==========
registerAgentSessionRoutes(app, ctx);
app.post('/api/logout', async (req, reply) => {
// Invalidate server-side session token (not just the browser cookie)
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
+2
View File
@@ -213,6 +213,7 @@ import {
registerCaseRoutes,
registerWorktreeRoutes,
registerSessionRoutes,
registerAgentSessionRoutes,
registerRespawnRoutes,
registerRalphRoutes,
registerPlanRoutes,
@@ -1249,6 +1250,7 @@ export class WebServer extends EventEmitter {
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
registerAgentSessionRoutes(this.app, ctx);
registerRespawnRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx);
registerPlanRoutes(this.app, ctx);