@openclaw/codex 2026.5.1-beta.1 → 2026.5.2-beta.1
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/package.json +3 -6
- package/src/app-server/auth-profile-runtime-contract.test.ts +1 -0
- package/src/app-server/client.test.ts +51 -2
- package/src/app-server/client.ts +114 -14
- package/src/app-server/compact.ts +1 -0
- package/src/app-server/dynamic-tool-profile.ts +31 -0
- package/src/app-server/event-projector.ts +1 -0
- package/src/app-server/managed-binary.test.ts +46 -0
- package/src/app-server/managed-binary.ts +75 -4
- package/src/app-server/run-attempt.context-engine.test.ts +3 -0
- package/src/app-server/run-attempt.test.ts +130 -1
- package/src/app-server/run-attempt.ts +87 -57
- package/src/app-server/schema-normalization-runtime-contract.test.ts +1 -0
- package/src/app-server/session-history.ts +1 -5
- package/src/app-server/shared-client.test.ts +28 -0
- package/src/app-server/shared-client.ts +17 -0
- package/src/app-server/thread-lifecycle.ts +43 -18
- package/src/app-server/transcript-mirror.ts +4 -1
- package/test-api.ts +79 -0
- package/dist/.boundary-tsc.stamp +0 -1
- package/dist/.boundary-tsc.tsbuildinfo +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/codex",
|
|
3
|
-
"version": "2026.5.
|
|
3
|
+
"version": "2026.5.2-beta.1",
|
|
4
4
|
"description": "OpenClaw Codex harness and model provider plugin",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,14 +26,11 @@
|
|
|
26
26
|
"defaultChoice": "npm",
|
|
27
27
|
"minHostVersion": ">=2026.5.1-beta.1"
|
|
28
28
|
},
|
|
29
|
-
"bundle": {
|
|
30
|
-
"includeInCore": false
|
|
31
|
-
},
|
|
32
29
|
"compat": {
|
|
33
|
-
"pluginApi": ">=2026.5.
|
|
30
|
+
"pluginApi": ">=2026.5.2-beta.1"
|
|
34
31
|
},
|
|
35
32
|
"build": {
|
|
36
|
-
"openclawVersion": "2026.5.
|
|
33
|
+
"openclawVersion": "2026.5.2-beta.1"
|
|
37
34
|
},
|
|
38
35
|
"release": {
|
|
39
36
|
"publishToClawHub": true,
|
|
@@ -26,6 +26,7 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
|
|
|
26
26
|
disableTools: true,
|
|
27
27
|
timeoutMs: 5_000,
|
|
28
28
|
authStorage: {} as never,
|
|
29
|
+
authProfileStore: { version: 1, profiles: {} },
|
|
29
30
|
modelRegistry: {} as never,
|
|
30
31
|
} as EmbeddedRunAttemptParams;
|
|
31
32
|
}
|
|
@@ -61,6 +61,7 @@ describe("CodexAppServerClient", () => {
|
|
|
61
61
|
expect(warn).toHaveBeenCalledWith(
|
|
62
62
|
"failed to parse codex app-server message",
|
|
63
63
|
expect.objectContaining({
|
|
64
|
+
consoleMessage: expect.stringContaining("<redacted>"),
|
|
64
65
|
linePreview: '{"token":"<redacted>"} trailing',
|
|
65
66
|
}),
|
|
66
67
|
),
|
|
@@ -68,6 +69,40 @@ describe("CodexAppServerClient", () => {
|
|
|
68
69
|
expect(JSON.stringify(warn.mock.calls)).not.toContain("secret-value");
|
|
69
70
|
});
|
|
70
71
|
|
|
72
|
+
it("redacts prefixed env credential names from app-server previews", () => {
|
|
73
|
+
expect(
|
|
74
|
+
__testing.redactCodexAppServerLinePreview(
|
|
75
|
+
"fatal OPENAI_API_KEY=sk-live ANTHROPIC_API_KEY='anthropic-secret' OTHER=value",
|
|
76
|
+
),
|
|
77
|
+
).toBe("fatal OPENAI_API_KEY=<redacted> ANTHROPIC_API_KEY='<redacted>' OTHER=value");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("recovers app-server messages split by raw newlines inside JSON strings", async () => {
|
|
81
|
+
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
|
82
|
+
const harness = createClientHarness();
|
|
83
|
+
clients.push(harness.client);
|
|
84
|
+
const notifications: unknown[] = [];
|
|
85
|
+
harness.client.addNotificationHandler((notification) => {
|
|
86
|
+
notifications.push(notification);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
harness.process.stdout.write(
|
|
90
|
+
'{"method":"item/commandExecution/outputDelta","params":{"delta":"first' +
|
|
91
|
+
"\n" +
|
|
92
|
+
'second"}}\n',
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await vi.waitFor(() =>
|
|
96
|
+
expect(notifications).toEqual([
|
|
97
|
+
{
|
|
98
|
+
method: "item/commandExecution/outputDelta",
|
|
99
|
+
params: { delta: "first\nsecond" },
|
|
100
|
+
},
|
|
101
|
+
]),
|
|
102
|
+
);
|
|
103
|
+
expect(warn).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
71
106
|
it("preserves JSON-RPC error codes", async () => {
|
|
72
107
|
const harness = createClientHarness();
|
|
73
108
|
clients.push(harness.client);
|
|
@@ -278,9 +313,23 @@ describe("CodexAppServerClient", () => {
|
|
|
278
313
|
// an unhandled exception tearing down the gateway.
|
|
279
314
|
await expect(pending).rejects.toThrow("write EPIPE");
|
|
280
315
|
|
|
281
|
-
// Subsequent requests
|
|
316
|
+
// Subsequent requests keep the original close reason so startup logs stay actionable.
|
|
317
|
+
await expect(harness.client.request("another/method")).rejects.toThrow("write EPIPE");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("preserves redacted app-server stderr on exit errors", async () => {
|
|
321
|
+
const harness = createClientHarness();
|
|
322
|
+
clients.push(harness.client);
|
|
323
|
+
|
|
324
|
+
const pending = harness.client.request("test/method");
|
|
325
|
+
harness.process.stderr.write('fatal token="secret-value" while booting\n');
|
|
326
|
+
harness.process.emit("exit", 1, null);
|
|
327
|
+
|
|
328
|
+
await expect(pending).rejects.toThrow(
|
|
329
|
+
'codex app-server exited: code=1 signal=null stderr="fatal token=\\"<redacted>\\" while booting"',
|
|
330
|
+
);
|
|
282
331
|
await expect(harness.client.request("another/method")).rejects.toThrow(
|
|
283
|
-
"codex app-server
|
|
332
|
+
"codex app-server exited: code=1 signal=null",
|
|
284
333
|
);
|
|
285
334
|
});
|
|
286
335
|
|
package/src/app-server/client.ts
CHANGED
|
@@ -25,7 +25,10 @@ import { MIN_CODEX_APP_SERVER_VERSION } from "./version.js";
|
|
|
25
25
|
|
|
26
26
|
export { MIN_CODEX_APP_SERVER_VERSION } from "./version.js";
|
|
27
27
|
const CODEX_APP_SERVER_PARSE_LOG_MAX = 500;
|
|
28
|
+
const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 1_000_000;
|
|
29
|
+
const CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES = 1_000;
|
|
28
30
|
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 30_000;
|
|
31
|
+
const CODEX_APP_SERVER_STDERR_TAIL_MAX = 2_000;
|
|
29
32
|
|
|
30
33
|
type PendingRequest = {
|
|
31
34
|
method: string;
|
|
@@ -46,6 +49,16 @@ export class CodexAppServerRpcError extends Error {
|
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
export function isCodexAppServerConnectionClosedError(error: unknown): boolean {
|
|
53
|
+
if (!(error instanceof Error)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
error.message === "codex app-server client is closed" ||
|
|
58
|
+
error.message.startsWith("codex app-server exited:")
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
type CodexServerRequestHandler = (
|
|
50
63
|
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
|
|
51
64
|
) => Promise<JsonValue | undefined> | JsonValue | undefined;
|
|
@@ -64,26 +77,33 @@ export class CodexAppServerClient {
|
|
|
64
77
|
private nextId = 1;
|
|
65
78
|
private initialized = false;
|
|
66
79
|
private closed = false;
|
|
80
|
+
private closeError: Error | undefined;
|
|
81
|
+
private stderrTail = "";
|
|
82
|
+
private pendingParse:
|
|
83
|
+
| {
|
|
84
|
+
text: string;
|
|
85
|
+
lineCount: number;
|
|
86
|
+
firstError: unknown;
|
|
87
|
+
}
|
|
88
|
+
| undefined;
|
|
67
89
|
|
|
68
90
|
private constructor(child: CodexAppServerTransport) {
|
|
69
91
|
this.child = child;
|
|
70
92
|
this.lines = createInterface({ input: child.stdout });
|
|
71
93
|
this.lines.on("line", (line) => this.handleLine(line));
|
|
72
94
|
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
73
|
-
const text = chunk.toString("utf8")
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
const text = chunk.toString("utf8");
|
|
96
|
+
this.stderrTail = appendBoundedTail(this.stderrTail, text, CODEX_APP_SERVER_STDERR_TAIL_MAX);
|
|
97
|
+
const trimmed = text.trim();
|
|
98
|
+
if (trimmed) {
|
|
99
|
+
embeddedAgentLog.debug(`codex app-server stderr: ${trimmed}`);
|
|
76
100
|
}
|
|
77
101
|
});
|
|
78
102
|
child.once("error", (error) =>
|
|
79
103
|
this.closeWithError(error instanceof Error ? error : new Error(String(error))),
|
|
80
104
|
);
|
|
81
105
|
child.once("exit", (code, signal) => {
|
|
82
|
-
this.closeWithError(
|
|
83
|
-
new Error(
|
|
84
|
-
`codex app-server exited: code=${formatExitValue(code)} signal=${formatExitValue(signal)}`,
|
|
85
|
-
),
|
|
86
|
-
);
|
|
106
|
+
this.closeWithError(buildCodexAppServerExitError(code, signal, this.stderrTail));
|
|
87
107
|
});
|
|
88
108
|
// Guard against unhandled EPIPE / write-after-close errors on the stdin
|
|
89
109
|
// stream. When the child process terminates abruptly the pipe can break
|
|
@@ -152,7 +172,7 @@ export class CodexAppServerClient {
|
|
|
152
172
|
): Promise<T> {
|
|
153
173
|
options ??= {};
|
|
154
174
|
if (this.closed) {
|
|
155
|
-
return Promise.reject(new Error("codex app-server client is closed"));
|
|
175
|
+
return Promise.reject(this.closeError ?? new Error("codex app-server client is closed"));
|
|
156
176
|
}
|
|
157
177
|
if (options.signal?.aborted) {
|
|
158
178
|
return Promise.reject(new Error(`${method} aborted`));
|
|
@@ -262,7 +282,12 @@ export class CodexAppServerClient {
|
|
|
262
282
|
}
|
|
263
283
|
|
|
264
284
|
private handleLine(line: string): void {
|
|
265
|
-
const
|
|
285
|
+
const rawLine = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
286
|
+
if (this.pendingParse) {
|
|
287
|
+
this.handlePendingParseLine(rawLine);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const trimmed = rawLine.trim();
|
|
266
291
|
if (!trimmed) {
|
|
267
292
|
return;
|
|
268
293
|
}
|
|
@@ -270,12 +295,43 @@ export class CodexAppServerClient {
|
|
|
270
295
|
try {
|
|
271
296
|
parsed = JSON.parse(trimmed);
|
|
272
297
|
} catch (error) {
|
|
273
|
-
|
|
274
|
-
error
|
|
275
|
-
|
|
276
|
-
}
|
|
298
|
+
if (shouldBufferCodexAppServerParseFailure(trimmed, error)) {
|
|
299
|
+
this.pendingParse = { text: trimmed, lineCount: 1, firstError: error };
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
logCodexAppServerParseFailure(trimmed, error, 1);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
this.handleParsedMessage(parsed);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private handlePendingParseLine(line: string): void {
|
|
309
|
+
const pending = this.pendingParse;
|
|
310
|
+
if (!pending) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const candidate = `${pending.text}\\n${line}`;
|
|
314
|
+
let parsed: unknown;
|
|
315
|
+
try {
|
|
316
|
+
parsed = JSON.parse(candidate);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
const lineCount = pending.lineCount + 1;
|
|
319
|
+
if (
|
|
320
|
+
candidate.length <= CODEX_APP_SERVER_PARSE_BUFFER_MAX &&
|
|
321
|
+
lineCount <= CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES
|
|
322
|
+
) {
|
|
323
|
+
this.pendingParse = { text: candidate, lineCount, firstError: pending.firstError };
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.pendingParse = undefined;
|
|
327
|
+
logCodexAppServerParseFailure(candidate, error, lineCount);
|
|
277
328
|
return;
|
|
278
329
|
}
|
|
330
|
+
this.pendingParse = undefined;
|
|
331
|
+
this.handleParsedMessage(parsed);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private handleParsedMessage(parsed: unknown): void {
|
|
279
335
|
if (!parsed || typeof parsed !== "object") {
|
|
280
336
|
return;
|
|
281
337
|
}
|
|
@@ -396,6 +452,7 @@ export class CodexAppServerClient {
|
|
|
396
452
|
return false;
|
|
397
453
|
}
|
|
398
454
|
this.closed = true;
|
|
455
|
+
this.closeError = error;
|
|
399
456
|
this.lines.close();
|
|
400
457
|
this.rejectPendingRequests(error);
|
|
401
458
|
return true;
|
|
@@ -541,12 +598,55 @@ function redactCodexAppServerLinePreview(value: string): string {
|
|
|
541
598
|
.replace(
|
|
542
599
|
/("(?:api_?key|authorization|token|access_token|refresh_token)"\s*:\s*")([^"]+)(")/gi,
|
|
543
600
|
"$1<redacted>$3",
|
|
601
|
+
)
|
|
602
|
+
.replace(
|
|
603
|
+
/\b([a-z0-9_]*(?:api_?key|authorization|access_token|refresh_token|token))(\s*=\s*)(["']?)[^\s"']+(\3)/gi,
|
|
604
|
+
"$1$2$3<redacted>$4",
|
|
544
605
|
);
|
|
545
606
|
return redacted.length > CODEX_APP_SERVER_PARSE_LOG_MAX
|
|
546
607
|
? `${redacted.slice(0, CODEX_APP_SERVER_PARSE_LOG_MAX)}...`
|
|
547
608
|
: redacted;
|
|
548
609
|
}
|
|
549
610
|
|
|
611
|
+
function appendBoundedTail(current: string, next: string, maxLength: number): string {
|
|
612
|
+
const combined = `${current}${next}`;
|
|
613
|
+
return combined.length > maxLength ? combined.slice(combined.length - maxLength) : combined;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function buildCodexAppServerExitError(code: unknown, signal: unknown, stderrTail: string): Error {
|
|
617
|
+
const stderrPreview = redactCodexAppServerLinePreview(stderrTail);
|
|
618
|
+
const suffix = stderrPreview ? ` stderr=${JSON.stringify(stderrPreview)}` : "";
|
|
619
|
+
return new Error(
|
|
620
|
+
`codex app-server exited: code=${formatExitValue(code)} signal=${formatExitValue(
|
|
621
|
+
signal,
|
|
622
|
+
)}${suffix}`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function shouldBufferCodexAppServerParseFailure(value: string, error: unknown): boolean {
|
|
627
|
+
if (!value.startsWith("{") && !value.startsWith("[")) {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
631
|
+
return (
|
|
632
|
+
message.includes("Unterminated string") || message.includes("Unexpected end of JSON input")
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function logCodexAppServerParseFailure(value: string, error: unknown, fragmentCount: number): void {
|
|
637
|
+
const linePreview = redactCodexAppServerLinePreview(value);
|
|
638
|
+
const suffix = fragmentCount > 1 ? ` fragments=${fragmentCount}` : "";
|
|
639
|
+
embeddedAgentLog.warn("failed to parse codex app-server message", {
|
|
640
|
+
error,
|
|
641
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
642
|
+
fragmentCount,
|
|
643
|
+
linePreview,
|
|
644
|
+
consoleMessage: `failed to parse codex app-server message${suffix}: preview=${JSON.stringify(
|
|
645
|
+
linePreview,
|
|
646
|
+
)}`,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
550
650
|
const CODEX_APP_SERVER_APPROVAL_REQUEST_METHODS = new Set([
|
|
551
651
|
"item/commandExecution/requestApproval",
|
|
552
652
|
"item/fileChange/requestApproval",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CodexPluginConfig } from "./config.js";
|
|
2
|
+
|
|
3
|
+
export const CODEX_NATIVE_FIRST_DYNAMIC_TOOL_EXCLUDES = [
|
|
4
|
+
"read",
|
|
5
|
+
"write",
|
|
6
|
+
"edit",
|
|
7
|
+
"apply_patch",
|
|
8
|
+
"exec",
|
|
9
|
+
"process",
|
|
10
|
+
"update_plan",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export function applyCodexDynamicToolProfile<T extends { name: string }>(
|
|
14
|
+
tools: T[],
|
|
15
|
+
config: Pick<CodexPluginConfig, "codexDynamicToolsProfile" | "codexDynamicToolsExclude">,
|
|
16
|
+
): T[] {
|
|
17
|
+
const excludes = new Set<string>();
|
|
18
|
+
const profile = config.codexDynamicToolsProfile ?? "native-first";
|
|
19
|
+
if (profile === "native-first") {
|
|
20
|
+
for (const name of CODEX_NATIVE_FIRST_DYNAMIC_TOOL_EXCLUDES) {
|
|
21
|
+
excludes.add(name);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const name of config.codexDynamicToolsExclude ?? []) {
|
|
25
|
+
const trimmed = name.trim();
|
|
26
|
+
if (trimmed) {
|
|
27
|
+
excludes.add(trimmed);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return excludes.size === 0 ? tools : tools.filter((tool) => !excludes.has(tool.name));
|
|
31
|
+
}
|
|
@@ -220,6 +220,7 @@ export class CodexAppServerEventProjector {
|
|
|
220
220
|
timedOut: false,
|
|
221
221
|
idleTimedOut: false,
|
|
222
222
|
timedOutDuringCompaction: false,
|
|
223
|
+
timedOutDuringToolExecution: false,
|
|
223
224
|
promptError,
|
|
224
225
|
promptErrorSource: promptError ? this.promptErrorSource || "prompt" : null,
|
|
225
226
|
sessionIdUsed: this.params.sessionId,
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import { describe, expect, it, vi } from "vitest";
|
|
3
5
|
import type { CodexAppServerStartOptions } from "./config.js";
|
|
4
6
|
import {
|
|
7
|
+
__testing,
|
|
5
8
|
resolveManagedCodexAppServerPaths,
|
|
6
9
|
resolveManagedCodexAppServerStartOptions,
|
|
7
10
|
} from "./managed-binary.js";
|
|
@@ -64,6 +67,16 @@ describe("managed Codex app-server binary", () => {
|
|
|
64
67
|
);
|
|
65
68
|
});
|
|
66
69
|
|
|
70
|
+
it("uses the package root when the resolver is bundled into a dist chunk", () => {
|
|
71
|
+
expect(__testing.resolveDefaultCodexPluginRoot("/repo/openclaw/dist")).toBe("/repo/openclaw");
|
|
72
|
+
expect(__testing.resolveDefaultCodexPluginRoot("/repo/openclaw/dist-runtime")).toBe(
|
|
73
|
+
"/repo/openclaw",
|
|
74
|
+
);
|
|
75
|
+
expect(
|
|
76
|
+
__testing.resolveDefaultCodexPluginRoot("/repo/openclaw/extensions/codex/src/app-server"),
|
|
77
|
+
).toBe("/repo/openclaw/extensions/codex");
|
|
78
|
+
});
|
|
79
|
+
|
|
67
80
|
it("finds Codex in the package install root used by packaged plugins", async () => {
|
|
68
81
|
const installRoot = path.join("/tmp", "openclaw-plugin-package", "codex");
|
|
69
82
|
const pluginRoot = path.join(installRoot, "dist", "extensions", "codex");
|
|
@@ -83,6 +96,39 @@ describe("managed Codex app-server binary", () => {
|
|
|
83
96
|
});
|
|
84
97
|
});
|
|
85
98
|
|
|
99
|
+
it("falls back to the resolved Codex package bin when no command shim exists", async () => {
|
|
100
|
+
const installRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-codex-package-"));
|
|
101
|
+
const pluginRoot = path.join(installRoot, "dist", "extensions", "codex");
|
|
102
|
+
const packageRoot = path.join(installRoot, "node_modules", "@openai", "codex");
|
|
103
|
+
const packageBin = path.join(packageRoot, "bin", "codex.js");
|
|
104
|
+
await mkdir(path.dirname(packageBin), { recursive: true });
|
|
105
|
+
await writeFile(
|
|
106
|
+
path.join(packageRoot, "package.json"),
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
name: "@openai/codex",
|
|
109
|
+
bin: {
|
|
110
|
+
codex: "bin/codex.js",
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
await writeFile(packageBin, "#!/usr/bin/env node\n");
|
|
115
|
+
const resolvedPackageBin = await realpath(packageBin);
|
|
116
|
+
|
|
117
|
+
const pathExists = vi.fn(async (filePath: string) => filePath === resolvedPackageBin);
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
|
|
121
|
+
platform: "linux",
|
|
122
|
+
pluginRoot,
|
|
123
|
+
pathExists,
|
|
124
|
+
}),
|
|
125
|
+
).resolves.toEqual({
|
|
126
|
+
...startOptions("managed"),
|
|
127
|
+
command: resolvedPackageBin,
|
|
128
|
+
commandSource: "resolved-managed",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
86
132
|
it("fails clearly when the managed Codex binary is missing", async () => {
|
|
87
133
|
await expect(
|
|
88
134
|
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { constants as fsConstants } from "node:fs";
|
|
1
|
+
import { constants as fsConstants, readFileSync } from "node:fs";
|
|
2
2
|
import { access } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import type { CodexAppServerStartOptions } from "./config.js";
|
|
6
7
|
import { MANAGED_CODEX_APP_SERVER_PACKAGE } from "./version.js";
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
+
const CODEX_APP_SERVER_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const CODEX_PLUGIN_ROOT = resolveDefaultCodexPluginRoot(CODEX_APP_SERVER_MODULE_DIR);
|
|
9
11
|
|
|
10
12
|
type ManagedCodexAppServerPaths = {
|
|
11
13
|
commandPath: string;
|
|
@@ -66,7 +68,29 @@ function resolveManagedCodexAppServerCommandCandidates(
|
|
|
66
68
|
): string[] {
|
|
67
69
|
const pathApi = pathForPlatform(platform);
|
|
68
70
|
const commandName = platform === "win32" ? "codex.cmd" : "codex";
|
|
69
|
-
const roots =
|
|
71
|
+
const roots = resolveManagedCodexAppServerCandidateRoots(pluginRoot, platform);
|
|
72
|
+
return [
|
|
73
|
+
...new Set([
|
|
74
|
+
...roots.map((root) => pathApi.join(root, "node_modules", ".bin", commandName)),
|
|
75
|
+
...resolveManagedCodexPackageBinCandidates(roots, platform),
|
|
76
|
+
]),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveDefaultCodexPluginRoot(moduleDir: string): string {
|
|
81
|
+
const moduleBaseName = path.basename(moduleDir);
|
|
82
|
+
if (moduleBaseName === "dist" || moduleBaseName === "dist-runtime") {
|
|
83
|
+
return path.dirname(moduleDir);
|
|
84
|
+
}
|
|
85
|
+
return path.resolve(moduleDir, "..", "..");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveManagedCodexAppServerCandidateRoots(
|
|
89
|
+
pluginRoot: string,
|
|
90
|
+
platform: NodeJS.Platform,
|
|
91
|
+
): string[] {
|
|
92
|
+
const pathApi = pathForPlatform(platform);
|
|
93
|
+
return [
|
|
70
94
|
pluginRoot,
|
|
71
95
|
pathApi.dirname(pluginRoot),
|
|
72
96
|
pathApi.dirname(pathApi.dirname(pluginRoot)),
|
|
@@ -74,9 +98,56 @@ function resolveManagedCodexAppServerCommandCandidates(
|
|
|
74
98
|
? pathApi.dirname(pathApi.dirname(pathApi.dirname(pluginRoot)))
|
|
75
99
|
: null,
|
|
76
100
|
].filter((root): root is string => Boolean(root));
|
|
77
|
-
return [...new Set(roots.map((root) => pathApi.join(root, "node_modules", ".bin", commandName)))];
|
|
78
101
|
}
|
|
79
102
|
|
|
103
|
+
function resolveManagedCodexPackageBinCandidates(
|
|
104
|
+
roots: readonly string[],
|
|
105
|
+
platform: NodeJS.Platform,
|
|
106
|
+
): string[] {
|
|
107
|
+
if (platform === "win32") {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const candidates: string[] = [];
|
|
112
|
+
for (const root of roots) {
|
|
113
|
+
const candidate = resolveManagedCodexPackageBinCandidate(root);
|
|
114
|
+
if (candidate) {
|
|
115
|
+
candidates.push(candidate);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return candidates;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveManagedCodexPackageBinCandidate(root: string): string | null {
|
|
122
|
+
try {
|
|
123
|
+
const requireFromRoot = createRequire(path.join(root, "package.json"));
|
|
124
|
+
const packageJsonPath = requireFromRoot.resolve(
|
|
125
|
+
`${MANAGED_CODEX_APP_SERVER_PACKAGE}/package.json`,
|
|
126
|
+
);
|
|
127
|
+
const packageRoot = path.dirname(packageJsonPath);
|
|
128
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
129
|
+
bin?: unknown;
|
|
130
|
+
};
|
|
131
|
+
const binPath =
|
|
132
|
+
typeof packageJson.bin === "string"
|
|
133
|
+
? packageJson.bin
|
|
134
|
+
: isRecord(packageJson.bin) && typeof packageJson.bin.codex === "string"
|
|
135
|
+
? packageJson.bin.codex
|
|
136
|
+
: null;
|
|
137
|
+
return binPath ? path.resolve(packageRoot, binPath) : null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
144
|
+
return typeof value === "object" && value !== null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const __testing = {
|
|
148
|
+
resolveDefaultCodexPluginRoot,
|
|
149
|
+
};
|
|
150
|
+
|
|
80
151
|
function isDistExtensionRoot(pluginRoot: string, platform: NodeJS.Platform): boolean {
|
|
81
152
|
const pathApi = pathForPlatform(platform);
|
|
82
153
|
const extensionsDir = pathApi.dirname(pluginRoot);
|
|
@@ -30,6 +30,7 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
|
|
|
30
30
|
disableTools: true,
|
|
31
31
|
timeoutMs: 5_000,
|
|
32
32
|
authStorage: {} as never,
|
|
33
|
+
authProfileStore: { version: 1, profiles: {} },
|
|
33
34
|
modelRegistry: {} as never,
|
|
34
35
|
} as EmbeddedRunAttemptParams;
|
|
35
36
|
}
|
|
@@ -212,6 +213,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
|
|
212
213
|
SessionManager.open(sessionFile).appendMessage(
|
|
213
214
|
assistantMessage("existing context", Date.now()) as never,
|
|
214
215
|
);
|
|
216
|
+
const openSpy = vi.spyOn(SessionManager, "open");
|
|
215
217
|
const contextEngine = createContextEngine();
|
|
216
218
|
const harness = createStartedThreadHarness();
|
|
217
219
|
const params = createParams(sessionFile, workspaceDir);
|
|
@@ -265,6 +267,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
|
|
265
267
|
|
|
266
268
|
await harness.completeTurn();
|
|
267
269
|
await run;
|
|
270
|
+
expect(openSpy).not.toHaveBeenCalled();
|
|
268
271
|
});
|
|
269
272
|
|
|
270
273
|
it("calls afterTurn with the mirrored transcript and runs turn maintenance", async () => {
|