70f1c47a28
* Stop using command-based logs for warnings and errors Co-authored-by: Cursor <cursoragent@cursor.com> * revert * tweak * de-noise * Remove redundant ts() timestamp prefix from log calls * Restore timestamped logging and refine debug output routing. Bring back timestamp prefixes for standard logs and make log.debug emit via core.debug when runner debug is enabled, while still surfacing debug lines for --debug runs. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
393 lines
14 KiB
TypeScript
393 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.info(
|
|
`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.info(`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(
|
|
`» model: ${effortConfig.model}${effortConfig.reasoningEffort ? ` (reasoningEffort: ${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.info(`[codex stderr] ${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.info(`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.info(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.info(`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.info(`Error: ${event.message}`);
|
|
},
|
|
};
|