@tangle-network/sdk-provider-codex 0.0.0-stage → 0.4.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/LICENSE +11 -0
- package/dist/index.d.mts +274 -0
- package/dist/index.mjs +1782 -0
- package/package.json +43 -3
- package/README.md +0 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1782 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { InteractionKind, ProxySecretBindingSchema, ProxySecretGrantSchema, ProxySecretReceiptSchema, ProxySecretUpdateControlsSchema, ProxySecretsSchema, isProxySecretScopeAttenuation, ownPropertyRecordSchema, proxySecretPresentationsEqual, resolveTangleRouterDefaults } from "@tangle-network/agent-interface";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { HELD_SPARE_IDLE_MS, createDeferredParser, createHeldSpareRegistry, launchDifferences, resolveRawCapturePath, spawnRpcBridge } from "@tangle-network/sdk-cli-runner";
|
|
5
|
+
import { CODEX_DISABLED_FEATURE_ARGS, CliProviderAdapterBase, InteractionBroker, McpInteractionSupport, NATIVE_SESSION_CONTRACTS, PROMPT_SECTION_SEPARATOR, SEALED_NATIVE_HOME_LAYOUT, assertSealedNativeHome, assertSealedNativeSearchPath, codexDeveloperInstructionArgs, deriveFilePart, forgetSessionScopes, interactionToggleEnabled, isLoopbackEndpoint, isPlainRecord, jsonEventParser, localMcpArgv, localMcpEnv, nativeEffortArgs, partitionImageAttachments, provenStoreBinary, reasoningEvents, recordCliTokenUsage, refuseSecretLaunch, resolveExecutableRealpath, secretMcpScope, secretSessionTurnQueue, servableMcpServer, servableStdioServer, sessionObservedEvents, streamPartBase, textDeltaEvents, toolResultEvents, toolStartEvents, trackFilePart, turnEndReason, untilSettledOrAborted, withTurnInteractions } from "@tangle-network/sdk-provider-cli-base";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import path, { join } from "node:path";
|
|
8
|
+
const TURN_KEY_PLACEHOLDER_PREFIX = "tangle-turn-key";
|
|
9
|
+
const MODEL_KEY_PROXY_PATH_PREFIX = "/v1/";
|
|
10
|
+
const LOOPBACK_BASE_URL = /^http:\/\/127\.0\.0\.1:[0-9]+$/;
|
|
11
|
+
function isTurnKeyPlaceholder(value) {
|
|
12
|
+
return value?.startsWith(`${TURN_KEY_PLACEHOLDER_PREFIX}.`) === true;
|
|
13
|
+
}
|
|
14
|
+
function parseModelKeyProxyBinding(raw) {
|
|
15
|
+
if (raw === void 0) return void 0;
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
const baseUrl = typeof parsed === "object" && parsed !== null && "baseUrl" in parsed ? parsed.baseUrl : void 0;
|
|
18
|
+
if (typeof baseUrl !== "string" || !LOOPBACK_BASE_URL.test(baseUrl)) throw new Error("Model key proxy binding is invalid");
|
|
19
|
+
return {
|
|
20
|
+
baseUrl,
|
|
21
|
+
passThrough: typeof parsed === "object" && parsed !== null && "passThrough" in parsed && parsed.passThrough === true
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const MODEL_KEY_PROXY_HOME_PATH_PREFIX = "/v1h/";
|
|
25
|
+
function modelKeyProxyHomeTag(runtimeHome) {
|
|
26
|
+
return Buffer.from(runtimeHome.replace(/\/+$/, "")).toString("base64url");
|
|
27
|
+
}
|
|
28
|
+
function proxiedModelUrl(proxyBaseUrl, upstream, options) {
|
|
29
|
+
const encoded = Buffer.from(upstream).toString("base64url");
|
|
30
|
+
return options?.runtimeHome ? `${proxyBaseUrl}${MODEL_KEY_PROXY_HOME_PATH_PREFIX}${modelKeyProxyHomeTag(options.runtimeHome)}/${encoded}` : `${proxyBaseUrl}${MODEL_KEY_PROXY_PATH_PREFIX}${encoded}`;
|
|
31
|
+
}
|
|
32
|
+
function baseProxyToken(token) {
|
|
33
|
+
const i = token.indexOf(".");
|
|
34
|
+
return i === -1 ? token : token.slice(0, i);
|
|
35
|
+
}
|
|
36
|
+
const MODEL_PROXY_TOKENS = {
|
|
37
|
+
OPENCODE_MODEL_API_KEY: "proxy-model-token",
|
|
38
|
+
OPENAI_API_KEY: "proxy-openai-token",
|
|
39
|
+
ANTHROPIC_API_KEY: "proxy-anthropic-token",
|
|
40
|
+
OPENROUTER_API_KEY: "proxy-openrouter-token",
|
|
41
|
+
TANGLE_INFERENCE_KEY: "proxy-openai-token",
|
|
42
|
+
ZAI_API_KEY: "proxy-zai-token",
|
|
43
|
+
ZHIPU_API_KEY: "proxy-zai-token"
|
|
44
|
+
};
|
|
45
|
+
const MODEL_PROXY_TOKEN_VALUES = new Set(Object.values(MODEL_PROXY_TOKENS));
|
|
46
|
+
function isManagedModelToken(value) {
|
|
47
|
+
const key = value?.trim();
|
|
48
|
+
return !!key && MODEL_PROXY_TOKEN_VALUES.has(baseProxyToken(key));
|
|
49
|
+
}
|
|
50
|
+
function managedModelProxyUrl(input) {
|
|
51
|
+
if (!isManagedModelToken(input.apiKey) || isTurnKeyPlaceholder(input.apiKey)) return void 0;
|
|
52
|
+
const binding = parseModelKeyProxyBinding(input.binding);
|
|
53
|
+
if (binding?.passThrough !== true) return void 0;
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(input.upstream);
|
|
56
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
|
|
57
|
+
if (url.username || url.password || url.search || url.hash) return void 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const runtimeHome = input.runtimeHome?.trim();
|
|
62
|
+
return proxiedModelUrl(binding.baseUrl, input.upstream, { ...runtimeHome ? { runtimeHome } : {} });
|
|
63
|
+
}
|
|
64
|
+
new Set([
|
|
65
|
+
...Object.keys(MODEL_PROXY_TOKENS),
|
|
66
|
+
"TANGLE_API_KEY",
|
|
67
|
+
"GITHUB_TOKEN",
|
|
68
|
+
"GH_TOKEN"
|
|
69
|
+
]);
|
|
70
|
+
/** True for a slot name, which exists at the proxy and never in a box env. */
|
|
71
|
+
function isDeclaredSecretSlotEnvVar(name) {
|
|
72
|
+
return /^TANGLE_DECLARED_SLOT_\d+$/.test(name);
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region ../runtime-contracts/dist/harness-warm-transport.js
|
|
76
|
+
/** Set only by the Sandbox API after the OpenCode warm-server rollout decision. */
|
|
77
|
+
const AGENT_INTERNAL_OPENCODE_WARM_SERVER_ENV_VAR = "AGENT_INTERNAL_OPENCODE_WARM_SERVER";
|
|
78
|
+
/**
|
|
79
|
+
* Sidecar switch: after a warm claim, start the first session's OpenCode
|
|
80
|
+
* server before that session's first turn. Effective only with
|
|
81
|
+
* AGENT_INTERNAL_OPENCODE_WARM_SERVER=1. Any value other than "1" is off.
|
|
82
|
+
*/
|
|
83
|
+
const AGENT_INTERNAL_OPENCODE_PREWARM_ENV_VAR = "AGENT_INTERNAL_OPENCODE_PREWARM";
|
|
84
|
+
/** Persistent Kimi wire process and shared create/resume prewarm gates. */
|
|
85
|
+
const AGENT_INTERNAL_KIMI_WARM_SERVER_ENV_VAR = "AGENT_INTERNAL_KIMI_WARM_SERVER";
|
|
86
|
+
const AGENT_INTERNAL_KIMI_PREWARM_ENV_VAR = "AGENT_INTERNAL_KIMI_PREWARM";
|
|
87
|
+
/**
|
|
88
|
+
* Claude Code and Codex run one process per turn. A harness's warm switch
|
|
89
|
+
* sends its managed-token turns' model requests through the sidecar's key
|
|
90
|
+
* proxy (which reports each turn's first model request) and lets a process
|
|
91
|
+
* started ahead of the turn take the turn: Claude Code launches a managed turn
|
|
92
|
+
* with its prompt on stdin (stream-json); Codex already reads its prompt from
|
|
93
|
+
* stdin. Its prewarm switch starts that process at claim, boot and recover.
|
|
94
|
+
* Both default off; off, every turn calls its upstream directly.
|
|
95
|
+
*/
|
|
96
|
+
const AGENT_INTERNAL_CLAUDE_CODE_WARM_SERVER_ENV_VAR = "AGENT_INTERNAL_CLAUDE_CODE_WARM_SERVER";
|
|
97
|
+
const AGENT_INTERNAL_CLAUDE_CODE_PREWARM_ENV_VAR = "AGENT_INTERNAL_CLAUDE_CODE_PREWARM";
|
|
98
|
+
const AGENT_INTERNAL_CODEX_WARM_SERVER_ENV_VAR = "AGENT_INTERNAL_CODEX_WARM_SERVER";
|
|
99
|
+
/**
|
|
100
|
+
* Per harness, the two switches the Sandbox API sets from its warm-server
|
|
101
|
+
* rollout decision: the long-lived process transport, and prewarm, which
|
|
102
|
+
* starts that process before the turn that uses it and is only meaningful
|
|
103
|
+
* with it. A harness joins the rollout by adding one row; the Sandbox API,
|
|
104
|
+
* the orchestrator's provision guard and its recreate carry all read this
|
|
105
|
+
* table. Values are "1" or "0"; anything else is off.
|
|
106
|
+
*/
|
|
107
|
+
const HARNESS_WARM_TRANSPORT_ENV = {
|
|
108
|
+
opencode: {
|
|
109
|
+
warmServer: AGENT_INTERNAL_OPENCODE_WARM_SERVER_ENV_VAR,
|
|
110
|
+
prewarm: AGENT_INTERNAL_OPENCODE_PREWARM_ENV_VAR
|
|
111
|
+
},
|
|
112
|
+
"kimi-code": {
|
|
113
|
+
warmServer: AGENT_INTERNAL_KIMI_WARM_SERVER_ENV_VAR,
|
|
114
|
+
prewarm: AGENT_INTERNAL_KIMI_PREWARM_ENV_VAR
|
|
115
|
+
},
|
|
116
|
+
"claude-code": {
|
|
117
|
+
warmServer: AGENT_INTERNAL_CLAUDE_CODE_WARM_SERVER_ENV_VAR,
|
|
118
|
+
prewarm: AGENT_INTERNAL_CLAUDE_CODE_PREWARM_ENV_VAR
|
|
119
|
+
},
|
|
120
|
+
codex: {
|
|
121
|
+
warmServer: AGENT_INTERNAL_CODEX_WARM_SERVER_ENV_VAR,
|
|
122
|
+
prewarm: "AGENT_INTERNAL_CODEX_PREWARM"
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
function isReservedProfileUid(uid) {
|
|
126
|
+
return Number.isSafeInteger(uid) && uid >= 2e5;
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region ../runtime-contracts/dist/proxy-secret-materialization.js
|
|
130
|
+
const syntheticHeader = {
|
|
131
|
+
alg: "HS256",
|
|
132
|
+
typ: "JWT"
|
|
133
|
+
};
|
|
134
|
+
const encode = (value) => btoa(value instanceof Uint8Array ? String.fromCharCode(...value) : JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
135
|
+
function placeholderPrefix(slot, format, expiresAt) {
|
|
136
|
+
return format === "jwt" ? `${encode(syntheticHeader)}.${encode({
|
|
137
|
+
sub: "sandbox-placeholder",
|
|
138
|
+
aud: "proxy-secret-placeholder",
|
|
139
|
+
exp: Math.floor(Date.parse(expiresAt) / 1e3)
|
|
140
|
+
})}.` : `proxy-declared-${slot.slice(21)}-token-`;
|
|
141
|
+
}
|
|
142
|
+
function isSyntheticPlaceholder(material) {
|
|
143
|
+
const prefix = placeholderPrefix(material.slot, material.grant.placeholderFormat, material.issuerExpiresAt);
|
|
144
|
+
return material.placeholder.startsWith(prefix) && /^[A-Za-z0-9_-]{43}$/.test(material.placeholder.slice(prefix.length));
|
|
145
|
+
}
|
|
146
|
+
z.array(z.strictObject({
|
|
147
|
+
binding: ProxySecretBindingSchema,
|
|
148
|
+
grant: ProxySecretGrantSchema,
|
|
149
|
+
placeholder: z.string().min(1),
|
|
150
|
+
slot: z.string().refine(isDeclaredSecretSlotEnvVar),
|
|
151
|
+
issuerExpiresAt: z.iso.datetime()
|
|
152
|
+
})).superRefine((materials, ctx) => {
|
|
153
|
+
const bindings = ProxySecretsSchema.safeParse(materials.map((m) => m.binding));
|
|
154
|
+
const unique = (values) => new Set(values).size === values.length;
|
|
155
|
+
if (!bindings.success || !unique(materials.map((m) => m.slot)) || !unique(materials.map((m) => m.placeholder)) || !unique(materials.map((m) => m.grant.grantId)) || new Set(materials.map((m) => m.grant.epoch)).size > 1 || new Set(materials.map((m) => m.grant.sandboxId)).size > 1 || materials.some((material) => !isSyntheticPlaceholder(material)) || materials.some(({ binding, grant, issuerExpiresAt }) => !isProxySecretScopeAttenuation(binding, grant.scope) || !proxySecretPresentationsEqual(binding.presentation, grant.presentation) || (binding.placeholderFormat ?? "opaque") !== grant.placeholderFormat || Date.parse(grant.expiresAt) > Date.parse(issuerExpiresAt))) ctx.addIssue({
|
|
156
|
+
code: "custom",
|
|
157
|
+
message: "Invalid proxy secret materialization"
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
ProxySecretUpdateControlsSchema.extend({
|
|
161
|
+
resolvedSecrets: ownPropertyRecordSchema(z.string().min(1), { maxEntries: Number.MAX_SAFE_INTEGER }).default({}),
|
|
162
|
+
proxySecretGrants: ProxySecretReceiptSchema.options[0].shape.bindings,
|
|
163
|
+
expectedProxySecretGeneration: ProxySecretUpdateControlsSchema.shape.expectedProxySecretGeneration.unwrap()
|
|
164
|
+
}).strict().refine((body) => new Set(body.proxySecretGrants.map((b) => b.envName)).size === body.proxySecretGrants.length);
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region ../runtime-contracts/dist/runtime-environment.js
|
|
167
|
+
const SAFE_ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
168
|
+
function isSafeEnvVarName(name) {
|
|
169
|
+
if (typeof name !== "string") return false;
|
|
170
|
+
if (name.length === 0 || name.length > 256) return false;
|
|
171
|
+
return SAFE_ENV_VAR_NAME_RE.test(name);
|
|
172
|
+
}
|
|
173
|
+
/** Client proxy URLs first, then bypass names. */
|
|
174
|
+
const EGRESS_PROXY_ENV_NAMES = [
|
|
175
|
+
"HTTPS_PROXY",
|
|
176
|
+
"https_proxy",
|
|
177
|
+
"HTTP_PROXY",
|
|
178
|
+
"http_proxy",
|
|
179
|
+
"NO_PROXY",
|
|
180
|
+
"no_proxy"
|
|
181
|
+
];
|
|
182
|
+
const CA_TRUST_ENV_NAMES = [
|
|
183
|
+
"NODE_EXTRA_CA_CERTS",
|
|
184
|
+
"SSL_CERT_FILE",
|
|
185
|
+
"CURL_CA_BUNDLE",
|
|
186
|
+
"GIT_SSL_CAINFO",
|
|
187
|
+
"NIX_SSL_CERT_FILE"
|
|
188
|
+
];
|
|
189
|
+
[...EGRESS_PROXY_ENV_NAMES, ...CA_TRUST_ENV_NAMES];
|
|
190
|
+
const FORBIDDEN_ENV_VAR_NAMES = new Set([
|
|
191
|
+
"LD_PRELOAD",
|
|
192
|
+
"LD_LIBRARY_PATH",
|
|
193
|
+
"LD_AUDIT",
|
|
194
|
+
"DYLD_INSERT_LIBRARIES",
|
|
195
|
+
"DYLD_LIBRARY_PATH",
|
|
196
|
+
"DYLD_FALLBACK_LIBRARY_PATH",
|
|
197
|
+
"NODE_OPTIONS",
|
|
198
|
+
"NODE_PATH",
|
|
199
|
+
"NODE_TLS_REJECT_UNAUTHORIZED",
|
|
200
|
+
"PYTHONPATH",
|
|
201
|
+
"PYTHONSTARTUP",
|
|
202
|
+
"PERL5OPT",
|
|
203
|
+
"PERL5LIB",
|
|
204
|
+
"RUBYOPT",
|
|
205
|
+
"RUBYLIB",
|
|
206
|
+
"PATH",
|
|
207
|
+
"NIX_BIN_PATH",
|
|
208
|
+
"EGRESS_EDGE_POOL_ENABLED",
|
|
209
|
+
"EDGE_BIND_ID",
|
|
210
|
+
"PRIVACY_EGRESS",
|
|
211
|
+
"LOG_REDACT",
|
|
212
|
+
"IFS",
|
|
213
|
+
"BASH_ENV",
|
|
214
|
+
"ENV",
|
|
215
|
+
"SHELL",
|
|
216
|
+
"SHELLOPTS",
|
|
217
|
+
"PROMPT_COMMAND",
|
|
218
|
+
"HOME",
|
|
219
|
+
"XDG_CONFIG_HOME",
|
|
220
|
+
"XDG_DATA_HOME",
|
|
221
|
+
"XDG_CACHE_HOME",
|
|
222
|
+
"XDG_STATE_HOME",
|
|
223
|
+
"XDG_RUNTIME_DIR",
|
|
224
|
+
"HTTPS_PROXY",
|
|
225
|
+
"HTTP_PROXY",
|
|
226
|
+
"ALL_PROXY",
|
|
227
|
+
"NO_PROXY",
|
|
228
|
+
"https_proxy",
|
|
229
|
+
"http_proxy",
|
|
230
|
+
"all_proxy",
|
|
231
|
+
"no_proxy",
|
|
232
|
+
"SSL_CERT_FILE",
|
|
233
|
+
"SSL_CERT_DIR",
|
|
234
|
+
"REQUESTS_CA_BUNDLE",
|
|
235
|
+
"CURL_CA_BUNDLE",
|
|
236
|
+
"NODE_EXTRA_CA_CERTS",
|
|
237
|
+
"HOSTNAME",
|
|
238
|
+
"CONTAINER_ID",
|
|
239
|
+
"AGENT_WORKSPACE_ROOT",
|
|
240
|
+
"WORKSPACE_PATH",
|
|
241
|
+
"STORAGE_PATH",
|
|
242
|
+
"WORKSPACE_FILE_API_ROOT_ONLY",
|
|
243
|
+
"WORKSPACE_ALLOWED_ABSOLUTE_PATHS",
|
|
244
|
+
"TANGLE_PLAN_CALLBACK_ALLOWED_ORIGINS"
|
|
245
|
+
]);
|
|
246
|
+
const FORBIDDEN_ENV_VAR_PREFIXES = [
|
|
247
|
+
"SIDECAR_",
|
|
248
|
+
"ORCHESTRATOR_",
|
|
249
|
+
"AGENT_INTERNAL_",
|
|
250
|
+
"SANDBOX_DEV_",
|
|
251
|
+
"AGENT_SUBPROCESS_",
|
|
252
|
+
"TEE_ATTESTATION_"
|
|
253
|
+
];
|
|
254
|
+
const RUNTIME_CALLER_ALLOWED_ENV_NAMES = new Set(["SIDECAR_CAPABILITIES", "SIDECAR_PLATFORM_CONTROL_RUNTIME"]);
|
|
255
|
+
new Set(Object.values(HARNESS_WARM_TRANSPORT_ENV).flatMap(({ warmServer, prewarm }) => [warmServer, prewarm]));
|
|
256
|
+
function isForbiddenEnvVarName(name) {
|
|
257
|
+
if (RUNTIME_CALLER_ALLOWED_ENV_NAMES.has(name)) return false;
|
|
258
|
+
if (FORBIDDEN_ENV_VAR_NAMES.has(name)) return true;
|
|
259
|
+
for (const prefix of FORBIDDEN_ENV_VAR_PREFIXES) if (name.startsWith(prefix)) return true;
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* POSIX names for caller-owned container env. Runtime process.env merges must additionally enforce
|
|
264
|
+
* isForbiddenEnvVarName, including project provisioning and Sidecar config.
|
|
265
|
+
*/
|
|
266
|
+
const safeEnvRecordSchema = ownPropertyRecordSchema(z.string(), { maxEntries: Number.MAX_SAFE_INTEGER }).refine((value) => Object.keys(value).every(isSafeEnvVarName), { message: "invalid env var name" });
|
|
267
|
+
/** Names only: the caller must keep a separate source and pass values to resume({ env }). */
|
|
268
|
+
const sandboxEnvRecoverySchema = z.object({ callerResupplies: z.array(z.string().max(256).refine(isSafeEnvVarName, { message: "invalid env var name" })).max(128).refine((keys) => new Set(keys).size === keys.length, { message: "callerResupplies must contain unique keys" }) }).strict();
|
|
269
|
+
z.object({
|
|
270
|
+
envRecovery: sandboxEnvRecoverySchema,
|
|
271
|
+
dryRun: z.boolean().optional()
|
|
272
|
+
}).strict();
|
|
273
|
+
z.object({
|
|
274
|
+
envRecovery: sandboxEnvRecoverySchema,
|
|
275
|
+
eligibleCallerResupplyKeys: z.array(z.string()),
|
|
276
|
+
requiredOnColdResumeKeys: z.array(z.string()),
|
|
277
|
+
automaticCredentialRecovery: z.literal(false),
|
|
278
|
+
dryRun: z.boolean()
|
|
279
|
+
}).strict();
|
|
280
|
+
safeEnvRecordSchema.refine((value) => Object.keys(value).every((key) => !isForbiddenEnvVarName(key) && !key.startsWith("SIDECAR_")), { message: "runtime-owned env var name cannot be set by callers" });
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/app-server-protocol.ts
|
|
283
|
+
function grantToPermissionProfile(grant, requested) {
|
|
284
|
+
if (grant === "deny") return {
|
|
285
|
+
permissions: {},
|
|
286
|
+
scope: "turn"
|
|
287
|
+
};
|
|
288
|
+
const permissions = {};
|
|
289
|
+
if (requested.network != null) permissions.network = requested.network;
|
|
290
|
+
if (requested.fileSystem != null) permissions.fileSystem = requested.fileSystem;
|
|
291
|
+
return {
|
|
292
|
+
permissions,
|
|
293
|
+
scope: grant === "allow_once" ? "turn" : "session"
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function normalizeChangeKind(kind) {
|
|
297
|
+
const raw = typeof kind === "string" ? kind : typeof kind === "object" && kind !== null ? kind.type : void 0;
|
|
298
|
+
if (raw === "add" || raw === "update" || raw === "delete") return raw;
|
|
299
|
+
}
|
|
300
|
+
function sandboxModeToPolicy(mode, workspaceRoot) {
|
|
301
|
+
if (mode === "danger-full-access") return { type: "dangerFullAccess" };
|
|
302
|
+
if (mode === "read-only") return {
|
|
303
|
+
type: "readOnly",
|
|
304
|
+
networkAccess: false
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
type: "workspaceWrite",
|
|
308
|
+
writableRoots: [workspaceRoot],
|
|
309
|
+
networkAccess: false,
|
|
310
|
+
excludeTmpdirEnvVar: false,
|
|
311
|
+
excludeSlashTmp: false
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function grantToReviewDecision(grant) {
|
|
315
|
+
if (grant === "deny") return "denied";
|
|
316
|
+
if (grant === "allow_once") return "approved";
|
|
317
|
+
return "approved_for_session";
|
|
318
|
+
}
|
|
319
|
+
function grantToApprovalDecision(grant) {
|
|
320
|
+
if (grant === "deny") return "decline";
|
|
321
|
+
if (grant === "allow_once") return "accept";
|
|
322
|
+
return "acceptForSession";
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/codex-app-server-interactions.ts
|
|
326
|
+
/**
|
|
327
|
+
* Approval reverse-calls (v1 + v2) → the unified broker; the resolved grant
|
|
328
|
+
* maps back to codex's decision enum and unblocks the turn. One shape, four
|
|
329
|
+
* wirings — each supplies its own id/tool extractors and v1-vs-v2 mapper.
|
|
330
|
+
*/
|
|
331
|
+
function registerBrokeredApprovals(bridge, approve) {
|
|
332
|
+
const onApproval = (method, id, tool, toDecision) => bridge.onRequest(method, async (p) => ({ decision: toDecision(await approve(id(p), tool(p), p)) }));
|
|
333
|
+
const cmd = (p) => (p.command ?? []).join(" ");
|
|
334
|
+
onApproval("execCommandApproval", (p) => p.callId, cmd, grantToReviewDecision);
|
|
335
|
+
onApproval("applyPatchApproval", (p) => p.callId, () => "apply_patch", grantToReviewDecision);
|
|
336
|
+
onApproval("item/commandExecution/requestApproval", (p) => p.callId ?? p.itemId ?? "", cmd, grantToApprovalDecision);
|
|
337
|
+
onApproval("item/fileChange/requestApproval", (p) => p.callId ?? p.itemId ?? "", () => "apply_patch", grantToApprovalDecision);
|
|
338
|
+
bridge.onRequest("item/permissions/requestApproval", async (p) => grantToPermissionProfile(await approve(p.itemId ?? "", `permission escalation${p.reason ? `: ${p.reason}` : ""}`, p), p.permissions ?? {}));
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Under approvalPolicy "never" codex should not ask; if it does anyway,
|
|
342
|
+
* deny in the method's own response shape and surface a warning instead
|
|
343
|
+
* of leaving a silent stall in the log. No Permission interaction is
|
|
344
|
+
* raised — the question channel never enabled that capability.
|
|
345
|
+
*/
|
|
346
|
+
function registerDeniedApprovals(bridge, onEvent) {
|
|
347
|
+
const denyApproval = (method, response) => bridge.onRequest(method, async () => {
|
|
348
|
+
onEvent?.({
|
|
349
|
+
type: "warning",
|
|
350
|
+
code: "CODEX_UNEXPECTED_APPROVAL",
|
|
351
|
+
message: `codex requested ${method} despite approvalPolicy "never"; denied (question interactions do not route permissions)`
|
|
352
|
+
});
|
|
353
|
+
return response;
|
|
354
|
+
});
|
|
355
|
+
denyApproval("execCommandApproval", { decision: grantToReviewDecision("deny") });
|
|
356
|
+
denyApproval("applyPatchApproval", { decision: grantToReviewDecision("deny") });
|
|
357
|
+
denyApproval("item/commandExecution/requestApproval", { decision: grantToApprovalDecision("deny") });
|
|
358
|
+
denyApproval("item/fileChange/requestApproval", { decision: grantToApprovalDecision("deny") });
|
|
359
|
+
denyApproval("item/permissions/requestApproval", grantToPermissionProfile("deny", {}));
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The agent asking the user a clarifying question (codex's request_user_input
|
|
363
|
+
* tool) → the unified broker's question round-trip. The positional answers
|
|
364
|
+
* map back onto codex's per-question-id reply; no answer (timeout / teardown /
|
|
365
|
+
* no live stream) replies with empty slots so the turn unblocks.
|
|
366
|
+
*/
|
|
367
|
+
function registerUserInputQuestions(bridge, permissions, sessionId, interactionBinding, questions, onEvent) {
|
|
368
|
+
bridge.onRequest("item/tool/requestUserInput", async (p) => {
|
|
369
|
+
if (!questions) {
|
|
370
|
+
onEvent?.({
|
|
371
|
+
type: "warning",
|
|
372
|
+
code: "CODEX_UNEXPECTED_QUESTION",
|
|
373
|
+
message: "codex requested item/tool/requestUserInput but question interactions were not enabled; answered with no selections"
|
|
374
|
+
});
|
|
375
|
+
const empty = {};
|
|
376
|
+
for (const q of p.questions ?? []) empty[q.id] = { answers: [] };
|
|
377
|
+
return { answers: empty };
|
|
378
|
+
}
|
|
379
|
+
const codexQuestions = p.questions ?? [];
|
|
380
|
+
const answers = await permissions.requestQuestion({
|
|
381
|
+
id: randomUUID(),
|
|
382
|
+
sessionId,
|
|
383
|
+
binding: interactionBinding,
|
|
384
|
+
questions: codexQuestions.map((q) => ({
|
|
385
|
+
question: q.question,
|
|
386
|
+
options: q.options?.map((o) => ({
|
|
387
|
+
label: o.label,
|
|
388
|
+
description: o.description
|
|
389
|
+
}))
|
|
390
|
+
})),
|
|
391
|
+
timeoutMs: typeof p.autoResolutionMs === "number" && p.autoResolutionMs > 0 ? p.autoResolutionMs : void 0,
|
|
392
|
+
emit: onEvent
|
|
393
|
+
});
|
|
394
|
+
const byId = {};
|
|
395
|
+
codexQuestions.forEach((q, index) => {
|
|
396
|
+
byId[q.id] = { answers: answers?.[index] ?? [] };
|
|
397
|
+
});
|
|
398
|
+
return { answers: byId };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function registerAppServerInteractions(bridge, permissions, sessionId, interactionBinding, interactive, questions, onEvent) {
|
|
402
|
+
const approve = (id, toolName, raw) => permissions.request({
|
|
403
|
+
id: id && id.length > 0 ? id : randomUUID(),
|
|
404
|
+
sessionId,
|
|
405
|
+
binding: interactionBinding,
|
|
406
|
+
toolName,
|
|
407
|
+
input: raw,
|
|
408
|
+
allowlistGrant: "deny",
|
|
409
|
+
emit: onEvent
|
|
410
|
+
});
|
|
411
|
+
if (interactive) registerBrokeredApprovals(bridge, approve);
|
|
412
|
+
else registerDeniedApprovals(bridge, onEvent);
|
|
413
|
+
registerUserInputQuestions(bridge, permissions, sessionId, interactionBinding, questions, onEvent);
|
|
414
|
+
}
|
|
415
|
+
function routesPermissions(config) {
|
|
416
|
+
return config.interactivePermissions === true || interactionToggleEnabled(config.interactions, "permission");
|
|
417
|
+
}
|
|
418
|
+
function routesQuestions(config) {
|
|
419
|
+
return routesPermissions(config) || interactionToggleEnabled(config.interactions, "question");
|
|
420
|
+
}
|
|
421
|
+
function codexRoutesPlans(config) {
|
|
422
|
+
return interactionToggleEnabled(config.interactions, "plan");
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/app-server-policy.ts
|
|
426
|
+
function appServerPolicy(config, input = {}) {
|
|
427
|
+
const turnConfig = withTurnInteractions(config, input);
|
|
428
|
+
const interactive = routesPermissions(turnConfig);
|
|
429
|
+
const questions = routesQuestions(turnConfig);
|
|
430
|
+
const plans = codexRoutesPlans(turnConfig);
|
|
431
|
+
return {
|
|
432
|
+
interactive,
|
|
433
|
+
questions,
|
|
434
|
+
plans,
|
|
435
|
+
approvalPolicy: interactive ? config.approvalPolicy ?? "on-request" : "never",
|
|
436
|
+
sandbox: plans ? "read-only" : interactive || config.bypassPermissions === false ? config.sandboxMode ?? "workspace-write" : "danger-full-access"
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function initialThreadId(input) {
|
|
440
|
+
const continuation = input.planContinuation;
|
|
441
|
+
if (!continuation) return input.nativeResume?.nativeSessionId;
|
|
442
|
+
if (continuation.providerState.kind !== "codex") throw new Error(`Codex cannot resume ${continuation.providerState.kind} plan state`);
|
|
443
|
+
return continuation.providerState.threadId;
|
|
444
|
+
}
|
|
445
|
+
//#endregion
|
|
446
|
+
//#region src/app-server-items.ts
|
|
447
|
+
function isTool(item) {
|
|
448
|
+
return item.type === "commandExecution" || item.type === "fileChange";
|
|
449
|
+
}
|
|
450
|
+
/** Native item IDs stay stable across tool, file and reasoning updates. */
|
|
451
|
+
var AppServerItemMapper = class {
|
|
452
|
+
tools = /* @__PURE__ */ new Map();
|
|
453
|
+
reasoning = /* @__PURE__ */ new Map();
|
|
454
|
+
emittedThinking = false;
|
|
455
|
+
constructor(ids, logger) {
|
|
456
|
+
this.ids = ids;
|
|
457
|
+
this.logger = logger;
|
|
458
|
+
}
|
|
459
|
+
partBase(id) {
|
|
460
|
+
return {
|
|
461
|
+
id,
|
|
462
|
+
sessionID: this.ids.sessionId,
|
|
463
|
+
messageID: this.ids.messageId
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
filePart(path) {
|
|
467
|
+
const derived = deriveFilePart({
|
|
468
|
+
path,
|
|
469
|
+
messageId: this.ids.messageId
|
|
470
|
+
});
|
|
471
|
+
if (!derived) {
|
|
472
|
+
this.logger?.debug("skipping file part for non-absolute fileChange path", { path });
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
...this.partBase(derived.id),
|
|
477
|
+
type: "file",
|
|
478
|
+
filename: derived.filename,
|
|
479
|
+
...derived.mediaType ? { mediaType: derived.mediaType } : {},
|
|
480
|
+
url: derived.url
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
toolPart(itemId, state) {
|
|
484
|
+
const settled = {
|
|
485
|
+
input: state.input,
|
|
486
|
+
output: state.output,
|
|
487
|
+
time: {
|
|
488
|
+
start: state.startTime,
|
|
489
|
+
end: Date.now()
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
const toolState = state.status === "running" ? {
|
|
493
|
+
status: "running",
|
|
494
|
+
input: state.input,
|
|
495
|
+
time: { start: state.startTime }
|
|
496
|
+
} : state.status === "failed" ? {
|
|
497
|
+
...settled,
|
|
498
|
+
status: "failed",
|
|
499
|
+
error: typeof state.output === "string" ? state.output : void 0
|
|
500
|
+
} : {
|
|
501
|
+
...settled,
|
|
502
|
+
status: "completed"
|
|
503
|
+
};
|
|
504
|
+
return {
|
|
505
|
+
...this.partBase(itemId),
|
|
506
|
+
type: "tool",
|
|
507
|
+
callID: itemId,
|
|
508
|
+
tool: state.tool,
|
|
509
|
+
state: toolState
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
onItemStarted(params) {
|
|
513
|
+
const item = params.item;
|
|
514
|
+
if (!isTool(item)) return [];
|
|
515
|
+
const state = {
|
|
516
|
+
tool: item.type === "commandExecution" ? "shell" : "apply_patch",
|
|
517
|
+
input: item.type === "commandExecution" ? {
|
|
518
|
+
command: item.command,
|
|
519
|
+
cwd: item.cwd
|
|
520
|
+
} : { changes: item.changes.map((change) => ({
|
|
521
|
+
path: change.path,
|
|
522
|
+
kind: normalizeChangeKind(change.kind) ?? change.kind
|
|
523
|
+
})) },
|
|
524
|
+
startTime: params.startedAtMs ?? Date.now(),
|
|
525
|
+
outputBuffer: "",
|
|
526
|
+
status: "running"
|
|
527
|
+
};
|
|
528
|
+
this.tools.set(item.id, state);
|
|
529
|
+
return [{
|
|
530
|
+
type: "message.part.updated",
|
|
531
|
+
part: this.toolPart(item.id, state)
|
|
532
|
+
}];
|
|
533
|
+
}
|
|
534
|
+
onItemCompleted(params) {
|
|
535
|
+
const item = params.item;
|
|
536
|
+
if (!isTool(item)) {
|
|
537
|
+
if (item.type === "reasoning" && typeof item.id === "string") this.reasoning.delete(item.id);
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
const state = this.tools.get(item.id);
|
|
541
|
+
if (!state) return [{
|
|
542
|
+
type: "warning",
|
|
543
|
+
code: "UNKNOWN_TOOL_RESULT",
|
|
544
|
+
message: `Received completion for unknown app-server item: ${item.id}`
|
|
545
|
+
}];
|
|
546
|
+
state.status = item.status === "completed" && (item.type !== "commandExecution" || (item.exitCode ?? 0) === 0) ? "completed" : "failed";
|
|
547
|
+
state.output = item.type === "commandExecution" ? item.aggregatedOutput ?? state.outputBuffer : item.changes.map((change) => change.diff).join("\n");
|
|
548
|
+
const events = [{
|
|
549
|
+
type: "message.part.updated",
|
|
550
|
+
part: this.toolPart(item.id, state)
|
|
551
|
+
}];
|
|
552
|
+
if (state.status !== "completed") return events;
|
|
553
|
+
if (item.type === "commandExecution") events.push({
|
|
554
|
+
type: "model-processing",
|
|
555
|
+
phase: "tool-result",
|
|
556
|
+
toolName: state.tool,
|
|
557
|
+
elapsedMs: item.durationMs ?? (params.completedAtMs ?? Date.now()) - state.startTime
|
|
558
|
+
});
|
|
559
|
+
else for (const change of item.changes) {
|
|
560
|
+
const kind = normalizeChangeKind(change.kind);
|
|
561
|
+
if (kind === "delete") continue;
|
|
562
|
+
if (kind === void 0 && change.kind == null) {
|
|
563
|
+
this.logger?.debug("fileChange change without a kind", { path: change.path });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const part = this.filePart(change.path);
|
|
567
|
+
if (part) events.push({
|
|
568
|
+
type: "message.part.updated",
|
|
569
|
+
part
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
return events;
|
|
573
|
+
}
|
|
574
|
+
onCommandOutputDelta(params) {
|
|
575
|
+
const state = this.tools.get(params.itemId);
|
|
576
|
+
if (state) state.outputBuffer += params.delta;
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
onReasoningDelta(params) {
|
|
580
|
+
if (!params.delta) return [];
|
|
581
|
+
const text = (this.reasoning.get(params.itemId) ?? "") + params.delta;
|
|
582
|
+
this.reasoning.set(params.itemId, text);
|
|
583
|
+
const part = {
|
|
584
|
+
...this.partBase(params.itemId),
|
|
585
|
+
type: "reasoning",
|
|
586
|
+
text
|
|
587
|
+
};
|
|
588
|
+
const events = [];
|
|
589
|
+
if (!this.emittedThinking) {
|
|
590
|
+
this.emittedThinking = true;
|
|
591
|
+
events.push({
|
|
592
|
+
type: "model-processing",
|
|
593
|
+
phase: "thinking"
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
events.push({
|
|
597
|
+
type: "message.part.updated",
|
|
598
|
+
part,
|
|
599
|
+
delta: params.delta
|
|
600
|
+
});
|
|
601
|
+
return events;
|
|
602
|
+
}
|
|
603
|
+
toolInvocations() {
|
|
604
|
+
return [...this.tools.values()].map((state) => ({
|
|
605
|
+
toolName: state.tool,
|
|
606
|
+
input: state.input,
|
|
607
|
+
result: state.output,
|
|
608
|
+
...state.status === "failed" ? { isError: true } : {}
|
|
609
|
+
}));
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/codex-app-server-turn.ts
|
|
614
|
+
const CODEX_PLAN_MCP_NAME = "tangle-interaction";
|
|
615
|
+
function durablePlanInstruction(continuation, planMode) {
|
|
616
|
+
if (continuation?.decision.outcome === "approved") return `The user approved durable plan ${continuation.plan.id}. Execute this exact plan now:\n\n${continuation.plan.body}`;
|
|
617
|
+
if (continuation?.decision.outcome === "rejected") return `The user rejected durable plan ${continuation.plan.id}. Revise it using this feedback:\n\n${continuation.decision.feedback}\n\nPrevious plan:\n${continuation.plan.body}\n\nWhen the revision is complete, call request_plan with the full revised plan. Submission ends this turn.`;
|
|
618
|
+
if (planMode) return "Produce a complete implementation plan, then call request_plan with its full body. Submission ends this turn; do not execute it.";
|
|
619
|
+
}
|
|
620
|
+
var CodexAppServerTurn = class {
|
|
621
|
+
activeThreadId;
|
|
622
|
+
submittedPlan;
|
|
623
|
+
planInteractions;
|
|
624
|
+
text = "";
|
|
625
|
+
tokenUsage;
|
|
626
|
+
activeTurnId;
|
|
627
|
+
closeEnd;
|
|
628
|
+
pendingTurnCompletions = [];
|
|
629
|
+
pendingTurnErrors = [];
|
|
630
|
+
planInterruptRequested = false;
|
|
631
|
+
terminalSettled = false;
|
|
632
|
+
resolveTurn;
|
|
633
|
+
rejectTurn;
|
|
634
|
+
turnDone;
|
|
635
|
+
items;
|
|
636
|
+
constructor(turn) {
|
|
637
|
+
this.turn = turn;
|
|
638
|
+
this.turnDone = new Promise((resolve, reject) => {
|
|
639
|
+
this.resolveTurn = resolve;
|
|
640
|
+
this.rejectTurn = reject;
|
|
641
|
+
});
|
|
642
|
+
this.turnDone.catch(() => {});
|
|
643
|
+
this.items = new AppServerItemMapper({
|
|
644
|
+
sessionId: turn.sessionId,
|
|
645
|
+
messageId: turn.messageId
|
|
646
|
+
}, turn.logger);
|
|
647
|
+
}
|
|
648
|
+
planProviderState() {
|
|
649
|
+
if (!this.activeThreadId) throw new Error("Codex thread identity was unavailable during plan submission");
|
|
650
|
+
return {
|
|
651
|
+
kind: "codex",
|
|
652
|
+
version: 1,
|
|
653
|
+
threadId: this.activeThreadId
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
subscribe(bridge) {
|
|
657
|
+
const unsubscribers = [
|
|
658
|
+
...this.subscribeItems(bridge),
|
|
659
|
+
this.subscribeAgentMessage(bridge),
|
|
660
|
+
this.subscribeTokenUsage(bridge),
|
|
661
|
+
bridge.onNotification("turn/completed", (p) => {
|
|
662
|
+
this.handleTurnCompleted(p);
|
|
663
|
+
}),
|
|
664
|
+
bridge.onNotification("error", (p) => this.handleErrorNotification(p)),
|
|
665
|
+
bridge.onClose((reason) => {
|
|
666
|
+
if (this.activeTurnId === void 0 && this.pendingTurnCompletions.length > 0) {
|
|
667
|
+
queueMicrotask(() => this.failFromClose(reason));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
this.failFromClose(reason);
|
|
671
|
+
})
|
|
672
|
+
];
|
|
673
|
+
return () => {
|
|
674
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
async run(bridge, params) {
|
|
678
|
+
const { onEvent, sessionId } = this.turn;
|
|
679
|
+
try {
|
|
680
|
+
onEvent?.({
|
|
681
|
+
type: "status",
|
|
682
|
+
status: "started"
|
|
683
|
+
});
|
|
684
|
+
await this.handshake(bridge, params);
|
|
685
|
+
await this.startTurn(bridge, params);
|
|
686
|
+
await this.turnDone;
|
|
687
|
+
await params.cleanup?.();
|
|
688
|
+
if (this.submittedPlan) {
|
|
689
|
+
if (!this.planInteractions) throw new Error("Codex committed a plan without terminal plan support");
|
|
690
|
+
this.submittedPlan = await this.planInteractions.confirmSubmittedPlan();
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
text: this.text,
|
|
694
|
+
toolInvocations: this.items.toolInvocations(),
|
|
695
|
+
sessionId,
|
|
696
|
+
outcome: this.submittedPlan ? {
|
|
697
|
+
type: "awaiting_plan_decision",
|
|
698
|
+
plan: this.submittedPlan
|
|
699
|
+
} : { type: "completed" },
|
|
700
|
+
...this.tokenUsage ? { tokenUsage: this.tokenUsage } : {}
|
|
701
|
+
};
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (this.submittedPlan) try {
|
|
704
|
+
await this.planInteractions?.withdrawSubmittedPlan(`Codex failed to terminalize its planning turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
705
|
+
} catch (withdrawalError) {
|
|
706
|
+
throw new AggregateError([error, withdrawalError], "Codex planning failed and its durable plan could not be withdrawn");
|
|
707
|
+
}
|
|
708
|
+
throw Object.assign(error, { reason: error.reason ?? this.closeEnd ?? turnEndReason({ aborted: this.turn.input.abortSignal?.aborted }) });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async handshake(bridge, params) {
|
|
712
|
+
const initializeStarted = performance.now();
|
|
713
|
+
await bridge.request("initialize", {
|
|
714
|
+
clientInfo: {
|
|
715
|
+
name: "tangle-sidecar",
|
|
716
|
+
title: null,
|
|
717
|
+
version: "0.1.0"
|
|
718
|
+
},
|
|
719
|
+
capabilities: params.initialize ? { experimentalApi: true } : null
|
|
720
|
+
});
|
|
721
|
+
bridge.notify("initialized");
|
|
722
|
+
await params.initialize?.(bridge);
|
|
723
|
+
const previous = this.activeThreadId;
|
|
724
|
+
const { thread } = await bridge.request(previous ? "thread/resume" : "thread/start", {
|
|
725
|
+
...previous ? { threadId: previous } : {},
|
|
726
|
+
cwd: params.cwd,
|
|
727
|
+
model: params.model,
|
|
728
|
+
approvalPolicy: params.approvalPolicy,
|
|
729
|
+
sandbox: params.sandbox
|
|
730
|
+
});
|
|
731
|
+
if (previous && thread.id !== previous) throw new Error("Codex resumed a different thread");
|
|
732
|
+
this.activeThreadId = thread.id;
|
|
733
|
+
await this.turn.onNativeSession?.(thread.id);
|
|
734
|
+
this.turn.onEvent?.({
|
|
735
|
+
type: "native.session.observed",
|
|
736
|
+
provider: "codex",
|
|
737
|
+
nativeSessionId: thread.id
|
|
738
|
+
});
|
|
739
|
+
this.turn.logger.observePhase?.("cli_initialize", performance.now() - initializeStarted);
|
|
740
|
+
}
|
|
741
|
+
async startTurn(bridge, params) {
|
|
742
|
+
const { input, plans } = this.turn;
|
|
743
|
+
const turnMessage = [durablePlanInstruction(input.planContinuation, plans), params.promptText].filter((part) => typeof part === "string" && part.length > 0).join(PROMPT_SECTION_SEPARATOR);
|
|
744
|
+
const startedTurn = await bridge.request("turn/start", {
|
|
745
|
+
threadId: this.activeThreadId,
|
|
746
|
+
input: [{
|
|
747
|
+
type: "text",
|
|
748
|
+
text: turnMessage,
|
|
749
|
+
text_elements: []
|
|
750
|
+
}],
|
|
751
|
+
cwd: params.cwd,
|
|
752
|
+
approvalPolicy: params.approvalPolicy,
|
|
753
|
+
sandboxPolicy: sandboxModeToPolicy(params.sandbox, params.cwd),
|
|
754
|
+
model: params.model
|
|
755
|
+
});
|
|
756
|
+
this.activeTurnId = startedTurn.turn.id;
|
|
757
|
+
this.drainPendingTurnEvents();
|
|
758
|
+
}
|
|
759
|
+
completeTurn() {
|
|
760
|
+
if (this.terminalSettled) return;
|
|
761
|
+
this.terminalSettled = true;
|
|
762
|
+
this.resolveTurn?.();
|
|
763
|
+
}
|
|
764
|
+
failTurn(cause) {
|
|
765
|
+
if (this.terminalSettled) return;
|
|
766
|
+
this.terminalSettled = true;
|
|
767
|
+
this.rejectTurn?.(cause instanceof Error ? cause : new Error(String(cause)));
|
|
768
|
+
}
|
|
769
|
+
forward(events) {
|
|
770
|
+
for (const event of events) this.turn.onEvent?.(event);
|
|
771
|
+
}
|
|
772
|
+
subscribeItems(bridge) {
|
|
773
|
+
return [
|
|
774
|
+
bridge.onNotification("item/started", (p) => this.forward(this.items.onItemStarted(p))),
|
|
775
|
+
bridge.onNotification("item/completed", (p) => this.handleItemCompleted(bridge, p)),
|
|
776
|
+
bridge.onNotification("item/commandExecution/outputDelta", (p) => this.forward(this.items.onCommandOutputDelta(p))),
|
|
777
|
+
bridge.onNotification("item/reasoning/textDelta", (p) => this.forward(this.items.onReasoningDelta(p))),
|
|
778
|
+
bridge.onNotification("item/reasoning/summaryTextDelta", (p) => this.forward(this.items.onReasoningDelta(p)))
|
|
779
|
+
];
|
|
780
|
+
}
|
|
781
|
+
async handleItemCompleted(bridge, lifecycle) {
|
|
782
|
+
this.forward(this.items.onItemCompleted(lifecycle));
|
|
783
|
+
const item = lifecycle.item;
|
|
784
|
+
if (!this.turn.plans || item.type !== "mcpToolCall" || item.server !== "tangle-interaction" || item.tool !== "request_plan") return;
|
|
785
|
+
if (lifecycle.threadId !== this.activeThreadId || lifecycle.turnId !== this.activeTurnId) {
|
|
786
|
+
this.failTurn(/* @__PURE__ */ new Error("Codex completed request_plan for a different thread or turn"));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (item.status !== "completed" || !this.submittedPlan) {
|
|
790
|
+
this.failTurn(/* @__PURE__ */ new Error("Codex request_plan completed without a durable plan receipt"));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (this.planInterruptRequested) return;
|
|
794
|
+
this.planInterruptRequested = true;
|
|
795
|
+
try {
|
|
796
|
+
await bridge.request("turn/interrupt", {
|
|
797
|
+
threadId: this.activeThreadId,
|
|
798
|
+
turnId: this.activeTurnId
|
|
799
|
+
});
|
|
800
|
+
} catch (error) {
|
|
801
|
+
this.failTurn(error);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
subscribeAgentMessage(bridge) {
|
|
805
|
+
const { sessionId, messageId, onEvent } = this.turn;
|
|
806
|
+
return bridge.onNotification("item/agentMessage/delta", (p) => {
|
|
807
|
+
const delta = p.delta ?? p.text ?? "";
|
|
808
|
+
if (!delta) return;
|
|
809
|
+
this.text += delta;
|
|
810
|
+
const part = {
|
|
811
|
+
id: messageId,
|
|
812
|
+
sessionID: sessionId,
|
|
813
|
+
messageID: messageId,
|
|
814
|
+
type: "text",
|
|
815
|
+
text: this.text
|
|
816
|
+
};
|
|
817
|
+
onEvent?.({
|
|
818
|
+
type: "message.part.updated",
|
|
819
|
+
part,
|
|
820
|
+
delta
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
subscribeTokenUsage(bridge) {
|
|
825
|
+
return bridge.onNotification("thread/tokenUsage/updated", (p) => {
|
|
826
|
+
const total = p.tokenUsage?.total;
|
|
827
|
+
if (!total) return;
|
|
828
|
+
this.tokenUsage = {
|
|
829
|
+
inputTokens: total.inputTokens ?? 0,
|
|
830
|
+
outputTokens: total.outputTokens ?? 0,
|
|
831
|
+
cacheReadInputTokens: total.cachedInputTokens
|
|
832
|
+
};
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
handleTurnCompleted(completed) {
|
|
836
|
+
if (this.activeThreadId === void 0 || this.activeTurnId === void 0) {
|
|
837
|
+
this.pendingTurnCompletions.push(completed);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (completed.threadId !== this.activeThreadId || completed.turn.id !== this.activeTurnId) return;
|
|
841
|
+
if (this.submittedPlan) {
|
|
842
|
+
if (!this.planInterruptRequested || completed.turn.status !== "interrupted") {
|
|
843
|
+
this.failTurn(/* @__PURE__ */ new Error(`Codex planning turn ended as ${completed.turn.status} before its durable plan was interrupted`));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
this.completeTurn();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (completed.turn.status === "completed") {
|
|
850
|
+
this.completeTurn();
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
this.failTurn(new Error(completed.turn.error?.message ?? `Codex turn ended with status ${completed.turn.status}`));
|
|
854
|
+
}
|
|
855
|
+
reportTurnError(error, warning) {
|
|
856
|
+
if (this.terminalSettled) return;
|
|
857
|
+
this.turn.onEvent?.({
|
|
858
|
+
type: "warning",
|
|
859
|
+
code: "CODEX_TURN_ERROR",
|
|
860
|
+
message: warning
|
|
861
|
+
});
|
|
862
|
+
this.failTurn(error);
|
|
863
|
+
}
|
|
864
|
+
drainPendingTurnEvents() {
|
|
865
|
+
if (this.activeThreadId === void 0 || this.activeTurnId === void 0) return;
|
|
866
|
+
const pendingCompletions = this.pendingTurnCompletions.splice(0);
|
|
867
|
+
for (const completed of pendingCompletions) this.handleTurnCompleted(completed);
|
|
868
|
+
const pendingErrors = this.pendingTurnErrors.splice(0);
|
|
869
|
+
for (const pending of pendingErrors) this.reportTurnError(pending.error, pending.warning);
|
|
870
|
+
}
|
|
871
|
+
handleErrorNotification(p) {
|
|
872
|
+
const e = p;
|
|
873
|
+
const base = e.error?.message ?? "codex turn error";
|
|
874
|
+
const warning = e.error?.additionalDetails ? `${base} (${e.error.additionalDetails})` : base;
|
|
875
|
+
if (e.willRetry === true) {
|
|
876
|
+
this.turn.onEvent?.({
|
|
877
|
+
type: "warning",
|
|
878
|
+
code: "CODEX_TURN_ERROR",
|
|
879
|
+
message: warning
|
|
880
|
+
});
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (this.activeTurnId === void 0 && this.pendingTurnCompletions.length > 0) {
|
|
884
|
+
this.pendingTurnErrors.push({
|
|
885
|
+
error: new Error(base),
|
|
886
|
+
warning
|
|
887
|
+
});
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
this.reportTurnError(new Error(base), warning);
|
|
891
|
+
}
|
|
892
|
+
failFromClose(reason) {
|
|
893
|
+
this.drainPendingTurnEvents();
|
|
894
|
+
if (this.terminalSettled) return;
|
|
895
|
+
const error = reason.error ?? /* @__PURE__ */ new Error(`Codex app-server closed before the matching turn completed (${reason.reason}): send the next turn to resume its thread`);
|
|
896
|
+
this.closeEnd = turnEndReason({
|
|
897
|
+
aborted: this.turn.input.abortSignal?.aborted,
|
|
898
|
+
timedOut: reason.reason === "aborted",
|
|
899
|
+
linkLost: this.activeTurnId !== void 0 && reason.reason !== "graceful"
|
|
900
|
+
});
|
|
901
|
+
this.failTurn(Object.assign(error, { reason: this.closeEnd }));
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
//#endregion
|
|
905
|
+
//#region src/codex-connection-config.ts
|
|
906
|
+
const DEFAULT_MODEL = "gpt-5-mini";
|
|
907
|
+
function resolveCodexRouterModel(model) {
|
|
908
|
+
if (!model) return DEFAULT_MODEL;
|
|
909
|
+
if (model.startsWith("openai/")) return model.slice(7);
|
|
910
|
+
if (model.startsWith("gpt-")) return model;
|
|
911
|
+
return DEFAULT_MODEL;
|
|
912
|
+
}
|
|
913
|
+
function normalizeExplicitCodexModel(model) {
|
|
914
|
+
return model.startsWith("openai/") ? model.slice(7) : model;
|
|
915
|
+
}
|
|
916
|
+
function resolveCodexAuthMode(config) {
|
|
917
|
+
return config.authMode ?? (config.apiKey ? "api-key" : "oauth");
|
|
918
|
+
}
|
|
919
|
+
const CODEX_CONFIG_PATH = ".codex/config.toml";
|
|
920
|
+
function isCodexConfigPath(filePath) {
|
|
921
|
+
return path.posix.normalize(filePath.replaceAll("\\", "/").replace(/^~\//, "")) === CODEX_CONFIG_PATH;
|
|
922
|
+
}
|
|
923
|
+
function resolveCodexApiKeyBaseUrl(config) {
|
|
924
|
+
return config.baseUrl || (config.apiKey ? "https://api.openai.com/v1" : resolveTangleRouterDefaults().baseUrl);
|
|
925
|
+
}
|
|
926
|
+
function resolveCodexConnection(config, explicitModel) {
|
|
927
|
+
const authMode = resolveCodexAuthMode(config);
|
|
928
|
+
const gatewayDefaults = resolveTangleRouterDefaults();
|
|
929
|
+
const baseUrl = authMode === "api-key" ? resolveCodexApiKeyBaseUrl(config) : void 0;
|
|
930
|
+
const gatewayModel = process.env.CODEX_MODEL || process.env.TANGLE_ROUTER_CODEX_MODEL || gatewayDefaults.model;
|
|
931
|
+
return {
|
|
932
|
+
baseUrl,
|
|
933
|
+
model: explicitModel !== void 0 ? normalizeExplicitCodexModel(explicitModel) : authMode === "api-key" ? resolveCodexRouterModel(gatewayModel) : void 0
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
function codexGatewayConfigArgs(baseUrl) {
|
|
937
|
+
if (!baseUrl) return [];
|
|
938
|
+
return Object.entries({
|
|
939
|
+
model_provider: "tangle",
|
|
940
|
+
"model_providers.tangle.name": "Tangle Router",
|
|
941
|
+
"model_providers.tangle.base_url": baseUrl,
|
|
942
|
+
"model_providers.tangle.env_key": "OPENAI_API_KEY",
|
|
943
|
+
"model_providers.tangle.wire_api": "responses"
|
|
944
|
+
}).flatMap(([key, value]) => ["-c", `${key}=${tomlString(value)}`]);
|
|
945
|
+
}
|
|
946
|
+
function tomlString(value) {
|
|
947
|
+
return JSON.stringify(value);
|
|
948
|
+
}
|
|
949
|
+
function codexPlanMcpConfigArgs(options) {
|
|
950
|
+
return [
|
|
951
|
+
"-c",
|
|
952
|
+
`mcp_servers.${options.name}.url=${tomlString(options.url)}`,
|
|
953
|
+
"-c",
|
|
954
|
+
`mcp_servers.${options.name}.bearer_token_env_var=${tomlString(options.tokenEnv)}`,
|
|
955
|
+
"-c",
|
|
956
|
+
`mcp_servers.${options.name}.tools.request_plan.approval_mode="approve"`
|
|
957
|
+
];
|
|
958
|
+
}
|
|
959
|
+
function routedBaseUrl(config, baseUrl, getAgentEnvironment) {
|
|
960
|
+
if (!baseUrl || !codexWarmSwitchOn()) return baseUrl;
|
|
961
|
+
const host = getAgentEnvironment();
|
|
962
|
+
return managedModelProxyUrl({
|
|
963
|
+
binding: Object.hasOwn(host, "TANGLE_OPENCODE_MODEL_PROXY") ? host["TANGLE_OPENCODE_MODEL_PROXY"] : process.env["TANGLE_OPENCODE_MODEL_PROXY"],
|
|
964
|
+
apiKey: config.apiKey,
|
|
965
|
+
upstream: baseUrl,
|
|
966
|
+
runtimeHome: config.runtimeHome
|
|
967
|
+
}) ?? baseUrl;
|
|
968
|
+
}
|
|
969
|
+
function codexWarmSwitchOn() {
|
|
970
|
+
return process.env[AGENT_INTERNAL_CODEX_WARM_SERVER_ENV_VAR] === "1";
|
|
971
|
+
}
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/codex-native-launch.ts
|
|
974
|
+
var CodexNativeLaunchAdapter = class extends CliProviderAdapterBase {
|
|
975
|
+
inheritProcessEnv = false;
|
|
976
|
+
nativeAttachmentKinds() {
|
|
977
|
+
return ["image"];
|
|
978
|
+
}
|
|
979
|
+
nativeConfigArgs(config, connection, prompts, flags) {
|
|
980
|
+
return [
|
|
981
|
+
...codexDeveloperInstructionArgs(prompts.appendSystemPrompt === void 0 ? prompts.systemPrompt : [prompts.systemPrompt, prompts.appendSystemPrompt].filter((part) => Boolean(part?.trim())).join("\n\n"), flags.concat(config.extraFlags ?? [])),
|
|
982
|
+
...codexGatewayConfigArgs(routedBaseUrl(config, connection.baseUrl, () => this.agentEnvironmentValues())),
|
|
983
|
+
...nativeEffortArgs("codex", config.profile?.model?.reasoningEffort),
|
|
984
|
+
...flags
|
|
985
|
+
];
|
|
986
|
+
}
|
|
987
|
+
buildExecArgs(params) {
|
|
988
|
+
const args = ["exec"];
|
|
989
|
+
if (params.sessionId) args.push("resume");
|
|
990
|
+
args.push("--json", ...CODEX_DISABLED_FEATURE_ARGS);
|
|
991
|
+
const explicitModel = params.model ?? params.config.model;
|
|
992
|
+
const connection = resolveCodexConnection(params.config, explicitModel);
|
|
993
|
+
if (connection.model) args.push("--model", connection.model);
|
|
994
|
+
if (!params.sessionId) args.push("--cd", params.workspaceRoot);
|
|
995
|
+
args.push("--skip-git-repo-check");
|
|
996
|
+
if (params.config.bypassPermissions !== false) args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
997
|
+
else if (params.config.sandboxMode) args.push("--sandbox", params.config.sandboxMode);
|
|
998
|
+
args.push(...this.nativeConfigArgs(params.config, connection, params, params.config.extraFlags ?? []));
|
|
999
|
+
const { native: nativeImages, message: messageWithRemoteMarkers } = partitionImageAttachments({
|
|
1000
|
+
message: params.message,
|
|
1001
|
+
attachments: params.attachments,
|
|
1002
|
+
qualifies: (attachment) => attachment.path !== void 0
|
|
1003
|
+
});
|
|
1004
|
+
const imageFlags = nativeImages.flatMap((attachment) => ["-i", attachment.path]);
|
|
1005
|
+
args.push(...imageFlags, ...params.sessionId ? [params.sessionId] : [], "-");
|
|
1006
|
+
return {
|
|
1007
|
+
command: this.getCliPath(),
|
|
1008
|
+
args,
|
|
1009
|
+
useStdin: true,
|
|
1010
|
+
stdin: messageWithRemoteMarkers
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
buildExecEnv(config) {
|
|
1014
|
+
const env = this.agentEnvironmentValues();
|
|
1015
|
+
if (config.runtimeHome) env.CODEX_HOME = path.join(config.runtimeHome, ".codex");
|
|
1016
|
+
if (resolveCodexAuthMode(config) === "oauth") {
|
|
1017
|
+
env.OPENAI_API_KEY = void 0;
|
|
1018
|
+
env.OPENAI_BASE_URL = void 0;
|
|
1019
|
+
} else {
|
|
1020
|
+
if (config.apiKey) env.OPENAI_API_KEY = config.apiKey;
|
|
1021
|
+
env.OPENAI_BASE_URL = routedBaseUrl(config, resolveCodexConnection(config).baseUrl, () => this.agentEnvironmentValues());
|
|
1022
|
+
}
|
|
1023
|
+
return env;
|
|
1024
|
+
}
|
|
1025
|
+
buildAppServerEnv(profilePlan, workspaceRoot) {
|
|
1026
|
+
return this.applyWritableHomeDefaults(this.mergeProfilePlanEnvironment(this.buildNativeExecutionEnvironment(this.config), profilePlan, this.config.nativeAuthEnv !== void 0 || (this.config.authFiles?.length ?? 0) > 0), workspaceRoot);
|
|
1027
|
+
}
|
|
1028
|
+
getManagedAuthFiles() {
|
|
1029
|
+
if (resolveCodexAuthMode(this.config) !== "oauth" || this.config.authFiles?.some((file) => isCodexConfigPath(file.path))) return [];
|
|
1030
|
+
const model = this.config.model ? normalizeExplicitCodexModel(this.config.model) : void 0;
|
|
1031
|
+
return [{
|
|
1032
|
+
path: CODEX_CONFIG_PATH,
|
|
1033
|
+
content: `${[...model ? [`model = ${tomlString(model)}`] : [], "cli_auth_credentials_store = \"file\""].join("\n")}\n`,
|
|
1034
|
+
mode: 384
|
|
1035
|
+
}];
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/codex-secret-profile.ts
|
|
1040
|
+
var CodexSecretProfileAdapter = class extends CodexNativeLaunchAdapter {
|
|
1041
|
+
supportsSecretProfileRevisions = true;
|
|
1042
|
+
mcpOwner;
|
|
1043
|
+
binary;
|
|
1044
|
+
constructor(options = {}) {
|
|
1045
|
+
super(options);
|
|
1046
|
+
this.mcpOwner = { proxy: options.mcpCredentialProxy };
|
|
1047
|
+
}
|
|
1048
|
+
refuse(reason, path = this.config.runtimeHome) {
|
|
1049
|
+
return refuseSecretLaunch(`Secret Codex ${reason}`, { path });
|
|
1050
|
+
}
|
|
1051
|
+
getCliPath() {
|
|
1052
|
+
return this.binary ?? super.getCliPath();
|
|
1053
|
+
}
|
|
1054
|
+
secretProfileVerificationScope() {
|
|
1055
|
+
return `${this.provider}\0${resolveExecutableRealpath(super.getCliPath(), this.agentEnvironmentValues().PATH) ?? super.getCliPath()}`;
|
|
1056
|
+
}
|
|
1057
|
+
async verifyPreparedSecretProfileRevision(input, plan) {
|
|
1058
|
+
await this.readNativeConfig(input, plan);
|
|
1059
|
+
}
|
|
1060
|
+
async readNativeConfig(input, plan) {
|
|
1061
|
+
const workspace = input.workspaceRoot ?? this.storagePath;
|
|
1062
|
+
const env = this.buildAppServerEnv(plan, workspace);
|
|
1063
|
+
const cwd = env.TANGLE_PROFILE_REVISION_WORKSPACE;
|
|
1064
|
+
const home = this.config.runtimeHome;
|
|
1065
|
+
const identity = this.subprocessIdentity();
|
|
1066
|
+
if (!cwd || !home || env.HOME !== home || env.CODEX_HOME !== `${home}/.codex` || env.TANGLE_SHARED_WORKSPACE !== workspace || !identity || !isReservedProfileUid(identity.uid)) this.refuse("identity or revision is invalid", home);
|
|
1067
|
+
assertSealedNativeHome(home, identity.gid, SEALED_NATIVE_HOME_LAYOUT.codex);
|
|
1068
|
+
assertSealedNativeSearchPath(cwd, [".codex", ".agents"]);
|
|
1069
|
+
const command = await provenStoreBinary(`codex-cli ${NATIVE_SESSION_CONTRACTS.codex.cliVersion}`, {
|
|
1070
|
+
command: super.getCliPath(),
|
|
1071
|
+
cwd,
|
|
1072
|
+
env,
|
|
1073
|
+
inheritEnv: this.inheritProcessEnv,
|
|
1074
|
+
isolatedIdentity: identity,
|
|
1075
|
+
signal: input.abortSignal
|
|
1076
|
+
});
|
|
1077
|
+
this.binary = command;
|
|
1078
|
+
const bridge = spawnRpcBridge({
|
|
1079
|
+
detached: true,
|
|
1080
|
+
inheritEnv: this.inheritProcessEnv,
|
|
1081
|
+
command,
|
|
1082
|
+
args: this.nativeArgs(input, plan),
|
|
1083
|
+
cwd,
|
|
1084
|
+
env,
|
|
1085
|
+
...identity,
|
|
1086
|
+
isolatedIdentity: this.config.subprocessUser,
|
|
1087
|
+
defaultRequestTimeoutMs: 15e3,
|
|
1088
|
+
signal: input.abortSignal
|
|
1089
|
+
});
|
|
1090
|
+
try {
|
|
1091
|
+
await bridge.request("initialize", {
|
|
1092
|
+
clientInfo: {
|
|
1093
|
+
name: "tangle-profile-prepare",
|
|
1094
|
+
version: "1"
|
|
1095
|
+
},
|
|
1096
|
+
capabilities: { experimentalApi: true }
|
|
1097
|
+
});
|
|
1098
|
+
bridge.notify("initialized");
|
|
1099
|
+
const servers = await this.readNativeControls(bridge, plan, cwd);
|
|
1100
|
+
const referenced = new Set(Object.values(servers).flatMap((server) => [...Object.values(server.env_http_headers ?? {}), ...server.env_vars ?? []]));
|
|
1101
|
+
if (Object.entries(env).some(([name, value]) => value?.includes("{env:TANGLE_PROFILE_SECRET_") && !referenced.has(name))) this.refuse("secret is outside MCP", cwd);
|
|
1102
|
+
return {
|
|
1103
|
+
cwd,
|
|
1104
|
+
env,
|
|
1105
|
+
servers
|
|
1106
|
+
};
|
|
1107
|
+
} finally {
|
|
1108
|
+
await bridge.close();
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
async readNativeControls(bridge, plan, cwd, expected) {
|
|
1112
|
+
const { config, layers } = await bridge.request("config/read", {
|
|
1113
|
+
cwd,
|
|
1114
|
+
includeLayers: true
|
|
1115
|
+
});
|
|
1116
|
+
const prompt = plan.spawn.flags.find((flag) => flag.startsWith("model_instructions_file="));
|
|
1117
|
+
if (config.features?.apps !== false || config.features.plugins !== false || prompt && config.model_instructions_file !== JSON.parse(prompt.slice(prompt.indexOf("=") + 1))) this.refuse("native controls differ", cwd);
|
|
1118
|
+
if (!layers || layers.some((layer) => layer.config?.mcp_servers !== void 0 && layer.name?.type !== "sessionFlags")) this.refuse("has ambient MCP configuration", cwd);
|
|
1119
|
+
const servers = config.mcp_servers ?? {};
|
|
1120
|
+
if (expected && (Object.keys(servers).length !== Object.keys(expected).length || Object.entries(servers).some(([name, server]) => {
|
|
1121
|
+
const declared = expected[name];
|
|
1122
|
+
return !declared || server.url !== declared.url || server.command || (server.args?.length ?? 0) > 0 || Object.keys(server.env ?? {}).length > 0 || (server.env_vars?.length ?? 0) > 0 || (server.bearer_token_env_var ?? void 0) !== declared.bearer_token_env_var || JSON.stringify(server.http_headers ?? {}) !== JSON.stringify(declared.http_headers ?? {}) || Object.keys(server.env_http_headers ?? {}).length > 0;
|
|
1123
|
+
}))) this.refuse("native MCP proxy controls differ", cwd);
|
|
1124
|
+
return servers;
|
|
1125
|
+
}
|
|
1126
|
+
nativeArgs(input, plan, mcp) {
|
|
1127
|
+
const prompts = this.resolveProfilePrompts(input, plan);
|
|
1128
|
+
if (this.config.secretProfileRevisionRoot && plan) {
|
|
1129
|
+
const context = `The mutable workspace is ${JSON.stringify(input.workspaceRoot ?? this.storagePath)}. The current directory holds the read-only supplied profile. Use absolute paths in the mutable workspace for edits, builds and git commands.`;
|
|
1130
|
+
prompts.appendSystemPrompt = [prompts.appendSystemPrompt, context].filter(Boolean).join("\n\n");
|
|
1131
|
+
}
|
|
1132
|
+
const connection = resolveCodexConnection(this.config, this.resolveProfileModel(input.model?.model) ?? this.config.model);
|
|
1133
|
+
const flags = (plan?.spawn.flags ?? []).filter((flag, index, all) => mcp === void 0 || !(flag === "-c" ? all[index + 1] : flag)?.startsWith("mcp_servers="));
|
|
1134
|
+
return [
|
|
1135
|
+
"app-server",
|
|
1136
|
+
"--strict-config",
|
|
1137
|
+
...CODEX_DISABLED_FEATURE_ARGS,
|
|
1138
|
+
...this.nativeConfigArgs(this.config, connection, prompts, flags),
|
|
1139
|
+
...mcp === void 0 ? [] : ["-c", `mcp_servers=${inlineToml(mcp)}`]
|
|
1140
|
+
];
|
|
1141
|
+
}
|
|
1142
|
+
async prepareSecretCodexTurn(input, plan, sessionId) {
|
|
1143
|
+
const native = await this.readNativeConfig(input, plan);
|
|
1144
|
+
const cwd = this.bindSecretProfileSessionView(native.cwd, sessionId);
|
|
1145
|
+
assertSealedNativeSearchPath(cwd, [".codex", ".agents"]);
|
|
1146
|
+
const refs = this.secretProfileReferences(plan) ?? {};
|
|
1147
|
+
const served = [];
|
|
1148
|
+
const inherited = (entries) => Object.fromEntries(entries.map(([name, key]) => [name, native.env[key]]));
|
|
1149
|
+
for (const [name, entry] of Object.entries(native.servers)) {
|
|
1150
|
+
if (entry.enabled === false) continue;
|
|
1151
|
+
const server = entry.url ? servableMcpServer(name, {
|
|
1152
|
+
url: entry.url,
|
|
1153
|
+
headers: {
|
|
1154
|
+
...entry.http_headers,
|
|
1155
|
+
...inherited(Object.entries(entry.env_http_headers ?? {}))
|
|
1156
|
+
}
|
|
1157
|
+
}, refs) : servableStdioServer(name, {
|
|
1158
|
+
command: [entry.command, ...entry.args ?? []],
|
|
1159
|
+
environment: {
|
|
1160
|
+
...entry.env,
|
|
1161
|
+
...inherited((entry.env_vars ?? []).map((key) => [key, key]))
|
|
1162
|
+
}
|
|
1163
|
+
}, refs);
|
|
1164
|
+
if (typeof server === "string") this.refuse(`cannot serve MCP ${name}: ${server}`, cwd);
|
|
1165
|
+
served.push(server);
|
|
1166
|
+
}
|
|
1167
|
+
const uid = this.config.subprocessUser?.uid;
|
|
1168
|
+
const scope = secretMcpScope(sessionId, uid, this.mcpOwner);
|
|
1169
|
+
const servers = {};
|
|
1170
|
+
if (served.length) {
|
|
1171
|
+
if (!this.mcpOwner.proxy) this.refuse("requires an MCP proxy", cwd);
|
|
1172
|
+
const endpoint = await this.mcpOwner.proxy.endpoint(scope, served.map((server) => server.name));
|
|
1173
|
+
for (const server of served) {
|
|
1174
|
+
const url = endpoint.urls[server.name];
|
|
1175
|
+
if (!url || !isLoopbackEndpoint(url)) this.refuse("MCP endpoint is not loopback", cwd);
|
|
1176
|
+
const declared = native.servers[server.name];
|
|
1177
|
+
servers[server.name] = {
|
|
1178
|
+
url,
|
|
1179
|
+
http_headers: { Authorization: `Bearer ${endpoint.placeholder}` },
|
|
1180
|
+
...Object.fromEntries(Object.entries(declared ?? {}).filter(([key, value]) => value !== null && [
|
|
1181
|
+
"tool_timeout_sec",
|
|
1182
|
+
"startup_timeout_sec",
|
|
1183
|
+
"enabled_tools",
|
|
1184
|
+
"disabled_tools",
|
|
1185
|
+
"required",
|
|
1186
|
+
"defer_loading"
|
|
1187
|
+
].includes(key)))
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const args = this.nativeArgs(input, plan, servers);
|
|
1192
|
+
return {
|
|
1193
|
+
cwd,
|
|
1194
|
+
env: native.env,
|
|
1195
|
+
args,
|
|
1196
|
+
release: served.length ? await this.mcpOwner.proxy?.lease(scope, served) : void 0,
|
|
1197
|
+
validate: (bridge, extra) => this.readNativeControls(bridge, plan, cwd, {
|
|
1198
|
+
...servers,
|
|
1199
|
+
...extra
|
|
1200
|
+
})
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
async shutdown() {
|
|
1204
|
+
try {
|
|
1205
|
+
if (!this.config.stableIsolatedIdentity) await forgetSessionScopes({ owner: this.mcpOwner });
|
|
1206
|
+
} finally {
|
|
1207
|
+
await super.shutdown();
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
function inlineToml(value) {
|
|
1212
|
+
if (typeof value === "string") return tomlString(value);
|
|
1213
|
+
if (typeof value === "boolean" || Number.isFinite(value)) return String(value);
|
|
1214
|
+
if (Array.isArray(value)) return `[${value.map(inlineToml).join(", ")}]`;
|
|
1215
|
+
if (isPlainRecord(value)) return `{ ${Object.entries(value).map(([key, entry]) => `${tomlString(key)} = ${inlineToml(entry)}`).join(", ")} }`;
|
|
1216
|
+
refuseSecretLaunch("Secret Codex config value is unsupported", { actual: typeof value });
|
|
1217
|
+
}
|
|
1218
|
+
//#endregion
|
|
1219
|
+
//#region src/codex-app-server-adapter.ts
|
|
1220
|
+
const CODEX_PLAN_MCP_TOKEN_ENV = "TANGLE_INTERACTION_TOKEN";
|
|
1221
|
+
function appServerRawCapture(runtimeHome) {
|
|
1222
|
+
return globalThis.process.getuid?.() === 0 && runtimeHome?.trim() ? { rawCapturePath: resolveRawCapturePath(runtimeHome.trim()) } : {};
|
|
1223
|
+
}
|
|
1224
|
+
var CodexAppServerAdapter = class extends CodexSecretProfileAdapter {
|
|
1225
|
+
permissions = new InteractionBroker();
|
|
1226
|
+
getCapabilities() {
|
|
1227
|
+
const { interactive: permission, questions: question, plans: plan } = appServerPolicy(this.config);
|
|
1228
|
+
return {
|
|
1229
|
+
...super.getCapabilities(),
|
|
1230
|
+
multimodal: !this.config.secretProfileRevisionRoot && !question && !plan,
|
|
1231
|
+
interactions: [
|
|
1232
|
+
...permission ? [InteractionKind.Permission] : [],
|
|
1233
|
+
...question ? [InteractionKind.Question] : [],
|
|
1234
|
+
...plan ? [InteractionKind.Plan] : []
|
|
1235
|
+
]
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
async respondToInteraction(response) {
|
|
1239
|
+
if (this.permissions.respond(response)) return;
|
|
1240
|
+
this.permissions.respondQuestion(response);
|
|
1241
|
+
}
|
|
1242
|
+
async execute(input, services, onEvent) {
|
|
1243
|
+
const nativeResume = input.nativeResume;
|
|
1244
|
+
if (nativeResume) {
|
|
1245
|
+
if (nativeResume.harness !== "codex" || input.planContinuation) throw new Error("Codex native resume requires Codex without plan continuation");
|
|
1246
|
+
await this.seedNativeSessionForResume(input, nativeResume.nativeSessionId);
|
|
1247
|
+
}
|
|
1248
|
+
const { interactive, questions, plans } = appServerPolicy(this.config, input);
|
|
1249
|
+
if (!this.config.secretProfileRevisionRoot && !interactive && !questions && !plans && !input.planContinuation) return super.execute(input, services, onEvent);
|
|
1250
|
+
return this.withExecution(input.abortSignal, (signal) => this.executeViaAppServer({
|
|
1251
|
+
...input,
|
|
1252
|
+
abortSignal: signal
|
|
1253
|
+
}, services, onEvent));
|
|
1254
|
+
}
|
|
1255
|
+
async executeViaAppServer(input, services, onEvent) {
|
|
1256
|
+
const prepared = await this.prepareAppServerTurn(input, services, onEvent);
|
|
1257
|
+
if (prepared.cached !== void 0) return prepared.cached;
|
|
1258
|
+
const queue = this.config.secretProfileRevisionRoot ? secretSessionTurnQueue(this.secretProfileSessionView(prepared.sessionId).view) : void 0;
|
|
1259
|
+
let bridge;
|
|
1260
|
+
let result;
|
|
1261
|
+
let closing;
|
|
1262
|
+
const close = () => closing ??= this.closeAppServerTurn(prepared, bridge, queue);
|
|
1263
|
+
prepared.run.cleanup = close;
|
|
1264
|
+
try {
|
|
1265
|
+
if (queue) await untilSettledOrAborted(queue.previous, input.abortSignal ?? new AbortController().signal);
|
|
1266
|
+
bridge = await this.launchAppServerTurn(prepared);
|
|
1267
|
+
result = await this.runAppServerTurn(prepared, bridge);
|
|
1268
|
+
} finally {
|
|
1269
|
+
if (!closing) await close();
|
|
1270
|
+
}
|
|
1271
|
+
await services.recorder.markTurnCompleted?.({ result });
|
|
1272
|
+
onEvent?.({
|
|
1273
|
+
type: "status",
|
|
1274
|
+
status: "completed"
|
|
1275
|
+
});
|
|
1276
|
+
return result;
|
|
1277
|
+
}
|
|
1278
|
+
/** Capture session binding, profile/prompt and native callbacks before acquiring resources.
|
|
1279
|
+
* Field construction stays one preparation record; launch/run/cleanup have separate owners. */
|
|
1280
|
+
async prepareAppServerTurn(input, services, onEvent) {
|
|
1281
|
+
this.ensureInitialized();
|
|
1282
|
+
const opened = await this.openTurnSession({
|
|
1283
|
+
...input,
|
|
1284
|
+
sessionId: input.sessionId ?? input.interactionBinding?.sessionId
|
|
1285
|
+
}, services.recorder, onEvent);
|
|
1286
|
+
if (opened.cached) return { cached: opened.cached };
|
|
1287
|
+
const { sessionId } = opened;
|
|
1288
|
+
const interactionBinding = this.interactionExecutionBinding(input, sessionId);
|
|
1289
|
+
const workspace = input.workspaceRoot ?? this.storagePath;
|
|
1290
|
+
const { profilePlan } = await this.materializeTurnProfile(input, sessionId);
|
|
1291
|
+
const { promptText } = await this.recordTurnPrompt(input, opened, services.recorder, {
|
|
1292
|
+
nativeAttachmentKinds: [],
|
|
1293
|
+
inlineImageBudgetBytes: 0
|
|
1294
|
+
});
|
|
1295
|
+
const connection = resolveCodexConnection(this.config, this.resolveProfileModel(input.model?.model) ?? this.config.model);
|
|
1296
|
+
const policy = appServerPolicy(this.config, input);
|
|
1297
|
+
const run = {
|
|
1298
|
+
promptText,
|
|
1299
|
+
cwd: workspace,
|
|
1300
|
+
model: connection.model,
|
|
1301
|
+
...policy
|
|
1302
|
+
};
|
|
1303
|
+
const turn = new CodexAppServerTurn({
|
|
1304
|
+
sessionId,
|
|
1305
|
+
messageId: opened.providerMessageIds?.assistantMessageId ?? randomUUID(),
|
|
1306
|
+
input,
|
|
1307
|
+
onEvent,
|
|
1308
|
+
logger: this.logger,
|
|
1309
|
+
plans: policy.plans,
|
|
1310
|
+
onNativeSession: async (providerSessionId) => {
|
|
1311
|
+
await this.getSessionStore().update(opened.sessionId, {
|
|
1312
|
+
providerSessionId,
|
|
1313
|
+
...this.config.runtimeHome ? { providerSessionHome: this.config.runtimeHome } : {}
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
turn.activeThreadId = initialThreadId(input) ?? opened.providerSessionId;
|
|
1318
|
+
return {
|
|
1319
|
+
releaseMcp: void 0,
|
|
1320
|
+
input,
|
|
1321
|
+
services,
|
|
1322
|
+
onEvent,
|
|
1323
|
+
sessionId,
|
|
1324
|
+
profilePlan,
|
|
1325
|
+
run,
|
|
1326
|
+
policy,
|
|
1327
|
+
turn,
|
|
1328
|
+
interactionBinding
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
async launchAppServerTurn(context) {
|
|
1332
|
+
const { input, profilePlan, run } = context;
|
|
1333
|
+
const secret = this.config.secretProfileRevisionRoot && profilePlan ? await this.prepareSecretCodexTurn(input, profilePlan, context.sessionId) : void 0;
|
|
1334
|
+
context.releaseMcp = secret?.release;
|
|
1335
|
+
const env = secret?.env ?? this.buildAppServerEnv(profilePlan, run.cwd);
|
|
1336
|
+
run.cwd = secret?.cwd ?? run.cwd;
|
|
1337
|
+
const args = secret?.args ?? this.nativeArgs(input, profilePlan);
|
|
1338
|
+
const extraMcp = await this.bindPlanMcp(context, args, env);
|
|
1339
|
+
if (secret) run.initialize = async (initialized) => {
|
|
1340
|
+
await secret.validate(initialized, extraMcp);
|
|
1341
|
+
if (existsSync(join(run.cwd, ".codex/skills"))) await initialized.request("skills/extraRoots/set", { extraRoots: [join(run.cwd, ".codex/skills")] });
|
|
1342
|
+
};
|
|
1343
|
+
return this.spawnAppServer(input, {
|
|
1344
|
+
args,
|
|
1345
|
+
cwd: run.cwd,
|
|
1346
|
+
env
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
spawnAppServer(input, launch) {
|
|
1350
|
+
const { timeoutMs } = input;
|
|
1351
|
+
const signal = AbortSignal.any([...input.abortSignal ? [input.abortSignal] : [], ...timeoutMs && timeoutMs > 0 ? [AbortSignal.timeout(timeoutMs)] : []]);
|
|
1352
|
+
const started = performance.now();
|
|
1353
|
+
const bridge = spawnRpcBridge({
|
|
1354
|
+
...launch,
|
|
1355
|
+
detached: true,
|
|
1356
|
+
inheritEnv: this.inheritProcessEnv,
|
|
1357
|
+
command: this.getCliPath(),
|
|
1358
|
+
signal,
|
|
1359
|
+
...this.subprocessSpawnOptions(),
|
|
1360
|
+
isolatedIdentity: this.config.subprocessUser,
|
|
1361
|
+
...appServerRawCapture(this.config.runtimeHome)
|
|
1362
|
+
});
|
|
1363
|
+
bridge.process?.once("spawn", () => this.logger.observePhase?.("cli_spawn", performance.now() - started));
|
|
1364
|
+
return bridge;
|
|
1365
|
+
}
|
|
1366
|
+
async runAppServerTurn(context, bridge) {
|
|
1367
|
+
const { turn, run, sessionId, services, onEvent, interactionBinding } = context;
|
|
1368
|
+
registerAppServerInteractions(bridge, this.permissions, sessionId, interactionBinding, context.policy.interactive, context.policy.questions, onEvent);
|
|
1369
|
+
const unsubscribe = turn.subscribe(bridge);
|
|
1370
|
+
try {
|
|
1371
|
+
const result = await turn.run(bridge, run);
|
|
1372
|
+
await services.recorder.appendAssistantParts([{
|
|
1373
|
+
id: randomUUID(),
|
|
1374
|
+
type: "text",
|
|
1375
|
+
text: result.text
|
|
1376
|
+
}]);
|
|
1377
|
+
await this.getMessageStore().append({
|
|
1378
|
+
sessionId,
|
|
1379
|
+
role: "assistant",
|
|
1380
|
+
parts: [{
|
|
1381
|
+
type: "text",
|
|
1382
|
+
text: result.text
|
|
1383
|
+
}]
|
|
1384
|
+
});
|
|
1385
|
+
return result;
|
|
1386
|
+
} finally {
|
|
1387
|
+
unsubscribe();
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
async closeAppServerTurn(context, bridge, queue) {
|
|
1391
|
+
try {
|
|
1392
|
+
await context.turn.planInteractions?.stop(context.sessionId);
|
|
1393
|
+
} finally {
|
|
1394
|
+
this.permissions.failSession(context.sessionId);
|
|
1395
|
+
try {
|
|
1396
|
+
await bridge?.close();
|
|
1397
|
+
} finally {
|
|
1398
|
+
await context.releaseMcp?.();
|
|
1399
|
+
}
|
|
1400
|
+
queue?.release();
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
async bindPlanMcp(context, args, env) {
|
|
1404
|
+
if (!context.policy.plans) return {};
|
|
1405
|
+
const { turn, sessionId, interactionBinding, services, onEvent } = context;
|
|
1406
|
+
turn.planInteractions = new McpInteractionSupport(CODEX_PLAN_MCP_NAME, [InteractionKind.Plan]);
|
|
1407
|
+
const planMcp = await turn.planInteractions.start(sessionId, interactionBinding, onEvent, {
|
|
1408
|
+
planHost: services.planHost,
|
|
1409
|
+
providerState: () => turn.planProviderState(),
|
|
1410
|
+
onCommitted: (plan) => {
|
|
1411
|
+
turn.submittedPlan = plan;
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
env[CODEX_PLAN_MCP_TOKEN_ENV] = planMcp.token;
|
|
1415
|
+
args.push(...codexPlanMcpConfigArgs({
|
|
1416
|
+
name: CODEX_PLAN_MCP_NAME,
|
|
1417
|
+
tokenEnv: CODEX_PLAN_MCP_TOKEN_ENV,
|
|
1418
|
+
url: planMcp.url
|
|
1419
|
+
}));
|
|
1420
|
+
return { [CODEX_PLAN_MCP_NAME]: {
|
|
1421
|
+
url: planMcp.url,
|
|
1422
|
+
bearer_token_env_var: CODEX_PLAN_MCP_TOKEN_ENV
|
|
1423
|
+
} };
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
//#endregion
|
|
1427
|
+
//#region src/codex-exec-events.ts
|
|
1428
|
+
function mapCodexEventToStreamEvents(event, state, provider, logger) {
|
|
1429
|
+
switch (event.type) {
|
|
1430
|
+
case "session_started": return mapSessionStartedEvent(event, state);
|
|
1431
|
+
case "thread.started": return mapThreadStartedEvent(event, state, provider);
|
|
1432
|
+
case "message": return mapMessageEvent(event, state, logger);
|
|
1433
|
+
case "item.started": return mapToolItem(event.item, state, false) ?? [{
|
|
1434
|
+
type: "raw",
|
|
1435
|
+
backend: provider,
|
|
1436
|
+
event
|
|
1437
|
+
}];
|
|
1438
|
+
case "item.completed": return mapToolItem(event.item, state, true) ?? mapItemCompletedEvent(event, state, provider, logger);
|
|
1439
|
+
case "turn.completed":
|
|
1440
|
+
recordCliTokenUsage(state, event.usage);
|
|
1441
|
+
return [{
|
|
1442
|
+
type: "raw",
|
|
1443
|
+
backend: provider,
|
|
1444
|
+
event
|
|
1445
|
+
}];
|
|
1446
|
+
case "task_started": return mapTaskStartedEvent(event);
|
|
1447
|
+
case "error": return mapErrorEvent(event);
|
|
1448
|
+
default: return [{
|
|
1449
|
+
type: "raw",
|
|
1450
|
+
backend: provider,
|
|
1451
|
+
event
|
|
1452
|
+
}];
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* A tool the agent ran, as `codex exec --json` reports it: an item that
|
|
1457
|
+
* starts and then completes. A completion without a start emits both.
|
|
1458
|
+
* Undefined for an item that is not a tool.
|
|
1459
|
+
*/
|
|
1460
|
+
function mapToolItem(item, state, completed) {
|
|
1461
|
+
const call = codexToolCall(item);
|
|
1462
|
+
if (!call) return void 0;
|
|
1463
|
+
const events = state.toolCalls.has(call.id) ? [] : toolStartEvents(state, call);
|
|
1464
|
+
if (!completed) return events;
|
|
1465
|
+
const failed = item.status === "failed" || item.status === "declined" || typeof item.exit_code === "number" && item.exit_code !== 0 || item.error !== void 0 && item.error !== null;
|
|
1466
|
+
return [...events, ...toolResultEvents(state, {
|
|
1467
|
+
id: call.id,
|
|
1468
|
+
content: item.aggregated_output ?? item.result ?? item.error ?? item.status,
|
|
1469
|
+
isError: failed
|
|
1470
|
+
})];
|
|
1471
|
+
}
|
|
1472
|
+
function codexToolCall(item) {
|
|
1473
|
+
const id = item.id;
|
|
1474
|
+
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
1475
|
+
switch (item.type) {
|
|
1476
|
+
case "command_execution": return {
|
|
1477
|
+
id,
|
|
1478
|
+
name: "bash",
|
|
1479
|
+
input: { command: item.command ?? "" }
|
|
1480
|
+
};
|
|
1481
|
+
case "mcp_tool_call":
|
|
1482
|
+
if (!item.server && !item.tool) return void 0;
|
|
1483
|
+
return {
|
|
1484
|
+
id,
|
|
1485
|
+
name: `mcp__${item.server ?? ""}__${item.tool ?? ""}`,
|
|
1486
|
+
input: item.arguments ?? {}
|
|
1487
|
+
};
|
|
1488
|
+
case "web_search": return {
|
|
1489
|
+
id,
|
|
1490
|
+
name: "websearch",
|
|
1491
|
+
input: { query: item.query ?? "" }
|
|
1492
|
+
};
|
|
1493
|
+
default: return;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
function mapItemCompletedEvent(event, state, provider, logger) {
|
|
1497
|
+
if (event.item?.type === "file_change") {
|
|
1498
|
+
const fileEvents = mapFileChangeCompleted(event.item, state, logger);
|
|
1499
|
+
if (fileEvents !== null) return fileEvents;
|
|
1500
|
+
return [{
|
|
1501
|
+
type: "raw",
|
|
1502
|
+
backend: provider,
|
|
1503
|
+
event
|
|
1504
|
+
}];
|
|
1505
|
+
}
|
|
1506
|
+
if (event.item?.type !== "agent_message" || typeof event.item.text !== "string") return [{
|
|
1507
|
+
type: "raw",
|
|
1508
|
+
backend: provider,
|
|
1509
|
+
event
|
|
1510
|
+
}];
|
|
1511
|
+
const nextText = event.item.text;
|
|
1512
|
+
const previousLength = state.textBuffer.length;
|
|
1513
|
+
const delta = nextText.startsWith(state.textBuffer) ? nextText.slice(previousLength) : void 0;
|
|
1514
|
+
state.textBuffer = nextText;
|
|
1515
|
+
state.textPartId ??= randomUUID();
|
|
1516
|
+
return [{
|
|
1517
|
+
type: "message.part.updated",
|
|
1518
|
+
part: {
|
|
1519
|
+
...streamPartBase(state, state.textPartId),
|
|
1520
|
+
type: "text",
|
|
1521
|
+
text: state.textBuffer
|
|
1522
|
+
},
|
|
1523
|
+
...delta ? { delta } : {}
|
|
1524
|
+
}];
|
|
1525
|
+
}
|
|
1526
|
+
function mapFileChangeCompleted(item, state, logger) {
|
|
1527
|
+
if (!Array.isArray(item.changes)) {
|
|
1528
|
+
logger.debug("file_change item.completed without a changes array", { itemId: item.id });
|
|
1529
|
+
return null;
|
|
1530
|
+
}
|
|
1531
|
+
if (item.status !== void 0 && item.status !== "completed") return null;
|
|
1532
|
+
const events = [];
|
|
1533
|
+
for (const change of item.changes) {
|
|
1534
|
+
if (typeof change?.path !== "string" || change.path.length === 0) {
|
|
1535
|
+
logger.debug("malformed change in file_change item", { itemId: item.id });
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
if (change.kind == null) {
|
|
1539
|
+
logger.debug("change without a kind in file_change item", {
|
|
1540
|
+
itemId: item.id,
|
|
1541
|
+
path: change.path
|
|
1542
|
+
});
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
const tracked = trackFilePart(state, {
|
|
1546
|
+
path: change.path,
|
|
1547
|
+
changeKind: normalizeChangeKind(change.kind) ?? "update"
|
|
1548
|
+
});
|
|
1549
|
+
if (!tracked) continue;
|
|
1550
|
+
events.push(filePartUpdated(state, tracked));
|
|
1551
|
+
}
|
|
1552
|
+
return events.length > 0 ? events : null;
|
|
1553
|
+
}
|
|
1554
|
+
function mapSessionStartedEvent(event, state) {
|
|
1555
|
+
return [...sessionObservedEvents(state, "codex", event.session_id), {
|
|
1556
|
+
type: "status",
|
|
1557
|
+
status: "processing",
|
|
1558
|
+
detail: "Session started"
|
|
1559
|
+
}];
|
|
1560
|
+
}
|
|
1561
|
+
function mapThreadStartedEvent(event, state, provider) {
|
|
1562
|
+
return [...sessionObservedEvents(state, "codex", event.thread_id), {
|
|
1563
|
+
type: "raw",
|
|
1564
|
+
backend: provider,
|
|
1565
|
+
event
|
|
1566
|
+
}];
|
|
1567
|
+
}
|
|
1568
|
+
function mapMessageEvent(event, state, logger) {
|
|
1569
|
+
if (event.role !== "assistant" || !event.message.content) return [];
|
|
1570
|
+
const events = [];
|
|
1571
|
+
for (const block of event.message.content) events.push(...mapContentBlock(block, state, logger));
|
|
1572
|
+
return events;
|
|
1573
|
+
}
|
|
1574
|
+
function mapContentBlock(block, state, logger) {
|
|
1575
|
+
switch (block.type) {
|
|
1576
|
+
case "text": return textDeltaEvents(state, block.text);
|
|
1577
|
+
case "thinking": return reasoningEvents(state, block.thinking, { signalPhase: true });
|
|
1578
|
+
case "tool_use": return toolStartEvents(state, block);
|
|
1579
|
+
case "tool_result": return toolResultEvents(state, {
|
|
1580
|
+
id: block.tool_use_id,
|
|
1581
|
+
content: block.content,
|
|
1582
|
+
isError: Boolean(block.is_error),
|
|
1583
|
+
afterSuccess: (toolCall) => detectFileParts(toolCall, block.content, state, logger)
|
|
1584
|
+
});
|
|
1585
|
+
default: return [];
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
function detectFileParts(toolCall, raw, state, logger) {
|
|
1589
|
+
const events = [];
|
|
1590
|
+
if (!toolCall.name.startsWith("mcp__") || !Array.isArray(raw)) return events;
|
|
1591
|
+
for (const block of raw) {
|
|
1592
|
+
if (typeof block !== "object" || block === null || block.type !== "image") continue;
|
|
1593
|
+
const { data, mimeType } = block;
|
|
1594
|
+
if (typeof data !== "string" || typeof mimeType !== "string" || !/^[a-zA-Z0-9.+-]+\/[a-zA-Z0-9.+-]+$/.test(mimeType)) {
|
|
1595
|
+
logger.debug("malformed image block in MCP tool result", { tool: toolCall.name });
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
const tracked = trackFilePart(state, {
|
|
1599
|
+
dataUri: `data:${mimeType};base64,${data}`,
|
|
1600
|
+
mediaType: mimeType
|
|
1601
|
+
});
|
|
1602
|
+
if (!tracked) continue;
|
|
1603
|
+
events.push(filePartUpdated(state, tracked));
|
|
1604
|
+
}
|
|
1605
|
+
return events;
|
|
1606
|
+
}
|
|
1607
|
+
function mapTaskStartedEvent(event) {
|
|
1608
|
+
return [{
|
|
1609
|
+
type: "status",
|
|
1610
|
+
status: "processing",
|
|
1611
|
+
detail: `Task ${event.task_type ?? event.task_id} started`
|
|
1612
|
+
}];
|
|
1613
|
+
}
|
|
1614
|
+
function mapErrorEvent(event) {
|
|
1615
|
+
return [{
|
|
1616
|
+
type: "warning",
|
|
1617
|
+
code: event.code ?? "CODEX_ERROR",
|
|
1618
|
+
message: event.message
|
|
1619
|
+
}];
|
|
1620
|
+
}
|
|
1621
|
+
/** The complete emitted part for either native-path or MCP image observations. */
|
|
1622
|
+
function filePartUpdated(state, tracked) {
|
|
1623
|
+
return {
|
|
1624
|
+
type: "message.part.updated",
|
|
1625
|
+
part: {
|
|
1626
|
+
...streamPartBase(state, tracked.id),
|
|
1627
|
+
type: "file",
|
|
1628
|
+
...tracked.filename ? { filename: tracked.filename } : {},
|
|
1629
|
+
...tracked.mediaType ? { mediaType: tracked.mediaType } : {},
|
|
1630
|
+
url: tracked.url
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
//#endregion
|
|
1635
|
+
//#region src/held-spare.ts
|
|
1636
|
+
/**
|
|
1637
|
+
* The Codex process started before the turn that uses it (the shared
|
|
1638
|
+
* mechanics are sdk-cli-runner held-spare.ts).
|
|
1639
|
+
*
|
|
1640
|
+
* `codex exec ... -` finishes its start (config, auth, model provider) and then
|
|
1641
|
+
* reads its prompt from stdin, so a process started with stdin held open does
|
|
1642
|
+
* that work ahead of the turn and takes the prompt the moment it arrives.
|
|
1643
|
+
* Codex requests carry no per-turn headers, so the turn's launch must equal
|
|
1644
|
+
* the spare's exactly: same argv, same environment.
|
|
1645
|
+
*/
|
|
1646
|
+
const codexHeldSpares = createHeldSpareRegistry();
|
|
1647
|
+
//#endregion
|
|
1648
|
+
//#region src/adapter.ts
|
|
1649
|
+
/** Type guard for Codex events. */
|
|
1650
|
+
function isCodexEvent(value) {
|
|
1651
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1652
|
+
return typeof value.type === "string";
|
|
1653
|
+
}
|
|
1654
|
+
/** Codex provider adapter. */
|
|
1655
|
+
var CodexProviderAdapter = class extends CodexAppServerAdapter {
|
|
1656
|
+
provider = "codex";
|
|
1657
|
+
profileHarness = "codex";
|
|
1658
|
+
defaultCliPath = "codex";
|
|
1659
|
+
/** Start the process the box's first turn will adopt: `codex exec` launched as an ordinary new-session managed turn. */
|
|
1660
|
+
async prewarmProcess(workspaceRootOrUndefined, launch) {
|
|
1661
|
+
this.ensureInitialized();
|
|
1662
|
+
const refused = { ready: Promise.resolve(false) };
|
|
1663
|
+
const home = this.config.runtimeHome?.trim();
|
|
1664
|
+
if (!home || !codexWarmSwitchOn() || !isManagedModelToken(this.config.apiKey)) return refused;
|
|
1665
|
+
const workspaceRoot = workspaceRootOrUndefined ?? this.storagePath;
|
|
1666
|
+
const existing = codexHeldSpares.spareFor(home);
|
|
1667
|
+
if (existing) return { ready: Promise.resolve(existing.state === "idle") };
|
|
1668
|
+
const input = {
|
|
1669
|
+
message: "",
|
|
1670
|
+
systemPrompt: launch.systemPrompt,
|
|
1671
|
+
workspaceRoot
|
|
1672
|
+
};
|
|
1673
|
+
if (this.resolveExecutionProfileForInput(input) || this.config.runtimeAttachments) return refused;
|
|
1674
|
+
const config = withTurnInteractions(this.config, input);
|
|
1675
|
+
if (routesPermissions(config) || routesQuestions(config) || codexRoutesPlans(config)) return refused;
|
|
1676
|
+
const execArgs = this.buildExecArgs({
|
|
1677
|
+
message: "",
|
|
1678
|
+
config,
|
|
1679
|
+
model: this.resolveProfileModel(void 0),
|
|
1680
|
+
workspaceRoot,
|
|
1681
|
+
systemPrompt: launch.systemPrompt
|
|
1682
|
+
});
|
|
1683
|
+
const env = this.buildTurnEnvironment(config, void 0, workspaceRoot, void 0);
|
|
1684
|
+
const id = randomUUID();
|
|
1685
|
+
const controller = new AbortController();
|
|
1686
|
+
const parser = createDeferredParser();
|
|
1687
|
+
const child = await super.spawnExecutionProcess({
|
|
1688
|
+
input,
|
|
1689
|
+
sessionId: id,
|
|
1690
|
+
config,
|
|
1691
|
+
execArgs,
|
|
1692
|
+
workspaceRoot,
|
|
1693
|
+
env,
|
|
1694
|
+
signal: controller.signal,
|
|
1695
|
+
parser: parser.config,
|
|
1696
|
+
deferRawCapture: true
|
|
1697
|
+
});
|
|
1698
|
+
const spare = {
|
|
1699
|
+
id,
|
|
1700
|
+
home,
|
|
1701
|
+
workspaceRoot,
|
|
1702
|
+
args: execArgs.args,
|
|
1703
|
+
env,
|
|
1704
|
+
process: child,
|
|
1705
|
+
parser,
|
|
1706
|
+
controller,
|
|
1707
|
+
startedAt: performance.now(),
|
|
1708
|
+
state: "idle"
|
|
1709
|
+
};
|
|
1710
|
+
spare.idleTimer = setTimeout(() => {
|
|
1711
|
+
this.logger.info("Held codex spare idle; stopping it", { home });
|
|
1712
|
+
codexHeldSpares.stop(spare);
|
|
1713
|
+
}, HELD_SPARE_IDLE_MS);
|
|
1714
|
+
spare.idleTimer.unref();
|
|
1715
|
+
codexHeldSpares.register(spare);
|
|
1716
|
+
child.exitPromise.then(() => codexHeldSpares.stop(spare), () => codexHeldSpares.stop(spare));
|
|
1717
|
+
this.logger.info("Held codex spare started", { home });
|
|
1718
|
+
return { ready: Promise.resolve(child.pid !== void 0) };
|
|
1719
|
+
}
|
|
1720
|
+
/** A new session takes the spare in its HOME when its own launch is the spare's: the same. */
|
|
1721
|
+
spawnExecutionProcess(request) {
|
|
1722
|
+
const spare = codexHeldSpares.spareFor(this.config.runtimeHome?.trim());
|
|
1723
|
+
if (!spare || spare.state !== "idle") return super.spawnExecutionProcess(request);
|
|
1724
|
+
const differences = launchDifferences(spare, {
|
|
1725
|
+
args: request.execArgs.args,
|
|
1726
|
+
env: request.env
|
|
1727
|
+
});
|
|
1728
|
+
const reason = spare.workspaceRoot !== request.workspaceRoot ? "workspace" : !request.execArgs.useStdin ? "prompt-not-on-stdin" : request.timeoutMs !== void 0 ? "turn has a timeout" : differences.length > 0 ? `launch differs: ${differences.join(",")}` : void 0;
|
|
1729
|
+
const own = () => super.spawnExecutionProcess(request);
|
|
1730
|
+
if (reason) {
|
|
1731
|
+
this.logger.info("Held codex spare not adopted", { reason });
|
|
1732
|
+
codexHeldSpares.stop(spare);
|
|
1733
|
+
return spare.process.exitPromise.then(own, own);
|
|
1734
|
+
}
|
|
1735
|
+
let adopted;
|
|
1736
|
+
try {
|
|
1737
|
+
adopted = codexHeldSpares.adopt(spare, request);
|
|
1738
|
+
} catch (error) {
|
|
1739
|
+
this.logger.info("Held codex spare not adopted", { reason: error instanceof Error ? error.message : String(error) });
|
|
1740
|
+
return spare.process.exitPromise.then(own, own);
|
|
1741
|
+
}
|
|
1742
|
+
this.logger.observePhase?.("cli_ready_wait", 0);
|
|
1743
|
+
this.logger.info("Held codex spare adopted", { heldMs: Math.round(performance.now() - spare.startedAt) });
|
|
1744
|
+
return adopted;
|
|
1745
|
+
}
|
|
1746
|
+
createEventParser(state, onEvent) {
|
|
1747
|
+
return jsonEventParser(this.logger, onEvent, {
|
|
1748
|
+
label: "Codex",
|
|
1749
|
+
accept: isCodexEvent,
|
|
1750
|
+
map: (event) => this.mapEventToStreamEvents(event, state),
|
|
1751
|
+
isReady: (event) => event.type === "thread.started"
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
buildMcpAddArgs(name, config) {
|
|
1755
|
+
const args = [
|
|
1756
|
+
"mcp",
|
|
1757
|
+
"add",
|
|
1758
|
+
name
|
|
1759
|
+
];
|
|
1760
|
+
if (config.type === "http" || config.type === "remote") {
|
|
1761
|
+
args.push("--url", config.url);
|
|
1762
|
+
if (config.headers?.Authorization) args.push("--bearer-token-env-var", "MCP_BEARER_TOKEN");
|
|
1763
|
+
} else {
|
|
1764
|
+
for (const [key, value] of Object.entries(localMcpEnv(config) ?? {})) args.push("--env", `${key}=${value}`);
|
|
1765
|
+
args.push("--", ...localMcpArgv(config));
|
|
1766
|
+
}
|
|
1767
|
+
return args;
|
|
1768
|
+
}
|
|
1769
|
+
buildMcpListArgs() {
|
|
1770
|
+
return ["mcp", "list"];
|
|
1771
|
+
}
|
|
1772
|
+
formatStdinMessage(message) {
|
|
1773
|
+
return message;
|
|
1774
|
+
}
|
|
1775
|
+
mapEventToStreamEvents(event, state) {
|
|
1776
|
+
return mapCodexEventToStreamEvents(event, state, this.provider, this.logger);
|
|
1777
|
+
}
|
|
1778
|
+
};
|
|
1779
|
+
//#endregion
|
|
1780
|
+
export { CodexProviderAdapter, codexHeldSpares, normalizeExplicitCodexModel, resolveCodexApiKeyBaseUrl };
|
|
1781
|
+
|
|
1782
|
+
//# sourceMappingURL=index.mjs.map
|