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

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.
@@ -0,0 +1,58 @@
1
+ function fuzzyMatchRanges(source, context) {
2
+ const trimmed = context.trim();
3
+ if (!trimmed)
4
+ return [];
5
+ const escapedTokens = trimmed
6
+ .split(/\s+/)
7
+ .map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
8
+ const matcher = new RegExp(escapedTokens.join("\\s+"), "gu");
9
+ const matches = [...source.matchAll(matcher)];
10
+ const ranges = [];
11
+ for (const match of matches) {
12
+ if (match.index === undefined)
13
+ continue;
14
+ let start = match.index;
15
+ let end = start + match[0].length;
16
+ if (/^\s/.test(context)) {
17
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
18
+ // Production tolerates indentation drift, but does not let a multiline
19
+ // context that starts with whitespace match halfway through a source line.
20
+ if (source.slice(lineStart, start).trim())
21
+ continue;
22
+ start = lineStart;
23
+ }
24
+ if (/\s$/.test(context)) {
25
+ while (end < source.length && /[ \t]/.test(source[end]))
26
+ end += 1;
27
+ }
28
+ ranges.push({ start, end });
29
+ }
30
+ return ranges;
31
+ }
32
+ function replaceText(source, context, replacement, requireUnique, options) {
33
+ const first = source.indexOf(context);
34
+ if (first !== -1) {
35
+ const second = source.indexOf(context, first + context.length);
36
+ if (requireUnique && second !== -1)
37
+ return undefined;
38
+ return (source.slice(0, first) +
39
+ replacement +
40
+ source.slice(first + context.length));
41
+ }
42
+ if (!options.whitespaceInsensitiveMultiline || !context.includes("\n")) {
43
+ return undefined;
44
+ }
45
+ const matches = fuzzyMatchRanges(source, context);
46
+ if (matches.length === 0 || (requireUnique && matches.length !== 1)) {
47
+ return undefined;
48
+ }
49
+ const match = matches[0];
50
+ return source.slice(0, match.start) + replacement + source.slice(match.end);
51
+ }
52
+ export function replaceFirstText(source, context, replacement, options = {}) {
53
+ return replaceText(source, context, replacement, false, options);
54
+ }
55
+ export function replaceUniqueText(source, context, replacement, options = {}) {
56
+ return replaceText(source, context, replacement, true, options);
57
+ }
58
+ //# sourceMappingURL=file-edit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-edit.js","sourceRoot":"","sources":["../src/file-edit.ts"],"names":[],"mappings":"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"}
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,12 +13,13 @@ 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";
24
25
  import { voiceRegistryForWorkspace, } from "./voice-registry.js";
@@ -214,7 +215,7 @@ const visualIntroText = "<arena-system-message>\n" +
214
215
  "treat them as the authentic output of the tool call.\n" +
215
216
  "</arena-system-message>";
216
217
  const bashOutputLimit = 20_000;
217
- const textFileByteLimit = 256 * 1024;
218
+ const textFileOutputByteLimit = 256 * 1024;
218
219
  const toolResultCharacterLimit = 1_000_000;
219
220
  // 中段截断,逐字反推自 3 个 truncated:true 参考样本:头尾各 9987 字符 +
220
221
  // 26 字符标记,总长恰 20000(由 T1(b) 执行回放发现,非首段截断)。
@@ -228,6 +229,24 @@ function capBashOutput(value) {
228
229
  truncated: true,
229
230
  };
230
231
  }
232
+ function readTextResult(data) {
233
+ if (data.length <= textFileOutputByteLimit) {
234
+ return { content: decodeText(data), truncated: false };
235
+ }
236
+ const half = textFileOutputByteLimit / 2;
237
+ const prefixBreak = data.lastIndexOf(0x0a, half);
238
+ const suffixBreak = data.indexOf(0x0a, data.length - half);
239
+ const prefixEnd = prefixBreak === -1 ? half : prefixBreak;
240
+ const suffixStart = suffixBreak === -1 ? data.length - half : suffixBreak + 1;
241
+ const elidedBytes = Math.max(0, suffixStart - prefixEnd);
242
+ const marker = `\n\n[${Math.round(elidedBytes / 1024)} KB elided from the middle of the file]\n\n`;
243
+ return {
244
+ content: decodeText(data.subarray(0, prefixEnd)) +
245
+ marker +
246
+ decodeText(data.subarray(suffixStart)),
247
+ truncated: true,
248
+ };
249
+ }
231
250
  const mimeTypes = {
232
251
  ".aac": "audio/aac",
233
252
  ".bmp": "image/bmp",
@@ -281,8 +300,7 @@ function decodeUtf32(data, littleEndian) {
281
300
  const codePoint = littleEndian
282
301
  ? data.readUInt32LE(index)
283
302
  : data.readUInt32BE(index);
284
- characters.push(codePoint <= 0x10ffff &&
285
- !(codePoint >= 0xd800 && codePoint <= 0xdfff)
303
+ characters.push(codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
286
304
  ? String.fromCodePoint(codePoint)
287
305
  : "\ufffd");
288
306
  }
@@ -560,12 +578,15 @@ class ArenaToolExecutor {
560
578
  config;
561
579
  workspace;
562
580
  workspaceReal;
581
+ tmpReal;
582
+ varTmpReal;
563
583
  validators = new Map();
564
584
  pendingVisualContent = new Map();
565
585
  askUserHandler;
566
586
  imageInputFallbackClient;
567
587
  signal;
568
588
  workspaceContext;
589
+ nativeSandboxPaths;
569
590
  pageReader;
570
591
  routifyPageReader;
571
592
  processManager;
@@ -587,6 +608,13 @@ class ArenaToolExecutor {
587
608
  this.workspace = path.resolve(config.workspace);
588
609
  mkdirSync(this.workspace, { recursive: true });
589
610
  this.workspaceReal = realpathSync(this.workspace);
611
+ // A Linux /home/user workspace is already an OS-level sandbox. Other
612
+ // callers still confine ordinary workspace paths, while /tmp and /var/tmp
613
+ // always refer to the actual system directories seen by Bash.
614
+ this.nativeSandboxPaths =
615
+ process.platform === "linux" && this.workspaceReal === "/home/user";
616
+ this.tmpReal = realpathSync("/tmp");
617
+ this.varTmpReal = realpathSync("/var/tmp");
590
618
  this.processManager = processManagerForWorkspace(this.workspaceReal);
591
619
  this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
592
620
  this.askUserHandler = options.askUserHandler;
@@ -747,10 +775,9 @@ class ArenaToolExecutor {
747
775
  const message = errorMessage(error);
748
776
  const durationMs = Date.now() - startedAt;
749
777
  if (name === "bash") {
750
- const stderr = this.virtualizeBashOutput(message);
751
778
  return {
752
779
  status: "shell_error",
753
- stderr,
780
+ stderr: message,
754
781
  stdout: "",
755
782
  exit_code: null,
756
783
  duration_ms: durationMs,
@@ -892,28 +919,57 @@ class ArenaToolExecutor {
892
919
  if (!raw)
893
920
  throw new Error("path must not be empty");
894
921
  let candidate;
895
- if (raw === "/home/user") {
896
- candidate = this.workspace;
922
+ let confinementRoot = this.workspace;
923
+ let confinementRootReal = this.workspaceReal;
924
+ const expanded = raw === "~"
925
+ ? "/home/user"
926
+ : raw.startsWith("~/")
927
+ ? `/home/user/${raw.slice(2)}`
928
+ : raw;
929
+ const virtualAbsolute = path.posix.resolve("/home/user", expanded);
930
+ let suffix;
931
+ if (virtualAbsolute === "/var/tmp" ||
932
+ virtualAbsolute.startsWith("/var/tmp/")) {
933
+ confinementRoot = "/var/tmp";
934
+ confinementRootReal = this.varTmpReal;
935
+ suffix = virtualAbsolute.slice("/var/tmp".length).replace(/^\/+/, "");
936
+ candidate = path.join(confinementRoot, suffix);
897
937
  }
898
- else if (raw.startsWith("/home/user/")) {
899
- candidate = path.join(this.workspace, raw.slice("/home/user/".length));
938
+ else if (virtualAbsolute === "/tmp" ||
939
+ virtualAbsolute.startsWith("/tmp/")) {
940
+ confinementRoot = "/tmp";
941
+ confinementRootReal = this.tmpReal;
942
+ suffix = virtualAbsolute.slice("/tmp".length).replace(/^\/+/, "");
943
+ candidate = path.join(confinementRoot, suffix);
900
944
  }
901
- else if (!this.workspaceContext && raw === "~") {
945
+ else if (this.nativeSandboxPaths && path.posix.isAbsolute(raw)) {
946
+ confinementRoot = "/";
947
+ confinementRootReal = "/";
948
+ candidate = raw;
949
+ }
950
+ else if (!this.workspaceContext) {
951
+ if (virtualAbsolute === "/home/user" ||
952
+ virtualAbsolute.startsWith("/home/user/")) {
953
+ suffix = virtualAbsolute.slice("/home/user".length).replace(/^\/+/, "");
954
+ }
955
+ else {
956
+ throw new Error(`path is outside the workspace: ${raw}`);
957
+ }
958
+ candidate = path.join(confinementRoot, suffix);
959
+ }
960
+ else if (raw === "/home/user") {
902
961
  candidate = this.workspace;
903
962
  }
904
- else if (!this.workspaceContext && raw.startsWith("~/")) {
905
- candidate = path.join(this.workspace, raw.slice(2));
963
+ else if (raw.startsWith("/home/user/")) {
964
+ candidate = path.join(this.workspace, raw.slice("/home/user/".length));
906
965
  }
907
966
  else {
908
- const defaultCwd = this.workspaceContext
909
- ? path.resolve(this.workspaceContext.cwd)
910
- : this.workspace;
967
+ const defaultCwd = path.resolve(this.workspaceContext.cwd);
911
968
  candidate = path.isAbsolute(raw) ? raw : path.join(defaultCwd, raw);
912
969
  }
913
970
  const absolute = path.resolve(candidate);
914
- const lexicalRelative = path.relative(this.workspace, absolute);
915
- if (lexicalRelative.startsWith("..") ||
916
- path.isAbsolute(lexicalRelative)) {
971
+ const lexicalRelative = path.relative(confinementRoot, absolute);
972
+ if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
917
973
  throw new Error(`path is outside the workspace: ${raw}`);
918
974
  }
919
975
  let ancestor = absolute;
@@ -925,26 +981,36 @@ class ArenaToolExecutor {
925
981
  }
926
982
  const ancestorReal = await realpath(ancestor);
927
983
  const resolved = path.resolve(ancestorReal, path.relative(ancestor, absolute));
928
- const realRelative = path.relative(this.workspaceReal, resolved);
984
+ const realRelative = path.relative(confinementRootReal, resolved);
929
985
  if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
930
986
  throw new Error(`path is outside the workspace: ${raw}`);
931
987
  }
932
988
  return resolved;
933
989
  }
934
990
  relativePath(target) {
991
+ for (const [root, virtualRoot] of [
992
+ [this.varTmpReal, "/var/tmp"],
993
+ [this.tmpReal, "/tmp"],
994
+ ]) {
995
+ const relative = path.relative(root, target);
996
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
997
+ const suffix = relative.split(path.sep).join("/");
998
+ return suffix ? `${virtualRoot}/${suffix}` : virtualRoot;
999
+ }
1000
+ }
935
1001
  return path.relative(this.workspaceReal, target).split(path.sep).join("/");
936
1002
  }
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");
1003
+ displayWorkspacePath(rawPath) {
1004
+ if (!this.workspaceContext) {
1005
+ if (rawPath === "/home/user" || rawPath === "~")
1006
+ return ".";
1007
+ if (rawPath.startsWith("/home/user/")) {
1008
+ return rawPath.slice("/home/user/".length);
1009
+ }
1010
+ if (rawPath.startsWith("~/"))
1011
+ return rawPath.slice(2);
946
1012
  }
947
- return virtualized;
1013
+ return rawPath;
948
1014
  }
949
1015
  sandboxEnvironment() {
950
1016
  const env = {
@@ -962,17 +1028,12 @@ class ArenaToolExecutor {
962
1028
  if (!(await stat(workdir)).isDirectory()) {
963
1029
  throw new Error(`cwd is not a directory: ${cwd}`);
964
1030
  }
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
1031
  return new Promise((resolve, reject) => {
971
1032
  const env = this.sandboxEnvironment();
972
1033
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
973
1034
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
974
1035
  // 必须杀整组,并在宽限期后强制结算兜底。
975
- const child = spawn("/bin/bash", ["-lc", translatedCommand], {
1036
+ const child = spawn("/bin/bash", ["-lc", command], {
976
1037
  cwd: workdir,
977
1038
  env,
978
1039
  stdio: ["ignore", "pipe", "pipe"],
@@ -1017,8 +1078,8 @@ class ArenaToolExecutor {
1017
1078
  reject(this.signal.reason || new Error("operation cancelled"));
1018
1079
  return;
1019
1080
  }
1020
- const out = capBashOutput(this.virtualizeBashOutput(stdout));
1021
- const err = capBashOutput(this.virtualizeBashOutput(stderr));
1081
+ const out = capBashOutput(stdout);
1082
+ const err = capBashOutput(stderr);
1022
1083
  resolve({
1023
1084
  status: timedOut ? "timeout" : signal ? "killed" : "completed",
1024
1085
  stderr: err.text,
@@ -1071,12 +1132,9 @@ class ArenaToolExecutor {
1071
1132
  if (!(await stat(workdir)).isDirectory()) {
1072
1133
  throw new Error(`cwd is not a directory: ${cwd}`);
1073
1134
  }
1074
- const translatedCommand = this.workspaceContext
1075
- ? command
1076
- : command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
1077
1135
  return this.processManager.start({
1078
1136
  name,
1079
- command: translatedCommand,
1137
+ command,
1080
1138
  cwd: workdir,
1081
1139
  startupWaitSeconds,
1082
1140
  env: this.sandboxEnvironment(),
@@ -1085,10 +1143,12 @@ class ArenaToolExecutor {
1085
1143
  }
1086
1144
  async askUser(questions, toolCallId, context) {
1087
1145
  if (this.askUserHandler) {
1088
- return this.askUserHandler(toolCallId, questions, context ? {
1089
- ...context,
1090
- signal: this.signal || context.signal,
1091
- } : undefined);
1146
+ return this.askUserHandler(toolCallId, questions, context
1147
+ ? {
1148
+ ...context,
1149
+ signal: this.signal || context.signal,
1150
+ }
1151
+ : undefined);
1092
1152
  }
1093
1153
  // ask_user is not offered in non-interactive sessions; if the model still
1094
1154
  // emits it (e.g. resumed history or a hallucinated call), report an error
@@ -1168,23 +1228,25 @@ class ArenaToolExecutor {
1168
1228
  catch {
1169
1229
  return {
1170
1230
  status: "error",
1171
- message: `Failed to read workspace file: ${rawPath}`,
1231
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1172
1232
  };
1173
1233
  }
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)) {
1234
+ // Agent Arena replaces the first match and tolerates multiline whitespace
1235
+ // drift. Code Arena deliberately uses a separate unique-match policy.
1236
+ const edited = replaceFirstText(source, oldText, newText, {
1237
+ whitespaceInsensitiveMultiline: true,
1238
+ });
1239
+ if (edited === undefined) {
1181
1240
  return {
1182
1241
  status: "error",
1183
1242
  message: "Context not found. Read the file to verify the text exists.",
1184
1243
  };
1185
1244
  }
1186
1245
  try {
1187
- await writeFile(target, source.replace(oldText, newText), "utf8");
1246
+ // Replacement text is literal. String.replace(string, string) expands
1247
+ // $&, $`, $' and $$ sequences and therefore corrupts JavaScript/jQuery
1248
+ // snippets observed in production trajectories.
1249
+ await writeFile(target, edited, "utf8");
1188
1250
  }
1189
1251
  catch {
1190
1252
  return {
@@ -1195,10 +1257,11 @@ class ArenaToolExecutor {
1195
1257
  return { status: "success", message: `Edited ${rawPath}.` };
1196
1258
  }
1197
1259
  async readWorkspaceFile(rawPath, toolCallId) {
1198
- const target = await this.confinedPath(rawPath);
1260
+ let target;
1199
1261
  let fileSize;
1200
1262
  let sample;
1201
1263
  try {
1264
+ target = await this.confinedPath(rawPath);
1202
1265
  const metadata = await stat(target);
1203
1266
  if (!metadata.isFile())
1204
1267
  throw new Error("not a regular file");
@@ -1215,23 +1278,13 @@ class ArenaToolExecutor {
1215
1278
  }
1216
1279
  }
1217
1280
  catch {
1218
- // 逐字对齐生产错误文案,路径按调用方原样回显。
1219
1281
  return {
1220
1282
  status: "error",
1221
- message: `Failed to read workspace file: ${rawPath}`,
1283
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1222
1284
  };
1223
1285
  }
1224
1286
  const extension = path.extname(target).toLowerCase();
1225
1287
  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
1288
  if (kind === "image" && fileSize > 20 * 1024 * 1024) {
1236
1289
  throw new Error(`image exceeds 20 MB: ${rawPath}`);
1237
1290
  }
@@ -1258,7 +1311,7 @@ class ArenaToolExecutor {
1258
1311
  catch {
1259
1312
  return {
1260
1313
  status: "error",
1261
- message: `Failed to read workspace file: ${rawPath}`,
1314
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1262
1315
  };
1263
1316
  }
1264
1317
  if (isStructuredDocument(target)) {
@@ -1286,12 +1339,13 @@ class ArenaToolExecutor {
1286
1339
  return result;
1287
1340
  }
1288
1341
  if (kind === "text") {
1289
- const content = decodeText(data);
1342
+ const text = readTextResult(data);
1290
1343
  return {
1291
1344
  kind: "text",
1292
- lines: content.split("\n").length,
1293
- size: Buffer.byteLength(content, "utf8"),
1294
- content,
1345
+ lines: data.reduce((count, byte) => count + (byte === 0x0a ? 1 : 0), 1),
1346
+ size: data.length,
1347
+ content: text.content,
1348
+ ...(text.truncated ? { truncated: true } : {}),
1295
1349
  };
1296
1350
  }
1297
1351
  const mimeType = mime.getType(target) ||
@@ -1341,9 +1395,8 @@ class ArenaToolExecutor {
1341
1395
  };
1342
1396
  }
1343
1397
  async writeWorkspaceFile(rawPath, content) {
1344
- let target;
1345
1398
  try {
1346
- target = await this.confinedPath(rawPath);
1399
+ const target = await this.confinedPath(rawPath);
1347
1400
  mkdirSync(path.dirname(target), { recursive: true });
1348
1401
  await writeFile(target, content, "utf8");
1349
1402
  }