@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.
- package/dist/harness.d.ts +70 -0
- package/dist/harness.js +436 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +61 -0
- package/dist/launch.d.ts +9 -0
- package/dist/launch.js +33 -0
- package/dist/test/codex.test.d.ts +1 -0
- package/dist/test/codex.test.js +277 -0
- package/package.json +33 -0
|
@@ -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;
|
package/dist/harness.js
ADDED
|
@@ -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;
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/launch.d.ts
ADDED
|
@@ -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
|
+
}
|