agentlas 0.5.2 → 0.5.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 (40) hide show
  1. package/README.md +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +109 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +120 -12
  10. package/engine/agentlas-input.cjs +116 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +99 -45
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +348 -23
  18. package/engine/agentlas.cjs +2742 -338
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/run-api-regression.cjs +322 -0
  33. package/test/runtime-env-protection.cjs +45 -0
  34. package/test/semver-precedence.cjs +39 -0
  35. package/test/smoke.sh +19 -0
  36. package/test/sqlite-driver-probe.cjs +22 -0
  37. package/test/terminal-ui-regression.cjs +454 -0
  38. package/test/timeout-regression.cjs +218 -0
  39. package/test/tool-workspace-boundary.cjs +165 -0
  40. package/test/update-safety.cjs +376 -0
@@ -16,6 +16,7 @@ const caps = require("./agentlas-capabilities.cjs");
16
16
  const input = require("./agentlas-input.cjs");
17
17
  const i18n = require("./agentlas-i18n.cjs");
18
18
  const style = require("./agentlas-style.cjs");
19
+ const permissions = require("./agentlas-permissions.cjs");
19
20
 
20
21
  function runtimeLabel(rt) {
21
22
  if (!rt) return "(none)";
@@ -56,6 +57,8 @@ function makeMemoryGuard(ui, heading) {
56
57
  };
57
58
  return {
58
59
  c: ui.c,
60
+ lang: ui.lang,
61
+ t: (...a) => ui.t(...a),
59
62
  streamStart: () => ui.streamStart(),
60
63
  streamDelta: (t) => {
61
64
  if (cut) {
@@ -89,6 +92,9 @@ function makeMemoryGuard(ui, heading) {
89
92
  ok: (...a) => ui.ok(...a),
90
93
  cost: (...a) => ui.cost(...a),
91
94
  line: (...a) => ui.line(...a),
95
+ applyTaskTool: (...a) => ui.applyTaskTool(...a),
96
+ applyTaskResult: (...a) => ui.applyTaskResult(...a),
97
+ replaceTasks: (...a) => ui.replaceTasks(...a),
92
98
  };
93
99
  }
94
100
 
@@ -111,10 +117,13 @@ function makeStyleGuard(ui) {
111
117
  };
112
118
  return {
113
119
  c: ui.c,
120
+ lang: ui.lang,
121
+ t: (...a) => ui.t(...a),
114
122
  streamStart: () => {
115
123
  buf = "";
116
124
  inCode = false;
117
- ui.streamStart();
125
+ // This guard emits complete lines, so the persistent turn footer can safely stay visible.
126
+ ui.streamStart(true);
118
127
  },
119
128
  streamDelta: (text) => {
120
129
  if (!text) return;
@@ -143,6 +152,9 @@ function makeStyleGuard(ui) {
143
152
  cost: (...a) => ui.cost(...a),
144
153
  line: (...a) => ui.line(...a),
145
154
  stopSpinner: (...a) => ui.stopSpinner(...a),
155
+ applyTaskTool: (...a) => ui.applyTaskTool(...a),
156
+ applyTaskResult: (...a) => ui.applyTaskResult(...a),
157
+ replaceTasks: (...a) => ui.replaceTasks(...a),
146
158
  };
147
159
  }
148
160
 
@@ -157,7 +169,7 @@ function startRepl(opts) {
157
169
  const state = {
158
170
  subject: opts.subject || null,
159
171
  runtime: opts.runtime,
160
- permission: opts.permission || "write",
172
+ permission: opts.permission == null ? "write" : permissions.normalize(opts.permission),
161
173
  cwd: opts.cwd,
162
174
  history: [],
163
175
  native: {}, // kind → { id }
@@ -197,6 +209,7 @@ function startRepl(opts) {
197
209
  let closed = false;
198
210
  let currentAbort = null;
199
211
  let idleExitArmedUntil = 0;
212
+ const permissionCycle = permissions.createCycleController();
200
213
  rl.on("close", () => {
201
214
  if (handoff) return; // intentionally closed to hand stdin to the raw-mode composer
202
215
  closed = true;
@@ -235,6 +248,19 @@ function startRepl(opts) {
235
248
  return { projectPath: state.projectPath, agentId: state.subject && state.subject.id, permission: state.permission, cwd: state.cwd, lang: ui.lang };
236
249
  }
237
250
 
251
+ function setPermission(value, options = {}) {
252
+ const notify = options.notify !== false;
253
+ const persist = options.persist !== false;
254
+ const level = permissions.normalize(value);
255
+ state.permission = level;
256
+ if (persist) {
257
+ prefs.permission = level;
258
+ if (opts.savePrefs) opts.savePrefs(prefs);
259
+ }
260
+ if (notify) ui.ok(ui.t("permSet", level));
261
+ return level;
262
+ }
263
+
238
264
  // Session usage ledger — accumulate per runtime label (host advantage: no single-model CLI can show this).
239
265
  function recordCost(label, usage) {
240
266
  const e = state.cost[label] || (state.cost[label] = { turns: 0, in: 0, out: 0, cost: 0, ms: 0 });
@@ -270,22 +296,27 @@ function startRepl(opts) {
270
296
  // ── run one turn ──
271
297
  async function runTurn(prompt, runOptions = {}) {
272
298
  busy = true;
273
- ui.beginTurn(); // 라이브 경과시간 스피너의 턴 시작점
274
299
  currentAbort = new AbortController();
275
300
  const signal = currentAbort.signal;
276
- const recordHistoryEntry = !runOptions.side;
277
- const targetLang = H.detectResponseLanguage ? H.detectResponseLanguage(prompt, ui.lang) : ui.lang;
278
- const ctx = { ...ctxNow(), lang: targetLang, uiLang: ui.lang };
279
- const rt = state.runtime;
280
- const costLabel = runtimeLabel(rt);
281
- const runEnv = H.buildChildEnv ? await H.buildChildEnv(db, { ...ctx, cwd: state.cwd }) : process.env;
282
- Object.assign(process.env, runEnv);
283
- ui._lastUsage = null;
284
- const assistantUi = makeStyleGuard(ui);
285
- const thinkingText = i18n.t(targetLang, "thinkingWith", costLabel);
286
- ui.info(thinkingText);
287
- ui.status(thinkingText);
288
301
  try {
302
+ ui.beginTurn({
303
+ ...composerMeta(),
304
+ onInterrupt: () => {
305
+ if (!currentAbort || currentAbort.signal.aborted) return;
306
+ currentAbort.abort();
307
+ ui.warn(ui.t("interrupted"));
308
+ },
309
+ }); // 작업 중에도 composer/status bar와 실제 runtime task 목록을 화면 하단에 유지
310
+ const recordHistoryEntry = !runOptions.side;
311
+ const targetLang = H.detectResponseLanguage ? H.detectResponseLanguage(prompt, ui.lang) : ui.lang;
312
+ const ctx = { ...ctxNow(), lang: targetLang, uiLang: ui.lang };
313
+ const rt = state.runtime;
314
+ const costLabel = runtimeLabel(rt);
315
+ const runEnv = H.buildChildEnv ? await H.buildChildEnv(db, { ...ctx, cwd: state.cwd }) : process.env;
316
+ ui._lastUsage = null;
317
+ const assistantUi = makeStyleGuard(ui);
318
+ const thinkingText = i18n.t(targetLang, "thinkingWith", costLabel);
319
+ ui.status(thinkingText);
289
320
  if (rt.mode === "cli") {
290
321
  const bin = H.which(H.RUNTIME_BIN[rt.kind]) || H.RUNTIME_BIN[rt.kind];
291
322
  const session = state.native[rt.kind] || (state.native[rt.kind] = {});
@@ -305,7 +336,7 @@ function startRepl(opts) {
305
336
  model: rt.model || null, // /model (claude --model, codex -m, gemini -m)
306
337
  effort: state.effort || null, // /effort (codex reasoning effort, claude think-keyword)
307
338
  mcpServers:
308
- (state.permission === "write" || state.permission === "full") && H.mcpServers
339
+ state.permission === "full" && H.mcpServers
309
340
  ? H.mcpServers(db).filter((s) => s.enabled && s.transport === "stdio")
310
341
  : [],
311
342
  env: runEnv,
@@ -341,7 +372,7 @@ function startRepl(opts) {
341
372
  apiKey,
342
373
  system: sys,
343
374
  messages,
344
- ctx,
375
+ ctx: { ...ctx, env: runEnv },
345
376
  ui: guard,
346
377
  signal,
347
378
  });
@@ -551,8 +582,8 @@ function startRepl(opts) {
551
582
 
552
583
  function printSlashSkills() {
553
584
  ui.line("");
554
- ui.rule("Skills");
555
- for (const entry of input.slashCommandEntries()) {
585
+ ui.rule(ui.t("skills.title"));
586
+ for (const entry of input.slashCommandEntries(ui.lang)) {
556
587
  const tag = entry.category ? ui.c.faint(entry.category.padEnd(10)) : "";
557
588
  ui.line(" " + ui.c.emerald(entry.command.padEnd(18)) + tag + ui.c.dim(entry.description));
558
589
  if (!entry.aliasOf && entry.usage) ui.line(" " + ui.c.faint(" ".repeat(18) + entry.usage));
@@ -561,14 +592,10 @@ function startRepl(opts) {
561
592
 
562
593
  function printPermissions() {
563
594
  ui.line("");
564
- ui.rule("Permissions");
565
- ui.line(" " + ui.c.faint("Current") + " " + ui.c.emerald(state.permission));
566
- const rows = [
567
- ["read", "inspect files and answer; no file writes or shell automation"],
568
- ["write", "read plus create/edit files in the current work area"],
569
- ["full", "write plus shell commands and external local automation"],
570
- ];
571
- for (const [level, description] of rows) {
595
+ ui.rule(ui.t("permissions.title"));
596
+ ui.line(" " + ui.c.faint(ui.t("permissions.current")) + " " + ui.c.emerald(state.permission));
597
+ for (const level of permissions.LEVELS) {
598
+ const description = permissions.copy(level, ui.lang).description;
572
599
  const mark = level === state.permission ? "› " : " ";
573
600
  ui.line(" " + ui.c.emerald((mark + level).padEnd(10)) + ui.c.dim(description));
574
601
  }
@@ -633,7 +660,8 @@ function startRepl(opts) {
633
660
  ui.tool("$ " + cmd);
634
661
  const r = spawnSync("bash", ["-lc", cmd], { cwd: state.cwd, encoding: "utf8", timeout: 120000, maxBuffer: 8 * 1024 * 1024 });
635
662
  const out = ((r.stdout || "") + (r.stderr || "")).trim();
636
- ui.toolResult(out || ("exit " + (r.status == null ? "?" : r.status)), r.status === 0 || r.status == null);
663
+ // `!command` is explicit user output, unlike autonomous runtime traces: keep it inspectable.
664
+ ui.toolResult(out || ("exit " + (r.status == null ? "?" : r.status)), r.status === 0 || r.status == null, { verbose: true });
637
665
  }
638
666
 
639
667
  // @path — inline the contents of mentioned files into the prompt as fenced context.
@@ -801,16 +829,14 @@ function startRepl(opts) {
801
829
  return true;
802
830
  }
803
831
  if (!["read", "write", "full"].includes(p)) return ui.warn(ui.t("permUsage")), true;
804
- state.permission = p;
805
- ui.ok(ui.t("permSet", p));
832
+ setPermission(p);
806
833
  return true;
807
834
  }
808
835
  case "permissions":
809
836
  if (arg) {
810
837
  const p = (arg || "").toLowerCase();
811
838
  if (!["read", "write", "full"].includes(p)) return ui.warn(ui.t("permUsage")), true;
812
- state.permission = p;
813
- ui.ok(ui.t("permSet", p));
839
+ setPermission(p);
814
840
  } else {
815
841
  printPermissions();
816
842
  }
@@ -913,10 +939,7 @@ function startRepl(opts) {
913
939
  const servers = H.mcpServers ? H.mcpServers(db) : [];
914
940
  ui.line("");
915
941
  ui.rule("MCP");
916
- if (!servers.length) {
917
- ui.info(ui.t("mcp.none"));
918
- return true;
919
- }
942
+ ui.line(" " + ui.c.emerald(ui.t("mcp.playwright").padEnd(22)) + ui.c.blue("stdio ") + ui.c.green("on ") + ui.c.dim(" " + ui.t("mcp.fullOnly")));
920
943
  for (const s of servers) {
921
944
  let envKeys = [];
922
945
  try { envKeys = JSON.parse(s.env_keys_json || "[]"); } catch { /* ignore */ }
@@ -925,7 +948,7 @@ function startRepl(opts) {
925
948
  const envStr = envKeys.length ? envKeys.join(", ") : "no key";
926
949
  ui.line(" " + ui.c.emerald(String(name).padEnd(22)) + ui.c.blue(String(s.transport || "").padEnd(7)) + on + ui.c.dim(" " + envStr));
927
950
  }
928
- const wired = servers.filter((s) => s.enabled && s.transport === "stdio").length + 1; // +1 = playwright(항상)
951
+ const wired = servers.filter((s) => s.enabled && s.transport === "stdio").length + 1; // +1 = full-only Playwright
929
952
  ui.line(" " + ui.c.faint(ui.t("mcp.wired", String(wired))));
930
953
  ui.line(" " + ui.c.faint(ui.t("mcp.usage")));
931
954
  return true;
@@ -1129,21 +1152,50 @@ function startRepl(opts) {
1129
1152
  }
1130
1153
 
1131
1154
  // ── composer (raw-mode bottom box) main loop ──
1132
- function composerStatus() {
1155
+ function composerMeta() {
1133
1156
  const rt = runtimeLabel(state.runtime);
1134
1157
  const subj = state.subject ? state.subject.label : ui.t("composer.autoroute");
1135
1158
  const eff = state.effort ? " · " + state.effort : "";
1136
- return `${rt} · ${state.permission}${eff} · ${subj} · ${ui.t("composer.hint")}`;
1159
+ const permissionLabel = permissions.copy(state.permission, ui.lang).label;
1160
+ return {
1161
+ lang: ui.lang,
1162
+ permission: state.permission,
1163
+ permissionLabel,
1164
+ status: `${rt}${eff} · ${subj} · ${ui.t("permCycleHint")} · ${ui.t("composer.hint")} · ↑↓ history`,
1165
+ onCyclePermission: () => {
1166
+ const cycle = permissionCycle.step(state.permission);
1167
+ if (cycle.armed) {
1168
+ return {
1169
+ ...composerMeta(),
1170
+ confirmation: ui.t("permFullArm"),
1171
+ confirmationTone: "danger",
1172
+ };
1173
+ }
1174
+ const level = setPermission(cycle.level, { notify: false, persist: false });
1175
+ return {
1176
+ ...composerMeta(),
1177
+ confirmation: cycle.enteredFull
1178
+ ? ui.t("permFullConfirm")
1179
+ : ui.t("permCycleConfirm", permissions.copy(level, ui.lang).label),
1180
+ confirmationTone: cycle.enteredFull ? "danger" : "normal",
1181
+ };
1182
+ },
1183
+ onPermissionCycleCancel: () => {
1184
+ permissionCycle.cancel();
1185
+ return { ...composerMeta(), confirmation: null, confirmationTone: null };
1186
+ },
1187
+ };
1137
1188
  }
1138
1189
  async function composerLoop() {
1139
1190
  let buffer = "";
1140
1191
  while (!closed) {
1141
1192
  let r;
1142
1193
  try {
1194
+ const meta = composerMeta();
1143
1195
  r = await composer.read({
1144
1196
  glyph: buffer ? "…" : "›",
1145
- status: composerStatus(),
1146
- suggest: (l) => input.slashCommandSuggestions(l),
1197
+ ...meta,
1198
+ suggest: (l) => input.slashCommandSuggestions(l, 12, ui.lang),
1147
1199
  complete: completer,
1148
1200
  });
1149
1201
  } catch (e) {
@@ -1236,10 +1288,10 @@ function startRepl(opts) {
1236
1288
  function printHelp(ui) {
1237
1289
  const c = ui.c;
1238
1290
  ui.line("");
1239
- ui.rule("Help");
1240
- ui.line(" " + c.bold(c.text("Agentlas runs local agents from this terminal, with runtime, permission, files, shell, and history controls.")));
1291
+ ui.rule(ui.t("help.title"));
1292
+ ui.line(" " + c.bold(c.text(ui.t("help.intro"))));
1241
1293
  ui.line("");
1242
- ui.line(" " + c.faint("Commands"));
1294
+ ui.line(" " + c.faint(ui.t("help.commands")));
1243
1295
  const rows = [
1244
1296
  [ui.t("help.talkKey"), ui.t("help.talk")],
1245
1297
  ["/skills", ui.t("help.skills")],
@@ -1297,10 +1349,12 @@ function printKeybindings(ui) {
1297
1349
  ["!cmd", ui.t("help.bang")],
1298
1350
  ["\\ + Enter", ui.t("help.multiline")],
1299
1351
  ["Tab", ui.t("help.tab")],
1352
+ ["Shift-Tab", ui.t("help.shiftTab")],
1353
+ ["Ctrl-T", ui.t("help.ctrlT")],
1300
1354
  ["Up / Down", ui.t("help.arrows")],
1301
1355
  ["Ctrl-C", ui.t("help.ctrlc")],
1302
1356
  ];
1303
1357
  for (const [k, v] of tips) ui.line(" " + c.emerald(k.padEnd(24)) + c.dim(v));
1304
1358
  }
1305
1359
 
1306
- module.exports = { startRepl, runtimeLabel };
1360
+ module.exports = { startRepl, runtimeLabel, makeMemoryGuard, makeStyleGuard };
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+
3
+ /* Normalize only task/todo events emitted by the native runtimes.
4
+ * Ordinary tool activity is intentionally excluded: a command is not a fabricated plan item.
5
+ */
6
+
7
+ function statusOf(value, completed) {
8
+ if (completed === true) return "completed";
9
+ const status = String(value || "pending").trim().toLowerCase().replace(/[ -]+/g, "_");
10
+ if (["completed", "complete", "done", "success", "succeeded"].includes(status)) return "completed";
11
+ if (["in_progress", "active", "running", "started"].includes(status)) return "in_progress";
12
+ if (["failed", "error", "blocked", "cancelled", "canceled"].includes(status)) return "failed";
13
+ return "pending";
14
+ }
15
+
16
+ function listFrom(payload) {
17
+ if (Array.isArray(payload)) return payload;
18
+ if (!payload || typeof payload !== "object") return [];
19
+ for (const key of ["todos", "items", "tasks"]) {
20
+ if (Array.isArray(payload[key])) return payload[key];
21
+ }
22
+ return [];
23
+ }
24
+
25
+ function hasExplicitList(payload) {
26
+ if (Array.isArray(payload)) return true;
27
+ if (!payload || typeof payload !== "object") return false;
28
+ return ["todos", "items", "tasks"].some((key) => Array.isArray(payload[key]));
29
+ }
30
+
31
+ function sanitizeLabel(value, max = 500) {
32
+ let text = String(value || "");
33
+ // Runtime task text is untrusted terminal content. Strip OSC/CSI/escape controls,
34
+ // flatten line breaks, then cap it before it reaches footer row accounting.
35
+ text = text
36
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
37
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
38
+ .replace(/\x1b./g, "")
39
+ .replace(/[\r\n\t]+/g, " ")
40
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
41
+ .replace(/\s+/g, " ")
42
+ .trim();
43
+ return Array.from(text).slice(0, max).join("");
44
+ }
45
+
46
+ function normalizeTaskList(payload, source = "runtime") {
47
+ return listFrom(payload).flatMap((raw, index) => {
48
+ if (!raw || typeof raw !== "object") return [];
49
+ const label = sanitizeLabel(raw.content || raw.subject || raw.description || raw.text || raw.title || raw.activeForm);
50
+ if (!label) return [];
51
+ return [{
52
+ id: String(raw.id || raw.taskId || `${source}:${index}`),
53
+ label,
54
+ status: statusOf(raw.status, raw.completed),
55
+ source,
56
+ }];
57
+ });
58
+ }
59
+
60
+ function applyTaskTool(current, name, payload, toolId) {
61
+ const tool = String(name || "").toLowerCase();
62
+ if (tool === "todowrite" || tool === "write_todos") {
63
+ return normalizeTaskList(payload, tool);
64
+ }
65
+
66
+ if (tool !== "taskcreate" && tool !== "taskupdate") return current;
67
+
68
+ const next = Array.isArray(current) ? current.map((task) => ({ ...task })) : [];
69
+ if (tool === "taskcreate") {
70
+ const label = sanitizeLabel(payload?.subject || payload?.description || payload?.activeForm);
71
+ if (!label) return next;
72
+ next.push({ id: String(payload?.taskId || toolId || `taskcreate:${next.length}`), label, status: "pending", source: "taskcreate" });
73
+ return next;
74
+ }
75
+ if (tool === "taskupdate") {
76
+ const id = String(payload?.taskId || toolId || "");
77
+ if (!id) return next;
78
+ if (String(payload?.status || "").toLowerCase() === "deleted") return next.filter((task) => task.id !== id);
79
+ const index = next.findIndex((task) => task.id === id);
80
+ const label = sanitizeLabel(payload?.subject || payload?.description || payload?.activeForm || (index >= 0 && next[index].label) || id);
81
+ const task = { id, label, status: statusOf(payload?.status), source: "taskupdate" };
82
+ if (index >= 0) next[index] = { ...next[index], ...task };
83
+ else next.push(task);
84
+ }
85
+ return next;
86
+ }
87
+
88
+ function applyTaskResult(current, name, payload, toolId) {
89
+ const tool = String(name || "").toLowerCase();
90
+ if (!["taskcreate", "taskupdate", "tasklist"].includes(tool)) return current;
91
+ if (!payload || typeof payload !== "object") return current;
92
+ if (tool === "tasklist") {
93
+ return hasExplicitList(payload) ? normalizeTaskList(payload, "tasklist") : current;
94
+ }
95
+ const record = payload.task && typeof payload.task === "object" ? payload.task : payload;
96
+ const id = String(record.id || record.taskId || payload.taskId || "");
97
+ if (!id) return current;
98
+ const next = Array.isArray(current) ? current.map((task) => ({ ...task })) : [];
99
+ const provisional = next.findIndex((task) => task.id === String(toolId || ""));
100
+ const existing = next.findIndex((task) => task.id === id);
101
+ const index = existing >= 0 ? existing : provisional;
102
+ const prior = index >= 0 ? next[index] : null;
103
+ const label = sanitizeLabel(record.content || record.subject || record.description || record.text || record.title || prior?.label || id);
104
+ const task = { id, label, status: statusOf(record.status || prior?.status), source: tool || "task-result" };
105
+ if (index >= 0) next[index] = { ...prior, ...task };
106
+ else next.push(task);
107
+ if (existing >= 0 && provisional >= 0 && provisional !== existing) next.splice(provisional, 1);
108
+ return next;
109
+ }
110
+
111
+ module.exports = { statusOf, sanitizeLabel, normalizeTaskList, applyTaskTool, applyTaskResult };
@@ -13,9 +13,171 @@ const { spawnSync } = require("node:child_process");
13
13
 
14
14
  const PERM_RANK = { read: 0, write: 1, full: 2 };
15
15
 
16
- function resolveIn(cwd, p) {
17
- if (!p) return cwd;
18
- return path.isAbsolute(p) ? p : path.resolve(cwd, p);
16
+ function pathDenied(reason) {
17
+ throw new Error(`workspace path denied: ${reason}`);
18
+ }
19
+
20
+ function contained(root, target) {
21
+ const relative = path.relative(root, target);
22
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
23
+ }
24
+
25
+ function getWorkspaceRoot(cwd) {
26
+ if (typeof cwd !== "string" || cwd.length === 0 || cwd.includes("\0")) {
27
+ pathDenied("working folder is invalid");
28
+ }
29
+ const root = fs.realpathSync(path.resolve(cwd));
30
+ if (!fs.statSync(root).isDirectory()) pathDenied("working folder is not a directory");
31
+ return root;
32
+ }
33
+
34
+ function validateRelativePath(input) {
35
+ if (typeof input !== "string" || input.length === 0 || input.includes("\0")) {
36
+ pathDenied("path must be a non-empty string");
37
+ }
38
+ // Check both dialects. A Windows absolute/UNC path must remain invalid even
39
+ // when a request is prepared or tested on a POSIX host (and vice versa).
40
+ if (
41
+ path.isAbsolute(input) ||
42
+ path.posix.isAbsolute(input) ||
43
+ path.win32.isAbsolute(input) ||
44
+ /^[A-Za-z]:/.test(input)
45
+ ) {
46
+ pathDenied("absolute paths are not allowed");
47
+ }
48
+ // Reject traversal before path.resolve() normalizes it away. This deliberately
49
+ // denies `safe/../file`, not only traversal that currently lands outside.
50
+ if (input.split(/[\\/]+/u).some((segment) => segment === "..")) {
51
+ pathDenied("parent traversal is not allowed");
52
+ }
53
+ return input;
54
+ }
55
+
56
+ function lexicalPath(root, input) {
57
+ const candidate = path.resolve(root, validateRelativePath(input));
58
+ if (!contained(root, candidate)) pathDenied("path leaves the working folder");
59
+ return candidate;
60
+ }
61
+
62
+ function resolveExistingIn(cwd, input) {
63
+ const root = getWorkspaceRoot(cwd);
64
+ const candidate = lexicalPath(root, input);
65
+ const real = fs.realpathSync(candidate);
66
+ if (!contained(root, real)) pathDenied("symbolic link leaves the working folder");
67
+ return real;
68
+ }
69
+
70
+ function resolveWritableIn(cwd, input) {
71
+ const root = getWorkspaceRoot(cwd);
72
+ const candidate = lexicalPath(root, input);
73
+ const missing = [];
74
+ let cursor = candidate;
75
+
76
+ // lstat (rather than existsSync) notices broken symlinks and makes them fail
77
+ // closed. Resolve the nearest existing ancestor before mkdir can have any
78
+ // side effect outside the workspace.
79
+ while (true) {
80
+ try {
81
+ fs.lstatSync(cursor);
82
+ break;
83
+ } catch (error) {
84
+ if (!error || error.code !== "ENOENT") throw error;
85
+ const parent = path.dirname(cursor);
86
+ if (parent === cursor) pathDenied("no existing workspace ancestor");
87
+ missing.unshift(path.basename(cursor));
88
+ cursor = parent;
89
+ }
90
+ }
91
+
92
+ let realAncestor;
93
+ try {
94
+ realAncestor = fs.realpathSync(cursor);
95
+ } catch (error) {
96
+ if (fs.lstatSync(cursor).isSymbolicLink()) pathDenied("symbolic link target is unavailable");
97
+ throw error;
98
+ }
99
+ if (!contained(root, realAncestor)) pathDenied("symbolic link leaves the working folder");
100
+ const ancestorStat = fs.statSync(realAncestor);
101
+ if (missing.length === 0 && !ancestorStat.isFile()) pathDenied("only regular files may be written");
102
+ if (missing.length > 0 && !ancestorStat.isDirectory()) pathDenied("write parent is not a directory");
103
+ const destination = path.join(realAncestor, ...missing);
104
+ if (!contained(root, destination)) pathDenied("path leaves the working folder");
105
+ return destination;
106
+ }
107
+
108
+ function safeOpenFlags() {
109
+ if (process.platform === "win32") return 0;
110
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
111
+ // Avoid blocking forever if a special file is swapped into place between
112
+ // canonicalization and open; fstat below will then reject it.
113
+ const nonBlock = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0;
114
+ return noFollow | nonBlock;
115
+ }
116
+
117
+ function openRegularFile(file, flags, mode) {
118
+ const fd = fs.openSync(file, flags | safeOpenFlags(), mode);
119
+ try {
120
+ if (!fs.fstatSync(fd).isFile()) pathDenied("only regular files are allowed");
121
+ return fd;
122
+ } catch (error) {
123
+ fs.closeSync(fd);
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ function readUtf8File(file) {
129
+ if (!fs.statSync(file).isFile()) pathDenied("only regular files may be read");
130
+ const fd = openRegularFile(file, fs.constants.O_RDONLY);
131
+ try {
132
+ return fs.readFileSync(fd, "utf8");
133
+ } finally {
134
+ fs.closeSync(fd);
135
+ }
136
+ }
137
+
138
+ function writeUtf8File(file, content) {
139
+ // Replace through a fresh inode instead of truncating an existing one. If the
140
+ // workspace entry is a hard link to a file elsewhere, this updates only the
141
+ // workspace path and cannot mutate the other link's inode.
142
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.agentlas-${process.pid}-${crypto.randomUUID()}.tmp`);
143
+ let targetMode = 0o600;
144
+ let targetOwner = null;
145
+ try {
146
+ const existing = fs.statSync(file);
147
+ if (existing.isFile()) {
148
+ targetMode = existing.mode & 0o777;
149
+ targetOwner = { uid: existing.uid, gid: existing.gid };
150
+ }
151
+ } catch (error) {
152
+ if (!error || error.code !== "ENOENT") throw error;
153
+ }
154
+ let fd;
155
+ try {
156
+ fd = openRegularFile(
157
+ temp,
158
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
159
+ 0o600,
160
+ );
161
+ fs.writeFileSync(fd, content, "utf8");
162
+ if (targetOwner) {
163
+ try { fs.fchownSync(fd, targetOwner.uid, targetOwner.gid); } catch { /* best-effort ownership preservation */ }
164
+ }
165
+ try { fs.fchmodSync(fd, targetMode); } catch { /* Windows/best-effort */ }
166
+ try { fs.fsyncSync(fd); } catch { /* best-effort durability */ }
167
+ fs.closeSync(fd);
168
+ fd = null;
169
+ try {
170
+ fs.renameSync(temp, file);
171
+ } catch (error) {
172
+ // Windows does not replace an existing destination with renameSync.
173
+ if (!error || !["EEXIST", "EPERM"].includes(error.code)) throw error;
174
+ fs.rmSync(file, { force: true });
175
+ fs.renameSync(temp, file);
176
+ }
177
+ } finally {
178
+ if (fd != null) fs.closeSync(fd);
179
+ try { fs.rmSync(temp, { force: true }); } catch { /* best-effort cleanup */ }
180
+ }
19
181
  }
20
182
  function truncate(s, n) {
21
183
  s = String(s);
@@ -32,7 +194,7 @@ const TOOLS = [
32
194
  properties: { path: { type: "string", description: "Directory path (default: working folder)" } },
33
195
  },
34
196
  run(args, ctx) {
35
- const dir = resolveIn(ctx.cwd, args.path || ".");
197
+ const dir = resolveExistingIn(ctx.cwd, args.path || ".");
36
198
  const entries = fs.readdirSync(dir, { withFileTypes: true });
37
199
  const lines = entries
38
200
  .slice(0, 400)
@@ -55,8 +217,8 @@ const TOOLS = [
55
217
  required: ["path"],
56
218
  },
57
219
  run(args, ctx) {
58
- const file = resolveIn(ctx.cwd, args.path);
59
- let content = fs.readFileSync(file, "utf8");
220
+ const file = resolveExistingIn(ctx.cwd, args.path);
221
+ let content = readUtf8File(file);
60
222
  if (args.offset || args.limit) {
61
223
  const lines = content.split("\n");
62
224
  const start = Math.max(0, (args.offset || 1) - 1);
@@ -76,10 +238,10 @@ const TOOLS = [
76
238
  required: ["path", "content"],
77
239
  },
78
240
  run(args, ctx) {
79
- const file = resolveIn(ctx.cwd, args.path);
241
+ const file = resolveWritableIn(ctx.cwd, args.path);
80
242
  fs.mkdirSync(path.dirname(file), { recursive: true });
81
243
  const existed = fs.existsSync(file);
82
- fs.writeFileSync(file, args.content, "utf8");
244
+ writeUtf8File(file, args.content);
83
245
  return `${existed ? "overwrote" : "created"} ${file} (${args.content.length} bytes)`;
84
246
  },
85
247
  },
@@ -100,15 +262,15 @@ const TOOLS = [
100
262
  },
101
263
  run(args, ctx) {
102
264
  if (args.old_string === "") throw new Error("old_string must be non-empty");
103
- const file = resolveIn(ctx.cwd, args.path);
104
- const src = fs.readFileSync(file, "utf8");
265
+ const file = resolveExistingIn(ctx.cwd, args.path);
266
+ const src = readUtf8File(file);
105
267
  if (!src.includes(args.old_string)) throw new Error("old_string not found");
106
268
  const count = src.split(args.old_string).length - 1;
107
269
  if (!args.replace_all && count > 1) throw new Error(`old_string occurs ${count}× (use replace_all or add context)`);
108
270
  const out = args.replace_all
109
271
  ? src.split(args.old_string).join(args.new_string)
110
272
  : src.replace(args.old_string, args.new_string);
111
- fs.writeFileSync(file, out, "utf8");
273
+ writeUtf8File(file, out);
112
274
  return `edited ${file} (${count} replacement${count > 1 ? "s" : ""})`;
113
275
  },
114
276
  },
@@ -129,7 +291,7 @@ const TOOLS = [
129
291
  encoding: "utf8",
130
292
  timeout,
131
293
  maxBuffer: 8 * 1024 * 1024,
132
- env: process.env,
294
+ env: ctx.env || process.env,
133
295
  });
134
296
  const parts = [];
135
297
  if (res.stdout) parts.push(res.stdout);