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.
- package/README.md +48 -6
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +40 -56
- package/engine/agentlas-capabilities.cjs +3 -0
- package/engine/agentlas-cloud-runtime.cjs +65 -11
- package/engine/agentlas-composer.cjs +109 -44
- package/engine/agentlas-doctor.cjs +40 -12
- package/engine/agentlas-i18n.cjs +120 -12
- package/engine/agentlas-input.cjs +116 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +315 -45
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +99 -45
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +348 -23
- package/engine/agentlas.cjs +2742 -338
- package/engine/semver.cjs +64 -0
- package/package.json +1 -1
- package/test/bootstrap-race.cjs +47 -0
- package/test/capture-runtime-guard.cjs +122 -0
- package/test/cloud-asset-restore.cjs +423 -0
- package/test/cloud-cas-client.cjs +333 -0
- package/test/cloud-owner-restore.cjs +183 -0
- package/test/cloud-runtime-paths.cjs +40 -0
- package/test/cloud-save-publish.cjs +453 -0
- package/test/credential-env-regression.cjs +52 -0
- package/test/login-loopback-security.cjs +115 -0
- package/test/mcp-config-isolation.cjs +36 -0
- package/test/permission-mapping.cjs +180 -0
- package/test/run-api-regression.cjs +322 -0
- package/test/runtime-env-protection.cjs +45 -0
- package/test/semver-precedence.cjs +39 -0
- package/test/smoke.sh +19 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +454 -0
- package/test/timeout-regression.cjs +218 -0
- package/test/tool-workspace-boundary.cjs +165 -0
- package/test/update-safety.cjs +376 -0
package/engine/agentlas-repl.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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("
|
|
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("
|
|
565
|
-
ui.line(" " + ui.c.faint("
|
|
566
|
-
const
|
|
567
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
1240
|
-
ui.line(" " + c.bold(c.text("
|
|
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("
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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 =
|
|
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 =
|
|
59
|
-
let content =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
104
|
-
const src =
|
|
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
|
-
|
|
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);
|