@fusionkit/tool-codex 0.1.6

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.
@@ -0,0 +1,70 @@
1
+ import type { HarnessAdapter } from "@fusionkit/ensemble";
2
+ export type CodexSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
3
+ export type CodexApprovalPolicy = "untrusted" | "on-failure" | "on-request" | "never";
4
+ export type CodexAmbientProvider = {
5
+ kind: "ambient";
6
+ credentialEnvNames?: readonly string[];
7
+ };
8
+ export type CodexResponsesProvider = {
9
+ kind: "responses";
10
+ baseUrl: string;
11
+ apiKey?: string;
12
+ apiKeyEnvName?: string;
13
+ requiresOpenAiAuth?: boolean;
14
+ providerId?: string;
15
+ name?: string;
16
+ };
17
+ export type CodexOpenAiCompatibleProvider = {
18
+ kind: "openai-compatible";
19
+ baseUrl: string;
20
+ apiKey?: string;
21
+ apiKeyEnvName?: string;
22
+ defaultModel?: string;
23
+ providerId?: string;
24
+ name?: string;
25
+ };
26
+ export type CodexProvider = CodexAmbientProvider | CodexResponsesProvider | CodexOpenAiCompatibleProvider;
27
+ export type CodexExecInput = {
28
+ command: string;
29
+ args: string[];
30
+ cwd: string;
31
+ env: Record<string, string>;
32
+ timeoutMs?: number;
33
+ };
34
+ export type CodexExecResult = {
35
+ stdout: string;
36
+ stderr: string;
37
+ exitCode: number;
38
+ timedOut?: boolean;
39
+ };
40
+ export type CodexExecRunner = (input: CodexExecInput) => Promise<CodexExecResult> | CodexExecResult;
41
+ export type CodexHarnessOptions = {
42
+ id?: string;
43
+ command?: string;
44
+ cwd?: string;
45
+ timeoutMs?: number;
46
+ env?: Record<string, string | undefined>;
47
+ provider?: CodexProvider;
48
+ runner?: CodexExecRunner;
49
+ sandboxMode?: CodexSandboxMode;
50
+ approvalPolicy?: CodexApprovalPolicy;
51
+ keepCodexHome?: boolean;
52
+ };
53
+ export type CodexHarnessEnv = Record<string, string | undefined>;
54
+ export type CodexConfigTomlInput = {
55
+ model: string;
56
+ sandboxMode: CodexSandboxMode;
57
+ approvalPolicy: CodexApprovalPolicy;
58
+ provider?: {
59
+ providerId?: string;
60
+ name?: string;
61
+ baseUrl: string;
62
+ apiKeyEnvName?: string;
63
+ requiresOpenAiAuth: boolean;
64
+ };
65
+ };
66
+ export declare function codexHarnessCredentialSkipReason(env?: CodexHarnessEnv, options?: Pick<CodexHarnessOptions, "provider">): string | undefined;
67
+ export declare function codexConfigToml(input: CodexConfigTomlInput): string;
68
+ export declare function defaultCodexRunner(input: CodexExecInput): Promise<CodexExecResult>;
69
+ export declare function createCodexHarness(options?: CodexHarnessOptions): HarnessAdapter;
70
+ export declare const codexHarness: typeof createCodexHarness;
@@ -0,0 +1,436 @@
1
+ import { spawn } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { artifactHash } from "@fusionkit/protocol";
6
+ import { OpenAiBackend, startGateway } from "@fusionkit/model-gateway";
7
+ import { buildSkippedCandidate, definedEnv, normalizeApiBaseUrl, readEnv } from "@fusionkit/tools";
8
+ const DEFAULT_CODEX_COMMAND = "codex";
9
+ const DEFAULT_PROVIDER_ID = "fusionkit-codex";
10
+ const DEFAULT_PROVIDER_NAME = "FusionKit Codex";
11
+ const DEFAULT_CREDENTIAL_ENV_NAMES = ["CODEX_API_KEY", "OPENAI_API_KEY"];
12
+ const INLINE_PROVIDER_API_KEY_ENV = "FUSIONKIT_CODEX_PROVIDER_API_KEY";
13
+ const CODEX_AUTH_FILE = "auth.json";
14
+ function tomlString(value) {
15
+ return JSON.stringify(value);
16
+ }
17
+ function stripResponsesRoute(baseUrl) {
18
+ return baseUrl.replace(/\/responses\/?$/, "");
19
+ }
20
+ function isLoopbackUrl(baseUrl) {
21
+ try {
22
+ const url = new URL(baseUrl);
23
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function firstPresentEnv(env, names) {
30
+ return names.find((name) => env[name] !== undefined && env[name].length > 0);
31
+ }
32
+ function codexHome(env) {
33
+ return env.CODEX_HOME && env.CODEX_HOME.length > 0
34
+ ? env.CODEX_HOME
35
+ : join(homedir(), ".codex");
36
+ }
37
+ function codexAuthFile(env) {
38
+ const path = join(codexHome(env), CODEX_AUTH_FILE);
39
+ return existsSync(path) ? path : undefined;
40
+ }
41
+ function providerFromEnv(env) {
42
+ const responsesBaseUrl = readEnv(env, "FUSIONKIT_CODEX_RESPONSES_BASE_URL") ?? env.CODEX_RESPONSES_BASE_URL;
43
+ if (responsesBaseUrl !== undefined && responsesBaseUrl.length > 0) {
44
+ const apiKeyEnvName = firstPresentEnv(env, [
45
+ "FUSIONKIT_CODEX_API_KEY",
46
+ "WARRANT_CODEX_API_KEY",
47
+ "CODEX_API_KEY",
48
+ "OPENAI_API_KEY"
49
+ ]);
50
+ return {
51
+ kind: "responses",
52
+ baseUrl: responsesBaseUrl,
53
+ ...(apiKeyEnvName ? { apiKeyEnvName } : {}),
54
+ requiresOpenAiAuth: !isLoopbackUrl(responsesBaseUrl)
55
+ };
56
+ }
57
+ const openAiBaseUrl = readEnv(env, "FUSIONKIT_CODEX_OPENAI_BASE_URL") ?? env.OPENAI_BASE_URL;
58
+ if (openAiBaseUrl !== undefined && openAiBaseUrl.length > 0) {
59
+ const apiKeyEnvName = firstPresentEnv(env, [
60
+ "FUSIONKIT_CODEX_OPENAI_API_KEY",
61
+ "WARRANT_CODEX_OPENAI_API_KEY",
62
+ "OPENAI_API_KEY"
63
+ ]);
64
+ return {
65
+ kind: "openai-compatible",
66
+ baseUrl: openAiBaseUrl,
67
+ ...(apiKeyEnvName ? { apiKeyEnvName } : {})
68
+ };
69
+ }
70
+ return { kind: "ambient" };
71
+ }
72
+ function credentialEnvName(provider, env) {
73
+ if (provider.apiKey !== undefined)
74
+ return INLINE_PROVIDER_API_KEY_ENV;
75
+ if (provider.apiKeyEnvName !== undefined)
76
+ return provider.apiKeyEnvName;
77
+ return firstPresentEnv(env, DEFAULT_CREDENTIAL_ENV_NAMES);
78
+ }
79
+ function missingCredentialReason(provider, env) {
80
+ switch (provider.kind) {
81
+ case "ambient": {
82
+ const names = provider.credentialEnvNames ?? DEFAULT_CREDENTIAL_ENV_NAMES;
83
+ return firstPresentEnv(env, names) === undefined && codexAuthFile(env) === undefined
84
+ ? `Codex credentials are absent; set ${names.join(" or ")} or configure a Responses/OpenAI-compatible provider.`
85
+ : undefined;
86
+ }
87
+ case "responses": {
88
+ if (provider.requiresOpenAiAuth === false || provider.apiKey !== undefined)
89
+ return undefined;
90
+ const envName = credentialEnvName(provider, env);
91
+ return envName === undefined || env[envName] === undefined || env[envName].length === 0
92
+ ? `Codex Responses provider credentials are absent; set ${provider.apiKeyEnvName ?? DEFAULT_CREDENTIAL_ENV_NAMES.join(" or ")} or mark the provider requiresOpenAiAuth=false for local endpoints.`
93
+ : undefined;
94
+ }
95
+ case "openai-compatible":
96
+ return undefined;
97
+ default: {
98
+ const exhausted = provider;
99
+ throw new Error(`unsupported Codex provider: ${String(exhausted)}`);
100
+ }
101
+ }
102
+ }
103
+ export function codexHarnessCredentialSkipReason(env = process.env, options = {}) {
104
+ const defined = definedEnv(env);
105
+ return missingCredentialReason(options.provider ?? providerFromEnv(defined), defined);
106
+ }
107
+ function sandboxModeFor(descriptor, override) {
108
+ if (override !== undefined)
109
+ return override;
110
+ switch (descriptor.policy.sideEffects) {
111
+ case "none":
112
+ case "read_only":
113
+ return "read-only";
114
+ case "writes_workspace":
115
+ case "network":
116
+ case "tool_execution":
117
+ case "unknown":
118
+ return "workspace-write";
119
+ default: {
120
+ const exhausted = descriptor.policy.sideEffects;
121
+ throw new Error(`unsupported side effects policy: ${String(exhausted)}`);
122
+ }
123
+ }
124
+ }
125
+ export function codexConfigToml(input) {
126
+ const lines = [
127
+ `model = ${tomlString(input.model)}`,
128
+ input.provider
129
+ ? `model_provider = ${tomlString(input.provider.providerId ?? DEFAULT_PROVIDER_ID)}`
130
+ : `model_provider = "openai"`,
131
+ `approval_policy = ${tomlString(input.approvalPolicy)}`,
132
+ `sandbox_mode = ${tomlString(input.sandboxMode)}`,
133
+ ""
134
+ ];
135
+ if (input.provider !== undefined) {
136
+ const providerId = input.provider.providerId ?? DEFAULT_PROVIDER_ID;
137
+ lines.push(`[model_providers.${providerId}]`, `name = ${tomlString(input.provider.name ?? DEFAULT_PROVIDER_NAME)}`, `base_url = ${tomlString(normalizeApiBaseUrl(stripResponsesRoute(input.provider.baseUrl)))}`, `wire_api = "responses"`, `requires_openai_auth = ${input.provider.requiresOpenAiAuth ? "true" : "false"}`);
138
+ if (input.provider.apiKeyEnvName !== undefined) {
139
+ lines.push(`env_key = ${tomlString(input.provider.apiKeyEnvName)}`);
140
+ }
141
+ lines.push("");
142
+ }
143
+ return lines.join("\n");
144
+ }
145
+ function codexArgs(prompt) {
146
+ return ["exec", "--json", "--skip-git-repo-check", prompt];
147
+ }
148
+ function writeCodexHome(input) {
149
+ const codexHome = mkdtempSync(join(input.tempRoot, "candidate-"));
150
+ const providerConfig = input.provider.kind === "ambient"
151
+ ? undefined
152
+ : {
153
+ providerId: input.provider.providerId,
154
+ name: input.provider.name,
155
+ baseUrl: input.providerBaseUrl ?? input.provider.baseUrl,
156
+ apiKeyEnvName: input.provider.kind === "responses"
157
+ ? credentialEnvName(input.provider, input.env)
158
+ : undefined,
159
+ requiresOpenAiAuth: input.provider.kind === "responses"
160
+ ? input.provider.requiresOpenAiAuth ?? true
161
+ : false
162
+ };
163
+ writeFileSync(join(codexHome, "config.toml"), codexConfigToml({
164
+ model: input.model.model,
165
+ sandboxMode: sandboxModeFor(input.descriptor, input.sandboxMode),
166
+ approvalPolicy: input.approvalPolicy,
167
+ ...(providerConfig ? { provider: providerConfig } : {})
168
+ }));
169
+ if (input.provider.kind === "ambient" &&
170
+ firstPresentEnv(input.env, input.provider.credentialEnvNames ?? DEFAULT_CREDENTIAL_ENV_NAMES) === undefined) {
171
+ const authFile = codexAuthFile(input.env);
172
+ if (authFile !== undefined) {
173
+ copyFileSync(authFile, join(codexHome, CODEX_AUTH_FILE));
174
+ }
175
+ }
176
+ return codexHome;
177
+ }
178
+ export async function defaultCodexRunner(input) {
179
+ return await new Promise((resolve, reject) => {
180
+ const child = spawn(input.command, input.args, {
181
+ cwd: input.cwd,
182
+ env: input.env,
183
+ stdio: ["ignore", "pipe", "pipe"]
184
+ });
185
+ const stdout = [];
186
+ const stderr = [];
187
+ let timedOut = false;
188
+ let timer;
189
+ if (input.timeoutMs !== undefined) {
190
+ timer = setTimeout(() => {
191
+ timedOut = true;
192
+ child.kill("SIGTERM");
193
+ }, input.timeoutMs);
194
+ }
195
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
196
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
197
+ child.on("error", reject);
198
+ child.on("exit", (code) => {
199
+ if (timer !== undefined)
200
+ clearTimeout(timer);
201
+ resolve({
202
+ stdout: Buffer.concat(stdout).toString("utf8"),
203
+ stderr: Buffer.concat(stderr).toString("utf8"),
204
+ exitCode: timedOut ? 124 : code ?? 0,
205
+ ...(timedOut ? { timedOut } : {})
206
+ });
207
+ });
208
+ });
209
+ }
210
+ async function runProvider(input) {
211
+ switch (input.provider.kind) {
212
+ case "ambient":
213
+ case "responses":
214
+ return {
215
+ provider: input.provider,
216
+ modelCallRecords: [],
217
+ close: async () => undefined
218
+ };
219
+ case "openai-compatible": {
220
+ const records = [];
221
+ const apiKey = input.provider.apiKey ??
222
+ (input.provider.apiKeyEnvName !== undefined
223
+ ? input.env[input.provider.apiKeyEnvName]
224
+ : input.env.OPENAI_API_KEY);
225
+ const gateway = await startGateway({
226
+ backend: new OpenAiBackend({
227
+ baseUrl: normalizeApiBaseUrl(input.provider.baseUrl),
228
+ ...(apiKey !== undefined ? { apiKey } : {}),
229
+ defaultModel: input.provider.defaultModel ?? input.model.model
230
+ }),
231
+ provenance: {
232
+ onModelCall(record) {
233
+ records.push(record);
234
+ }
235
+ }
236
+ });
237
+ return {
238
+ provider: input.provider,
239
+ configBaseUrl: gateway.url(),
240
+ modelCallRecords: records,
241
+ close: () => gateway.close()
242
+ };
243
+ }
244
+ default: {
245
+ const exhausted = input.provider;
246
+ throw new Error(`unsupported Codex provider: ${String(exhausted)}`);
247
+ }
248
+ }
249
+ }
250
+ function metadataFor(input) {
251
+ return {
252
+ adapter: "codex",
253
+ command: input.command,
254
+ args: input.args,
255
+ provider_kind: input.provider.kind,
256
+ stdout_bytes: Buffer.byteLength(input.stdout),
257
+ stderr_bytes: Buffer.byteLength(input.stderr),
258
+ timed_out: input.timedOut === true,
259
+ model_call_count: input.modelCallRecords.length
260
+ };
261
+ }
262
+ function skippedCandidate(input) {
263
+ return buildSkippedCandidate({
264
+ descriptor: input.descriptor,
265
+ model: input.model,
266
+ ordinal: input.ordinal,
267
+ reason: input.reason,
268
+ adapter: "codex",
269
+ transcript: `Codex adapter skipped: ${input.reason}`,
270
+ metadata: { provider_kind: input.provider.kind }
271
+ });
272
+ }
273
+ function failedToSpawnCandidate(input) {
274
+ const errno = input.error;
275
+ const reason = errno.code === "ENOENT"
276
+ ? "Codex CLI binary was not found on PATH."
277
+ : input.error instanceof Error
278
+ ? input.error.message
279
+ : String(input.error);
280
+ return skippedCandidate({
281
+ descriptor: input.descriptor,
282
+ model: input.model,
283
+ ordinal: input.ordinal,
284
+ reason,
285
+ provider: input.provider
286
+ });
287
+ }
288
+ export function createCodexHarness(options = {}) {
289
+ const id = options.id ?? "codex";
290
+ const command = options.command ?? DEFAULT_CODEX_COMMAND;
291
+ const runner = options.runner ?? defaultCodexRunner;
292
+ const approvalPolicy = options.approvalPolicy ?? "never";
293
+ return {
294
+ id,
295
+ harnessKind: "codex",
296
+ prepare: () => {
297
+ const env = definedEnv(options.env ?? process.env);
298
+ return {
299
+ tempRoot: mkdtempSync(join(tmpdir(), "warrant-codex-")),
300
+ env,
301
+ provider: options.provider ?? providerFromEnv(env)
302
+ };
303
+ },
304
+ capabilities: () => ({
305
+ workspace_read: "supported",
306
+ apply_patch: "supported",
307
+ shell_command: "degraded",
308
+ artifact_capture: "supported",
309
+ model_gateway_responses: "supported",
310
+ openai_compatible_gateway: "supported",
311
+ verification: "supported"
312
+ }),
313
+ verificationProfile: () => ({
314
+ id: `${id}-verification`,
315
+ requiredEvidence: ["codex transcript", "exit code", "optional model-call record"]
316
+ }),
317
+ run: async ({ descriptor, model, ordinal, prepared, worktree }) => {
318
+ const state = prepared;
319
+ const missing = missingCredentialReason(state.provider, state.env);
320
+ if (missing !== undefined) {
321
+ return skippedCandidate({ descriptor, model, ordinal, reason: missing, provider: state.provider });
322
+ }
323
+ const provider = await runProvider({
324
+ provider: state.provider,
325
+ env: state.env,
326
+ model
327
+ });
328
+ try {
329
+ const env = { ...state.env };
330
+ if (provider.provider.kind === "responses" && provider.provider.apiKey !== undefined) {
331
+ env[INLINE_PROVIDER_API_KEY_ENV] = provider.provider.apiKey;
332
+ }
333
+ const codexHome = writeCodexHome({
334
+ tempRoot: state.tempRoot,
335
+ model,
336
+ providerBaseUrl: provider.configBaseUrl,
337
+ provider: provider.provider,
338
+ env,
339
+ descriptor,
340
+ sandboxMode: options.sandboxMode,
341
+ approvalPolicy
342
+ });
343
+ env.CODEX_HOME = codexHome;
344
+ const args = codexArgs(descriptor.prompt);
345
+ const cwd = worktree?.path ?? options.cwd ?? descriptor.workspace ?? process.cwd();
346
+ const timeoutMs = options.timeoutMs ?? descriptor.policy.timeoutMs;
347
+ let result;
348
+ try {
349
+ result = await runner({ command, args, cwd, env, timeoutMs });
350
+ }
351
+ catch (error) {
352
+ return failedToSpawnCandidate({
353
+ descriptor,
354
+ model,
355
+ ordinal,
356
+ error,
357
+ provider: provider.provider
358
+ });
359
+ }
360
+ const transcript = [result.stdout, result.stderr].filter(Boolean).join("\n");
361
+ const status = result.exitCode === 0 && result.timedOut !== true ? "succeeded" : "failed";
362
+ const outputHash = artifactHash(transcript);
363
+ const modelCallRecord = provider.modelCallRecords.at(-1);
364
+ return {
365
+ candidateId: `${descriptor.id}_${model.id}_${ordinal}`,
366
+ model,
367
+ status,
368
+ ...(modelCallRecord ? { modelCallId: modelCallRecord.call_id, modelCallRecord } : {}),
369
+ ...(worktree ? { branchName: worktree.branchName, worktreePath: worktree.path } : {}),
370
+ transcript,
371
+ log: transcript,
372
+ artifacts: [
373
+ {
374
+ artifact_id: `artifact_${descriptor.id}_${model.id}_codex_output`,
375
+ kind: "log",
376
+ hash: outputHash,
377
+ redaction_status: "synthetic"
378
+ }
379
+ ],
380
+ toolRecords: [
381
+ {
382
+ execution_id: `exec_${descriptor.id}_${model.id}_${ordinal}_codex`,
383
+ plan_id: `plan_${descriptor.id}_${model.id}_${ordinal}_codex`,
384
+ status,
385
+ output_hash: outputHash,
386
+ ...(status === "failed"
387
+ ? {
388
+ error: {
389
+ kind: result.timedOut === true ? "timeout" : "provider_error",
390
+ message: result.timedOut === true ? "Codex CLI timed out." : result.stderr.slice(0, 500),
391
+ retryable: result.timedOut === true
392
+ }
393
+ }
394
+ : {})
395
+ }
396
+ ],
397
+ verification: {
398
+ status,
399
+ evidence: [`exit_code=${result.exitCode}`, outputHash],
400
+ exitCode: result.exitCode
401
+ },
402
+ ...(status === "failed"
403
+ ? {
404
+ error: {
405
+ kind: result.timedOut === true ? "timeout" : "provider_error",
406
+ message: result.timedOut === true ? "Codex CLI timed out." : result.stderr.slice(0, 500),
407
+ retryable: result.timedOut === true
408
+ }
409
+ }
410
+ : {}),
411
+ metadata: metadataFor({
412
+ command,
413
+ args,
414
+ provider: provider.provider,
415
+ stdout: result.stdout,
416
+ stderr: result.stderr,
417
+ ...(result.timedOut !== undefined ? { timedOut: result.timedOut } : {}),
418
+ modelCallRecords: provider.modelCallRecords
419
+ })
420
+ };
421
+ }
422
+ finally {
423
+ await provider.close();
424
+ }
425
+ },
426
+ collectArtifacts: () => [],
427
+ cleanup: ({ prepared }) => {
428
+ if (options.keepCodexHome === true)
429
+ return;
430
+ const state = prepared;
431
+ if (state !== undefined)
432
+ rmSync(state.tempRoot, { recursive: true, force: true });
433
+ }
434
+ };
435
+ }
436
+ export const codexHarness = createCodexHarness;
@@ -0,0 +1,5 @@
1
+ import type { ToolIntegration } from "@fusionkit/tools";
2
+ export declare const codexTool: ToolIntegration;
3
+ export { codexConfigToml, codexHarness, codexHarnessCredentialSkipReason, createCodexHarness, defaultCodexRunner } from "./harness.js";
4
+ export type { CodexAmbientProvider, CodexApprovalPolicy, CodexConfigTomlInput, CodexExecInput, CodexExecResult, CodexExecRunner, CodexHarnessEnv, CodexHarnessOptions, CodexOpenAiCompatibleProvider, CodexProvider, CodexResponsesProvider, CodexSandboxMode } from "./harness.js";
5
+ export { codexLaunchConfigToml, launchCodex } from "./launch.js";
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ import { codexHarness, codexHarnessCredentialSkipReason, createCodexHarness } from "./harness.js";
2
+ import { launchCodex } from "./launch.js";
3
+ const LIVE_SMOKE_PROMPT = "Read README.md if present, then reply exactly CODEX_LIVE_SMOKE_OK. Do not modify files.";
4
+ export const codexTool = {
5
+ id: "codex",
6
+ displayName: "Codex",
7
+ pickerHint: "OpenAI Codex CLI",
8
+ binary: "codex",
9
+ modes: ["fusion", "local"],
10
+ harnessKinds: ["codex"],
11
+ launch: launchCodex,
12
+ createHarness: (_kind, options) => createCodexHarness({
13
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
14
+ provider: {
15
+ kind: "openai-compatible",
16
+ baseUrl: options.fusionBackendUrl,
17
+ ...(options.fusionApiKey !== undefined ? { apiKey: options.fusionApiKey } : {})
18
+ }
19
+ }),
20
+ harness: {
21
+ harnessKind: "codex",
22
+ sideEffects: "writes_workspace",
23
+ responseShape: "Return a Codex-style result summary with patch and verification evidence."
24
+ },
25
+ dashboard: {
26
+ id: "codex",
27
+ harnessKind: "codex",
28
+ displayName: "Codex",
29
+ availability: "credential_gated",
30
+ capabilities: {
31
+ model_override: "supported",
32
+ transcript_capture: "supported",
33
+ diff_capture: "supported",
34
+ tool_loop_capture: "degraded",
35
+ patch_apply_visibility: "supported",
36
+ route_model_observation: "supported",
37
+ verification_hint: "supported",
38
+ replay_support: "degraded"
39
+ },
40
+ notes: ["Credential-gated; dashboard smoke uses an empty env skip path."],
41
+ makeMatrixHarness: (env) => codexHarness({ env, provider: { kind: "ambient" } }),
42
+ credentialSkipReason: (env) => codexHarnessCredentialSkipReason(env),
43
+ smoke: {
44
+ taskId: "codex-skipped",
45
+ model: { id: "codex", model: "gpt-5.5-codex" },
46
+ sideEffects: "writes_workspace",
47
+ allowedTools: ["read_file", "apply_patch"],
48
+ makeHarness: () => codexHarness({ env: {}, provider: { kind: "ambient" } })
49
+ },
50
+ liveSmoke: {
51
+ taskId: "codex-live",
52
+ envName: "FUSIONKIT_CODEX_SMOKE",
53
+ prompt: LIVE_SMOKE_PROMPT,
54
+ modelEnvName: "FUSIONKIT_CODEX_SMOKE_MODEL",
55
+ defaultModel: "gpt-5.5-codex",
56
+ makeHarness: (env) => codexHarness({ env })
57
+ }
58
+ }
59
+ };
60
+ export { codexConfigToml, codexHarness, codexHarnessCredentialSkipReason, createCodexHarness, defaultCodexRunner } from "./harness.js";
61
+ export { codexLaunchConfigToml, launchCodex } from "./launch.js";
@@ -0,0 +1,9 @@
1
+ import type { ToolLaunchContext } from "@fusionkit/tools";
2
+ /**
3
+ * Codex config.toml fragment defining the gateway as a Responses provider.
4
+ * Written into an ephemeral CODEX_HOME so the user's own config is untouched.
5
+ * (This is the launcher shim; the harness has its own richer config builder.)
6
+ */
7
+ export declare function codexLaunchConfigToml(gatewayUrl: string, model: string): string;
8
+ /** Boot the Codex CLI against the gateway via an ephemeral CODEX_HOME. */
9
+ export declare function launchCodex(ctx: ToolLaunchContext): Promise<number>;
package/dist/launch.js ADDED
@@ -0,0 +1,33 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { LOCAL_MODEL_LABEL, spawnTool } from "@fusionkit/tools";
5
+ /**
6
+ * Codex config.toml fragment defining the gateway as a Responses provider.
7
+ * Written into an ephemeral CODEX_HOME so the user's own config is untouched.
8
+ * (This is the launcher shim; the harness has its own richer config builder.)
9
+ */
10
+ export function codexLaunchConfigToml(gatewayUrl, model) {
11
+ return [
12
+ `model = "${model}"`,
13
+ `model_provider = "${LOCAL_MODEL_LABEL}"`,
14
+ "",
15
+ `[model_providers.${LOCAL_MODEL_LABEL}]`,
16
+ `name = "FusionKit local"`,
17
+ `base_url = "${gatewayUrl}/v1"`,
18
+ `wire_api = "responses"`,
19
+ `requires_openai_auth = false`,
20
+ ""
21
+ ].join("\n");
22
+ }
23
+ /** Boot the Codex CLI against the gateway via an ephemeral CODEX_HOME. */
24
+ export async function launchCodex(ctx) {
25
+ const home = mkdtempSync(join(tmpdir(), "fusionkit-codex-"));
26
+ ctx.registerDisposer(() => rmSync(home, { recursive: true, force: true }));
27
+ writeFileSync(join(home, "config.toml"), codexLaunchConfigToml(ctx.gatewayUrl, ctx.modelLabel));
28
+ ctx.prepareForPassthrough();
29
+ if (ctx.mode === "fusion") {
30
+ ctx.log("fusion: launching codex (each prompt is a coding task fused across the panel)...");
31
+ }
32
+ return await spawnTool("codex", ctx.toolArgs, { CODEX_HOME: home }, ctx.repo);
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,277 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { test } from "node:test";
7
+ import { createMockHarness, ensemble } from "@fusionkit/ensemble";
8
+ import { codexConfigToml, codexHarness, defaultCodexRunner } from "../index.js";
9
+ function tempOutputRoot() {
10
+ const outputRoot = mkdtempSync(join(tmpdir(), "ensemble-codex-out-"));
11
+ return {
12
+ outputRoot,
13
+ cleanup: () => rmSync(outputRoot, { recursive: true, force: true })
14
+ };
15
+ }
16
+ function descriptor(outputRoot, overrides = {}) {
17
+ return {
18
+ id: "codex_ensemble_test",
19
+ harness: createMockHarness(),
20
+ models: [{ id: "codex", model: "gpt-5.1-codex-max" }],
21
+ runtime: { id: "local" },
22
+ judge: { id: "judge", model: "fake-judge" },
23
+ policy: {
24
+ id: "policy",
25
+ allowedTools: ["read_file", "apply_patch"],
26
+ sideEffects: "writes_workspace",
27
+ timeoutMs: 1_000
28
+ },
29
+ prompt: "Summarize Codex harness evidence.",
30
+ sourceRepo: "handoffkit",
31
+ baseGitSha: "b".repeat(40),
32
+ outputRoot,
33
+ ...overrides
34
+ };
35
+ }
36
+ async function readBody(req) {
37
+ const chunks = [];
38
+ for await (const chunk of req)
39
+ chunks.push(chunk);
40
+ return Buffer.concat(chunks);
41
+ }
42
+ async function closeServer(server) {
43
+ await new Promise((resolve, reject) => {
44
+ server.close((error) => (error ? reject(error) : resolve()));
45
+ });
46
+ }
47
+ async function startOpenAiCompatibleServer() {
48
+ const requests = [];
49
+ const server = createServer((req, res) => {
50
+ void (async () => {
51
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
52
+ if (req.method === "GET" && path === "/v1/models") {
53
+ res.writeHead(200, { "content-type": "application/json" });
54
+ res.end(JSON.stringify({ data: [{ id: "local-model" }] }));
55
+ return;
56
+ }
57
+ if (req.method === "POST" && path === "/v1/chat/completions") {
58
+ const body = JSON.parse((await readBody(req)).toString("utf8"));
59
+ requests.push(body);
60
+ const model = typeof body.model === "string" ? body.model : "local-model";
61
+ res.writeHead(200, { "content-type": "application/json" });
62
+ res.end(JSON.stringify({
63
+ id: "chatcmpl_test",
64
+ model,
65
+ choices: [{ message: { role: "assistant", content: "gateway-ok" } }],
66
+ usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }
67
+ }));
68
+ return;
69
+ }
70
+ res.writeHead(404, { "content-type": "application/json" });
71
+ res.end(JSON.stringify({ error: { message: "not found" } }));
72
+ })().catch((error) => {
73
+ res.writeHead(500, { "content-type": "application/json" });
74
+ res.end(JSON.stringify({ error: { message: String(error) } }));
75
+ });
76
+ });
77
+ await new Promise((resolve, reject) => {
78
+ server.once("error", reject);
79
+ server.listen(0, "127.0.0.1", () => {
80
+ server.off("error", reject);
81
+ resolve();
82
+ });
83
+ });
84
+ const address = server.address();
85
+ assert.ok(typeof address === "object" && address !== null);
86
+ return {
87
+ url: `http://127.0.0.1:${address.port}`,
88
+ requests,
89
+ close: () => closeServer(server)
90
+ };
91
+ }
92
+ test("codexConfigToml declares a Responses provider without requiring auth", () => {
93
+ const toml = codexConfigToml({
94
+ model: "local-model",
95
+ sandboxMode: "workspace-write",
96
+ approvalPolicy: "never",
97
+ provider: {
98
+ baseUrl: "http://127.0.0.1:9000",
99
+ requiresOpenAiAuth: false
100
+ }
101
+ });
102
+ assert.ok(toml.includes('model = "local-model"'));
103
+ assert.ok(toml.includes('model_provider = "fusionkit-codex"'));
104
+ assert.ok(toml.includes("[model_providers.fusionkit-codex]"));
105
+ assert.ok(toml.includes('base_url = "http://127.0.0.1:9000/v1"'));
106
+ assert.ok(toml.includes('wire_api = "responses"'));
107
+ assert.ok(toml.includes("requires_openai_auth = false"));
108
+ });
109
+ test("codex adapter skips clearly when credentials are absent", async () => {
110
+ const { outputRoot, cleanup } = tempOutputRoot();
111
+ const emptyCodexHome = mkdtempSync(join(tmpdir(), "ensemble-codex-empty-home-"));
112
+ let invoked = false;
113
+ const runner = () => {
114
+ invoked = true;
115
+ return { stdout: "", stderr: "", exitCode: 0 };
116
+ };
117
+ try {
118
+ const result = await ensemble.run(descriptor(outputRoot, {
119
+ harness: codexHarness({ env: { CODEX_HOME: emptyCodexHome }, runner })
120
+ }));
121
+ assert.equal(invoked, false);
122
+ assert.equal(result.harnessRunResult.status, "skipped");
123
+ assert.equal(result.candidates[0]?.status, "skipped");
124
+ assert.equal(result.candidates[0]?.error?.kind, "capability_missing");
125
+ assert.match(result.candidates[0]?.error?.message ?? "", /CODEX_API_KEY|OPENAI_API_KEY/);
126
+ }
127
+ finally {
128
+ cleanup();
129
+ rmSync(emptyCodexHome, { recursive: true, force: true });
130
+ }
131
+ });
132
+ test("codex adapter accepts local CLI auth without exported API keys", async () => {
133
+ const { outputRoot, cleanup } = tempOutputRoot();
134
+ const sourceHome = mkdtempSync(join(tmpdir(), "ensemble-codex-source-home-"));
135
+ writeFileSync(join(sourceHome, "auth.json"), "{\"auth\":\"redacted-test-token\"}\n");
136
+ let seenAuthFile = false;
137
+ const runner = (input) => {
138
+ const codexHome = input.env.CODEX_HOME;
139
+ assert.ok(codexHome);
140
+ assert.notEqual(codexHome, sourceHome);
141
+ assert.equal(input.env.CODEX_API_KEY, undefined);
142
+ assert.equal(input.env.OPENAI_API_KEY, undefined);
143
+ seenAuthFile = existsSync(join(codexHome, "auth.json"));
144
+ return { stdout: "codex local auth ok", stderr: "", exitCode: 0 };
145
+ };
146
+ try {
147
+ const result = await ensemble.run(descriptor(outputRoot, {
148
+ harness: codexHarness({ env: { CODEX_HOME: sourceHome }, runner })
149
+ }));
150
+ assert.equal(seenAuthFile, true);
151
+ assert.equal(result.harnessRunResult.status, "succeeded");
152
+ assert.equal(result.candidates[0]?.metadata?.provider_kind, "ambient");
153
+ }
154
+ finally {
155
+ cleanup();
156
+ rmSync(sourceHome, { recursive: true, force: true });
157
+ }
158
+ });
159
+ test("generic ensemble descriptor swaps mock harness for Codex harness", async () => {
160
+ const { outputRoot, cleanup } = tempOutputRoot();
161
+ let seenArgs;
162
+ let seenConfig = "";
163
+ const runner = (input) => {
164
+ seenArgs = input.args;
165
+ const codexHome = input.env.CODEX_HOME;
166
+ assert.ok(codexHome);
167
+ seenConfig = readFileSync(join(codexHome, "config.toml"), "utf8");
168
+ assert.equal(input.env.CODEX_API_KEY, "test-key");
169
+ return { stdout: '{"type":"message","message":"codex-ok"}\n', stderr: "", exitCode: 0 };
170
+ };
171
+ try {
172
+ const base = descriptor(outputRoot);
173
+ const mock = await ensemble.run(base);
174
+ const codex = await ensemble.run({
175
+ ...base,
176
+ harness: codexHarness({ env: { CODEX_API_KEY: "test-key" }, runner })
177
+ });
178
+ assert.equal(mock.harnessRunResult.status, "succeeded");
179
+ assert.equal(codex.harnessRunResult.status, "succeeded");
180
+ assert.deepEqual(seenArgs?.slice(0, 3), ["exec", "--json", "--skip-git-repo-check"]);
181
+ assert.equal(seenArgs?.at(-1), base.prompt);
182
+ assert.ok(seenConfig.includes('model = "gpt-5.1-codex-max"'));
183
+ assert.equal(codex.candidates[0]?.metadata?.provider_kind, "ambient");
184
+ }
185
+ finally {
186
+ cleanup();
187
+ }
188
+ });
189
+ test("defaultCodexRunner captures stdout/stderr and exit code from a real process", async () => {
190
+ const workdir = mkdtempSync(join(tmpdir(), "codex-runner-"));
191
+ const stubCli = join(workdir, "codex-stub");
192
+ writeFileSync(stubCli, '#!/bin/sh\necho "codex-stdout-ok"\necho "codex-stderr-ok" 1>&2\nexit 0\n');
193
+ chmodSync(stubCli, 0o755);
194
+ try {
195
+ const result = await defaultCodexRunner({
196
+ command: stubCli,
197
+ args: ["exec", "hello"],
198
+ cwd: workdir,
199
+ env: { PATH: process.env.PATH ?? "" },
200
+ timeoutMs: 10_000
201
+ });
202
+ assert.equal(result.exitCode, 0);
203
+ assert.match(result.stdout, /codex-stdout-ok/);
204
+ assert.match(result.stderr, /codex-stderr-ok/);
205
+ assert.notEqual(result.timedOut, true);
206
+ }
207
+ finally {
208
+ rmSync(workdir, { recursive: true, force: true });
209
+ }
210
+ });
211
+ test("defaultCodexRunner reports a non-zero exit code from the process", async () => {
212
+ const workdir = mkdtempSync(join(tmpdir(), "codex-runner-fail-"));
213
+ const stubCli = join(workdir, "codex-stub");
214
+ writeFileSync(stubCli, '#!/bin/sh\necho "boom" 1>&2\nexit 3\n');
215
+ chmodSync(stubCli, 0o755);
216
+ try {
217
+ const result = await defaultCodexRunner({
218
+ command: stubCli,
219
+ args: ["exec"],
220
+ cwd: workdir,
221
+ env: { PATH: process.env.PATH ?? "" }
222
+ });
223
+ assert.equal(result.exitCode, 3);
224
+ assert.match(result.stderr, /boom/);
225
+ }
226
+ finally {
227
+ rmSync(workdir, { recursive: true, force: true });
228
+ }
229
+ });
230
+ test("Codex OpenAI-compatible provider goes through Responses gateway records", async () => {
231
+ const { outputRoot, cleanup } = tempOutputRoot();
232
+ const upstream = await startOpenAiCompatibleServer();
233
+ let gatewayBaseUrl;
234
+ const runner = async (input) => {
235
+ const codexHome = input.env.CODEX_HOME;
236
+ assert.ok(codexHome);
237
+ const config = readFileSync(join(codexHome, "config.toml"), "utf8");
238
+ const match = /base_url = "([^"]+)"/.exec(config);
239
+ assert.ok(match);
240
+ gatewayBaseUrl = match[1];
241
+ assert.ok(gatewayBaseUrl);
242
+ const response = await fetch(`${gatewayBaseUrl}/responses`, {
243
+ method: "POST",
244
+ headers: { "content-type": "application/json" },
245
+ body: JSON.stringify({
246
+ input: "hello from fake codex",
247
+ stream: false
248
+ })
249
+ });
250
+ assert.equal(response.status, 200);
251
+ return { stdout: "codex gateway ok", stderr: "", exitCode: 0 };
252
+ };
253
+ try {
254
+ const result = await ensemble.run(descriptor(outputRoot, {
255
+ harness: codexHarness({
256
+ env: {},
257
+ provider: {
258
+ kind: "openai-compatible",
259
+ baseUrl: `${upstream.url}/v1`,
260
+ defaultModel: "local-model"
261
+ },
262
+ runner
263
+ })
264
+ }));
265
+ assert.match(gatewayBaseUrl ?? "", /^http:\/\/127\.0\.0\.1:\d+\/v1$/);
266
+ assert.equal(upstream.requests.length, 1);
267
+ assert.equal(result.harnessRunResult.status, "succeeded");
268
+ assert.equal(result.modelCallRecords.length, 1);
269
+ assert.equal(result.modelCallRecords[0]?.metadata?.dialect, "openai-responses");
270
+ assert.equal(result.modelCallRecords[0]?.model, "local-model");
271
+ assert.equal(result.candidates[0]?.metadata?.model_call_count, 1);
272
+ }
273
+ finally {
274
+ await upstream.close();
275
+ cleanup();
276
+ }
277
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@fusionkit/tool-codex",
3
+ "private": false,
4
+ "version": "0.1.6",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/velum-labs/handoffkit.git",
8
+ "directory": "packages/tool-codex"
9
+ },
10
+ "description": "Codex CLI tool integration for fusionkit: launcher config shim plus the ensemble Codex harness adapter.",
11
+ "license": "UNLICENSED",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org",
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "dependencies": {
28
+ "@fusionkit/ensemble": "0.1.6",
29
+ "@fusionkit/protocol": "0.1.6",
30
+ "@fusionkit/tools": "0.1.6",
31
+ "@fusionkit/model-gateway": "0.1.6"
32
+ }
33
+ }