agentlas 0.5.2 → 0.6.0
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 +112 -44
- package/engine/agentlas-doctor.cjs +40 -12
- package/engine/agentlas-i18n.cjs +136 -12
- package/engine/agentlas-input.cjs +118 -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 +239 -70
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +352 -23
- package/engine/agentlas.cjs +2819 -351
- 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/route-regression.cjs +121 -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 +20 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +472 -0
- package/test/timeout-regression.cjs +218 -0
- package/test/tool-workspace-boundary.cjs +165 -0
- package/test/update-safety.cjs +376 -0
|
@@ -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);
|