Skip to content

Commit 213c934

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

File tree

3 files changed

+189
-64
lines changed

3 files changed

+189
-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: 165 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,169 @@ 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> = {
233+
transport: server.transport,
234+
};
219235
if (server.command) {
220-
mcpServersToml += `command = "${server.command}"\n`;
236+
serverConfig.command = server.command;
221237
console.log(` command: ${server.command}`);
222238
}
223239
if (server.args && server.args.length > 0) {
224-
const argsJson = JSON.stringify(server.args);
225-
mcpServersToml += `args = ${argsJson}\n`;
226-
console.log(` args: ${argsJson}`);
240+
serverConfig.args = server.args;
241+
console.log(` args: ${JSON.stringify(server.args)}`);
227242
}
228-
229-
// Add environment variables if present
230243
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-
}
244+
serverConfig.env = server.env;
245+
console.log(` env vars: ${Object.keys(server.env).length} variable(s)`);
237246
}
238-
}
239247

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-
: '';
244-
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}`;
248+
managedServerConfigs[serverName] = serverConfig;
249+
}
253250

254251
// Create hash to detect changes (include network access in hash)
255-
const configHash = `${approvalPolicy}:${networkAccess}:${JSON.stringify(stdioServers.map(s => s.mcp_server_id))}`;
252+
const configHashPayload = stdioServers.map(server => ({
253+
id: server.mcp_server_id,
254+
transport: server.transport,
255+
command: server.command,
256+
args: server.args,
257+
env: server.env,
258+
}));
259+
const configHash = `${approvalPolicy}:${networkAccess}:${JSON.stringify(configHashPayload)}`;
256260

257261
// Skip if config hasn't changed (avoid unnecessary file I/O)
258262
if (this.lastMCPServersHash === configHash) {
259263
console.log(`✅ [Codex MCP] Config unchanged, skipping write`);
260264
return stdioServers.length;
261265
}
262266

267+
const configTargets: Array<{ label: 'global' | 'worktree'; path: string }> = [];
268+
263269
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;
270+
if (homeDir) {
271+
configTargets.push({
272+
label: 'global',
273+
path: path.join(homeDir, '.codex', 'config.toml'),
274+
});
275+
} else {
276+
console.warn(
277+
'⚠️ [Codex MCP] Could not determine home directory, skipping global Codex config'
278+
);
267279
}
268280

269-
const codexConfigDir = path.join(homeDir, '.codex');
270-
const configPath = path.join(codexConfigDir, 'config.toml');
281+
if (configTargets.length === 0) {
282+
console.warn('⚠️ [Codex MCP] No writable locations found for Codex config');
283+
return stdioServers.length;
284+
}
271285

272-
console.log(`📁 [Codex MCP] Writing config to: ${configPath}`);
273-
console.log(`📄 [Codex MCP] Config content:\n${configContent}`);
286+
for (const target of configTargets) {
287+
let config: CodexTomlConfig = {};
288+
let previouslyManaged = new Set<string>();
289+
290+
try {
291+
const existing = await fs.readFile(target.path, 'utf-8');
292+
config = parseToml(existing) as CodexTomlConfig;
293+
const managedList = Array.isArray(config.agor_managed_servers)
294+
? config.agor_managed_servers.map((value: unknown) => String(value)).filter(Boolean)
295+
: [];
296+
previouslyManaged = new Set<string>(managedList);
297+
} catch (error) {
298+
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
299+
console.warn(
300+
`⚠️ [Codex MCP] Failed to read existing ${target.label} config (${target.path}):`,
301+
error
302+
);
303+
}
304+
config = {};
305+
previouslyManaged = new Set<string>();
306+
}
274307

275-
await fs.mkdir(codexConfigDir, { recursive: true });
276-
await fs.writeFile(configPath, configContent, 'utf-8');
308+
// Upsert approval policy
309+
config.approval_policy = approvalPolicy;
310+
311+
// Upsert sandbox workspace settings
312+
if (networkAccess) {
313+
const sandbox =
314+
typeof config.sandbox_workspace_write === 'object' &&
315+
config.sandbox_workspace_write !== null
316+
? { ...config.sandbox_workspace_write }
317+
: {};
318+
sandbox.network_access = true;
319+
config.sandbox_workspace_write = sandbox;
320+
} else if (
321+
typeof config.sandbox_workspace_write === 'object' &&
322+
config.sandbox_workspace_write !== null
323+
) {
324+
const sandbox = { ...config.sandbox_workspace_write };
325+
delete sandbox.network_access;
326+
if (Object.keys(sandbox).length === 0) {
327+
delete config.sandbox_workspace_write;
328+
} else {
329+
config.sandbox_workspace_write = sandbox;
330+
}
331+
}
277332

278-
this.lastMCPServersHash = configHash;
279-
console.log(
280-
`✅ [Codex] Updated config.toml with approval_policy = "${approvalPolicy}", network_access = ${networkAccess}`
281-
);
333+
// Prepare MCP servers table
334+
const mcpServersTable: Record<string, CodexTomlServerConfig> =
335+
typeof config.mcp_servers === 'object' && config.mcp_servers !== null
336+
? { ...config.mcp_servers }
337+
: {};
338+
339+
// Remove previously managed entries no longer present
340+
for (const name of previouslyManaged) {
341+
if (!managedServerNames.has(name)) {
342+
delete mcpServersTable[name];
343+
}
344+
}
345+
346+
// Upsert current managed servers
347+
for (const [name, serverConfig] of Object.entries(managedServerConfigs)) {
348+
mcpServersTable[name] = serverConfig;
349+
}
282350

283-
// Reinitialize Codex SDK to pick up the new config
351+
if (Object.keys(mcpServersTable).length > 0) {
352+
config.mcp_servers = mcpServersTable;
353+
} else {
354+
delete config.mcp_servers;
355+
}
356+
357+
if (managedServerNames.size > 0) {
358+
config.agor_managed_servers = Array.from(managedServerNames).sort();
359+
} else {
360+
delete config.agor_managed_servers;
361+
}
362+
363+
const header = `# Codex configuration\n# Generated by Agor - ${new Date().toISOString()}\n\n`;
364+
const body = stringifyToml(config as JsonMap);
365+
366+
try {
367+
await fs.mkdir(path.dirname(target.path), { recursive: true });
368+
await fs.writeFile(target.path, header + body, 'utf-8');
369+
console.log(
370+
`✅ [Codex MCP] Updated ${target.label} config with ${managedServerNames.size} managed MCP server(s)`
371+
);
372+
} catch (error) {
373+
console.error(
374+
`❌ [Codex MCP] Failed to write ${target.label} config (${target.path}):`,
375+
error
376+
);
377+
}
378+
}
379+
380+
this.lastMCPServersHash = configHash;
284381
this.reinitializeCodex();
285382
if (stdioServers.length > 0) {
286383
console.log(
287-
`✅ [Codex MCP] Configured ${stdioServers.length} STDIO MCP server(s): ${stdioServers.map(s => s.name).join(', ')}`
384+
`✅ [Codex MCP] Configured ${stdioServers.length} STDIO MCP server(s): ${stdioServers
385+
.map(s => s.name)
386+
.join(', ')}`
288387
);
289388
}
290389

@@ -423,8 +522,23 @@ ${networkAccessToml}${mcpServersToml}`;
423522
` Using Codex permissions: sandboxMode=${sandboxMode}, approvalPolicy=${approvalPolicy}, networkAccess=${networkAccess}`
424523
);
425524

525+
// Fetch worktree to determine working directory (needed before writing repo-level config)
526+
const worktree = this.worktreesRepo
527+
? await this.worktreesRepo.findById(session.worktree_id)
528+
: null;
529+
if (!worktree) {
530+
throw new Error(`Worktree ${session.worktree_id} not found for session ${sessionId}`);
531+
}
532+
533+
const worktreePath = worktree.path;
534+
426535
// 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);
536+
const mcpServerCount = await this.ensureCodexConfig(
537+
approvalPolicy,
538+
networkAccess,
539+
sessionId,
540+
worktreePath
541+
);
428542

429543
const totalMcpServers = this.sessionMCPServerRepo
430544
? (await this.sessionMCPServerRepo.listServers(sessionId, true)).length
@@ -439,19 +553,11 @@ ${networkAccessToml}${mcpServersToml}`;
439553
);
440554
}
441555

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}`);
556+
console.log(` Working directory: ${worktreePath}`);
451557

452558
// Build thread options with sandbox mode and worktree working directory
453559
const threadOptions = {
454-
workingDirectory: worktree.path,
560+
workingDirectory: worktreePath,
455561
skipGitRepoCheck: false,
456562
sandboxMode,
457563
};

0 commit comments

Comments
 (0)