@karowanorg/orc-harness-codex 0.1.0
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/LICENSE +14 -0
- package/README.md +5 -0
- package/dist/harness.d.ts +21 -0
- package/dist/harness.js +604 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/rpc.d.ts +34 -0
- package/dist/rpc.js +100 -0
- package/dist/schema.d.ts +37 -0
- package/dist/schema.js +281 -0
- package/package.json +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
BSD Zero Clause License (0BSD)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kyle Rowan
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
9
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
10
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
11
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
12
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
13
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
14
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @karowanorg/orc-harness-codex
|
|
2
|
+
|
|
3
|
+
Codex harness for orc: codex app-server JSON-RPC through the executor.
|
|
4
|
+
|
|
5
|
+
Part of orc - model-authored promise-native agent programs with deterministic replay, live monitoring, and pluggable harnesses. See the orc repository README for the full picture.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Harness } from "@karowanorg/orc-core";
|
|
2
|
+
import { type ModelRate } from "@karowanorg/orc-core";
|
|
3
|
+
export interface CodexHarnessOptions {
|
|
4
|
+
/** Command spawned through the executor. Default: ["codex", "app-server"]. */
|
|
5
|
+
appServerCommand?: string[];
|
|
6
|
+
/** Wait after turn/interrupt before killing the process. Default 2000ms. */
|
|
7
|
+
interruptGraceMs?: number;
|
|
8
|
+
clientInfo?: {
|
|
9
|
+
name: string;
|
|
10
|
+
version: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Per-model rate table (USD per 1M tokens) for cost ESTIMATION, since codex's
|
|
14
|
+
* app-server reports tokens but no dollar figure. Defaults to the built-in
|
|
15
|
+
* DEFAULT_COST_RATES (overridable via ORC_COST_RATES / config costRates).
|
|
16
|
+
*/
|
|
17
|
+
costRates?: Record<string, ModelRate>;
|
|
18
|
+
}
|
|
19
|
+
export declare function createCodexHarness(options?: CodexHarnessOptions): Harness;
|
|
20
|
+
/** Default codex harness bound to the real `codex app-server` binary. */
|
|
21
|
+
export declare const codexHarness: Harness;
|
package/dist/harness.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex harness: drives `codex app-server` (codex-cli 0.144.x) over
|
|
3
|
+
* newline-delimited JSON-RPC on stdio. The server process is spawned THROUGH
|
|
4
|
+
* the provided executor, so the same harness works locally and over ssh —
|
|
5
|
+
* JSON-RPC simply flows through the ssh pipes.
|
|
6
|
+
*
|
|
7
|
+
* Wire protocol notes (verified empirically against codex-cli 0.144.5 and its
|
|
8
|
+
* `codex app-server generate-json-schema` output):
|
|
9
|
+
*
|
|
10
|
+
* client -> server: initialize, (notify) initialized, thread/start,
|
|
11
|
+
* thread/resume, turn/start, turn/interrupt, model/list
|
|
12
|
+
* server -> client notifications: thread/started, turn/started,
|
|
13
|
+
* item/started, item/completed, item/agentMessage/delta,
|
|
14
|
+
* thread/tokenUsage/updated, turn/completed, error
|
|
15
|
+
* server -> client requests (approvals), current family:
|
|
16
|
+
* item/commandExecution/requestApproval -> {decision:
|
|
17
|
+
* "accept"|"acceptForSession"|"decline"|"cancel"}
|
|
18
|
+
* item/fileChange/requestApproval -> same enum
|
|
19
|
+
* legacy family (still in the protocol schema):
|
|
20
|
+
* execCommandApproval / applyPatchApproval -> {decision:
|
|
21
|
+
* "approved"|"approved_for_session"|"denied"|"abort"}
|
|
22
|
+
*
|
|
23
|
+
* Approval-mode mapping (design name -> wire name):
|
|
24
|
+
* manual -> approvalPolicy "on-request"; bridge every request
|
|
25
|
+
* accept-edits -> approvalPolicy "on-request"; auto-accept fileChange
|
|
26
|
+
* approvals confined to req.cwd, bridge the rest
|
|
27
|
+
* auto / bypass -> approvalPolicy "never"
|
|
28
|
+
* Filesystem policy is orthogonal to approvals (see below): read-only leaf ->
|
|
29
|
+
* "read-only"; a default write leaf OMITS the sandbox param so codex uses the
|
|
30
|
+
* user's own ~/.codex config (never more power than the caller); `sandbox` ->
|
|
31
|
+
* "workspace-write" with cwd + sandboxDirs as runtime workspace roots;
|
|
32
|
+
* explicit `bypass` -> "danger-full-access".
|
|
33
|
+
*/
|
|
34
|
+
import * as path from "node:path";
|
|
35
|
+
import { estimateCostUsd, loadCostRates } from "@karowanorg/orc-core";
|
|
36
|
+
import { JsonRpcClient } from "./rpc.js";
|
|
37
|
+
import { extractFirstJsonObject, lintStrictOutputSchema, normalizeSchema, restoreOptionalNulls, } from "./schema.js";
|
|
38
|
+
/** Simple push-based async event queue backing the invoke() iterator. */
|
|
39
|
+
class EventQueue {
|
|
40
|
+
items = [];
|
|
41
|
+
waiter;
|
|
42
|
+
done = false;
|
|
43
|
+
push(ev) {
|
|
44
|
+
if (this.done)
|
|
45
|
+
return;
|
|
46
|
+
this.items.push(ev);
|
|
47
|
+
this.waiter?.();
|
|
48
|
+
}
|
|
49
|
+
end() {
|
|
50
|
+
this.done = true;
|
|
51
|
+
this.waiter?.();
|
|
52
|
+
}
|
|
53
|
+
async *[Symbol.asyncIterator]() {
|
|
54
|
+
for (;;) {
|
|
55
|
+
while (this.items.length > 0)
|
|
56
|
+
yield this.items.shift();
|
|
57
|
+
if (this.done)
|
|
58
|
+
return;
|
|
59
|
+
await new Promise((resolve) => (this.waiter = resolve));
|
|
60
|
+
this.waiter = undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function asRecord(v) {
|
|
65
|
+
return v !== null && typeof v === "object" ? v : {};
|
|
66
|
+
}
|
|
67
|
+
/** Is `candidate` lexically inside (or equal to) directory `root`? */
|
|
68
|
+
function withinDir(candidate, root) {
|
|
69
|
+
const rel = path.relative(path.resolve(root), path.resolve(root, candidate));
|
|
70
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
71
|
+
}
|
|
72
|
+
/** Collect changed file paths from a fileChange item / legacy fileChanges map. */
|
|
73
|
+
function changedPaths(changes) {
|
|
74
|
+
if (Array.isArray(changes)) {
|
|
75
|
+
return changes
|
|
76
|
+
.map((c) => asRecord(c).path)
|
|
77
|
+
.filter((p) => typeof p === "string");
|
|
78
|
+
}
|
|
79
|
+
if (changes !== null && typeof changes === "object")
|
|
80
|
+
return Object.keys(changes);
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
export function createCodexHarness(options = {}) {
|
|
84
|
+
const appServerCommand = options.appServerCommand ?? ["codex", "app-server"];
|
|
85
|
+
const interruptGraceMs = options.interruptGraceMs ?? 2000;
|
|
86
|
+
const clientInfo = options.clientInfo ?? { name: "orc", version: "0.1.0" };
|
|
87
|
+
const costRates = loadCostRates(options.costRates);
|
|
88
|
+
async function discover(ctx) {
|
|
89
|
+
const base = {
|
|
90
|
+
available: false,
|
|
91
|
+
models: [],
|
|
92
|
+
approvalModes: ["manual", "accept-edits", "auto", "bypass"],
|
|
93
|
+
structuredOutput: true,
|
|
94
|
+
sessions: true,
|
|
95
|
+
};
|
|
96
|
+
const ver = await ctx.executor
|
|
97
|
+
.run(["codex", "--version"], { timeoutMs: 15_000 })
|
|
98
|
+
.catch(() => ({ code: -1, stdout: "", stderr: "" }));
|
|
99
|
+
if (ver.code !== 0)
|
|
100
|
+
return { ...base, detail: "codex binary not found" };
|
|
101
|
+
base.available = true;
|
|
102
|
+
base.version = ver.stdout.trim().replace(/^codex(-cli)?\s+/, "");
|
|
103
|
+
// Native discovery: model/list on a short-lived app-server session. (The
|
|
104
|
+
// generate-json-schema output was checked and carries NO model/effort
|
|
105
|
+
// enums — ReasoningEffort is a free string there — so model/list is the
|
|
106
|
+
// only native source of truth.)
|
|
107
|
+
try {
|
|
108
|
+
const models = await modelList(ctx.executor);
|
|
109
|
+
if (models.length > 0) {
|
|
110
|
+
return { ...base, models, detail: "codex app-server model/list" };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// fall through to config.toml
|
|
115
|
+
}
|
|
116
|
+
const fallback = await configTomlModel(ctx.executor);
|
|
117
|
+
return {
|
|
118
|
+
...base,
|
|
119
|
+
models: fallback
|
|
120
|
+
? [
|
|
121
|
+
{
|
|
122
|
+
id: fallback,
|
|
123
|
+
// Documented effort ladder for codex-cli 0.14x, used only when
|
|
124
|
+
// the live catalog is unreachable.
|
|
125
|
+
reasoningEfforts: ["minimal", "low", "medium", "high", "xhigh"],
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
: [],
|
|
129
|
+
detail: fallback
|
|
130
|
+
? "fallback: model default from ~/.codex/config.toml (app-server model/list unavailable)"
|
|
131
|
+
: "fallback: app-server model/list unavailable and no ~/.codex/config.toml model",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function modelList(executor) {
|
|
135
|
+
const proc = executor.spawn(appServerCommand);
|
|
136
|
+
try {
|
|
137
|
+
const rpc = new JsonRpcClient(proc);
|
|
138
|
+
const withTimeout = async (p, ms) => {
|
|
139
|
+
let timer;
|
|
140
|
+
try {
|
|
141
|
+
return await Promise.race([
|
|
142
|
+
p,
|
|
143
|
+
new Promise((_, reject) => {
|
|
144
|
+
timer = setTimeout(() => reject(new Error("discover timeout")), ms);
|
|
145
|
+
}),
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
if (timer)
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
await withTimeout(rpc.request("initialize", { clientInfo }), 20_000);
|
|
154
|
+
rpc.notify("initialized", {});
|
|
155
|
+
const res = await withTimeout(rpc.request("model/list", {}), 20_000);
|
|
156
|
+
const visible = (res.data ?? []).filter((m) => !m.hidden && typeof m.id === "string");
|
|
157
|
+
// Default model first.
|
|
158
|
+
visible.sort((a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false));
|
|
159
|
+
return visible.map((m) => {
|
|
160
|
+
const efforts = [];
|
|
161
|
+
for (const e of m.supportedReasoningEfforts ?? []) {
|
|
162
|
+
if (typeof e.reasoningEffort === "string" && !efforts.includes(e.reasoningEffort)) {
|
|
163
|
+
efforts.push(e.reasoningEffort);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
id: m.id,
|
|
168
|
+
displayName: m.displayName,
|
|
169
|
+
reasoningEfforts: efforts,
|
|
170
|
+
default: m.isDefault === true ? true : undefined,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
proc.kill();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function configTomlModel(executor) {
|
|
179
|
+
try {
|
|
180
|
+
const home = (await executor.run(["sh", "-c", 'printf %s "$HOME"'])).stdout.trim();
|
|
181
|
+
if (!home)
|
|
182
|
+
return undefined;
|
|
183
|
+
const toml = await executor.readFile(`${home}/.codex/config.toml`);
|
|
184
|
+
const m = /^\s*model\s*=\s*"([^"]+)"/m.exec(toml);
|
|
185
|
+
return m?.[1];
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function invoke(req, ctx) {
|
|
192
|
+
const queue = new EventQueue();
|
|
193
|
+
void runTurn(req, ctx, queue); // runTurn reports its own errors via the queue
|
|
194
|
+
return queue;
|
|
195
|
+
}
|
|
196
|
+
async function runTurn(req, ctx, queue) {
|
|
197
|
+
// --- approval-mode mapping (design -> wire) ----------------------------
|
|
198
|
+
const approvalPolicy = req.approvalMode === "manual" || req.approvalMode === "accept-edits"
|
|
199
|
+
? "on-request"
|
|
200
|
+
: "never";
|
|
201
|
+
// Filesystem policy — never gives a leaf MORE power than the user's own
|
|
202
|
+
// codex, orthogonal to the approval policy:
|
|
203
|
+
// - read-only leaf -> "read-only" (orc's readOnly restriction)
|
|
204
|
+
// - write leaf + sandbox -> "workspace-write"(explicit confinement, less power)
|
|
205
|
+
// - write leaf + bypass -> "danger-full-access" (explicit max, opt-in)
|
|
206
|
+
// - write leaf (default) -> OMIT the param → codex uses the user's own
|
|
207
|
+
// ~/.codex/config.toml default, so a leaf has exactly the caller's power.
|
|
208
|
+
const sandbox = req.readOnly
|
|
209
|
+
? "read-only"
|
|
210
|
+
: req.sandbox
|
|
211
|
+
? "workspace-write"
|
|
212
|
+
: req.approvalMode === "bypass"
|
|
213
|
+
? "danger-full-access"
|
|
214
|
+
: undefined;
|
|
215
|
+
const runtimeWorkspaceRoots = req.sandbox && !req.readOnly
|
|
216
|
+
? [
|
|
217
|
+
...new Set([req.cwd, ...(req.sandboxDirs ?? [])].map((root) => path.normalize(path.isAbsolute(root) ? root : path.resolve(req.cwd, root)))),
|
|
218
|
+
]
|
|
219
|
+
: undefined;
|
|
220
|
+
// Announce the model/effort actually being requested (codex uses its
|
|
221
|
+
// configured default when req.model is unset).
|
|
222
|
+
queue.push({ kind: "model", model: req.model ?? "(codex default)", reasoningEffort: req.reasoningEffort });
|
|
223
|
+
const proc = ctx.executor.spawn(appServerCommand, { cwd: req.cwd });
|
|
224
|
+
let turnId;
|
|
225
|
+
let threadId;
|
|
226
|
+
let lastErrorMessage;
|
|
227
|
+
const itemsById = new Map();
|
|
228
|
+
const messageBuffers = new Map();
|
|
229
|
+
let finalAnswer;
|
|
230
|
+
let lastAgentMessage;
|
|
231
|
+
// Forward server stderr (tracing logs) to the supervisor log.
|
|
232
|
+
let errBuf = "";
|
|
233
|
+
proc.stderr.setEncoding("utf8");
|
|
234
|
+
proc.stderr.on("data", (chunk) => {
|
|
235
|
+
errBuf += chunk;
|
|
236
|
+
let idx;
|
|
237
|
+
while ((idx = errBuf.indexOf("\n")) >= 0) {
|
|
238
|
+
const line = errBuf.slice(0, idx).trim();
|
|
239
|
+
errBuf = errBuf.slice(idx + 1);
|
|
240
|
+
if (line)
|
|
241
|
+
ctx.log(line); // hlog is already per-leaf + harness-attributed
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
// Output-idle watchdog: any stdout byte resets the timer.
|
|
245
|
+
let idleTimer;
|
|
246
|
+
let idleFired = false;
|
|
247
|
+
const armIdle = () => {
|
|
248
|
+
if (typeof req.idleTimeoutMs !== "number")
|
|
249
|
+
return;
|
|
250
|
+
if (idleTimer)
|
|
251
|
+
clearTimeout(idleTimer);
|
|
252
|
+
idleTimer = setTimeout(() => {
|
|
253
|
+
idleFired = true;
|
|
254
|
+
queue.push({
|
|
255
|
+
kind: "error",
|
|
256
|
+
message: `idle timeout: no app-server output for ${req.idleTimeoutMs}ms`,
|
|
257
|
+
});
|
|
258
|
+
proc.kill();
|
|
259
|
+
}, req.idleTimeoutMs);
|
|
260
|
+
idleTimer.unref?.();
|
|
261
|
+
};
|
|
262
|
+
proc.stdout.on("data", armIdle);
|
|
263
|
+
armIdle();
|
|
264
|
+
let resolveTurn;
|
|
265
|
+
const turnDone = new Promise((resolve) => (resolveTurn = resolve));
|
|
266
|
+
const onNotification = (method, rawParams) => {
|
|
267
|
+
const params = asRecord(rawParams);
|
|
268
|
+
if (typeof params.threadId === "string" && threadId && params.threadId !== threadId)
|
|
269
|
+
return;
|
|
270
|
+
switch (method) {
|
|
271
|
+
case "item/started": {
|
|
272
|
+
const item = asRecord(params.item);
|
|
273
|
+
if (typeof item.id === "string")
|
|
274
|
+
itemsById.set(item.id, item);
|
|
275
|
+
const atMs = Date.now();
|
|
276
|
+
switch (item.type) {
|
|
277
|
+
case "commandExecution":
|
|
278
|
+
queue.push({
|
|
279
|
+
kind: "tool-call-open",
|
|
280
|
+
id: item.id ?? "",
|
|
281
|
+
name: "command",
|
|
282
|
+
input: { command: item.command ?? null, cwd: item.cwd ?? null },
|
|
283
|
+
atMs,
|
|
284
|
+
});
|
|
285
|
+
break;
|
|
286
|
+
case "fileChange":
|
|
287
|
+
queue.push({
|
|
288
|
+
kind: "tool-call-open",
|
|
289
|
+
id: item.id ?? "",
|
|
290
|
+
name: "edit",
|
|
291
|
+
input: { changes: (item.changes ?? null) },
|
|
292
|
+
atMs,
|
|
293
|
+
});
|
|
294
|
+
break;
|
|
295
|
+
case "webSearch":
|
|
296
|
+
queue.push({
|
|
297
|
+
kind: "tool-call-open",
|
|
298
|
+
id: item.id ?? "",
|
|
299
|
+
name: "webSearch",
|
|
300
|
+
input: { query: (item.query ?? null) },
|
|
301
|
+
atMs,
|
|
302
|
+
});
|
|
303
|
+
break;
|
|
304
|
+
case "mcpToolCall":
|
|
305
|
+
queue.push({
|
|
306
|
+
kind: "tool-call-open",
|
|
307
|
+
id: item.id ?? "",
|
|
308
|
+
name: "mcpToolCall",
|
|
309
|
+
input: {
|
|
310
|
+
server: (item.server ?? null),
|
|
311
|
+
tool: (item.tool ?? null),
|
|
312
|
+
arguments: (item.arguments ?? null),
|
|
313
|
+
},
|
|
314
|
+
atMs,
|
|
315
|
+
});
|
|
316
|
+
break;
|
|
317
|
+
default:
|
|
318
|
+
break; // userMessage / reasoning / agentMessage etc: no tool event
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case "item/completed": {
|
|
323
|
+
const item = asRecord(params.item);
|
|
324
|
+
if (typeof item.id === "string")
|
|
325
|
+
itemsById.set(item.id, item);
|
|
326
|
+
const atMs = Date.now();
|
|
327
|
+
switch (item.type) {
|
|
328
|
+
case "commandExecution":
|
|
329
|
+
case "fileChange":
|
|
330
|
+
case "webSearch":
|
|
331
|
+
case "mcpToolCall": {
|
|
332
|
+
const failed = item.status === "failed" || item.status === "declined";
|
|
333
|
+
queue.push({
|
|
334
|
+
kind: "tool-call-close",
|
|
335
|
+
id: item.id ?? "",
|
|
336
|
+
status: failed ? "error" : "ok",
|
|
337
|
+
atMs,
|
|
338
|
+
});
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case "agentMessage": {
|
|
342
|
+
const text = typeof item.text === "string" ? item.text : "";
|
|
343
|
+
lastAgentMessage = text;
|
|
344
|
+
if (item.phase === "final_answer" || item.phase === undefined)
|
|
345
|
+
finalAnswer = text;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
default:
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case "item/agentMessage/delta": {
|
|
354
|
+
const delta = typeof params.delta === "string" ? params.delta : "";
|
|
355
|
+
const itemId = typeof params.itemId === "string" ? params.itemId : "";
|
|
356
|
+
messageBuffers.set(itemId, (messageBuffers.get(itemId) ?? "") + delta);
|
|
357
|
+
if (delta)
|
|
358
|
+
queue.push({ kind: "text", delta, atMs: Date.now() });
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "thread/tokenUsage/updated": {
|
|
362
|
+
const usage = asRecord(asRecord(params.tokenUsage).total);
|
|
363
|
+
const inputTokens = typeof usage.inputTokens === "number" ? usage.inputTokens : undefined;
|
|
364
|
+
const outputTokens = typeof usage.outputTokens === "number" ? usage.outputTokens : undefined;
|
|
365
|
+
const cachedInputTokens = typeof usage.cachedInputTokens === "number" ? usage.cachedInputTokens : undefined;
|
|
366
|
+
// codex reports tokens but no dollars — estimate from the rate table.
|
|
367
|
+
// Fall back to the gpt-5.6 family key when the program didn't pin a model.
|
|
368
|
+
const costUsd = estimateCostUsd(costRates, req.model ?? "gpt-5.6", {
|
|
369
|
+
inputTokens,
|
|
370
|
+
cachedInputTokens,
|
|
371
|
+
outputTokens,
|
|
372
|
+
});
|
|
373
|
+
queue.push({
|
|
374
|
+
kind: "usage",
|
|
375
|
+
tokensIn: inputTokens,
|
|
376
|
+
tokensOut: outputTokens,
|
|
377
|
+
...(costUsd !== undefined ? { costUsd, costEstimated: true } : {}),
|
|
378
|
+
});
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "turn/completed": {
|
|
382
|
+
resolveTurn(asRecord(params.turn));
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case "error": {
|
|
386
|
+
const message = asRecord(params.error).message;
|
|
387
|
+
if (typeof message === "string")
|
|
388
|
+
lastErrorMessage = message;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
default:
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const bridgeApproval = async (toolName, input) => {
|
|
396
|
+
const decision = await ctx.requestApproval({
|
|
397
|
+
runId: req.runId,
|
|
398
|
+
seq: req.seq,
|
|
399
|
+
toolName,
|
|
400
|
+
input,
|
|
401
|
+
});
|
|
402
|
+
if (decision.behavior === "deny") {
|
|
403
|
+
queue.push({
|
|
404
|
+
kind: "denied",
|
|
405
|
+
toolName,
|
|
406
|
+
reason: decision.message ?? "denied by operator",
|
|
407
|
+
atMs: Date.now(),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return decision;
|
|
411
|
+
};
|
|
412
|
+
const denyReadOnly = (toolName) => {
|
|
413
|
+
queue.push({
|
|
414
|
+
kind: "denied",
|
|
415
|
+
toolName,
|
|
416
|
+
reason: "read-only leaf denied approval",
|
|
417
|
+
atMs: Date.now(),
|
|
418
|
+
});
|
|
419
|
+
return { behavior: "deny" };
|
|
420
|
+
};
|
|
421
|
+
const decideFileChange = async (paths, grantRoot, input) => {
|
|
422
|
+
if (req.readOnly)
|
|
423
|
+
return denyReadOnly("edit");
|
|
424
|
+
const confined = paths.length > 0 &&
|
|
425
|
+
paths.every((p) => withinDir(p, req.cwd)) &&
|
|
426
|
+
(typeof grantRoot !== "string" || withinDir(grantRoot, req.cwd));
|
|
427
|
+
if (req.approvalMode === "accept-edits" && confined) {
|
|
428
|
+
return { behavior: "allow" };
|
|
429
|
+
}
|
|
430
|
+
if (req.approvalMode === "auto" || req.approvalMode === "bypass") {
|
|
431
|
+
// approvalPolicy is "never" for these modes; if the server asks
|
|
432
|
+
// anyway, honor the mode's intent and allow.
|
|
433
|
+
return { behavior: "allow" };
|
|
434
|
+
}
|
|
435
|
+
return bridgeApproval("edit", input);
|
|
436
|
+
};
|
|
437
|
+
const onServerRequest = async (method, rawParams) => {
|
|
438
|
+
const params = asRecord(rawParams);
|
|
439
|
+
switch (method) {
|
|
440
|
+
// --- current (v2) approval family --------------------------------
|
|
441
|
+
case "item/commandExecution/requestApproval": {
|
|
442
|
+
const item = typeof params.itemId === "string" ? itemsById.get(params.itemId) : undefined;
|
|
443
|
+
const decision = req.readOnly
|
|
444
|
+
? denyReadOnly("command")
|
|
445
|
+
: req.approvalMode === "auto" || req.approvalMode === "bypass"
|
|
446
|
+
? { behavior: "allow" }
|
|
447
|
+
: await bridgeApproval("command", {
|
|
448
|
+
command: (params.command ?? item?.command ?? null),
|
|
449
|
+
cwd: (params.cwd ?? item?.cwd ?? null),
|
|
450
|
+
reason: (params.reason ?? null),
|
|
451
|
+
});
|
|
452
|
+
return { decision: decision.behavior === "allow" ? "accept" : "decline" };
|
|
453
|
+
}
|
|
454
|
+
case "item/fileChange/requestApproval": {
|
|
455
|
+
const item = typeof params.itemId === "string" ? itemsById.get(params.itemId) : undefined;
|
|
456
|
+
const paths = changedPaths(item?.changes);
|
|
457
|
+
const decision = await decideFileChange(paths, params.grantRoot, {
|
|
458
|
+
changes: (item?.changes ?? null),
|
|
459
|
+
paths,
|
|
460
|
+
reason: (params.reason ?? null),
|
|
461
|
+
});
|
|
462
|
+
return { decision: decision.behavior === "allow" ? "accept" : "decline" };
|
|
463
|
+
}
|
|
464
|
+
// --- legacy approval family (older servers) ----------------------
|
|
465
|
+
case "execCommandApproval": {
|
|
466
|
+
const decision = req.readOnly
|
|
467
|
+
? denyReadOnly("command")
|
|
468
|
+
: req.approvalMode === "auto" || req.approvalMode === "bypass"
|
|
469
|
+
? { behavior: "allow" }
|
|
470
|
+
: await bridgeApproval("command", {
|
|
471
|
+
command: (params.command ?? null),
|
|
472
|
+
cwd: (params.cwd ?? null),
|
|
473
|
+
reason: (params.reason ?? null),
|
|
474
|
+
});
|
|
475
|
+
return { decision: decision.behavior === "allow" ? "approved" : "denied" };
|
|
476
|
+
}
|
|
477
|
+
case "applyPatchApproval": {
|
|
478
|
+
const paths = changedPaths(params.fileChanges);
|
|
479
|
+
const decision = await decideFileChange(paths, params.grantRoot, {
|
|
480
|
+
changes: (params.fileChanges ?? null),
|
|
481
|
+
paths,
|
|
482
|
+
reason: (params.reason ?? null),
|
|
483
|
+
});
|
|
484
|
+
return { decision: decision.behavior === "allow" ? "approved" : "denied" };
|
|
485
|
+
}
|
|
486
|
+
default:
|
|
487
|
+
throw new Error(`unsupported server request: ${method}`);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const rpc = new JsonRpcClient(proc, { onNotification, onServerRequest, log: ctx.log });
|
|
491
|
+
// Cancellation: best-effort turn/interrupt, then kill.
|
|
492
|
+
let aborted = false;
|
|
493
|
+
const onAbort = () => {
|
|
494
|
+
aborted = true;
|
|
495
|
+
if (threadId && turnId) {
|
|
496
|
+
rpc.request("turn/interrupt", { threadId, turnId }).catch(() => undefined);
|
|
497
|
+
}
|
|
498
|
+
setTimeout(() => proc.kill(), interruptGraceMs).unref?.();
|
|
499
|
+
};
|
|
500
|
+
if (ctx.signal.aborted)
|
|
501
|
+
onAbort();
|
|
502
|
+
else
|
|
503
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
504
|
+
try {
|
|
505
|
+
await rpc.request("initialize", { clientInfo });
|
|
506
|
+
rpc.notify("initialized", {});
|
|
507
|
+
const developerInstructions = req.system.trim() || undefined;
|
|
508
|
+
if (req.sessionId) {
|
|
509
|
+
const resumed = await rpc.request("thread/resume", {
|
|
510
|
+
threadId: req.sessionId,
|
|
511
|
+
cwd: req.cwd,
|
|
512
|
+
...(runtimeWorkspaceRoots ? { runtimeWorkspaceRoots } : {}),
|
|
513
|
+
approvalPolicy,
|
|
514
|
+
...(sandbox !== undefined ? { sandbox } : {}), // omit -> inherit user's config default
|
|
515
|
+
...(developerInstructions ? { developerInstructions } : {}),
|
|
516
|
+
});
|
|
517
|
+
threadId = resumed.thread?.id ?? req.sessionId;
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
const started = await rpc.request("thread/start", {
|
|
521
|
+
cwd: req.cwd,
|
|
522
|
+
...(runtimeWorkspaceRoots ? { runtimeWorkspaceRoots } : {}),
|
|
523
|
+
approvalPolicy,
|
|
524
|
+
...(sandbox !== undefined ? { sandbox } : {}), // omit -> inherit user's config default
|
|
525
|
+
...(developerInstructions ? { developerInstructions } : {}),
|
|
526
|
+
});
|
|
527
|
+
threadId = started.thread?.id;
|
|
528
|
+
if (!threadId)
|
|
529
|
+
throw new Error("thread/start returned no thread id");
|
|
530
|
+
}
|
|
531
|
+
queue.push({ kind: "session", sessionId: threadId });
|
|
532
|
+
const turnStarted = await rpc.request("turn/start", {
|
|
533
|
+
threadId,
|
|
534
|
+
input: [{ type: "text", text: req.prompt }],
|
|
535
|
+
...(req.model ? { model: req.model } : {}),
|
|
536
|
+
...(req.reasoningEffort ? { effort: req.reasoningEffort } : {}),
|
|
537
|
+
...(req.schema !== undefined ? { outputSchema: normalizeSchema(req.schema) } : {}),
|
|
538
|
+
});
|
|
539
|
+
turnId = turnStarted.turn?.id;
|
|
540
|
+
const outcome = await Promise.race([
|
|
541
|
+
turnDone.then((turn) => ({ turn })),
|
|
542
|
+
proc.exited.then((code) => ({ exitCode: code })),
|
|
543
|
+
]);
|
|
544
|
+
if ("exitCode" in outcome) {
|
|
545
|
+
if (idleFired)
|
|
546
|
+
return; // idle watchdog already reported the error
|
|
547
|
+
queue.push({
|
|
548
|
+
kind: "error",
|
|
549
|
+
message: aborted
|
|
550
|
+
? "cancelled: turn aborted by supervisor"
|
|
551
|
+
: `app-server exited before turn completion (code ${outcome.exitCode})${lastErrorMessage ? `: ${lastErrorMessage}` : ""}`,
|
|
552
|
+
});
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const turn = outcome.turn;
|
|
556
|
+
if (turn.status === "completed") {
|
|
557
|
+
const message = finalAnswer ?? lastAgentMessage ?? "";
|
|
558
|
+
if (req.schema !== undefined) {
|
|
559
|
+
let output;
|
|
560
|
+
try {
|
|
561
|
+
output = JSON.parse(message);
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
output = extractFirstJsonObject(message);
|
|
565
|
+
}
|
|
566
|
+
if (output === undefined) {
|
|
567
|
+
queue.push({
|
|
568
|
+
kind: "error",
|
|
569
|
+
message: `structured output parse failed: ${message.slice(0, 500)}`,
|
|
570
|
+
});
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
queue.push({ kind: "result", output: restoreOptionalNulls(output, req.schema) });
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
queue.push({ kind: "result", output: { text: message } });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else if (turn.status === "interrupted") {
|
|
580
|
+
queue.push({ kind: "error", message: "cancelled: turn interrupted" });
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
queue.push({
|
|
584
|
+
kind: "error",
|
|
585
|
+
message: `turn failed: ${turn.error?.message ?? lastErrorMessage ?? "unknown error"}`,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
// Must run before the finally ends the queue, or the event is dropped.
|
|
591
|
+
queue.push({ kind: "error", message: String(err) });
|
|
592
|
+
}
|
|
593
|
+
finally {
|
|
594
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
595
|
+
if (idleTimer)
|
|
596
|
+
clearTimeout(idleTimer);
|
|
597
|
+
proc.kill();
|
|
598
|
+
queue.end();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return { name: "codex", discover, invoke, lintOutputSchema: lintStrictOutputSchema };
|
|
602
|
+
}
|
|
603
|
+
/** Default codex harness bound to the real `codex app-server` binary. */
|
|
604
|
+
export const codexHarness = createCodexHarness();
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/rpc.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Proc } from "@karowanorg/orc-core";
|
|
2
|
+
export type RpcId = number | string;
|
|
3
|
+
export interface JsonRpcClientOptions {
|
|
4
|
+
/** Server -> client notification (method, params). */
|
|
5
|
+
onNotification?: (method: string, params: unknown) => void;
|
|
6
|
+
/**
|
|
7
|
+
* Server -> client request. The resolved value is sent back as the JSON-RPC
|
|
8
|
+
* result; a rejection is sent as a JSON-RPC error.
|
|
9
|
+
*/
|
|
10
|
+
onServerRequest?: (method: string, params: unknown) => Promise<unknown>;
|
|
11
|
+
log?: (message: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Minimal newline-delimited JSON-RPC 2.0 client over a Proc's stdio.
|
|
15
|
+
* Hand-rolled on purpose: the same client works locally and across ssh,
|
|
16
|
+
* because the executor provides the pipes.
|
|
17
|
+
*/
|
|
18
|
+
export declare class JsonRpcClient {
|
|
19
|
+
private readonly proc;
|
|
20
|
+
private readonly opts;
|
|
21
|
+
private nextId;
|
|
22
|
+
private readonly pending;
|
|
23
|
+
private buffer;
|
|
24
|
+
private closed;
|
|
25
|
+
constructor(proc: Proc, opts?: JsonRpcClientOptions);
|
|
26
|
+
request<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
27
|
+
notify(method: string, params?: unknown): void;
|
|
28
|
+
respond(id: RpcId, result: unknown): void;
|
|
29
|
+
respondError(id: RpcId, code: number, message: string): void;
|
|
30
|
+
private send;
|
|
31
|
+
private onData;
|
|
32
|
+
private dispatch;
|
|
33
|
+
private failAll;
|
|
34
|
+
}
|
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal newline-delimited JSON-RPC 2.0 client over a Proc's stdio.
|
|
3
|
+
* Hand-rolled on purpose: the same client works locally and across ssh,
|
|
4
|
+
* because the executor provides the pipes.
|
|
5
|
+
*/
|
|
6
|
+
export class JsonRpcClient {
|
|
7
|
+
proc;
|
|
8
|
+
opts;
|
|
9
|
+
nextId = 1;
|
|
10
|
+
pending = new Map();
|
|
11
|
+
buffer = "";
|
|
12
|
+
closed = false;
|
|
13
|
+
constructor(proc, opts = {}) {
|
|
14
|
+
this.proc = proc;
|
|
15
|
+
this.opts = opts;
|
|
16
|
+
proc.stdout.setEncoding("utf8");
|
|
17
|
+
proc.stdout.on("data", (chunk) => this.onData(chunk));
|
|
18
|
+
void proc.exited.then((code) => this.failAll(new Error(`app-server exited (code ${code})`)));
|
|
19
|
+
}
|
|
20
|
+
request(method, params) {
|
|
21
|
+
const id = this.nextId++;
|
|
22
|
+
const promise = new Promise((resolve, reject) => {
|
|
23
|
+
this.pending.set(id, { resolve: resolve, reject });
|
|
24
|
+
});
|
|
25
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
26
|
+
return promise;
|
|
27
|
+
}
|
|
28
|
+
notify(method, params) {
|
|
29
|
+
this.send({ jsonrpc: "2.0", method, params });
|
|
30
|
+
}
|
|
31
|
+
respond(id, result) {
|
|
32
|
+
this.send({ jsonrpc: "2.0", id, result });
|
|
33
|
+
}
|
|
34
|
+
respondError(id, code, message) {
|
|
35
|
+
this.send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
36
|
+
}
|
|
37
|
+
send(msg) {
|
|
38
|
+
if (this.closed || !this.proc.stdin)
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
this.opts.log?.(`rpc write failed: ${String(err)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
onData(chunk) {
|
|
48
|
+
this.buffer += chunk;
|
|
49
|
+
let idx;
|
|
50
|
+
while ((idx = this.buffer.indexOf("\n")) >= 0) {
|
|
51
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
52
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
53
|
+
if (!line)
|
|
54
|
+
continue;
|
|
55
|
+
let msg;
|
|
56
|
+
try {
|
|
57
|
+
msg = JSON.parse(line);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
this.opts.log?.(`rpc: unparseable line: ${line.slice(0, 200)}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
this.dispatch(msg);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
dispatch(msg) {
|
|
67
|
+
if (msg.id !== undefined && msg.method === undefined) {
|
|
68
|
+
// Response to one of our requests.
|
|
69
|
+
const entry = this.pending.get(msg.id);
|
|
70
|
+
if (!entry)
|
|
71
|
+
return;
|
|
72
|
+
this.pending.delete(msg.id);
|
|
73
|
+
if (msg.error)
|
|
74
|
+
entry.reject(new Error(`${msg.error.message} (rpc ${msg.error.code})`));
|
|
75
|
+
else
|
|
76
|
+
entry.resolve(msg.result);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (msg.id !== undefined && msg.method !== undefined) {
|
|
80
|
+
// Server -> client request (approvals etc.).
|
|
81
|
+
const id = msg.id;
|
|
82
|
+
const handler = this.opts.onServerRequest;
|
|
83
|
+
if (!handler) {
|
|
84
|
+
this.respondError(id, -32601, `no handler for ${msg.method}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
handler(msg.method, msg.params).then((result) => this.respond(id, result), (err) => this.respondError(id, -32603, String(err)));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (msg.method !== undefined) {
|
|
91
|
+
this.opts.onNotification?.(msg.method, msg.params);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
failAll(err) {
|
|
95
|
+
this.closed = true;
|
|
96
|
+
for (const [, entry] of this.pending)
|
|
97
|
+
entry.reject(err);
|
|
98
|
+
this.pending.clear();
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Json } from "@karowanorg/orc-core";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a JSON Schema for codex `outputSchema`.
|
|
4
|
+
*
|
|
5
|
+
* OpenAI structured output (which codex uses) is STRICTER than Claude's:
|
|
6
|
+
* on every object it requires `additionalProperties: false` AND that `required`
|
|
7
|
+
* lists every key in `properties`. A merely-optional field ("gap" not in
|
|
8
|
+
* required) is rejected as `invalid_json_schema`. The documented transform is:
|
|
9
|
+
* make every property required, and for the ones that were originally optional,
|
|
10
|
+
* make them NULLABLE so the model can still omit them by returning null. That
|
|
11
|
+
* preserves the author's intent (optional) while satisfying strict mode.
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizeSchema(schema: Json): Json;
|
|
14
|
+
/**
|
|
15
|
+
* Project Codex's strict-schema output back to the caller's schema semantics.
|
|
16
|
+
* Strict mode represents an omitted optional field as `null`; the original
|
|
17
|
+
* schema generally expects that key to be absent instead.
|
|
18
|
+
*/
|
|
19
|
+
export declare function restoreOptionalNulls(value: Json, schema: Json): Json;
|
|
20
|
+
/**
|
|
21
|
+
* Statically flag the parts of a JSON Schema that OpenAI/codex strict output
|
|
22
|
+
* REJECTS and that `normalizeSchema` cannot auto-fix without changing meaning.
|
|
23
|
+
*
|
|
24
|
+
* The one construct that can't be repaired is an open-ended object — a map with
|
|
25
|
+
* `additionalProperties: true` (or a sub-schema). Strict mode can only describe
|
|
26
|
+
* a fixed set of keys, so the only faithful fix is the author's: replace the map
|
|
27
|
+
* with an array of typed records (e.g. `{ name, value }`). `normalizeSchema`
|
|
28
|
+
* injects `additionalProperties: false` only when the key is ABSENT, so an
|
|
29
|
+
* explicit open map survives to a runtime `invalid_json_schema` — which this
|
|
30
|
+
* lint turns into an up-front `orc validate` problem instead.
|
|
31
|
+
*/
|
|
32
|
+
export declare function lintStrictOutputSchema(schema: Json): string[];
|
|
33
|
+
/**
|
|
34
|
+
* Extract the first balanced top-level JSON object from free text and parse
|
|
35
|
+
* it. Returns undefined when no parseable object exists.
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractFirstJsonObject(text: string): Json | undefined;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { validateAgainstSchema } from "@karowanorg/orc-core";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a JSON Schema for codex `outputSchema`.
|
|
4
|
+
*
|
|
5
|
+
* OpenAI structured output (which codex uses) is STRICTER than Claude's:
|
|
6
|
+
* on every object it requires `additionalProperties: false` AND that `required`
|
|
7
|
+
* lists every key in `properties`. A merely-optional field ("gap" not in
|
|
8
|
+
* required) is rejected as `invalid_json_schema`. The documented transform is:
|
|
9
|
+
* make every property required, and for the ones that were originally optional,
|
|
10
|
+
* make them NULLABLE so the model can still omit them by returning null. That
|
|
11
|
+
* preserves the author's intent (optional) while satisfying strict mode.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeSchema(schema) {
|
|
14
|
+
if (Array.isArray(schema))
|
|
15
|
+
return schema.map(normalizeSchema);
|
|
16
|
+
if (schema === null || typeof schema !== "object")
|
|
17
|
+
return schema;
|
|
18
|
+
const source = { ...schema };
|
|
19
|
+
const isObjectType = source.type === "object";
|
|
20
|
+
const hasProps = source.properties !== null && typeof source.properties === "object";
|
|
21
|
+
let originallyRequired = new Set();
|
|
22
|
+
if (Array.isArray(source.required)) {
|
|
23
|
+
originallyRequired = new Set(source.required.filter((k) => typeof k === "string"));
|
|
24
|
+
}
|
|
25
|
+
const out = {};
|
|
26
|
+
const propKeys = [];
|
|
27
|
+
for (const key of Object.keys(source).sort()) {
|
|
28
|
+
if (key === "properties" && hasProps) {
|
|
29
|
+
const props = source[key];
|
|
30
|
+
const sorted = {};
|
|
31
|
+
for (const p of Object.keys(props).sort()) {
|
|
32
|
+
propKeys.push(p);
|
|
33
|
+
let normalized = normalizeSchema(props[p]);
|
|
34
|
+
if (!originallyRequired.has(p))
|
|
35
|
+
normalized = makeNullable(normalized);
|
|
36
|
+
sorted[p] = normalized;
|
|
37
|
+
}
|
|
38
|
+
out[key] = sorted;
|
|
39
|
+
}
|
|
40
|
+
else if (key === "required" && hasProps) {
|
|
41
|
+
// replaced below with the full key set (strict mode)
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
out[key] = normalizeSchema(source[key]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Every object gets additionalProperties:false (unless explicitly set); an
|
|
48
|
+
// object WITH properties additionally requires every key (strict mode).
|
|
49
|
+
if (isObjectType && !("additionalProperties" in source))
|
|
50
|
+
out.additionalProperties = false;
|
|
51
|
+
if (hasProps)
|
|
52
|
+
out.required = [...propKeys].sort();
|
|
53
|
+
// Stable wire form: sorted top-level keys (incl. the injected ones).
|
|
54
|
+
const sortedOut = {};
|
|
55
|
+
for (const k of Object.keys(out).sort())
|
|
56
|
+
sortedOut[k] = out[k];
|
|
57
|
+
return sortedOut;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Project Codex's strict-schema output back to the caller's schema semantics.
|
|
61
|
+
* Strict mode represents an omitted optional field as `null`; the original
|
|
62
|
+
* schema generally expects that key to be absent instead.
|
|
63
|
+
*/
|
|
64
|
+
export function restoreOptionalNulls(value, schema) {
|
|
65
|
+
return restoreNode(value, schema, schema, new Set());
|
|
66
|
+
}
|
|
67
|
+
function restoreNode(value, schema, root, refs) {
|
|
68
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema))
|
|
69
|
+
return value;
|
|
70
|
+
const s = schema;
|
|
71
|
+
if (typeof s.$ref === "string" && !refs.has(s.$ref)) {
|
|
72
|
+
const target = resolveLocalRef(root, s.$ref);
|
|
73
|
+
if (target !== undefined) {
|
|
74
|
+
refs.add(s.$ref);
|
|
75
|
+
value = restoreNode(value, target, root, refs);
|
|
76
|
+
refs.delete(s.$ref);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
if (s.items !== undefined && !Array.isArray(s.items)) {
|
|
81
|
+
value = value.map((item) => restoreNode(item, s.items, root, refs));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (value !== null && typeof value === "object") {
|
|
85
|
+
const properties = s.properties !== null && typeof s.properties === "object" && !Array.isArray(s.properties)
|
|
86
|
+
? s.properties
|
|
87
|
+
: undefined;
|
|
88
|
+
if (properties) {
|
|
89
|
+
const required = new Set(Array.isArray(s.required)
|
|
90
|
+
? s.required.filter((key) => typeof key === "string")
|
|
91
|
+
: []);
|
|
92
|
+
const out = { ...value };
|
|
93
|
+
for (const [key, propertySchema] of Object.entries(properties)) {
|
|
94
|
+
if (!(key in out))
|
|
95
|
+
continue;
|
|
96
|
+
if (out[key] === null &&
|
|
97
|
+
!required.has(key) &&
|
|
98
|
+
!acceptsNull(propertySchema, root)) {
|
|
99
|
+
delete out[key];
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
out[key] = restoreNode(out[key], propertySchema, root, refs);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
value = out;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(s.allOf)) {
|
|
109
|
+
for (const branch of s.allOf)
|
|
110
|
+
value = restoreNode(value, branch, root, refs);
|
|
111
|
+
}
|
|
112
|
+
const union = Array.isArray(s.anyOf)
|
|
113
|
+
? s.anyOf
|
|
114
|
+
: Array.isArray(s.oneOf)
|
|
115
|
+
? s.oneOf
|
|
116
|
+
: undefined;
|
|
117
|
+
if (union) {
|
|
118
|
+
const original = union.find((branch) => matchesSchema(value, branch, root));
|
|
119
|
+
if (original)
|
|
120
|
+
return restoreNode(value, original, root, refs);
|
|
121
|
+
for (const branch of union) {
|
|
122
|
+
const candidate = restoreNode(value, branch, root, refs);
|
|
123
|
+
if (matchesSchema(candidate, branch, root))
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
function matchesSchema(value, schema, root) {
|
|
130
|
+
if (schema === null ||
|
|
131
|
+
typeof schema !== "object" ||
|
|
132
|
+
Array.isArray(schema) ||
|
|
133
|
+
root === null ||
|
|
134
|
+
typeof root !== "object" ||
|
|
135
|
+
Array.isArray(root)) {
|
|
136
|
+
return validateAgainstSchema(value, schema) === null;
|
|
137
|
+
}
|
|
138
|
+
const rootSchema = root;
|
|
139
|
+
return (validateAgainstSchema(value, {
|
|
140
|
+
...schema,
|
|
141
|
+
...("$defs" in rootSchema && !("$defs" in schema) ? { $defs: rootSchema.$defs } : {}),
|
|
142
|
+
...("definitions" in rootSchema && !("definitions" in schema)
|
|
143
|
+
? { definitions: rootSchema.definitions }
|
|
144
|
+
: {}),
|
|
145
|
+
}) === null);
|
|
146
|
+
}
|
|
147
|
+
function resolveLocalRef(root, ref) {
|
|
148
|
+
if (ref === "#")
|
|
149
|
+
return root;
|
|
150
|
+
if (!ref.startsWith("#/"))
|
|
151
|
+
return undefined;
|
|
152
|
+
let node = root;
|
|
153
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
154
|
+
if (node === null || typeof node !== "object" || Array.isArray(node))
|
|
155
|
+
return undefined;
|
|
156
|
+
const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
157
|
+
node = node[key];
|
|
158
|
+
}
|
|
159
|
+
return node;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Statically flag the parts of a JSON Schema that OpenAI/codex strict output
|
|
163
|
+
* REJECTS and that `normalizeSchema` cannot auto-fix without changing meaning.
|
|
164
|
+
*
|
|
165
|
+
* The one construct that can't be repaired is an open-ended object — a map with
|
|
166
|
+
* `additionalProperties: true` (or a sub-schema). Strict mode can only describe
|
|
167
|
+
* a fixed set of keys, so the only faithful fix is the author's: replace the map
|
|
168
|
+
* with an array of typed records (e.g. `{ name, value }`). `normalizeSchema`
|
|
169
|
+
* injects `additionalProperties: false` only when the key is ABSENT, so an
|
|
170
|
+
* explicit open map survives to a runtime `invalid_json_schema` — which this
|
|
171
|
+
* lint turns into an up-front `orc validate` problem instead.
|
|
172
|
+
*/
|
|
173
|
+
export function lintStrictOutputSchema(schema) {
|
|
174
|
+
const problems = [];
|
|
175
|
+
walk(schema, "$");
|
|
176
|
+
return problems;
|
|
177
|
+
function walk(node, path) {
|
|
178
|
+
if (Array.isArray(node)) {
|
|
179
|
+
node.forEach((item, i) => walk(item, `${path}[${i}]`));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (node === null || typeof node !== "object")
|
|
183
|
+
return;
|
|
184
|
+
const o = node;
|
|
185
|
+
const isObject = o.type === "object" || (o.properties != null && typeof o.properties === "object");
|
|
186
|
+
if (isObject && "additionalProperties" in o && o.additionalProperties !== false) {
|
|
187
|
+
problems.push(`${path}: open-ended object (additionalProperties is not false) — codex strict structured output can't express arbitrary keys; replace the map with an array of typed records, e.g. [{ name, value }]`);
|
|
188
|
+
}
|
|
189
|
+
for (const [k, v] of Object.entries(o))
|
|
190
|
+
walk(v, path === "$" ? `$.${k}` : `${path}.${k}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Make a property schema accept null (optional-in-strict-mode). */
|
|
194
|
+
function makeNullable(schema) {
|
|
195
|
+
if (schema === false)
|
|
196
|
+
return { type: "null" };
|
|
197
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema))
|
|
198
|
+
return schema;
|
|
199
|
+
const s = { ...schema };
|
|
200
|
+
if (acceptsNull(s))
|
|
201
|
+
return s;
|
|
202
|
+
return { anyOf: [s, { type: "null" }] };
|
|
203
|
+
}
|
|
204
|
+
function acceptsNull(schema, root = schema, seen = new Set()) {
|
|
205
|
+
if (typeof schema === "boolean")
|
|
206
|
+
return schema;
|
|
207
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema))
|
|
208
|
+
return false;
|
|
209
|
+
if (seen.has(schema))
|
|
210
|
+
return true;
|
|
211
|
+
seen.add(schema);
|
|
212
|
+
const s = schema;
|
|
213
|
+
let allowed = true;
|
|
214
|
+
if (typeof s.type === "string")
|
|
215
|
+
allowed &&= s.type === "null";
|
|
216
|
+
else if (Array.isArray(s.type))
|
|
217
|
+
allowed &&= s.type.includes("null");
|
|
218
|
+
if (Array.isArray(s.enum))
|
|
219
|
+
allowed &&= s.enum.includes(null);
|
|
220
|
+
if ("const" in s)
|
|
221
|
+
allowed &&= s.const === null;
|
|
222
|
+
if (typeof s.$ref === "string") {
|
|
223
|
+
const target = resolveLocalRef(root, s.$ref);
|
|
224
|
+
allowed &&= target !== undefined && acceptsNull(target, root, seen);
|
|
225
|
+
}
|
|
226
|
+
if (Array.isArray(s.allOf)) {
|
|
227
|
+
allowed &&= s.allOf.every((branch) => acceptsNull(branch, root, seen));
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(s.anyOf)) {
|
|
230
|
+
allowed &&= s.anyOf.some((branch) => acceptsNull(branch, root, seen));
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(s.oneOf)) {
|
|
233
|
+
allowed &&=
|
|
234
|
+
s.oneOf.filter((branch) => acceptsNull(branch, root, new Set(seen))).length === 1;
|
|
235
|
+
}
|
|
236
|
+
if (s.not !== undefined)
|
|
237
|
+
allowed &&= !acceptsNull(s.not, root, new Set(seen));
|
|
238
|
+
seen.delete(schema);
|
|
239
|
+
return allowed;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Extract the first balanced top-level JSON object from free text and parse
|
|
243
|
+
* it. Returns undefined when no parseable object exists.
|
|
244
|
+
*/
|
|
245
|
+
export function extractFirstJsonObject(text) {
|
|
246
|
+
let start = text.indexOf("{");
|
|
247
|
+
while (start >= 0) {
|
|
248
|
+
let depth = 0;
|
|
249
|
+
let inString = false;
|
|
250
|
+
let escaped = false;
|
|
251
|
+
for (let i = start; i < text.length; i++) {
|
|
252
|
+
const ch = text[i];
|
|
253
|
+
if (inString) {
|
|
254
|
+
if (escaped)
|
|
255
|
+
escaped = false;
|
|
256
|
+
else if (ch === "\\")
|
|
257
|
+
escaped = true;
|
|
258
|
+
else if (ch === '"')
|
|
259
|
+
inString = false;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (ch === '"')
|
|
263
|
+
inString = true;
|
|
264
|
+
else if (ch === "{")
|
|
265
|
+
depth++;
|
|
266
|
+
else if (ch === "}") {
|
|
267
|
+
depth--;
|
|
268
|
+
if (depth === 0) {
|
|
269
|
+
try {
|
|
270
|
+
return JSON.parse(text.slice(start, i + 1));
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
break; // unbalanced-in-spirit; retry from the next brace
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
start = text.indexOf("{", start + 1);
|
|
279
|
+
}
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@karowanorg/orc-harness-codex",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "0BSD",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@karowanorg/orc-core": "^0.1.0",
|
|
9
|
+
"@karowanorg/orc-executors": "^0.1.0"
|
|
10
|
+
},
|
|
11
|
+
"description": "Codex harness for orc: codex app-server JSON-RPC through the executor.",
|
|
12
|
+
"types": "dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
}
|
|
28
|
+
}
|