agentlas 1.0.65 → 1.0.67

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 (163) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +12 -4
  3. package/bin/agentlas.cjs +354 -16
  4. package/engine/acp/server.cjs +34 -3
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +30 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +21 -4
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/browser/cdp.cjs +190 -17
  43. package/engine/browser/vault.cjs +1 -0
  44. package/engine/cli-output.cjs +96 -30
  45. package/engine/cloud/auth.cjs +131 -13
  46. package/engine/cloud/hub-client.cjs +41 -16
  47. package/engine/cloud-assets/cas.cjs +18 -9
  48. package/engine/cloud-assets/commands.cjs +108 -30
  49. package/engine/cloud-assets/package.cjs +66 -3
  50. package/engine/cloud-assets/restore.cjs +12 -9
  51. package/engine/cloud-assets/state.cjs +609 -116
  52. package/engine/commands/acp.cjs +13 -2
  53. package/engine/commands/automation.cjs +75 -24
  54. package/engine/commands/billing.cjs +6 -1
  55. package/engine/commands/browser.cjs +61 -29
  56. package/engine/commands/build.cjs +72 -14
  57. package/engine/commands/call.cjs +1 -1
  58. package/engine/commands/cd.cjs +1 -1
  59. package/engine/commands/cloud.cjs +2 -1
  60. package/engine/commands/connect.cjs +38 -13
  61. package/engine/commands/creds.cjs +74 -29
  62. package/engine/commands/doctor.cjs +17 -1
  63. package/engine/commands/document.cjs +38 -10
  64. package/engine/commands/env.cjs +6 -1
  65. package/engine/commands/firm.cjs +42 -12
  66. package/engine/commands/graph.cjs +36 -32
  67. package/engine/commands/help.cjs +11 -1
  68. package/engine/commands/hep.cjs +1 -1
  69. package/engine/commands/import.cjs +5 -4
  70. package/engine/commands/index.cjs +1 -1
  71. package/engine/commands/install.cjs +6 -4
  72. package/engine/commands/list.cjs +56 -16
  73. package/engine/commands/login.cjs +5 -0
  74. package/engine/commands/logout.cjs +6 -1
  75. package/engine/commands/mcp.cjs +150 -37
  76. package/engine/commands/memory.cjs +3 -2
  77. package/engine/commands/multimodal.cjs +12 -0
  78. package/engine/commands/native.cjs +5 -1
  79. package/engine/commands/one.cjs +78 -33
  80. package/engine/commands/plugin.cjs +13 -2
  81. package/engine/commands/project.cjs +15 -12
  82. package/engine/commands/research.cjs +18 -7
  83. package/engine/commands/roles.cjs +104 -28
  84. package/engine/commands/route.cjs +2 -2
  85. package/engine/commands/run.cjs +42 -9
  86. package/engine/commands/search.cjs +23 -2
  87. package/engine/commands/setup.cjs +6 -1
  88. package/engine/commands/storm.cjs +44 -6
  89. package/engine/commands/swarm.cjs +47 -9
  90. package/engine/commands/telegram.cjs +6 -1
  91. package/engine/commands/uninstall.cjs +12 -6
  92. package/engine/commands/update.cjs +5 -0
  93. package/engine/commands/upload.cjs +2 -1
  94. package/engine/commands/usage.cjs +6 -1
  95. package/engine/commands/version.cjs +6 -1
  96. package/engine/commands/whoami.cjs +6 -1
  97. package/engine/commands/workforce.cjs +60 -16
  98. package/engine/core/capability-grants.cjs +7 -2
  99. package/engine/core/daemon-client.cjs +36 -7
  100. package/engine/core/db.cjs +10 -2
  101. package/engine/core/desktop-core-fetch.cjs +477 -77
  102. package/engine/core/schema-ensure.cjs +23 -11
  103. package/engine/experience/build.cjs +5 -1
  104. package/engine/experience/intents.cjs +35 -82
  105. package/engine/experience/runtime.cjs +7 -1
  106. package/engine/experience/variant.cjs +58 -4
  107. package/engine/firms/orchestrate.cjs +9 -6
  108. package/engine/graph/ask-model.cjs +22 -11
  109. package/engine/graph/interview.cjs +74 -23
  110. package/engine/graph/package.cjs +63 -8
  111. package/engine/hephaestus/local-core.cjs +18 -5
  112. package/engine/hephaestus/runtime.cjs +48 -43
  113. package/engine/hub/install.cjs +484 -63
  114. package/engine/hub/plugins.cjs +391 -29
  115. package/engine/mcp/consent.cjs +25 -17
  116. package/engine/mcp/contract.cjs +229 -24
  117. package/engine/mcp/inventory.cjs +46 -10
  118. package/engine/mcp/plan.cjs +10 -1
  119. package/engine/mcp/probe.cjs +24 -9
  120. package/engine/memory-cli/curate.cjs +74 -16
  121. package/engine/oberon/common.cjs +37 -12
  122. package/engine/oberon/manifest.cjs +25 -2
  123. package/engine/oberon/outputs.cjs +49 -19
  124. package/engine/oberon/render.cjs +665 -40
  125. package/engine/project/career-graph.cjs +15 -2
  126. package/engine/project/controller.cjs +133 -15
  127. package/engine/project/credentials.cjs +382 -34
  128. package/engine/project/env-file.cjs +93 -8
  129. package/engine/project/memory-context.cjs +11 -57
  130. package/engine/project/ontology.cjs +66 -25
  131. package/engine/project/paths.cjs +42 -8
  132. package/engine/project/seed.cjs +88 -51
  133. package/engine/project/state.cjs +41 -16
  134. package/engine/project/team.cjs +26 -1
  135. package/engine/runtime-refusal.cjs +4 -1
  136. package/engine/runtimes/acp-driver.cjs +56 -9
  137. package/engine/runtimes/auth-evidence.cjs +21 -2
  138. package/engine/runtimes/detect.cjs +28 -8
  139. package/engine/runtimes/kinds.cjs +12 -4
  140. package/engine/runtimes/overrides.cjs +4 -3
  141. package/engine/runtimes/resolve.cjs +8 -7
  142. package/engine/runtimes/roles.cjs +7 -5
  143. package/engine/sessions/apply-fences.cjs +396 -56
  144. package/engine/sessions/fences.cjs +111 -27
  145. package/engine/sessions/memory-turn.cjs +62 -3
  146. package/engine/sessions/orchestrator.cjs +11 -1
  147. package/engine/sessions/prompt.cjs +66 -9
  148. package/engine/sessions/session.cjs +70 -10
  149. package/engine/sessions/sink.cjs +78 -12
  150. package/engine/sessions/store.cjs +29 -7
  151. package/engine/storm/storm.cjs +26 -3
  152. package/engine/storm/swarm.cjs +31 -3
  153. package/engine/telegram/connect.cjs +185 -22
  154. package/engine/ui/commands-catalog.cjs +1 -1
  155. package/engine/ui/palette.cjs +12 -2
  156. package/engine/ui/repl.cjs +57 -9
  157. package/engine/ui/screens.cjs +2 -4
  158. package/engine/vendor/desktop-core.manifest.json +5 -5
  159. package/engine/workforce/capture.cjs +9 -27
  160. package/engine/workforce/deps.cjs +18 -6
  161. package/engine/workforce/local-core-transport.cjs +68 -5
  162. package/package.json +1 -1
  163. package/engine/commands/career-graph.cjs +0 -51
@@ -11,6 +11,7 @@ const fs = require("node:fs");
11
11
  const os = require("node:os");
12
12
  const path = require("node:path");
13
13
  const readline = require("node:readline");
14
+ const { randomUUID } = require("node:crypto");
14
15
  const i18n = require("./agentlas-i18n.cjs");
15
16
 
16
17
  function userDataDir() {
@@ -24,8 +25,13 @@ function userDataDir() {
24
25
 
25
26
  const HISTORY_MAX = 500;
26
27
  const HISTORY_SCHEMA_VERSION = 2;
28
+ const HISTORY_MAX_FILE_BYTES = 8 * 1024 * 1024;
29
+ const HISTORY_MAX_ENTRY_BYTES = 64 * 1024;
30
+ const HISTORY_MAX_SCOPE_BYTES = 4 * 1024;
27
31
  const HISTORY_LOCK_WAIT_MS = 2_000;
28
32
  const HISTORY_LOCK_STALE_MS = 30_000;
33
+ const HISTORY_LOCK_OWNER_MAX_BYTES = 1024;
34
+ const HISTORY_LOCK_OWNER_RE = /^\.owner-(\d+)-([0-9a-f-]{36})\.json$/;
29
35
  function historyPath() {
30
36
  return path.join(userDataDir(), "cli-history.json");
31
37
  }
@@ -34,6 +40,14 @@ function historyBackupPath() {
34
40
  return historyPath() + ".bak";
35
41
  }
36
42
 
43
+ function validHistoryText(value) {
44
+ return typeof value === "string" && value.trim() && Buffer.byteLength(value, "utf8") <= HISTORY_MAX_ENTRY_BYTES;
45
+ }
46
+
47
+ function validHistoryScope(value) {
48
+ return typeof value === "string" && value && Buffer.byteLength(value, "utf8") <= HISTORY_MAX_SCOPE_BYTES && !/[\u0000-\u001f\u007f]/.test(value);
49
+ }
50
+
37
51
  function normalizeHistoryDocument(raw) {
38
52
  if (Array.isArray(raw)) {
39
53
  // Legacy history had no project identity. Keep it quarantined on disk so
@@ -41,24 +55,51 @@ function normalizeHistoryDocument(raw) {
41
55
  return {
42
56
  version: HISTORY_SCHEMA_VERSION,
43
57
  entries: [],
44
- legacyUnscoped: raw.filter((x) => typeof x === "string").slice(0, HISTORY_MAX),
58
+ legacyUnscoped: raw.filter(validHistoryText).slice(0, HISTORY_MAX),
45
59
  };
46
60
  }
47
61
  if (raw && raw.version === HISTORY_SCHEMA_VERSION && Array.isArray(raw.entries)) {
48
62
  return {
49
63
  version: HISTORY_SCHEMA_VERSION,
50
- entries: raw.entries,
51
- legacyUnscoped: Array.isArray(raw.legacyUnscoped) ? raw.legacyUnscoped.slice(0, HISTORY_MAX) : [],
64
+ entries: raw.entries.filter((entry) => (
65
+ entry && typeof entry === "object" && !Array.isArray(entry) &&
66
+ validHistoryText(entry.text) && validHistoryScope(entry.cwd) &&
67
+ Number.isFinite(Number(entry.ts)) && Number(entry.ts) >= 0
68
+ )).slice(0, HISTORY_MAX),
69
+ legacyUnscoped: Array.isArray(raw.legacyUnscoped) ? raw.legacyUnscoped.filter(validHistoryText).slice(0, HISTORY_MAX) : [],
52
70
  };
53
71
  }
54
72
  return null;
55
73
  }
56
74
 
57
75
  function readHistoryFile(file) {
76
+ let fd = null;
58
77
  try {
59
- return normalizeHistoryDocument(JSON.parse(fs.readFileSync(file, "utf8")));
78
+ const before = fs.lstatSync(file);
79
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > HISTORY_MAX_FILE_BYTES) return null;
80
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
81
+ const opened = fs.fstatSync(fd);
82
+ if (!opened.isFile() || opened.nlink !== 1 || opened.size > HISTORY_MAX_FILE_BYTES || !sameFileIdentity(before, opened)) return null;
83
+ const chunks = [];
84
+ let total = 0;
85
+ while (total <= HISTORY_MAX_FILE_BYTES) {
86
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, HISTORY_MAX_FILE_BYTES + 1 - total));
87
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
88
+ if (!count) break;
89
+ chunks.push(buffer.subarray(0, count));
90
+ total += count;
91
+ }
92
+ const after = fs.fstatSync(fd);
93
+ if (
94
+ total > HISTORY_MAX_FILE_BYTES || !after.isFile() || after.nlink !== 1 || !sameFileIdentity(opened, after) ||
95
+ after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || total !== after.size
96
+ ) return null;
97
+ const raw = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total));
98
+ return normalizeHistoryDocument(JSON.parse(raw));
60
99
  } catch {
61
100
  return null;
101
+ } finally {
102
+ if (fd !== null) try { fs.closeSync(fd); } catch { /* ignore */ }
62
103
  }
63
104
  }
64
105
 
@@ -83,23 +124,226 @@ function sleepSync(ms) {
83
124
  }
84
125
  }
85
126
 
127
+ function sameFileIdentity(a, b) {
128
+ return Boolean(a && b && a.dev === b.dev && a.ino === b.ino);
129
+ }
130
+
131
+ function ensureHistoryDirectory({ create = false } = {}) {
132
+ const directory = path.resolve(userDataDir());
133
+ if (create) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
134
+ const stat = fs.lstatSync(directory);
135
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("history directory must be a real directory");
136
+ try { fs.chmodSync(directory, 0o700); } catch { /* win32 */ }
137
+ return { path: directory, stat };
138
+ }
139
+
140
+ function isProcessAlive(pid) {
141
+ if (!Number.isInteger(pid) || pid <= 0) return false;
142
+ try {
143
+ process.kill(pid, 0);
144
+ return true;
145
+ } catch (error) {
146
+ return error && error.code === "EPERM";
147
+ }
148
+ }
149
+
150
+ function readHistoryLockOwner(lock) {
151
+ let lockStat;
152
+ try {
153
+ lockStat = fs.lstatSync(lock);
154
+ if (!lockStat.isDirectory()) return null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ let names;
159
+ try {
160
+ names = fs.readdirSync(lock);
161
+ } catch {
162
+ return null;
163
+ }
164
+ const ownerNames = names.filter((name) => HISTORY_LOCK_OWNER_RE.test(name));
165
+ if (ownerNames.length !== 1) return null;
166
+ const ownerName = ownerNames[0];
167
+ const match = HISTORY_LOCK_OWNER_RE.exec(ownerName);
168
+ const pid = Number(match[1]);
169
+ const nonce = match[2];
170
+ const ownerPath = path.join(lock, ownerName);
171
+ let ownerStat;
172
+ let ownerIdentity;
173
+ try {
174
+ ownerStat = fs.lstatSync(ownerPath);
175
+ if (!ownerStat.isFile() || ownerStat.nlink !== 1 || ownerStat.size <= 0 || ownerStat.size > HISTORY_LOCK_OWNER_MAX_BYTES) return null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ let payload = null;
180
+ try {
181
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
182
+ const fd = fs.openSync(ownerPath, flags);
183
+ try {
184
+ const current = fs.fstatSync(fd);
185
+ if (
186
+ !current.isFile() || current.nlink !== 1 || current.size <= 0 || current.size > HISTORY_LOCK_OWNER_MAX_BYTES ||
187
+ !sameFileIdentity(ownerStat, current)
188
+ ) return null;
189
+ ownerIdentity = current;
190
+ const buffer = Buffer.allocUnsafe(current.size);
191
+ let offset = 0;
192
+ while (offset < buffer.length) {
193
+ const count = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
194
+ if (!count) break;
195
+ offset += count;
196
+ }
197
+ const after = fs.fstatSync(fd);
198
+ if (
199
+ offset !== buffer.length || !sameFileIdentity(current, after) || after.size !== current.size ||
200
+ after.mtimeMs !== current.mtimeMs || after.ctimeMs !== current.ctimeMs
201
+ ) return null;
202
+ payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer));
203
+ } finally {
204
+ fs.closeSync(fd);
205
+ }
206
+ } catch {
207
+ // The filename still carries the owner PID, so a crashed process with a
208
+ // partially-written marker can be reaped once that PID is gone.
209
+ }
210
+ return {
211
+ pid,
212
+ nonce,
213
+ ownerPath,
214
+ identity: ownerIdentity || ownerStat,
215
+ valid: Boolean(
216
+ payload
217
+ && payload.version === 1
218
+ && Number(payload.pid) === pid
219
+ && payload.nonce === nonce,
220
+ ),
221
+ };
222
+ }
223
+
224
+ function removeHistoryLockMarker(lockState) {
225
+ try {
226
+ const current = fs.lstatSync(lockState.lock);
227
+ if (!sameFileIdentity(current, lockState.identity)) return false;
228
+ const marker = fs.lstatSync(lockState.ownerPath);
229
+ if (!marker.isFile() || marker.nlink !== 1) return false;
230
+ if (lockState.ownerIdentity && !sameFileIdentity(marker, lockState.ownerIdentity)) return false;
231
+ fs.unlinkSync(lockState.ownerPath);
232
+ fs.rmdirSync(lockState.lock);
233
+ return true;
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+
239
+ function removeClaimedHistoryLock(lockState) {
240
+ try {
241
+ const current = fs.lstatSync(lockState.lock);
242
+ if (!sameFileIdentity(current, lockState.identity)) return false;
243
+ try {
244
+ const marker = fs.lstatSync(lockState.ownerPath);
245
+ if (!marker.isFile() || marker.nlink !== 1) return false;
246
+ fs.unlinkSync(lockState.ownerPath);
247
+ } catch (error) {
248
+ if (!error || error.code !== "ENOENT") return false;
249
+ }
250
+ fs.rmdirSync(lockState.lock);
251
+ return true;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+
257
+ function releaseHistoryLock(lockState) {
258
+ if (!lockState) return false;
259
+ const owner = readHistoryLockOwner(lockState.lock);
260
+ if (!owner || !owner.valid || owner.pid !== lockState.pid || owner.nonce !== lockState.nonce) return false;
261
+ return removeHistoryLockMarker({ ...lockState, ownerIdentity: owner.identity });
262
+ }
263
+
264
+ function reapStaleHistoryLock(lock, stat, owner) {
265
+ if (!owner) {
266
+ try {
267
+ const current = fs.lstatSync(lock);
268
+ if (!sameFileIdentity(current, stat) || !current.isDirectory()) return false;
269
+ // A process can die after mkdir and before writing its owner marker. Once
270
+ // that marker-less directory is stale, removing only the still-empty,
271
+ // identity-matched directory is safe; unknown contents remain fail-closed.
272
+ if (fs.readdirSync(lock).length !== 0) return false;
273
+ fs.rmdirSync(lock);
274
+ return true;
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+ if (isProcessAlive(owner.pid)) return false;
280
+ return removeHistoryLockMarker({
281
+ lock,
282
+ identity: stat,
283
+ ownerPath: owner.ownerPath,
284
+ ownerIdentity: owner.identity,
285
+ });
286
+ }
287
+
288
+ function claimHistoryLock(lock) {
289
+ const identity = fs.lstatSync(lock);
290
+ if (!identity.isDirectory()) throw new Error("history lock is not a directory");
291
+ const pid = process.pid;
292
+ const nonce = randomUUID();
293
+ const ownerPath = path.join(lock, `.owner-${pid}-${nonce}.json`);
294
+ const lockState = { lock, identity, ownerPath, pid, nonce };
295
+ let fd = null;
296
+ try {
297
+ fd = fs.openSync(ownerPath, "wx", 0o600);
298
+ fs.writeFileSync(fd, JSON.stringify({ version: 1, pid, nonce, createdAt: Date.now() }) + "\n", "utf8");
299
+ fs.fsyncSync(fd);
300
+ fs.closeSync(fd);
301
+ fd = null;
302
+ const owner = readHistoryLockOwner(lock);
303
+ const verifiedIdentity = fs.lstatSync(lock);
304
+ if (!owner || !owner.valid || owner.pid !== pid || owner.nonce !== nonce || owner.ownerPath !== ownerPath) {
305
+ throw new Error("history lock owner verification failed");
306
+ }
307
+ if (!sameFileIdentity(identity, verifiedIdentity)) throw new Error("history lock identity changed");
308
+ return lockState;
309
+ } catch (error) {
310
+ if (fd != null) {
311
+ try { fs.closeSync(fd); } catch { /* ignore */ }
312
+ fd = null;
313
+ }
314
+ removeClaimedHistoryLock(lockState);
315
+ throw error;
316
+ } finally {
317
+ if (fd != null) {
318
+ try { fs.closeSync(fd); } catch { /* ignore */ }
319
+ }
320
+ }
321
+ }
322
+
86
323
  function withHistoryLock(callback) {
87
324
  const lock = historyPath() + ".lock";
88
325
  const deadline = Date.now() + HISTORY_LOCK_WAIT_MS;
326
+ let lockState = null;
89
327
  while (true) {
328
+ let created = false;
90
329
  try {
91
330
  fs.mkdirSync(lock, { mode: 0o700 });
331
+ created = true;
332
+ lockState = claimHistoryLock(lock);
92
333
  break;
93
334
  } catch (error) {
335
+ if (created) throw error;
94
336
  if (!error || error.code !== "EEXIST") throw error;
95
337
  try {
96
- const stat = fs.statSync(lock);
97
- if (Date.now() - stat.mtimeMs > HISTORY_LOCK_STALE_MS) {
98
- fs.rmdirSync(lock);
99
- continue;
338
+ const stat = fs.lstatSync(lock);
339
+ if (stat.isDirectory() && Date.now() - stat.mtimeMs > HISTORY_LOCK_STALE_MS) {
340
+ const owner = readHistoryLockOwner(lock);
341
+ if (reapStaleHistoryLock(lock, stat, owner)) continue;
100
342
  }
101
- } catch {
102
- continue;
343
+ } catch (statError) {
344
+ // If the contender disappeared, retry immediately. Permission and I/O
345
+ // failures must still respect the bounded wait instead of busy-spinning.
346
+ if (statError && statError.code === "ENOENT") continue;
103
347
  }
104
348
  if (Date.now() >= deadline) throw new Error("history lock timeout");
105
349
  sleepSync(10);
@@ -108,21 +352,33 @@ function withHistoryLock(callback) {
108
352
  try {
109
353
  return callback();
110
354
  } finally {
111
- try { fs.rmdirSync(lock); } catch { /* another process will recover a stale lock */ }
355
+ releaseHistoryLock(lockState);
112
356
  }
113
357
  }
114
358
 
115
359
  function atomicWriteHistoryFile(file, document) {
116
360
  const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
117
- let fd;
361
+ const directory = ensureHistoryDirectory();
362
+ const payload = Buffer.from(JSON.stringify(document), "utf8");
363
+ if (payload.length <= 0 || payload.length > HISTORY_MAX_FILE_BYTES) throw new Error("history document exceeds its safety limit");
364
+ let fd = null;
118
365
  try {
119
- fd = fs.openSync(tmp, "wx", 0o600);
120
- fs.writeFileSync(fd, JSON.stringify(document), "utf8");
366
+ fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
367
+ const created = fs.fstatSync(fd);
368
+ if (!created.isFile() || created.nlink !== 1) throw new Error("history temp file is unsafe");
369
+ let offset = 0;
370
+ while (offset < payload.length) offset += fs.writeSync(fd, payload, offset, payload.length - offset);
121
371
  fs.fsyncSync(fd);
372
+ fs.fchmodSync(fd, 0o600);
373
+ const written = fs.fstatSync(fd);
374
+ if (!sameFileIdentity(created, written) || written.size !== payload.length) throw new Error("history temp file changed while writing");
122
375
  fs.closeSync(fd);
123
376
  fd = null;
377
+ const currentDirectory = fs.lstatSync(directory.path);
378
+ if (!sameFileIdentity(directory.stat, currentDirectory) || !currentDirectory.isDirectory() || currentDirectory.isSymbolicLink()) {
379
+ throw new Error("history directory changed while writing");
380
+ }
124
381
  fs.renameSync(tmp, file);
125
- try { fs.chmodSync(file, 0o600); } catch { /* win32 */ }
126
382
  } finally {
127
383
  if (fd != null) {
128
384
  try { fs.closeSync(fd); } catch { /* ignore */ }
@@ -151,8 +407,8 @@ function loadHistory(cwd = process.cwd()) {
151
407
 
152
408
  function saveHistory(list, cwd = process.cwd()) {
153
409
  try {
154
- fs.mkdirSync(userDataDir(), { recursive: true });
155
- const clean = (list || []).filter((x) => typeof x === "string" && x.trim()).slice(0, HISTORY_MAX);
410
+ ensureHistoryDirectory({ create: true });
411
+ const clean = (list || []).filter(validHistoryText).slice(0, HISTORY_MAX);
156
412
  const scope = historyScope(cwd);
157
413
  return withHistoryLock(() => {
158
414
  const currentFileWasValid = Boolean(readHistoryFile(historyPath()));
@@ -263,7 +519,6 @@ const SLASH_COMMAND_META = [
263
519
  { command: "/help", description: "Show Agentlas terminal commands", category: "Help", usage: "/help", detail: "Open the command reference, shortcuts, and common flows." },
264
520
  { command: "/status", description: "Show model/runtime, agent, permission, and directory", category: "Session", usage: "/status", detail: "Print the current runtime, active agent or company, permission level, and cwd." },
265
521
  { command: "/skills", description: "List available Agentlas terminal skills", category: "Discovery", usage: "/skills", detail: "Show the slash-command skills Agentlas can run inside this terminal." },
266
- { command: "/career-graph", description: "Show or add Career Graph source refs", category: "Knowledge", usage: "/career-graph add ./docs", detail: "Career Graph routes agents to source Markdown, JSONL ledgers, sitemap, and code map before broad scans.", examples: ["/career-graph status", "/career-graph add ./docs", "/career-graph open"] },
267
522
  { command: "/ontology", description: "Turn on, list, or add project ontology sources", category: "Knowledge", usage: "/ontology add ./docs", detail: "Also understands natural text like /ontology use ./docs as company knowledge.", examples: ["/ontology list", "/ontology use ./docs as company knowledge", "/ontology open"] },
268
523
  { command: "/agents", description: "List installed agents", category: "Routing", usage: "/agents", detail: "Show local agents and their routed runtime." },
269
524
  { command: "/team", description: "View or pin each agent runtime", category: "Routing", usage: "/team <agent> <runtime|auto>", detail: "Pin one agent to claude-code, codex, gemini, or automatic routing." },
@@ -314,7 +569,6 @@ const HELP_KEY_BY_COMMAND = {
314
569
  "/help": "help.help",
315
570
  "/status": "help.status",
316
571
  "/skills": "help.skills",
317
- "/career-graph": "help.careerGraph",
318
572
  "/ontology": "help.ontology",
319
573
  "/agents": "help.agents",
320
574
  "/team": "help.team",
@@ -860,7 +1114,6 @@ function makeCompleter(ctx) {
860
1114
  return [uniqStartsWith(RUNTIME_SPECS.concat(["auto"]), last), last];
861
1115
  case "/cwd":
862
1116
  case "/import":
863
- case "/career-graph":
864
1117
  case "/ontology":
865
1118
  return [completePath(last, getCwd(), ""), last];
866
1119
  default:
Binary file
@@ -68,31 +68,85 @@ function findEnvValue(source, key) {
68
68
  return match ? source[match] : undefined;
69
69
  }
70
70
 
71
- function ensurePrivateDirectory(directory) {
72
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
73
- const stat = fs.lstatSync(directory);
74
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("MCP runtime home must be a real directory");
75
- try { fs.chmodSync(directory, 0o700); } catch { /* Windows/best-effort */ }
76
- return directory;
71
+ function samePath(left, right) {
72
+ const a = path.normalize(String(left || ""));
73
+ const b = path.normalize(String(right || ""));
74
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
75
+ }
76
+
77
+ function sameDirectoryIdentity(left, right) {
78
+ return !!left && !!right
79
+ && left.isDirectory() && right.isDirectory()
80
+ && !left.isSymbolicLink() && !right.isSymbolicLink()
81
+ && left.dev === right.dev && left.ino === right.ino;
82
+ }
83
+
84
+ function realPath(directory) {
85
+ // Keep the non-native resolver on Windows so HOMEDRIVE/HOMEPATH retain the
86
+ // normal Win32 spelling; POSIX still gets the platform's canonical path.
87
+ return fs.realpathSync(path.resolve(directory));
88
+ }
89
+
90
+ function inspectPrivateDirectory(directory) {
91
+ const requestedPath = path.resolve(String(directory || ""));
92
+ const stat = fs.lstatSync(requestedPath);
93
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
94
+ throw new Error("MCP runtime home must be a real directory");
95
+ }
96
+ const realpath = realPath(requestedPath);
97
+ // A swap between the first lstat and realpath must not become the anchor
98
+ // returned to callers. Re-read the pathname and retain both identities.
99
+ const verified = fs.lstatSync(requestedPath);
100
+ if (!sameDirectoryIdentity(stat, verified)) {
101
+ throw new Error("MCP runtime directory changed during inspection");
102
+ }
103
+ return { requestedPath, stat: verified, realpath };
104
+ }
105
+
106
+ function assertPrivateDirectoryIdentity(expected, label = "MCP runtime directory") {
107
+ const actual = inspectPrivateDirectory(expected.requestedPath);
108
+ if (!sameDirectoryIdentity(expected.stat, actual.stat) || !samePath(expected.realpath, actual.realpath)) {
109
+ throw new Error(`${label} changed during setup`);
110
+ }
111
+ return actual;
112
+ }
113
+
114
+ function ensurePrivateDirectoryInfo(directory) {
115
+ const requestedPath = path.resolve(String(directory || ""));
116
+ fs.mkdirSync(requestedPath, { recursive: true, mode: 0o700 });
117
+ const before = inspectPrivateDirectory(requestedPath);
118
+ try { fs.chmodSync(requestedPath, 0o700); } catch { /* Windows/best-effort */ }
119
+ const after = inspectPrivateDirectory(requestedPath);
120
+ if (!sameDirectoryIdentity(before.stat, after.stat) || !samePath(before.realpath, after.realpath)) {
121
+ throw new Error("MCP runtime directory changed during setup");
122
+ }
123
+ return after;
124
+ }
125
+
126
+ function isStrictChild(parent, child) {
127
+ const relative = path.relative(parent, child);
128
+ return !!relative && !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`);
77
129
  }
78
130
 
79
131
  function mcpRuntimeHome(dataDir, serverIdentity) {
80
- const root = path.resolve(dataDir || path.join(os.tmpdir(), "agentlas-mcp-runtime"));
81
- ensurePrivateDirectory(root);
82
- const parent = path.join(root, "mcp-runtime-homes");
83
- ensurePrivateDirectory(parent);
132
+ const root = ensurePrivateDirectoryInfo(path.resolve(dataDir || path.join(os.tmpdir(), "agentlas-mcp-runtime")));
133
+ const parent = ensurePrivateDirectoryInfo(path.join(root.realpath, "mcp-runtime-homes"));
134
+ if (!isStrictChild(root.realpath, parent.realpath)) throw new Error("MCP runtime home escaped its private root");
135
+ assertPrivateDirectoryIdentity(root, "MCP runtime root");
136
+ assertPrivateDirectoryIdentity(parent, "MCP runtime parent");
137
+
84
138
  const digest = crypto.createHash("sha256").update(String(serverIdentity || "mcp"), "utf8").digest("hex").slice(0, 32);
85
- const target = path.join(parent, digest);
86
- ensurePrivateDirectory(target);
87
- const relative = path.relative(fs.realpathSync(parent), fs.realpathSync(target));
88
- if (!relative || path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
89
- throw new Error("MCP runtime home escaped its private root");
90
- }
91
- return target;
139
+ const target = ensurePrivateDirectoryInfo(path.join(parent.realpath, digest));
140
+ if (!isStrictChild(parent.realpath, target.realpath)) throw new Error("MCP runtime home escaped its private parent");
141
+ assertPrivateDirectoryIdentity(root, "MCP runtime root");
142
+ assertPrivateDirectoryIdentity(parent, "MCP runtime parent");
143
+ assertPrivateDirectoryIdentity(target, "MCP runtime target");
144
+ return target.realpath;
92
145
  }
93
146
 
94
147
  function safeBaseEnvironment(source, options = {}) {
95
- const runtimeHome = ensurePrivateDirectory(path.resolve(options.runtimeHome || mcpRuntimeHome(null, options.serverIdentity)));
148
+ const home = ensurePrivateDirectoryInfo(path.resolve(options.runtimeHome || mcpRuntimeHome(null, options.serverIdentity)));
149
+ const runtimeHome = home.realpath;
96
150
  const result = {};
97
151
  for (const key of SAFE_BASE_ENV_KEYS) {
98
152
  const value = findEnvValue(source, key);
@@ -100,15 +154,22 @@ function safeBaseEnvironment(source, options = {}) {
100
154
  }
101
155
  // Never expose the user's real home through the environment. MCPs that need
102
156
  // credentials must use their declared registry key, not ambient dotfiles.
103
- const runtimeTmp = ensurePrivateDirectory(path.join(runtimeHome, "tmp"));
104
- result.HOME = runtimeHome;
157
+ // Anchor HOME before creating TMP so a target/parent swap cannot redirect
158
+ // the mkdir through a successor symlink.
159
+ assertPrivateDirectoryIdentity(home, "MCP runtime home");
160
+ const runtimeTmpInfo = ensurePrivateDirectoryInfo(path.join(runtimeHome, "tmp"));
161
+ assertPrivateDirectoryIdentity(home, "MCP runtime home");
162
+ const runtimeTmp = assertPrivateDirectoryIdentity(runtimeTmpInfo, "MCP runtime tmp").realpath;
163
+ if (!samePath(path.dirname(runtimeTmp), runtimeHome)) throw new Error("MCP runtime tmp escaped its private home");
164
+ const canonicalHome = assertPrivateDirectoryIdentity(home, "MCP runtime home").realpath;
165
+ result.HOME = canonicalHome;
105
166
  result.TMPDIR = runtimeTmp;
106
167
  result.TMP = runtimeTmp;
107
168
  result.TEMP = runtimeTmp;
108
169
  if (process.platform === "win32") {
109
- result.USERPROFILE = runtimeHome;
110
- result.HOMEDRIVE = path.parse(runtimeHome).root.replace(/[\\/]$/, "") || "C:";
111
- result.HOMEPATH = runtimeHome.slice(result.HOMEDRIVE.length) || "\\";
170
+ result.USERPROFILE = canonicalHome;
171
+ result.HOMEDRIVE = path.parse(canonicalHome).root.replace(/[\\/]$/, "") || "C:";
172
+ result.HOMEPATH = canonicalHome.slice(result.HOMEDRIVE.length) || "\\";
112
173
  }
113
174
  return result;
114
175
  }
@@ -8,13 +8,18 @@
8
8
  const { spawn } = require("node:child_process");
9
9
  const { buildMcpChildEnv, decodeLaunchDescriptor } = require("./agentlas-mcp-env.cjs");
10
10
 
11
+ const CHILD_SHUTDOWN_GRACE_MS = 5_000;
12
+
11
13
  function fail(code) {
12
14
  process.stderr.write(`agentlas MCP wrapper: ${code}\n`);
13
15
  process.exitCode = 1;
14
16
  }
15
17
 
16
18
  let descriptor;
17
- try { descriptor = decodeLaunchDescriptor(process.argv[2]); }
19
+ try {
20
+ if (process.argv.length !== 3) throw new Error("invalid wrapper arguments");
21
+ descriptor = decodeLaunchDescriptor(process.argv[2]);
22
+ }
18
23
  catch { fail("invalid_launch_descriptor"); }
19
24
 
20
25
  if (descriptor) {
@@ -25,6 +30,7 @@ if (descriptor) {
25
30
  env: buildMcpChildEnv(process.env, descriptor.credentialKeyNames, {
26
31
  runtimeHome: descriptor.runtimeHome,
27
32
  }),
33
+ detached: process.platform !== "win32",
28
34
  stdio: ["inherit", "inherit", "inherit"],
29
35
  windowsHide: true,
30
36
  });
@@ -33,15 +39,41 @@ if (descriptor) {
33
39
  }
34
40
 
35
41
  if (child) {
36
- const relay = (signal) => {
42
+ let terminationSignal = null;
43
+ let shutdownTimer = null;
44
+ const clearShutdownTimer = () => {
45
+ if (shutdownTimer) clearTimeout(shutdownTimer);
46
+ shutdownTimer = null;
47
+ };
48
+ const terminateChild = (signal) => {
49
+ const pid = Number(child.pid);
50
+ if (process.platform !== "win32" && Number.isInteger(pid) && pid > 1) {
51
+ try { process.kill(-pid, signal); return; } catch { /* fall through */ }
52
+ }
37
53
  try { child.kill(signal); } catch { /* child already stopped */ }
38
54
  };
55
+ const relay = (signal) => {
56
+ if (terminationSignal) {
57
+ terminateChild("SIGKILL");
58
+ return;
59
+ }
60
+ terminationSignal = signal;
61
+ terminateChild(signal);
62
+ shutdownTimer = setTimeout(() => {
63
+ terminateChild("SIGKILL");
64
+ }, CHILD_SHUTDOWN_GRACE_MS);
65
+ if (typeof shutdownTimer.unref === "function") shutdownTimer.unref();
66
+ };
39
67
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
40
68
  try { process.on(signal, () => relay(signal)); } catch { /* unavailable on this platform */ }
41
69
  }
42
- child.once("error", () => fail("child_spawn_failed"));
70
+ child.once("error", () => {
71
+ clearShutdownTimer();
72
+ fail("child_spawn_failed");
73
+ });
43
74
  child.once("close", (code, signal) => {
44
- if (signal) {
75
+ clearShutdownTimer();
76
+ if (terminationSignal || signal) {
45
77
  process.exitCode = 1;
46
78
  return;
47
79
  }
@@ -32,6 +32,7 @@ const CONFIDENCE_LEVELS = new Set(["high", "medium", "low"]);
32
32
 
33
33
  const { SECRET_PATTERNS } = require("./agentlas-secret-patterns.cjs");
34
34
  const { runWriteTransaction } = require("./agentlas-sqlite-policy.cjs");
35
+ const permissions = require("./agentlas-permissions.cjs");
35
36
  const ABSOLUTE_PATH_PATTERNS = [
36
37
  /(?:^|[\s("'`])~\/[A-Za-z0-9._-]/,
37
38
  /(?:^|[\s("'`])\/(?:Users|home|private|var|tmp|opt|etc|Volumes|Applications|System|Library)\//,
@@ -67,7 +68,7 @@ function stableJson(value) {
67
68
  }
68
69
 
69
70
  function normalizePermission(value) {
70
- return ["read", "write", "full"].includes(value) ? value : "write";
71
+ return permissions.normalize(value, "read");
71
72
  }
72
73
 
73
74
  function projectKey(projectPath) {