Skip to content

Commit f65abf3

Browse files
committed
fix(codex): write mcp config to worktree
1 parent a893ff6 commit f65abf3

File tree

3 files changed

+190
-64
lines changed

3 files changed

+190
-64
lines changed

packages/core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@
161161
"@feathersjs/typebox": "^5.0.37",
162162
"@google/gemini-cli-core": "^0.9.0",
163163
"@google/genai": "^1.26.0",
164+
"@iarna/toml": "^3.0.0",
164165
"@libsql/client": "^0.15.15",
165166
"@openai/codex-sdk": "^0.47.0",
166167
"@opencode-ai/sdk": "^1.0.30",
@@ -176,6 +177,7 @@
176177
"devDependencies": {
177178
"@types/bcryptjs": "^2.4.6",
178179
"@types/js-yaml": "^4.0.9",
180+
"@types/iarna__toml": "^2.0.2",
179181
"@types/node": "^22.10.2",
180182
"@vitest/coverage-v8": "^4.0.3",
181183
"drizzle-kit": "^0.31.5",

packages/core/src/tools/codex/prompt-service.ts

Lines changed: 166 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import * as fs from 'node:fs/promises';
1414
import * as path from 'node:path';
15+
import { type JsonMap, parse as parseToml, stringify as stringifyToml } from '@iarna/toml';
1516
import { Codex, type Thread, type ThreadItem } from '@openai/codex-sdk';
1617
import { getCredential, resolveApiKey, resolveUserEnvironment } from '../../config';
1718
import type { Database } from '../../db/client';
@@ -24,6 +25,16 @@ import type { TokenUsage } from '../../utils/pricing';
2425
import { DEFAULT_CODEX_MODEL } from './models';
2526
import { 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+
2738
export 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-z0-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

Comments
 (0)