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
47 changes: 45 additions & 2 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,51 @@ RUN curl -fsSL "$(curl -s https://api.github.com/repos/cli/cli/releases/latest |
mv /tmp/gh_*/bin/gh /usr/local/bin/ && \
rm -rf /tmp/gh_*

# Install pnpm and AI coding agent CLIs (always get latest versions)
RUN npm install -g pnpm@latest @anthropic-ai/claude-code@latest @google/gemini-cli@latest @openai/codex@latest
# Install pnpm, AI coding agent CLIs, and MCP servers (always get latest versions)
RUN npm install -g \
pnpm@latest \
@anthropic-ai/claude-code@latest \
@google/gemini-cli@latest \
@openai/codex@latest \
@playwright/mcp@latest

# Install Playwright browsers for @playwright/mcp
# Note: Installing system dependencies first for Playwright browsers
RUN apt-get update && apt-get install -y \
# Dependencies for Chromium
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
# Dependencies for WebKit
libwoff1 \
libopus0 \
libwebpdemux2 \
libenchant-2-2 \
libsecret-1-0 \
libhyphen0 \
libgdk-pixbuf2.0-0 \
libegl1 \
libnotify4 \
libxslt1.1 \
libevent-2.1-7 \
libgles2 \
libvpx9 \
# Clean up
&& rm -rf /var/lib/apt/lists/*

# Install Playwright browsers (run as root before switching to agor user)
RUN npx playwright install chromium firefox webkit

# Create non-root 'agor' user for development
RUN useradd -m -s /bin/bash agor && \
Expand Down
20 changes: 20 additions & 0 deletions context/concepts/mcp-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ MCP (Model Context Protocol) servers extend agent capabilities by connecting to
}
```

**STDIO bridge for HTTP MCP servers:**

Use Agor's built-in bridge when an MCP client only speaks stdio but the target server is HTTP-only (e.g., Codex → Agor MCP).

```typescript
{
transport: 'stdio',
command: 'node',
args: [
'./scripts/agor-mcp-stdio-bridge.js',
'--session-token',
'<SESSION_TOKEN>',
'--url',
'http://localhost:3030/mcp'
]
}
```

The bridge forwards JSON-RPC requests between stdio and the HTTP endpoint (`sessionToken` may also be provided via `AGOR_MCP_SESSION_TOKEN`).

**HTTP (Remote Server):**

```typescript
Expand Down
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
"@feathersjs/typebox": "^5.0.37",
"@google/gemini-cli-core": "^0.9.0",
"@google/genai": "^1.26.0",
"@iarna/toml": "^3.0.0",
"@libsql/client": "^0.15.15",
"@openai/codex-sdk": "^0.47.0",
"@opencode-ai/sdk": "^1.0.30",
Expand All @@ -176,6 +177,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/js-yaml": "^4.0.9",
"@types/iarna__toml": "^2.0.2",
"@types/node": "^22.10.2",
"@vitest/coverage-v8": "^4.0.3",
"drizzle-kit": "^0.31.5",
Expand Down
211 changes: 153 additions & 58 deletions packages/core/src/tools/codex/prompt-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { type JsonMap, parse as parseToml, stringify as stringifyToml } from '@iarna/toml';
import { Codex, type Thread, type ThreadItem } from '@openai/codex-sdk';
import { getCredential, resolveApiKey, resolveUserEnvironment } from '../../config';
import type { Database } from '../../db/client';
Expand All @@ -24,6 +25,16 @@ import type { TokenUsage } from '../../utils/pricing';
import { DEFAULT_CODEX_MODEL } from './models';
import { extractCodexTokenUsage } from './usage';

type CodexTomlServerConfig = Record<string, unknown>;

interface CodexTomlConfig {
approval_policy?: string;
sandbox_workspace_write?: Record<string, unknown>;
mcp_servers?: Record<string, CodexTomlServerConfig>;
agor_managed_servers?: string[];
[key: string]: unknown;
}

export interface CodexPromptResult {
/** Complete assistant response from Codex */
messages: Array<{
Expand Down Expand Up @@ -165,7 +176,7 @@ export class CodexPromptService {
}

/**
* Generate ~/.codex/config.toml with approval_policy, network_access, and MCP servers
* Generate Codex configuration files with approval_policy, network_access, and MCP servers
*
* NOTE: approval_policy, network_access, and MCP servers must be configured via config.toml
* (not available in ThreadOptions). We minimize file writes by tracking a hash
Expand All @@ -174,12 +185,14 @@ export class CodexPromptService {
* @param approvalPolicy - Codex approval policy (untrusted, on-request, on-failure, never)
* @param networkAccess - Whether to allow outbound network access in workspace-write mode
* @param sessionId - Session ID for fetching MCP servers
* @param worktreePath - Optional worktree path for repo-local .codex/config.toml
* @returns Number of MCP servers configured
*/
private async ensureCodexConfig(
approvalPolicy: 'untrusted' | 'on-request' | 'on-failure' | 'never',
networkAccess: boolean,
sessionId: SessionID
sessionId: SessionID,
worktreePath?: string
): Promise<number> {
// Fetch MCP servers for this session (if repository is available)
console.log(`🔍 [Codex MCP] Fetching MCP servers for session ${sessionId.substring(0, 8)}...`);
Expand Down Expand Up @@ -208,83 +221,158 @@ export class CodexPromptService {
}
}

// Generate MCP servers TOML blocks (stdio only)
let mcpServersToml = '';
// Build normalized server configs for cautious upsert
const managedServerConfigs: Record<string, Record<string, unknown>> = {};
const managedServerNames = new Set<string>();
for (const server of stdioServers) {
// Normalize server name to lowercase for TOML convention
const serverName = server.name.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
managedServerNames.add(serverName);
console.log(` 📝 [Codex MCP] Configuring server: ${server.name} -> ${serverName}`);

mcpServersToml += `\n[mcp_servers.${serverName}]\n`;
// Build server config (NOTE: don't include 'transport' field - it's not valid in Codex config)
// Codex uses STDIO by default when 'command' is specified, HTTP when 'url' is specified
const serverConfig: Record<string, unknown> = {};

if (server.command) {
mcpServersToml += `command = "${server.command}"\n`;
serverConfig.command = server.command;
console.log(` command: ${server.command}`);
}
if (server.args && server.args.length > 0) {
const argsJson = JSON.stringify(server.args);
mcpServersToml += `args = ${argsJson}\n`;
console.log(` args: ${argsJson}`);
serverConfig.args = server.args;
console.log(` args: ${JSON.stringify(server.args)}`);
}

// Add environment variables if present
if (server.env && Object.keys(server.env).length > 0) {
mcpServersToml += `\n[mcp_servers.${serverName}.env]\n`;
const envCount = Object.keys(server.env).length;
console.log(` env vars: ${envCount} variable(s)`);
for (const [key, value] of Object.entries(server.env)) {
mcpServersToml += `${key} = "${value}"\n`;
}
serverConfig.env = server.env;
console.log(` env vars: ${Object.keys(server.env).length} variable(s)`);
}
}

// Generate network access TOML section (only for workspace-write sandbox)
const networkAccessToml = networkAccess
? `\n[sandbox_workspace_write]\nnetwork_access = true\n`
: '';

// Generate complete config content
const configContent = `# Codex configuration
# Generated by Agor - ${new Date().toISOString()}

# Approval policy controls when Codex asks before running commands
# Options: "untrusted", "on-request", "on-failure", "never"
approval_policy = "${approvalPolicy}"
${networkAccessToml}${mcpServersToml}`;
managedServerConfigs[serverName] = serverConfig;
}

// Create hash to detect changes (include network access in hash)
const configHash = `${approvalPolicy}:${networkAccess}:${JSON.stringify(stdioServers.map(s => s.mcp_server_id))}`;
const configHashPayload = stdioServers.map(server => ({
id: server.mcp_server_id,
transport: server.transport,
command: server.command,
args: server.args,
env: server.env,
}));
const configHash = `${approvalPolicy}:${networkAccess}:${JSON.stringify(configHashPayload)}`;

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

// Write to global ~/.codex/config.toml only
// NOTE: Codex does NOT support repo-local .codex/config.toml (verified from docs)
const homeDir = process.env.HOME || process.env.USERPROFILE;
if (!homeDir) {
console.warn('⚠️ [Codex MCP] Could not determine home directory, skipping Codex config');
return 0;
console.warn('⚠️ [Codex MCP] Could not determine home directory, cannot write Codex config');
return stdioServers.length;
}

const codexConfigDir = path.join(homeDir, '.codex');
const configPath = path.join(codexConfigDir, 'config.toml');
const configPath = path.join(homeDir, '.codex', 'config.toml');
console.log(`📝 [Codex MCP] Writing config to: ${configPath}`);

console.log(`📁 [Codex MCP] Writing config to: ${configPath}`);
console.log(`📄 [Codex MCP] Config content:\n${configContent}`);
let config: CodexTomlConfig = {};
let previouslyManaged = new Set<string>();

await fs.mkdir(codexConfigDir, { recursive: true });
await fs.writeFile(configPath, configContent, 'utf-8');
try {
const existing = await fs.readFile(configPath, 'utf-8');
config = parseToml(existing) as CodexTomlConfig;
const managedList = Array.isArray(config.agor_managed_servers)
? config.agor_managed_servers.map((value: unknown) => String(value)).filter(Boolean)
: [];
previouslyManaged = new Set<string>(managedList);
console.log(` Found existing config with ${previouslyManaged.size} Agor-managed server(s)`);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.warn(`⚠️ [Codex MCP] Failed to read existing config (${configPath}):`, error);
}
config = {};
previouslyManaged = new Set<string>();
console.log(` No existing config found, creating new`);
}

this.lastMCPServersHash = configHash;
console.log(
`✅ [Codex] Updated config.toml with approval_policy = "${approvalPolicy}", network_access = ${networkAccess}`
);
// Upsert approval policy
config.approval_policy = approvalPolicy;

// Upsert sandbox workspace settings
if (networkAccess) {
const sandbox =
typeof config.sandbox_workspace_write === 'object' &&
config.sandbox_workspace_write !== null
? { ...config.sandbox_workspace_write }
: {};
sandbox.network_access = true;
config.sandbox_workspace_write = sandbox;
} else if (
typeof config.sandbox_workspace_write === 'object' &&
config.sandbox_workspace_write !== null
) {
const sandbox = { ...config.sandbox_workspace_write };
delete sandbox.network_access;
if (Object.keys(sandbox).length === 0) {
delete config.sandbox_workspace_write;
} else {
config.sandbox_workspace_write = sandbox;
}
}

// Prepare MCP servers table
const mcpServersTable: Record<string, CodexTomlServerConfig> =
typeof config.mcp_servers === 'object' && config.mcp_servers !== null
? { ...config.mcp_servers }
: {};

// Remove previously managed entries no longer present
for (const name of previouslyManaged) {
if (!managedServerNames.has(name)) {
console.log(` 🗑️ Removing previously managed server: ${name}`);
delete mcpServersTable[name];
}
}

// Upsert current managed servers
for (const [name, serverConfig] of Object.entries(managedServerConfigs)) {
mcpServersTable[name] = serverConfig;
}

if (Object.keys(mcpServersTable).length > 0) {
config.mcp_servers = mcpServersTable;
} else {
delete config.mcp_servers;
}

// Reinitialize Codex SDK to pick up the new config
if (managedServerNames.size > 0) {
config.agor_managed_servers = Array.from(managedServerNames).sort();
} else {
delete config.agor_managed_servers;
}

const header = `# Codex configuration\n# Generated by Agor - ${new Date().toISOString()}\n\n`;
const body = stringifyToml(config as JsonMap);

try {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, header + body, 'utf-8');
console.log(
`✅ [Codex MCP] Updated global config with ${managedServerNames.size} managed MCP server(s)`
);
console.log(` Config written to: ${configPath}`);
} catch (error) {
console.error(`❌ [Codex MCP] Failed to write config (${configPath}):`, error);
}

this.lastMCPServersHash = configHash;
this.reinitializeCodex();
if (stdioServers.length > 0) {
console.log(
`✅ [Codex MCP] Configured ${stdioServers.length} STDIO MCP server(s): ${stdioServers.map(s => s.name).join(', ')}`
`✅ [Codex MCP] Configured ${stdioServers.length} STDIO MCP server(s): ${stdioServers
.map(s => s.name)
.join(', ')}`
);
}

Expand Down Expand Up @@ -423,8 +511,23 @@ ${networkAccessToml}${mcpServersToml}`;
` Using Codex permissions: sandboxMode=${sandboxMode}, approvalPolicy=${approvalPolicy}, networkAccess=${networkAccess}`
);

// Fetch worktree to determine working directory (needed before writing repo-level config)
const worktree = this.worktreesRepo
? await this.worktreesRepo.findById(session.worktree_id)
: null;
if (!worktree) {
throw new Error(`Worktree ${session.worktree_id} not found for session ${sessionId}`);
}

const worktreePath = worktree.path;

// Set approval_policy, network_access, and MCP servers in config.toml (required because they're not available in ThreadOptions)
const mcpServerCount = await this.ensureCodexConfig(approvalPolicy, networkAccess, sessionId);
const mcpServerCount = await this.ensureCodexConfig(
approvalPolicy,
networkAccess,
sessionId,
worktreePath
);

const totalMcpServers = this.sessionMCPServerRepo
? (await this.sessionMCPServerRepo.listServers(sessionId, true)).length
Expand All @@ -439,19 +542,11 @@ ${networkAccessToml}${mcpServersToml}`;
);
}

// Fetch worktree to get working directory
const worktree = this.worktreesRepo
? await this.worktreesRepo.findById(session.worktree_id)
: null;
if (!worktree) {
throw new Error(`Worktree ${session.worktree_id} not found for session ${sessionId}`);
}

console.log(` Working directory: ${worktree.path}`);
console.log(` Working directory: ${worktreePath}`);

// Build thread options with sandbox mode and worktree working directory
const threadOptions = {
workingDirectory: worktree.path,
workingDirectory: worktreePath,
skipGitRepoCheck: false,
sandboxMode,
};
Expand Down
Loading