@ai-sdk/harness-codex 0.0.0 → 1.0.0-beta.11
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/CHANGELOG.md +95 -0
- package/LICENSE +13 -0
- package/README.md +73 -0
- package/dist/bridge/host-tool-mcp.mjs +105 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +924 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +12 -0
- package/dist/bridge/pnpm-lock.yaml +879 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +848 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -1
- package/src/bridge/cli-relay.ts +114 -0
- package/src/bridge/host-tool-mcp.ts +163 -0
- package/src/bridge/index.ts +683 -0
- package/src/bridge/package.json +12 -0
- package/src/bridge/pnpm-lock.yaml +879 -0
- package/src/codex-auth.ts +100 -0
- package/src/codex-bridge-protocol.ts +39 -0
- package/src/codex-harness.ts +1098 -0
- package/src/index.ts +12 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
// src/codex-harness.ts
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import {
|
|
7
|
+
commonTool,
|
|
8
|
+
HarnessCapabilityUnsupportedError,
|
|
9
|
+
harnessV1DiagnosticFromBridgeFrame
|
|
10
|
+
} from "@ai-sdk/harness";
|
|
11
|
+
import {
|
|
12
|
+
classifyDiskLog,
|
|
13
|
+
markBridgeStarting,
|
|
14
|
+
SandboxChannel,
|
|
15
|
+
waitForBridgeReady
|
|
16
|
+
} from "@ai-sdk/harness/utils";
|
|
17
|
+
import { WebSocket } from "ws";
|
|
18
|
+
import { z as z2 } from "zod";
|
|
19
|
+
|
|
20
|
+
// src/codex-auth.ts
|
|
21
|
+
function resolveCodexEnv(auth, processEnv = process.env) {
|
|
22
|
+
if (auth?.openaiCompatible) {
|
|
23
|
+
return pickOpenAICompatible(auth.openaiCompatible, processEnv);
|
|
24
|
+
}
|
|
25
|
+
if (auth?.openai) {
|
|
26
|
+
return pickOpenAI({ explicit: auth.openai, processEnv });
|
|
27
|
+
}
|
|
28
|
+
if (auth?.gateway) {
|
|
29
|
+
return pickGateway(auth.gateway, processEnv);
|
|
30
|
+
}
|
|
31
|
+
if (processEnv.AI_GATEWAY_API_KEY) {
|
|
32
|
+
return pickGateway({}, processEnv);
|
|
33
|
+
}
|
|
34
|
+
return pickOpenAI({ processEnv });
|
|
35
|
+
}
|
|
36
|
+
function pickOpenAICompatible(explicit, processEnv) {
|
|
37
|
+
const env = {};
|
|
38
|
+
const apiKey = explicit.apiKey ?? processEnv.OPENAI_API_KEY ?? processEnv.CODEX_API_KEY;
|
|
39
|
+
if (apiKey) env.CODEX_API_KEY = apiKey;
|
|
40
|
+
if (explicit.baseUrl) env.OPENAI_BASE_URL = explicit.baseUrl;
|
|
41
|
+
if (explicit.modelProviderName)
|
|
42
|
+
env.CODEX_MODEL_PROVIDER_NAME = explicit.modelProviderName;
|
|
43
|
+
if (explicit.queryParamsJson)
|
|
44
|
+
env.OPENAI_QUERY_PARAMS_JSON = explicit.queryParamsJson;
|
|
45
|
+
return env;
|
|
46
|
+
}
|
|
47
|
+
function pickOpenAI({
|
|
48
|
+
explicit,
|
|
49
|
+
processEnv
|
|
50
|
+
}) {
|
|
51
|
+
const env = {};
|
|
52
|
+
const apiKey = explicit?.apiKey ?? processEnv.OPENAI_API_KEY ?? processEnv.CODEX_API_KEY;
|
|
53
|
+
if (apiKey) env.CODEX_API_KEY = apiKey;
|
|
54
|
+
const baseUrl = explicit?.baseUrl ?? processEnv.OPENAI_BASE_URL;
|
|
55
|
+
if (baseUrl) env.OPENAI_BASE_URL = baseUrl;
|
|
56
|
+
const organization = explicit?.organization ?? processEnv.OPENAI_ORGANIZATION;
|
|
57
|
+
if (organization) env.OPENAI_ORGANIZATION = organization;
|
|
58
|
+
const project = explicit?.project ?? processEnv.OPENAI_PROJECT;
|
|
59
|
+
if (project) env.OPENAI_PROJECT = project;
|
|
60
|
+
return env;
|
|
61
|
+
}
|
|
62
|
+
function pickGateway(explicit, processEnv) {
|
|
63
|
+
const apiKey = explicit.apiKey ?? processEnv.AI_GATEWAY_API_KEY;
|
|
64
|
+
const baseUrl = explicit.baseUrl ?? processEnv.AI_GATEWAY_BASE_URL ?? "https://ai-gateway.vercel.sh/v1";
|
|
65
|
+
const env = {};
|
|
66
|
+
if (apiKey) {
|
|
67
|
+
env.AI_GATEWAY_API_KEY = apiKey;
|
|
68
|
+
env.CODEX_API_KEY = apiKey;
|
|
69
|
+
}
|
|
70
|
+
env.AI_GATEWAY_BASE_URL = baseUrl;
|
|
71
|
+
return env;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/codex-bridge-protocol.ts
|
|
75
|
+
import {
|
|
76
|
+
harnessV1BridgeInboundCommandSchemas,
|
|
77
|
+
harnessV1BridgeOutboundMessageSchema,
|
|
78
|
+
harnessV1BridgeReadySchema,
|
|
79
|
+
harnessV1BridgeStartBaseSchema
|
|
80
|
+
} from "@ai-sdk/harness";
|
|
81
|
+
import { z } from "zod/v4";
|
|
82
|
+
var outboundMessageSchema = harnessV1BridgeOutboundMessageSchema;
|
|
83
|
+
var startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
|
|
84
|
+
instructions: z.string().optional(),
|
|
85
|
+
reasoningEffort: z.enum(["low", "medium", "high"]).optional(),
|
|
86
|
+
webSearch: z.boolean().optional(),
|
|
87
|
+
// Resume signal. When supplied, the bridge calls
|
|
88
|
+
// `codex.resumeThread(resumeThreadId, …)` instead of starting a fresh thread.
|
|
89
|
+
// The host sources the id from lifecycle state `data` cached from a prior
|
|
90
|
+
// `agent.detach`.
|
|
91
|
+
resumeThreadId: z.string().optional()
|
|
92
|
+
});
|
|
93
|
+
var inboundMessageSchema = z.discriminatedUnion("type", [
|
|
94
|
+
startMessageSchema,
|
|
95
|
+
...harnessV1BridgeInboundCommandSchemas
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// src/codex-harness.ts
|
|
99
|
+
var DEFAULT_CODEX_MODEL = "gpt-5.3-codex";
|
|
100
|
+
var CODEX_BUILTIN_TOOLS = {
|
|
101
|
+
bash: commonTool("bash", {
|
|
102
|
+
nativeName: "shell",
|
|
103
|
+
toolUseKind: "bash",
|
|
104
|
+
description: "Execute a shell command",
|
|
105
|
+
inputSchema: z2.object({ command: z2.string() })
|
|
106
|
+
}),
|
|
107
|
+
webSearch: commonTool("webSearch", {
|
|
108
|
+
nativeName: "web_search",
|
|
109
|
+
toolUseKind: "readonly",
|
|
110
|
+
description: "Search the web",
|
|
111
|
+
inputSchema: z2.object({ query: z2.string() })
|
|
112
|
+
})
|
|
113
|
+
};
|
|
114
|
+
var BOOTSTRAP_DIR = "/tmp/harness/codex";
|
|
115
|
+
var bridgeCoordsSchema = z2.object({
|
|
116
|
+
port: z2.number(),
|
|
117
|
+
token: z2.string(),
|
|
118
|
+
lastSeenEventId: z2.number(),
|
|
119
|
+
sandboxId: z2.string().optional()
|
|
120
|
+
});
|
|
121
|
+
var codexResumeStateSchema = z2.object({
|
|
122
|
+
threadId: z2.string().optional(),
|
|
123
|
+
bridge: bridgeCoordsSchema.optional()
|
|
124
|
+
});
|
|
125
|
+
function createCodex(settings = {}) {
|
|
126
|
+
let cachedBootstrap;
|
|
127
|
+
return {
|
|
128
|
+
specificationVersion: "harness-v1",
|
|
129
|
+
harnessId: "codex",
|
|
130
|
+
builtinTools: CODEX_BUILTIN_TOOLS,
|
|
131
|
+
supportsBuiltinToolApprovals: false,
|
|
132
|
+
lifecycleStateSchema: codexResumeStateSchema,
|
|
133
|
+
getBootstrap: async () => {
|
|
134
|
+
if (cachedBootstrap != null) return cachedBootstrap;
|
|
135
|
+
const [pkg, lock, bridge, hostToolMcp] = await Promise.all([
|
|
136
|
+
readBridgeAsset("package.json"),
|
|
137
|
+
readBridgeAsset("pnpm-lock.yaml"),
|
|
138
|
+
readBridgeAsset("index.mjs"),
|
|
139
|
+
readBridgeAsset("host-tool-mcp.mjs")
|
|
140
|
+
]);
|
|
141
|
+
cachedBootstrap = {
|
|
142
|
+
harnessId: "codex",
|
|
143
|
+
bootstrapDir: BOOTSTRAP_DIR,
|
|
144
|
+
files: [
|
|
145
|
+
{ path: `${BOOTSTRAP_DIR}/package.json`, content: pkg },
|
|
146
|
+
{ path: `${BOOTSTRAP_DIR}/pnpm-lock.yaml`, content: lock },
|
|
147
|
+
{ path: `${BOOTSTRAP_DIR}/bridge.mjs`, content: bridge },
|
|
148
|
+
{
|
|
149
|
+
path: `${BOOTSTRAP_DIR}/host-tool-mcp.mjs`,
|
|
150
|
+
content: hostToolMcp
|
|
151
|
+
}
|
|
152
|
+
],
|
|
153
|
+
commands: [
|
|
154
|
+
{ command: `mkdir -p ${BOOTSTRAP_DIR}` },
|
|
155
|
+
{
|
|
156
|
+
command: `pnpm --dir ${BOOTSTRAP_DIR} install --frozen-lockfile --store-dir ${BOOTSTRAP_DIR}/.pnpm-store`
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
};
|
|
160
|
+
return cachedBootstrap;
|
|
161
|
+
},
|
|
162
|
+
doStart: async (startOpts) => {
|
|
163
|
+
if (startOpts.permissionMode != null && startOpts.permissionMode !== "allow-all") {
|
|
164
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
165
|
+
message: "Harness 'codex' does not support built-in tool approval requests; use permissionMode: 'allow-all'.",
|
|
166
|
+
harnessId: "codex"
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const sandboxSession = startOpts.sandboxSession;
|
|
170
|
+
const session = sandboxSession.restricted();
|
|
171
|
+
const sandboxId = sandboxSession.id;
|
|
172
|
+
const lifecycleState = startOpts.continueFrom ?? startOpts.resumeFrom;
|
|
173
|
+
const isResume = lifecycleState != null;
|
|
174
|
+
const isContinue = startOpts.continueFrom != null;
|
|
175
|
+
const resumeData = isResume && typeof lifecycleState?.data === "object" ? lifecycleState.data : void 0;
|
|
176
|
+
const resumeThreadId = resumeData?.threadId;
|
|
177
|
+
const resumeThreadIdString = typeof resumeThreadId === "string" && resumeThreadId.length > 0 ? resumeThreadId : void 0;
|
|
178
|
+
const coords = resumeData?.bridge;
|
|
179
|
+
const workDir = startOpts.sessionWorkDir;
|
|
180
|
+
const sessionDataDir = `${sandboxSession.defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`;
|
|
181
|
+
const bridgeStateDir = `${sessionDataDir}/bridge`;
|
|
182
|
+
const timeoutMs = settings.startupTimeoutMs ?? 12e4;
|
|
183
|
+
const report = startOpts.observability?.report;
|
|
184
|
+
const onDiagnostic = report ? (frame) => report(
|
|
185
|
+
harnessV1DiagnosticFromBridgeFrame(frame, {
|
|
186
|
+
sessionId: startOpts.sessionId,
|
|
187
|
+
timestamp: Date.now()
|
|
188
|
+
})
|
|
189
|
+
) : void 0;
|
|
190
|
+
if (coords) {
|
|
191
|
+
try {
|
|
192
|
+
const attachUrl = await sandboxSession.getPortUrl({
|
|
193
|
+
port: coords.port,
|
|
194
|
+
protocol: "ws"
|
|
195
|
+
}) + `?agent_bridge_token=${encodeURIComponent(coords.token)}`;
|
|
196
|
+
const attachChannel = new SandboxChannel({
|
|
197
|
+
connect: () => openWebSocket(attachUrl),
|
|
198
|
+
outboundSchema: outboundMessageSchema,
|
|
199
|
+
initialLastSeenEventId: coords.lastSeenEventId,
|
|
200
|
+
onDiagnostic
|
|
201
|
+
});
|
|
202
|
+
await attachChannel.open(isContinue ? { resume: true } : void 0);
|
|
203
|
+
return createSession({
|
|
204
|
+
sessionId: startOpts.sessionId,
|
|
205
|
+
channel: attachChannel,
|
|
206
|
+
// The live bridge was spawned by another process; no process handle.
|
|
207
|
+
proc: void 0,
|
|
208
|
+
model: settings.model ?? DEFAULT_CODEX_MODEL,
|
|
209
|
+
reasoningEffort: settings.reasoningEffort,
|
|
210
|
+
webSearch: settings.webSearch,
|
|
211
|
+
resumeThreadId: resumeThreadIdString,
|
|
212
|
+
isResume: true,
|
|
213
|
+
seedResumeThreadOnFirstPrompt: false,
|
|
214
|
+
rerunContinue: false,
|
|
215
|
+
bridgePort: coords.port,
|
|
216
|
+
bridgeToken: coords.token,
|
|
217
|
+
sandboxId,
|
|
218
|
+
debug: startOpts.observability?.debug,
|
|
219
|
+
permissionMode: startOpts.permissionMode
|
|
220
|
+
});
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
let respawnStrategy = isResume ? "rerun" : void 0;
|
|
225
|
+
if (coords && isContinue) {
|
|
226
|
+
const logRaw = await Promise.resolve(
|
|
227
|
+
session.readTextFile({
|
|
228
|
+
path: `${bridgeStateDir}/event-log.ndjson`,
|
|
229
|
+
abortSignal: startOpts.abortSignal
|
|
230
|
+
})
|
|
231
|
+
).catch(() => null);
|
|
232
|
+
if (await classifyDiskLog(logRaw) === "replay") {
|
|
233
|
+
respawnStrategy = "replay";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const port = resolveBridgePort(sandboxSession, settings.port);
|
|
237
|
+
const token = randomBytes(32).toString("hex");
|
|
238
|
+
const codexSkillSetup = startOpts.skills && startOpts.skills.length > 0 ? await writeSkills({
|
|
239
|
+
sandbox: session,
|
|
240
|
+
skills: startOpts.skills,
|
|
241
|
+
abortSignal: startOpts.abortSignal
|
|
242
|
+
}) : void 0;
|
|
243
|
+
const env = {
|
|
244
|
+
...resolveCodexEnv(settings.auth),
|
|
245
|
+
BRIDGE_CHANNEL_TOKEN: token,
|
|
246
|
+
BRIDGE_WS_PORT: String(port),
|
|
247
|
+
...codexSkillSetup ? {
|
|
248
|
+
HOME: codexSkillSetup.homeDir,
|
|
249
|
+
CODEX_HOME: codexSkillSetup.codexHomeDir
|
|
250
|
+
} : {},
|
|
251
|
+
...respawnStrategy === "replay" ? { BRIDGE_REPLAY_FROM_DISK: "1" } : {}
|
|
252
|
+
};
|
|
253
|
+
if (respawnStrategy === void 0) {
|
|
254
|
+
await session.run({
|
|
255
|
+
command: `mkdir -p ${workDir} ${bridgeStateDir}`,
|
|
256
|
+
abortSignal: startOpts.abortSignal
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
await markBridgeStarting({
|
|
260
|
+
sandbox: session,
|
|
261
|
+
bridgeStateDir,
|
|
262
|
+
bridgeType: "codex",
|
|
263
|
+
abortSignal: startOpts.abortSignal
|
|
264
|
+
});
|
|
265
|
+
const proc = await session.spawn({
|
|
266
|
+
command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${workDir} --bridge-state-dir ${bridgeStateDir} --bootstrap-dir ${BOOTSTRAP_DIR}`,
|
|
267
|
+
env,
|
|
268
|
+
abortSignal: startOpts.abortSignal
|
|
269
|
+
});
|
|
270
|
+
const { port: boundPort } = await waitForBridgeReady({
|
|
271
|
+
proc,
|
|
272
|
+
sandbox: session,
|
|
273
|
+
bridgeStateDir,
|
|
274
|
+
bridgeType: "codex",
|
|
275
|
+
timeoutMs,
|
|
276
|
+
abortSignal: startOpts.abortSignal,
|
|
277
|
+
createTimeoutError: () => new Error("codex bridge did not become ready in time."),
|
|
278
|
+
createExitError: () => new Error("codex bridge exited before becoming ready.")
|
|
279
|
+
});
|
|
280
|
+
void drainRest(proc.stdout);
|
|
281
|
+
void forwardBridgeStderr(proc.stderr);
|
|
282
|
+
const wsUrl = await sandboxSession.getPortUrl({
|
|
283
|
+
port: boundPort,
|
|
284
|
+
protocol: "ws"
|
|
285
|
+
}) + `?agent_bridge_token=${encodeURIComponent(token)}`;
|
|
286
|
+
const channel = new SandboxChannel({
|
|
287
|
+
connect: () => openWebSocket(wsUrl),
|
|
288
|
+
outboundSchema: outboundMessageSchema,
|
|
289
|
+
onDiagnostic,
|
|
290
|
+
// In replay mode the respawned bridge reloaded the finished turn from
|
|
291
|
+
// disk; seed the cursor and resume so it streams the tail (incl.
|
|
292
|
+
// `finish`).
|
|
293
|
+
...respawnStrategy === "replay" ? { initialLastSeenEventId: coords?.lastSeenEventId ?? 0 } : {}
|
|
294
|
+
});
|
|
295
|
+
await channel.open(
|
|
296
|
+
respawnStrategy === "replay" ? { resume: true } : void 0
|
|
297
|
+
);
|
|
298
|
+
return createSession({
|
|
299
|
+
sessionId: startOpts.sessionId,
|
|
300
|
+
channel,
|
|
301
|
+
proc,
|
|
302
|
+
model: settings.model ?? DEFAULT_CODEX_MODEL,
|
|
303
|
+
reasoningEffort: settings.reasoningEffort,
|
|
304
|
+
webSearch: settings.webSearch,
|
|
305
|
+
resumeThreadId: resumeThreadIdString,
|
|
306
|
+
isResume: respawnStrategy !== void 0,
|
|
307
|
+
seedResumeThreadOnFirstPrompt: respawnStrategy !== void 0,
|
|
308
|
+
rerunContinue: respawnStrategy === "rerun",
|
|
309
|
+
bridgePort: boundPort,
|
|
310
|
+
bridgeToken: token,
|
|
311
|
+
sandboxId,
|
|
312
|
+
debug: startOpts.observability?.debug,
|
|
313
|
+
permissionMode: startOpts.permissionMode
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function resolveBridgePort(sandboxSession, override) {
|
|
319
|
+
if (override !== void 0) return override;
|
|
320
|
+
if (sandboxSession.ports.length > 0) return sandboxSession.ports[0];
|
|
321
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
322
|
+
harnessId: "codex",
|
|
323
|
+
message: "The codex harness needs a TCP port exposed by the sandbox. Create the sandbox with `ports: [<port>]` or pass `createCodex({ port })`."
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
async function readBridgeAsset(name) {
|
|
327
|
+
const candidates = [
|
|
328
|
+
new URL(`./bridge/${name}`, import.meta.url),
|
|
329
|
+
new URL(`../bridge/${name}`, import.meta.url)
|
|
330
|
+
];
|
|
331
|
+
let lastErr;
|
|
332
|
+
for (const url of candidates) {
|
|
333
|
+
try {
|
|
334
|
+
return await readFile(fileURLToPath(url), "utf8");
|
|
335
|
+
} catch (err) {
|
|
336
|
+
const code = err.code;
|
|
337
|
+
if (code !== "ENOENT") throw err;
|
|
338
|
+
lastErr = err;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
throw lastErr ?? new Error(`bridge asset not found: ${name}`);
|
|
342
|
+
}
|
|
343
|
+
async function writeSkills({
|
|
344
|
+
sandbox,
|
|
345
|
+
skills,
|
|
346
|
+
abortSignal
|
|
347
|
+
}) {
|
|
348
|
+
for (const skill of skills) {
|
|
349
|
+
safeCodexSkillName(skill.name);
|
|
350
|
+
for (const file of skill.files ?? []) {
|
|
351
|
+
safeCodexSkillFilePath({
|
|
352
|
+
skillName: skill.name,
|
|
353
|
+
filePath: file.path
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const homeDir = await resolveSandboxHomeDir({ sandbox, abortSignal });
|
|
358
|
+
const codexHomeDir = path.posix.join(homeDir, ".codex");
|
|
359
|
+
await sandbox.run({
|
|
360
|
+
command: `mkdir -p ${shellQuote(codexHomeDir)}`,
|
|
361
|
+
abortSignal
|
|
362
|
+
});
|
|
363
|
+
const rootDir = path.posix.join(homeDir, ".agents", "skills");
|
|
364
|
+
await sandbox.run({
|
|
365
|
+
command: `mkdir -p ${shellQuote(rootDir)}`,
|
|
366
|
+
abortSignal
|
|
367
|
+
});
|
|
368
|
+
for (const skill of skills) {
|
|
369
|
+
const name = safeCodexSkillName(skill.name);
|
|
370
|
+
const skillDir = path.posix.join(rootDir, name);
|
|
371
|
+
const content = `---
|
|
372
|
+
name: ${skill.name}
|
|
373
|
+
description: ${skill.description}
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
${skill.content}`;
|
|
377
|
+
await sandbox.writeTextFile({
|
|
378
|
+
path: path.posix.join(skillDir, "SKILL.md"),
|
|
379
|
+
content,
|
|
380
|
+
abortSignal
|
|
381
|
+
});
|
|
382
|
+
for (const file of skill.files ?? []) {
|
|
383
|
+
const filePath = safeCodexSkillFilePath({
|
|
384
|
+
skillName: skill.name,
|
|
385
|
+
filePath: file.path
|
|
386
|
+
});
|
|
387
|
+
await sandbox.writeTextFile({
|
|
388
|
+
path: path.posix.join(skillDir, filePath),
|
|
389
|
+
content: file.content,
|
|
390
|
+
abortSignal
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
homeDir,
|
|
396
|
+
codexHomeDir
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
async function resolveSandboxHomeDir({
|
|
400
|
+
sandbox,
|
|
401
|
+
abortSignal
|
|
402
|
+
}) {
|
|
403
|
+
const result = await sandbox.run({
|
|
404
|
+
command: 'printf "%s" "$HOME"',
|
|
405
|
+
abortSignal
|
|
406
|
+
});
|
|
407
|
+
const homeDir = result.stdout.trim();
|
|
408
|
+
if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
return homeDir;
|
|
414
|
+
}
|
|
415
|
+
function safeCodexSkillName(name) {
|
|
416
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === "." || name === "..") {
|
|
417
|
+
throw new Error(`Invalid Codex skill name: ${name}`);
|
|
418
|
+
}
|
|
419
|
+
return name;
|
|
420
|
+
}
|
|
421
|
+
function safeCodexSkillFilePath({
|
|
422
|
+
skillName,
|
|
423
|
+
filePath
|
|
424
|
+
}) {
|
|
425
|
+
const normalized = path.posix.normalize(filePath);
|
|
426
|
+
if (normalized === "." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`Invalid Codex skill file path for ${skillName}: ${filePath}`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return normalized;
|
|
432
|
+
}
|
|
433
|
+
function shellQuote(value) {
|
|
434
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
435
|
+
}
|
|
436
|
+
async function forwardBridgeStderr(stream) {
|
|
437
|
+
try {
|
|
438
|
+
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
439
|
+
while (true) {
|
|
440
|
+
const { value, done } = await reader.read();
|
|
441
|
+
if (done) return;
|
|
442
|
+
if (value) {
|
|
443
|
+
const trimmed = value.endsWith("\n") ? value.slice(0, -1) : value;
|
|
444
|
+
if (trimmed.length > 0) {
|
|
445
|
+
console.log(`[bridge stderr] ${trimmed}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function drainRest(stream) {
|
|
453
|
+
try {
|
|
454
|
+
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
455
|
+
while (true) {
|
|
456
|
+
const { done } = await reader.read();
|
|
457
|
+
if (done) return;
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function openWebSocket(url) {
|
|
463
|
+
return new Promise((resolve, reject) => {
|
|
464
|
+
const ws = new WebSocket(url);
|
|
465
|
+
const onOpen = () => {
|
|
466
|
+
ws.off("error", onError);
|
|
467
|
+
resolve(ws);
|
|
468
|
+
};
|
|
469
|
+
const onError = (err) => {
|
|
470
|
+
ws.off("open", onOpen);
|
|
471
|
+
reject(err);
|
|
472
|
+
};
|
|
473
|
+
ws.once("open", onOpen);
|
|
474
|
+
ws.once("error", onError);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function createSession({
|
|
478
|
+
sessionId,
|
|
479
|
+
channel,
|
|
480
|
+
proc,
|
|
481
|
+
model,
|
|
482
|
+
reasoningEffort,
|
|
483
|
+
webSearch,
|
|
484
|
+
resumeThreadId,
|
|
485
|
+
isResume,
|
|
486
|
+
seedResumeThreadOnFirstPrompt,
|
|
487
|
+
rerunContinue,
|
|
488
|
+
bridgePort,
|
|
489
|
+
bridgeToken,
|
|
490
|
+
sandboxId,
|
|
491
|
+
debug,
|
|
492
|
+
permissionMode
|
|
493
|
+
}) {
|
|
494
|
+
let stopped = false;
|
|
495
|
+
let stopPromise;
|
|
496
|
+
let pendingResumeThreadId = seedResumeThreadOnFirstPrompt ? resumeThreadId : void 0;
|
|
497
|
+
let instructionsApplied = isResume;
|
|
498
|
+
let latestThreadId = resumeThreadId;
|
|
499
|
+
channel.on("bridge-thread", (msg) => {
|
|
500
|
+
latestThreadId = msg.threadId;
|
|
501
|
+
});
|
|
502
|
+
const wireTurn = (turnOpts) => {
|
|
503
|
+
let pendingResolve;
|
|
504
|
+
let pendingReject;
|
|
505
|
+
const done = new Promise((resolve, reject) => {
|
|
506
|
+
pendingResolve = resolve;
|
|
507
|
+
pendingReject = reject;
|
|
508
|
+
});
|
|
509
|
+
const unsubs = [];
|
|
510
|
+
const forward = (event) => {
|
|
511
|
+
try {
|
|
512
|
+
turnOpts.emit(event);
|
|
513
|
+
} catch {
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const eventTypes = [
|
|
517
|
+
"stream-start",
|
|
518
|
+
"text-start",
|
|
519
|
+
"text-delta",
|
|
520
|
+
"text-end",
|
|
521
|
+
"reasoning-start",
|
|
522
|
+
"reasoning-delta",
|
|
523
|
+
"reasoning-end",
|
|
524
|
+
"tool-call",
|
|
525
|
+
"tool-approval-request",
|
|
526
|
+
"tool-result",
|
|
527
|
+
"file-change",
|
|
528
|
+
"finish-step",
|
|
529
|
+
"raw"
|
|
530
|
+
];
|
|
531
|
+
let isSettled = false;
|
|
532
|
+
const settleSuccess = () => {
|
|
533
|
+
if (isSettled) return;
|
|
534
|
+
isSettled = true;
|
|
535
|
+
for (const u of unsubs) u();
|
|
536
|
+
pendingResolve();
|
|
537
|
+
};
|
|
538
|
+
const settleError = (err) => {
|
|
539
|
+
if (isSettled) return;
|
|
540
|
+
isSettled = true;
|
|
541
|
+
for (const u of unsubs) u();
|
|
542
|
+
pendingReject(err);
|
|
543
|
+
};
|
|
544
|
+
for (const type of eventTypes) {
|
|
545
|
+
unsubs.push(
|
|
546
|
+
channel.on(type, (msg) => {
|
|
547
|
+
forward(msg);
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
unsubs.push(
|
|
552
|
+
channel.on("finish", (msg) => {
|
|
553
|
+
forward(msg);
|
|
554
|
+
settleSuccess();
|
|
555
|
+
})
|
|
556
|
+
);
|
|
557
|
+
unsubs.push(
|
|
558
|
+
channel.on("error", (msg) => {
|
|
559
|
+
forward(msg);
|
|
560
|
+
settleError(msg.error);
|
|
561
|
+
})
|
|
562
|
+
);
|
|
563
|
+
const onClose = (_code, reason) => {
|
|
564
|
+
if (isSettled) return;
|
|
565
|
+
if (reason === "suspended") {
|
|
566
|
+
settleSuccess();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
settleError(new Error("codex bridge closed before the turn finished."));
|
|
570
|
+
};
|
|
571
|
+
channel.onClose(onClose);
|
|
572
|
+
const onAbort = () => {
|
|
573
|
+
if (isSettled) return;
|
|
574
|
+
try {
|
|
575
|
+
channel.send({ type: "abort" });
|
|
576
|
+
} catch {
|
|
577
|
+
}
|
|
578
|
+
settleError(
|
|
579
|
+
turnOpts.abortSignal?.reason ?? new DOMException("Aborted", "AbortError")
|
|
580
|
+
);
|
|
581
|
+
};
|
|
582
|
+
if (turnOpts.abortSignal) {
|
|
583
|
+
if (turnOpts.abortSignal.aborted) {
|
|
584
|
+
onAbort();
|
|
585
|
+
} else {
|
|
586
|
+
turnOpts.abortSignal.addEventListener("abort", onAbort, {
|
|
587
|
+
once: true
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
submitToolResult: async (input) => {
|
|
593
|
+
channel.send({
|
|
594
|
+
type: "tool-result",
|
|
595
|
+
toolCallId: input.toolCallId,
|
|
596
|
+
output: input.output,
|
|
597
|
+
isError: input.isError
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
submitToolApproval: async (input) => {
|
|
601
|
+
channel.send({
|
|
602
|
+
type: "tool-approval-response",
|
|
603
|
+
approvalId: input.approvalId,
|
|
604
|
+
approved: input.approved,
|
|
605
|
+
reason: input.reason
|
|
606
|
+
});
|
|
607
|
+
},
|
|
608
|
+
submitUserMessage: async (text) => {
|
|
609
|
+
channel.send({ type: "user-message", text });
|
|
610
|
+
},
|
|
611
|
+
done
|
|
612
|
+
};
|
|
613
|
+
};
|
|
614
|
+
return {
|
|
615
|
+
sessionId,
|
|
616
|
+
isResume,
|
|
617
|
+
modelId: model,
|
|
618
|
+
doPromptTurn: async (promptOpts) => {
|
|
619
|
+
const control = wireTurn({
|
|
620
|
+
emit: promptOpts.emit,
|
|
621
|
+
abortSignal: promptOpts.abortSignal
|
|
622
|
+
});
|
|
623
|
+
const applyInstructions = !instructionsApplied && !!promptOpts.instructions;
|
|
624
|
+
instructionsApplied = true;
|
|
625
|
+
const startMessage = {
|
|
626
|
+
type: "start",
|
|
627
|
+
prompt: extractUserText(promptOpts.prompt),
|
|
628
|
+
...applyInstructions ? { instructions: promptOpts.instructions } : {},
|
|
629
|
+
tools: (promptOpts.tools ?? []).map((t) => ({
|
|
630
|
+
name: t.name,
|
|
631
|
+
description: t.description,
|
|
632
|
+
inputSchema: t.inputSchema
|
|
633
|
+
})),
|
|
634
|
+
model,
|
|
635
|
+
reasoningEffort,
|
|
636
|
+
webSearch,
|
|
637
|
+
...permissionMode ? { permissionMode } : {},
|
|
638
|
+
...pendingResumeThreadId ? { resumeThreadId: pendingResumeThreadId } : {},
|
|
639
|
+
...debug ? { debug } : {}
|
|
640
|
+
};
|
|
641
|
+
pendingResumeThreadId = void 0;
|
|
642
|
+
channel.send(startMessage);
|
|
643
|
+
return control;
|
|
644
|
+
},
|
|
645
|
+
doContinueTurn: async (continueOpts) => {
|
|
646
|
+
const control = wireTurn({
|
|
647
|
+
emit: continueOpts.emit,
|
|
648
|
+
abortSignal: continueOpts.abortSignal
|
|
649
|
+
});
|
|
650
|
+
if (rerunContinue) {
|
|
651
|
+
const threadId = pendingResumeThreadId ?? latestThreadId;
|
|
652
|
+
pendingResumeThreadId = void 0;
|
|
653
|
+
channel.send({
|
|
654
|
+
type: "start",
|
|
655
|
+
/*
|
|
656
|
+
* A continuation nudge rather than an empty prompt: `resumeThreadId`
|
|
657
|
+
* rehydrates the prior thread, and this is the new user turn that
|
|
658
|
+
* drives it forward. Keeping it non-empty avoids handing the runtime
|
|
659
|
+
* an empty user message (and mirrors the claude-code adapter, where an
|
|
660
|
+
* empty text block trips the Anthropic API's `cache_control` rule).
|
|
661
|
+
*/
|
|
662
|
+
prompt: "Continue.",
|
|
663
|
+
tools: (continueOpts.tools ?? []).map((t) => ({
|
|
664
|
+
name: t.name,
|
|
665
|
+
description: t.description,
|
|
666
|
+
inputSchema: t.inputSchema
|
|
667
|
+
})),
|
|
668
|
+
model,
|
|
669
|
+
reasoningEffort,
|
|
670
|
+
webSearch,
|
|
671
|
+
...permissionMode ? { permissionMode } : {},
|
|
672
|
+
...threadId ? { resumeThreadId: threadId } : {},
|
|
673
|
+
...debug ? { debug } : {}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return control;
|
|
677
|
+
},
|
|
678
|
+
doCompact: async () => {
|
|
679
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
680
|
+
message: "Harness 'codex' does not support manual compaction; Codex auto-compacts its context internally.",
|
|
681
|
+
harnessId: "codex"
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
doDetach: async () => {
|
|
685
|
+
if (stopped) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`codex session ${sessionId} is already stopped; cannot detach.`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
stopped = true;
|
|
691
|
+
const lastSeenEventId = await channel.suspend();
|
|
692
|
+
const payload = {
|
|
693
|
+
type: "resume-session",
|
|
694
|
+
harnessId: "codex",
|
|
695
|
+
specificationVersion: "harness-v1",
|
|
696
|
+
data: {
|
|
697
|
+
...latestThreadId ? { threadId: latestThreadId } : {},
|
|
698
|
+
bridge: {
|
|
699
|
+
port: bridgePort,
|
|
700
|
+
token: bridgeToken,
|
|
701
|
+
lastSeenEventId,
|
|
702
|
+
sandboxId
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
return payload;
|
|
707
|
+
},
|
|
708
|
+
doDestroy: async () => {
|
|
709
|
+
if (stopped) return stopPromise;
|
|
710
|
+
stopped = true;
|
|
711
|
+
stopPromise = (async () => {
|
|
712
|
+
channel.beginClose();
|
|
713
|
+
try {
|
|
714
|
+
if (!channel.isClosed()) {
|
|
715
|
+
channel.send({ type: "shutdown" });
|
|
716
|
+
}
|
|
717
|
+
} catch {
|
|
718
|
+
}
|
|
719
|
+
let stopTimer;
|
|
720
|
+
try {
|
|
721
|
+
if (proc) {
|
|
722
|
+
await Promise.race([
|
|
723
|
+
proc.wait(),
|
|
724
|
+
new Promise((resolve) => {
|
|
725
|
+
stopTimer = setTimeout(resolve, 5e3);
|
|
726
|
+
stopTimer.unref?.();
|
|
727
|
+
})
|
|
728
|
+
]);
|
|
729
|
+
}
|
|
730
|
+
} finally {
|
|
731
|
+
if (stopTimer) clearTimeout(stopTimer);
|
|
732
|
+
try {
|
|
733
|
+
await proc?.kill();
|
|
734
|
+
} catch {
|
|
735
|
+
}
|
|
736
|
+
channel.close();
|
|
737
|
+
}
|
|
738
|
+
})();
|
|
739
|
+
return stopPromise;
|
|
740
|
+
},
|
|
741
|
+
doStop: async () => {
|
|
742
|
+
if (stopped) {
|
|
743
|
+
throw new Error(
|
|
744
|
+
`codex session ${sessionId} is already stopped; cannot stop.`
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
stopped = true;
|
|
748
|
+
channel.beginClose();
|
|
749
|
+
const data = channel.isClosed() ? {} : await new Promise((resolve, reject) => {
|
|
750
|
+
const timer = setTimeout(() => {
|
|
751
|
+
unsub();
|
|
752
|
+
reject(
|
|
753
|
+
new Error(
|
|
754
|
+
`codex session ${sessionId} did not reply to detach within 5s.`
|
|
755
|
+
)
|
|
756
|
+
);
|
|
757
|
+
}, 5e3);
|
|
758
|
+
timer.unref?.();
|
|
759
|
+
const unsub = channel.on("bridge-detach", (msg) => {
|
|
760
|
+
clearTimeout(timer);
|
|
761
|
+
unsub();
|
|
762
|
+
resolve(msg.data);
|
|
763
|
+
});
|
|
764
|
+
try {
|
|
765
|
+
channel.send({ type: "detach" });
|
|
766
|
+
} catch (err) {
|
|
767
|
+
clearTimeout(timer);
|
|
768
|
+
unsub();
|
|
769
|
+
reject(err);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
let stopTimer;
|
|
773
|
+
try {
|
|
774
|
+
if (proc) {
|
|
775
|
+
await Promise.race([
|
|
776
|
+
proc.wait(),
|
|
777
|
+
new Promise((resolve) => {
|
|
778
|
+
stopTimer = setTimeout(resolve, 5e3);
|
|
779
|
+
stopTimer.unref?.();
|
|
780
|
+
})
|
|
781
|
+
]);
|
|
782
|
+
}
|
|
783
|
+
} finally {
|
|
784
|
+
if (stopTimer) clearTimeout(stopTimer);
|
|
785
|
+
try {
|
|
786
|
+
await proc?.kill();
|
|
787
|
+
} catch {
|
|
788
|
+
}
|
|
789
|
+
channel.close();
|
|
790
|
+
}
|
|
791
|
+
const payload = {
|
|
792
|
+
type: "resume-session",
|
|
793
|
+
harnessId: "codex",
|
|
794
|
+
specificationVersion: "harness-v1",
|
|
795
|
+
data: data ?? {}
|
|
796
|
+
};
|
|
797
|
+
return payload;
|
|
798
|
+
},
|
|
799
|
+
doSuspendTurn: async () => {
|
|
800
|
+
if (stopped) {
|
|
801
|
+
throw new Error(
|
|
802
|
+
`codex session ${sessionId} is stopped; cannot suspend.`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
stopped = true;
|
|
806
|
+
const lastSeenEventId = await channel.suspend();
|
|
807
|
+
const payload = {
|
|
808
|
+
type: "continue-turn",
|
|
809
|
+
harnessId: "codex",
|
|
810
|
+
specificationVersion: "harness-v1",
|
|
811
|
+
data: {
|
|
812
|
+
...latestThreadId ? { threadId: latestThreadId } : {},
|
|
813
|
+
bridge: {
|
|
814
|
+
port: bridgePort,
|
|
815
|
+
token: bridgeToken,
|
|
816
|
+
lastSeenEventId,
|
|
817
|
+
sandboxId
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
return payload;
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
function extractUserText(prompt) {
|
|
826
|
+
if (typeof prompt === "string") return prompt;
|
|
827
|
+
const { content } = prompt;
|
|
828
|
+
if (typeof content === "string") return content;
|
|
829
|
+
const parts = [];
|
|
830
|
+
for (const part of content) {
|
|
831
|
+
if (part.type !== "text") {
|
|
832
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
833
|
+
harnessId: "codex",
|
|
834
|
+
message: `The codex harness does not yet support user message parts of type '${part.type}'. Pass a string or a user message whose content contains only text parts.`
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
parts.push(part.text);
|
|
838
|
+
}
|
|
839
|
+
return parts.join("\n\n");
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// src/index.ts
|
|
843
|
+
var codex = createCodex();
|
|
844
|
+
export {
|
|
845
|
+
codex,
|
|
846
|
+
createCodex
|
|
847
|
+
};
|
|
848
|
+
//# sourceMappingURL=index.js.map
|