Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-codex-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stoneforge/smithy": patch
---

Update Codex interactive sessions to use the documented workspace-write sandbox flag.
5 changes: 5 additions & 0 deletions .changeset/quiet-codex-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stoneforge/smithy": patch
---

Fix Codex interactive resume so only UUID session IDs from the Codex continuation footer are persisted. Stopping an interactive Codex session now requests a clean `/exit` shutdown, allowing Stoneforge to capture the resume ID and avoid offering invalid resume targets from ordinary terminal text.
12 changes: 12 additions & 0 deletions apps/smithy-server/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { formatSessionRecord } from '../formatters.js';
import { notifySSEClientsOfNewSession } from './events.js';

const logger = createLogger('sessions');
const CODEX_RESUME_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

type NotifyClientsCallback = (
agentId: EntityId,
Expand Down Expand Up @@ -593,6 +594,17 @@ Please begin working on this task. Use \`sf task get ${taskResult.id}\` to see f
providerSessionId = resumable.providerSessionId;
}

const meta = getAgentMetadata(agent);
if ((meta as { provider?: string } | undefined)?.provider === 'codex'
&& !CODEX_RESUME_SESSION_ID_PATTERN.test(providerSessionId)) {
return c.json({
error: {
code: 'INVALID_PROVIDER_SESSION_ID',
message: 'Codex resume requires a valid UUID session ID',
},
}, 400);
}

const { session, events, uwpCheck } = await sessionManager.resumeSession(agentId, {
providerSessionId,
workingDirectory: body.workingDirectory,
Expand Down
47 changes: 28 additions & 19 deletions apps/smithy-web/src/api/hooks/useAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ import type {
// ============================================================================

const API_BASE = '/api';
const CODEX_RESUME_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function hasValidProviderSessionIdForAgent(agent: Agent, session: unknown): session is { providerSessionId: string } {
const providerSessionId = (session as { providerSessionId?: unknown } | null)?.providerSessionId;
if (typeof providerSessionId !== 'string' || providerSessionId.length === 0) {
return false;
}

return agent.metadata?.agent?.provider !== 'codex'
|| CODEX_RESUME_SESSION_ID_PATTERN.test(providerSessionId);
}

async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
Expand Down Expand Up @@ -211,25 +222,23 @@ export function useDirectors(): {
})),
});

const directors: DirectorInfo[] = useMemo(() => {
return (directorAgents ?? []).map((director, i) => {
const query = statusQueries[i];
const statusData = query?.data;
const history = statusData?.recentHistory ?? [];
const lastResumableSession = history.find((h) => !!h.providerSessionId) ?? null;

return {
director,
hasActiveSession: statusData?.hasActiveSession ?? false,
activeSession: statusData?.activeSession ?? null,
recentHistory: history,
lastResumableSession,
hasResumableSession: lastResumableSession !== null,
isLoading: query?.isLoading ?? true,
error: (query?.error as Error) ?? null,
};
});
}, [directorAgents, statusQueries]);
const directors: DirectorInfo[] = (directorAgents ?? []).map((director, i) => {
const query = statusQueries[i];
const statusData = query?.data;
const history = statusData?.recentHistory ?? [];
const lastResumableSession = history.find((h) => hasValidProviderSessionIdForAgent(director, h)) ?? null;

return {
director,
hasActiveSession: statusData?.hasActiveSession ?? false,
activeSession: statusData?.activeSession ?? null,
recentHistory: history,
lastResumableSession,
hasResumableSession: lastResumableSession !== null,
isLoading: query?.isLoading ?? true,
error: (query?.error as Error) ?? null,
};
});

const isLoading = agentsLoading || statusQueries.some(q => q.isLoading);
const combinedError = agentsError ?? statusQueries.find(q => q.error)?.error ?? null;
Expand Down
103 changes: 80 additions & 23 deletions packages/smithy/src/providers/codex/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,87 @@ import { shellQuote } from '../shell-quote.js';
// Helpers
// ============================================================================

type CodexInteractiveArgOptions = Pick<
InteractiveSpawnOptions,
'resumeSessionId' | 'workingDirectory' | 'model'
>;

const CODEX_RESUME_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const CODEX_CONTINUE_SESSION_PATTERN =
/To continue this session,\s+run\s+codex\s+resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
const ANSI_ESCAPE_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
const MAX_CODEX_OUTPUT_BUFFER = 4096;

export function isCodexResumeSessionId(value: string): value is ProviderSessionId {
return CODEX_RESUME_SESSION_ID_PATTERN.test(value);
}

export function extractCodexResumeSessionId(output: string): ProviderSessionId | undefined {
const normalizedOutput = output.replace(ANSI_ESCAPE_PATTERN, '');
return normalizedOutput.match(CODEX_CONTINUE_SESSION_PATTERN)?.[1];
}

export function createCodexResumeSessionIdDetector(
maxBufferLength = MAX_CODEX_OUTPUT_BUFFER,
): (chunk: string) => ProviderSessionId | undefined {
let buffer = '';

return (chunk: string) => {
buffer = (buffer + chunk).slice(-maxBufferLength);
return extractCodexResumeSessionId(buffer);
};
}

export function writeCodexExitCommand(
write: (data: string) => void,
scheduleEnter: (callback: () => void) => void = (callback) => {
setTimeout(callback, 50);
},
): void {
write('/exit');
scheduleEnter(() => write('\r'));
}

export function buildCodexInteractiveArgs(
options: CodexInteractiveArgOptions,
platform: NodeJS.Platform = process.platform,
): string[] {
const quote = (value: string) => shellQuote(value, platform);
const args: string[] = [];

if (options.resumeSessionId) {
if (!isCodexResumeSessionId(options.resumeSessionId)) {
throw new Error(`Invalid Codex resume session ID: ${options.resumeSessionId}`);
}
args.push('resume', quote(options.resumeSessionId), '--sandbox', 'workspace-write');
} else {
args.push('--sandbox', 'workspace-write', '--cd', quote(options.workingDirectory));
}

if (options.model) {
args.push('--model', quote(options.model));
}

return args;
}

// ============================================================================
// Codex Interactive Session
// ============================================================================

class CodexInteractiveSession implements InteractiveSession {
private ptyProcess: IPty;
private sessionId: ProviderSessionId | undefined;
private readonly detectResumeSessionId = createCodexResumeSessionIdDetector();

readonly pid?: number;

constructor(ptyProcess: IPty) {
constructor(ptyProcess: IPty, resumeSessionId?: ProviderSessionId) {
this.ptyProcess = ptyProcess;
this.pid = ptyProcess.pid;
this.sessionId = resumeSessionId;

// Listen for thread/session ID in output and auto-respond to terminal queries.
// Listen for Codex's continuation footer and auto-respond to terminal queries.
// Codex sends DSR (Device Status Report) \x1b[6n on startup to query cursor
// position. When no terminal emulator (e.g. xterm.js) is connected to respond,
// codex times out and exits. We auto-respond with a default cursor position
Expand All @@ -46,9 +112,9 @@ class CodexInteractiveSession implements InteractiveSession {
}

if (!this.sessionId) {
const match = data.match(/(?:Thread|Session|thr_)[:=\s]*([a-z0-9_-]+)/i);
if (match) {
this.sessionId = match[1];
const detectedSessionId = this.detectResumeSessionId(data);
if (detectedSessionId) {
this.sessionId = detectedSessionId;
}
}
});
Expand All @@ -58,6 +124,10 @@ class CodexInteractiveSession implements InteractiveSession {
this.ptyProcess.write(data);
}

requestExit(): void {
writeCodexExitCommand((data) => this.ptyProcess.write(data));
}

resize(cols: number, rows: number): void {
this.ptyProcess.resize(cols, rows);
}
Expand Down Expand Up @@ -95,7 +165,7 @@ export class CodexInteractiveProvider implements InteractiveProvider {
}

async spawn(options: InteractiveSpawnOptions): Promise<InteractiveSession> {
const args = this.buildArgs(options);
const args = buildCodexInteractiveArgs(options);

const env: Record<string, string> = {
...(process.env as Record<string, string>),
Expand Down Expand Up @@ -131,7 +201,10 @@ export class CodexInteractiveProvider implements InteractiveProvider {
env,
});

const session = new CodexInteractiveSession(ptyProcess);
const resumeSessionId = options.resumeSessionId && isCodexResumeSessionId(options.resumeSessionId)
? options.resumeSessionId
: undefined;
const session = new CodexInteractiveSession(ptyProcess, resumeSessionId);

// On Windows, write the command to cmd.exe stdin (bash -c handles this on Unix)
if (process.platform === 'win32') {
Expand All @@ -156,20 +229,4 @@ export class CodexInteractiveProvider implements InteractiveProvider {
}
}

private buildArgs(options: InteractiveSpawnOptions): string[] {
const args: string[] = [];

if (options.resumeSessionId) {
args.push('resume', shellQuote(options.resumeSessionId), '--full-auto');
} else {
args.push('--full-auto', '--cd', shellQuote(options.workingDirectory));
}

// Add model flag if provided
if (options.model) {
args.push('--model', shellQuote(options.model));
}

return args;
}
}
Loading