1212
1313import * as fs from 'node:fs/promises' ;
1414import * as path from 'node:path' ;
15+ import { type JsonMap , parse as parseToml , stringify as stringifyToml } from '@iarna/toml' ;
1516import { Codex , type Thread , type ThreadItem } from '@openai/codex-sdk' ;
1617import { getCredential , resolveApiKey , resolveUserEnvironment } from '../../config' ;
1718import type { Database } from '../../db/client' ;
@@ -24,6 +25,16 @@ import type { TokenUsage } from '../../utils/pricing';
2425import { DEFAULT_CODEX_MODEL } from './models' ;
2526import { extractCodexTokenUsage } from './usage' ;
2627
28+ type CodexTomlServerConfig = Record < string , unknown > ;
29+
30+ interface CodexTomlConfig {
31+ approval_policy ?: string ;
32+ sandbox_workspace_write ?: Record < string , unknown > ;
33+ mcp_servers ?: Record < string , CodexTomlServerConfig > ;
34+ agor_managed_servers ?: string [ ] ;
35+ [ key : string ] : unknown ;
36+ }
37+
2738export interface CodexPromptResult {
2839 /** Complete assistant response from Codex */
2940 messages : Array < {
@@ -165,7 +176,7 @@ export class CodexPromptService {
165176 }
166177
167178 /**
168- * Generate ~/.codex/config.toml with approval_policy, network_access, and MCP servers
179+ * Generate Codex configuration files with approval_policy, network_access, and MCP servers
169180 *
170181 * NOTE: approval_policy, network_access, and MCP servers must be configured via config.toml
171182 * (not available in ThreadOptions). We minimize file writes by tracking a hash
@@ -174,12 +185,14 @@ export class CodexPromptService {
174185 * @param approvalPolicy - Codex approval policy (untrusted, on-request, on-failure, never)
175186 * @param networkAccess - Whether to allow outbound network access in workspace-write mode
176187 * @param sessionId - Session ID for fetching MCP servers
188+ * @param worktreePath - Optional worktree path for repo-local .codex/config.toml
177189 * @returns Number of MCP servers configured
178190 */
179191 private async ensureCodexConfig (
180192 approvalPolicy : 'untrusted' | 'on-request' | 'on-failure' | 'never' ,
181193 networkAccess : boolean ,
182- sessionId : SessionID
194+ sessionId : SessionID ,
195+ worktreePath ?: string
183196 ) : Promise < number > {
184197 // Fetch MCP servers for this session (if repository is available)
185198 console . log ( `🔍 [Codex MCP] Fetching MCP servers for session ${ sessionId . substring ( 0 , 8 ) } ...` ) ;
@@ -208,83 +221,170 @@ export class CodexPromptService {
208221 }
209222 }
210223
211- // Generate MCP servers TOML blocks (stdio only)
212- let mcpServersToml = '' ;
224+ // Build normalized server configs for cautious upsert
225+ const managedServerConfigs : Record < string , Record < string , unknown > > = { } ;
226+ const managedServerNames = new Set < string > ( ) ;
213227 for ( const server of stdioServers ) {
214- // Normalize server name to lowercase for TOML convention
215228 const serverName = server . name . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 _ - ] / g, '_' ) ;
229+ managedServerNames . add ( serverName ) ;
216230 console . log ( ` 📝 [Codex MCP] Configuring server: ${ server . name } -> ${ serverName } ` ) ;
217231
218- mcpServersToml += `\n[mcp_servers. ${ serverName } ]\n` ;
232+ const serverConfig : Record < string , unknown > = { } ;
219233 if ( server . command ) {
220- mcpServersToml += ` command = " ${ server . command } "\n` ;
234+ serverConfig . command = server . command ;
221235 console . log ( ` command: ${ server . command } ` ) ;
222236 }
223237 if ( server . args && server . args . length > 0 ) {
224- const argsJson = JSON . stringify ( server . args ) ;
225- mcpServersToml += `args = ${ argsJson } \n` ;
226- console . log ( ` args: ${ argsJson } ` ) ;
238+ serverConfig . args = server . args ;
239+ console . log ( ` args: ${ JSON . stringify ( server . args ) } ` ) ;
227240 }
228-
229- // Add environment variables if present
230241 if ( server . env && Object . keys ( server . env ) . length > 0 ) {
231- mcpServersToml += `\n[mcp_servers.${ serverName } .env]\n` ;
232- const envCount = Object . keys ( server . env ) . length ;
233- console . log ( ` env vars: ${ envCount } variable(s)` ) ;
234- for ( const [ key , value ] of Object . entries ( server . env ) ) {
235- mcpServersToml += `${ key } = "${ value } "\n` ;
236- }
242+ serverConfig . env = server . env ;
243+ console . log ( ` env vars: ${ Object . keys ( server . env ) . length } variable(s)` ) ;
237244 }
238- }
239-
240- // Generate network access TOML section (only for workspace-write sandbox)
241- const networkAccessToml = networkAccess
242- ? `\n[sandbox_workspace_write]\nnetwork_access = true\n`
243- : '' ;
244245
245- // Generate complete config content
246- const configContent = `# Codex configuration
247- # Generated by Agor - ${ new Date ( ) . toISOString ( ) }
248-
249- # Approval policy controls when Codex asks before running commands
250- # Options: "untrusted", "on-request", "on-failure", "never"
251- approval_policy = "${ approvalPolicy } "
252- ${ networkAccessToml } ${ mcpServersToml } `;
246+ managedServerConfigs [ serverName ] = serverConfig ;
247+ }
253248
254249 // Create hash to detect changes (include network access in hash)
255- const configHash = `${ approvalPolicy } :${ networkAccess } :${ JSON . stringify ( stdioServers . map ( s => s . mcp_server_id ) ) } ` ;
250+ const configHash = `${ approvalPolicy } :${ networkAccess } :${ worktreePath ?? '' } :${ JSON . stringify (
251+ stdioServers . map ( s => s . mcp_server_id )
252+ ) } `;
256253
257254 // Skip if config hasn't changed (avoid unnecessary file I/O)
258255 if ( this . lastMCPServersHash === configHash ) {
259256 console . log ( `✅ [Codex MCP] Config unchanged, skipping write` ) ;
260257 return stdioServers . length ;
261258 }
262259
260+ const configTargets : Array < { label : 'global' | 'worktree' ; path : string } > = [ ] ;
261+
263262 const homeDir = process . env . HOME || process . env . USERPROFILE ;
264- if ( ! homeDir ) {
265- console . warn ( '⚠️ [Codex MCP] Could not determine home directory, skipping Codex config' ) ;
266- return 0 ;
263+ if ( homeDir ) {
264+ configTargets . push ( {
265+ label : 'global' ,
266+ path : path . join ( homeDir , '.codex' , 'config.toml' ) ,
267+ } ) ;
268+ } else {
269+ console . warn (
270+ '⚠️ [Codex MCP] Could not determine home directory, skipping global Codex config'
271+ ) ;
267272 }
268273
269- const codexConfigDir = path . join ( homeDir , '.codex' ) ;
270- const configPath = path . join ( codexConfigDir , 'config.toml' ) ;
274+ if ( worktreePath ) {
275+ const repoConfigPath = path . join ( worktreePath , '.codex' , 'config.toml' ) ;
276+ configTargets . push ( {
277+ label : 'worktree' ,
278+ path : repoConfigPath ,
279+ } ) ;
280+ }
271281
272- console . log ( `📁 [Codex MCP] Writing config to: ${ configPath } ` ) ;
273- console . log ( `📄 [Codex MCP] Config content:\n${ configContent } ` ) ;
282+ if ( configTargets . length === 0 ) {
283+ console . warn ( '⚠️ [Codex MCP] No writable locations found for Codex config' ) ;
284+ return stdioServers . length ;
285+ }
274286
275- await fs . mkdir ( codexConfigDir , { recursive : true } ) ;
276- await fs . writeFile ( configPath , configContent , 'utf-8' ) ;
287+ for ( const target of configTargets ) {
288+ let config : CodexTomlConfig = { } ;
289+ let previouslyManaged = new Set < string > ( ) ;
290+
291+ try {
292+ const existing = await fs . readFile ( target . path , 'utf-8' ) ;
293+ config = parseToml ( existing ) as CodexTomlConfig ;
294+ const managedList = Array . isArray ( config . agor_managed_servers )
295+ ? config . agor_managed_servers . map ( ( value : unknown ) => String ( value ) ) . filter ( Boolean )
296+ : [ ] ;
297+ previouslyManaged = new Set < string > ( managedList ) ;
298+ } catch ( error ) {
299+ if ( ( error as NodeJS . ErrnoException ) ?. code !== 'ENOENT' ) {
300+ console . warn (
301+ `⚠️ [Codex MCP] Failed to read existing ${ target . label } config (${ target . path } ):` ,
302+ error
303+ ) ;
304+ }
305+ config = { } ;
306+ previouslyManaged = new Set < string > ( ) ;
307+ }
277308
278- this . lastMCPServersHash = configHash ;
279- console . log (
280- `✅ [Codex] Updated config.toml with approval_policy = "${ approvalPolicy } ", network_access = ${ networkAccess } `
281- ) ;
309+ // Upsert approval policy
310+ config . approval_policy = approvalPolicy ;
311+
312+ // Upsert sandbox workspace settings
313+ if ( networkAccess ) {
314+ const sandbox =
315+ typeof config . sandbox_workspace_write === 'object' &&
316+ config . sandbox_workspace_write !== null
317+ ? { ...config . sandbox_workspace_write }
318+ : { } ;
319+ sandbox . network_access = true ;
320+ config . sandbox_workspace_write = sandbox ;
321+ } else if (
322+ typeof config . sandbox_workspace_write === 'object' &&
323+ config . sandbox_workspace_write !== null
324+ ) {
325+ const sandbox = { ...config . sandbox_workspace_write } ;
326+ delete sandbox . network_access ;
327+ if ( Object . keys ( sandbox ) . length === 0 ) {
328+ delete config . sandbox_workspace_write ;
329+ } else {
330+ config . sandbox_workspace_write = sandbox ;
331+ }
332+ }
282333
283- // Reinitialize Codex SDK to pick up the new config
334+ // Prepare MCP servers table
335+ const mcpServersTable : Record < string , CodexTomlServerConfig > =
336+ typeof config . mcp_servers === 'object' && config . mcp_servers !== null
337+ ? { ...config . mcp_servers }
338+ : { } ;
339+
340+ // Remove previously managed entries no longer present
341+ for ( const name of previouslyManaged ) {
342+ if ( ! managedServerNames . has ( name ) ) {
343+ delete mcpServersTable [ name ] ;
344+ }
345+ }
346+
347+ // Upsert current managed servers
348+ for ( const [ name , serverConfig ] of Object . entries ( managedServerConfigs ) ) {
349+ mcpServersTable [ name ] = serverConfig ;
350+ }
351+
352+ if ( Object . keys ( mcpServersTable ) . length > 0 ) {
353+ config . mcp_servers = mcpServersTable ;
354+ } else {
355+ delete config . mcp_servers ;
356+ }
357+
358+ if ( managedServerNames . size > 0 ) {
359+ config . agor_managed_servers = Array . from ( managedServerNames ) . sort ( ) ;
360+ } else {
361+ delete config . agor_managed_servers ;
362+ }
363+
364+ const header = `# Codex configuration\n# Generated by Agor - ${ new Date ( ) . toISOString ( ) } \n\n` ;
365+ const body = stringifyToml ( config as JsonMap ) ;
366+
367+ try {
368+ await fs . mkdir ( path . dirname ( target . path ) , { recursive : true } ) ;
369+ await fs . writeFile ( target . path , header + body , 'utf-8' ) ;
370+ console . log (
371+ `✅ [Codex MCP] Updated ${ target . label } config with ${ managedServerNames . size } managed MCP server(s)`
372+ ) ;
373+ } catch ( error ) {
374+ console . error (
375+ `❌ [Codex MCP] Failed to write ${ target . label } config (${ target . path } ):` ,
376+ error
377+ ) ;
378+ }
379+ }
380+
381+ this . lastMCPServersHash = configHash ;
284382 this . reinitializeCodex ( ) ;
285383 if ( stdioServers . length > 0 ) {
286384 console . log (
287- `✅ [Codex MCP] Configured ${ stdioServers . length } STDIO MCP server(s): ${ stdioServers . map ( s => s . name ) . join ( ', ' ) } `
385+ `✅ [Codex MCP] Configured ${ stdioServers . length } STDIO MCP server(s): ${ stdioServers
386+ . map ( s => s . name )
387+ . join ( ', ' ) } `
288388 ) ;
289389 }
290390
@@ -423,8 +523,23 @@ ${networkAccessToml}${mcpServersToml}`;
423523 ` Using Codex permissions: sandboxMode=${ sandboxMode } , approvalPolicy=${ approvalPolicy } , networkAccess=${ networkAccess } `
424524 ) ;
425525
526+ // Fetch worktree to determine working directory (needed before writing repo-level config)
527+ const worktree = this . worktreesRepo
528+ ? await this . worktreesRepo . findById ( session . worktree_id )
529+ : null ;
530+ if ( ! worktree ) {
531+ throw new Error ( `Worktree ${ session . worktree_id } not found for session ${ sessionId } ` ) ;
532+ }
533+
534+ const worktreePath = worktree . path ;
535+
426536 // Set approval_policy, network_access, and MCP servers in config.toml (required because they're not available in ThreadOptions)
427- const mcpServerCount = await this . ensureCodexConfig ( approvalPolicy , networkAccess , sessionId ) ;
537+ const mcpServerCount = await this . ensureCodexConfig (
538+ approvalPolicy ,
539+ networkAccess ,
540+ sessionId ,
541+ worktreePath
542+ ) ;
428543
429544 const totalMcpServers = this . sessionMCPServerRepo
430545 ? ( await this . sessionMCPServerRepo . listServers ( sessionId , true ) ) . length
@@ -439,19 +554,11 @@ ${networkAccessToml}${mcpServersToml}`;
439554 ) ;
440555 }
441556
442- // Fetch worktree to get working directory
443- const worktree = this . worktreesRepo
444- ? await this . worktreesRepo . findById ( session . worktree_id )
445- : null ;
446- if ( ! worktree ) {
447- throw new Error ( `Worktree ${ session . worktree_id } not found for session ${ sessionId } ` ) ;
448- }
449-
450- console . log ( ` Working directory: ${ worktree . path } ` ) ;
557+ console . log ( ` Working directory: ${ worktreePath } ` ) ;
451558
452559 // Build thread options with sandbox mode and worktree working directory
453560 const threadOptions = {
454- workingDirectory : worktree . path ,
561+ workingDirectory : worktreePath ,
455562 skipGitRepoCheck : false ,
456563 sandboxMode,
457564 } ;
0 commit comments