@yansigit/opencodex 2.31.2 → 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.
- package/gui/dist/assets/{index-BAMgarF9.js → index-Cxt5fZMP.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/command-code-project-context.ts +377 -0
- package/src/adapters/command-code.ts +5 -1
- package/src/adapters/cursor/live-transport.ts +21 -0
- package/src/adapters/cursor/native-exec-bridge.ts +141 -0
- package/src/adapters/google-http.ts +12 -2
- package/src/adapters/google-wire-compiler.ts +83 -2
- package/src/adapters/google.ts +57 -15
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +23 -15
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/oauth/index.ts +3 -0
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/responses/core.ts +17 -7
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +6 -0
- package/src/web-search/gemini-executor.ts +35 -13
- package/src/web-search/index.ts +85 -1
|
@@ -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, "&")
|
|
155
|
+
.replace(/</g, "<")
|
|
156
|
+
.replace(/>/g, ">")
|
|
157
|
+
.replace(/"/g, """);
|
|
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),
|
|
471
|
+
config: await commandCodeConfig(cwd), ...projectContext,
|
|
468
472
|
permissionMode: "standard", mode: "agent",
|
|
469
473
|
params: {
|
|
470
474
|
model: canonicalCommandCodeModelId(parsed.modelId),
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
retryableGoogleStatus,
|
|
6
6
|
safeGoogleHttpErrorMessage,
|
|
7
7
|
} from "./google-errors";
|
|
8
|
-
import { repairGoogleInvalidRequestBody } from "./google-wire-compiler";
|
|
8
|
+
import { isGoogleMixedBuiltinToolError, repairGoogleInvalidRequestBody, stripGoogleBuiltinToolsFromWireBody } from "./google-wire-compiler";
|
|
9
9
|
import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error";
|
|
10
10
|
import { recordAntigravityCooldown } from "../oauth/antigravity-routing";
|
|
11
11
|
import {
|
|
@@ -378,7 +378,17 @@ async function fetchGoogleWithRetryInternal(
|
|
|
378
378
|
} catch (error) {
|
|
379
379
|
if (ctx.abortSignal?.aborted) throw error;
|
|
380
380
|
}
|
|
381
|
-
|
|
381
|
+
// Mixed built-in + function tools must win over schema repair: those 400s often mention
|
|
382
|
+
// function_declarations, which would otherwise empty parameters and leave google_search attached.
|
|
383
|
+
let repairedBody: string | undefined;
|
|
384
|
+
if (isGoogleMixedBuiltinToolError(payloadText)) {
|
|
385
|
+
repairedBody = stripGoogleBuiltinToolsFromWireBody(activeRequest.body);
|
|
386
|
+
if (repairedBody === undefined) {
|
|
387
|
+
repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText);
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText);
|
|
391
|
+
}
|
|
382
392
|
if (repairedBody !== undefined) {
|
|
383
393
|
compatibilityReplayUsed = true;
|
|
384
394
|
activeRequest = { ...activeRequest, body: repairedBody };
|