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.
Files changed (41) 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 +112 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +136 -12
  10. package/engine/agentlas-input.cjs +118 -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 +239 -70
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +352 -23
  18. package/engine/agentlas.cjs +2819 -351
  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/route-regression.cjs +121 -0
  33. package/test/run-api-regression.cjs +322 -0
  34. package/test/runtime-env-protection.cjs +45 -0
  35. package/test/semver-precedence.cjs +39 -0
  36. package/test/smoke.sh +20 -0
  37. package/test/sqlite-driver-probe.cjs +22 -0
  38. package/test/terminal-ui-regression.cjs +472 -0
  39. package/test/timeout-regression.cjs +218 -0
  40. package/test/tool-workspace-boundary.cjs +165 -0
  41. 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 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);