@kodelyth/codex 2026.5.42 → 2026.6.2
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 +17 -2
- package/doctor-contract-api.test.ts +0 -44
- package/doctor-contract-api.ts +0 -68
- package/harness.ts +0 -72
- package/index.test.ts +0 -230
- package/index.ts +0 -66
- package/media-understanding-provider.test.ts +0 -486
- package/media-understanding-provider.ts +0 -521
- package/prompt-overlay-runtime-contract.test.ts +0 -48
- package/prompt-overlay.ts +0 -21
- package/provider-catalog.ts +0 -83
- package/provider-discovery.ts +0 -45
- package/provider.test.ts +0 -384
- package/provider.ts +0 -243
- package/src/app-server/app-inventory-cache.test.ts +0 -176
- package/src/app-server/app-inventory-cache.ts +0 -324
- package/src/app-server/approval-bridge.test.ts +0 -1471
- package/src/app-server/approval-bridge.ts +0 -1211
- package/src/app-server/auth-bridge.test.ts +0 -1449
- package/src/app-server/auth-bridge.ts +0 -614
- package/src/app-server/auth-profile-runtime-contract.test.ts +0 -239
- package/src/app-server/capabilities.ts +0 -27
- package/src/app-server/client-factory.ts +0 -24
- package/src/app-server/client.test.ts +0 -563
- package/src/app-server/client.ts +0 -715
- package/src/app-server/compact.test.ts +0 -710
- package/src/app-server/compact.ts +0 -500
- package/src/app-server/computer-use.test.ts +0 -788
- package/src/app-server/computer-use.ts +0 -683
- package/src/app-server/config.test.ts +0 -879
- package/src/app-server/config.ts +0 -1038
- package/src/app-server/context-engine-projection.test.ts +0 -252
- package/src/app-server/context-engine-projection.ts +0 -403
- package/src/app-server/delivery-no-reply-runtime-contract.test.ts +0 -80
- package/src/app-server/dynamic-tool-diagnostics.ts +0 -73
- package/src/app-server/dynamic-tool-profile.ts +0 -69
- package/src/app-server/dynamic-tools.test.ts +0 -1302
- package/src/app-server/dynamic-tools.ts +0 -623
- package/src/app-server/elicitation-bridge.test.ts +0 -1056
- package/src/app-server/elicitation-bridge.ts +0 -783
- package/src/app-server/event-projector.test.ts +0 -2668
- package/src/app-server/event-projector.ts +0 -2057
- package/src/app-server/image-payload-sanitizer.test.ts +0 -49
- package/src/app-server/image-payload-sanitizer.ts +0 -167
- package/src/app-server/klaw-owned-tool-runtime-contract.test.ts +0 -456
- package/src/app-server/local-runtime-attribution.ts +0 -39
- package/src/app-server/managed-binary.test.ts +0 -139
- package/src/app-server/managed-binary.ts +0 -193
- package/src/app-server/models.test.ts +0 -246
- package/src/app-server/models.ts +0 -172
- package/src/app-server/native-hook-relay.test.ts +0 -271
- package/src/app-server/native-hook-relay.ts +0 -150
- package/src/app-server/native-subagent-task-mirror.test.ts +0 -573
- package/src/app-server/native-subagent-task-mirror.ts +0 -497
- package/src/app-server/outcome-fallback-runtime-contract.test.ts +0 -404
- package/src/app-server/plugin-activation.test.ts +0 -336
- package/src/app-server/plugin-activation.ts +0 -283
- package/src/app-server/plugin-app-cache-key.ts +0 -74
- package/src/app-server/plugin-approval-roundtrip.ts +0 -122
- package/src/app-server/plugin-inventory.test.ts +0 -355
- package/src/app-server/plugin-inventory.ts +0 -357
- package/src/app-server/plugin-thread-config.test.ts +0 -865
- package/src/app-server/plugin-thread-config.ts +0 -455
- package/src/app-server/protocol-generated/json/DynamicToolCallParams.json +0 -33
- package/src/app-server/protocol-generated/json/v2/ErrorNotification.json +0 -199
- package/src/app-server/protocol-generated/json/v2/GetAccountResponse.json +0 -102
- package/src/app-server/protocol-generated/json/v2/ModelListResponse.json +0 -227
- package/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json +0 -2630
- package/src/app-server/protocol-generated/json/v2/ThreadStartResponse.json +0 -2630
- package/src/app-server/protocol-generated/json/v2/TurnCompletedNotification.json +0 -1659
- package/src/app-server/protocol-generated/json/v2/TurnStartResponse.json +0 -1655
- package/src/app-server/protocol-validators.test.ts +0 -75
- package/src/app-server/protocol-validators.ts +0 -203
- package/src/app-server/protocol.ts +0 -520
- package/src/app-server/rate-limit-cache.ts +0 -48
- package/src/app-server/rate-limits.test.ts +0 -202
- package/src/app-server/rate-limits.ts +0 -583
- package/src/app-server/request.ts +0 -73
- package/src/app-server/run-attempt.context-engine.test.ts +0 -1004
- package/src/app-server/run-attempt.test.ts +0 -9477
- package/src/app-server/run-attempt.ts +0 -4683
- package/src/app-server/run-attempt.vision-tools.test.ts +0 -35
- package/src/app-server/schema-normalization-runtime-contract.test.ts +0 -206
- package/src/app-server/session-binding.test.ts +0 -303
- package/src/app-server/session-binding.ts +0 -398
- package/src/app-server/session-history.ts +0 -44
- package/src/app-server/shared-client.test.ts +0 -589
- package/src/app-server/shared-client.ts +0 -289
- package/src/app-server/side-question.test.ts +0 -1175
- package/src/app-server/side-question.ts +0 -1007
- package/src/app-server/test-support.ts +0 -48
- package/src/app-server/thread-lifecycle.test.ts +0 -447
- package/src/app-server/thread-lifecycle.ts +0 -939
- package/src/app-server/thread-lifecycle.user-mcp-servers.test.ts +0 -442
- package/src/app-server/timeout.ts +0 -9
- package/src/app-server/tool-progress-normalization.ts +0 -77
- package/src/app-server/trajectory.test.ts +0 -205
- package/src/app-server/trajectory.ts +0 -365
- package/src/app-server/transcript-mirror.test.ts +0 -524
- package/src/app-server/transcript-mirror.ts +0 -208
- package/src/app-server/transcript-repair-runtime-contract.test.ts +0 -44
- package/src/app-server/transport-stdio.test.ts +0 -171
- package/src/app-server/transport-stdio.ts +0 -107
- package/src/app-server/transport-websocket.test.ts +0 -69
- package/src/app-server/transport-websocket.ts +0 -90
- package/src/app-server/transport.ts +0 -117
- package/src/app-server/user-input-bridge.test.ts +0 -249
- package/src/app-server/user-input-bridge.ts +0 -316
- package/src/app-server/version.ts +0 -4
- package/src/app-server/vision-tools.ts +0 -12
- package/src/command-account.ts +0 -544
- package/src/command-formatters.ts +0 -425
- package/src/command-handlers.ts +0 -2004
- package/src/command-rpc.test.ts +0 -16
- package/src/command-rpc.ts +0 -142
- package/src/commands.test.ts +0 -3312
- package/src/commands.ts +0 -65
- package/src/conversation-binding-data.ts +0 -124
- package/src/conversation-binding.test.ts +0 -599
- package/src/conversation-binding.ts +0 -561
- package/src/conversation-control.test.ts +0 -126
- package/src/conversation-control.ts +0 -303
- package/src/conversation-turn-collector.test.ts +0 -191
- package/src/conversation-turn-collector.ts +0 -186
- package/src/conversation-turn-input.test.ts +0 -141
- package/src/conversation-turn-input.ts +0 -106
- package/src/manifest.test.ts +0 -20
- package/src/migration/apply.ts +0 -501
- package/src/migration/helpers.ts +0 -55
- package/src/migration/plan.ts +0 -461
- package/src/migration/provider.test.ts +0 -1741
- package/src/migration/provider.ts +0 -41
- package/src/migration/source.ts +0 -643
- package/src/migration/targets.ts +0 -25
- package/src/node-cli-sessions.test.ts +0 -180
- package/src/node-cli-sessions.ts +0 -711
- package/test-api.ts +0 -82
- package/tsconfig.json +0 -16
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
-
acquireSessionWriteLock,
|
|
5
|
-
appendSessionTranscriptMessage,
|
|
6
|
-
emitSessionTranscriptUpdate,
|
|
7
|
-
resolveSessionWriteLockOptions,
|
|
8
|
-
runAgentHarnessBeforeMessageWriteHook,
|
|
9
|
-
type AgentMessage,
|
|
10
|
-
type EmbeddedRunAttemptParams,
|
|
11
|
-
type SessionWriteLockAcquireTimeoutConfig,
|
|
12
|
-
} from "klaw/plugin-sdk/agent-harness-runtime";
|
|
13
|
-
|
|
14
|
-
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
|
15
|
-
|
|
16
|
-
const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const;
|
|
17
|
-
|
|
18
|
-
function normalizeOptionalString(value: string | null | undefined): string | undefined {
|
|
19
|
-
const normalized = value?.trim();
|
|
20
|
-
return normalized ? normalized : undefined;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function buildSenderLabel(params: {
|
|
24
|
-
senderId?: string;
|
|
25
|
-
senderName?: string;
|
|
26
|
-
senderUsername?: string;
|
|
27
|
-
senderE164?: string;
|
|
28
|
-
}): string | undefined {
|
|
29
|
-
const label = params.senderName ?? params.senderUsername ?? params.senderE164 ?? params.senderId;
|
|
30
|
-
if (!label) {
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
if (!params.senderId || label.includes(params.senderId)) {
|
|
34
|
-
return label;
|
|
35
|
-
}
|
|
36
|
-
return `${label} (${params.senderId})`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): AgentMessage {
|
|
40
|
-
const senderId = normalizeOptionalString(params.senderId);
|
|
41
|
-
const senderName = normalizeOptionalString(params.senderName);
|
|
42
|
-
const senderUsername = normalizeOptionalString(params.senderUsername);
|
|
43
|
-
const senderE164 = normalizeOptionalString(params.senderE164);
|
|
44
|
-
const senderLabel = buildSenderLabel({ senderId, senderName, senderUsername, senderE164 });
|
|
45
|
-
const sourceChannel = normalizeOptionalString(
|
|
46
|
-
params.inputProvenance?.sourceChannel ?? params.messageChannel ?? params.messageProvider,
|
|
47
|
-
);
|
|
48
|
-
return {
|
|
49
|
-
role: "user",
|
|
50
|
-
content: params.prompt,
|
|
51
|
-
timestamp: Date.now(),
|
|
52
|
-
...(params.inputProvenance ? { provenance: params.inputProvenance } : {}),
|
|
53
|
-
...(sourceChannel ? { sourceChannel } : {}),
|
|
54
|
-
...(senderId ? { senderId } : {}),
|
|
55
|
-
...(senderName ? { senderName } : {}),
|
|
56
|
-
...(senderUsername ? { senderUsername } : {}),
|
|
57
|
-
...(senderE164 ? { senderE164 } : {}),
|
|
58
|
-
...(senderLabel ? { senderLabel } : {}),
|
|
59
|
-
} as AgentMessage;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Tag a message with a stable logical identity for mirror dedupe. Callers
|
|
64
|
-
* should use a value that is invariant for the same logical message across
|
|
65
|
-
* re-emits (e.g. `${turnId}:prompt`, `${turnId}:assistant`) but distinct
|
|
66
|
-
* for genuinely-distinct messages (different turns, different kinds). When
|
|
67
|
-
* present this identity replaces the role/content fingerprint in the
|
|
68
|
-
* idempotency key, so the dedupe survives caller-scope rotation without
|
|
69
|
-
* collapsing distinct same-content turns.
|
|
70
|
-
*/
|
|
71
|
-
export function attachCodexMirrorIdentity<T extends AgentMessage>(message: T, identity: string): T {
|
|
72
|
-
const record = message as unknown as Record<string, unknown>;
|
|
73
|
-
const existing = record["__klaw"];
|
|
74
|
-
const baseMeta =
|
|
75
|
-
existing && typeof existing === "object" && !Array.isArray(existing)
|
|
76
|
-
? (existing as Record<string, unknown>)
|
|
77
|
-
: {};
|
|
78
|
-
return {
|
|
79
|
-
...record,
|
|
80
|
-
__klaw: { ...baseMeta, [MIRROR_IDENTITY_META_KEY]: identity },
|
|
81
|
-
} as unknown as T;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function readMirrorIdentity(message: MirroredAgentMessage): string | undefined {
|
|
85
|
-
const record = message as unknown as { __klaw?: unknown };
|
|
86
|
-
const meta = record["__klaw"];
|
|
87
|
-
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
|
-
const id = (meta as Record<string, unknown>)[MIRROR_IDENTITY_META_KEY];
|
|
91
|
-
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Fallback content fingerprint for callers that did not tag the message
|
|
95
|
-
// with a stable mirror identity. Only role and content participate; volatile
|
|
96
|
-
// metadata (timestamps, usage, etc.) is intentionally excluded so the
|
|
97
|
-
// fingerprint survives snapshot reordering inside a fixed scope. Distinct
|
|
98
|
-
// same-content turns are still distinguished by the caller's idempotency
|
|
99
|
-
// scope when callers route through this fallback.
|
|
100
|
-
function fingerprintMirrorMessageContent(message: MirroredAgentMessage): string {
|
|
101
|
-
const payload = JSON.stringify({ role: message.role, content: message.content });
|
|
102
|
-
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
|
|
106
|
-
const explicit = readMirrorIdentity(message);
|
|
107
|
-
if (explicit) {
|
|
108
|
-
return explicit;
|
|
109
|
-
}
|
|
110
|
-
return `${message.role}:${fingerprintMirrorMessageContent(message)}`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export async function mirrorCodexAppServerTranscript(params: {
|
|
114
|
-
sessionFile: string;
|
|
115
|
-
sessionKey?: string;
|
|
116
|
-
agentId?: string;
|
|
117
|
-
messages: AgentMessage[];
|
|
118
|
-
idempotencyScope?: string;
|
|
119
|
-
config?: SessionWriteLockAcquireTimeoutConfig;
|
|
120
|
-
}): Promise<void> {
|
|
121
|
-
const messages = params.messages.filter(
|
|
122
|
-
(message): message is MirroredAgentMessage =>
|
|
123
|
-
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
|
124
|
-
);
|
|
125
|
-
if (messages.length === 0) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const lock = await acquireSessionWriteLock({
|
|
130
|
-
sessionFile: params.sessionFile,
|
|
131
|
-
...resolveSessionWriteLockOptions(params.config),
|
|
132
|
-
});
|
|
133
|
-
try {
|
|
134
|
-
const existingIdempotencyKeys = await readTranscriptIdempotencyKeys(params.sessionFile);
|
|
135
|
-
for (const message of messages) {
|
|
136
|
-
const dedupeIdentity = buildMirrorDedupeIdentity(message);
|
|
137
|
-
const idempotencyKey = params.idempotencyScope
|
|
138
|
-
? `${params.idempotencyScope}:${dedupeIdentity}`
|
|
139
|
-
: undefined;
|
|
140
|
-
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
const transcriptMessage = {
|
|
144
|
-
...message,
|
|
145
|
-
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
146
|
-
} as AgentMessage;
|
|
147
|
-
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
|
148
|
-
message: transcriptMessage,
|
|
149
|
-
agentId: params.agentId,
|
|
150
|
-
sessionKey: params.sessionKey,
|
|
151
|
-
});
|
|
152
|
-
if (!nextMessage) {
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
const messageToAppend = (
|
|
156
|
-
idempotencyKey
|
|
157
|
-
? {
|
|
158
|
-
...(nextMessage as unknown as Record<string, unknown>),
|
|
159
|
-
idempotencyKey,
|
|
160
|
-
}
|
|
161
|
-
: nextMessage
|
|
162
|
-
) as AgentMessage;
|
|
163
|
-
await appendSessionTranscriptMessage({
|
|
164
|
-
transcriptPath: params.sessionFile,
|
|
165
|
-
message: messageToAppend,
|
|
166
|
-
config: params.config,
|
|
167
|
-
});
|
|
168
|
-
if (idempotencyKey) {
|
|
169
|
-
existingIdempotencyKeys.add(idempotencyKey);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
} finally {
|
|
173
|
-
await lock.release();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (params.sessionKey) {
|
|
177
|
-
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
|
|
178
|
-
} else {
|
|
179
|
-
emitSessionTranscriptUpdate(params.sessionFile);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async function readTranscriptIdempotencyKeys(sessionFile: string): Promise<Set<string>> {
|
|
184
|
-
const keys = new Set<string>();
|
|
185
|
-
let raw: string;
|
|
186
|
-
try {
|
|
187
|
-
raw = await fs.readFile(sessionFile, "utf8");
|
|
188
|
-
} catch (error) {
|
|
189
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
190
|
-
throw error;
|
|
191
|
-
}
|
|
192
|
-
return keys;
|
|
193
|
-
}
|
|
194
|
-
for (const line of raw.split(/\r?\n/)) {
|
|
195
|
-
if (!line.trim()) {
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
|
|
200
|
-
if (typeof parsed.message?.idempotencyKey === "string") {
|
|
201
|
-
keys.add(parsed.message.idempotencyKey);
|
|
202
|
-
}
|
|
203
|
-
} catch {
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return keys;
|
|
208
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
assistantHistoryMessage,
|
|
3
|
-
currentPromptHistoryMessage,
|
|
4
|
-
mediaOnlyHistoryMessage,
|
|
5
|
-
structuredHistoryMessage,
|
|
6
|
-
} from "klaw/plugin-sdk/agent-runtime-test-contracts";
|
|
7
|
-
import { describe, expect, it } from "vitest";
|
|
8
|
-
import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js";
|
|
9
|
-
|
|
10
|
-
describe("Codex transcript projection runtime contract", () => {
|
|
11
|
-
it("drops only the duplicate trailing current prompt while preserving prior structured context", () => {
|
|
12
|
-
const prompt = "newest inbound message";
|
|
13
|
-
|
|
14
|
-
const result = projectContextEngineAssemblyForCodex({
|
|
15
|
-
prompt,
|
|
16
|
-
originalHistoryMessages: [structuredHistoryMessage()],
|
|
17
|
-
assembledMessages: [
|
|
18
|
-
structuredHistoryMessage(),
|
|
19
|
-
assistantHistoryMessage(),
|
|
20
|
-
currentPromptHistoryMessage(prompt),
|
|
21
|
-
],
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
expect(result.promptText).toContain("Current user request:\nnewest inbound message");
|
|
25
|
-
expect(result.promptText).toContain("[user]\nolder structured context\n[image omitted]");
|
|
26
|
-
expect(result.promptText).toContain("[assistant]\nack");
|
|
27
|
-
expect(result.promptText).not.toContain("[user]\nnewest inbound message");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("keeps media-only user history visible as omitted media instead of dropping the turn", () => {
|
|
31
|
-
const result = projectContextEngineAssemblyForCodex({
|
|
32
|
-
prompt: "newest inbound message",
|
|
33
|
-
originalHistoryMessages: [mediaOnlyHistoryMessage()],
|
|
34
|
-
assembledMessages: [
|
|
35
|
-
mediaOnlyHistoryMessage(),
|
|
36
|
-
currentPromptHistoryMessage("newest inbound message"),
|
|
37
|
-
],
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
expect(result.promptText).toContain("[user]\n[image omitted]");
|
|
41
|
-
expect(result.promptText).not.toContain("data:image/png");
|
|
42
|
-
expect(result.promptText).not.toContain("bbbb");
|
|
43
|
-
});
|
|
44
|
-
});
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import type { CodexAppServerStartOptions } from "./config.js";
|
|
6
|
-
import {
|
|
7
|
-
resolveCodexAppServerSpawnEnv,
|
|
8
|
-
resolveCodexAppServerSpawnInvocation,
|
|
9
|
-
} from "./transport-stdio.js";
|
|
10
|
-
|
|
11
|
-
const tempDirs: string[] = [];
|
|
12
|
-
|
|
13
|
-
async function createTempDir(): Promise<string> {
|
|
14
|
-
const dir = await mkdtemp(path.join(os.tmpdir(), "klaw-codex-spawn-"));
|
|
15
|
-
tempDirs.push(dir);
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
afterEach(async () => {
|
|
20
|
-
for (const dir of tempDirs.splice(0)) {
|
|
21
|
-
await rm(dir, { recursive: true, force: true });
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
function startOptions(command: string): CodexAppServerStartOptions {
|
|
26
|
-
return {
|
|
27
|
-
transport: "stdio",
|
|
28
|
-
command,
|
|
29
|
-
args: ["app-server", "--listen", "stdio://"],
|
|
30
|
-
headers: {},
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("resolveCodexAppServerSpawnInvocation", () => {
|
|
35
|
-
it("keeps non-Windows Codex app-server invocation unchanged", () => {
|
|
36
|
-
const resolved = resolveCodexAppServerSpawnInvocation(startOptions("codex"), {
|
|
37
|
-
platform: "darwin",
|
|
38
|
-
env: {},
|
|
39
|
-
execPath: "/usr/local/bin/node",
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
expect(resolved).toEqual({
|
|
43
|
-
command: "codex",
|
|
44
|
-
args: ["app-server", "--listen", "stdio://"],
|
|
45
|
-
shell: undefined,
|
|
46
|
-
windowsHide: undefined,
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("requires managed Codex commands to be resolved before spawn", () => {
|
|
51
|
-
expect(() =>
|
|
52
|
-
resolveCodexAppServerSpawnInvocation(
|
|
53
|
-
{
|
|
54
|
-
...startOptions("codex"),
|
|
55
|
-
commandSource: "managed",
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
platform: "darwin",
|
|
59
|
-
env: {},
|
|
60
|
-
execPath: "/usr/local/bin/node",
|
|
61
|
-
},
|
|
62
|
-
),
|
|
63
|
-
).toThrow("must be resolved before spawn");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("resolves Windows npm .cmd Codex shims through Node instead of raw spawn", async () => {
|
|
67
|
-
const binDir = await createTempDir();
|
|
68
|
-
const entryPath = path.join(binDir, "node_modules", "@openai", "codex", "bin", "codex.js");
|
|
69
|
-
const shimPath = path.join(binDir, "codex.cmd");
|
|
70
|
-
await mkdir(path.dirname(entryPath), { recursive: true });
|
|
71
|
-
await writeFile(entryPath, "console.log('codex')\n", "utf8");
|
|
72
|
-
await writeFile(
|
|
73
|
-
shimPath,
|
|
74
|
-
'@ECHO off\r\n"%~dp0\\node_modules\\@openai\\codex\\bin\\codex.js" %*\r\n',
|
|
75
|
-
"utf8",
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
const resolved = resolveCodexAppServerSpawnInvocation(startOptions("codex"), {
|
|
79
|
-
platform: "win32",
|
|
80
|
-
env: { PATH: binDir, PATHEXT: ".CMD;.EXE;.BAT" },
|
|
81
|
-
execPath: "C:\\node\\node.exe",
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
expect(resolved).toEqual({
|
|
85
|
-
command: "C:\\node\\node.exe",
|
|
86
|
-
args: [entryPath, "app-server", "--listen", "stdio://"],
|
|
87
|
-
shell: undefined,
|
|
88
|
-
windowsHide: true,
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe("resolveCodexAppServerSpawnEnv", () => {
|
|
94
|
-
it("applies configured env overrides before clearing denied env vars", () => {
|
|
95
|
-
expect({
|
|
96
|
-
...resolveCodexAppServerSpawnEnv(
|
|
97
|
-
{
|
|
98
|
-
env: {
|
|
99
|
-
OPENAI_API_KEY: "configured-openai-key",
|
|
100
|
-
KEEP: "override",
|
|
101
|
-
},
|
|
102
|
-
clearEnv: ["OPENAI_API_KEY", "CODEX_API_KEY", "MISSING"],
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
OPENAI_API_KEY: "parent-openai-key",
|
|
106
|
-
CODEX_API_KEY: "parent-codex-key",
|
|
107
|
-
KEEP: "parent",
|
|
108
|
-
},
|
|
109
|
-
),
|
|
110
|
-
}).toEqual({
|
|
111
|
-
KEEP: "override",
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("clears denied env vars case-insensitively on Windows", () => {
|
|
116
|
-
expect({
|
|
117
|
-
...resolveCodexAppServerSpawnEnv(
|
|
118
|
-
{
|
|
119
|
-
env: {
|
|
120
|
-
OpenAI_Api_Key: "configured-openai-key",
|
|
121
|
-
Other: "configured",
|
|
122
|
-
},
|
|
123
|
-
clearEnv: ["OPENAI_API_KEY", " CODEX_API_KEY ", ""],
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
Codex_Api_Key: "parent-codex-key",
|
|
127
|
-
KEEP: "parent",
|
|
128
|
-
},
|
|
129
|
-
"win32",
|
|
130
|
-
),
|
|
131
|
-
}).toEqual({
|
|
132
|
-
KEEP: "parent",
|
|
133
|
-
Other: "configured",
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("uses a null-prototype env map and ignores prototype-polluting keys", () => {
|
|
138
|
-
const overrides = Object.create(null) as Record<string, string | undefined>;
|
|
139
|
-
Object.defineProperty(overrides, "__proto__", {
|
|
140
|
-
value: "polluted",
|
|
141
|
-
enumerable: true,
|
|
142
|
-
});
|
|
143
|
-
Object.defineProperty(overrides, "constructor", {
|
|
144
|
-
value: "polluted",
|
|
145
|
-
enumerable: true,
|
|
146
|
-
});
|
|
147
|
-
Object.defineProperty(overrides, "prototype", {
|
|
148
|
-
value: "polluted",
|
|
149
|
-
enumerable: true,
|
|
150
|
-
});
|
|
151
|
-
overrides.SAFE = "1";
|
|
152
|
-
|
|
153
|
-
const env = resolveCodexAppServerSpawnEnv(
|
|
154
|
-
{
|
|
155
|
-
env: overrides as Record<string, string>,
|
|
156
|
-
},
|
|
157
|
-
{
|
|
158
|
-
BASE: "1",
|
|
159
|
-
},
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
expect(Object.getPrototypeOf(env)).toBeNull();
|
|
163
|
-
expect({ ...env }).toEqual({
|
|
164
|
-
BASE: "1",
|
|
165
|
-
SAFE: "1",
|
|
166
|
-
});
|
|
167
|
-
expect(Object.hasOwn(env, "__proto__")).toBe(false);
|
|
168
|
-
expect(Object.hasOwn(env, "constructor")).toBe(false);
|
|
169
|
-
expect(Object.hasOwn(env, "prototype")).toBe(false);
|
|
170
|
-
});
|
|
171
|
-
});
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
materializeWindowsSpawnProgram,
|
|
4
|
-
resolveWindowsSpawnProgram,
|
|
5
|
-
} from "klaw/plugin-sdk/windows-spawn";
|
|
6
|
-
import type { CodexAppServerStartOptions } from "./config.js";
|
|
7
|
-
import type { CodexAppServerTransport } from "./transport.js";
|
|
8
|
-
|
|
9
|
-
const UNSAFE_ENVIRONMENT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
10
|
-
|
|
11
|
-
type CodexAppServerSpawnRuntime = {
|
|
12
|
-
platform: NodeJS.Platform;
|
|
13
|
-
env: NodeJS.ProcessEnv;
|
|
14
|
-
execPath: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const DEFAULT_SPAWN_RUNTIME: CodexAppServerSpawnRuntime = {
|
|
18
|
-
platform: process.platform,
|
|
19
|
-
env: process.env,
|
|
20
|
-
execPath: process.execPath,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export function resolveCodexAppServerSpawnInvocation(
|
|
24
|
-
options: CodexAppServerStartOptions,
|
|
25
|
-
runtime: CodexAppServerSpawnRuntime = DEFAULT_SPAWN_RUNTIME,
|
|
26
|
-
): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } {
|
|
27
|
-
if (options.commandSource === "managed") {
|
|
28
|
-
throw new Error("Managed Codex app-server start options must be resolved before spawn.");
|
|
29
|
-
}
|
|
30
|
-
const program = resolveWindowsSpawnProgram({
|
|
31
|
-
command: options.command,
|
|
32
|
-
platform: runtime.platform,
|
|
33
|
-
env: runtime.env,
|
|
34
|
-
execPath: runtime.execPath,
|
|
35
|
-
packageName: "@openai/codex",
|
|
36
|
-
});
|
|
37
|
-
const resolved = materializeWindowsSpawnProgram(program, options.args);
|
|
38
|
-
return {
|
|
39
|
-
command: resolved.command,
|
|
40
|
-
args: resolved.argv,
|
|
41
|
-
shell: resolved.shell,
|
|
42
|
-
windowsHide: resolved.windowsHide,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function resolveCodexAppServerSpawnEnv(
|
|
47
|
-
options: Pick<CodexAppServerStartOptions, "env" | "clearEnv">,
|
|
48
|
-
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
49
|
-
platform: NodeJS.Platform = process.platform,
|
|
50
|
-
): NodeJS.ProcessEnv {
|
|
51
|
-
const env = Object.create(null) as NodeJS.ProcessEnv;
|
|
52
|
-
copySafeEnvironmentEntries(env, baseEnv);
|
|
53
|
-
copySafeEnvironmentEntries(env, options.env ?? {});
|
|
54
|
-
const keysToClear = normalizedEnvironmentKeys(options.clearEnv ?? []);
|
|
55
|
-
if (platform === "win32") {
|
|
56
|
-
const lowerCaseKeysToClear = new Set(keysToClear.map((key) => key.toLowerCase()));
|
|
57
|
-
for (const candidate of Object.keys(env)) {
|
|
58
|
-
if (lowerCaseKeysToClear.has(candidate.toLowerCase())) {
|
|
59
|
-
delete env[candidate];
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
} else {
|
|
63
|
-
for (const key of keysToClear) {
|
|
64
|
-
delete env[key];
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return env;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function normalizedEnvironmentKeys(rawKeys: readonly string[]): string[] {
|
|
71
|
-
const keys: string[] = [];
|
|
72
|
-
for (const rawKey of rawKeys) {
|
|
73
|
-
const key = rawKey.trim();
|
|
74
|
-
if (key.length > 0) {
|
|
75
|
-
keys.push(key);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return keys;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function copySafeEnvironmentEntries(
|
|
82
|
-
target: NodeJS.ProcessEnv,
|
|
83
|
-
source: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
84
|
-
): void {
|
|
85
|
-
for (const [key, value] of Object.entries(source)) {
|
|
86
|
-
if (UNSAFE_ENVIRONMENT_KEYS.has(key)) {
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
target[key] = value;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
|
|
94
|
-
const env = resolveCodexAppServerSpawnEnv(options);
|
|
95
|
-
const invocation = resolveCodexAppServerSpawnInvocation(options, {
|
|
96
|
-
platform: process.platform,
|
|
97
|
-
env,
|
|
98
|
-
execPath: process.execPath,
|
|
99
|
-
});
|
|
100
|
-
return spawn(invocation.command, invocation.args, {
|
|
101
|
-
env,
|
|
102
|
-
detached: process.platform !== "win32",
|
|
103
|
-
shell: invocation.shell,
|
|
104
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
105
|
-
windowsHide: invocation.windowsHide,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { WebSocketServer, type RawData } from "ws";
|
|
3
|
-
import { CodexAppServerClient } from "./client.js";
|
|
4
|
-
|
|
5
|
-
describe("Codex app-server websocket transport", () => {
|
|
6
|
-
const clients: CodexAppServerClient[] = [];
|
|
7
|
-
const servers: WebSocketServer[] = [];
|
|
8
|
-
|
|
9
|
-
afterEach(async () => {
|
|
10
|
-
for (const client of clients) {
|
|
11
|
-
client.close();
|
|
12
|
-
}
|
|
13
|
-
clients.length = 0;
|
|
14
|
-
await Promise.all(
|
|
15
|
-
servers
|
|
16
|
-
.splice(0)
|
|
17
|
-
.map(
|
|
18
|
-
(server) =>
|
|
19
|
-
new Promise<void>((resolve, reject) =>
|
|
20
|
-
server.close((error) => (error ? reject(error) : resolve())),
|
|
21
|
-
),
|
|
22
|
-
),
|
|
23
|
-
);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("can speak JSON-RPC over websocket transport", async () => {
|
|
27
|
-
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
28
|
-
servers.push(server);
|
|
29
|
-
const authHeaders: Array<string | undefined> = [];
|
|
30
|
-
server.on("connection", (socket, request) => {
|
|
31
|
-
authHeaders.push(request.headers.authorization);
|
|
32
|
-
socket.on("message", (data) => {
|
|
33
|
-
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
|
|
34
|
-
if (message.method === "initialize") {
|
|
35
|
-
socket.send(JSON.stringify({ id: message.id, result: { userAgent: "klaw/0.125.0" } }));
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
if (message.method === "model/list") {
|
|
39
|
-
socket.send(JSON.stringify({ id: message.id, result: { data: [] } }));
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
44
|
-
const address = server.address();
|
|
45
|
-
if (!address || typeof address === "string") {
|
|
46
|
-
throw new Error("expected websocket test server port");
|
|
47
|
-
}
|
|
48
|
-
const client = CodexAppServerClient.start({
|
|
49
|
-
transport: "websocket",
|
|
50
|
-
url: `ws://127.0.0.1:${address.port}`,
|
|
51
|
-
authToken: "secret",
|
|
52
|
-
});
|
|
53
|
-
clients.push(client);
|
|
54
|
-
|
|
55
|
-
await expect(client.initialize()).resolves.toBeUndefined();
|
|
56
|
-
await expect(client.request("model/list", {})).resolves.toEqual({ data: [] });
|
|
57
|
-
expect(authHeaders).toEqual(["Bearer secret"]);
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
function rawDataToText(data: RawData): string {
|
|
62
|
-
if (Array.isArray(data)) {
|
|
63
|
-
return Buffer.concat(data).toString("utf8");
|
|
64
|
-
}
|
|
65
|
-
if (data instanceof ArrayBuffer) {
|
|
66
|
-
return Buffer.from(new Uint8Array(data)).toString("utf8");
|
|
67
|
-
}
|
|
68
|
-
return Buffer.from(data).toString("utf8");
|
|
69
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from "node:events";
|
|
2
|
-
import { PassThrough, Writable } from "node:stream";
|
|
3
|
-
import WebSocket, { type RawData } from "ws";
|
|
4
|
-
import type { CodexAppServerStartOptions } from "./config.js";
|
|
5
|
-
import type { CodexAppServerTransport } from "./transport.js";
|
|
6
|
-
|
|
7
|
-
export function createWebSocketTransport(
|
|
8
|
-
options: CodexAppServerStartOptions,
|
|
9
|
-
): CodexAppServerTransport {
|
|
10
|
-
if (!options.url) {
|
|
11
|
-
throw new Error(
|
|
12
|
-
"codex app-server websocket transport requires plugins.entries.codex.config.appServer.url",
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
const events = new EventEmitter();
|
|
16
|
-
const stdout = new PassThrough();
|
|
17
|
-
const stderr = new PassThrough();
|
|
18
|
-
const headers = {
|
|
19
|
-
...options.headers,
|
|
20
|
-
...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}),
|
|
21
|
-
};
|
|
22
|
-
const socket = new WebSocket(options.url, { headers });
|
|
23
|
-
const pendingFrames: string[] = [];
|
|
24
|
-
let killed = false;
|
|
25
|
-
|
|
26
|
-
const sendFrame = (frame: string) => {
|
|
27
|
-
const trimmed = frame.trim();
|
|
28
|
-
if (!trimmed) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
if (socket.readyState === WebSocket.OPEN) {
|
|
32
|
-
socket.send(trimmed);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
pendingFrames.push(trimmed);
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
// `initialize` can be written before the WebSocket open event fires. Buffer
|
|
39
|
-
// whole JSON-RPC frames so stdio and websocket transports share call timing.
|
|
40
|
-
socket.once("open", () => {
|
|
41
|
-
for (const frame of pendingFrames.splice(0)) {
|
|
42
|
-
socket.send(frame);
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
socket.once("error", (error) => events.emit("error", error));
|
|
46
|
-
socket.once("close", (code, reason) => {
|
|
47
|
-
killed = true;
|
|
48
|
-
events.emit("exit", code, reason.toString("utf8"));
|
|
49
|
-
});
|
|
50
|
-
socket.on("message", (data) => {
|
|
51
|
-
const text = websocketFrameToText(data);
|
|
52
|
-
stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const stdin = new Writable({
|
|
56
|
-
write(chunk, _encoding, callback) {
|
|
57
|
-
for (const frame of chunk.toString("utf8").split("\n")) {
|
|
58
|
-
sendFrame(frame);
|
|
59
|
-
}
|
|
60
|
-
callback();
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
stdin,
|
|
66
|
-
stdout,
|
|
67
|
-
stderr,
|
|
68
|
-
get killed() {
|
|
69
|
-
return killed;
|
|
70
|
-
},
|
|
71
|
-
kill: () => {
|
|
72
|
-
killed = true;
|
|
73
|
-
socket.close();
|
|
74
|
-
},
|
|
75
|
-
once: (event, listener) => events.once(event, listener),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function websocketFrameToText(data: RawData): string {
|
|
80
|
-
if (typeof data === "string") {
|
|
81
|
-
return data;
|
|
82
|
-
}
|
|
83
|
-
if (Buffer.isBuffer(data)) {
|
|
84
|
-
return data.toString("utf8");
|
|
85
|
-
}
|
|
86
|
-
if (Array.isArray(data)) {
|
|
87
|
-
return Buffer.concat(data).toString("utf8");
|
|
88
|
-
}
|
|
89
|
-
return Buffer.from(data).toString("utf8");
|
|
90
|
-
}
|