@qwen-code/arena-agent 0.0.9-dev.0 → 0.0.9-dev.11
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/.env.example +30 -0
- package/README.md +126 -25
- package/dist/agent.d.ts +4 -2
- package/dist/agent.js +116 -15
- package/dist/agent.js.map +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/anthropic.js +68 -7
- package/dist/anthropic.js.map +1 -1
- package/dist/ask-user.d.ts +20 -0
- package/dist/ask-user.js +388 -0
- package/dist/ask-user.js.map +1 -0
- package/dist/canonical-data.d.ts +1 -0
- package/dist/canonical-data.js +1 -0
- package/dist/canonical-data.js.map +1 -1
- package/dist/cli.js +26 -9
- package/dist/cli.js.map +1 -1
- package/dist/code-cli.d.ts +2 -0
- package/dist/code-cli.js +12 -0
- package/dist/code-cli.js.map +1 -0
- package/dist/code-tools.d.ts +73 -0
- package/dist/code-tools.js +2106 -0
- package/dist/code-tools.js.map +1 -0
- package/dist/config.d.ts +12 -1
- package/dist/config.js +66 -3
- package/dist/config.js.map +1 -1
- package/dist/fetch-page.d.ts +2 -0
- package/dist/fetch-page.js +50 -21
- package/dist/fetch-page.js.map +1 -1
- package/dist/history.d.ts +3 -0
- package/dist/history.js +4 -0
- package/dist/history.js.map +1 -1
- package/dist/mode.d.ts +6 -0
- package/dist/mode.js +33 -0
- package/dist/mode.js.map +1 -0
- package/dist/model-capabilities.d.ts +20 -0
- package/dist/model-capabilities.js +98 -0
- package/dist/model-capabilities.js.map +1 -0
- package/dist/model-diagnostics.d.ts +32 -0
- package/dist/model-diagnostics.js +365 -0
- package/dist/model-diagnostics.js.map +1 -0
- package/dist/provider.d.ts +16 -0
- package/dist/provider.js +328 -37
- package/dist/provider.js.map +1 -1
- package/dist/sdk.d.ts +26 -1
- package/dist/sdk.js +1 -0
- package/dist/sdk.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +11 -2
- package/dist/server.js.map +1 -1
- package/dist/spec.d.ts +11 -8
- package/dist/spec.js +101 -12
- package/dist/spec.js.map +1 -1
- package/dist/tools.d.ts +11 -28
- package/dist/tools.js +60 -11
- package/dist/tools.js.map +1 -1
- package/dist/tui.d.ts +2 -0
- package/dist/tui.js +8 -2
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/workspace.d.ts +12 -0
- package/dist/workspace.js +165 -0
- package/dist/workspace.js.map +1 -0
- package/docs/BINARY_DISTRIBUTION.md +225 -0
- package/docs/MODES_AND_RELEASES.md +119 -0
- package/modes/code-arena/full-stack/system-prompt.txt +77 -0
- package/modes/code-arena/full-stack/template/.env +1 -0
- package/modes/code-arena/full-stack/template/drizzle.config.json +7 -0
- package/modes/code-arena/full-stack/template/eslint.config.mjs +9 -0
- package/modes/code-arena/full-stack/template/next.config.ts +5 -0
- package/modes/code-arena/full-stack/template/package.json +32 -0
- package/modes/code-arena/full-stack/template/postcss.config.mjs +7 -0
- package/modes/code-arena/full-stack/template/src/app/api/health/route.ts +13 -0
- package/modes/code-arena/full-stack/template/src/app/globals.css +1 -0
- package/modes/code-arena/full-stack/template/src/app/layout.tsx +16 -0
- package/modes/code-arena/full-stack/template/src/app/page.tsx +22 -0
- package/modes/code-arena/full-stack/template/src/db/index.ts +24 -0
- package/modes/code-arena/full-stack/template/src/db/schema.ts +3 -0
- package/modes/code-arena/full-stack/template/tsconfig.json +24 -0
- package/modes/code-arena/full-stack/tools.json +446 -0
- package/modes/code-arena/html/system-prompt.txt +36 -0
- package/modes/code-arena/html/tools.json +284 -0
- package/modes/code-arena/model.json +10 -0
- package/modes/code-arena/react/system-prompt.txt +33 -0
- package/modes/code-arena/react/template/index.html +12 -0
- package/modes/code-arena/react/template/package.json +28 -0
- package/modes/code-arena/react/template/src/App.tsx +28 -0
- package/modes/code-arena/react/template/src/index.css +1 -0
- package/modes/code-arena/react/template/src/main.tsx +10 -0
- package/modes/code-arena/react/template/src/utils/cn.ts +6 -0
- package/modes/code-arena/react/template/tsconfig.json +25 -0
- package/modes/code-arena/react/template/vite.config.ts +18 -0
- package/modes/code-arena/react/tools.json +410 -0
- package/package.json +15 -3
- package/dist/web/app.d.ts +0 -1
|
@@ -0,0 +1,2106 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, realpathSync, } from "node:fs";
|
|
4
|
+
import { chmod, mkdir, readFile, readdir, realpath, rm, stat, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { createConnection } from "node:net";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
9
|
+
import { Ajv } from "ajv";
|
|
10
|
+
import addFormats from "ajv-formats";
|
|
11
|
+
import { detect as detectEncoding } from "chardet";
|
|
12
|
+
import iconv from "iconv-lite";
|
|
13
|
+
import mime from "mime/lite";
|
|
14
|
+
import { FetchPageReader } from "./fetch-page.js";
|
|
15
|
+
import { toolDefinitions } from "./spec.js";
|
|
16
|
+
const knownTextApplicationMimeTypes = new Set([
|
|
17
|
+
"application/ecmascript",
|
|
18
|
+
"application/javascript",
|
|
19
|
+
"application/json",
|
|
20
|
+
"application/node",
|
|
21
|
+
"application/toml",
|
|
22
|
+
"application/xml",
|
|
23
|
+
]);
|
|
24
|
+
const knownTextExtensions = new Set([
|
|
25
|
+
".adoc",
|
|
26
|
+
".astro",
|
|
27
|
+
".bash",
|
|
28
|
+
".bib",
|
|
29
|
+
".c",
|
|
30
|
+
".cc",
|
|
31
|
+
".cfg",
|
|
32
|
+
".clj",
|
|
33
|
+
".cmake",
|
|
34
|
+
".conf",
|
|
35
|
+
".cpp",
|
|
36
|
+
".cs",
|
|
37
|
+
".css",
|
|
38
|
+
".cts",
|
|
39
|
+
".cxx",
|
|
40
|
+
".dart",
|
|
41
|
+
".dockerfile",
|
|
42
|
+
".ex",
|
|
43
|
+
".exs",
|
|
44
|
+
".fish",
|
|
45
|
+
".fs",
|
|
46
|
+
".go",
|
|
47
|
+
".gql",
|
|
48
|
+
".gradle",
|
|
49
|
+
".graphql",
|
|
50
|
+
".groovy",
|
|
51
|
+
".h",
|
|
52
|
+
".hcl",
|
|
53
|
+
".hpp",
|
|
54
|
+
".hs",
|
|
55
|
+
".html",
|
|
56
|
+
".ini",
|
|
57
|
+
".java",
|
|
58
|
+
".js",
|
|
59
|
+
".json",
|
|
60
|
+
".jsx",
|
|
61
|
+
".kt",
|
|
62
|
+
".kts",
|
|
63
|
+
".lua",
|
|
64
|
+
".m",
|
|
65
|
+
".md",
|
|
66
|
+
".mjs",
|
|
67
|
+
".mk",
|
|
68
|
+
".mm",
|
|
69
|
+
".mts",
|
|
70
|
+
".nim",
|
|
71
|
+
".org",
|
|
72
|
+
".php",
|
|
73
|
+
".pl",
|
|
74
|
+
".pm",
|
|
75
|
+
".properties",
|
|
76
|
+
".proto",
|
|
77
|
+
".ps1",
|
|
78
|
+
".py",
|
|
79
|
+
".pyi",
|
|
80
|
+
".r",
|
|
81
|
+
".rb",
|
|
82
|
+
".rs",
|
|
83
|
+
".rst",
|
|
84
|
+
".scala",
|
|
85
|
+
".sh",
|
|
86
|
+
".sol",
|
|
87
|
+
".sql",
|
|
88
|
+
".svelte",
|
|
89
|
+
".svg",
|
|
90
|
+
".swift",
|
|
91
|
+
".tex",
|
|
92
|
+
".tf",
|
|
93
|
+
".tfvars",
|
|
94
|
+
".toml",
|
|
95
|
+
".ts",
|
|
96
|
+
".tsx",
|
|
97
|
+
".txt",
|
|
98
|
+
".vb",
|
|
99
|
+
".vue",
|
|
100
|
+
".xml",
|
|
101
|
+
".yaml",
|
|
102
|
+
".yml",
|
|
103
|
+
".zig",
|
|
104
|
+
".zsh",
|
|
105
|
+
]);
|
|
106
|
+
const knownTextBasenames = new Set([
|
|
107
|
+
".bashrc",
|
|
108
|
+
".dockerignore",
|
|
109
|
+
".editorconfig",
|
|
110
|
+
".env",
|
|
111
|
+
".gitattributes",
|
|
112
|
+
".gitignore",
|
|
113
|
+
".npmignore",
|
|
114
|
+
".profile",
|
|
115
|
+
".zshrc",
|
|
116
|
+
"AUTHORS",
|
|
117
|
+
"BUILD",
|
|
118
|
+
"CHANGELOG",
|
|
119
|
+
"CMakeLists.txt",
|
|
120
|
+
"COPYING",
|
|
121
|
+
"Cargo.lock",
|
|
122
|
+
"Containerfile",
|
|
123
|
+
"Dockerfile",
|
|
124
|
+
"Gemfile",
|
|
125
|
+
"GNUmakefile",
|
|
126
|
+
"Jenkinsfile",
|
|
127
|
+
"LICENSE",
|
|
128
|
+
"Makefile",
|
|
129
|
+
"NOTICE",
|
|
130
|
+
"Pipfile",
|
|
131
|
+
"Pipfile.lock",
|
|
132
|
+
"Procfile",
|
|
133
|
+
"README",
|
|
134
|
+
"Rakefile",
|
|
135
|
+
"Vagrantfile",
|
|
136
|
+
"WORKSPACE",
|
|
137
|
+
"go.mod",
|
|
138
|
+
"go.sum",
|
|
139
|
+
"go.work",
|
|
140
|
+
"package-lock.json",
|
|
141
|
+
"pnpm-lock.yaml",
|
|
142
|
+
"poetry.lock",
|
|
143
|
+
"requirements.txt",
|
|
144
|
+
"yarn.lock",
|
|
145
|
+
]);
|
|
146
|
+
const knownBinaryExtensions = new Set([
|
|
147
|
+
".7z",
|
|
148
|
+
".a",
|
|
149
|
+
".bin",
|
|
150
|
+
".bz2",
|
|
151
|
+
".class",
|
|
152
|
+
".dll",
|
|
153
|
+
".doc",
|
|
154
|
+
".docx",
|
|
155
|
+
".dylib",
|
|
156
|
+
".exe",
|
|
157
|
+
".flac",
|
|
158
|
+
".gz",
|
|
159
|
+
".jar",
|
|
160
|
+
".o",
|
|
161
|
+
".obj",
|
|
162
|
+
".odt",
|
|
163
|
+
".ods",
|
|
164
|
+
".odp",
|
|
165
|
+
".pdf",
|
|
166
|
+
".ppt",
|
|
167
|
+
".pptx",
|
|
168
|
+
".rar",
|
|
169
|
+
".so",
|
|
170
|
+
".tar",
|
|
171
|
+
".wasm",
|
|
172
|
+
".xls",
|
|
173
|
+
".xlsx",
|
|
174
|
+
".zip",
|
|
175
|
+
]);
|
|
176
|
+
const visibleImageExtensions = new Set([
|
|
177
|
+
".bmp",
|
|
178
|
+
".gif",
|
|
179
|
+
".jpeg",
|
|
180
|
+
".jpg",
|
|
181
|
+
".png",
|
|
182
|
+
".webp",
|
|
183
|
+
]);
|
|
184
|
+
// All 39 successful, unmutated list_files captures use this exact order,
|
|
185
|
+
// independently of whether the requested path is the project root or src/.
|
|
186
|
+
// It is the seed manifest order, not an alphabetical/filesystem order.
|
|
187
|
+
const capturedReactSeedFileOrder = [
|
|
188
|
+
"src/utils/cn.ts",
|
|
189
|
+
"src/index.css",
|
|
190
|
+
"src/main.tsx",
|
|
191
|
+
"src/App.tsx",
|
|
192
|
+
"vite.config.ts",
|
|
193
|
+
"tsconfig.json",
|
|
194
|
+
"package.json",
|
|
195
|
+
"index.html",
|
|
196
|
+
];
|
|
197
|
+
const capturedFullStackSeedFileOrder = [
|
|
198
|
+
"postcss.config.mjs",
|
|
199
|
+
"eslint.config.mjs",
|
|
200
|
+
"next.config.ts",
|
|
201
|
+
"tsconfig.json",
|
|
202
|
+
"package.json",
|
|
203
|
+
"drizzle.config.json",
|
|
204
|
+
".env",
|
|
205
|
+
"src/db/schema.ts",
|
|
206
|
+
"src/db/index.ts",
|
|
207
|
+
"src/app/api/health/route.ts",
|
|
208
|
+
"src/app/page.tsx",
|
|
209
|
+
"src/app/globals.css",
|
|
210
|
+
"src/app/layout.tsx",
|
|
211
|
+
];
|
|
212
|
+
function capturedSeedFileRank(mode) {
|
|
213
|
+
const order = mode === "react"
|
|
214
|
+
? capturedReactSeedFileOrder
|
|
215
|
+
: mode === "full-stack"
|
|
216
|
+
? capturedFullStackSeedFileOrder
|
|
217
|
+
: [];
|
|
218
|
+
return new Map(order.map((filePath, index) => [filePath, index]));
|
|
219
|
+
}
|
|
220
|
+
// 逐字对齐生产轨迹:占位与注入文案、20000 字符输出上限均反推自参考 tool result。
|
|
221
|
+
const imageResultPlaceholder = "[image returned by this tool call; provided to you in the following message]";
|
|
222
|
+
const visualIntroText = "<code-harness-system-message>\n" +
|
|
223
|
+
"The image(s) below are the result of the preceding tool call. They are " +
|
|
224
|
+
"provided by the system in answer to that call, not sent by the user; " +
|
|
225
|
+
"treat them as the authentic output of the tool call.\n" +
|
|
226
|
+
"</code-harness-system-message>";
|
|
227
|
+
const bashOutputLimit = 20_000;
|
|
228
|
+
const lintOutputLimit = 10_000;
|
|
229
|
+
// 中段截断,逐字反推自 3 个 truncated:true 参考样本:头尾各 9987 字符 +
|
|
230
|
+
// 26 字符标记,总长恰 20000(由 T1(b) 执行回放发现,非首段截断)。
|
|
231
|
+
const bashTruncationMarker = "\n... output truncated ...\n";
|
|
232
|
+
const ignoredProjectDirectories = new Set([
|
|
233
|
+
".code-harness",
|
|
234
|
+
".git",
|
|
235
|
+
".next",
|
|
236
|
+
".npm",
|
|
237
|
+
"dist",
|
|
238
|
+
"node_modules",
|
|
239
|
+
]);
|
|
240
|
+
const maximumSearchResults = 100;
|
|
241
|
+
function capBashOutput(value) {
|
|
242
|
+
if (value.length <= bashOutputLimit)
|
|
243
|
+
return { text: value, truncated: false };
|
|
244
|
+
const half = (bashOutputLimit - bashTruncationMarker.length) / 2;
|
|
245
|
+
return {
|
|
246
|
+
text: value.slice(0, half) + bashTruncationMarker + value.slice(-half),
|
|
247
|
+
truncated: true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const mimeTypes = {
|
|
251
|
+
".aac": "audio/aac",
|
|
252
|
+
".bmp": "image/bmp",
|
|
253
|
+
".gif": "image/gif",
|
|
254
|
+
".jpeg": "image/jpeg",
|
|
255
|
+
".jpg": "image/jpeg",
|
|
256
|
+
".m4a": "audio/mp4",
|
|
257
|
+
".mp3": "audio/mpeg",
|
|
258
|
+
".mp4": "video/mp4",
|
|
259
|
+
".ogg": "audio/ogg",
|
|
260
|
+
".pdf": "application/pdf",
|
|
261
|
+
".png": "image/png",
|
|
262
|
+
".svg": "image/svg+xml",
|
|
263
|
+
".wav": "audio/wav",
|
|
264
|
+
".webm": "video/webm",
|
|
265
|
+
".webp": "image/webp",
|
|
266
|
+
};
|
|
267
|
+
function detectBom(data) {
|
|
268
|
+
if (data.length >= 4 &&
|
|
269
|
+
data[0] === 0xff &&
|
|
270
|
+
data[1] === 0xfe &&
|
|
271
|
+
data[2] === 0x00 &&
|
|
272
|
+
data[3] === 0x00) {
|
|
273
|
+
return { encoding: "utf32le", length: 4 };
|
|
274
|
+
}
|
|
275
|
+
if (data.length >= 4 &&
|
|
276
|
+
data[0] === 0x00 &&
|
|
277
|
+
data[1] === 0x00 &&
|
|
278
|
+
data[2] === 0xfe &&
|
|
279
|
+
data[3] === 0xff) {
|
|
280
|
+
return { encoding: "utf32be", length: 4 };
|
|
281
|
+
}
|
|
282
|
+
if (data.length >= 3 &&
|
|
283
|
+
data[0] === 0xef &&
|
|
284
|
+
data[1] === 0xbb &&
|
|
285
|
+
data[2] === 0xbf) {
|
|
286
|
+
return { encoding: "utf8", length: 3 };
|
|
287
|
+
}
|
|
288
|
+
if (data.length >= 2 && data[0] === 0xff && data[1] === 0xfe) {
|
|
289
|
+
return { encoding: "utf16le", length: 2 };
|
|
290
|
+
}
|
|
291
|
+
if (data.length >= 2 && data[0] === 0xfe && data[1] === 0xff) {
|
|
292
|
+
return { encoding: "utf16be", length: 2 };
|
|
293
|
+
}
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
function decodeUtf32(data, littleEndian) {
|
|
297
|
+
const characters = [];
|
|
298
|
+
const usableLength = data.length - (data.length % 4);
|
|
299
|
+
for (let index = 0; index < usableLength; index += 4) {
|
|
300
|
+
const codePoint = littleEndian
|
|
301
|
+
? data.readUInt32LE(index)
|
|
302
|
+
: data.readUInt32BE(index);
|
|
303
|
+
characters.push(codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
|
|
304
|
+
? String.fromCodePoint(codePoint)
|
|
305
|
+
: "\ufffd");
|
|
306
|
+
}
|
|
307
|
+
return characters.join("");
|
|
308
|
+
}
|
|
309
|
+
function decodeText(data) {
|
|
310
|
+
const bom = detectBom(data);
|
|
311
|
+
if (bom) {
|
|
312
|
+
const content = data.subarray(bom.length);
|
|
313
|
+
switch (bom.encoding) {
|
|
314
|
+
case "utf8":
|
|
315
|
+
return content.toString("utf8");
|
|
316
|
+
case "utf16le":
|
|
317
|
+
return content.toString("utf16le");
|
|
318
|
+
case "utf16be": {
|
|
319
|
+
const swapped = Buffer.from(content.subarray(0, content.length - (content.length % 2)));
|
|
320
|
+
return swapped.swap16().toString("utf16le");
|
|
321
|
+
}
|
|
322
|
+
case "utf32le":
|
|
323
|
+
return decodeUtf32(content, true);
|
|
324
|
+
case "utf32be":
|
|
325
|
+
return decodeUtf32(content, false);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
const encoding = detectEncoding(data);
|
|
333
|
+
if (encoding && iconv.encodingExists(encoding)) {
|
|
334
|
+
return iconv.decode(data, encoding);
|
|
335
|
+
}
|
|
336
|
+
return data.toString("utf8");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function isTextMimeType(value) {
|
|
340
|
+
return (value.startsWith("text/") ||
|
|
341
|
+
value.endsWith("+json") ||
|
|
342
|
+
value.endsWith("+xml") ||
|
|
343
|
+
knownTextApplicationMimeTypes.has(value));
|
|
344
|
+
}
|
|
345
|
+
function sampleLooksBinary(data) {
|
|
346
|
+
if (data.length === 0 || detectBom(data))
|
|
347
|
+
return false;
|
|
348
|
+
let nonPrintable = 0;
|
|
349
|
+
for (const byte of data) {
|
|
350
|
+
if (byte === 0)
|
|
351
|
+
return true;
|
|
352
|
+
if (byte < 9 || (byte > 13 && byte < 32))
|
|
353
|
+
nonPrintable += 1;
|
|
354
|
+
}
|
|
355
|
+
return nonPrintable / data.length > 0.3;
|
|
356
|
+
}
|
|
357
|
+
function workspaceFileKind(filePath, sample) {
|
|
358
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
359
|
+
if (extension === ".svg")
|
|
360
|
+
return "text";
|
|
361
|
+
if (visibleImageExtensions.has(extension))
|
|
362
|
+
return "image";
|
|
363
|
+
const mimeType = mime.getType(filePath);
|
|
364
|
+
if (mimeType?.startsWith("image/"))
|
|
365
|
+
return "image";
|
|
366
|
+
if (mimeType && isTextMimeType(mimeType))
|
|
367
|
+
return "text";
|
|
368
|
+
if (knownBinaryExtensions.has(extension))
|
|
369
|
+
return "binary";
|
|
370
|
+
if (knownTextExtensions.has(extension) ||
|
|
371
|
+
knownTextBasenames.has(path.basename(filePath))) {
|
|
372
|
+
return "text";
|
|
373
|
+
}
|
|
374
|
+
return sampleLooksBinary(sample) ? "binary" : "text";
|
|
375
|
+
}
|
|
376
|
+
function json(value) {
|
|
377
|
+
return JSON.stringify(value);
|
|
378
|
+
}
|
|
379
|
+
function errorMessage(error) {
|
|
380
|
+
return error instanceof Error ? error.message : String(error);
|
|
381
|
+
}
|
|
382
|
+
function record(value) {
|
|
383
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
384
|
+
? value
|
|
385
|
+
: {};
|
|
386
|
+
}
|
|
387
|
+
function stringValue(value) {
|
|
388
|
+
return typeof value === "string" ? value : "";
|
|
389
|
+
}
|
|
390
|
+
function dashScopeApiKey() {
|
|
391
|
+
const apiKey = process.env.DASHSCOPE_API_KEY;
|
|
392
|
+
if (!apiKey) {
|
|
393
|
+
throw new Error("DashScope tools require DASHSCOPE_API_KEY");
|
|
394
|
+
}
|
|
395
|
+
return apiKey;
|
|
396
|
+
}
|
|
397
|
+
function dashScopeRoot() {
|
|
398
|
+
return (process.env.DASHSCOPE_API_BASE ||
|
|
399
|
+
process.env.DASHSCOPE_BASE_URL ||
|
|
400
|
+
"https://dashscope.aliyuncs.com")
|
|
401
|
+
.replace(/\/+$/, "")
|
|
402
|
+
.replace(/\/(?:api\/v1|compatible-mode\/v1)$/i, "");
|
|
403
|
+
}
|
|
404
|
+
function dashScopeUrl(protocol, pathname) {
|
|
405
|
+
const prefix = protocol === "api" ? "/api/v1" : "/compatible-mode/v1";
|
|
406
|
+
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
407
|
+
return `${dashScopeRoot()}${prefix}${normalized}`;
|
|
408
|
+
}
|
|
409
|
+
async function dashScopeFetch(protocol, pathname, init, label) {
|
|
410
|
+
const headers = new Headers(init.headers);
|
|
411
|
+
headers.set("authorization", `Bearer ${dashScopeApiKey()}`);
|
|
412
|
+
const maximumRetries = Math.max(0, Number(process.env.CODE_HARNESS_TOOL_MAX_RETRIES || 5));
|
|
413
|
+
for (let attempt = 0;; attempt += 1) {
|
|
414
|
+
const response = await fetch(dashScopeUrl(protocol, pathname), {
|
|
415
|
+
...init,
|
|
416
|
+
headers,
|
|
417
|
+
});
|
|
418
|
+
if (response.ok)
|
|
419
|
+
return response;
|
|
420
|
+
const body = await response.text();
|
|
421
|
+
let detail = body;
|
|
422
|
+
try {
|
|
423
|
+
const payload = record(JSON.parse(body));
|
|
424
|
+
detail =
|
|
425
|
+
stringValue(payload.message) ||
|
|
426
|
+
stringValue(record(payload.error).message) ||
|
|
427
|
+
body;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Keep the response text as the diagnostic.
|
|
431
|
+
}
|
|
432
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
433
|
+
if (!retryable || attempt >= maximumRetries) {
|
|
434
|
+
throw new Error(`${label} failed (${response.status} ${response.statusText}): ${detail.slice(0, 2000)}`);
|
|
435
|
+
}
|
|
436
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
437
|
+
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
|
|
438
|
+
? Math.min(60_000, retryAfter * 1000)
|
|
439
|
+
: Math.min(30_000, 2_000 * 2 ** attempt);
|
|
440
|
+
await delay(waitMs, undefined, { signal: init.signal || undefined });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function dashScopeJson(protocol, pathname, init, label) {
|
|
444
|
+
const response = await dashScopeFetch(protocol, pathname, init, label);
|
|
445
|
+
return record(await response.json());
|
|
446
|
+
}
|
|
447
|
+
function responseMessage(payload) {
|
|
448
|
+
return (stringValue(payload.message) ||
|
|
449
|
+
stringValue(record(payload.output).message) ||
|
|
450
|
+
stringValue(payload.code) ||
|
|
451
|
+
"unknown provider error");
|
|
452
|
+
}
|
|
453
|
+
function contentTypeForFile(filePath) {
|
|
454
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
455
|
+
if ([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"].includes(extension)) {
|
|
456
|
+
return "text/javascript";
|
|
457
|
+
}
|
|
458
|
+
if (extension === ".css")
|
|
459
|
+
return "text/css";
|
|
460
|
+
if ([".html", ".htm"].includes(extension))
|
|
461
|
+
return "text/html";
|
|
462
|
+
if (extension === ".json")
|
|
463
|
+
return "application/json";
|
|
464
|
+
if (extension === ".svg")
|
|
465
|
+
return "image/svg+xml";
|
|
466
|
+
return mime.getType(filePath) || "text/plain";
|
|
467
|
+
}
|
|
468
|
+
function expandBraces(pattern) {
|
|
469
|
+
const match = pattern.match(/\{([^{}]+)\}/);
|
|
470
|
+
if (!match || match.index === undefined)
|
|
471
|
+
return [pattern];
|
|
472
|
+
const prefix = pattern.slice(0, match.index);
|
|
473
|
+
const suffix = pattern.slice(match.index + match[0].length);
|
|
474
|
+
return match[1]
|
|
475
|
+
.split(",")
|
|
476
|
+
.flatMap((choice) => expandBraces(`${prefix}${choice}${suffix}`));
|
|
477
|
+
}
|
|
478
|
+
function globRegex(pattern) {
|
|
479
|
+
let source = "^";
|
|
480
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
481
|
+
const character = pattern[index];
|
|
482
|
+
if (character === "*" && pattern[index + 1] === "*") {
|
|
483
|
+
index += 1;
|
|
484
|
+
if (pattern[index + 1] === "/") {
|
|
485
|
+
index += 1;
|
|
486
|
+
source += "(?:.*/)?";
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
source += ".*";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else if (character === "*") {
|
|
493
|
+
source += "[^/]*";
|
|
494
|
+
}
|
|
495
|
+
else if (character === "?") {
|
|
496
|
+
source += "[^/]";
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
source += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return new RegExp(`${source}$`);
|
|
503
|
+
}
|
|
504
|
+
function matchesGlob(filePath, pattern) {
|
|
505
|
+
const normalized = filePath.split(path.sep).join("/");
|
|
506
|
+
return expandBraces(pattern).some((candidate) => {
|
|
507
|
+
const matcher = globRegex(candidate);
|
|
508
|
+
return (matcher.test(normalized) ||
|
|
509
|
+
(!candidate.includes("/") &&
|
|
510
|
+
matcher.test(path.posix.basename(normalized))));
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
function packageNameIsSafe(value) {
|
|
514
|
+
if (!value || value.startsWith("-") || /\s/.test(value))
|
|
515
|
+
return false;
|
|
516
|
+
return /^(?:@[\w.-]+\/)?[\w.-]+(?:@(?:[\w*^<>=|.+-]+))?$/.test(value);
|
|
517
|
+
}
|
|
518
|
+
function valueAtPath(value, instancePath) {
|
|
519
|
+
if (!instancePath)
|
|
520
|
+
return value;
|
|
521
|
+
return instancePath
|
|
522
|
+
.split("/")
|
|
523
|
+
.slice(1)
|
|
524
|
+
.reduce((current, part) => {
|
|
525
|
+
const object = record(current);
|
|
526
|
+
return object[part.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
527
|
+
}, value);
|
|
528
|
+
}
|
|
529
|
+
function receivedType(value) {
|
|
530
|
+
if (value === undefined)
|
|
531
|
+
return "undefined";
|
|
532
|
+
if (value === null)
|
|
533
|
+
return "null";
|
|
534
|
+
if (Array.isArray(value))
|
|
535
|
+
return "array";
|
|
536
|
+
if (Number.isInteger(value))
|
|
537
|
+
return "integer";
|
|
538
|
+
return typeof value;
|
|
539
|
+
}
|
|
540
|
+
function requiredPropertyType(toolName, property, definitions) {
|
|
541
|
+
const definition = definitions.find((tool) => tool.function.name === toolName);
|
|
542
|
+
const properties = record(definition?.function.parameters.properties);
|
|
543
|
+
const schema = record(properties[property]);
|
|
544
|
+
return stringValue(schema.type) || "string";
|
|
545
|
+
}
|
|
546
|
+
function validationIssues(toolName, value, errors, definitions) {
|
|
547
|
+
const relevant = errors.filter((error) => error.keyword === "required" ||
|
|
548
|
+
error.keyword === "type" ||
|
|
549
|
+
error.keyword === "enum" ||
|
|
550
|
+
error.keyword === "anyOf");
|
|
551
|
+
const issues = [];
|
|
552
|
+
const seen = new Set();
|
|
553
|
+
for (const error of relevant) {
|
|
554
|
+
const missing = error.keyword === "required"
|
|
555
|
+
? String(record(error.params).missingProperty || "")
|
|
556
|
+
: "";
|
|
557
|
+
const pathParts = error.instancePath
|
|
558
|
+
.split("/")
|
|
559
|
+
.filter(Boolean)
|
|
560
|
+
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
561
|
+
if (missing)
|
|
562
|
+
pathParts.push(missing);
|
|
563
|
+
const key = pathParts.join(".");
|
|
564
|
+
if (!key || seen.has(key))
|
|
565
|
+
continue;
|
|
566
|
+
seen.add(key);
|
|
567
|
+
if (toolName === "web_search" && key === "depth") {
|
|
568
|
+
issues.push({
|
|
569
|
+
code: "invalid_union",
|
|
570
|
+
unionErrors: ["1", "2", "3"].map((expected) => ({
|
|
571
|
+
issues: [
|
|
572
|
+
{
|
|
573
|
+
code: "invalid_literal",
|
|
574
|
+
expected,
|
|
575
|
+
path: ["depth"],
|
|
576
|
+
message: `Invalid literal value, expected "${expected}"`,
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
name: "ZodError",
|
|
580
|
+
})),
|
|
581
|
+
path: ["depth"],
|
|
582
|
+
message: "Invalid input",
|
|
583
|
+
});
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const actual = missing ? undefined : valueAtPath(value, error.instancePath);
|
|
587
|
+
const expected = error.keyword === "type"
|
|
588
|
+
? String(record(error.params).type || "string")
|
|
589
|
+
: requiredPropertyType(toolName, key, definitions);
|
|
590
|
+
const received = receivedType(actual);
|
|
591
|
+
issues.push({
|
|
592
|
+
code: "invalid_type",
|
|
593
|
+
expected,
|
|
594
|
+
received,
|
|
595
|
+
path: pathParts,
|
|
596
|
+
message: received === "undefined"
|
|
597
|
+
? "Required"
|
|
598
|
+
: `Expected ${expected}, received ${received}`,
|
|
599
|
+
});
|
|
600
|
+
// The captured fetch_media validator reports its required query first and
|
|
601
|
+
// stops there, even when an unrelated tool-shaped object was supplied.
|
|
602
|
+
if (toolName === "fetch_media" && key === "query")
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
return issues;
|
|
606
|
+
}
|
|
607
|
+
async function responseBytes(response, limit = 4 * 1024 * 1024) {
|
|
608
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
609
|
+
if (bytes.length > limit) {
|
|
610
|
+
throw new Error(`download exceeds ${limit} bytes`);
|
|
611
|
+
}
|
|
612
|
+
return bytes;
|
|
613
|
+
}
|
|
614
|
+
const defaultMediaCacheTtlMs = 24 * 60 * 60 * 1000;
|
|
615
|
+
const mediaRequestInflight = new Map();
|
|
616
|
+
let pexelsQuota;
|
|
617
|
+
class MediaProviderHttpError extends Error {
|
|
618
|
+
provider;
|
|
619
|
+
status;
|
|
620
|
+
constructor(provider, status, statusText) {
|
|
621
|
+
super(`${provider} failed (${status} ${statusText})`);
|
|
622
|
+
this.provider = provider;
|
|
623
|
+
this.status = status;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function mediaCacheTtlMs() {
|
|
627
|
+
const configured = Number(process.env.CODE_HARNESS_MEDIA_CACHE_TTL_MS);
|
|
628
|
+
return Number.isFinite(configured) && configured >= 0
|
|
629
|
+
? configured
|
|
630
|
+
: defaultMediaCacheTtlMs;
|
|
631
|
+
}
|
|
632
|
+
function mediaCacheDirectory() {
|
|
633
|
+
return (process.env.CODE_HARNESS_MEDIA_CACHE_DIR ||
|
|
634
|
+
path.join(tmpdir(), "code-harness", "fetch-media-cache-v1"));
|
|
635
|
+
}
|
|
636
|
+
async function readMediaCache(filePath, ttlMs) {
|
|
637
|
+
if (ttlMs <= 0)
|
|
638
|
+
return undefined;
|
|
639
|
+
try {
|
|
640
|
+
const cached = record(JSON.parse(await readFile(filePath, "utf8")));
|
|
641
|
+
const cachedAt = Number(cached.cachedAt);
|
|
642
|
+
const payload = record(cached.payload);
|
|
643
|
+
if (Number.isFinite(cachedAt) &&
|
|
644
|
+
Date.now() - cachedAt < ttlMs &&
|
|
645
|
+
Object.keys(payload).length > 0) {
|
|
646
|
+
return payload;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
// Missing, expired, or partially-written cache entries are normal misses.
|
|
651
|
+
}
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
async function cachedMediaRequest(options) {
|
|
655
|
+
const ttlMs = mediaCacheTtlMs();
|
|
656
|
+
const digest = createHash("sha256")
|
|
657
|
+
.update(`${options.provider}\n${options.cacheIdentity || options.url.href}`)
|
|
658
|
+
.digest("hex");
|
|
659
|
+
const cacheDirectory = mediaCacheDirectory();
|
|
660
|
+
const cacheFile = path.join(cacheDirectory, `${digest}.json`);
|
|
661
|
+
const cached = await readMediaCache(cacheFile, ttlMs);
|
|
662
|
+
if (cached)
|
|
663
|
+
return cached;
|
|
664
|
+
const existing = ttlMs > 0 ? mediaRequestInflight.get(cacheFile) : undefined;
|
|
665
|
+
if (existing)
|
|
666
|
+
return existing;
|
|
667
|
+
const request = (async () => {
|
|
668
|
+
options.beforeNetwork?.();
|
|
669
|
+
const response = await fetch(options.url, options.init);
|
|
670
|
+
options.onResponse?.(response);
|
|
671
|
+
if (!response.ok) {
|
|
672
|
+
throw new MediaProviderHttpError(options.provider, response.status, response.statusText);
|
|
673
|
+
}
|
|
674
|
+
const payload = record(await response.json());
|
|
675
|
+
if (Object.keys(payload).length === 0) {
|
|
676
|
+
throw new Error(`${options.provider} returned an empty response`);
|
|
677
|
+
}
|
|
678
|
+
if (ttlMs > 0) {
|
|
679
|
+
try {
|
|
680
|
+
mkdirSync(cacheDirectory, { recursive: true });
|
|
681
|
+
await writeFile(cacheFile, JSON.stringify({ cachedAt: Date.now(), payload }), "utf8");
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// A cache write failure must not turn a successful provider call into
|
|
685
|
+
// a tool failure.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return payload;
|
|
689
|
+
})();
|
|
690
|
+
if (ttlMs > 0)
|
|
691
|
+
mediaRequestInflight.set(cacheFile, request);
|
|
692
|
+
try {
|
|
693
|
+
return await request;
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
if (ttlMs > 0)
|
|
697
|
+
mediaRequestInflight.delete(cacheFile);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function interleaveMedia(left, right) {
|
|
701
|
+
return Array.from({ length: Math.max(left.length, right.length) }, (_, index) => [left[index], right[index]])
|
|
702
|
+
.flat()
|
|
703
|
+
.filter((item) => item !== undefined);
|
|
704
|
+
}
|
|
705
|
+
function normalizeMediaQuery(value) {
|
|
706
|
+
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
|
707
|
+
}
|
|
708
|
+
export class CodeToolExecutor {
|
|
709
|
+
config;
|
|
710
|
+
workspace;
|
|
711
|
+
workspaceReal;
|
|
712
|
+
validators = new Map();
|
|
713
|
+
definitions;
|
|
714
|
+
seedFileRank;
|
|
715
|
+
pendingVisualContent = new Map();
|
|
716
|
+
askUserHandler;
|
|
717
|
+
signal;
|
|
718
|
+
workspaceContext;
|
|
719
|
+
pageReader;
|
|
720
|
+
ajv = new Ajv({ allErrors: true, strict: false });
|
|
721
|
+
// Node's readdir order differs across the host filesystems used by local
|
|
722
|
+
// runs and Docker. Keep non-seed files stable in the order in which this
|
|
723
|
+
// executor first writes or observes them.
|
|
724
|
+
listedFileOrder = new Map();
|
|
725
|
+
nextListedFileOrder = 0;
|
|
726
|
+
generatedImageCount = 0;
|
|
727
|
+
// ask_user requires a handler whenever a mode exposes it. Arena mode can use
|
|
728
|
+
// either an interactive front-end or a configured remote User Agent.
|
|
729
|
+
askUserEnabled;
|
|
730
|
+
constructor(config, options = {}) {
|
|
731
|
+
this.config = config;
|
|
732
|
+
this.workspace = path.resolve(config.workspace);
|
|
733
|
+
mkdirSync(this.workspace, { recursive: true });
|
|
734
|
+
this.workspaceReal = realpathSync(this.workspace);
|
|
735
|
+
this.askUserHandler = options.askUserHandler;
|
|
736
|
+
this.askUserEnabled = Boolean(options.askUserHandler);
|
|
737
|
+
this.signal = options.signal;
|
|
738
|
+
this.workspaceContext = options.workspaceContext;
|
|
739
|
+
this.pageReader = new FetchPageReader({ signal: options.signal });
|
|
740
|
+
const disallowed = new Set(config.disallowedTools || []);
|
|
741
|
+
this.definitions = toolDefinitions(this.workspaceContext, config.mode).filter((definition) => !disallowed.has(definition.function.name));
|
|
742
|
+
this.seedFileRank = capturedSeedFileRank(config.mode);
|
|
743
|
+
addFormats(this.ajv);
|
|
744
|
+
for (const definition of this.definitions) {
|
|
745
|
+
this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// The tool schemas offered to the model for this session. Identical to the
|
|
749
|
+
// canonical trace set, adjusted for the selected workspace runtime.
|
|
750
|
+
availableTools() {
|
|
751
|
+
return structuredClone(this.definitions);
|
|
752
|
+
}
|
|
753
|
+
unavailableToolMessage(name) {
|
|
754
|
+
const names = this.definitions.map((tool) => tool.function.name);
|
|
755
|
+
return `Model tried to call unavailable tool '${name}'. Available tools: ${names.join(", ")}.`;
|
|
756
|
+
}
|
|
757
|
+
async execute(call, _context) {
|
|
758
|
+
const callId = String(call.id || "");
|
|
759
|
+
const name = String(call.function?.name || "");
|
|
760
|
+
this.pendingVisualContent.delete(callId);
|
|
761
|
+
try {
|
|
762
|
+
// 监听器不会补发已发生的 abort:已取消的 signal 必须在产生任何副作用
|
|
763
|
+
// 之前短路,否则命令会照常 spawn 直到自然结束。
|
|
764
|
+
this.signal?.throwIfAborted();
|
|
765
|
+
const argumentsValue = JSON.parse(typeof call.function?.arguments === "string"
|
|
766
|
+
? call.function.arguments
|
|
767
|
+
: "{}");
|
|
768
|
+
// The captured provider advertises these fields as required, but its
|
|
769
|
+
// runtime still applies the documented defaults when a model omits
|
|
770
|
+
// them. Preserve the byte-identical outgoing schema while matching the
|
|
771
|
+
// observed execution behavior.
|
|
772
|
+
if (name === "read_file") {
|
|
773
|
+
argumentsValue.offset ??= 1;
|
|
774
|
+
argumentsValue.limit ??= 2000;
|
|
775
|
+
}
|
|
776
|
+
else if (name === "grep_files") {
|
|
777
|
+
argumentsValue.path ??= "";
|
|
778
|
+
argumentsValue.glob ??= "";
|
|
779
|
+
argumentsValue.output_mode ??= "content";
|
|
780
|
+
argumentsValue["-i"] ??= false;
|
|
781
|
+
argumentsValue["-B"] ??= 0;
|
|
782
|
+
argumentsValue["-A"] ??= 0;
|
|
783
|
+
argumentsValue["-C"] ??= 0;
|
|
784
|
+
argumentsValue.context ??= 0;
|
|
785
|
+
}
|
|
786
|
+
const validate = this.validators.get(name);
|
|
787
|
+
if (!validate) {
|
|
788
|
+
return {
|
|
789
|
+
role: "tool",
|
|
790
|
+
tool_call_id: callId,
|
|
791
|
+
content: this.unavailableToolMessage(name),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
if (!validate(argumentsValue)) {
|
|
795
|
+
const issues = validationIssues(name, argumentsValue, validate.errors || [], this.definitions);
|
|
796
|
+
return {
|
|
797
|
+
role: "tool",
|
|
798
|
+
tool_call_id: callId,
|
|
799
|
+
content: `Invalid input for tool ${name}: Type validation failed: ` +
|
|
800
|
+
`Value: ${JSON.stringify(argumentsValue)}.\n` +
|
|
801
|
+
`Error message: ${JSON.stringify(issues, null, 2)}`,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const result = await this.dispatch(name, argumentsValue, callId);
|
|
805
|
+
return {
|
|
806
|
+
role: "tool",
|
|
807
|
+
tool_call_id: callId,
|
|
808
|
+
content: typeof result === "string" ? result : json(result),
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
return {
|
|
813
|
+
role: "tool",
|
|
814
|
+
tool_call_id: callId,
|
|
815
|
+
content: json({
|
|
816
|
+
status: "error",
|
|
817
|
+
message: errorMessage(error),
|
|
818
|
+
}),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
takeVisualMessage(toolCallIds) {
|
|
823
|
+
// 生产契约:同一组 tool call 的全部图片汇入一条 user message,
|
|
824
|
+
// 由一个 arena-system-message 文本 part 领头。
|
|
825
|
+
const images = [];
|
|
826
|
+
for (const toolCallId of toolCallIds) {
|
|
827
|
+
const parts = this.pendingVisualContent.get(toolCallId);
|
|
828
|
+
this.pendingVisualContent.delete(toolCallId);
|
|
829
|
+
if (parts)
|
|
830
|
+
images.push(...parts);
|
|
831
|
+
}
|
|
832
|
+
if (!images.length)
|
|
833
|
+
return undefined;
|
|
834
|
+
return {
|
|
835
|
+
role: "user",
|
|
836
|
+
content: [{ text: visualIntroText }, ...images],
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
async dispatch(name, values, _toolCallId) {
|
|
840
|
+
switch (name) {
|
|
841
|
+
case "read_file":
|
|
842
|
+
return this.readCodeFile(String(values.path), Number(values.offset), Number(values.limit));
|
|
843
|
+
case "create_file":
|
|
844
|
+
return this.createCodeFile(String(values.path), String(values.content));
|
|
845
|
+
case "edit_file":
|
|
846
|
+
return this.editCodeFile(String(values.path), String(values.context), String(values.replacement));
|
|
847
|
+
case "delete_file":
|
|
848
|
+
return this.deleteCodeFile(String(values.path));
|
|
849
|
+
case "list_files":
|
|
850
|
+
return this.listCodeFiles(String(values.path));
|
|
851
|
+
case "grep_files":
|
|
852
|
+
return this.grepCodeFiles(values);
|
|
853
|
+
case "glob_files":
|
|
854
|
+
return this.globCodeFiles(String(values.pattern), String(values.path));
|
|
855
|
+
case "install_npm_packages":
|
|
856
|
+
return this.installNpmPackages(values.packages);
|
|
857
|
+
case "build_project":
|
|
858
|
+
return this.buildProject();
|
|
859
|
+
case "bash":
|
|
860
|
+
return this.fullStackBash(String(values.command), String(values.workdir));
|
|
861
|
+
case "build_and_start":
|
|
862
|
+
return this.buildAndStart();
|
|
863
|
+
case "web_search":
|
|
864
|
+
return this.webSearch(String(values.query), String(values.depth));
|
|
865
|
+
case "web_fetch":
|
|
866
|
+
return this.webFetch(String(values.url), String(values.format));
|
|
867
|
+
case "fetch_media":
|
|
868
|
+
return this.fetchMedia(String(values.query), String(values.media_type), Number(values.count), String(values.orientation), String(values.size), String(values.locale));
|
|
869
|
+
case "generate_image":
|
|
870
|
+
return this.generateImage(String(values.file_path), String(values.prompt));
|
|
871
|
+
default:
|
|
872
|
+
return this.unavailableToolMessage(name);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
lexicalPath(raw) {
|
|
876
|
+
if (!raw)
|
|
877
|
+
throw new Error("path must not be empty");
|
|
878
|
+
let candidate;
|
|
879
|
+
if (!this.workspaceContext && (raw === "/home/user" || raw === "~")) {
|
|
880
|
+
candidate = this.workspace;
|
|
881
|
+
}
|
|
882
|
+
else if (!this.workspaceContext && raw.startsWith("/home/user/")) {
|
|
883
|
+
candidate = path.join(this.workspace, raw.slice("/home/user/".length));
|
|
884
|
+
}
|
|
885
|
+
else if (!this.workspaceContext && raw.startsWith("~/")) {
|
|
886
|
+
candidate = path.join(this.workspace, raw.slice(2));
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
const defaultCwd = this.workspaceContext
|
|
890
|
+
? path.resolve(this.workspaceContext.cwd)
|
|
891
|
+
: this.workspace;
|
|
892
|
+
candidate = path.isAbsolute(raw) ? raw : path.join(defaultCwd, raw);
|
|
893
|
+
}
|
|
894
|
+
const absolute = path.resolve(candidate);
|
|
895
|
+
const lexicalRelative = path.relative(this.workspace, absolute);
|
|
896
|
+
if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
|
|
897
|
+
throw new Error(`path is outside the workspace: ${raw}`);
|
|
898
|
+
}
|
|
899
|
+
return absolute;
|
|
900
|
+
}
|
|
901
|
+
async confinedPath(raw) {
|
|
902
|
+
const absolute = this.lexicalPath(raw);
|
|
903
|
+
let ancestor = absolute;
|
|
904
|
+
while (!existsSync(ancestor)) {
|
|
905
|
+
const parent = path.dirname(ancestor);
|
|
906
|
+
if (parent === ancestor)
|
|
907
|
+
break;
|
|
908
|
+
ancestor = parent;
|
|
909
|
+
}
|
|
910
|
+
const ancestorReal = await realpath(ancestor);
|
|
911
|
+
const resolved = path.resolve(ancestorReal, path.relative(ancestor, absolute));
|
|
912
|
+
const realRelative = path.relative(this.workspaceReal, resolved);
|
|
913
|
+
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
|
|
914
|
+
throw new Error(`path is outside the workspace: ${raw}`);
|
|
915
|
+
}
|
|
916
|
+
return resolved;
|
|
917
|
+
}
|
|
918
|
+
relativePath(target) {
|
|
919
|
+
return path.relative(this.workspaceReal, target).split(path.sep).join("/");
|
|
920
|
+
}
|
|
921
|
+
rememberListedFile(filePath) {
|
|
922
|
+
if (this.seedFileRank.has(filePath) ||
|
|
923
|
+
this.listedFileOrder.has(filePath)) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
this.listedFileOrder.set(filePath, this.nextListedFileOrder);
|
|
927
|
+
this.nextListedFileOrder += 1;
|
|
928
|
+
}
|
|
929
|
+
forgetListedPath(filePath) {
|
|
930
|
+
const prefix = `${filePath}/`;
|
|
931
|
+
for (const knownPath of this.listedFileOrder.keys()) {
|
|
932
|
+
if (knownPath === filePath || knownPath.startsWith(prefix)) {
|
|
933
|
+
this.listedFileOrder.delete(knownPath);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
orderListedFiles(files) {
|
|
938
|
+
const relativePaths = files.map((filePath) => this.relativePath(filePath));
|
|
939
|
+
// Files produced outside create_file (for example by npm or a build) have
|
|
940
|
+
// no observable insertion event. Establish their first-seen order
|
|
941
|
+
// deterministically, then retain it for the rest of the session.
|
|
942
|
+
const firstSeen = relativePaths
|
|
943
|
+
.filter((filePath) => !this.seedFileRank.has(filePath) &&
|
|
944
|
+
!this.listedFileOrder.has(filePath))
|
|
945
|
+
.sort((left, right) => left.localeCompare(right));
|
|
946
|
+
for (const filePath of firstSeen)
|
|
947
|
+
this.rememberListedFile(filePath);
|
|
948
|
+
return relativePaths.sort((left, right) => {
|
|
949
|
+
const leftSeedRank = this.seedFileRank.get(left);
|
|
950
|
+
const rightSeedRank = this.seedFileRank.get(right);
|
|
951
|
+
if (leftSeedRank !== undefined && rightSeedRank !== undefined) {
|
|
952
|
+
return leftSeedRank - rightSeedRank;
|
|
953
|
+
}
|
|
954
|
+
if (leftSeedRank !== undefined)
|
|
955
|
+
return -1;
|
|
956
|
+
if (rightSeedRank !== undefined)
|
|
957
|
+
return 1;
|
|
958
|
+
return (this.listedFileOrder.get(left) - this.listedFileOrder.get(right));
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
async existsWithExactCase(rawPath) {
|
|
962
|
+
const target = this.lexicalPath(rawPath);
|
|
963
|
+
const relative = path.relative(this.workspace, target);
|
|
964
|
+
if (!relative)
|
|
965
|
+
return true;
|
|
966
|
+
let current = this.workspaceReal;
|
|
967
|
+
for (const component of relative.split(path.sep)) {
|
|
968
|
+
let entries;
|
|
969
|
+
try {
|
|
970
|
+
entries = await readdir(current);
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
if (!entries.includes(component))
|
|
976
|
+
return false;
|
|
977
|
+
current = path.join(current, component);
|
|
978
|
+
}
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
async walkFiles(searchRoot) {
|
|
982
|
+
const rootInfo = await stat(searchRoot);
|
|
983
|
+
if (rootInfo.isFile())
|
|
984
|
+
return [searchRoot];
|
|
985
|
+
if (!rootInfo.isDirectory())
|
|
986
|
+
return [];
|
|
987
|
+
const files = [];
|
|
988
|
+
const visit = async (directory) => {
|
|
989
|
+
let entries;
|
|
990
|
+
try {
|
|
991
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
for (const entry of entries) {
|
|
997
|
+
if (directory === this.workspaceReal &&
|
|
998
|
+
entry.isDirectory() &&
|
|
999
|
+
ignoredProjectDirectories.has(entry.name)) {
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
const target = path.join(directory, entry.name);
|
|
1003
|
+
if (entry.isDirectory()) {
|
|
1004
|
+
await visit(target);
|
|
1005
|
+
}
|
|
1006
|
+
else if (entry.isFile()) {
|
|
1007
|
+
files.push(target);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
await visit(searchRoot);
|
|
1012
|
+
return files;
|
|
1013
|
+
}
|
|
1014
|
+
async readCodeFile(rawPath, offset, limit) {
|
|
1015
|
+
const target = await this.confinedPath(rawPath);
|
|
1016
|
+
let data;
|
|
1017
|
+
try {
|
|
1018
|
+
if (!(await this.existsWithExactCase(rawPath)))
|
|
1019
|
+
throw new Error("missing");
|
|
1020
|
+
data = await readFile(target);
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
return {
|
|
1024
|
+
status: "error",
|
|
1025
|
+
message: `File "${rawPath}" does not exist`,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
if (workspaceFileKind(target, data.subarray(0, 4096)) !== "text") {
|
|
1029
|
+
return {
|
|
1030
|
+
status: "error",
|
|
1031
|
+
message: `File "${rawPath}" is not a supported text file`,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const lines = decodeText(data).split("\n");
|
|
1035
|
+
const start = Math.max(1, Math.trunc(offset));
|
|
1036
|
+
const end = Math.min(lines.length, start + Math.max(1, Math.trunc(limit)) - 1);
|
|
1037
|
+
const numbered = start > lines.length
|
|
1038
|
+
? ""
|
|
1039
|
+
: lines
|
|
1040
|
+
.slice(start - 1, end)
|
|
1041
|
+
.map((line, index) => `${start + index}: ${line}`)
|
|
1042
|
+
.join("\n");
|
|
1043
|
+
const footer = start === 1 && end === lines.length
|
|
1044
|
+
? `(End of file - ${lines.length} lines)`
|
|
1045
|
+
: `(Showing lines ${start}-${end} of ${lines.length}. Use offset=${end + 1} to continue.)`;
|
|
1046
|
+
return {
|
|
1047
|
+
status: "success",
|
|
1048
|
+
file: {
|
|
1049
|
+
path: this.relativePath(target),
|
|
1050
|
+
content: `${numbered}\n\n${footer}`,
|
|
1051
|
+
contentType: contentTypeForFile(target),
|
|
1052
|
+
},
|
|
1053
|
+
totalLines: lines.length,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
async typeErrors() {
|
|
1057
|
+
if (!existsSync(path.join(this.workspaceReal, "tsconfig.json")))
|
|
1058
|
+
return "";
|
|
1059
|
+
const executable = path.join(this.workspaceReal, "node_modules", ".bin", process.platform === "win32" ? "tsc.cmd" : "tsc");
|
|
1060
|
+
const localCompiler = existsSync(executable);
|
|
1061
|
+
const npmCli = process.env.CODE_HARNESS_NPM_CLI_JS;
|
|
1062
|
+
const result = localCompiler
|
|
1063
|
+
? await this.runProcess(executable, ["--noEmit", "--pretty", "false"], 30_000)
|
|
1064
|
+
: await this.runProcess(npmCli ? process.execPath : "npx", [
|
|
1065
|
+
...(npmCli ? [npmCli, "exec", "--"] : []),
|
|
1066
|
+
"tsc",
|
|
1067
|
+
"--noEmit",
|
|
1068
|
+
"--pretty",
|
|
1069
|
+
"false",
|
|
1070
|
+
], 30_000, { npm_config_cache: path.join(this.workspaceReal, ".npm") });
|
|
1071
|
+
if (result.exitCode === 0)
|
|
1072
|
+
return "";
|
|
1073
|
+
const output = [result.stdout.trim(), result.stderr.trim()]
|
|
1074
|
+
.filter(Boolean)
|
|
1075
|
+
.join("\n");
|
|
1076
|
+
const virtualized = this.virtualizeCodeOutput(output);
|
|
1077
|
+
return virtualized.length <= lintOutputLimit
|
|
1078
|
+
? virtualized
|
|
1079
|
+
: `${virtualized.slice(0, lintOutputLimit)}\n...truncated`;
|
|
1080
|
+
}
|
|
1081
|
+
async runNpmProcess(args, timeoutMs) {
|
|
1082
|
+
const npmCli = process.env.CODE_HARNESS_NPM_CLI_JS;
|
|
1083
|
+
return npmCli
|
|
1084
|
+
? this.runProcess(process.execPath, [npmCli, ...args], timeoutMs)
|
|
1085
|
+
: this.runProcess("npm", args, timeoutMs);
|
|
1086
|
+
}
|
|
1087
|
+
virtualizeCodeOutput(value) {
|
|
1088
|
+
if (this.workspaceContext || this.config.mode === "full-stack")
|
|
1089
|
+
return value;
|
|
1090
|
+
let virtualized = value;
|
|
1091
|
+
const workspaceRoots = [...new Set([this.workspace, this.workspaceReal])]
|
|
1092
|
+
.filter((root) => root !== path.parse(root).root)
|
|
1093
|
+
.sort((left, right) => right.length - left.length);
|
|
1094
|
+
for (const root of workspaceRoots) {
|
|
1095
|
+
virtualized = virtualized.replaceAll(root, "/home/user/sandbox");
|
|
1096
|
+
}
|
|
1097
|
+
return virtualized;
|
|
1098
|
+
}
|
|
1099
|
+
async mutationResult(verb, rawPath) {
|
|
1100
|
+
const lintError = await this.typeErrors();
|
|
1101
|
+
if (lintError) {
|
|
1102
|
+
return {
|
|
1103
|
+
status: "success",
|
|
1104
|
+
message: `${verb} ${rawPath}. Type errors found.`,
|
|
1105
|
+
lint_error: lintError,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
return { status: "success", message: `${verb} ${rawPath}.` };
|
|
1109
|
+
}
|
|
1110
|
+
async createCodeFile(rawPath, content) {
|
|
1111
|
+
const target = await this.confinedPath(rawPath);
|
|
1112
|
+
const existed = existsSync(target);
|
|
1113
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
1114
|
+
await writeFile(target, content, "utf8");
|
|
1115
|
+
if (!existed)
|
|
1116
|
+
this.rememberListedFile(this.relativePath(target));
|
|
1117
|
+
return this.mutationResult("Created", rawPath);
|
|
1118
|
+
}
|
|
1119
|
+
async editCodeFile(rawPath, context, replacement) {
|
|
1120
|
+
const target = await this.confinedPath(rawPath);
|
|
1121
|
+
let source;
|
|
1122
|
+
try {
|
|
1123
|
+
if (!(await this.existsWithExactCase(rawPath)))
|
|
1124
|
+
throw new Error("missing");
|
|
1125
|
+
source = await readFile(target, "utf8");
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
return {
|
|
1129
|
+
status: "error",
|
|
1130
|
+
message: `File "${rawPath}" does not exist`,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
if (!source.includes(context)) {
|
|
1134
|
+
return {
|
|
1135
|
+
status: "error",
|
|
1136
|
+
message: "Context not found. Read the file to verify the text exists.",
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
await writeFile(target, source.replace(context, replacement), "utf8");
|
|
1140
|
+
return this.mutationResult("Edited", rawPath);
|
|
1141
|
+
}
|
|
1142
|
+
async deleteCodeFile(rawPath) {
|
|
1143
|
+
const target = await this.confinedPath(rawPath);
|
|
1144
|
+
await rm(target, { force: true, recursive: true });
|
|
1145
|
+
this.forgetListedPath(this.relativePath(target));
|
|
1146
|
+
return { status: "success" };
|
|
1147
|
+
}
|
|
1148
|
+
async listCodeFiles(rawPath) {
|
|
1149
|
+
const target = await this.confinedPath(rawPath || ".");
|
|
1150
|
+
if (!(await this.existsWithExactCase(rawPath || ".")) ||
|
|
1151
|
+
!existsSync(target)) {
|
|
1152
|
+
return { status: "success", files: [] };
|
|
1153
|
+
}
|
|
1154
|
+
const files = this.orderListedFiles(await this.walkFiles(target));
|
|
1155
|
+
return {
|
|
1156
|
+
status: "success",
|
|
1157
|
+
files: files.map((filePath) => ({ path: filePath })),
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
async grepCodeFiles(values) {
|
|
1161
|
+
const target = await this.confinedPath(String(values.path || "."));
|
|
1162
|
+
const targetInfo = await stat(target);
|
|
1163
|
+
const files = await this.walkFiles(target);
|
|
1164
|
+
const glob = String(values.glob || "");
|
|
1165
|
+
const flags = Boolean(values["-i"]) ? "i" : "";
|
|
1166
|
+
let expression;
|
|
1167
|
+
try {
|
|
1168
|
+
expression = new RegExp(String(values.pattern), flags);
|
|
1169
|
+
}
|
|
1170
|
+
catch (error) {
|
|
1171
|
+
throw new Error(`Invalid regular expression: ${errorMessage(error)}`);
|
|
1172
|
+
}
|
|
1173
|
+
const symmetric = Math.max(Number(values.context || 0), Number(values["-C"] || 0));
|
|
1174
|
+
const before = symmetric || Number(values["-B"] || 0);
|
|
1175
|
+
const after = symmetric || Number(values["-A"] || 0);
|
|
1176
|
+
const matches = [];
|
|
1177
|
+
const matchedFiles = [];
|
|
1178
|
+
const counts = [];
|
|
1179
|
+
let totalMatches = 0;
|
|
1180
|
+
for (const filePath of files) {
|
|
1181
|
+
const relative = this.relativePath(filePath);
|
|
1182
|
+
const relativeToSearch = targetInfo.isFile()
|
|
1183
|
+
? path.basename(filePath)
|
|
1184
|
+
: path.relative(target, filePath).split(path.sep).join("/");
|
|
1185
|
+
if (glob && !matchesGlob(relativeToSearch, glob))
|
|
1186
|
+
continue;
|
|
1187
|
+
let content;
|
|
1188
|
+
try {
|
|
1189
|
+
const data = await readFile(filePath);
|
|
1190
|
+
if (workspaceFileKind(filePath, data.subarray(0, 4096)) !== "text") {
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
content = decodeText(data);
|
|
1194
|
+
}
|
|
1195
|
+
catch {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
const lines = content.split("\n");
|
|
1199
|
+
let fileCount = 0;
|
|
1200
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1201
|
+
if (!expression.test(lines[index]))
|
|
1202
|
+
continue;
|
|
1203
|
+
fileCount += 1;
|
|
1204
|
+
totalMatches += 1;
|
|
1205
|
+
if (matches.length < maximumSearchResults) {
|
|
1206
|
+
const match = {
|
|
1207
|
+
path: relative,
|
|
1208
|
+
lineNumber: index + 1,
|
|
1209
|
+
lineContent: lines[index],
|
|
1210
|
+
};
|
|
1211
|
+
if (before > 0) {
|
|
1212
|
+
match.contextBefore = lines.slice(Math.max(0, index - before), index);
|
|
1213
|
+
}
|
|
1214
|
+
if (after > 0) {
|
|
1215
|
+
match.contextAfter = lines.slice(index + 1, index + 1 + after);
|
|
1216
|
+
}
|
|
1217
|
+
matches.push(match);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (fileCount > 0) {
|
|
1221
|
+
if (matchedFiles.length < maximumSearchResults)
|
|
1222
|
+
matchedFiles.push(relative);
|
|
1223
|
+
if (counts.length < maximumSearchResults) {
|
|
1224
|
+
counts.push({ path: relative, count: fileCount });
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
const mode = String(values.output_mode || "content");
|
|
1229
|
+
if (mode === "files_with_matches") {
|
|
1230
|
+
return {
|
|
1231
|
+
status: "success",
|
|
1232
|
+
mode,
|
|
1233
|
+
files: matchedFiles,
|
|
1234
|
+
truncated: counts.length >= maximumSearchResults,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
if (mode === "count") {
|
|
1238
|
+
return {
|
|
1239
|
+
status: "success",
|
|
1240
|
+
mode,
|
|
1241
|
+
counts,
|
|
1242
|
+
totalMatches,
|
|
1243
|
+
truncated: counts.length >= maximumSearchResults,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return {
|
|
1247
|
+
status: "success",
|
|
1248
|
+
mode: "content",
|
|
1249
|
+
matches,
|
|
1250
|
+
truncated: totalMatches > maximumSearchResults,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
async globCodeFiles(pattern, rawPath) {
|
|
1254
|
+
const target = await this.confinedPath(rawPath || ".");
|
|
1255
|
+
const files = await this.walkFiles(target);
|
|
1256
|
+
const allPaths = files
|
|
1257
|
+
.filter((filePath) => matchesGlob(path.relative(target, filePath).split(path.sep).join("/"), pattern))
|
|
1258
|
+
.map((filePath) => this.relativePath(filePath));
|
|
1259
|
+
return {
|
|
1260
|
+
status: "success",
|
|
1261
|
+
paths: allPaths.slice(0, maximumSearchResults),
|
|
1262
|
+
truncated: allPaths.length > maximumSearchResults,
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
async runProcess(command, args, timeoutMs, envOverrides = {}) {
|
|
1266
|
+
return new Promise((resolve, reject) => {
|
|
1267
|
+
const child = spawn(command, args, {
|
|
1268
|
+
cwd: this.workspaceReal,
|
|
1269
|
+
env: { ...process.env, ...envOverrides },
|
|
1270
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1271
|
+
detached: process.platform !== "win32",
|
|
1272
|
+
});
|
|
1273
|
+
let stdout = "";
|
|
1274
|
+
let stderr = "";
|
|
1275
|
+
let settled = false;
|
|
1276
|
+
const kill = () => {
|
|
1277
|
+
try {
|
|
1278
|
+
if (process.platform !== "win32" && child.pid) {
|
|
1279
|
+
process.kill(-child.pid, "SIGKILL");
|
|
1280
|
+
}
|
|
1281
|
+
else {
|
|
1282
|
+
child.kill("SIGKILL");
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
if (error.code !== "ESRCH")
|
|
1287
|
+
throw error;
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
const finish = (exitCode) => {
|
|
1291
|
+
if (settled)
|
|
1292
|
+
return;
|
|
1293
|
+
settled = true;
|
|
1294
|
+
clearTimeout(timeout);
|
|
1295
|
+
this.signal?.removeEventListener("abort", abort);
|
|
1296
|
+
if (this.signal?.aborted) {
|
|
1297
|
+
reject(this.signal.reason || new Error("operation cancelled"));
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
resolve({ exitCode, stdout, stderr });
|
|
1301
|
+
};
|
|
1302
|
+
const abort = () => {
|
|
1303
|
+
kill();
|
|
1304
|
+
finish(null);
|
|
1305
|
+
};
|
|
1306
|
+
const timeout = setTimeout(() => {
|
|
1307
|
+
kill();
|
|
1308
|
+
stderr += `${stderr ? "\n" : ""}Process timed out`;
|
|
1309
|
+
finish(null);
|
|
1310
|
+
}, timeoutMs);
|
|
1311
|
+
this.signal?.addEventListener("abort", abort, { once: true });
|
|
1312
|
+
child.stdout.on("data", (chunk) => {
|
|
1313
|
+
stdout += chunk.toString("utf8");
|
|
1314
|
+
});
|
|
1315
|
+
child.stderr.on("data", (chunk) => {
|
|
1316
|
+
stderr += chunk.toString("utf8");
|
|
1317
|
+
});
|
|
1318
|
+
child.once("error", (error) => {
|
|
1319
|
+
if (settled)
|
|
1320
|
+
return;
|
|
1321
|
+
settled = true;
|
|
1322
|
+
clearTimeout(timeout);
|
|
1323
|
+
this.signal?.removeEventListener("abort", abort);
|
|
1324
|
+
reject(error);
|
|
1325
|
+
});
|
|
1326
|
+
child.once("close", finish);
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
async installNpmPackages(packages) {
|
|
1330
|
+
const invalid = packages.find((packageName) => !packageNameIsSafe(packageName));
|
|
1331
|
+
if (invalid) {
|
|
1332
|
+
return {
|
|
1333
|
+
status: "error",
|
|
1334
|
+
message: `Invalid package name: ${invalid}`,
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
const args = ["install", "-S"];
|
|
1338
|
+
if (this.config.mode === "react")
|
|
1339
|
+
args.push("--package-lock=false");
|
|
1340
|
+
args.push(...packages);
|
|
1341
|
+
const result = await this.runNpmProcess(args, 5 * 60_000);
|
|
1342
|
+
if (result.exitCode !== 0) {
|
|
1343
|
+
return {
|
|
1344
|
+
status: "error",
|
|
1345
|
+
message: "Failed to install npm packages",
|
|
1346
|
+
stdout: this.virtualizeCodeOutput(result.stdout),
|
|
1347
|
+
stderr: this.virtualizeCodeOutput(result.stderr),
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
return {
|
|
1351
|
+
status: "success",
|
|
1352
|
+
stdout: this.virtualizeCodeOutput(result.stdout.trim()),
|
|
1353
|
+
stderr: this.virtualizeCodeOutput(result.stderr.trim()),
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
async buildProject() {
|
|
1357
|
+
const result = await this.runNpmProcess(["run", "build"], 5 * 60_000);
|
|
1358
|
+
if (result.exitCode !== 0) {
|
|
1359
|
+
return {
|
|
1360
|
+
status: "error",
|
|
1361
|
+
message: "Failed to build project",
|
|
1362
|
+
stdout: this.virtualizeCodeOutput(result.stdout),
|
|
1363
|
+
stderr: this.virtualizeCodeOutput(result.stderr),
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
return {
|
|
1367
|
+
status: "success",
|
|
1368
|
+
stdout: this.virtualizeCodeOutput(result.stdout),
|
|
1369
|
+
stderr: this.virtualizeCodeOutput(result.stderr),
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
virtualizeBashOutput(value) {
|
|
1373
|
+
if (this.workspaceContext || this.config.mode === "full-stack")
|
|
1374
|
+
return value;
|
|
1375
|
+
let virtualized = value;
|
|
1376
|
+
const workspaceRoots = [...new Set([this.workspace, this.workspaceReal])]
|
|
1377
|
+
.filter((root) => root !== path.parse(root).root)
|
|
1378
|
+
.sort((left, right) => right.length - left.length);
|
|
1379
|
+
for (const root of workspaceRoots) {
|
|
1380
|
+
virtualized = virtualized.replaceAll(root, "/home/user");
|
|
1381
|
+
}
|
|
1382
|
+
return virtualized;
|
|
1383
|
+
}
|
|
1384
|
+
async fullStackBash(command, workdir) {
|
|
1385
|
+
// The captured Full Stack sandbox exposes PostgreSQL and psql from the
|
|
1386
|
+
// beginning of the session, not only after build_and_start. Materialize
|
|
1387
|
+
// that baseline before every bash call so early drizzle/psql commands see
|
|
1388
|
+
// the same environment. ensureFullStackDatabase is a cheap port check
|
|
1389
|
+
// after the first call.
|
|
1390
|
+
const databaseError = await this.ensureFullStackDatabase();
|
|
1391
|
+
if (databaseError) {
|
|
1392
|
+
return {
|
|
1393
|
+
status: "error",
|
|
1394
|
+
message: `Fullstack sandbox setup failed: ${databaseError}`,
|
|
1395
|
+
stdout: "",
|
|
1396
|
+
stderr: databaseError,
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
await this.preparePostgresCliShim();
|
|
1400
|
+
const configuredTimeout = Number(process.env.CODE_HARNESS_BASH_TIMEOUT_SECONDS || 300);
|
|
1401
|
+
const timeoutSeconds = Number.isFinite(configuredTimeout) && configuredTimeout > 0
|
|
1402
|
+
? configuredTimeout
|
|
1403
|
+
: 300;
|
|
1404
|
+
const result = await this.bash(command, workdir.trim() || ".", timeoutSeconds);
|
|
1405
|
+
const exitCode = result.exit_code;
|
|
1406
|
+
const stdout = String(result.stdout || "");
|
|
1407
|
+
const stderr = String(result.stderr || "");
|
|
1408
|
+
if (exitCode !== 0) {
|
|
1409
|
+
return {
|
|
1410
|
+
status: "error",
|
|
1411
|
+
message: `Command exited with code ${exitCode ?? -1}`,
|
|
1412
|
+
stdout,
|
|
1413
|
+
stderr,
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
return { status: "success", stdout, stderr };
|
|
1417
|
+
}
|
|
1418
|
+
async portIsOpen(hostname, port, timeoutMs = 500) {
|
|
1419
|
+
return new Promise((resolve) => {
|
|
1420
|
+
const socket = createConnection({ host: hostname, port });
|
|
1421
|
+
const finish = (open) => {
|
|
1422
|
+
socket.removeAllListeners();
|
|
1423
|
+
socket.destroy();
|
|
1424
|
+
resolve(open);
|
|
1425
|
+
};
|
|
1426
|
+
socket.setTimeout(timeoutMs);
|
|
1427
|
+
socket.once("connect", () => finish(true));
|
|
1428
|
+
socket.once("error", () => finish(false));
|
|
1429
|
+
socket.once("timeout", () => finish(false));
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
async fullStackDatabaseUrl() {
|
|
1433
|
+
const envPath = path.join(this.workspaceReal, ".env");
|
|
1434
|
+
let configured = process.env.DATABASE_URL || "";
|
|
1435
|
+
try {
|
|
1436
|
+
const source = await readFile(envPath, "utf8");
|
|
1437
|
+
const match = source.match(/^\s*DATABASE_URL\s*=\s*([^\r\n]+)\s*$/m);
|
|
1438
|
+
if (match?.[1])
|
|
1439
|
+
configured = match[1].replace(/^['"]|['"]$/g, "");
|
|
1440
|
+
}
|
|
1441
|
+
catch (error) {
|
|
1442
|
+
if (error.code !== "ENOENT")
|
|
1443
|
+
throw error;
|
|
1444
|
+
}
|
|
1445
|
+
return new URL(configured || "postgresql://postgres:postgres@127.0.0.1:5432/app_db");
|
|
1446
|
+
}
|
|
1447
|
+
async ensureFullStackDatabase() {
|
|
1448
|
+
const databaseUrl = await this.fullStackDatabaseUrl();
|
|
1449
|
+
const hostname = databaseUrl.hostname;
|
|
1450
|
+
const port = Number(databaseUrl.port || 5432);
|
|
1451
|
+
const username = decodeURIComponent(databaseUrl.username || "postgres");
|
|
1452
|
+
const password = decodeURIComponent(databaseUrl.password || "postgres");
|
|
1453
|
+
const database = databaseUrl.pathname.replace(/^\//, "") || "app_db";
|
|
1454
|
+
if (await this.portIsOpen(hostname, port))
|
|
1455
|
+
return undefined;
|
|
1456
|
+
if (!["127.0.0.1", "localhost", "::1"].includes(hostname)) {
|
|
1457
|
+
return `PostgreSQL is not reachable at ${hostname}:${port}`;
|
|
1458
|
+
}
|
|
1459
|
+
let dockerInfo;
|
|
1460
|
+
try {
|
|
1461
|
+
dockerInfo = await this.runProcess("docker", ["info"], 15_000);
|
|
1462
|
+
}
|
|
1463
|
+
catch (error) {
|
|
1464
|
+
return `PostgreSQL is not running and Docker is unavailable: ${errorMessage(error)}`;
|
|
1465
|
+
}
|
|
1466
|
+
if (dockerInfo.exitCode !== 0) {
|
|
1467
|
+
return (dockerInfo.stderr.trim() ||
|
|
1468
|
+
"PostgreSQL is not running and Docker is unavailable");
|
|
1469
|
+
}
|
|
1470
|
+
const containerName = this.postgresContainerName();
|
|
1471
|
+
const inspection = await this.runProcess("docker", ["inspect", "-f", "{{.State.Running}}", containerName], 15_000);
|
|
1472
|
+
let started;
|
|
1473
|
+
if (inspection.exitCode === 0) {
|
|
1474
|
+
started = inspection.stdout.trim() === "true"
|
|
1475
|
+
? { exitCode: 0, stdout: "", stderr: "" }
|
|
1476
|
+
: await this.runProcess("docker", ["start", containerName], 60_000);
|
|
1477
|
+
}
|
|
1478
|
+
else {
|
|
1479
|
+
started = await this.runProcess("docker", [
|
|
1480
|
+
"run",
|
|
1481
|
+
"-d",
|
|
1482
|
+
"--name",
|
|
1483
|
+
containerName,
|
|
1484
|
+
"-e",
|
|
1485
|
+
`POSTGRES_USER=${username}`,
|
|
1486
|
+
"-e",
|
|
1487
|
+
`POSTGRES_PASSWORD=${password}`,
|
|
1488
|
+
"-e",
|
|
1489
|
+
`POSTGRES_DB=${database}`,
|
|
1490
|
+
"-p",
|
|
1491
|
+
`127.0.0.1:${port}:5432`,
|
|
1492
|
+
process.env.CODE_HARNESS_POSTGRES_IMAGE || "postgres:16-alpine",
|
|
1493
|
+
], 2 * 60_000);
|
|
1494
|
+
}
|
|
1495
|
+
if (started.exitCode !== 0) {
|
|
1496
|
+
return started.stderr.trim() || "Failed to start PostgreSQL container";
|
|
1497
|
+
}
|
|
1498
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
1499
|
+
if (await this.portIsOpen(hostname, port, 1000)) {
|
|
1500
|
+
const ready = await this.runProcess("docker", [
|
|
1501
|
+
"exec",
|
|
1502
|
+
containerName,
|
|
1503
|
+
"pg_isready",
|
|
1504
|
+
"-U",
|
|
1505
|
+
username,
|
|
1506
|
+
"-d",
|
|
1507
|
+
database,
|
|
1508
|
+
], 5_000);
|
|
1509
|
+
if (ready.exitCode === 0)
|
|
1510
|
+
return undefined;
|
|
1511
|
+
}
|
|
1512
|
+
await delay(500);
|
|
1513
|
+
}
|
|
1514
|
+
return `PostgreSQL did not become ready at ${hostname}:${port}`;
|
|
1515
|
+
}
|
|
1516
|
+
postgresContainerName() {
|
|
1517
|
+
const digest = createHash("sha256")
|
|
1518
|
+
.update(this.workspaceReal)
|
|
1519
|
+
.digest("hex")
|
|
1520
|
+
.slice(0, 12);
|
|
1521
|
+
return `code-harness-postgres-${digest}`;
|
|
1522
|
+
}
|
|
1523
|
+
async preparePostgresCliShim() {
|
|
1524
|
+
const containerName = this.postgresContainerName();
|
|
1525
|
+
let inspection;
|
|
1526
|
+
try {
|
|
1527
|
+
inspection = await this.runProcess("docker", ["inspect", "-f", "{{.State.Running}}", containerName], 15_000);
|
|
1528
|
+
}
|
|
1529
|
+
catch {
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (inspection.exitCode !== 0 || inspection.stdout.trim() !== "true")
|
|
1533
|
+
return;
|
|
1534
|
+
const binDirectory = path.join(this.workspaceReal, ".code-harness", "bin");
|
|
1535
|
+
const wrapperPath = path.join(binDirectory, "psql");
|
|
1536
|
+
await mkdir(binDirectory, { recursive: true });
|
|
1537
|
+
await writeFile(wrapperPath, `#!/bin/sh\nexec docker exec -i ${containerName} psql "$@"\n`, "utf8");
|
|
1538
|
+
await chmod(wrapperPath, 0o755);
|
|
1539
|
+
}
|
|
1540
|
+
buildAndStartFailure(stage, message, logs) {
|
|
1541
|
+
return {
|
|
1542
|
+
status: "error",
|
|
1543
|
+
message,
|
|
1544
|
+
stage,
|
|
1545
|
+
logTail: logs.trim().slice(-12_000),
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
async stopPreviousPreview(pidPath) {
|
|
1549
|
+
try {
|
|
1550
|
+
const pid = Number((await readFile(pidPath, "utf8")).trim());
|
|
1551
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
1552
|
+
return;
|
|
1553
|
+
try {
|
|
1554
|
+
if (process.platform === "win32")
|
|
1555
|
+
process.kill(pid, "SIGTERM");
|
|
1556
|
+
else
|
|
1557
|
+
process.kill(-pid, "SIGTERM");
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
if (error.code !== "ESRCH")
|
|
1561
|
+
throw error;
|
|
1562
|
+
}
|
|
1563
|
+
await delay(250);
|
|
1564
|
+
}
|
|
1565
|
+
catch (error) {
|
|
1566
|
+
if (error.code !== "ENOENT")
|
|
1567
|
+
throw error;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
async buildAndStart() {
|
|
1571
|
+
const timeoutMs = Number(process.env.CODE_HARNESS_FULLSTACK_BUILD_TIMEOUT_MS || 300_000);
|
|
1572
|
+
const databaseError = await this.ensureFullStackDatabase();
|
|
1573
|
+
if (databaseError) {
|
|
1574
|
+
return this.buildAndStartFailure("database", `Fullstack sandbox setup failed: ${databaseError}`, databaseError);
|
|
1575
|
+
}
|
|
1576
|
+
await this.preparePostgresCliShim();
|
|
1577
|
+
const nextBinary = path.join(this.workspaceReal, "node_modules", ".bin", "next");
|
|
1578
|
+
if (!existsSync(nextBinary)) {
|
|
1579
|
+
const installed = await this.runNpmProcess(["install"], timeoutMs);
|
|
1580
|
+
if (installed.exitCode !== 0) {
|
|
1581
|
+
const logs = `${installed.stdout}${installed.stderr}`;
|
|
1582
|
+
return this.buildAndStartFailure("install", `Fullstack sandbox setup failed: Dependency installation failed (exit ${installed.exitCode ?? -1}) after ${timeoutMs}ms. See startup diagnostics for log output.`, logs);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
const buildStartedAt = Date.now();
|
|
1586
|
+
const built = await this.runNpmProcess(["run", "build"], timeoutMs);
|
|
1587
|
+
const buildLatencyMs = Date.now() - buildStartedAt;
|
|
1588
|
+
if (built.exitCode !== 0) {
|
|
1589
|
+
const logs = `${built.stdout}${built.stderr}`;
|
|
1590
|
+
return this.buildAndStartFailure("build", `Fullstack sandbox setup failed: Production build failed (exit ${built.exitCode ?? -1}) after ${timeoutMs}ms. See startup diagnostics for log output.`, logs);
|
|
1591
|
+
}
|
|
1592
|
+
const runtimeDirectory = path.join(this.workspaceReal, ".code-harness");
|
|
1593
|
+
const pidPath = path.join(runtimeDirectory, "preview.pid");
|
|
1594
|
+
const logPath = path.join(runtimeDirectory, "preview.log");
|
|
1595
|
+
await mkdir(runtimeDirectory, { recursive: true });
|
|
1596
|
+
await this.stopPreviousPreview(pidPath);
|
|
1597
|
+
const portValue = Number(process.env.CODE_HARNESS_PREVIEW_PORT || 3000);
|
|
1598
|
+
const port = Number.isInteger(portValue) && portValue > 0 ? portValue : 3000;
|
|
1599
|
+
const npmCli = process.env.CODE_HARNESS_NPM_CLI_JS;
|
|
1600
|
+
const command = npmCli ? process.execPath : "npm";
|
|
1601
|
+
const args = [
|
|
1602
|
+
...(npmCli ? [npmCli] : []),
|
|
1603
|
+
"run",
|
|
1604
|
+
"start",
|
|
1605
|
+
"--",
|
|
1606
|
+
"--hostname",
|
|
1607
|
+
"127.0.0.1",
|
|
1608
|
+
"--port",
|
|
1609
|
+
String(port),
|
|
1610
|
+
];
|
|
1611
|
+
const logFd = openSync(logPath, "w");
|
|
1612
|
+
const child = spawn(command, args, {
|
|
1613
|
+
cwd: this.workspaceReal,
|
|
1614
|
+
env: { ...process.env, PORT: String(port) },
|
|
1615
|
+
detached: process.platform !== "win32",
|
|
1616
|
+
stdio: ["ignore", logFd, logFd],
|
|
1617
|
+
});
|
|
1618
|
+
closeSync(logFd);
|
|
1619
|
+
if (!child.pid) {
|
|
1620
|
+
return this.buildAndStartFailure("start", "Fullstack sandbox setup failed: Production server did not start.", "Production server did not return a process id");
|
|
1621
|
+
}
|
|
1622
|
+
child.unref();
|
|
1623
|
+
await writeFile(pidPath, `${child.pid}\n`, "utf8");
|
|
1624
|
+
const localUrl = `http://127.0.0.1:${port}`;
|
|
1625
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
1626
|
+
try {
|
|
1627
|
+
const response = await fetch(`${localUrl}/api/health`, {
|
|
1628
|
+
signal: AbortSignal.timeout(1000),
|
|
1629
|
+
});
|
|
1630
|
+
if (response.ok) {
|
|
1631
|
+
return {
|
|
1632
|
+
status: "success",
|
|
1633
|
+
previewUrl: process.env.CODE_HARNESS_PREVIEW_URL || localUrl,
|
|
1634
|
+
buildLatencyMs,
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
catch {
|
|
1639
|
+
// The production server is still starting.
|
|
1640
|
+
}
|
|
1641
|
+
await delay(500);
|
|
1642
|
+
}
|
|
1643
|
+
try {
|
|
1644
|
+
if (process.platform === "win32")
|
|
1645
|
+
process.kill(child.pid, "SIGTERM");
|
|
1646
|
+
else
|
|
1647
|
+
process.kill(-child.pid, "SIGTERM");
|
|
1648
|
+
}
|
|
1649
|
+
catch {
|
|
1650
|
+
// The failed preview may already have exited.
|
|
1651
|
+
}
|
|
1652
|
+
let logs = "";
|
|
1653
|
+
try {
|
|
1654
|
+
logs = await readFile(logPath, "utf8");
|
|
1655
|
+
}
|
|
1656
|
+
catch {
|
|
1657
|
+
logs = "Production server exited without logs";
|
|
1658
|
+
}
|
|
1659
|
+
return this.buildAndStartFailure("healthcheck", "Fullstack sandbox setup failed: Production server failed the /api/health check.", logs);
|
|
1660
|
+
}
|
|
1661
|
+
async bash(command, cwd, timeoutSeconds) {
|
|
1662
|
+
const workdir = await this.confinedPath(cwd);
|
|
1663
|
+
if (!(await stat(workdir)).isDirectory()) {
|
|
1664
|
+
throw new Error(`cwd is not a directory: ${cwd}`);
|
|
1665
|
+
}
|
|
1666
|
+
// Runtime-aware callers execute commands exactly as written and report
|
|
1667
|
+
// real local paths. The legacy /home/user translation is limited to the
|
|
1668
|
+
// older non-Full-Stack replay contract.
|
|
1669
|
+
const translatedCommand = this.workspaceContext || this.config.mode === "full-stack"
|
|
1670
|
+
? command
|
|
1671
|
+
: command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
|
|
1672
|
+
return new Promise((resolve, reject) => {
|
|
1673
|
+
const env = {
|
|
1674
|
+
...process.env,
|
|
1675
|
+
HOME: this.workspace,
|
|
1676
|
+
ARENA_WORKSPACE: this.workspace,
|
|
1677
|
+
CODE_HARNESS_WORKSPACE: this.workspace,
|
|
1678
|
+
PATH: `${path.join(this.workspaceReal, ".code-harness", "bin")}${path.delimiter}${process.env.PATH || ""}`,
|
|
1679
|
+
// 生产 sandbox 是 C collation(参考 find|sort 为逐字节序),宿主
|
|
1680
|
+
// 常见 en_US.UTF-8 会翻转大小写次序;统一成 C.UTF-8 对齐生产。
|
|
1681
|
+
LANG: "C.UTF-8",
|
|
1682
|
+
LC_ALL: "C.UTF-8",
|
|
1683
|
+
};
|
|
1684
|
+
// 交互终端(node --test、npm 等)会向子进程注入强制着色变量,
|
|
1685
|
+
// 管道输出被 ANSI 色码污染;生产轨迹无色码,剥离之。
|
|
1686
|
+
delete env.FORCE_COLOR;
|
|
1687
|
+
delete env.CLICOLOR_FORCE;
|
|
1688
|
+
// detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
|
|
1689
|
+
// 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
|
|
1690
|
+
// 必须杀整组,并在宽限期后强制结算兜底。
|
|
1691
|
+
const child = spawn("/bin/bash", ["-c", translatedCommand], {
|
|
1692
|
+
cwd: workdir,
|
|
1693
|
+
env,
|
|
1694
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1695
|
+
detached: process.platform !== "win32",
|
|
1696
|
+
});
|
|
1697
|
+
const startedAt = Date.now();
|
|
1698
|
+
let stdout = "";
|
|
1699
|
+
let stderr = "";
|
|
1700
|
+
let timedOut = false;
|
|
1701
|
+
let settled = false;
|
|
1702
|
+
let killRequested = false;
|
|
1703
|
+
let forceSettle;
|
|
1704
|
+
const killTree = (signal) => {
|
|
1705
|
+
try {
|
|
1706
|
+
if (process.platform !== "win32" && child.pid) {
|
|
1707
|
+
process.kill(-child.pid, signal);
|
|
1708
|
+
}
|
|
1709
|
+
else {
|
|
1710
|
+
child.kill(signal);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
catch (error) {
|
|
1714
|
+
// ESRCH:组已消失。EPERM:Darwin 在组正被回收时对重复 kill 的
|
|
1715
|
+
// 返回值,同样视作已处理。
|
|
1716
|
+
const code = error.code;
|
|
1717
|
+
if (code !== "ESRCH" && code !== "EPERM")
|
|
1718
|
+
throw error;
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
const finish = (code) => {
|
|
1722
|
+
if (settled)
|
|
1723
|
+
return;
|
|
1724
|
+
settled = true;
|
|
1725
|
+
clearTimeout(timeout);
|
|
1726
|
+
if (forceSettle)
|
|
1727
|
+
clearTimeout(forceSettle);
|
|
1728
|
+
// 组长退出会立刻触发 close 并取消宽限定时器;取消/超时一旦请求过
|
|
1729
|
+
// 击杀,就必须在结算时补一发组级 SIGKILL,否则忽略 TERM 且关闭了
|
|
1730
|
+
// 继承管道的后台子进程会活过取消。正常退出不清场——模型可能有意
|
|
1731
|
+
// 留下后台服务供后续调用使用。
|
|
1732
|
+
if (killRequested)
|
|
1733
|
+
killTree("SIGKILL");
|
|
1734
|
+
this.signal?.removeEventListener("abort", abort);
|
|
1735
|
+
if (this.signal?.aborted) {
|
|
1736
|
+
reject(this.signal.reason || new Error("operation cancelled"));
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
// 生产契约:status 恒为 "completed"(包括非零退出码),超时无参考样本,
|
|
1740
|
+
// 按最小猜测同样返回 completed + exit_code null,stderr 附超时说明。
|
|
1741
|
+
if (timedOut && this.config.mode !== "full-stack") {
|
|
1742
|
+
stderr +=
|
|
1743
|
+
(stderr ? "\n" : "") + `command exceeded ${timeoutSeconds}s`;
|
|
1744
|
+
}
|
|
1745
|
+
const out = capBashOutput(this.virtualizeBashOutput(stdout));
|
|
1746
|
+
const err = capBashOutput(this.virtualizeBashOutput(stderr));
|
|
1747
|
+
resolve({
|
|
1748
|
+
status: "completed",
|
|
1749
|
+
stderr: err.text,
|
|
1750
|
+
stdout: out.text,
|
|
1751
|
+
exit_code: timedOut ? null : code,
|
|
1752
|
+
duration_ms: Date.now() - startedAt,
|
|
1753
|
+
stderr_truncated: err.truncated,
|
|
1754
|
+
stdout_truncated: out.truncated,
|
|
1755
|
+
});
|
|
1756
|
+
};
|
|
1757
|
+
// 宽限期结束时升级为组级 SIGKILL 再结算:abort 只发 SIGTERM,
|
|
1758
|
+
// trap TERM 的进程会活过 Promise 拒绝并继续改写工作区。
|
|
1759
|
+
const armForceSettle = () => {
|
|
1760
|
+
forceSettle ??= setTimeout(() => {
|
|
1761
|
+
killTree("SIGKILL");
|
|
1762
|
+
finish(null);
|
|
1763
|
+
}, 2000);
|
|
1764
|
+
};
|
|
1765
|
+
const timeout = setTimeout(() => {
|
|
1766
|
+
timedOut = true;
|
|
1767
|
+
killRequested = true;
|
|
1768
|
+
killTree("SIGKILL");
|
|
1769
|
+
armForceSettle();
|
|
1770
|
+
}, timeoutSeconds * 1000);
|
|
1771
|
+
const abort = () => {
|
|
1772
|
+
killRequested = true;
|
|
1773
|
+
killTree("SIGTERM");
|
|
1774
|
+
armForceSettle();
|
|
1775
|
+
};
|
|
1776
|
+
this.signal?.addEventListener("abort", abort, { once: true });
|
|
1777
|
+
child.stdout.on("data", (chunk) => {
|
|
1778
|
+
stdout += chunk.toString("utf8");
|
|
1779
|
+
});
|
|
1780
|
+
child.stderr.on("data", (chunk) => {
|
|
1781
|
+
stderr += chunk.toString("utf8");
|
|
1782
|
+
});
|
|
1783
|
+
child.once("error", (error) => {
|
|
1784
|
+
if (settled)
|
|
1785
|
+
return;
|
|
1786
|
+
settled = true;
|
|
1787
|
+
clearTimeout(timeout);
|
|
1788
|
+
if (forceSettle)
|
|
1789
|
+
clearTimeout(forceSettle);
|
|
1790
|
+
this.signal?.removeEventListener("abort", abort);
|
|
1791
|
+
reject(error);
|
|
1792
|
+
});
|
|
1793
|
+
child.once("close", (code) => finish(code));
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
async webSearch(query, depth) {
|
|
1797
|
+
const model = process.env.CODE_HARNESS_WEB_SEARCH_MODEL ||
|
|
1798
|
+
process.env.ARENA_WEB_SEARCH_MODEL ||
|
|
1799
|
+
"qwen-plus";
|
|
1800
|
+
const searchStrategy = depth === "1" ? "turbo" : "max";
|
|
1801
|
+
const payload = await dashScopeJson("api", "/services/aigc/text-generation/generation", {
|
|
1802
|
+
method: "POST",
|
|
1803
|
+
headers: { "content-type": "application/json" },
|
|
1804
|
+
body: JSON.stringify({
|
|
1805
|
+
model,
|
|
1806
|
+
input: {
|
|
1807
|
+
messages: [{ role: "user", content: query }],
|
|
1808
|
+
},
|
|
1809
|
+
parameters: {
|
|
1810
|
+
enable_search: true,
|
|
1811
|
+
result_format: "message",
|
|
1812
|
+
search_options: {
|
|
1813
|
+
forced_search: true,
|
|
1814
|
+
enable_source: true,
|
|
1815
|
+
enable_citation: true,
|
|
1816
|
+
citation_format: "[<number>]",
|
|
1817
|
+
search_strategy: searchStrategy,
|
|
1818
|
+
},
|
|
1819
|
+
},
|
|
1820
|
+
}),
|
|
1821
|
+
signal: this.signal,
|
|
1822
|
+
}, "web_search");
|
|
1823
|
+
const output = record(payload.output);
|
|
1824
|
+
const searchInfo = record(output.search_info);
|
|
1825
|
+
const searchResults = Array.isArray(searchInfo.search_results)
|
|
1826
|
+
? searchInfo.search_results
|
|
1827
|
+
: [];
|
|
1828
|
+
const limit = { "1": 3, "2": 5, "3": 8 }[depth] || 5;
|
|
1829
|
+
// 参考轨迹条目:{id, url, title, description[, pageAge]};
|
|
1830
|
+
// DashScope 无 pageAge,按可选键省略。
|
|
1831
|
+
return {
|
|
1832
|
+
status: "success",
|
|
1833
|
+
results: searchResults.slice(0, limit).map((value, index) => {
|
|
1834
|
+
const item = record(value);
|
|
1835
|
+
return {
|
|
1836
|
+
id: Number(item.index) || index + 1,
|
|
1837
|
+
url: stringValue(item.url),
|
|
1838
|
+
title: stringValue(item.title),
|
|
1839
|
+
description: stringValue(item.content) ||
|
|
1840
|
+
stringValue(item.snippet) ||
|
|
1841
|
+
stringValue(item.summary),
|
|
1842
|
+
};
|
|
1843
|
+
}),
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
async webFetch(url, format) {
|
|
1847
|
+
const result = await this.pageReader.readFormatted(url, format);
|
|
1848
|
+
return {
|
|
1849
|
+
status: "success",
|
|
1850
|
+
title: String(result.title || "No Title Found"),
|
|
1851
|
+
content: String(result.content || ""),
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
async pexelsRequest(pathname, parameters) {
|
|
1855
|
+
const apiKey = process.env.PEXELS_API_KEY;
|
|
1856
|
+
if (!apiKey)
|
|
1857
|
+
throw new Error("fetch_media requires PEXELS_API_KEY");
|
|
1858
|
+
const url = new URL(pathname, "https://api.pexels.com");
|
|
1859
|
+
for (const [name, value] of Object.entries(parameters)) {
|
|
1860
|
+
if (value && value !== "any")
|
|
1861
|
+
url.searchParams.set(name, value);
|
|
1862
|
+
}
|
|
1863
|
+
return cachedMediaRequest({
|
|
1864
|
+
provider: "fetch_media",
|
|
1865
|
+
url,
|
|
1866
|
+
init: {
|
|
1867
|
+
headers: { authorization: apiKey },
|
|
1868
|
+
signal: this.signal,
|
|
1869
|
+
},
|
|
1870
|
+
beforeNetwork: () => {
|
|
1871
|
+
if (pexelsQuota &&
|
|
1872
|
+
pexelsQuota.remaining <= 0 &&
|
|
1873
|
+
Date.now() < pexelsQuota.resetAtMs) {
|
|
1874
|
+
throw new MediaProviderHttpError("fetch_media", 429, "Pexels quota exhausted");
|
|
1875
|
+
}
|
|
1876
|
+
},
|
|
1877
|
+
onResponse: (response) => {
|
|
1878
|
+
if (!response.ok)
|
|
1879
|
+
return;
|
|
1880
|
+
const remaining = Number(response.headers.get("x-ratelimit-remaining"));
|
|
1881
|
+
const resetAtSeconds = Number(response.headers.get("x-ratelimit-reset"));
|
|
1882
|
+
if (Number.isFinite(remaining) && Number.isFinite(resetAtSeconds)) {
|
|
1883
|
+
pexelsQuota = {
|
|
1884
|
+
remaining,
|
|
1885
|
+
resetAtMs: resetAtSeconds * 1000,
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
},
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
mapPexelsPhotos(payload) {
|
|
1892
|
+
const photos = Array.isArray(payload.photos) ? payload.photos : [];
|
|
1893
|
+
return photos.map((value) => {
|
|
1894
|
+
const photo = record(value);
|
|
1895
|
+
const source = record(photo.src);
|
|
1896
|
+
return {
|
|
1897
|
+
type: "image",
|
|
1898
|
+
id: Number(photo.id),
|
|
1899
|
+
pexelsUrl: stringValue(photo.url),
|
|
1900
|
+
recommendedUrl: stringValue(source.large2x) ||
|
|
1901
|
+
stringValue(source.large) ||
|
|
1902
|
+
stringValue(source.original),
|
|
1903
|
+
thumbnailUrl: stringValue(source.medium) ||
|
|
1904
|
+
stringValue(source.small) ||
|
|
1905
|
+
stringValue(source.tiny),
|
|
1906
|
+
width: Number(photo.width),
|
|
1907
|
+
height: Number(photo.height),
|
|
1908
|
+
creatorName: stringValue(photo.photographer),
|
|
1909
|
+
creatorUrl: stringValue(photo.photographer_url),
|
|
1910
|
+
alt: stringValue(photo.alt),
|
|
1911
|
+
};
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
mapPexelsVideos(payload) {
|
|
1915
|
+
const videos = Array.isArray(payload.videos) ? payload.videos : [];
|
|
1916
|
+
return videos.map((value) => {
|
|
1917
|
+
const video = record(value);
|
|
1918
|
+
const user = record(video.user);
|
|
1919
|
+
const pictures = Array.isArray(video.video_pictures)
|
|
1920
|
+
? video.video_pictures.map(record)
|
|
1921
|
+
: [];
|
|
1922
|
+
const files = Array.isArray(video.video_files)
|
|
1923
|
+
? video.video_files.map(record)
|
|
1924
|
+
: [];
|
|
1925
|
+
const preferred = files.find((item) => stringValue(item.quality) === "hd") ||
|
|
1926
|
+
files[0] ||
|
|
1927
|
+
{};
|
|
1928
|
+
return {
|
|
1929
|
+
type: "video",
|
|
1930
|
+
id: Number(video.id),
|
|
1931
|
+
pexelsUrl: stringValue(video.url),
|
|
1932
|
+
recommendedUrl: stringValue(preferred.link),
|
|
1933
|
+
thumbnailUrl: stringValue(pictures[0]?.picture),
|
|
1934
|
+
width: Number(video.width),
|
|
1935
|
+
height: Number(video.height),
|
|
1936
|
+
duration: Number(video.duration),
|
|
1937
|
+
creatorName: stringValue(user.name),
|
|
1938
|
+
creatorUrl: stringValue(user.url),
|
|
1939
|
+
};
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
async fetchMediaFromPexels(query, mediaType, requestedCount, orientation, size, locale) {
|
|
1943
|
+
const common = {
|
|
1944
|
+
query: normalizeMediaQuery(query),
|
|
1945
|
+
per_page: "10",
|
|
1946
|
+
orientation,
|
|
1947
|
+
size,
|
|
1948
|
+
locale,
|
|
1949
|
+
};
|
|
1950
|
+
let totalResults = 0;
|
|
1951
|
+
let photoResults = [];
|
|
1952
|
+
let videoResults = [];
|
|
1953
|
+
if (mediaType === "image" || mediaType === "both") {
|
|
1954
|
+
const photos = await this.pexelsRequest("/v1/search", common);
|
|
1955
|
+
totalResults += Number(photos.total_results || 0);
|
|
1956
|
+
photoResults = this.mapPexelsPhotos(photos);
|
|
1957
|
+
}
|
|
1958
|
+
if (mediaType === "video" || mediaType === "both") {
|
|
1959
|
+
const videos = await this.pexelsRequest("/v1/videos/search", common);
|
|
1960
|
+
totalResults += Number(videos.total_results || 0);
|
|
1961
|
+
videoResults = this.mapPexelsVideos(videos);
|
|
1962
|
+
}
|
|
1963
|
+
const results = mediaType === "both"
|
|
1964
|
+
? interleaveMedia(photoResults, videoResults)
|
|
1965
|
+
: mediaType === "image"
|
|
1966
|
+
? photoResults
|
|
1967
|
+
: videoResults;
|
|
1968
|
+
return {
|
|
1969
|
+
status: "success",
|
|
1970
|
+
query,
|
|
1971
|
+
mediaType,
|
|
1972
|
+
totalResults,
|
|
1973
|
+
results: results.slice(0, requestedCount),
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
async fetchMedia(query, mediaType, count, orientation, size, locale) {
|
|
1977
|
+
const requestedCount = Math.min(10, Math.max(1, Math.trunc(count)));
|
|
1978
|
+
return this.fetchMediaFromPexels(query, mediaType, requestedCount, orientation, size, locale);
|
|
1979
|
+
}
|
|
1980
|
+
async generateImage(rawPath, prompt, images) {
|
|
1981
|
+
if (this.generatedImageCount >= 10) {
|
|
1982
|
+
return {
|
|
1983
|
+
status: "error",
|
|
1984
|
+
message: "generate_image can be used at most 10 times per session",
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
this.generatedImageCount += 1;
|
|
1988
|
+
const target = await this.confinedPath(rawPath);
|
|
1989
|
+
if (![".jpg", ".jpeg", ".png"].includes(path.extname(target).toLowerCase())) {
|
|
1990
|
+
throw new Error("file_path must end in .jpg, .jpeg, or .png");
|
|
1991
|
+
}
|
|
1992
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
1993
|
+
const model = process.env.CODE_HARNESS_IMAGE_MODEL ||
|
|
1994
|
+
process.env.ARENA_IMAGE_MODEL ||
|
|
1995
|
+
"qwen-image-2.0-pro";
|
|
1996
|
+
const isQwenImage2 = model.startsWith("qwen-image-2.0");
|
|
1997
|
+
const content = [];
|
|
1998
|
+
for (const imagePath of images || []) {
|
|
1999
|
+
const source = await this.confinedPath(imagePath);
|
|
2000
|
+
const extension = path.extname(source).toLowerCase();
|
|
2001
|
+
const mimeType = mimeTypes[extension];
|
|
2002
|
+
if (!mimeType?.startsWith("image/")) {
|
|
2003
|
+
throw new Error(`unsupported reference image format: ${imagePath}`);
|
|
2004
|
+
}
|
|
2005
|
+
const bytes = await readFile(source);
|
|
2006
|
+
if (bytes.length > 20 * 1024 * 1024) {
|
|
2007
|
+
throw new Error(`reference image exceeds 20 MB: ${imagePath}`);
|
|
2008
|
+
}
|
|
2009
|
+
content.push({
|
|
2010
|
+
image: `data:${mimeType};base64,${bytes.toString("base64")}`,
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
content.push({ text: prompt });
|
|
2014
|
+
const parameters = {
|
|
2015
|
+
size: process.env.CODE_HARNESS_IMAGE_SIZE ||
|
|
2016
|
+
process.env.ARENA_IMAGE_SIZE ||
|
|
2017
|
+
(isQwenImage2 ? "2048*2048" : "2K"),
|
|
2018
|
+
watermark: false,
|
|
2019
|
+
};
|
|
2020
|
+
if (isQwenImage2) {
|
|
2021
|
+
parameters.prompt_extend = true;
|
|
2022
|
+
}
|
|
2023
|
+
else {
|
|
2024
|
+
parameters.n = 1;
|
|
2025
|
+
if (!images?.length)
|
|
2026
|
+
parameters.thinking_mode = true;
|
|
2027
|
+
}
|
|
2028
|
+
const headers = {
|
|
2029
|
+
"content-type": "application/json",
|
|
2030
|
+
};
|
|
2031
|
+
if (!isQwenImage2)
|
|
2032
|
+
headers["x-dashscope-async"] = "enable";
|
|
2033
|
+
const submitted = await dashScopeJson("api", isQwenImage2
|
|
2034
|
+
? "/services/aigc/multimodal-generation/generation"
|
|
2035
|
+
: "/services/aigc/image-generation/generation", {
|
|
2036
|
+
method: "POST",
|
|
2037
|
+
headers,
|
|
2038
|
+
body: JSON.stringify({
|
|
2039
|
+
model,
|
|
2040
|
+
input: {
|
|
2041
|
+
messages: [{ role: "user", content }],
|
|
2042
|
+
},
|
|
2043
|
+
parameters,
|
|
2044
|
+
}),
|
|
2045
|
+
signal: this.signal,
|
|
2046
|
+
}, "generate_image");
|
|
2047
|
+
let completed = isQwenImage2
|
|
2048
|
+
? submitted
|
|
2049
|
+
: undefined;
|
|
2050
|
+
if (!isQwenImage2) {
|
|
2051
|
+
const taskId = stringValue(record(submitted.output).task_id);
|
|
2052
|
+
if (!taskId) {
|
|
2053
|
+
throw new Error(`generate_image returned no task_id: ${responseMessage(submitted)}`);
|
|
2054
|
+
}
|
|
2055
|
+
const timeoutMs = Number(process.env.DASHSCOPE_TASK_TIMEOUT_MS || 5 * 60 * 1000);
|
|
2056
|
+
const pollIntervalMs = Number(process.env.DASHSCOPE_TASK_POLL_INTERVAL_MS || 1000);
|
|
2057
|
+
const deadline = Date.now() + timeoutMs;
|
|
2058
|
+
while (Date.now() <= deadline) {
|
|
2059
|
+
const task = await dashScopeJson("api", `/tasks/${encodeURIComponent(taskId)}`, { method: "GET", signal: this.signal }, "generate_image status");
|
|
2060
|
+
const output = record(task.output);
|
|
2061
|
+
const taskStatus = stringValue(output.task_status).toUpperCase();
|
|
2062
|
+
if (taskStatus === "SUCCEEDED") {
|
|
2063
|
+
completed = task;
|
|
2064
|
+
break;
|
|
2065
|
+
}
|
|
2066
|
+
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
|
2067
|
+
throw new Error(`generate_image task ${taskStatus}: ${responseMessage(task)}`);
|
|
2068
|
+
}
|
|
2069
|
+
if (Date.now() + pollIntervalMs > deadline)
|
|
2070
|
+
break;
|
|
2071
|
+
await delay(pollIntervalMs, undefined, { signal: this.signal });
|
|
2072
|
+
}
|
|
2073
|
+
if (!completed) {
|
|
2074
|
+
throw new Error(`generate_image timed out waiting for task ${taskId}`);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
if (!completed) {
|
|
2078
|
+
throw new Error("generate_image returned no response");
|
|
2079
|
+
}
|
|
2080
|
+
const output = record(completed.output);
|
|
2081
|
+
const choices = Array.isArray(output.choices) ? output.choices : [];
|
|
2082
|
+
const message = record(record(choices[0]).message);
|
|
2083
|
+
const generatedContent = Array.isArray(message.content)
|
|
2084
|
+
? message.content
|
|
2085
|
+
: [];
|
|
2086
|
+
const generated = generatedContent
|
|
2087
|
+
.map(record)
|
|
2088
|
+
.find((item) => typeof item.image === "string");
|
|
2089
|
+
const legacyResults = Array.isArray(output.results) ? output.results : [];
|
|
2090
|
+
const imageUrl = stringValue(generated?.image) ||
|
|
2091
|
+
stringValue(record(legacyResults[0]).url);
|
|
2092
|
+
if (!imageUrl) {
|
|
2093
|
+
throw new Error("generate_image response did not include an image URL");
|
|
2094
|
+
}
|
|
2095
|
+
const imageResponse = await fetch(imageUrl, { signal: this.signal });
|
|
2096
|
+
if (!imageResponse.ok) {
|
|
2097
|
+
throw new Error(`image download failed (${imageResponse.status})`);
|
|
2098
|
+
}
|
|
2099
|
+
await writeFile(target, await responseBytes(imageResponse, 25 * 1024 * 1024));
|
|
2100
|
+
return {
|
|
2101
|
+
status: "success",
|
|
2102
|
+
message: `Image saved to ${this.relativePath(target)}`,
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
//# sourceMappingURL=code-tools.js.map
|