mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(agent): address inspection and staging review feedback
This commit is contained in:
1 parent
a24b0ec7bb
commit
4c2f050a96
24 files changed
+517
-116
No files matched your search
+20
-14
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user