@yansigit/opencodex 2.31.1 → 2.31.3

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.
Files changed (36) hide show
  1. package/gui/dist/assets/{index-DJDp_XER.js → index-Cxt5fZMP.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/base.ts +6 -0
  5. package/src/adapters/command-code-project-context.ts +377 -0
  6. package/src/adapters/command-code.ts +5 -1
  7. package/src/adapters/cursor/cursor-errors.ts +12 -0
  8. package/src/adapters/cursor/live-transport.ts +21 -0
  9. package/src/adapters/cursor/native-exec-bridge.ts +141 -0
  10. package/src/adapters/cursor/thread-continuity.ts +71 -0
  11. package/src/adapters/cursor.ts +90 -28
  12. package/src/adapters/google-http.ts +12 -2
  13. package/src/adapters/google-wire-compiler.ts +91 -2
  14. package/src/adapters/google.ts +83 -15
  15. package/src/config.ts +2 -0
  16. package/src/generated/compatibility-version.json +57 -25
  17. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  18. package/src/oauth/anthropic-routing.ts +129 -219
  19. package/src/oauth/antigravity-routing.ts +165 -0
  20. package/src/oauth/cursor-routing.ts +252 -0
  21. package/src/oauth/index.ts +3 -0
  22. package/src/providers/cursor-pool.ts +3 -3
  23. package/src/routing/account-pool/affinity.ts +125 -0
  24. package/src/routing/account-pool/cooldown.ts +145 -0
  25. package/src/routing/account-pool/index.ts +45 -0
  26. package/src/routing/account-pool/resolve.ts +356 -0
  27. package/src/routing/account-pool/types.ts +32 -0
  28. package/src/routing/compatibility/behavior.ts +3 -0
  29. package/src/server/management/oauth-account-routes.ts +47 -12
  30. package/src/server/responses/core.ts +400 -72
  31. package/src/types/config.ts +8 -0
  32. package/src/types/provider.ts +7 -0
  33. package/src/types/request.ts +13 -3
  34. package/src/usage/log.ts +4 -0
  35. package/src/web-search/gemini-executor.ts +35 -13
  36. package/src/web-search/index.ts +85 -1
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DJDp_XER.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-Cxt5fZMP.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-CGoDO3uO.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yansigit/opencodex",
3
- "version": "2.31.1",
3
+ "version": "2.31.3",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -24,6 +24,12 @@ export interface IncomingMeta {
24
24
  export interface ProviderAdapter {
25
25
  name: string;
26
26
 
27
+ /**
28
+ * Validate a parsed request before any attempt, sidecar, pacing, queue, or transport work.
29
+ * Adapters may throw a client-facing validation error when the request is unsupported.
30
+ */
31
+ validateRequest?(parsed: OcxParsedRequest): void;
32
+
27
33
  /**
28
34
  * Convert an already-read provider HTTP error into client-safe text. This hook must be pure and
29
35
  * return fully redacted output: callers may pass untrusted provider headers and payload text.
@@ -0,0 +1,377 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { open, opendir } from "node:fs/promises";
3
+ import { join, sep } from "node:path";
4
+
5
+ export type CommandCodeProjectContext = {
6
+ memory: string;
7
+ taste: string | null;
8
+ skills: string | null;
9
+ };
10
+
11
+ export const EMPTY_COMMAND_CODE_PROJECT_CONTEXT: CommandCodeProjectContext = {
12
+ memory: "",
13
+ taste: null,
14
+ skills: null,
15
+ };
16
+
17
+ const MEMORY_CAP_BYTES = 32_768;
18
+ const TASTE_CAP_BYTES = 8_192;
19
+ const SKILLS_XML_CAP_BYTES = 32_768;
20
+ const MAX_SKILLS = 16;
21
+ // Upper bound on how many candidate skill directories one root may enumerate before
22
+ // stopping. Realistic projects have far fewer; this bounds pathological/hostile
23
+ // directories (millions of entries) to a finite scan while preserving alphabetical
24
+ // selection for any realistic case (≤ MAX_SKILL_DIRS_TO_SCAN valid dirs per root).
25
+ const MAX_SKILL_DIRS_TO_SCAN = 256;
26
+ const FILE_OP_TIMEOUT_MS = 2_000;
27
+ const PROJECT_CONTEXT_TTL_MS = 30_000;
28
+ const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128;
29
+
30
+ const TRUNCATION_MARKER = "\n<!-- truncated -->";
31
+
32
+ const SKILL_ROOTS = [
33
+ ".commandcode/skills",
34
+ ".agents/skills",
35
+ ".pi/skills",
36
+ ] as const;
37
+
38
+ export const projectContextCache = new Map<string, { collectedAt: number; value: CommandCodeProjectContext }>();
39
+
40
+ /**
41
+ * Evict expired entries first, then the oldest live entry if at capacity.
42
+ * Called before inserting a new key so the cache never exceeds the cap.
43
+ */
44
+ function pruneExpiredProjectContextCache(now: number): void {
45
+ for (const [key, entry] of projectContextCache) {
46
+ if (now - entry.collectedAt >= PROJECT_CONTEXT_TTL_MS) {
47
+ projectContextCache.delete(key);
48
+ }
49
+ }
50
+ }
51
+
52
+ export function pruneProjectContextCache(now: number): void {
53
+ pruneExpiredProjectContextCache(now);
54
+ if (projectContextCache.size >= MAX_PROJECT_CONTEXT_CACHE_ENTRIES) {
55
+ let oldestKey: string | null = null;
56
+ let oldestAt = Infinity;
57
+ for (const [key, entry] of projectContextCache) {
58
+ if (entry.collectedAt < oldestAt) {
59
+ oldestAt = entry.collectedAt;
60
+ oldestKey = key;
61
+ }
62
+ }
63
+ if (oldestKey !== null) projectContextCache.delete(oldestKey);
64
+ }
65
+ }
66
+
67
+ /** Fail-soft canonical path; returns null when the path does not exist or cannot be resolved. */
68
+ function canonicalPath(candidate: string): string | null {
69
+ if (!existsSync(candidate)) return null;
70
+ try {
71
+ return realpathSync.native(candidate);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function normalizePathIdentity(path: string): string {
78
+ return process.platform === "win32" ? path.toLowerCase() : path;
79
+ }
80
+
81
+ function confinedCanonicalPath(filePath: string, cwdCanonical: string): string | null {
82
+ const fileCanonical = canonicalPath(filePath);
83
+ if (!fileCanonical) return null;
84
+ const fileId = normalizePathIdentity(fileCanonical);
85
+ const cwdId = normalizePathIdentity(cwdCanonical);
86
+ if (fileId === cwdId || fileId.startsWith(cwdId + sep)) return fileCanonical;
87
+ return null;
88
+ }
89
+
90
+ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
91
+ let timer: ReturnType<typeof setTimeout> | undefined;
92
+ try {
93
+ return await Promise.race([
94
+ promise,
95
+ new Promise<never>((_, reject) => {
96
+ timer = setTimeout(() => reject(new Error("timeout")), ms);
97
+ }),
98
+ ]);
99
+ } finally {
100
+ if (timer !== undefined) clearTimeout(timer);
101
+ }
102
+ }
103
+
104
+ function truncateUtf8(text: string, capBytes: number): string {
105
+ const buf = Buffer.from(text, "utf8");
106
+ if (buf.length <= capBytes) return text;
107
+ const markerBuf = Buffer.from(TRUNCATION_MARKER, "utf8");
108
+ const prefixCap = capBytes - markerBuf.length;
109
+ if (prefixCap <= 0) return TRUNCATION_MARKER.slice(0, capBytes);
110
+ let end = prefixCap;
111
+ while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--;
112
+ return buf.subarray(0, end).toString("utf8") + TRUNCATION_MARKER;
113
+ }
114
+
115
+ async function readUtf8File(path: string, capBytes: number): Promise<string | null> {
116
+ type FileHandle = Awaited<ReturnType<typeof open>>;
117
+ let fileHandle: FileHandle | undefined;
118
+ const closedHandles = new WeakSet<object>();
119
+ const opened = open(path, "r");
120
+ const closeBestEffort = (handle: FileHandle): Promise<void> => {
121
+ if (closedHandles.has(handle)) return Promise.resolve();
122
+ closedHandles.add(handle);
123
+ return Promise.resolve()
124
+ .then(() => handle.close())
125
+ .catch(() => {
126
+ /* closing a timed-out read is best-effort */
127
+ });
128
+ };
129
+
130
+ const read = (async () => {
131
+ const handle = await opened;
132
+ fileHandle = handle;
133
+ try {
134
+ const data = Buffer.alloc(capBytes + 1);
135
+ const { bytesRead } = await handle.read(data, 0, data.length, 0);
136
+ return data.subarray(0, bytesRead).toString("utf8");
137
+ } finally {
138
+ await closeBestEffort(handle);
139
+ if (fileHandle === handle) fileHandle = undefined;
140
+ }
141
+ })();
142
+
143
+ try {
144
+ return await withTimeout(read, FILE_OP_TIMEOUT_MS);
145
+ } catch {
146
+ if (fileHandle) void closeBestEffort(fileHandle);
147
+ void opened.then(handle => closeBestEffort(handle), () => undefined);
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function xmlEscape(value: string): string {
153
+ return value
154
+ .replace(/&/g, "&amp;")
155
+ .replace(/</g, "&lt;")
156
+ .replace(/>/g, "&gt;")
157
+ .replace(/"/g, "&quot;");
158
+ }
159
+
160
+ function parseSkillFrontmatter(text: string): { name: string | null; body: string } {
161
+ const opening = text.startsWith("---\r\n") ? "---\r\n" : text.startsWith("---\n") ? "---\n" : null;
162
+ if (!opening) return { name: null, body: text };
163
+ const closing = text.slice(opening.length).match(/^---(?:\r?\n|$)/m);
164
+ if (!closing || closing.index === undefined) return { name: null, body: text };
165
+ const end = opening.length + closing.index;
166
+ const frontmatter = text.slice(opening.length, end).replace(/\r?\n$/, "");
167
+ let name: string | null = null;
168
+ for (const line of frontmatter.split(/\r?\n/)) {
169
+ const match = line.match(/^name:\s*(.+)$/);
170
+ if (match) {
171
+ const parsed = match[1]!.trim();
172
+ if (parsed.length > 0) name = parsed;
173
+ break;
174
+ }
175
+ }
176
+ const bodyStart = end + closing[0].length;
177
+ const body = text.slice(bodyStart);
178
+ return { name, body };
179
+ }
180
+
181
+ async function readMemory(cwd: string, cwdCanonical: string): Promise<string> {
182
+ const path = join(cwd, "AGENTS.md");
183
+ const canonical = confinedCanonicalPath(path, cwdCanonical);
184
+ if (!canonical) return "";
185
+ const text = await readUtf8File(canonical, MEMORY_CAP_BYTES);
186
+ if (text === null) return "";
187
+ return truncateUtf8(text, MEMORY_CAP_BYTES);
188
+ }
189
+
190
+ async function readTaste(cwd: string, cwdCanonical: string): Promise<string | null> {
191
+ const path = join(cwd, ".commandcode", "taste", "taste.md");
192
+ const canonical = confinedCanonicalPath(path, cwdCanonical);
193
+ if (!canonical) return null;
194
+ if (!existsSync(canonical)) return null;
195
+ const text = await readUtf8File(canonical, TASTE_CAP_BYTES);
196
+ if (text === null) return null;
197
+ return truncateUtf8(text, TASTE_CAP_BYTES);
198
+ }
199
+
200
+ interface SkillEntry {
201
+ name: string;
202
+ body: string;
203
+ }
204
+
205
+ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number): Promise<string[]> {
206
+ if (scanBudget <= 0) return [];
207
+ const skillRootCanonical = confinedCanonicalPath(skillRoot, cwdCanonical);
208
+ if (!skillRootCanonical) return [];
209
+ let dir: Awaited<ReturnType<typeof opendir>> | undefined;
210
+ try {
211
+ return await withTimeout(
212
+ (async () => {
213
+ const openedDir = await opendir(skillRootCanonical);
214
+ dir = openedDir;
215
+ const names: string[] = [];
216
+ try {
217
+ for await (const entry of openedDir) {
218
+ if (entry.name.startsWith(".")) continue;
219
+ if (!entry.isDirectory()) continue;
220
+ const skillMd = join(skillRoot, entry.name, "SKILL.md");
221
+ const skillMdCanonical = confinedCanonicalPath(skillMd, cwdCanonical);
222
+ if (!skillMdCanonical) continue;
223
+ if (!existsSync(skillMdCanonical)) continue;
224
+ names.push(entry.name);
225
+ if (names.length >= scanBudget) break;
226
+ }
227
+ } catch {
228
+ try {
229
+ await openedDir.close();
230
+ } catch {
231
+ /* closing a failed iterator is best-effort */
232
+ }
233
+ /* directory iteration is best-effort */
234
+ }
235
+ names.sort();
236
+ return names;
237
+ })(),
238
+ FILE_OP_TIMEOUT_MS,
239
+ );
240
+ } catch {
241
+ if (dir) {
242
+ try {
243
+ await dir.close();
244
+ } catch {
245
+ /* closing a timed-out iterator is best-effort */
246
+ }
247
+ }
248
+ return [];
249
+ }
250
+ }
251
+
252
+ async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string): Promise<SkillEntry | null> {
253
+ const path = join(skillRoot, dirName, "SKILL.md");
254
+ const canonical = confinedCanonicalPath(path, cwdCanonical);
255
+ if (!canonical) return null;
256
+ const text = await readUtf8File(canonical, SKILLS_XML_CAP_BYTES);
257
+ if (text === null) return null;
258
+ const { name, body } = parseSkillFrontmatter(text);
259
+ return { name: name ?? dirName, body };
260
+ }
261
+
262
+ function buildSkillsXml(skills: SkillEntry[]): string | null {
263
+ if (skills.length === 0) return null;
264
+ const lines = ["<skills>"];
265
+ let usedBytes = Buffer.byteLength(lines[0]! + "\n</skills>", "utf8");
266
+
267
+ for (const skill of skills) {
268
+ const open = ` <skill name="${xmlEscape(skill.name)}">`;
269
+ const close = "</skill>";
270
+ let body = skill.body;
271
+ let line = `${open}${xmlEscape(body)}${close}`;
272
+ let lineBytes = Buffer.byteLength(line + "\n", "utf8");
273
+
274
+ if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) {
275
+ const overhead = Buffer.byteLength(open + close + "\n", "utf8");
276
+ const bodyBudget = SKILLS_XML_CAP_BYTES - usedBytes - overhead;
277
+ if (bodyBudget <= 0) break;
278
+ const fittedBody = truncateUtf8BodyForXml(body, bodyBudget);
279
+ if (fittedBody === null) break;
280
+ const wasTruncated = fittedBody !== body;
281
+ body = fittedBody;
282
+ line = `${open}${xmlEscape(body)}${close}`;
283
+ lineBytes = Buffer.byteLength(line + "\n", "utf8");
284
+ if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) break;
285
+ lines.push(line);
286
+ usedBytes += lineBytes;
287
+ if (wasTruncated) break;
288
+ continue;
289
+ }
290
+
291
+ lines.push(line);
292
+ usedBytes += lineBytes;
293
+ }
294
+
295
+ lines.push("</skills>");
296
+ if (lines.length === 2) return null;
297
+ return lines.join("\n");
298
+ }
299
+
300
+ function truncateUtf8BodyForXml(body: string, capBytes: number): string | null {
301
+ const rawBuf = Buffer.from(body, "utf8");
302
+ if (Buffer.byteLength(xmlEscape(body), "utf8") <= capBytes) return body;
303
+ if (Buffer.byteLength(xmlEscape(TRUNCATION_MARKER), "utf8") > capBytes) return null;
304
+
305
+ // XML entities can expand a raw body by several bytes per character. Binary-search the
306
+ // largest UTF-8 prefix whose escaped form, including the marker, fits the actual wire cap.
307
+ let low = 0;
308
+ let high = rawBuf.length;
309
+ let best = TRUNCATION_MARKER;
310
+ while (low <= high) {
311
+ const mid = Math.floor((low + high) / 2);
312
+ let rawEnd = mid;
313
+ while (rawEnd > 0 && (rawBuf[rawEnd]! & 0xc0) === 0x80) rawEnd--;
314
+ const candidate = rawBuf.subarray(0, rawEnd).toString("utf8") + TRUNCATION_MARKER;
315
+ if (Buffer.byteLength(xmlEscape(candidate), "utf8") <= capBytes) {
316
+ best = candidate;
317
+ low = mid + 1;
318
+ } else {
319
+ high = mid - 1;
320
+ }
321
+ }
322
+ return best;
323
+ }
324
+
325
+ async function readSkills(cwd: string, cwdCanonical: string): Promise<string | null> {
326
+ const seen = new Set<string>();
327
+ const collected: SkillEntry[] = [];
328
+
329
+ for (const rootRel of SKILL_ROOTS) {
330
+ const skillRoot = join(cwd, ...rootRel.split("/"));
331
+ const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN);
332
+ for (const dirName of dirs) {
333
+ if (collected.length >= MAX_SKILLS) break;
334
+ const skill = await readSkill(skillRoot, dirName, cwdCanonical);
335
+ if (!skill) continue;
336
+ if (seen.has(skill.name)) continue;
337
+ seen.add(skill.name);
338
+ collected.push(skill);
339
+ }
340
+ if (collected.length >= MAX_SKILLS) break;
341
+ }
342
+
343
+ return buildSkillsXml(collected);
344
+ }
345
+
346
+ async function collectProjectContext(cwd: string): Promise<CommandCodeProjectContext> {
347
+ const cwdCanonical = canonicalPath(cwd);
348
+ if (!cwdCanonical) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT };
349
+
350
+ const [memory, taste, skills] = await Promise.all([
351
+ readMemory(cwd, cwdCanonical),
352
+ readTaste(cwd, cwdCanonical),
353
+ readSkills(cwd, cwdCanonical),
354
+ ]);
355
+
356
+ return { memory, taste, skills };
357
+ }
358
+
359
+ export async function loadCommandCodeProjectContext(cwd: string | undefined): Promise<CommandCodeProjectContext> {
360
+ if (!cwd) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT };
361
+
362
+ const hadCachedEntry = projectContextCache.has(cwd);
363
+ const cached = projectContextCache.get(cwd);
364
+ if (cached && Date.now() - cached.collectedAt < PROJECT_CONTEXT_TTL_MS) {
365
+ return cached.value;
366
+ }
367
+
368
+ const value = await collectProjectContext(cwd);
369
+ const now = Date.now();
370
+ if (hadCachedEntry) {
371
+ pruneExpiredProjectContextCache(now);
372
+ } else {
373
+ pruneProjectContextCache(now);
374
+ }
375
+ projectContextCache.set(cwd, { collectedAt: now, value });
376
+ return value;
377
+ }
@@ -12,6 +12,7 @@ import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from
12
12
  import { identifyRoutedModel } from "./identity";
13
13
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
14
14
  import { parseDataUrl } from "./image";
15
+ import { EMPTY_COMMAND_CODE_PROJECT_CONTEXT, loadCommandCodeProjectContext } from "./command-code-project-context";
15
16
 
16
17
  // Retain the short ids emitted by the first local integration. New requests use the live catalog's
17
18
  // provider-native IDs directly; this map is compatibility-only and is not a model fallback list.
@@ -463,8 +464,11 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
463
464
  ...(choiceInstruction ? [choiceInstruction] : []),
464
465
  ].join("\n\n"), parsed.modelId);
465
466
  const reasoningEffort = supportedCommandCodeEffort(provider, parsed.modelId, parsed.options.reasoning);
467
+ const projectContext = provider.projectContext === "on"
468
+ ? await loadCommandCodeProjectContext(cwd)
469
+ : EMPTY_COMMAND_CODE_PROJECT_CONTEXT;
466
470
  const body = {
467
- config: await commandCodeConfig(cwd), memory: "", taste: null, skills: null,
471
+ config: await commandCodeConfig(cwd), ...projectContext,
468
472
  permissionMode: "standard", mode: "agent",
469
473
  params: {
470
474
  model: canonicalCommandCodeModelId(parsed.modelId),
@@ -147,6 +147,18 @@ function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean {
147
147
  return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow;
148
148
  }
149
149
 
150
+ /**
151
+ * True when a transport error is the bare 0-token resource_exhausted overflow shape
152
+ * (not quota/rate) that should surface for Codex compact or remint on later hits.
153
+ */
154
+ export function isCursorOverflowRemintCandidate(err: unknown, sizeContext?: CursorSizeContext): boolean {
155
+ const message = errorMessage(err);
156
+ if (!message) return false;
157
+ const lower = message.toLowerCase();
158
+ if (!isCursorZeroTokenResourceExhausted(lower)) return false;
159
+ return classifyCursorError(message, sizeContext) === "Cursor context limit exceeded";
160
+ }
161
+
150
162
  export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean {
151
163
  if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false;
152
164
  // Any explicit quota/rate cue wins: this is a real 429.
@@ -55,12 +55,18 @@ import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, i
55
55
  import { mcpArgsFromToolCall } from "./protobuf-events";
56
56
  import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
57
57
  import {
58
+ cursorUnsafeNativeLocalExecEnabled,
58
59
  handleCursorNativeExec,
59
60
  handleCursorNativeKv,
60
61
  releaseCursorBlobRequestScope,
61
62
  type CursorBlobRequestScopeToken,
62
63
  type CursorNativeExecContext,
63
64
  } from "./native-exec";
65
+ import {
66
+ advertisedBareCodexShellBridgeName,
67
+ nativeExecBridgeToMcpExec,
68
+ planNativeExecBridge,
69
+ } from "./native-exec-bridge";
64
70
  import { effectiveCursorNativeExecAllow } from "./exec-policy";
65
71
  import { resolveMcpServers } from "./mcp-config";
66
72
  import { CursorMcpManager } from "./mcp-manager";
@@ -1448,6 +1454,21 @@ class LiveCursorTransport implements CursorTransport {
1448
1454
  return;
1449
1455
  }
1450
1456
  }
1457
+ const advertisedShellBridgeName = advertisedBareCodexShellBridgeName(state.clientToolNames ?? []);
1458
+ const nativePlan = planNativeExecBridge(execMsg, {
1459
+ nativeLocalExecEnabled: cursorUnsafeNativeLocalExecEnabled(this.execContext),
1460
+ advertisedShellBridgeName,
1461
+ });
1462
+ if (nativePlan.bridge) {
1463
+ const plan = planMcpArgsHandling(nativeExecBridgeToMcpExec(execMsg, nativePlan), state);
1464
+ if (plan.handledByResponsesBridge) {
1465
+ this.noteClientToolActivity();
1466
+ for (const event of plan.events) push(event);
1467
+ if (plan.cancelCursorRun) this.cancelCursorRun();
1468
+ else if (plan.finalizeWhenDrained) this.scheduleClientToolFinalize(state, push);
1469
+ return;
1470
+ }
1471
+ }
1451
1472
  // Native exec/MCP is handled inside this transport and can mutate files/process state
1452
1473
  // without emitting a Responses tool event. Mark the turn replay-unsafe before executing so
1453
1474
  // an eventual invalid_argument cannot cause the adapter's fresh-conversation fallback to
@@ -0,0 +1,141 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import {
3
+ ExecServerMessageSchema,
4
+ McpArgsSchema,
5
+ type ExecServerMessage,
6
+ } from "./gen/agent_pb";
7
+ import {
8
+ CODEX_SHELL_BRIDGE_TOOL_NAMES,
9
+ isCodexShellBridgeToolName,
10
+ normalizeCursorWireName,
11
+ OCX_RESPONSES_TOOL_PROVIDER,
12
+ } from "./tool-definitions";
13
+
14
+ export type NativeExecBridgePlan =
15
+ | { bridge: false }
16
+ | {
17
+ bridge: true;
18
+ toolName: string;
19
+ toolCallId: string;
20
+ args: { command: string; workdir?: string };
21
+ };
22
+
23
+ export function posixSingleQuote(value: string): string {
24
+ return `'${value.replace(/'/g, "'\\''")}'`;
25
+ }
26
+
27
+ export function advertisedBareCodexShellBridgeName(clientToolNames: Iterable<string>): string | undefined {
28
+ const advertised = new Set(
29
+ [...clientToolNames]
30
+ .map(normalizeCursorWireName)
31
+ .filter(isCodexShellBridgeToolName),
32
+ );
33
+ for (const bridgeName of CODEX_SHELL_BRIDGE_TOOL_NAMES) {
34
+ if (advertised.has(bridgeName)) return bridgeName;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export function planNativeExecBridge(
40
+ execMsg: ExecServerMessage,
41
+ opts: { nativeLocalExecEnabled: boolean; advertisedShellBridgeName?: string },
42
+ ): NativeExecBridgePlan {
43
+ if (opts.nativeLocalExecEnabled || !opts.advertisedShellBridgeName) return { bridge: false };
44
+
45
+ const execCase = execMsg.message.case;
46
+ if (!execCase) return { bridge: false };
47
+
48
+ let toolCallId = `exec_${execMsg.id}`;
49
+ let command = "";
50
+ let workdir: string | undefined;
51
+
52
+ switch (execCase) {
53
+ case "shellArgs":
54
+ case "shellStreamArgs": {
55
+ const args = execMsg.message.value;
56
+ command = args.command.trim();
57
+ if (args.workingDirectory.trim()) workdir = args.workingDirectory.trim();
58
+ if (args.toolCallId) toolCallId = args.toolCallId;
59
+ break;
60
+ }
61
+ case "backgroundShellSpawnArgs": {
62
+ const args = execMsg.message.value;
63
+ command = args.command.trim();
64
+ if (args.workingDirectory.trim()) workdir = args.workingDirectory.trim();
65
+ if (args.toolCallId) toolCallId = args.toolCallId;
66
+ break;
67
+ }
68
+ case "readArgs": {
69
+ const args = execMsg.message.value;
70
+ const path = args.path.trim();
71
+ if (!path) return { bridge: false };
72
+ command = `cat -- ${posixSingleQuote(path)}`;
73
+ if (args.toolCallId) toolCallId = args.toolCallId;
74
+ break;
75
+ }
76
+ case "lsArgs": {
77
+ const args = execMsg.message.value;
78
+ const path = args.path.trim();
79
+ if (!path) return { bridge: false };
80
+ command = `ls -la -- ${posixSingleQuote(path)}`;
81
+ if (args.toolCallId) toolCallId = args.toolCallId;
82
+ break;
83
+ }
84
+ case "grepArgs": {
85
+ const args = execMsg.message.value;
86
+ const pattern = args.pattern.trim();
87
+ if (!pattern) return { bridge: false };
88
+ const path = args.path?.trim();
89
+ command = path
90
+ ? `rg -n -- ${posixSingleQuote(pattern)} ${posixSingleQuote(path)}`
91
+ : `rg -n -- ${posixSingleQuote(pattern)}`;
92
+ if (args.toolCallId) toolCallId = args.toolCallId;
93
+ break;
94
+ }
95
+ case "fetchArgs": {
96
+ const args = execMsg.message.value;
97
+ const url = args.url.trim();
98
+ if (!url) return { bridge: false };
99
+ command = `curl -fsSL -- ${posixSingleQuote(url)}`;
100
+ if (args.toolCallId) toolCallId = args.toolCallId;
101
+ break;
102
+ }
103
+ default:
104
+ return { bridge: false };
105
+ }
106
+
107
+ if (!command) return { bridge: false };
108
+
109
+ return {
110
+ bridge: true,
111
+ toolName: opts.advertisedShellBridgeName,
112
+ toolCallId,
113
+ args: workdir ? { command, workdir } : { command },
114
+ };
115
+ }
116
+
117
+ export function nativeExecBridgeToMcpExec(
118
+ execMsg: ExecServerMessage,
119
+ plan: Extract<NativeExecBridgePlan, { bridge: true }>,
120
+ ): ExecServerMessage {
121
+ const args: Record<string, Uint8Array> = {
122
+ command: new TextEncoder().encode(JSON.stringify(plan.args.command)),
123
+ };
124
+ if (plan.args.workdir) {
125
+ args.workdir = new TextEncoder().encode(JSON.stringify(plan.args.workdir));
126
+ }
127
+ return create(ExecServerMessageSchema, {
128
+ id: execMsg.id,
129
+ execId: execMsg.execId,
130
+ message: {
131
+ case: "mcpArgs",
132
+ value: create(McpArgsSchema, {
133
+ name: plan.toolName,
134
+ toolName: plan.toolName,
135
+ toolCallId: plan.toolCallId,
136
+ providerIdentifier: OCX_RESPONSES_TOOL_PROVIDER,
137
+ args,
138
+ }),
139
+ },
140
+ });
141
+ }