395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
|
|
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
|
// changes to web search configuration should be reflected in wiki/websearch.md
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { ThreadEvent } from "@openai/codex-sdk";
|
|
import type { Effort } from "../external.ts";
|
|
import { ghPullfrogMcpName } from "../external.ts";
|
|
import { markActivity } from "../utils/activity.ts";
|
|
import { log } from "../utils/cli.ts";
|
|
import { installFromNpmTarball } from "../utils/install.ts";
|
|
import { filterEnv } from "../utils/secrets.ts";
|
|
import { spawn } from "../utils/subprocess.ts";
|
|
import { ThinkingTimer } from "../utils/timer.ts";
|
|
import { type AgentRunContext, agent } from "./shared.ts";
|
|
|
|
// pinned CLI version — no 1-1 package.json dependency for the CLI package
|
|
// (package.json has @openai/codex-sdk which is the SDK, not the CLI)
|
|
const CODEX_CLI_VERSION = "0.101.0";
|
|
|
|
// configuration based on effort level
|
|
// https://developers.openai.com/codex/models/
|
|
type ModelReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
type CodexEffortConfig = { model: string; reasoningEffort?: ModelReasoningEffort };
|
|
|
|
// preferred model for auto/max — falls back to gpt-5.2-codex if API key lacks access
|
|
const PREFERRED_MODEL = "gpt-5.3-codex";
|
|
const FALLBACK_MODEL = "gpt-5.2-codex";
|
|
|
|
function getCodexEffortConfig(model: string): Record<Effort, CodexEffortConfig> {
|
|
return {
|
|
mini: { model: "gpt-5.2-codex", reasoningEffort: "low" },
|
|
auto: { model },
|
|
max: { model, reasoningEffort: "high" },
|
|
};
|
|
}
|
|
|
|
// check if a model is available for the given API key via GET /v1/models
|
|
async function isModelAvailable(ctx: { apiKey: string; model: string }): Promise<boolean> {
|
|
try {
|
|
const response = await fetch("https://api.openai.com/v1/models", {
|
|
headers: { Authorization: `Bearer ${ctx.apiKey}` },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!response.ok) {
|
|
log.warning(
|
|
`failed to list models (HTTP ${response.status}), falling back to ${FALLBACK_MODEL}`
|
|
);
|
|
return false;
|
|
}
|
|
const body = (await response.json()) as { data: Array<{ id: string }> };
|
|
return body.data.some((m) => m.id === ctx.model);
|
|
} catch (err) {
|
|
log.warning(`failed to list models: ${err}, falling back to ${FALLBACK_MODEL}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// resolve the best available model for auto/max effort levels
|
|
async function resolveModel(apiKey: string): Promise<string> {
|
|
const available = await isModelAvailable({ apiKey, model: PREFERRED_MODEL });
|
|
if (available) {
|
|
log.info(`» ${PREFERRED_MODEL} is available for this API key`);
|
|
return PREFERRED_MODEL;
|
|
}
|
|
log.info(`» ${PREFERRED_MODEL} not available, using ${FALLBACK_MODEL}`);
|
|
return FALLBACK_MODEL;
|
|
}
|
|
|
|
function writeCodexConfig(ctx: AgentRunContext): string {
|
|
const codexDir = join(ctx.tmpdir, ".codex");
|
|
mkdirSync(codexDir, { recursive: true });
|
|
const configPath = join(codexDir, "config.toml");
|
|
|
|
// build MCP servers section
|
|
log.info(`» adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`);
|
|
const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}]\nurl = "${ctx.mcpServerUrl}"`];
|
|
|
|
// build features section for tool control
|
|
// disable native shell if bash is "disabled" or "restricted"
|
|
// when "restricted", agent uses MCP bash tool which filters secrets
|
|
const bash = ctx.payload.bash;
|
|
const features: string[] = [];
|
|
if (bash !== "enabled") {
|
|
features.push("shell_tool = false");
|
|
features.push("unified_exec = false");
|
|
}
|
|
// note: there is no Codex feature flag to disable the native apply_patch tool.
|
|
// apply_patch_freeform only controls the freeform variant and defaults to false.
|
|
// native file tools are steered to MCP via instructions, and the sandbox (workspace-write
|
|
// or read-only) constrains what the native tool can access even if the agent ignores instructions.
|
|
const featuresSection = features.length > 0 ? `[features]\n${features.join("\n")}` : "";
|
|
|
|
// trust the project so codex loads repo-level .codex/config.toml
|
|
const cwd = process.cwd();
|
|
const projectTrustSection = `[projects."${cwd}"]\ntrust_level = "trusted"`;
|
|
|
|
// set approval_policy = "never" so we can avoid --dangerously-bypass-approvals-and-sandbox.
|
|
// this keeps sandbox enforcement active while still running non-interactively.
|
|
// the sandbox (workspace-write or read-only) constrains native file tool access.
|
|
const approvalSection = `approval_policy = "never"`;
|
|
|
|
writeFileSync(
|
|
configPath,
|
|
`# written by pullfrog
|
|
${approvalSection}
|
|
|
|
${featuresSection}
|
|
|
|
${projectTrustSection}
|
|
|
|
${mcpServerSections.join("\n\n")}
|
|
`.trim() + "\n"
|
|
);
|
|
|
|
log.info(
|
|
`» Codex config written to ${configPath} (shell: ${bash === "enabled" ? "enabled" : "disabled"}, project trusted: ${cwd})`
|
|
);
|
|
|
|
return codexDir;
|
|
}
|
|
|
|
// cache the installed CLI path so subagents don't re-download
|
|
let cachedCliPath: string | null = null;
|
|
|
|
async function installCodex(): Promise<string> {
|
|
if (cachedCliPath) return cachedCliPath;
|
|
|
|
const cliPath = await installFromNpmTarball({
|
|
packageName: "@openai/codex",
|
|
version: CODEX_CLI_VERSION,
|
|
executablePath: "bin/codex.js",
|
|
installDependencies: true,
|
|
});
|
|
|
|
cachedCliPath = cliPath;
|
|
return cliPath;
|
|
}
|
|
|
|
export const codex = agent({
|
|
name: "codex",
|
|
install: installCodex,
|
|
run: async (ctx) => {
|
|
// validate API key first
|
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
if (!apiKey) {
|
|
throw new Error("OPENAI_API_KEY is required for codex agent");
|
|
}
|
|
|
|
// install CLI and resolve model concurrently
|
|
const [cliPath, model] = await Promise.all([installCodex(), resolveModel(apiKey)]);
|
|
|
|
// write config file (creates ~/.codex/config.toml)
|
|
const codexDir = writeCodexConfig(ctx);
|
|
|
|
// get model and reasoning effort based on effort level
|
|
const effortConfig = getCodexEffortConfig(model)[ctx.payload.effort];
|
|
log.info(`» using model: ${effortConfig.model} (effort: ${ctx.payload.effort})`);
|
|
if (effortConfig.reasoningEffort) {
|
|
log.info(`» using modelReasoningEffort: ${effortConfig.reasoningEffort}`);
|
|
}
|
|
|
|
// determine sandbox mode based on push permission
|
|
// push: "disabled" → read-only sandbox, otherwise workspace-write.
|
|
// we avoid danger-full-access because it completely disables the sandbox,
|
|
// which would let native file tools (apply_patch) write anywhere unrestricted.
|
|
// workspace-write constrains native file access to the working directory.
|
|
const sandboxMode = ctx.payload.push === "disabled" ? "read-only" : "workspace-write";
|
|
|
|
// determine network and search permissions
|
|
// web: "disabled" → no network access, otherwise enabled
|
|
const networkAccessEnabled = ctx.payload.web !== "disabled";
|
|
// search: "disabled" → no web search, otherwise enabled
|
|
const webSearchEnabled = ctx.payload.search !== "disabled";
|
|
|
|
// note: we intentionally do NOT use --dangerously-bypass-approvals-and-sandbox.
|
|
// that flag bypasses both approvals AND the sandbox. instead, we set
|
|
// approval_policy = "never" in config.toml and keep the sandbox active.
|
|
// this ensures native file tools (apply_patch) are constrained by the sandbox
|
|
// even if the agent ignores MCP-only instructions.
|
|
const args: string[] = [
|
|
cliPath,
|
|
"exec",
|
|
ctx.instructions.full,
|
|
"--model",
|
|
effortConfig.model,
|
|
"--sandbox",
|
|
sandboxMode,
|
|
"--json",
|
|
"--config",
|
|
`sandbox_workspace_write.network_access=${networkAccessEnabled}`,
|
|
"--config",
|
|
`features.web_search_request=${webSearchEnabled}`,
|
|
];
|
|
|
|
if (effortConfig.reasoningEffort) {
|
|
args.push("--config", `model_reasoning_effort="${effortConfig.reasoningEffort}"`);
|
|
}
|
|
|
|
log.info(
|
|
`» Codex options: sandboxMode=${sandboxMode}, networkAccess=${networkAccessEnabled}, webSearch=${webSearchEnabled}`
|
|
);
|
|
log.info("» running Codex CLI...");
|
|
|
|
let stdoutBuffer = "";
|
|
let finalOutput = "";
|
|
|
|
// Track command execution IDs to identify when command results come back
|
|
const commandExecutionIds = new Set<string>();
|
|
const thinkingTimer = new ThinkingTimer();
|
|
|
|
// when bash is restricted/disabled, filter sensitive env vars from the codex process.
|
|
// defense-in-depth: codex 0.99.0's shell_command_tool feature flag is unreliable,
|
|
// so native shell commands may still run. filtering the process env ensures secrets
|
|
// (matching *_TOKEN, *_KEY, *_SECRET, etc.) are not accessible even if native shell
|
|
// bypasses the MCP bash tool's filterEnv.
|
|
// API key is explicitly re-added since codex needs it for API calls.
|
|
const baseEnv = ctx.payload.bash === "enabled" ? process.env : filterEnv();
|
|
const env: NodeJS.ProcessEnv = {
|
|
...baseEnv,
|
|
CODEX_HOME: codexDir,
|
|
CODEX_API_KEY: apiKey,
|
|
OPENAI_API_KEY: apiKey,
|
|
};
|
|
|
|
const result = await spawn({
|
|
cmd: "node",
|
|
args,
|
|
cwd: process.cwd(),
|
|
env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
|
onStdout: async (chunk) => {
|
|
finalOutput += chunk;
|
|
markActivity(); // reset activity timeout on any CLI output
|
|
|
|
// buffer incomplete lines across chunks (NDJSON format)
|
|
stdoutBuffer += chunk;
|
|
const lines = stdoutBuffer.split("\n");
|
|
|
|
// keep the last element (may be incomplete) in the buffer
|
|
stdoutBuffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
try {
|
|
const event = JSON.parse(trimmed) as ThreadEvent;
|
|
markActivity(); // reset activity timeout on every event
|
|
log.debug(JSON.stringify(event, null, 2));
|
|
|
|
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
|
|
if (handler) {
|
|
await handler(event as never, commandExecutionIds, thinkingTimer);
|
|
}
|
|
} catch {
|
|
// ignore parse errors - might be non-JSON output
|
|
log.debug(`[codex] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
|
|
}
|
|
}
|
|
},
|
|
onStderr: (chunk) => {
|
|
const trimmed = chunk.trim();
|
|
if (trimmed) {
|
|
log.debug(`[codex stderr] ${trimmed}`);
|
|
log.warning(trimmed);
|
|
finalOutput += trimmed + "\n";
|
|
}
|
|
},
|
|
});
|
|
|
|
if (result.exitCode !== 0) {
|
|
const errorMessage =
|
|
result.stderr || finalOutput || result.stdout || "Unknown error - no output from Codex CLI";
|
|
log.error(`Codex CLI exited with code ${result.exitCode}: ${errorMessage}`);
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
output: finalOutput || result.stdout || "",
|
|
};
|
|
}
|
|
|
|
log.info("» Codex CLI completed successfully");
|
|
|
|
return {
|
|
success: true,
|
|
output: finalOutput || result.stdout || "",
|
|
};
|
|
},
|
|
});
|
|
|
|
type ThreadEventHandler<type extends ThreadEvent["type"]> = (
|
|
event: Extract<ThreadEvent, { type: type }>,
|
|
commandExecutionIds: Set<string>,
|
|
thinkingTimer: ThinkingTimer
|
|
) => void | Promise<void>;
|
|
|
|
const messageHandlers: {
|
|
[type in ThreadEvent["type"]]: ThreadEventHandler<type>;
|
|
} = {
|
|
"thread.started": () => {
|
|
// No logging needed
|
|
},
|
|
"turn.started": () => {
|
|
// No logging needed
|
|
},
|
|
"turn.completed": async (event) => {
|
|
log.table([
|
|
[
|
|
{ data: "Input Tokens", header: true },
|
|
{ data: "Cached Input Tokens", header: true },
|
|
{ data: "Output Tokens", header: true },
|
|
],
|
|
[
|
|
String(event.usage.input_tokens || 0),
|
|
String(event.usage.cached_input_tokens || 0),
|
|
String(event.usage.output_tokens || 0),
|
|
],
|
|
]);
|
|
},
|
|
"turn.failed": (event) => {
|
|
log.error(`Turn failed: ${event.error.message}`);
|
|
},
|
|
"item.started": (event, commandExecutionIds, thinkingTimer) => {
|
|
const item = event.item;
|
|
if (item.type === "command_execution") {
|
|
commandExecutionIds.add(item.id);
|
|
thinkingTimer.markToolCall();
|
|
log.toolCall({
|
|
toolName: item.command,
|
|
input: (item as any).args || {},
|
|
});
|
|
} else if (item.type === "agent_message") {
|
|
// Will be handled on completion
|
|
} else if (item.type === "mcp_tool_call") {
|
|
thinkingTimer.markToolCall();
|
|
log.toolCall({
|
|
toolName: item.tool,
|
|
input: {
|
|
server: item.server,
|
|
...((item as any).arguments || {}),
|
|
},
|
|
});
|
|
}
|
|
// Reasoning items are handled on completion for better readability
|
|
},
|
|
"item.updated": (event) => {
|
|
const item = event.item;
|
|
if (item.type === "command_execution") {
|
|
if (item.status === "in_progress" && item.aggregated_output) {
|
|
// Command is still running, could show progress if needed
|
|
}
|
|
}
|
|
},
|
|
"item.completed": (event, commandExecutionIds, thinkingTimer) => {
|
|
const item = event.item;
|
|
if (item.type === "agent_message") {
|
|
log.box(item.text.trim(), { title: "Codex" });
|
|
} else if (item.type === "command_execution") {
|
|
const isTracked = commandExecutionIds.has(item.id);
|
|
if (isTracked) {
|
|
thinkingTimer.markToolResult();
|
|
log.startGroup(`bash output`);
|
|
if (item.status === "failed" || (item.exit_code !== undefined && item.exit_code !== 0)) {
|
|
log.warning(item.aggregated_output || "Command failed");
|
|
} else {
|
|
log.info(item.aggregated_output || "");
|
|
}
|
|
log.endGroup();
|
|
commandExecutionIds.delete(item.id);
|
|
}
|
|
} else if (item.type === "mcp_tool_call") {
|
|
thinkingTimer.markToolResult();
|
|
if (item.status === "failed" && item.error) {
|
|
log.warning(`MCP tool call failed: ${item.error.message}`);
|
|
} else if ((item as any).output) {
|
|
// log successful MCP tool call output so it appears in captured output
|
|
const output = (item as any).output;
|
|
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
|
|
log.debug(`tool output: ${outputStr}`);
|
|
}
|
|
} else if (item.type === "reasoning") {
|
|
// Display reasoning in a human-readable format
|
|
const reasoningText = item.text.trim();
|
|
// Remove markdown bold markers if present for cleaner output
|
|
const cleanText = reasoningText.replace(/\*\*/g, "");
|
|
log.box(cleanText, { title: "Codex" });
|
|
}
|
|
},
|
|
error: (event) => {
|
|
log.error(`Error: ${event.message}`);
|
|
},
|
|
};
|