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
125 changes: 123 additions & 2 deletions container/agent-runner/src/providers/codex-app-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@ import path from 'path';

import {
type AppServer,
CODEX_APP_SERVER_ARGS,
attachCodexAutoApproval,
buildCodexProcessEnv,
startOrResumeCodexThread,
tomlBasicString,
writeCodexConfigToml,
} from './codex-app-server.js';

const MEMORY_SESSION_HOOK = {
command: 'bun /app/src/memory/hook.ts',
legacyCommands: ['bun /app/src/memory-hook.ts'],
sources: ['startup', 'clear', 'compact'],
} as const;

let tmpHome: string | null = null;
const originalHome = process.env.HOME;

Expand Down Expand Up @@ -43,6 +51,7 @@ describe('Codex config TOML', () => {
env: { FOO: 'bar' },
},
},
MEMORY_SESSION_HOOK,
{ model: 'gpt-5', effort: 'medium' },
);

Expand All @@ -52,13 +61,91 @@ describe('Codex config TOML', () => {
expect(content).toContain('project_doc_max_bytes = 32768');
expect(content).toContain('model = "gpt-5"');
expect(content).toContain('model_reasoning_effort = "medium"');
expect(content).toContain('[features]\nmemories = false');
expect(content).toContain('[memories]\nuse_memories = false\ngenerate_memories = false');
expect(content).not.toContain('[sandbox_workspace_write]');
expect(content).not.toContain('writable_roots =');
expect(content).toContain('[mcp_servers.nanoclaw]');
expect(content).toContain('command = "bun"');
expect(content).toContain('args = ["run", "/app/src/mcp-tools/index.ts"]');
expect(content).toContain('[mcp_servers.nanoclaw.env]');
expect(content).toContain('FOO = "bar"');

const hooks = JSON.parse(fs.readFileSync(path.join(tmpHome, '.codex', 'hooks.json'), 'utf-8'));
expect(hooks.hooks.SessionStart).toEqual([
{
matcher: 'startup|clear|compact',
hooks: [{ type: 'command', command: 'bun /app/src/memory/hook.ts', timeout: 10 }],
},
]);
expect(CODEX_APP_SERVER_ARGS).toContain('--dangerously-bypass-hook-trust');
});

it('preserves unrelated hooks and refreshes only the NanoClaw memory entry', () => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-'));
process.env.HOME = tmpHome;
const hooksPath = path.join(tmpHome, '.codex', 'hooks.json');
fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
fs.writeFileSync(
hooksPath,
JSON.stringify({
version: 1,
hooks: {
Stop: [{ hooks: [{ type: 'command', command: 'custom-stop' }] }],
SessionStart: [
{ matcher: 'resume', hooks: [{ type: 'command', command: 'custom-resume' }] },
{
matcher: '.*',
hooks: [
{ type: 'command', command: 'bun /app/src/memory-hook.ts' },
{ type: 'command', command: 'custom-start' },
],
},
],
},
}),
);

writeCodexConfigToml({}, MEMORY_SESSION_HOOK);
writeCodexConfigToml({}, MEMORY_SESSION_HOOK);

const config = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
expect(config.version).toBe(1);
expect(config.hooks.Stop).toHaveLength(1);
expect(config.hooks.SessionStart).toContainEqual({
matcher: '.*',
hooks: [{ type: 'command', command: 'custom-start' }],
});
expect(
config.hooks.SessionStart.filter((entry: { hooks?: Array<{ command?: string }> }) =>
entry.hooks?.some((hook) => hook.command === 'bun /app/src/memory/hook.ts'),
),
).toEqual([
{
matcher: 'startup|clear|compact',
hooks: [{ type: 'command', command: 'bun /app/src/memory/hook.ts', timeout: 10 }],
},
]);
});
});

describe('Codex thread SessionStart source', () => {
it('sets startup only for a new thread', async () => {
const { server, requests } = autoRespondingServer();

await startOrResumeCodexThread(server, undefined, { cwd: '/workspace/agent' });

expect(requests[0].method).toBe('thread/start');
expect(requests[0].params.sessionStartSource).toBe('startup');
});

it('does not send startup when resuming', async () => {
const { server, requests } = autoRespondingServer();

await startOrResumeCodexThread(server, 'thread-existing', { cwd: '/workspace/agent' });

expect(requests[0].method).toBe('thread/resume');
expect(requests[0].params.sessionStartSource).toBeUndefined();
});
});

Expand Down Expand Up @@ -89,7 +176,11 @@ describe('Codex auto-approval', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);

server.serverRequestHandlers[0]({ id: 2, method: 'item/fileChange/requestApproval', params: { grantRoot: '/etc' } });
server.serverRequestHandlers[0]({
id: 2,
method: 'item/fileChange/requestApproval',
params: { grantRoot: '/etc' },
});
server.serverRequestHandlers[0]({
id: 3,
method: 'item/commandExecution/requestApproval',
Expand All @@ -109,7 +200,11 @@ describe('Codex auto-approval', () => {
method: 'applyPatchApproval',
params: { fileChanges: { '/etc/passwd': {} } },
});
server.serverRequestHandlers[0]({ id: 5, method: 'execCommandApproval', params: { command: 'rm -rf /', cwd: '/' } });
server.serverRequestHandlers[0]({
id: 5,
method: 'execCommandApproval',
params: { command: 'rm -rf /', cwd: '/' },
});

expect(JSON.parse(writes[0]).result).toEqual({ decision: 'approved' });
expect(JSON.parse(writes[1]).result).toEqual({ decision: 'approved' });
Expand Down Expand Up @@ -160,3 +255,29 @@ function fakeServer(): { server: AppServer; writes: string[] } {
} as unknown as AppServer;
return { server, writes };
}

function autoRespondingServer(): {
server: AppServer;
requests: Array<{ id: number; method: string; params: Record<string, unknown> }>;
} {
const requests: Array<{ id: number; method: string; params: Record<string, unknown> }> = [];
let server: AppServer;
server = {
process: {
stdin: {
write: (line: string) => {
const request = JSON.parse(line) as { id: number; method: string; params: Record<string, unknown> };
requests.push(request);
const threadId = (request.params.threadId as string | undefined) ?? 'thread-new';
server.pending.get(request.id)?.resolve({ id: request.id, result: { thread: { id: threadId } } });
},
},
},
readline: { close: () => {} },
pending: new Map(),
notificationHandlers: [],
exitHandlers: [],
serverRequestHandlers: [],
} as unknown as AppServer;
return { server, requests };
}
90 changes: 85 additions & 5 deletions container/agent-runner/src/providers/codex-app-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ function log(msg: string): void {

const INIT_TIMEOUT_MS = 30_000;

export const CODEX_APP_SERVER_ARGS = ['--dangerously-bypass-hook-trust', 'app-server', '--listen', 'stdio://'] as const;

export interface CodexMemorySessionHook {
readonly command: string;
readonly legacyCommands: readonly string[];
readonly sources: readonly string[];
}

export const STALE_THREAD_RE = /thread\s+not\s+found|unknown\s+thread|thread[_\s]id|no such thread/i;

let nextRequestId = 1;
Expand Down Expand Up @@ -116,7 +124,9 @@ export interface TurnParams {
}

export function spawnCodexAppServer(): AppServer {
const args = ['app-server', '--listen', 'stdio://'];
// NanoClaw generates the hook config and has no interactive user available
// to approve hook trust inside the container.
const args = [...CODEX_APP_SERVER_ARGS];
log(`Spawning: codex ${args.join(' ')}`);

const proc = spawn('codex', args, {
Expand Down Expand Up @@ -261,22 +271,21 @@ export async function startOrResumeCodexThread(
threadId: string | undefined,
params: ThreadParams,
): Promise<string> {
const baseParams = {
const commonParams = {
model: params.model,
cwd: params.cwd,
approvalPolicy: CODEX_APPROVAL_POLICY,
sandbox: CODEX_SANDBOX_MODE,
baseInstructions: params.baseInstructions,
developerInstructions: params.developerInstructions,
personality: 'friendly',
sessionStartSource: 'startup',
persistExtendedHistory: false,
};

if (threadId) {
const resp = await sendCodexRequest(server, 'thread/resume', {
threadId,
...baseParams,
...commonParams,
excludeTurns: true,
});
if (!resp.error) return threadId;
Expand All @@ -287,7 +296,8 @@ export async function startOrResumeCodexThread(
}

const resp = await sendCodexRequest(server, 'thread/start', {
...baseParams,
...commonParams,
sessionStartSource: 'startup',
experimentalRawEvents: false,
});
if (resp.error) throw new Error(`thread/start failed: ${resp.error.message}`);
Expand Down Expand Up @@ -372,11 +382,13 @@ export function attachCodexAutoApproval(server: AppServer): void {

export function writeCodexConfigToml(
servers: Record<string, CodexMcpServer>,
memorySessionHook: CodexMemorySessionHook,
opts: { model?: string; effort?: string } = {},
): void {
const codexConfigDir = path.join(process.env.HOME || '/home/node', '.codex');
fs.mkdirSync(codexConfigDir, { recursive: true });
const configTomlPath = path.join(codexConfigDir, 'config.toml');
const hooksJsonPath = path.join(codexConfigDir, 'hooks.json');

// Instance-level defaults the app-server reads on startup; threads/turns inherit them.
const lines: string[] = [
Expand All @@ -388,6 +400,16 @@ export function writeCodexConfigToml(
if (opts.effort) lines.push(`model_reasoning_effort = ${tomlBasicString(opts.effort)}`);
lines.push('');

// NanoClaw owns persistent memory across providers. Keep Codex's native
// memory disabled even if its defaults or a user-level config change.
lines.push('[features]');
lines.push('memories = false');
lines.push('');
lines.push('[memories]');
lines.push('use_memories = false');
lines.push('generate_memories = false');
lines.push('');

for (const [name, config] of Object.entries(servers)) {
lines.push(`[mcp_servers.${name}]`);
lines.push(`command = ${tomlBasicString(config.command)}`);
Expand All @@ -404,6 +426,64 @@ export function writeCodexConfigToml(
}

fs.writeFileSync(configTomlPath, lines.join('\n'));
const hooksConfig = readHooksConfig(hooksJsonPath);
const hooks = objectProperty(hooksConfig, 'hooks');
const sessionStart = arrayProperty(hooks, 'SessionStart');

const memoryCommands = new Set([memorySessionHook.command, ...memorySessionHook.legacyCommands]);
const nextSessionStart = sessionStart
.map((entry) => removeNanoClawMemoryHooks(entry, memoryCommands))
.filter((entry) => entry !== undefined);
nextSessionStart.push({
matcher: memorySessionHook.sources.join('|'),
hooks: [{ type: 'command', command: memorySessionHook.command, timeout: 10 }],
});
hooks.SessionStart = nextSessionStart;
fs.writeFileSync(hooksJsonPath, JSON.stringify(hooksConfig, null, 2) + '\n');
}

function readHooksConfig(filePath: string): Record<string, unknown> {
if (!fs.existsSync(filePath)) return {};
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (!isRecord(parsed)) {
throw new Error(`${filePath} must contain a JSON object`);
}
return parsed;
}

function objectProperty(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const value = parent[key];
if (value === undefined) {
const created: Record<string, unknown> = {};
parent[key] = created;
return created;
}
if (!isRecord(value)) {
throw new Error(`Codex hooks config property '${key}' must be an object`);
}
return value;
}

function arrayProperty(parent: Record<string, unknown>, key: string): unknown[] {
const value = parent[key];
if (value === undefined) return [];
if (!Array.isArray(value)) {
throw new Error(`Codex hooks config property '${key}' must be an array`);
}
return value;
}

function removeNanoClawMemoryHooks(value: unknown, commands: ReadonlySet<string>): unknown {
if (!isRecord(value) || !Array.isArray(value.hooks)) return value;
const remaining = value.hooks.filter((hook) => {
if (!isRecord(hook)) return true;
return typeof hook.command !== 'string' || !commands.has(hook.command);
});
return remaining.length > 0 ? { ...value, hooks: remaining } : undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}

export function buildCodexProcessEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
Expand Down
4 changes: 4 additions & 0 deletions container/agent-runner/src/providers/codex.factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ describe('CodexProvider', () => {
it('accepts supported reasoning effort values', () => {
expect(new CodexProvider({ effort: 'xhigh' })).toBeInstanceOf(CodexProvider);
});

it('requires the shared memory hook before starting a query', () => {
expect(() => new CodexProvider({}).query({ prompt: 'hello', cwd: '/workspace/agent' })).toThrow(/not registered/);
});
});
16 changes: 12 additions & 4 deletions container/agent-runner/src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
import { archiveProviderExchange } from './exchange-archive.js';
import {
type AppServer,
type CodexMemorySessionHook,
type CodexReasoningEffort,
type JsonRpcNotification,
STALE_THREAD_RE,
Expand Down Expand Up @@ -74,9 +75,6 @@ function normalizeEffort(effort: string | undefined): CodexReasoningEffort | und

export class CodexProvider implements AgentProvider {
readonly supportsNativeSlashCommands = false;
// Codex has no native NanoClaw memory — opt in to the runner's persistent
// memory/ scaffold (see memory-scaffold.ts).
readonly usesMemoryScaffold = true;
// The app-server keeps history server-side; there is no on-disk transcript,
// so the provider persists each exchange itself into `conversations/`
// (see exchange-archive.ts). The poll-loop reports exchanges through this
Expand All @@ -95,6 +93,7 @@ export class CodexProvider implements AgentProvider {
private readonly model?: string;
private readonly effort?: CodexReasoningEffort;
private readonly runtime: CodexRuntimeDeps;
private memorySessionHook?: CodexMemorySessionHook;

constructor(options: ProviderOptions = {}, runtime: CodexRuntimeDeps = defaultCodexRuntimeDeps) {
this.mcpServers = options.mcpServers ?? {};
Expand All @@ -103,12 +102,18 @@ export class CodexProvider implements AgentProvider {
this.effort = normalizeEffort(options.effort);
}

registerMemorySessionHook(hook: CodexMemorySessionHook): void {
this.memorySessionHook = hook;
}

isSessionInvalid(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return STALE_THREAD_RE.test(msg);
}

query(input: QueryInput): AgentQuery {
if (!this.memorySessionHook) throw new Error('Codex memory session hook was not registered');
const memorySessionHook = this.memorySessionHook;
const pending: string[] = [input.prompt];
let waiting: (() => void) | null = null;
let ended = false;
Expand Down Expand Up @@ -138,7 +143,10 @@ export class CodexProvider implements AgentProvider {
const self = this;

async function* gen(): AsyncGenerator<ProviderEvent> {
self.runtime.writeCodexConfigToml(self.mcpServers, { model: self.model, effort: self.effort });
self.runtime.writeCodexConfigToml(self.mcpServers, memorySessionHook, {
model: self.model,
effort: self.effort,
});
const server = self.runtime.spawnCodexAppServer();
activeServer = server;
self.runtime.attachCodexAutoApproval(server);
Expand Down
Loading