diff --git a/container/agent-runner/src/providers/codex-app-server.test.ts b/container/agent-runner/src/providers/codex-app-server.test.ts index 5b1e3734aae..f99db07a8c6 100644 --- a/container/agent-runner/src/providers/codex-app-server.test.ts +++ b/container/agent-runner/src/providers/codex-app-server.test.ts @@ -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; @@ -43,6 +51,7 @@ describe('Codex config TOML', () => { env: { FOO: 'bar' }, }, }, + MEMORY_SESSION_HOOK, { model: 'gpt-5', effort: 'medium' }, ); @@ -52,6 +61,8 @@ 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]'); @@ -59,6 +70,82 @@ describe('Codex config TOML', () => { 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(); }); }); @@ -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', @@ -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' }); @@ -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 }>; +} { + const requests: Array<{ id: number; method: string; params: Record }> = []; + let server: AppServer; + server = { + process: { + stdin: { + write: (line: string) => { + const request = JSON.parse(line) as { id: number; method: string; params: Record }; + 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 }; +} diff --git a/container/agent-runner/src/providers/codex-app-server.ts b/container/agent-runner/src/providers/codex-app-server.ts index dcbe60aa044..32f28130cf5 100644 --- a/container/agent-runner/src/providers/codex-app-server.ts +++ b/container/agent-runner/src/providers/codex-app-server.ts @@ -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; @@ -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, { @@ -261,7 +271,7 @@ export async function startOrResumeCodexThread( threadId: string | undefined, params: ThreadParams, ): Promise { - const baseParams = { + const commonParams = { model: params.model, cwd: params.cwd, approvalPolicy: CODEX_APPROVAL_POLICY, @@ -269,14 +279,13 @@ export async function startOrResumeCodexThread( 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; @@ -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}`); @@ -372,11 +382,13 @@ export function attachCodexAutoApproval(server: AppServer): void { export function writeCodexConfigToml( servers: Record, + 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[] = [ @@ -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)}`); @@ -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 { + 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, key: string): Record { + const value = parent[key]; + if (value === undefined) { + const created: Record = {}; + 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, 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): 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 { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } export function buildCodexProcessEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { diff --git a/container/agent-runner/src/providers/codex.factory.test.ts b/container/agent-runner/src/providers/codex.factory.test.ts index a8fc7742624..22a888384e9 100644 --- a/container/agent-runner/src/providers/codex.factory.test.ts +++ b/container/agent-runner/src/providers/codex.factory.test.ts @@ -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/); + }); }); diff --git a/container/agent-runner/src/providers/codex.ts b/container/agent-runner/src/providers/codex.ts index f6dfe7200e6..e906042ba79 100644 --- a/container/agent-runner/src/providers/codex.ts +++ b/container/agent-runner/src/providers/codex.ts @@ -14,6 +14,7 @@ import type { import { archiveProviderExchange } from './exchange-archive.js'; import { type AppServer, + type CodexMemorySessionHook, type CodexReasoningEffort, type JsonRpcNotification, STALE_THREAD_RE, @@ -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 @@ -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 ?? {}; @@ -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; @@ -138,7 +143,10 @@ export class CodexProvider implements AgentProvider { const self = this; async function* gen(): AsyncGenerator { - 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); diff --git a/container/agent-runner/src/providers/codex.turns.test.ts b/container/agent-runner/src/providers/codex.turns.test.ts index 2d7e2d5d384..3af08a2e019 100644 --- a/container/agent-runner/src/providers/codex.turns.test.ts +++ b/container/agent-runner/src/providers/codex.turns.test.ts @@ -7,10 +7,22 @@ import { CodexProvider, type CodexRuntimeDeps } from './codex.js'; import type { AppServer, JsonRpcNotification, TurnParams } from './codex-app-server.js'; import type { ProviderEvent } from './types.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; + +function createCodexProvider(...args: ConstructorParameters): CodexProvider { + const provider = new CodexProvider(...args); + provider.registerMemorySessionHook(MEMORY_SESSION_HOOK); + return provider; +} + describe('CodexProvider active turns', () => { it('steers follow-ups into the active turn and yields liveness activity', async () => { const fake = createFakeCodexRuntime(); - const provider = new CodexProvider({}, fake.runtime); + const provider = createCodexProvider({}, fake.runtime); const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' }); const events: ProviderEvent[] = []; @@ -34,7 +46,7 @@ describe('CodexProvider active turns', () => { it('queues follow-ups for the next turn when steering is rejected', async () => { const fake = createFakeCodexRuntime({ rejectSteer: true }); - const provider = new CodexProvider({}, fake.runtime); + const provider = createCodexProvider({}, fake.runtime); const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' }); const events: ProviderEvent[] = []; @@ -62,7 +74,7 @@ describe('CodexProvider active turns', () => { it('queues a follow-up that races turn completion into a new turn, never steering the finished turn', async () => { const fake = createFakeCodexRuntime(); - const provider = new CodexProvider({}, fake.runtime); + const provider = createCodexProvider({}, fake.runtime); const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' }); const events: ProviderEvent[] = []; @@ -93,7 +105,7 @@ describe('CodexProvider active turns', () => { it('interrupts the active turn and closes the stream on abort', async () => { const fake = createFakeCodexRuntime(); - const provider = new CodexProvider({}, fake.runtime); + const provider = createCodexProvider({}, fake.runtime); const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' }); const events: ProviderEvent[] = []; @@ -111,7 +123,7 @@ describe('CodexProvider active turns', () => { it('threads the configured model and effort into the turn', async () => { const fake = createFakeCodexRuntime(); - const provider = new CodexProvider({ model: 'gpt-5.5', effort: 'high' }, fake.runtime); + const provider = createCodexProvider({ model: 'gpt-5.5', effort: 'high' }, fake.runtime); const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' }); const events: ProviderEvent[] = []; @@ -134,7 +146,7 @@ describe('CodexProvider active turns', () => { process.env.CODEX_HOME = codexHome; try { const fake = createFakeCodexRuntime(); - const provider = new CodexProvider({}, fake.runtime); + const provider = createCodexProvider({}, fake.runtime); const query = provider.query({ prompt: 'make an image', cwd: '/workspace/agent' }); const events: ProviderEvent[] = []; const collect = collectEvents(query.events, events); @@ -163,7 +175,7 @@ describe('CodexProvider active turns', () => { it('ends the turn immediately with the real cause when the app-server dies mid-turn', async () => { const fake = createFakeCodexRuntime(); - const provider = new CodexProvider({}, fake.runtime); + const provider = createCodexProvider({}, fake.runtime); const query = provider.query({ prompt: 'prompt', cwd: '/workspace/agent' }); const events: ProviderEvent[] = []; diff --git a/src/providers/codex-agents-md.test.ts b/src/providers/codex-agents-md.test.ts index 10b14c5e86d..fed372391f7 100644 --- a/src/providers/codex-agents-md.test.ts +++ b/src/providers/codex-agents-md.test.ts @@ -60,29 +60,30 @@ describe('composeGroupAgentsMd cap handling', () => { // codex-discovered (~/.codex/skills). /workspace/agent/skills is not // scanned by codex, so authored skills there never trigger. expect(doc).toContain('~/.codex/skills'); + expect(doc).toContain('linked memory files'); + expect(doc).not.toContain('memories, data'); expect(Buffer.byteLength(doc, 'utf-8')).toBeLessThanOrEqual(CODEX_PROJECT_DOC_MAX_BYTES); } finally { fs.rmSync(groupDir, { recursive: true, force: true }); } }); - it('inlines the memory index so recall does not depend on a file read', () => { + it('never reads or inlines the host memory index', () => { const g = group('with-memory'); createAgentGroup(g); ensureContainerConfig(g.id); const groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-md-')); try { + const sentinel = path.join(TEST_ROOT, 'host-secret'); + fs.writeFileSync(sentinel, 'must not enter AGENTS.md\n'); fs.mkdirSync(path.join(groupDir, 'memory'), { recursive: true }); - fs.writeFileSync( - path.join(groupDir, 'memory', 'index.md'), - '# Memory Index\n- [People](memories/people/) - notes about people and their preferences\n', - ); + fs.symlinkSync(sentinel, path.join(groupDir, 'memory', 'index.md')); composeGroupAgentsMd(g, groupDir); const doc = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8'); - expect(doc).toContain('Current memory index'); - expect(doc).toContain('notes about people and their preferences'); + expect(doc).toContain('supplied by NanoClaw at session startup'); + expect(doc).not.toContain('must not enter AGENTS.md'); } finally { fs.rmSync(groupDir, { recursive: true, force: true }); } diff --git a/src/providers/codex-agents-md.ts b/src/providers/codex-agents-md.ts index 58eafa1d7af..7bf3099cc14 100644 --- a/src/providers/codex-agents-md.ts +++ b/src/providers/codex-agents-md.ts @@ -4,8 +4,8 @@ * AGENTS.md is Codex's project doc (its CLAUDE.md equivalent). Composed fresh * on every spawn by the codex provider contribution (see ./codex.ts) from: * - the shared base (`container/AGENTS.md`) - * - a pointer to the runner-scaffolded memory system (created container-side - * at boot via the `usesMemoryScaffold` capability — nothing is written here) + * - a pointer to the runner-scaffolded memory system (content is supplied by + * the provider's native SessionStart hook — nothing is read here) * - a pointer to codex-native skills under `.agents/skills` * - each enabled NanoClaw module's `*.instructions.md` fragment * - MCP server `instructions` from container.json @@ -30,28 +30,14 @@ const HEADER = '