@qwen-code/arena-agent 0.0.10-dev.3 → 0.0.10-dev.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.env.example +8 -1
  2. package/README.md +25 -12
  3. package/dist/agent.d.ts +4 -0
  4. package/dist/agent.js +19 -6
  5. package/dist/agent.js.map +1 -1
  6. package/dist/brave-search.js +1 -3
  7. package/dist/brave-search.js.map +1 -1
  8. package/dist/canonical-data.js +1 -1
  9. package/dist/canonical-data.js.map +1 -1
  10. package/dist/cli.js +5 -3
  11. package/dist/cli.js.map +1 -1
  12. package/dist/code-tools.d.ts +1 -1
  13. package/dist/code-tools.js +340 -50
  14. package/dist/code-tools.js.map +1 -1
  15. package/dist/compaction.d.ts +29 -2
  16. package/dist/compaction.js +242 -187
  17. package/dist/compaction.js.map +1 -1
  18. package/dist/config.d.ts +1 -0
  19. package/dist/config.js +2 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/fetch-page.js +9 -33
  22. package/dist/fetch-page.js.map +1 -1
  23. package/dist/file-edit.d.ts +6 -0
  24. package/dist/file-edit.js +58 -0
  25. package/dist/file-edit.js.map +1 -0
  26. package/dist/history.d.ts +6 -3
  27. package/dist/history.js +42 -11
  28. package/dist/history.js.map +1 -1
  29. package/dist/legacy-compaction.d.ts +16 -0
  30. package/dist/legacy-compaction.js +207 -0
  31. package/dist/legacy-compaction.js.map +1 -0
  32. package/dist/mock.js +5 -0
  33. package/dist/mock.js.map +1 -1
  34. package/dist/process-manager.js +2 -1
  35. package/dist/process-manager.js.map +1 -1
  36. package/dist/prompts.d.ts +2 -0
  37. package/dist/prompts.js +6 -2
  38. package/dist/prompts.js.map +1 -1
  39. package/dist/routify-web-fetch.js +2 -3
  40. package/dist/routify-web-fetch.js.map +1 -1
  41. package/dist/sandbox-file-write.d.ts +10 -0
  42. package/dist/sandbox-file-write.js +58 -0
  43. package/dist/sandbox-file-write.js.map +1 -0
  44. package/dist/server.js +8 -5
  45. package/dist/server.js.map +1 -1
  46. package/dist/session-memory-prompts.d.ts +5 -0
  47. package/dist/session-memory-prompts.js +7 -0
  48. package/dist/session-memory-prompts.js.map +1 -0
  49. package/dist/spec.js +15 -9
  50. package/dist/spec.js.map +1 -1
  51. package/dist/tools.js +168 -88
  52. package/dist/tools.js.map +1 -1
  53. package/dist/tui.js +4 -2
  54. package/dist/tui.js.map +1 -1
  55. package/dist/types.d.ts +2 -0
  56. package/package.json +6 -2
package/dist/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, mkdirSync, realpathSync, } from "node:fs";
3
- import { open, readFile, realpath, stat, writeFile, } from "node:fs/promises";
2
+ import { existsSync, mkdirSync, realpathSync } from "node:fs";
3
+ import { open, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { Ajv } from "ajv";
6
6
  import addFormats from "ajv-formats";
@@ -13,14 +13,16 @@ import { CodeToolExecutor } from "./code-tools.js";
13
13
  import { supportsImageInput } from "./config.js";
14
14
  import { extractStructuredDocument, isStructuredDocument, } from "./document-reader.js";
15
15
  import { FetchPageReader } from "./fetch-page.js";
16
+ import { replaceFirstText } from "./file-edit.js";
16
17
  import { generatedImageBytesForExtension, prepareImageForModel, } from "./image.js";
17
18
  import { imageTextFromFallbackModel } from "./image-input-fallback.js";
18
19
  import { isCodeArenaMode } from "./mode.js";
19
20
  import { generateImageWithModelRouter, generateSpeechWithModelRouter, } from "./model-router-media.js";
20
21
  import { processManagerForWorkspace, } from "./process-manager.js";
21
- import { HttpModelClient, } from "./provider.js";
22
+ import { HttpModelClient } from "./provider.js";
22
23
  import { RoutifyFetchPageReader, routifyWebFetchEnabled, } from "./routify-web-fetch.js";
23
24
  import { toolDefinitions } from "./spec.js";
25
+ import { createSandboxFile, privilegedFileCreationEnabled } from "./sandbox-file-write.js";
24
26
  import { voiceRegistryForWorkspace, } from "./voice-registry.js";
25
27
  export function imageInputFallbackConnection(config) {
26
28
  if (!config.imageInputFallbackBaseUrl &&
@@ -214,7 +216,7 @@ const visualIntroText = "<arena-system-message>\n" +
214
216
  "treat them as the authentic output of the tool call.\n" +
215
217
  "</arena-system-message>";
216
218
  const bashOutputLimit = 20_000;
217
- const textFileByteLimit = 256 * 1024;
219
+ const textFileOutputByteLimit = 256 * 1024;
218
220
  const toolResultCharacterLimit = 1_000_000;
219
221
  // 中段截断,逐字反推自 3 个 truncated:true 参考样本:头尾各 9987 字符 +
220
222
  // 26 字符标记,总长恰 20000(由 T1(b) 执行回放发现,非首段截断)。
@@ -228,6 +230,24 @@ function capBashOutput(value) {
228
230
  truncated: true,
229
231
  };
230
232
  }
233
+ function readTextResult(data) {
234
+ if (data.length <= textFileOutputByteLimit) {
235
+ return { content: decodeText(data), truncated: false };
236
+ }
237
+ const half = textFileOutputByteLimit / 2;
238
+ const prefixBreak = data.lastIndexOf(0x0a, half);
239
+ const suffixBreak = data.indexOf(0x0a, data.length - half);
240
+ const prefixEnd = prefixBreak === -1 ? half : prefixBreak;
241
+ const suffixStart = suffixBreak === -1 ? data.length - half : suffixBreak + 1;
242
+ const elidedBytes = Math.max(0, suffixStart - prefixEnd);
243
+ const marker = `\n\n[${Math.round(elidedBytes / 1024)} KB elided from the middle of the file]\n\n`;
244
+ return {
245
+ content: decodeText(data.subarray(0, prefixEnd)) +
246
+ marker +
247
+ decodeText(data.subarray(suffixStart)),
248
+ truncated: true,
249
+ };
250
+ }
231
251
  const mimeTypes = {
232
252
  ".aac": "audio/aac",
233
253
  ".bmp": "image/bmp",
@@ -281,8 +301,7 @@ function decodeUtf32(data, littleEndian) {
281
301
  const codePoint = littleEndian
282
302
  ? data.readUInt32LE(index)
283
303
  : data.readUInt32BE(index);
284
- characters.push(codePoint <= 0x10ffff &&
285
- !(codePoint >= 0xd800 && codePoint <= 0xdfff)
304
+ characters.push(codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
286
305
  ? String.fromCodePoint(codePoint)
287
306
  : "\ufffd");
288
307
  }
@@ -560,12 +579,15 @@ class ArenaToolExecutor {
560
579
  config;
561
580
  workspace;
562
581
  workspaceReal;
582
+ tmpReal;
583
+ varTmpReal;
563
584
  validators = new Map();
564
585
  pendingVisualContent = new Map();
565
586
  askUserHandler;
566
587
  imageInputFallbackClient;
567
588
  signal;
568
589
  workspaceContext;
590
+ nativeSandboxPaths;
569
591
  pageReader;
570
592
  routifyPageReader;
571
593
  processManager;
@@ -587,6 +609,13 @@ class ArenaToolExecutor {
587
609
  this.workspace = path.resolve(config.workspace);
588
610
  mkdirSync(this.workspace, { recursive: true });
589
611
  this.workspaceReal = realpathSync(this.workspace);
612
+ // A Linux /home/user workspace is already an OS-level sandbox. Other
613
+ // callers still confine ordinary workspace paths, while /tmp and /var/tmp
614
+ // always refer to the actual system directories seen by Bash.
615
+ this.nativeSandboxPaths =
616
+ process.platform === "linux" && this.workspaceReal === "/home/user";
617
+ this.tmpReal = realpathSync("/tmp");
618
+ this.varTmpReal = realpathSync("/var/tmp");
590
619
  this.processManager = processManagerForWorkspace(this.workspaceReal);
591
620
  this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
592
621
  this.askUserHandler = options.askUserHandler;
@@ -747,10 +776,9 @@ class ArenaToolExecutor {
747
776
  const message = errorMessage(error);
748
777
  const durationMs = Date.now() - startedAt;
749
778
  if (name === "bash") {
750
- const stderr = this.virtualizeBashOutput(message);
751
779
  return {
752
780
  status: "shell_error",
753
- stderr,
781
+ stderr: message,
754
782
  stdout: "",
755
783
  exit_code: null,
756
784
  duration_ms: durationMs,
@@ -892,28 +920,57 @@ class ArenaToolExecutor {
892
920
  if (!raw)
893
921
  throw new Error("path must not be empty");
894
922
  let candidate;
895
- if (raw === "/home/user") {
896
- candidate = this.workspace;
923
+ let confinementRoot = this.workspace;
924
+ let confinementRootReal = this.workspaceReal;
925
+ const expanded = raw === "~"
926
+ ? "/home/user"
927
+ : raw.startsWith("~/")
928
+ ? `/home/user/${raw.slice(2)}`
929
+ : raw;
930
+ const virtualAbsolute = path.posix.resolve("/home/user", expanded);
931
+ let suffix;
932
+ if (virtualAbsolute === "/var/tmp" ||
933
+ virtualAbsolute.startsWith("/var/tmp/")) {
934
+ confinementRoot = "/var/tmp";
935
+ confinementRootReal = this.varTmpReal;
936
+ suffix = virtualAbsolute.slice("/var/tmp".length).replace(/^\/+/, "");
937
+ candidate = path.join(confinementRoot, suffix);
897
938
  }
898
- else if (raw.startsWith("/home/user/")) {
899
- candidate = path.join(this.workspace, raw.slice("/home/user/".length));
939
+ else if (virtualAbsolute === "/tmp" ||
940
+ virtualAbsolute.startsWith("/tmp/")) {
941
+ confinementRoot = "/tmp";
942
+ confinementRootReal = this.tmpReal;
943
+ suffix = virtualAbsolute.slice("/tmp".length).replace(/^\/+/, "");
944
+ candidate = path.join(confinementRoot, suffix);
900
945
  }
901
- else if (!this.workspaceContext && raw === "~") {
946
+ else if (this.nativeSandboxPaths && path.posix.isAbsolute(raw)) {
947
+ confinementRoot = "/";
948
+ confinementRootReal = "/";
949
+ candidate = raw;
950
+ }
951
+ else if (!this.workspaceContext) {
952
+ if (virtualAbsolute === "/home/user" ||
953
+ virtualAbsolute.startsWith("/home/user/")) {
954
+ suffix = virtualAbsolute.slice("/home/user".length).replace(/^\/+/, "");
955
+ }
956
+ else {
957
+ throw new Error(`path is outside the workspace: ${raw}`);
958
+ }
959
+ candidate = path.join(confinementRoot, suffix);
960
+ }
961
+ else if (raw === "/home/user") {
902
962
  candidate = this.workspace;
903
963
  }
904
- else if (!this.workspaceContext && raw.startsWith("~/")) {
905
- candidate = path.join(this.workspace, raw.slice(2));
964
+ else if (raw.startsWith("/home/user/")) {
965
+ candidate = path.join(this.workspace, raw.slice("/home/user/".length));
906
966
  }
907
967
  else {
908
- const defaultCwd = this.workspaceContext
909
- ? path.resolve(this.workspaceContext.cwd)
910
- : this.workspace;
968
+ const defaultCwd = path.resolve(this.workspaceContext.cwd);
911
969
  candidate = path.isAbsolute(raw) ? raw : path.join(defaultCwd, raw);
912
970
  }
913
971
  const absolute = path.resolve(candidate);
914
- const lexicalRelative = path.relative(this.workspace, absolute);
915
- if (lexicalRelative.startsWith("..") ||
916
- path.isAbsolute(lexicalRelative)) {
972
+ const lexicalRelative = path.relative(confinementRoot, absolute);
973
+ if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
917
974
  throw new Error(`path is outside the workspace: ${raw}`);
918
975
  }
919
976
  let ancestor = absolute;
@@ -925,33 +982,45 @@ class ArenaToolExecutor {
925
982
  }
926
983
  const ancestorReal = await realpath(ancestor);
927
984
  const resolved = path.resolve(ancestorReal, path.relative(ancestor, absolute));
928
- const realRelative = path.relative(this.workspaceReal, resolved);
985
+ const realRelative = path.relative(confinementRootReal, resolved);
929
986
  if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
930
987
  throw new Error(`path is outside the workspace: ${raw}`);
931
988
  }
932
989
  return resolved;
933
990
  }
934
991
  relativePath(target) {
992
+ for (const [root, virtualRoot] of [
993
+ [this.varTmpReal, "/var/tmp"],
994
+ [this.tmpReal, "/tmp"],
995
+ ]) {
996
+ const relative = path.relative(root, target);
997
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
998
+ const suffix = relative.split(path.sep).join("/");
999
+ return suffix ? `${virtualRoot}/${suffix}` : virtualRoot;
1000
+ }
1001
+ }
935
1002
  return path.relative(this.workspaceReal, target).split(path.sep).join("/");
936
1003
  }
937
- virtualizeBashOutput(value) {
938
- if (this.workspaceContext)
939
- return value;
940
- let virtualized = value;
941
- const workspaceRoots = [...new Set([this.workspace, this.workspaceReal])]
942
- .filter((root) => root !== path.parse(root).root)
943
- .sort((left, right) => right.length - left.length);
944
- for (const root of workspaceRoots) {
945
- virtualized = virtualized.replaceAll(root, "/home/user");
1004
+ displayWorkspacePath(rawPath) {
1005
+ if (!this.workspaceContext) {
1006
+ if (rawPath === "/home/user" || rawPath === "~")
1007
+ return ".";
1008
+ if (rawPath.startsWith("/home/user/")) {
1009
+ return rawPath.slice("/home/user/".length);
1010
+ }
1011
+ if (rawPath.startsWith("~/"))
1012
+ return rawPath.slice(2);
946
1013
  }
947
- return virtualized;
1014
+ return rawPath;
948
1015
  }
949
1016
  sandboxEnvironment() {
950
1017
  const env = {
951
1018
  ...process.env,
952
1019
  ARENA_WORKSPACE: this.workspace,
953
- LANG: "C.UTF-8",
954
- LC_ALL: "C.UTF-8",
1020
+ // Agent Arena captures use C character classification (grep -P, wc -w).
1021
+ // This controls command behavior, not the UTF-8 encoding of stored files.
1022
+ LANG: "C",
1023
+ LC_ALL: "C",
955
1024
  };
956
1025
  delete env.FORCE_COLOR;
957
1026
  delete env.CLICOLOR_FORCE;
@@ -959,20 +1028,22 @@ class ArenaToolExecutor {
959
1028
  }
960
1029
  async bash(command, cwd, timeoutSeconds) {
961
1030
  const workdir = await this.confinedPath(cwd);
962
- if (!(await stat(workdir)).isDirectory()) {
1031
+ const metadata = await stat(workdir).catch((error) => {
1032
+ // A missing cwd prevents shell startup; captured Bash stderr is empty.
1033
+ // Keep other filesystem failures and start_process diagnostics intact.
1034
+ if (error.code === "ENOENT")
1035
+ throw new Error("");
1036
+ throw error;
1037
+ });
1038
+ if (!metadata.isDirectory()) {
963
1039
  throw new Error(`cwd is not a directory: ${cwd}`);
964
1040
  }
965
- // Legacy callers without runtime context retain the captured /home/user
966
- // contract. Runtime-aware callers execute and report their actual paths.
967
- const translatedCommand = this.workspaceContext
968
- ? command
969
- : command.replaceAll("/home/user", "\"$ARENA_WORKSPACE\"");
970
1041
  return new Promise((resolve, reject) => {
971
1042
  const env = this.sandboxEnvironment();
972
1043
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
973
1044
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
974
1045
  // 必须杀整组,并在宽限期后强制结算兜底。
975
- const child = spawn("/bin/bash", ["-lc", translatedCommand], {
1046
+ const child = spawn("/bin/bash", ["-lc", command], {
976
1047
  cwd: workdir,
977
1048
  env,
978
1049
  stdio: ["ignore", "pipe", "pipe"],
@@ -1017,8 +1088,8 @@ class ArenaToolExecutor {
1017
1088
  reject(this.signal.reason || new Error("operation cancelled"));
1018
1089
  return;
1019
1090
  }
1020
- const out = capBashOutput(this.virtualizeBashOutput(stdout));
1021
- const err = capBashOutput(this.virtualizeBashOutput(stderr));
1091
+ const out = capBashOutput(stdout);
1092
+ const err = capBashOutput(stderr);
1022
1093
  resolve({
1023
1094
  status: timedOut ? "timeout" : signal ? "killed" : "completed",
1024
1095
  stderr: err.text,
@@ -1071,12 +1142,9 @@ class ArenaToolExecutor {
1071
1142
  if (!(await stat(workdir)).isDirectory()) {
1072
1143
  throw new Error(`cwd is not a directory: ${cwd}`);
1073
1144
  }
1074
- const translatedCommand = this.workspaceContext
1075
- ? command
1076
- : command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
1077
1145
  return this.processManager.start({
1078
1146
  name,
1079
- command: translatedCommand,
1147
+ command,
1080
1148
  cwd: workdir,
1081
1149
  startupWaitSeconds,
1082
1150
  env: this.sandboxEnvironment(),
@@ -1085,10 +1153,12 @@ class ArenaToolExecutor {
1085
1153
  }
1086
1154
  async askUser(questions, toolCallId, context) {
1087
1155
  if (this.askUserHandler) {
1088
- return this.askUserHandler(toolCallId, questions, context ? {
1089
- ...context,
1090
- signal: this.signal || context.signal,
1091
- } : undefined);
1156
+ return this.askUserHandler(toolCallId, questions, context
1157
+ ? {
1158
+ ...context,
1159
+ signal: this.signal || context.signal,
1160
+ }
1161
+ : undefined);
1092
1162
  }
1093
1163
  // ask_user is not offered in non-interactive sessions; if the model still
1094
1164
  // emits it (e.g. resumed history or a hallucinated call), report an error
@@ -1168,23 +1238,25 @@ class ArenaToolExecutor {
1168
1238
  catch {
1169
1239
  return {
1170
1240
  status: "error",
1171
- message: `Failed to read workspace file: ${rawPath}`,
1241
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1172
1242
  };
1173
1243
  }
1174
- // 仅精确匹配:曾有空白容错回退,但 T1(b) 回放证实生产没有——case-003
1175
- // 四个 old_text 精确缺失的 edit 生产全部返回 Context not found,而回退
1176
- // 会静默改错文件;全部参考成功样本也都能走精确路径复现。
1177
- // 注意:spec/tools.json 的 edit_file description(与生产逐字一致,受
1178
- // T1(c) 门禁保护)声称支持 fuzzy matching——那是上游自身的描述与行为
1179
- // 不一致,按实测行为对齐,描述不改。见 TRAJECTORY_FIDELITY.md §5。
1180
- if (!source.includes(oldText)) {
1244
+ // Agent Arena replaces the first match and tolerates multiline whitespace
1245
+ // drift. Code Arena deliberately uses a separate unique-match policy.
1246
+ const edited = replaceFirstText(source, oldText, newText, {
1247
+ whitespaceInsensitiveMultiline: true,
1248
+ });
1249
+ if (edited === undefined) {
1181
1250
  return {
1182
1251
  status: "error",
1183
1252
  message: "Context not found. Read the file to verify the text exists.",
1184
1253
  };
1185
1254
  }
1186
1255
  try {
1187
- await writeFile(target, source.replace(oldText, newText), "utf8");
1256
+ // Replacement text is literal. String.replace(string, string) expands
1257
+ // $&, $`, $' and $$ sequences and therefore corrupts JavaScript/jQuery
1258
+ // snippets observed in production trajectories.
1259
+ await writeFile(target, edited, "utf8");
1188
1260
  }
1189
1261
  catch {
1190
1262
  return {
@@ -1195,10 +1267,11 @@ class ArenaToolExecutor {
1195
1267
  return { status: "success", message: `Edited ${rawPath}.` };
1196
1268
  }
1197
1269
  async readWorkspaceFile(rawPath, toolCallId) {
1198
- const target = await this.confinedPath(rawPath);
1270
+ let target;
1199
1271
  let fileSize;
1200
1272
  let sample;
1201
1273
  try {
1274
+ target = await this.confinedPath(rawPath);
1202
1275
  const metadata = await stat(target);
1203
1276
  if (!metadata.isFile())
1204
1277
  throw new Error("not a regular file");
@@ -1215,23 +1288,13 @@ class ArenaToolExecutor {
1215
1288
  }
1216
1289
  }
1217
1290
  catch {
1218
- // 逐字对齐生产错误文案,路径按调用方原样回显。
1219
1291
  return {
1220
1292
  status: "error",
1221
- message: `Failed to read workspace file: ${rawPath}`,
1293
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1222
1294
  };
1223
1295
  }
1224
1296
  const extension = path.extname(target).toLowerCase();
1225
1297
  const kind = workspaceFileKind(target, sample);
1226
- if (kind === "text" && fileSize > textFileByteLimit) {
1227
- return {
1228
- status: "error",
1229
- message: `File content (${displayByteSize(fileSize)}) exceeds the maximum allowed size ` +
1230
- `(${displayByteSize(textFileByteLimit)}). Use bash with sed/head/tail to read ` +
1231
- "specific portions of the file, or grep/rg to search for specific content " +
1232
- "instead of reading the whole file.",
1233
- };
1234
- }
1235
1298
  if (kind === "image" && fileSize > 20 * 1024 * 1024) {
1236
1299
  throw new Error(`image exceeds 20 MB: ${rawPath}`);
1237
1300
  }
@@ -1258,7 +1321,7 @@ class ArenaToolExecutor {
1258
1321
  catch {
1259
1322
  return {
1260
1323
  status: "error",
1261
- message: `Failed to read workspace file: ${rawPath}`,
1324
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1262
1325
  };
1263
1326
  }
1264
1327
  if (isStructuredDocument(target)) {
@@ -1286,12 +1349,13 @@ class ArenaToolExecutor {
1286
1349
  return result;
1287
1350
  }
1288
1351
  if (kind === "text") {
1289
- const content = decodeText(data);
1352
+ const text = readTextResult(data);
1290
1353
  return {
1291
1354
  kind: "text",
1292
- lines: content.split("\n").length,
1293
- size: Buffer.byteLength(content, "utf8"),
1294
- content,
1355
+ lines: data.reduce((count, byte) => count + (byte === 0x0a ? 1 : 0), 1),
1356
+ size: data.length,
1357
+ content: text.content,
1358
+ ...(text.truncated ? { truncated: true } : {}),
1295
1359
  };
1296
1360
  }
1297
1361
  const mimeType = mime.getType(target) ||
@@ -1341,11 +1405,22 @@ class ArenaToolExecutor {
1341
1405
  };
1342
1406
  }
1343
1407
  async writeWorkspaceFile(rawPath, content) {
1344
- let target;
1345
1408
  try {
1346
- target = await this.confinedPath(rawPath);
1409
+ const target = await this.confinedPath(rawPath);
1347
1410
  mkdirSync(path.dirname(target), { recursive: true });
1348
- await writeFile(target, content, "utf8");
1411
+ try {
1412
+ await writeFile(target, content, "utf8");
1413
+ }
1414
+ catch (error) {
1415
+ const code = error.code;
1416
+ if (!["EACCES", "EPERM"].includes(code || "") || !privilegedFileCreationEnabled({
1417
+ platform: process.platform, workspace: this.workspaceReal,
1418
+ docker: existsSync("/.dockerenv"), flag: process.env.ARENA_SANDBOX_PRIVILEGED_FILE_CREATION,
1419
+ uid: process.getuid?.(),
1420
+ }))
1421
+ throw error;
1422
+ await createSandboxFile(target, content);
1423
+ }
1349
1424
  }
1350
1425
  catch {
1351
1426
  return {
@@ -1368,18 +1443,21 @@ class ArenaToolExecutor {
1368
1443
  const slug = query
1369
1444
  .toLowerCase()
1370
1445
  .replace(/[^a-z0-9]+/g, "-")
1371
- .slice(0, 40);
1446
+ .replace(/^-+|-+$/g, "")
1447
+ .slice(0, 40) || "results";
1372
1448
  for (const value of results) {
1373
1449
  if (saved.length >= count)
1374
1450
  break;
1375
1451
  const item = record(value);
1376
1452
  const properties = record(item.properties);
1377
1453
  const thumbnail = record(item.thumbnail);
1378
- const sourceUrl = stringValue(properties.url) || stringValue(thumbnail.src);
1379
- const thumbnailUrl = stringValue(thumbnail.src) || sourceUrl;
1380
- if (!sourceUrl)
1454
+ // Brave's result URL is the source page; properties.url is an image asset.
1455
+ const sourceUrl = stringValue(item.url);
1456
+ const imageUrl = stringValue(properties.url) || stringValue(thumbnail.src);
1457
+ const thumbnailUrl = stringValue(thumbnail.src) || imageUrl;
1458
+ if (!imageUrl)
1381
1459
  continue;
1382
- const downloadUrls = [...new Set([sourceUrl, thumbnailUrl])];
1460
+ const downloadUrls = [...new Set([imageUrl, thumbnailUrl])];
1383
1461
  for (const downloadUrl of downloadUrls) {
1384
1462
  try {
1385
1463
  const imageResponse = await fetch(downloadUrl, {
@@ -1394,7 +1472,9 @@ class ArenaToolExecutor {
1394
1472
  .split(";")[0]
1395
1473
  ?.trim()
1396
1474
  .toLowerCase();
1397
- const extension = Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
1475
+ const extension = normalizedType === "image/jpeg"
1476
+ ? ".jpg"
1477
+ : Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
1398
1478
  const target = await this.confinedPath(`image-search/${slug}-${saved.length + 1}${extension}`);
1399
1479
  mkdirSync(path.dirname(target), { recursive: true });
1400
1480
  await writeFile(target, await responseBytes(imageResponse));
@@ -1475,7 +1555,7 @@ class ArenaToolExecutor {
1475
1555
  await writeFile(target, await generatedImageBytesForExtension(generated.bytes, generated.mimeType, extension));
1476
1556
  return {
1477
1557
  status: "success",
1478
- file_path: this.relativePath(target),
1558
+ file_path: rawPath,
1479
1559
  };
1480
1560
  }
1481
1561
  async generateSpeech(rawPath, text, voiceId, language) {
@@ -1511,7 +1591,7 @@ class ArenaToolExecutor {
1511
1591
  }));
1512
1592
  return {
1513
1593
  status: "success",
1514
- file_path: this.relativePath(target),
1594
+ file_path: rawPath,
1515
1595
  };
1516
1596
  }
1517
1597
  }