@sweny-ai/core 0.1.142 → 0.1.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-env.d.ts +24 -0
- package/dist/agent-env.js +40 -7
- package/dist/cli/config-file.d.ts +1 -1
- package/dist/cli/config-file.js +1 -1
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +15 -7
- package/dist/cli/main.js +29 -4
- package/dist/cli/run-history.d.ts +2 -0
- package/dist/cli/run-history.js +2 -0
- package/dist/cli/run-output.d.ts +13 -2
- package/dist/cli/run-output.js +23 -2
- package/dist/executor.js +20 -2
- package/dist/harness/abort.d.ts +17 -0
- package/dist/harness/abort.js +53 -0
- package/dist/harness/agents.d.ts +11 -0
- package/dist/harness/agents.js +15 -0
- package/dist/harness/capabilities.d.ts +23 -0
- package/dist/harness/capabilities.js +35 -0
- package/dist/harness/claude-code.d.ts +9 -40
- package/dist/harness/claude-code.js +21 -308
- package/dist/harness/codex.d.ts +175 -0
- package/dist/harness/codex.js +917 -0
- package/dist/harness/index.d.ts +11 -3
- package/dist/harness/index.js +8 -8
- package/dist/harness/parse.d.ts +49 -0
- package/dist/harness/parse.js +253 -0
- package/dist/harness/policy.d.ts +23 -1
- package/dist/harness/policy.js +73 -15
- package/dist/harness/prompts.d.ts +7 -1
- package/dist/harness/prompts.js +16 -0
- package/dist/harness/types.d.ts +13 -2
- package/dist/harness/types.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/schema.d.ts +12 -11
- package/dist/schema.js +7 -3
- package/dist/types.d.ts +8 -1
- package/package.json +1 -1
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex harness (#331): runs a node on OpenAI Codex through `codex exec --json`.
|
|
3
|
+
*
|
|
4
|
+
* Interfaces used, all read from openai/codex at rust-v0.159.2:
|
|
5
|
+
* - `codex-rs/exec/src/cli.rs`: `exec --json`, `--output-schema <file>`,
|
|
6
|
+
* `--ignore-user-config`, `--ignore-rules`, `--ephemeral`,
|
|
7
|
+
* `--skip-git-repo-check`, `--sandbox`, `--cd`, `--model`, `-c key=value`,
|
|
8
|
+
* prompt on stdin with `-`.
|
|
9
|
+
* - `codex-rs/exec/src/exec_events.rs`: the JSONL events (`thread.started`,
|
|
10
|
+
* `turn.started|completed|failed`, `item.started|updated|completed`,
|
|
11
|
+
* `error`) and item kinds.
|
|
12
|
+
* - `sdk/typescript/src/exec.ts`: how the official SDK spawns the same CLI
|
|
13
|
+
* (config overrides as TOML values, stdin prompt, exit handling).
|
|
14
|
+
* - `codex-rs/core/config.schema.json`: `features.*`, `web_search`,
|
|
15
|
+
* `agents.enabled`, `sandbox_workspace_write.network_access`,
|
|
16
|
+
* `mcp_servers.<id>.{command,args,env,env_vars,url,http_headers,...}`.
|
|
17
|
+
* - `codex-rs/rmcp-client/src/utils.rs`: an MCP stdio server gets only a
|
|
18
|
+
* small default env plus the names listed in `env_vars`, so the tool
|
|
19
|
+
* bridge token travels by name, never in argv.
|
|
20
|
+
*
|
|
21
|
+
* Every sweny opinion in the Codex column of the enforcement matrix is
|
|
22
|
+
* enforced natively here, kept by sweny (watchdog, fencing, env scoping,
|
|
23
|
+
* output validation), reported in `degraded`, or refused under strict policy
|
|
24
|
+
* by `policyGate`. Nothing is dropped silently.
|
|
25
|
+
*/
|
|
26
|
+
import { spawn, execFile } from "node:child_process";
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { consoleLogger } from "../types.js";
|
|
31
|
+
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, } from "../agent-env.js";
|
|
32
|
+
import { CODEX_CAPABILITIES } from "./capabilities.js";
|
|
33
|
+
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
34
|
+
import { buildNodePrompt } from "./prompts.js";
|
|
35
|
+
import { makeAbort } from "./abort.js";
|
|
36
|
+
import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
|
|
37
|
+
import { startToolBridge } from "./tool-bridge/server.js";
|
|
38
|
+
import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandbox-wrapper.js";
|
|
39
|
+
import { TOKEN_ENV } from "./tool-bridge/protocol.js";
|
|
40
|
+
export { CODEX_CAPABILITIES };
|
|
41
|
+
/** Oldest Codex CLI whose flags this adapter uses (`--ignore-user-config`, `--ignore-rules`). */
|
|
42
|
+
export const MIN_CODEX_VERSION = "0.159.0";
|
|
43
|
+
/** Same system prompt Claude Code nodes get, sent as Codex developer instructions. */
|
|
44
|
+
const SYSTEM_PROMPT = `You are a step in an automated workflow. Execute the instruction precisely using the tools available to you. Be thorough but concise. When you're done, summarize your findings and results.`;
|
|
45
|
+
/**
|
|
46
|
+
* Codex features sweny always turns off, so a node sees only the tools sweny
|
|
47
|
+
* gave it: ChatGPT apps and plugins (their own MCP servers), tool suggestions,
|
|
48
|
+
* browser and computer use, lifecycle hooks, and image generation. Keys from
|
|
49
|
+
* `codex-rs/features/src/lib.rs` at rust-v0.159.2.
|
|
50
|
+
*/
|
|
51
|
+
export const CODEX_ISOLATION_FEATURES_OFF = [
|
|
52
|
+
"apps",
|
|
53
|
+
"plugins",
|
|
54
|
+
"tool_suggest",
|
|
55
|
+
"browser_use",
|
|
56
|
+
"computer_use",
|
|
57
|
+
"hooks",
|
|
58
|
+
"image_generation",
|
|
59
|
+
];
|
|
60
|
+
/**
|
|
61
|
+
* Built-in tool names a node's `disallowed_tools` may use, per portable class.
|
|
62
|
+
* Claude Code names are included because bundled workflows (triage.yml) were
|
|
63
|
+
* written for it; Codex names come from its tool registry.
|
|
64
|
+
*/
|
|
65
|
+
const DENY_NAME_TO_CLASSES = {
|
|
66
|
+
// Claude Code built-ins
|
|
67
|
+
Bash: ["shell"],
|
|
68
|
+
Write: ["write"],
|
|
69
|
+
NotebookEdit: ["write"],
|
|
70
|
+
Edit: ["edit"],
|
|
71
|
+
MultiEdit: ["edit"],
|
|
72
|
+
WebFetch: ["net"],
|
|
73
|
+
WebSearch: ["net"],
|
|
74
|
+
Task: ["subagent"],
|
|
75
|
+
Agent: ["subagent"],
|
|
76
|
+
// Codex built-ins
|
|
77
|
+
shell: ["shell"],
|
|
78
|
+
exec_command: ["shell"],
|
|
79
|
+
local_shell: ["shell"],
|
|
80
|
+
apply_patch: ["write", "edit"],
|
|
81
|
+
web_search: ["net"],
|
|
82
|
+
spawn_agent: ["subagent"],
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Hosts Codex itself needs (its model API and ChatGPT sign-in), always allowed
|
|
86
|
+
* through the sandbox wrapper, plus the host of `OPENAI_BASE_URL` when set.
|
|
87
|
+
*/
|
|
88
|
+
export function codexBackendHosts(env = process.env) {
|
|
89
|
+
const hosts = ["api.openai.com", "chatgpt.com", "auth.openai.com"];
|
|
90
|
+
if (env.OPENAI_BASE_URL) {
|
|
91
|
+
try {
|
|
92
|
+
hosts.push(new URL(env.OPENAI_BASE_URL).host);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// not a URL: nothing to add
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return hosts;
|
|
99
|
+
}
|
|
100
|
+
/** Codex item kinds that are tool calls; the turn watchdog counts these. */
|
|
101
|
+
const TOOL_ITEM_KINDS = new Set([
|
|
102
|
+
"command_execution",
|
|
103
|
+
"file_change",
|
|
104
|
+
"mcp_tool_call",
|
|
105
|
+
"web_search",
|
|
106
|
+
"collab_tool_call",
|
|
107
|
+
]);
|
|
108
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
109
|
+
/** A TOML value for `-c key=value`, as the official SDK writes it (sdk/typescript/src/exec.ts). */
|
|
110
|
+
export function toTomlValue(value) {
|
|
111
|
+
if (typeof value === "string")
|
|
112
|
+
return JSON.stringify(value);
|
|
113
|
+
if (typeof value === "number") {
|
|
114
|
+
if (!Number.isFinite(value))
|
|
115
|
+
throw new Error("codex config: numbers must be finite");
|
|
116
|
+
return String(value);
|
|
117
|
+
}
|
|
118
|
+
if (typeof value === "boolean")
|
|
119
|
+
return value ? "true" : "false";
|
|
120
|
+
if (Array.isArray(value))
|
|
121
|
+
return `[${value.map(toTomlValue).join(", ")}]`;
|
|
122
|
+
if (value && typeof value === "object") {
|
|
123
|
+
const parts = Object.entries(value)
|
|
124
|
+
.filter(([, v]) => v !== undefined)
|
|
125
|
+
.map(([k, v]) => `${/^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k)} = ${toTomlValue(v)}`);
|
|
126
|
+
return `{${parts.join(", ")}}`;
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`codex config: unsupported value ${String(value)}`);
|
|
129
|
+
}
|
|
130
|
+
/** A dotted config key segment, quoted when it is not a bare TOML key. */
|
|
131
|
+
function keySegment(k) {
|
|
132
|
+
return /^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k);
|
|
133
|
+
}
|
|
134
|
+
function cfg(key, value) {
|
|
135
|
+
return ["-c", `${key}=${toTomlValue(value)}`];
|
|
136
|
+
}
|
|
137
|
+
function compareVersions(a, b) {
|
|
138
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
139
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
140
|
+
for (let i = 0; i < 3; i++) {
|
|
141
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
142
|
+
return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
143
|
+
}
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Split `disallowed_tools` names into portable classes Codex can act on and
|
|
148
|
+
* the names it has no tool for.
|
|
149
|
+
*/
|
|
150
|
+
export function translateDenyNames(names = []) {
|
|
151
|
+
const classes = [];
|
|
152
|
+
const unknown = [];
|
|
153
|
+
for (const n of names) {
|
|
154
|
+
const mapped = DENY_NAME_TO_CLASSES[n];
|
|
155
|
+
if (mapped)
|
|
156
|
+
classes.push(...mapped);
|
|
157
|
+
else
|
|
158
|
+
unknown.push(n);
|
|
159
|
+
}
|
|
160
|
+
return { classes: [...new Set(classes)], unknown };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Is this JSON schema accepted by OpenAI strict structured outputs? Codex
|
|
164
|
+
* sends `--output-schema` with `strict: true` (core/src/session/turn.rs), which
|
|
165
|
+
* requires `additionalProperties: false` and every property listed in
|
|
166
|
+
* `required` on every object. Not a full check; it catches the common misses.
|
|
167
|
+
*/
|
|
168
|
+
export function isStrictCompatibleSchema(schema) {
|
|
169
|
+
const visit = (s) => {
|
|
170
|
+
if (!s || typeof s !== "object")
|
|
171
|
+
return true;
|
|
172
|
+
const o = s;
|
|
173
|
+
const props = o.properties;
|
|
174
|
+
if (o.type === "object" || props) {
|
|
175
|
+
if (o.additionalProperties !== false)
|
|
176
|
+
return false;
|
|
177
|
+
const req = Array.isArray(o.required) ? o.required : [];
|
|
178
|
+
for (const k of Object.keys(props ?? {}))
|
|
179
|
+
if (!req.includes(k))
|
|
180
|
+
return false;
|
|
181
|
+
for (const v of Object.values(props ?? {}))
|
|
182
|
+
if (!visit(v))
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
if (o.items && !visit(o.items))
|
|
186
|
+
return false;
|
|
187
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
188
|
+
const list = o[key];
|
|
189
|
+
if (Array.isArray(list) && !list.every(visit))
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
for (const key of ["$defs", "definitions"]) {
|
|
193
|
+
const defs = o[key];
|
|
194
|
+
if (defs && !Object.values(defs).every(visit))
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
};
|
|
199
|
+
return visit(schema);
|
|
200
|
+
}
|
|
201
|
+
// ─── Process registry (crash cleanup) ────────────────────────────
|
|
202
|
+
const liveChildren = new Set();
|
|
203
|
+
let exitHookInstalled = false;
|
|
204
|
+
/**
|
|
205
|
+
* Stop Codex. It stays in sweny's process group (not detached), so a Ctrl-C
|
|
206
|
+
* or a CI cancel reaches it too; its MCP servers exit when their stdin closes.
|
|
207
|
+
*/
|
|
208
|
+
function stopChild(child, signal) {
|
|
209
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
|
|
210
|
+
return;
|
|
211
|
+
try {
|
|
212
|
+
child.kill(signal);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// already gone
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function trackChild(child) {
|
|
219
|
+
liveChildren.add(child);
|
|
220
|
+
if (!exitHookInstalled) {
|
|
221
|
+
exitHookInstalled = true;
|
|
222
|
+
// A crashing sweny must not leave Codex (and its MCP servers) running.
|
|
223
|
+
process.on("exit", () => {
|
|
224
|
+
for (const c of liveChildren)
|
|
225
|
+
stopChild(c, "SIGKILL");
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function mapUsage(u) {
|
|
230
|
+
if (!u || typeof u !== "object")
|
|
231
|
+
return undefined;
|
|
232
|
+
const m = u;
|
|
233
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
234
|
+
const usage = {
|
|
235
|
+
inputTokens: num(m.input_tokens),
|
|
236
|
+
outputTokens: num(m.output_tokens),
|
|
237
|
+
cacheReadTokens: num(m.cached_input_tokens),
|
|
238
|
+
cacheCreationTokens: num(m.cache_write_input_tokens),
|
|
239
|
+
};
|
|
240
|
+
const present = Object.fromEntries(Object.entries(usage).filter(([, v]) => v !== undefined));
|
|
241
|
+
return Object.keys(present).length > 0 ? present : undefined;
|
|
242
|
+
}
|
|
243
|
+
// ─── Adapter ─────────────────────────────────────────────────────
|
|
244
|
+
export class CodexHarness {
|
|
245
|
+
id = "codex";
|
|
246
|
+
capabilities = CODEX_CAPABILITIES;
|
|
247
|
+
/**
|
|
248
|
+
* Empty on purpose: judges use Codex's own default model. Leaving this
|
|
249
|
+
* undefined would fall back to the Claude judge model, which Codex cannot run.
|
|
250
|
+
*/
|
|
251
|
+
defaultJudgeModel = "";
|
|
252
|
+
logger;
|
|
253
|
+
model;
|
|
254
|
+
maxTurns;
|
|
255
|
+
cwd;
|
|
256
|
+
defaultContext;
|
|
257
|
+
mcpServers;
|
|
258
|
+
defaultMcpServers;
|
|
259
|
+
envPassthrough;
|
|
260
|
+
envScope;
|
|
261
|
+
sandboxMode;
|
|
262
|
+
sandboxProbe;
|
|
263
|
+
sandboxWarned = false;
|
|
264
|
+
sandboxWrapper;
|
|
265
|
+
loginWarned = false;
|
|
266
|
+
policyMode;
|
|
267
|
+
codexCommand;
|
|
268
|
+
toolBridgeShim;
|
|
269
|
+
killGraceMs;
|
|
270
|
+
preflightResult;
|
|
271
|
+
version = "unknown";
|
|
272
|
+
constructor(opts = {}) {
|
|
273
|
+
this.model = opts.model;
|
|
274
|
+
this.maxTurns = opts.maxTurns ?? 20;
|
|
275
|
+
this.cwd = opts.cwd ?? process.cwd();
|
|
276
|
+
this.logger = opts.logger ?? consoleLogger;
|
|
277
|
+
this.defaultContext = opts.defaultContext ?? { config: {}, logger: this.logger };
|
|
278
|
+
this.mcpServers = opts.mcpServers ?? {};
|
|
279
|
+
this.defaultMcpServers = opts.defaultMcpServers ?? {};
|
|
280
|
+
this.envPassthrough = opts.envPassthrough;
|
|
281
|
+
this.envScope = opts.envScope;
|
|
282
|
+
this.sandboxMode = opts.sandbox;
|
|
283
|
+
this.sandboxProbe = opts.sandboxProbe;
|
|
284
|
+
this.sandboxWrapper = opts.sandboxWrapper;
|
|
285
|
+
this.policyMode = opts.policy ?? resolveHarnessPolicy(process.env, undefined, this.logger);
|
|
286
|
+
this.codexCommand = opts.codexCommand ?? { command: process.env.SWENY_CODEX_PATH || "codex", args: [] };
|
|
287
|
+
this.toolBridgeShim = opts.toolBridgeShim;
|
|
288
|
+
this.killGraceMs = opts.killGraceMs ?? 2000;
|
|
289
|
+
}
|
|
290
|
+
info() {
|
|
291
|
+
return { id: this.id, version: this.version };
|
|
292
|
+
}
|
|
293
|
+
/** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
|
|
294
|
+
preflight() {
|
|
295
|
+
this.preflightResult ??= new Promise((resolve) => {
|
|
296
|
+
const { command, args } = this.codexCommand;
|
|
297
|
+
execFile(command, [...args, "--version"], { timeout: 30_000 }, (err, stdout) => {
|
|
298
|
+
if (err) {
|
|
299
|
+
const code = err.code;
|
|
300
|
+
resolve({
|
|
301
|
+
ok: false,
|
|
302
|
+
reason: code === "ENOENT"
|
|
303
|
+
? `codex CLI not found ("${command}"). Install it: npm install -g @openai/codex (>= ${MIN_CODEX_VERSION})`
|
|
304
|
+
: `codex --version failed: ${err.message}`,
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const m = /(\d+\.\d+\.\d+)/.exec(String(stdout));
|
|
309
|
+
if (!m) {
|
|
310
|
+
resolve({ ok: false, reason: `could not read the codex version from "${String(stdout).trim()}"` });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (compareVersions(m[1], MIN_CODEX_VERSION) < 0) {
|
|
314
|
+
resolve({ ok: false, reason: `codex ${m[1]} is too old; sweny needs >= ${MIN_CODEX_VERSION}` });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.version = m[1];
|
|
318
|
+
resolve({ ok: true, version: m[1] });
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
return this.preflightResult;
|
|
322
|
+
}
|
|
323
|
+
/** Env for the Codex process: scoped like Claude Code's, with Codex's auth vars instead of Anthropic's. */
|
|
324
|
+
buildEnv(extraVars = []) {
|
|
325
|
+
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
326
|
+
const authed = resolveCodexAuthEnv(full);
|
|
327
|
+
if (!resolveEnvScope(process.env, this.envScope, this.logger))
|
|
328
|
+
return authed;
|
|
329
|
+
const { env, withheld } = scopeAgentEnv(authed, {
|
|
330
|
+
extraVars,
|
|
331
|
+
passthrough: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
|
|
332
|
+
authVars: CODEX_AUTH_VARS,
|
|
333
|
+
prefixes: CODEX_ENV_PREFIXES,
|
|
334
|
+
logger: this.logger,
|
|
335
|
+
});
|
|
336
|
+
reportWithheldEnv(withheld, this.logger);
|
|
337
|
+
return env;
|
|
338
|
+
}
|
|
339
|
+
/** The node policy after `disallowed_tools` names are translated into Codex classes. */
|
|
340
|
+
compilePolicy(req, maxTurns) {
|
|
341
|
+
const base = req.policy ?? {
|
|
342
|
+
readOnly: !!req.readOnly,
|
|
343
|
+
deny: req.deny ?? [],
|
|
344
|
+
nativeDeny: req.disallowedTools,
|
|
345
|
+
egress: req.agentAccess?.domains ?? [],
|
|
346
|
+
strict: this.policyMode === "strict",
|
|
347
|
+
};
|
|
348
|
+
const { classes, unknown } = translateDenyNames(base.nativeDeny);
|
|
349
|
+
return {
|
|
350
|
+
...base,
|
|
351
|
+
deny: [...new Set([...base.deny, ...classes])],
|
|
352
|
+
nativeDeny: unknown,
|
|
353
|
+
maxTurns: base.maxTurns ?? maxTurns,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** Resolve the Codex `--sandbox` value for a normal (not read-only) run. */
|
|
357
|
+
resolveSandbox(mode) {
|
|
358
|
+
if (mode === "off")
|
|
359
|
+
return { mode: "danger-full-access" };
|
|
360
|
+
const reason = (this.sandboxProbe ?? (() => checkSandboxSupport()))();
|
|
361
|
+
if (!reason)
|
|
362
|
+
return { mode: "workspace-write" };
|
|
363
|
+
if (mode === "strict") {
|
|
364
|
+
return {
|
|
365
|
+
mode: "workspace-write",
|
|
366
|
+
error: `SWENY_SANDBOX=strict but the Codex sandbox cannot run on this host: ${reason}`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (!this.sandboxWarned) {
|
|
370
|
+
this.sandboxWarned = true;
|
|
371
|
+
const prefix = process.env.GITHUB_ACTIONS === "true" ? "::warning title=SWEny agent sandbox::" : "";
|
|
372
|
+
this.logger.warn(`${prefix}Codex commands will run unsandboxed (SWENY_SANDBOX=auto): ${reason}`);
|
|
373
|
+
}
|
|
374
|
+
return { mode: "danger-full-access" };
|
|
375
|
+
}
|
|
376
|
+
/** argv shared by run() and complete(). */
|
|
377
|
+
baseArgs(opts) {
|
|
378
|
+
const args = [
|
|
379
|
+
...this.codexCommand.args,
|
|
380
|
+
"exec",
|
|
381
|
+
"--json",
|
|
382
|
+
"--color",
|
|
383
|
+
"never",
|
|
384
|
+
"--skip-git-repo-check",
|
|
385
|
+
"--ephemeral",
|
|
386
|
+
"--ignore-user-config",
|
|
387
|
+
"--ignore-rules",
|
|
388
|
+
"--cd",
|
|
389
|
+
this.cwd,
|
|
390
|
+
"--sandbox",
|
|
391
|
+
opts.sandbox,
|
|
392
|
+
...cfg("approval_policy", "never"),
|
|
393
|
+
...cfg("check_for_update_on_startup", false),
|
|
394
|
+
...cfg("developer_instructions", SYSTEM_PROMPT),
|
|
395
|
+
];
|
|
396
|
+
if (opts.model)
|
|
397
|
+
args.push("--model", opts.model);
|
|
398
|
+
for (const f of CODEX_ISOLATION_FEATURES_OFF)
|
|
399
|
+
args.push(...cfg(`features.${f}`, false));
|
|
400
|
+
if (opts.deny.has("shell"))
|
|
401
|
+
args.push(...cfg("features.shell_tool", false));
|
|
402
|
+
if (opts.deny.has("net"))
|
|
403
|
+
args.push(...cfg("web_search", "disabled"));
|
|
404
|
+
if (opts.deny.has("subagent")) {
|
|
405
|
+
args.push(...cfg("features.multi_agent", false), ...cfg("agents.enabled", false));
|
|
406
|
+
}
|
|
407
|
+
if (opts.sandbox === "workspace-write") {
|
|
408
|
+
args.push(...cfg("sandbox_workspace_write.network_access", opts.network));
|
|
409
|
+
}
|
|
410
|
+
const baseUrl = process.env.OPENAI_BASE_URL;
|
|
411
|
+
if (baseUrl)
|
|
412
|
+
args.push(...cfg("openai_base_url", baseUrl));
|
|
413
|
+
return args;
|
|
414
|
+
}
|
|
415
|
+
/** `-c mcp_servers.<id>.*` for each server. Returns the args and anything Codex cannot load. */
|
|
416
|
+
mcpArgs(servers) {
|
|
417
|
+
const args = [];
|
|
418
|
+
const unsupported = [];
|
|
419
|
+
for (const [id, s] of Object.entries(servers)) {
|
|
420
|
+
const key = `mcp_servers.${keySegment(id)}`;
|
|
421
|
+
if (s.type === "http" || (!s.type && s.url && !s.command)) {
|
|
422
|
+
if (!s.url) {
|
|
423
|
+
unsupported.push(`${id} (http without url)`);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
args.push(...cfg(`${key}.url`, s.url));
|
|
427
|
+
if (s.headers && Object.keys(s.headers).length > 0)
|
|
428
|
+
args.push(...cfg(`${key}.http_headers`, s.headers));
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (!s.command) {
|
|
432
|
+
unsupported.push(`${id} (type ${String(s.type)})`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
args.push(...cfg(`${key}.command`, s.command));
|
|
436
|
+
if (s.args && s.args.length > 0)
|
|
437
|
+
args.push(...cfg(`${key}.args`, s.args));
|
|
438
|
+
if (s.env && Object.keys(s.env).length > 0)
|
|
439
|
+
args.push(...cfg(`${key}.env`, s.env));
|
|
440
|
+
}
|
|
441
|
+
return { args, unsupported };
|
|
442
|
+
}
|
|
443
|
+
/** Spawn codex exec, feed the prompt on stdin, and read JSONL until it exits. */
|
|
444
|
+
async exec(opts) {
|
|
445
|
+
const out = {
|
|
446
|
+
completed: false,
|
|
447
|
+
exitCode: null,
|
|
448
|
+
exitSignal: null,
|
|
449
|
+
stderrTail: "",
|
|
450
|
+
messages: [],
|
|
451
|
+
toolCalls: [],
|
|
452
|
+
};
|
|
453
|
+
const byId = new Map();
|
|
454
|
+
let toolCount = 0;
|
|
455
|
+
let stopped = false;
|
|
456
|
+
const abort = makeAbort(opts.timeoutMs, opts.signal);
|
|
457
|
+
if (abort?.controller.signal.aborted) {
|
|
458
|
+
// Aborted before Codex started: nothing to spawn or stop.
|
|
459
|
+
abort.clear();
|
|
460
|
+
out.abortReason = abort.reason();
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
let child;
|
|
464
|
+
try {
|
|
465
|
+
child = spawn(opts.spawn.command, opts.spawn.args, {
|
|
466
|
+
cwd: opts.spawn.cwd,
|
|
467
|
+
env: opts.spawn.env,
|
|
468
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
abort?.clear();
|
|
473
|
+
out.spawnError = err instanceof Error ? err.message : String(err);
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
trackChild(child);
|
|
477
|
+
const closed = new Promise((resolve) => {
|
|
478
|
+
child.once("close", (code, signal) => {
|
|
479
|
+
out.exitCode = code;
|
|
480
|
+
out.exitSignal = signal;
|
|
481
|
+
resolve();
|
|
482
|
+
});
|
|
483
|
+
child.once("error", (err) => {
|
|
484
|
+
out.spawnError = err.message;
|
|
485
|
+
resolve();
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
let killTimer;
|
|
489
|
+
const stop = () => {
|
|
490
|
+
if (stopped)
|
|
491
|
+
return;
|
|
492
|
+
stopped = true;
|
|
493
|
+
stopChild(child, "SIGTERM");
|
|
494
|
+
killTimer = setTimeout(() => stopChild(child, "SIGKILL"), this.killGraceMs);
|
|
495
|
+
killTimer.unref?.();
|
|
496
|
+
};
|
|
497
|
+
if (abort) {
|
|
498
|
+
const onAbort = () => {
|
|
499
|
+
out.abortReason = abort.reason();
|
|
500
|
+
stop();
|
|
501
|
+
};
|
|
502
|
+
abort.controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
503
|
+
if (abort.controller.signal.aborted)
|
|
504
|
+
onAbort();
|
|
505
|
+
}
|
|
506
|
+
child.stdin?.on("error", () => {
|
|
507
|
+
// Codex exited before reading the prompt; the exit code tells the story.
|
|
508
|
+
});
|
|
509
|
+
child.stdin?.end(opts.prompt);
|
|
510
|
+
child.stderr?.setEncoding("utf8");
|
|
511
|
+
child.stderr?.on("data", (chunk) => {
|
|
512
|
+
this.logger.debug(`[codex] ${chunk.trimEnd()}`);
|
|
513
|
+
out.stderrTail = (out.stderrTail + chunk).slice(-2000);
|
|
514
|
+
});
|
|
515
|
+
const startCall = (id, call) => {
|
|
516
|
+
out.toolCalls.push(call);
|
|
517
|
+
byId.set(id, call);
|
|
518
|
+
toolCount++;
|
|
519
|
+
if (opts.maxToolCalls !== undefined && toolCount > opts.maxToolCalls && !out.overBudget) {
|
|
520
|
+
out.overBudget = { count: toolCount, limit: opts.maxToolCalls };
|
|
521
|
+
stop();
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const onItem = (phase, item) => {
|
|
525
|
+
const id = typeof item.id === "string" ? item.id : "";
|
|
526
|
+
const kind = item.type;
|
|
527
|
+
if (kind === "agent_message") {
|
|
528
|
+
if (phase === "completed" && typeof item.text === "string")
|
|
529
|
+
out.messages.push(item.text);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (kind === "error") {
|
|
533
|
+
if (typeof item.message === "string")
|
|
534
|
+
this.logger.warn(` codex: ${item.message}`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!TOOL_ITEM_KINDS.has(kind))
|
|
538
|
+
return;
|
|
539
|
+
let call = byId.get(id);
|
|
540
|
+
if (!call) {
|
|
541
|
+
if (phase === "updated")
|
|
542
|
+
return;
|
|
543
|
+
const input = kind === "command_execution"
|
|
544
|
+
? { command: item.command }
|
|
545
|
+
: kind === "file_change"
|
|
546
|
+
? { changes: item.changes }
|
|
547
|
+
: kind === "mcp_tool_call"
|
|
548
|
+
? item.arguments
|
|
549
|
+
: kind === "web_search"
|
|
550
|
+
? { query: item.query }
|
|
551
|
+
: { prompt: item.prompt };
|
|
552
|
+
const tool = kind === "command_execution"
|
|
553
|
+
? "shell"
|
|
554
|
+
: kind === "file_change"
|
|
555
|
+
? "apply_patch"
|
|
556
|
+
: kind === "mcp_tool_call" || kind === "collab_tool_call"
|
|
557
|
+
? String(item.tool ?? "")
|
|
558
|
+
: "web_search";
|
|
559
|
+
call = { tool, input };
|
|
560
|
+
startCall(id, call);
|
|
561
|
+
const detail = kind === "command_execution" ? `shell: ${String(item.command ?? "")}` : tool;
|
|
562
|
+
opts.onProgress?.(detail.length > 80 ? detail.slice(0, 79) + "…" : detail);
|
|
563
|
+
}
|
|
564
|
+
if (phase !== "completed" || call.status !== undefined)
|
|
565
|
+
return;
|
|
566
|
+
const status = String(item.status ?? "");
|
|
567
|
+
if (kind === "mcp_tool_call") {
|
|
568
|
+
const resultIsError = item.result?.is_error === true || item.result?.isError === true;
|
|
569
|
+
if (status === "failed" || resultIsError || item.error) {
|
|
570
|
+
const detail = item.error?.message ?? parseToolResultContent(item.result?.content ?? "");
|
|
571
|
+
call.status = "error";
|
|
572
|
+
call.output = { error: detail };
|
|
573
|
+
this.logger.warn(` tool ${call.tool} failed: ${summarizeToolError(detail)}`);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
const content = item.result?.content;
|
|
577
|
+
const hasContent = Array.isArray(content) ? content.length > 0 : content !== undefined;
|
|
578
|
+
call.status = "success";
|
|
579
|
+
call.output = hasContent ? parseToolResultContent(content) : (item.result?.structured_content ?? null);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
else if (kind === "command_execution") {
|
|
583
|
+
const ok = status === "completed" && (item.exit_code === 0 || item.exit_code === undefined);
|
|
584
|
+
call.status = ok ? "success" : "error";
|
|
585
|
+
call.output = ok ? item.aggregated_output : { error: item.aggregated_output ?? status };
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
const ok = status !== "failed";
|
|
589
|
+
call.status = ok ? "success" : "error";
|
|
590
|
+
call.output = ok ? (item.results ?? item.changes ?? null) : { error: status };
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
let buf = "";
|
|
594
|
+
const onLine = (line) => {
|
|
595
|
+
if (stopped && out.overBudget)
|
|
596
|
+
return;
|
|
597
|
+
const trimmed = line.trim();
|
|
598
|
+
if (!trimmed)
|
|
599
|
+
return;
|
|
600
|
+
let ev;
|
|
601
|
+
try {
|
|
602
|
+
ev = JSON.parse(trimmed);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
this.logger.debug(`[codex] non-JSON stdout line ignored`);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
switch (ev.type) {
|
|
609
|
+
case "item.started":
|
|
610
|
+
case "item.updated":
|
|
611
|
+
case "item.completed":
|
|
612
|
+
if (ev.item && typeof ev.item === "object")
|
|
613
|
+
onItem(ev.type.slice(5), ev.item);
|
|
614
|
+
break;
|
|
615
|
+
case "turn.completed":
|
|
616
|
+
out.completed = true;
|
|
617
|
+
out.usage = mapUsage(ev.usage);
|
|
618
|
+
break;
|
|
619
|
+
case "turn.failed":
|
|
620
|
+
out.failure = String(ev.error?.message ?? "turn failed");
|
|
621
|
+
break;
|
|
622
|
+
case "error":
|
|
623
|
+
// Codex also reports errors it retries past (exec's jsonl processor
|
|
624
|
+
// maps every app-server Error notification here), so this is only
|
|
625
|
+
// the reason when no turn.completed follows.
|
|
626
|
+
out.lastError = String(ev.message ?? "codex error");
|
|
627
|
+
break;
|
|
628
|
+
default:
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
child.stdout?.setEncoding("utf8");
|
|
633
|
+
child.stdout?.on("data", (chunk) => {
|
|
634
|
+
buf += chunk;
|
|
635
|
+
let nl;
|
|
636
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
637
|
+
const line = buf.slice(0, nl);
|
|
638
|
+
buf = buf.slice(nl + 1);
|
|
639
|
+
onLine(line);
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
await closed;
|
|
643
|
+
// A final line without a newline is complete only if it parses.
|
|
644
|
+
if (buf.trim())
|
|
645
|
+
onLine(buf);
|
|
646
|
+
if (killTimer)
|
|
647
|
+
clearTimeout(killTimer);
|
|
648
|
+
abort?.clear();
|
|
649
|
+
liveChildren.delete(child);
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Run a node. The gate runs first: in strict mode a node whose opinions
|
|
654
|
+
* Codex cannot honor is refused before Codex starts. With a sandbox mode
|
|
655
|
+
* other than off, the whole Codex process runs inside the host's process
|
|
656
|
+
* sandbox wrapper (#360 step 2) when there is one, which also keeps the
|
|
657
|
+
* node's per-host egress allowlist that Codex cannot keep itself.
|
|
658
|
+
*/
|
|
659
|
+
async run(req) {
|
|
660
|
+
const maxTurns = req.maxTurns ?? this.maxTurns;
|
|
661
|
+
const mode = req.policy?.sandbox ?? resolveSandboxMode(process.env, this.sandboxMode, this.logger);
|
|
662
|
+
const policy = { ...this.compilePolicy(req, maxTurns), sandbox: mode };
|
|
663
|
+
// Codex's network is one switch, not a host list, so any sandbox mode but
|
|
664
|
+
// off needs the process wrapper for full containment.
|
|
665
|
+
const needsWrapper = mode !== "off" && !(this.capabilities.sandbox.fs && this.capabilities.sandbox.network);
|
|
666
|
+
const wrapper = needsWrapper && this.sandboxWrapper !== null
|
|
667
|
+
? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
|
|
668
|
+
: undefined;
|
|
669
|
+
const gate = policyGate(this.capabilities, policy, wrappersFrom(wrapper));
|
|
670
|
+
let degraded = gate.degraded;
|
|
671
|
+
const tag = (r, extra = []) => ({
|
|
672
|
+
...r,
|
|
673
|
+
harness: this.info(),
|
|
674
|
+
degraded: [...degraded, ...extra],
|
|
675
|
+
});
|
|
676
|
+
const refused = (why) => {
|
|
677
|
+
const msg = `codex refused this node: ${why}`;
|
|
678
|
+
this.logger.error(msg);
|
|
679
|
+
// `refused` keeps fail_soft from softening a policy refusal (executor.ts).
|
|
680
|
+
return tag({ status: "failed", data: { error: msg, refused: true }, toolCalls: [] });
|
|
681
|
+
};
|
|
682
|
+
if (gate.refuse)
|
|
683
|
+
return refused(gate.refuse);
|
|
684
|
+
const pre = await this.preflight();
|
|
685
|
+
if (!pre.ok) {
|
|
686
|
+
this.logger.error(pre.reason);
|
|
687
|
+
return tag({ status: "failed", data: { error: pre.reason }, toolCalls: [] });
|
|
688
|
+
}
|
|
689
|
+
const readOnly = policy.readOnly;
|
|
690
|
+
const deny = new Set(policy.deny);
|
|
691
|
+
if (readOnly)
|
|
692
|
+
for (const c of ["shell", "net", "subagent"])
|
|
693
|
+
deny.add(c);
|
|
694
|
+
let sandbox = "read-only";
|
|
695
|
+
if (!readOnly) {
|
|
696
|
+
if (wrapper) {
|
|
697
|
+
// The wrapper contains the whole process; Codex's own sandbox nested
|
|
698
|
+
// inside it would only get in the way.
|
|
699
|
+
sandbox = "danger-full-access";
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
const s = this.resolveSandbox(mode);
|
|
703
|
+
if (s.error) {
|
|
704
|
+
this.logger.error(s.error);
|
|
705
|
+
return tag({ status: "failed", data: { error: s.error }, toolCalls: [] });
|
|
706
|
+
}
|
|
707
|
+
sandbox = s.mode;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const env = this.buildEnv(req.agentAccess?.envVars);
|
|
711
|
+
if (wrapper && !env.CODEX_API_KEY && !env.CODEX_ACCESS_TOKEN && !this.loginWarned) {
|
|
712
|
+
this.loginWarned = true;
|
|
713
|
+
this.logger.warn("Codex runs inside the sandbox wrapper, where a stored `codex login` is not readable; set CODEX_API_KEY or OPENAI_API_KEY.");
|
|
714
|
+
}
|
|
715
|
+
const extraDegraded = [];
|
|
716
|
+
let bridge;
|
|
717
|
+
let scratch;
|
|
718
|
+
try {
|
|
719
|
+
// Dry run: external MCP servers cannot be classified per tool, so only
|
|
720
|
+
// sweny's own (already read-filtered) skill tools remain.
|
|
721
|
+
const servers = readOnly
|
|
722
|
+
? {}
|
|
723
|
+
: { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
|
|
724
|
+
const { args: mcpArgs, unsupported } = this.mcpArgs(servers);
|
|
725
|
+
if (unsupported.length > 0) {
|
|
726
|
+
const note = `mcp: codex cannot load ${unsupported.join(", ")}`;
|
|
727
|
+
this.logger.warn(` ${note}`);
|
|
728
|
+
extraDegraded.push(note);
|
|
729
|
+
}
|
|
730
|
+
const toolArgs = [];
|
|
731
|
+
if (req.tools.length > 0) {
|
|
732
|
+
bridge = await startToolBridge({
|
|
733
|
+
tools: req.tools,
|
|
734
|
+
context: this.defaultContext,
|
|
735
|
+
logger: this.logger,
|
|
736
|
+
...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
|
|
737
|
+
});
|
|
738
|
+
const shim = bridge.mcpServer;
|
|
739
|
+
// The token reaches the shim by name (`env_vars`), never in argv.
|
|
740
|
+
env[TOKEN_ENV] = bridge.token;
|
|
741
|
+
const key = "mcp_servers.sweny-core";
|
|
742
|
+
toolArgs.push(...cfg(`${key}.command`, shim.command), ...cfg(`${key}.args`, shim.args ?? []), ...cfg(`${key}.env`, { SWENY_NO_UPDATE_CHECK: "1" }), ...cfg(`${key}.env_vars`, [TOKEN_ENV]), ...cfg(`${key}.required`, true), ...cfg(`${key}.startup_timeout_sec`, 30), ...cfg(`${key}.tool_timeout_sec`, 600));
|
|
743
|
+
}
|
|
744
|
+
const prompt = buildNodePrompt(req.instruction, req.context, req.outputSchema);
|
|
745
|
+
let schemaArgs = [];
|
|
746
|
+
if (req.outputSchema) {
|
|
747
|
+
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-codex-"));
|
|
748
|
+
const schemaPath = path.join(scratch, "output-schema.json");
|
|
749
|
+
fs.writeFileSync(schemaPath, JSON.stringify(req.outputSchema), { mode: 0o600 });
|
|
750
|
+
schemaArgs = ["--output-schema", schemaPath];
|
|
751
|
+
}
|
|
752
|
+
const argsFor = (withSchema) => [
|
|
753
|
+
...this.baseArgs({
|
|
754
|
+
model: req.model ?? this.model,
|
|
755
|
+
sandbox,
|
|
756
|
+
deny,
|
|
757
|
+
network: !deny.has("net"),
|
|
758
|
+
}),
|
|
759
|
+
...mcpArgs,
|
|
760
|
+
...toolArgs,
|
|
761
|
+
...(withSchema ? schemaArgs : []),
|
|
762
|
+
"-",
|
|
763
|
+
];
|
|
764
|
+
// Gate and (maybe) wrap each spawn; the wrapper's scratch goes away with the process.
|
|
765
|
+
const execOnce = async (withSchema) => {
|
|
766
|
+
const prep = await prepareAgentSpawn({
|
|
767
|
+
caps: this.capabilities,
|
|
768
|
+
policy,
|
|
769
|
+
spawn: { command: this.codexCommand.command, args: argsFor(withSchema), env, cwd: this.cwd },
|
|
770
|
+
wrapper: wrapper ?? null,
|
|
771
|
+
harnessEgress: codexBackendHosts(),
|
|
772
|
+
env: process.env,
|
|
773
|
+
});
|
|
774
|
+
degraded = prep.degraded;
|
|
775
|
+
if (prep.refuse) {
|
|
776
|
+
await prep.cleanup();
|
|
777
|
+
return { refuse: prep.refuse };
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
return await this.exec({
|
|
781
|
+
spawn: prep.spawn,
|
|
782
|
+
prompt,
|
|
783
|
+
timeoutMs: req.timeoutMs,
|
|
784
|
+
signal: req.signal,
|
|
785
|
+
maxToolCalls: maxTurns,
|
|
786
|
+
onProgress: req.onProgress,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
finally {
|
|
790
|
+
await prep.cleanup();
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
let out = await execOnce(schemaArgs.length > 0);
|
|
794
|
+
if ("refuse" in out)
|
|
795
|
+
return refused(out.refuse);
|
|
796
|
+
// OpenAI strict structured outputs reject some JSON schemas. When Codex
|
|
797
|
+
// fails on the schema before doing anything, run once more without the
|
|
798
|
+
// native schema: sweny still asks for it in the prompt and checks it.
|
|
799
|
+
if (schemaArgs.length > 0 &&
|
|
800
|
+
!out.completed &&
|
|
801
|
+
!out.abortReason &&
|
|
802
|
+
out.toolCalls.length === 0 &&
|
|
803
|
+
/schema|response_format|text\.format/i.test(`${out.failure ?? ""} ${out.lastError ?? ""} ${out.stderrTail}`)) {
|
|
804
|
+
const why = isStrictCompatibleSchema(req.outputSchema)
|
|
805
|
+
? "codex rejected the output schema"
|
|
806
|
+
: "the output schema is not OpenAI strict-compatible";
|
|
807
|
+
extraDegraded.push(`structured_output: ${why}; sweny parsed and checked the output instead`);
|
|
808
|
+
this.logger.warn(` codex: ${why}; retrying without --output-schema`);
|
|
809
|
+
const again = await execOnce(false);
|
|
810
|
+
if ("refuse" in again)
|
|
811
|
+
return refused(again.refuse);
|
|
812
|
+
out = again;
|
|
813
|
+
}
|
|
814
|
+
return tag(this.toResult(out, req.outputSchema, req.timeoutMs), extraDegraded);
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
const msg = `Codex run failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
818
|
+
this.logger.error(msg);
|
|
819
|
+
return tag({ status: "failed", data: { error: msg }, toolCalls: [] }, extraDegraded);
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
await bridge?.close();
|
|
823
|
+
if (scratch)
|
|
824
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/** Map one exec outcome onto a NodeResult with Claude Code's semantics. */
|
|
828
|
+
toResult(out, outputSchema, timeoutMs) {
|
|
829
|
+
const { toolCalls, usage } = out;
|
|
830
|
+
const withUsage = (r) => (usage ? { ...r, usage } : r);
|
|
831
|
+
const last = out.messages.at(-1) ?? "";
|
|
832
|
+
const fail = (error, partial = false) => {
|
|
833
|
+
this.logger.error(error);
|
|
834
|
+
return withUsage({
|
|
835
|
+
status: "failed",
|
|
836
|
+
data: { error, ...(partial && last.trim() !== "" ? { summary: last.trim() } : {}) },
|
|
837
|
+
toolCalls,
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
if (out.spawnError)
|
|
841
|
+
return fail(`Codex could not start: ${out.spawnError}`);
|
|
842
|
+
if (out.abortReason === "timeout")
|
|
843
|
+
return fail(`Codex run timed out after ${timeoutMs}ms`);
|
|
844
|
+
if (out.abortReason === "signal")
|
|
845
|
+
return fail("Codex run aborted");
|
|
846
|
+
if (out.overBudget) {
|
|
847
|
+
// Same shape as Claude Code's max_turns stop: partial text is kept for fail_soft nodes.
|
|
848
|
+
return fail(`Codex run terminated early: max_turns (${out.overBudget.limit} tool calls; stopped by the sweny watchdog)`, true);
|
|
849
|
+
}
|
|
850
|
+
if (out.failure)
|
|
851
|
+
return fail(`Codex run failed: ${out.failure}`, true);
|
|
852
|
+
if (!out.completed) {
|
|
853
|
+
if (out.lastError)
|
|
854
|
+
return fail(`Codex run failed: ${out.lastError}`, true);
|
|
855
|
+
const code = out.exitSignal ? `signal ${out.exitSignal}` : `code ${out.exitCode}`;
|
|
856
|
+
const tail = out.stderrTail.trim().split("\n").slice(-3).join(" | ");
|
|
857
|
+
return fail(out.exitCode === 0 && !out.exitSignal
|
|
858
|
+
? `agent stream ended without a result message (${toolCalls.length} tool calls captured)`
|
|
859
|
+
: `Codex exited with ${code}${tail ? `: ${tail}` : ""}`);
|
|
860
|
+
}
|
|
861
|
+
if (out.exitCode !== 0 || out.exitSignal) {
|
|
862
|
+
const code = out.exitSignal ? `signal ${out.exitSignal}` : `code ${out.exitCode}`;
|
|
863
|
+
return fail(`Codex exited with ${code} after completing the turn`);
|
|
864
|
+
}
|
|
865
|
+
const parsed = tryParseJSON(last, outputSchema, this.logger, "Codex");
|
|
866
|
+
return withUsage({ status: "success", data: { summary: last, ...parsed }, toolCalls });
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* One completion: read-only sandbox, no shell, no web search, no subagents,
|
|
870
|
+
* no MCP. null when it failed (callers fail closed).
|
|
871
|
+
*/
|
|
872
|
+
async complete(req) {
|
|
873
|
+
const purpose = req.purpose ?? "ask";
|
|
874
|
+
const failClosed = (why) => {
|
|
875
|
+
this.logger.warn(purpose === "evaluate"
|
|
876
|
+
? `codex.evaluate: ${why}. Failing closed (no route decision).`
|
|
877
|
+
: `codex.ask: ${why}. Returning empty string.`);
|
|
878
|
+
return null;
|
|
879
|
+
};
|
|
880
|
+
const pre = await this.preflight();
|
|
881
|
+
if (!pre.ok)
|
|
882
|
+
return failClosed(pre.reason);
|
|
883
|
+
const args = [
|
|
884
|
+
...this.baseArgs({
|
|
885
|
+
// An empty model (the judge default) means Codex's own default model.
|
|
886
|
+
model: req.model || this.model,
|
|
887
|
+
sandbox: "read-only",
|
|
888
|
+
deny: new Set(["shell", "net", "subagent"]),
|
|
889
|
+
network: false,
|
|
890
|
+
}),
|
|
891
|
+
"-",
|
|
892
|
+
];
|
|
893
|
+
const out = await this.exec({
|
|
894
|
+
spawn: { command: this.codexCommand.command, args, env: this.buildEnv(), cwd: this.cwd },
|
|
895
|
+
prompt: req.prompt,
|
|
896
|
+
timeoutMs: req.timeoutMs,
|
|
897
|
+
signal: req.signal,
|
|
898
|
+
// Classification calls use no tools; any tool call is over budget.
|
|
899
|
+
maxToolCalls: 0,
|
|
900
|
+
});
|
|
901
|
+
if (out.spawnError)
|
|
902
|
+
return failClosed(`codex could not start: ${out.spawnError}`);
|
|
903
|
+
if (out.abortReason === "timeout")
|
|
904
|
+
return failClosed(`timed out after ${req.timeoutMs}ms`);
|
|
905
|
+
if (out.abortReason === "signal")
|
|
906
|
+
return failClosed("aborted");
|
|
907
|
+
if (out.overBudget)
|
|
908
|
+
return failClosed("the model tried to call a tool");
|
|
909
|
+
if (out.failure)
|
|
910
|
+
return failClosed(`codex failed: ${out.failure}`);
|
|
911
|
+
if (!out.completed)
|
|
912
|
+
return failClosed(out.lastError ?? "agent stream ended without a result message");
|
|
913
|
+
if (out.exitCode !== 0 || out.exitSignal)
|
|
914
|
+
return failClosed(`codex exited with code ${out.exitCode}`);
|
|
915
|
+
return out.messages.at(-1) ?? "";
|
|
916
|
+
}
|
|
917
|
+
}
|