agentlas 1.0.64 → 1.0.66

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 (164) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +8 -6
  3. package/bin/agentlas.cjs +341 -16
  4. package/engine/acp/server.cjs +27 -1
  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 +29 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +10 -3
  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/bootstrap-schema.sql +4 -4
  43. package/engine/browser/cdp.cjs +315 -32
  44. package/engine/browser/vault.cjs +1 -0
  45. package/engine/cli-output.cjs +80 -28
  46. package/engine/cloud/auth.cjs +131 -13
  47. package/engine/cloud/hub-client.cjs +41 -16
  48. package/engine/cloud-assets/cas.cjs +18 -9
  49. package/engine/cloud-assets/commands.cjs +108 -30
  50. package/engine/cloud-assets/package.cjs +66 -3
  51. package/engine/cloud-assets/restore.cjs +12 -9
  52. package/engine/cloud-assets/state.cjs +308 -113
  53. package/engine/commands/acp.cjs +13 -2
  54. package/engine/commands/automation.cjs +75 -24
  55. package/engine/commands/billing.cjs +6 -1
  56. package/engine/commands/browser.cjs +67 -34
  57. package/engine/commands/build.cjs +42 -12
  58. package/engine/commands/call.cjs +1 -1
  59. package/engine/commands/cd.cjs +1 -1
  60. package/engine/commands/cloud.cjs +2 -1
  61. package/engine/commands/connect.cjs +38 -13
  62. package/engine/commands/creds.cjs +74 -29
  63. package/engine/commands/doctor.cjs +17 -1
  64. package/engine/commands/document.cjs +40 -12
  65. package/engine/commands/env.cjs +6 -1
  66. package/engine/commands/firm.cjs +42 -12
  67. package/engine/commands/graph.cjs +36 -32
  68. package/engine/commands/help.cjs +11 -1
  69. package/engine/commands/hep.cjs +1 -1
  70. package/engine/commands/import.cjs +5 -4
  71. package/engine/commands/index.cjs +1 -1
  72. package/engine/commands/install.cjs +6 -4
  73. package/engine/commands/list.cjs +56 -16
  74. package/engine/commands/login.cjs +5 -0
  75. package/engine/commands/logout.cjs +6 -1
  76. package/engine/commands/mcp.cjs +8 -2
  77. package/engine/commands/memory.cjs +3 -2
  78. package/engine/commands/multimodal.cjs +12 -0
  79. package/engine/commands/native.cjs +5 -1
  80. package/engine/commands/one.cjs +78 -33
  81. package/engine/commands/plugin.cjs +13 -2
  82. package/engine/commands/project.cjs +15 -12
  83. package/engine/commands/research.cjs +18 -7
  84. package/engine/commands/roles.cjs +104 -28
  85. package/engine/commands/route.cjs +2 -2
  86. package/engine/commands/run.cjs +42 -9
  87. package/engine/commands/search.cjs +23 -2
  88. package/engine/commands/setup.cjs +6 -1
  89. package/engine/commands/storm.cjs +44 -6
  90. package/engine/commands/swarm.cjs +47 -9
  91. package/engine/commands/telegram.cjs +6 -1
  92. package/engine/commands/uninstall.cjs +12 -6
  93. package/engine/commands/update.cjs +5 -0
  94. package/engine/commands/upload.cjs +2 -1
  95. package/engine/commands/usage.cjs +6 -1
  96. package/engine/commands/version.cjs +6 -1
  97. package/engine/commands/whoami.cjs +6 -1
  98. package/engine/commands/workforce.cjs +60 -16
  99. package/engine/core/capability-grants.cjs +7 -2
  100. package/engine/core/daemon-client.cjs +36 -7
  101. package/engine/core/db.cjs +10 -2
  102. package/engine/core/desktop-core-fetch.cjs +477 -77
  103. package/engine/core/schema-ensure.cjs +23 -11
  104. package/engine/experience/build.cjs +5 -1
  105. package/engine/experience/intents.cjs +29 -82
  106. package/engine/experience/runtime.cjs +7 -1
  107. package/engine/experience/variant.cjs +58 -4
  108. package/engine/firms/orchestrate.cjs +9 -6
  109. package/engine/graph/ask-model.cjs +22 -11
  110. package/engine/graph/interview.cjs +74 -23
  111. package/engine/graph/package.cjs +63 -8
  112. package/engine/hephaestus/local-core.cjs +18 -5
  113. package/engine/hephaestus/runtime.cjs +48 -43
  114. package/engine/hub/install.cjs +230 -61
  115. package/engine/hub/plugins.cjs +391 -29
  116. package/engine/mcp/consent.cjs +25 -17
  117. package/engine/mcp/contract.cjs +229 -24
  118. package/engine/mcp/inventory.cjs +15 -5
  119. package/engine/mcp/plan.cjs +5 -1
  120. package/engine/mcp/probe.cjs +24 -9
  121. package/engine/memory-cli/curate.cjs +74 -16
  122. package/engine/oberon/common.cjs +37 -12
  123. package/engine/oberon/manifest.cjs +25 -2
  124. package/engine/oberon/outputs.cjs +49 -19
  125. package/engine/oberon/render.cjs +665 -40
  126. package/engine/project/career-graph.cjs +15 -2
  127. package/engine/project/controller.cjs +133 -15
  128. package/engine/project/credentials.cjs +212 -34
  129. package/engine/project/env-file.cjs +93 -8
  130. package/engine/project/memory-context.cjs +11 -57
  131. package/engine/project/ontology.cjs +66 -25
  132. package/engine/project/paths.cjs +42 -8
  133. package/engine/project/seed.cjs +88 -51
  134. package/engine/project/state.cjs +41 -16
  135. package/engine/project/team.cjs +26 -1
  136. package/engine/runtime-refusal.cjs +4 -1
  137. package/engine/runtimes/acp-driver.cjs +56 -9
  138. package/engine/runtimes/auth-evidence.cjs +21 -2
  139. package/engine/runtimes/detect.cjs +28 -8
  140. package/engine/runtimes/kinds.cjs +12 -4
  141. package/engine/runtimes/overrides.cjs +4 -3
  142. package/engine/runtimes/resolve.cjs +8 -7
  143. package/engine/runtimes/roles.cjs +7 -5
  144. package/engine/sessions/apply-fences.cjs +396 -56
  145. package/engine/sessions/fences.cjs +111 -27
  146. package/engine/sessions/memory-turn.cjs +62 -3
  147. package/engine/sessions/orchestrator.cjs +11 -1
  148. package/engine/sessions/prompt.cjs +66 -9
  149. package/engine/sessions/session.cjs +70 -10
  150. package/engine/sessions/sink.cjs +78 -12
  151. package/engine/sessions/store.cjs +29 -7
  152. package/engine/storm/storm.cjs +26 -3
  153. package/engine/storm/swarm.cjs +31 -3
  154. package/engine/telegram/connect.cjs +187 -24
  155. package/engine/ui/commands-catalog.cjs +1 -1
  156. package/engine/ui/palette.cjs +12 -2
  157. package/engine/ui/repl.cjs +57 -9
  158. package/engine/ui/screens.cjs +2 -4
  159. package/engine/vendor/desktop-core.manifest.json +5 -5
  160. package/engine/workforce/capture.cjs +9 -27
  161. package/engine/workforce/deps.cjs +18 -6
  162. package/engine/workforce/local-core-transport.cjs +68 -5
  163. package/package.json +1 -1
  164. package/engine/commands/career-graph.cjs +0 -51
@@ -18,6 +18,7 @@ const MAX_JSON_BYTES = 2 * 1024 * 1024;
18
18
  const MAX_APPROVED_MCP_PER_BUILD = 8;
19
19
  const STATE_LOCK_STALE_MS = 30_000;
20
20
  const STATE_LOCK_WAIT_MS = 2_000;
21
+ const STATE_LOCK_OWNER_MAX_BYTES = 512;
21
22
 
22
23
  // Public contract text must be compact, value-free, and instruction-safe.
23
24
  const UNSAFE_TEXT_PATTERNS = [
@@ -92,14 +93,109 @@ function safeDisplayName(value, fallback) {
92
93
  return text;
93
94
  }
94
95
 
96
+ function fileIdentityEqual(left, right) {
97
+ return Boolean(left && right) && left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink && left.mode === right.mode;
98
+ }
99
+
100
+ function fileTimesEqual(left, right) {
101
+ return Boolean(left && right) && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
102
+ }
103
+
104
+ function assertRegularJsonFile(stat, label) {
105
+ if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
106
+ throw new Error(`${label} must be a regular file (symlinks are not accepted)`);
107
+ }
108
+ }
109
+
110
+ function assertJsonFileSize(stat, label) {
111
+ if (stat.size <= 0 || stat.size > MAX_JSON_BYTES) throw new Error(`${label} has an invalid size`);
112
+ }
113
+
114
+ function openReadOnlyNoFollow(filePath) {
115
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
116
+ try {
117
+ return fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
118
+ } catch (error) {
119
+ // Node does not expose a no-follow open flag on every Windows filesystem.
120
+ // Keep the lstat/fstat/path identity checks below as the fallback there;
121
+ // never silently drop O_NOFOLLOW on POSIX.
122
+ if (process.platform === "win32" && noFollow && ["EINVAL", "ENOTSUP"].includes(error && error.code)) {
123
+ return fs.openSync(filePath, fs.constants.O_RDONLY);
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ function readBoundedDescriptor(fd, maxBytes) {
130
+ const chunks = [];
131
+ let total = 0;
132
+ while (total <= maxBytes) {
133
+ const capacity = Math.min(64 * 1024, maxBytes + 1 - total);
134
+ if (capacity <= 0) break;
135
+ const chunk = Buffer.allocUnsafe(capacity);
136
+ const result = fs.readSync(fd, chunk, 0, capacity, null);
137
+ const bytesRead = typeof result === "number" ? result : result.bytesRead;
138
+ if (!Number.isInteger(bytesRead) || bytesRead < 0) throw new Error("descriptor read returned an invalid byte count");
139
+ if (bytesRead === 0) break;
140
+ chunks.push(chunk.subarray(0, bytesRead));
141
+ total += bytesRead;
142
+ }
143
+ return { bytes: Buffer.concat(chunks, total), byteLength: total };
144
+ }
145
+
95
146
  function readJsonFile(filePath, label) {
96
147
  const absolute = path.resolve(filePath);
97
- const stat = fs.lstatSync(absolute);
98
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${label} must be a regular file (symlinks are not accepted)`);
99
- if (stat.size <= 0 || stat.size > MAX_JSON_BYTES) throw new Error(`${label} has an invalid size`);
100
- let value;
101
- try { value = JSON.parse(fs.readFileSync(absolute, "utf8")); } catch (error) { throw new Error(`${label} is not valid JSON: ${error.message}`); }
102
- return { absolute, value };
148
+ let fd;
149
+ let listed;
150
+ try {
151
+ listed = fs.lstatSync(absolute);
152
+ assertRegularJsonFile(listed, label);
153
+ assertJsonFileSize(listed, label);
154
+ fd = openReadOnlyNoFollow(absolute);
155
+ } catch (error) {
156
+ if (error && error.code === "ELOOP") {
157
+ throw new Error(`${label} must be a regular file (symlinks are not accepted)`);
158
+ }
159
+ throw error;
160
+ }
161
+ try {
162
+ const before = fs.fstatSync(fd);
163
+ assertRegularJsonFile(before, label);
164
+ assertJsonFileSize(before, label);
165
+ if (!fileIdentityEqual(listed, before) || !fileTimesEqual(listed, before) || listed.size !== before.size) {
166
+ throw new Error(`${label} changed while it was being read`);
167
+ }
168
+
169
+ let captured;
170
+ try {
171
+ captured = readBoundedDescriptor(fd, MAX_JSON_BYTES);
172
+ } catch (error) {
173
+ throw new Error(`${label} is not valid JSON: ${error.message}`);
174
+ }
175
+ const after = fs.fstatSync(fd);
176
+ let current;
177
+ try { current = fs.lstatSync(absolute); }
178
+ catch { throw new Error(`${label} changed while it was being read`); }
179
+ if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1) {
180
+ throw new Error(`${label} changed while it was being read`);
181
+ }
182
+ if (captured.byteLength <= 0 || captured.byteLength > MAX_JSON_BYTES || after.size <= 0 || after.size > MAX_JSON_BYTES) {
183
+ throw new Error(`${label} has an invalid size`);
184
+ }
185
+ if (
186
+ !fileIdentityEqual(before, after) || !fileTimesEqual(before, after) || after.size !== before.size ||
187
+ captured.byteLength !== after.size || !fileIdentityEqual(before, current) ||
188
+ !fileTimesEqual(before, current) || current.size !== after.size
189
+ ) {
190
+ throw new Error(`${label} changed while it was being read`);
191
+ }
192
+ let value;
193
+ try { value = JSON.parse(captured.bytes.toString("utf8")); }
194
+ catch (error) { throw new Error(`${label} is not valid JSON: ${error.message}`); }
195
+ return { absolute, value };
196
+ } finally {
197
+ try { fs.closeSync(fd); } catch { /* best effort */ }
198
+ }
103
199
  }
104
200
 
105
201
  function writePrivateJsonAtomic(filePath, value) {
@@ -122,43 +218,152 @@ function waitSync(milliseconds) {
122
218
  Atomics.wait(signal, 0, 0, milliseconds);
123
219
  }
124
220
 
221
+ function processIsAlive(pid) {
222
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
223
+ try {
224
+ process.kill(pid, 0);
225
+ return true;
226
+ } catch (error) {
227
+ if (error && error.code === "ESRCH") return false;
228
+ // EPERM and unknown failures mean the process may still be alive. Never
229
+ // reclaim a stale-looking lock when liveness cannot be disproved.
230
+ return true;
231
+ }
232
+ }
233
+
234
+ function readLockOwner(lockPath, labels) {
235
+ const ownerPath = path.join(lockPath, "owner.json");
236
+ let fd;
237
+ try {
238
+ const listed = fs.lstatSync(ownerPath);
239
+ if (!listed.isFile() || listed.isSymbolicLink() || listed.nlink !== 1) throw new Error(labels.unsafe);
240
+ fd = openReadOnlyNoFollow(ownerPath);
241
+ const before = fs.fstatSync(fd);
242
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size <= 0 || before.size > STATE_LOCK_OWNER_MAX_BYTES) {
243
+ throw new Error(labels.unsafe);
244
+ }
245
+ // 정상 release/다음 owner 획득과 겹친 교체는 unsafe 파일이 아니라 재시도 신호다.
246
+ if (!fileIdentityEqual(listed, before) || !fileTimesEqual(listed, before)) return null;
247
+ const captured = readBoundedDescriptor(fd, STATE_LOCK_OWNER_MAX_BYTES);
248
+ const after = fs.fstatSync(fd);
249
+ let current;
250
+ try { current = fs.lstatSync(ownerPath); }
251
+ catch (error) {
252
+ if (error && error.code === "ENOENT") return null;
253
+ throw new Error(labels.unsafe);
254
+ }
255
+ if (
256
+ captured.byteLength !== before.size || !fileIdentityEqual(before, after) || !fileTimesEqual(before, after) ||
257
+ after.size !== before.size || !fileIdentityEqual(before, current) || !fileTimesEqual(before, current) ||
258
+ current.size !== after.size
259
+ ) return null;
260
+ let owner;
261
+ try { owner = JSON.parse(captured.bytes.toString("utf8")); }
262
+ catch { throw new Error(labels.unsafe); }
263
+ if (
264
+ !owner || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 ||
265
+ typeof owner.nonce !== "string" || !/^[a-f0-9]{32}$/.test(owner.nonce)
266
+ ) throw new Error(labels.unsafe);
267
+ return owner;
268
+ } catch (error) {
269
+ if (error && error.code === "ENOENT") return null;
270
+ if (error && error.code === "ELOOP") throw new Error(labels.unsafe);
271
+ throw error;
272
+ } finally {
273
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
274
+ }
275
+ }
276
+
125
277
  /**
126
- * <stateFile>.lock 파일 기반 상호배제. 크래시 잔류 락은 STALE_MS 이후 회수한다.
127
- * busyMessage/unsafeMessage를 호출자가 주어 사용자 문구를 상태별로 유지한다.
278
+ * <stateFile>.lock directory 기반 상호배제. 크래시 잔류 락은 owner PID가
279
+ * 실제로 죽었다고 확인될 때만 고유 quarantine 경로로 회수한다. 디렉터리
280
+ * rename이 소유권 hand-off 경계이므로 이전 소유자의 cleanup이 successor를
281
+ * unlink할 수 없다.
128
282
  */
129
283
  function withPrivateStateLock(stateFile, labels, action) {
130
284
  const dir = path.dirname(stateFile);
131
285
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
132
286
  try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
133
- const lockFile = `${stateFile}.lock`;
287
+ const lockPath = `${stateFile}.lock`;
134
288
  const deadline = Date.now() + STATE_LOCK_WAIT_MS;
135
- let descriptor = null;
136
- while (descriptor == null) {
289
+ let acquiredOwner = null;
290
+ let acquired = false;
291
+ while (!acquired) {
137
292
  try {
138
- descriptor = fs.openSync(lockFile, "wx", 0o600);
139
- fs.writeFileSync(descriptor, `${process.pid}\n${new Date().toISOString()}\n`, "utf8");
293
+ fs.mkdirSync(lockPath, { mode: 0o700 });
140
294
  } catch (error) {
141
- if (!error || error.code !== "EEXIST") throw error;
142
- try {
143
- const stat = fs.lstatSync(lockFile);
144
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(labels.unsafe);
145
- if (Date.now() - stat.mtimeMs > STATE_LOCK_STALE_MS) {
146
- fs.unlinkSync(lockFile);
147
- continue;
148
- }
149
- } catch (statError) {
295
+ if (!error || (error.code !== "EEXIST" && error.code !== "ENOENT")) throw error;
296
+ if (error.code === "ENOENT") continue;
297
+ let stat;
298
+ try { stat = fs.lstatSync(lockPath); }
299
+ catch (statError) {
150
300
  if (statError && statError.code === "ENOENT") continue;
151
301
  throw statError;
152
302
  }
303
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(labels.unsafe);
304
+ let owner;
305
+ try { owner = readLockOwner(lockPath, labels); }
306
+ catch (ownerError) { throw ownerError; }
307
+ if (Date.now() - stat.mtimeMs > STATE_LOCK_STALE_MS) {
308
+ // Missing or malformed owner identity is not evidence of a dead PID.
309
+ // Stay fail-closed rather than stealing a possibly live lock.
310
+ if (!owner || processIsAlive(owner.pid)) {
311
+ if (Date.now() >= deadline) throw new Error(labels.busy);
312
+ waitSync(25);
313
+ continue;
314
+ }
315
+ const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
316
+ try {
317
+ fs.renameSync(lockPath, quarantine);
318
+ fs.rmSync(quarantine, { recursive: true, force: true });
319
+ continue;
320
+ } catch (reclaimError) {
321
+ if (reclaimError && reclaimError.code === "ENOENT") continue;
322
+ throw reclaimError;
323
+ }
324
+ }
153
325
  if (Date.now() >= deadline) throw new Error(labels.busy);
154
326
  waitSync(25);
327
+ // 기존 소유자가 정상적으로 보유 중이다. 같은 디렉터리의 owner.json에
328
+ // 쓰기를 시도하면 EEXIST 처리 중 활성 잠금을 abandoned로 오인해 지울 수 있다.
329
+ continue;
330
+ }
331
+ try {
332
+ acquiredOwner = {
333
+ pid: process.pid,
334
+ nonce: crypto.randomBytes(16).toString("hex"),
335
+ createdAt: new Date().toISOString(),
336
+ };
337
+ // owner.json 자체도 원자적으로 공개한다. mkdir 직후의 부분 쓰기를 다른
338
+ // contender가 size=0/malformed unsafe lock으로 관측하면 정상 경합이 실패한다.
339
+ writePrivateJsonAtomic(path.join(lockPath, "owner.json"), acquiredOwner);
340
+ acquired = true;
341
+ } catch (error) {
342
+ const abandoned = `${lockPath}.abandoned-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
343
+ try {
344
+ fs.renameSync(lockPath, abandoned);
345
+ fs.rmSync(abandoned, { recursive: true, force: true });
346
+ } catch { /* original owner-write error remains authoritative */ }
347
+ throw error;
155
348
  }
156
349
  }
157
350
  try {
158
351
  return action();
159
352
  } finally {
160
- try { fs.closeSync(descriptor); } catch { /* noop */ }
161
- try { fs.unlinkSync(lockFile); } catch { /* crash recovery handles leftovers */ }
353
+ let ownsLock = false;
354
+ try {
355
+ const owner = readLockOwner(lockPath, labels);
356
+ ownsLock = Boolean(owner && acquiredOwner && owner.pid === acquiredOwner.pid && owner.nonce === acquiredOwner.nonce);
357
+ } catch { /* do not remove an unverified lock */ }
358
+ if (ownsLock) {
359
+ const cleanup = `${lockPath}.done-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
360
+ try {
361
+ fs.renameSync(lockPath, cleanup);
362
+ fs.rmSync(cleanup, { recursive: true, force: true });
363
+ } catch (error) {
364
+ if (!error || error.code !== "ENOENT") throw error;
365
+ }
366
+ }
162
367
  }
163
368
  }
164
369
 
@@ -13,6 +13,7 @@ const crypto = require("node:crypto");
13
13
  const fs = require("node:fs");
14
14
  const os = require("node:os");
15
15
  const path = require("node:path");
16
+ const { userDataDir: defaultUserDataDir } = require("../core/paths.cjs");
16
17
  const {
17
18
  hasSensitiveRuntimeArgument,
18
19
  mcpRuntimeHome,
@@ -40,6 +41,10 @@ const TOKEN_BUDGET = Object.freeze({
40
41
  experienceRetrievalMaxItems: 8,
41
42
  });
42
43
 
44
+ function resolvedMcpUserDataDir(value) {
45
+ return typeof value === "string" && value.trim() ? value : defaultUserDataDir();
46
+ }
47
+
43
48
  const MCP_REQUIREMENT_REQUIRED = [
44
49
  "schemaVersion", "kind", "requirementId", "catalogId", "reason", "capabilities",
45
50
  "required", "requiresKey", "priority", "permissions", "alternatives", "unavailablePolicy",
@@ -144,8 +149,12 @@ function validateMcpPolicy(value) {
144
149
 
145
150
  function loadProjectMcpPolicy(cwd) {
146
151
  const file = path.join(cwd || process.cwd(), ".agentlas", "mcp-policy.json");
147
- if (!fs.existsSync(file)) return null;
148
- const { value } = readJsonFile(file, "MCP policy");
152
+ let value;
153
+ try { ({ value } = readJsonFile(file, "MCP policy")); }
154
+ catch (error) {
155
+ if (error && error.code === "ENOENT") return null;
156
+ throw error;
157
+ }
149
158
  return validateMcpPolicy(value);
150
159
  }
151
160
 
@@ -154,9 +163,10 @@ function loadProjectMcpPolicy(cwd) {
154
163
  * 소스: 프로세스 env + userData/credentials.env + ~/.agentlas/credentials.env.
155
164
  */
156
165
  function readCredentialNames(userDataDir, env = process.env) {
166
+ const resolvedUserDataDir = resolvedMcpUserDataDir(userDataDir);
157
167
  const names = new Set(Object.keys(env || {}).filter((key) => ENV_RE.test(key) && env[key]));
158
168
  const files = [
159
- path.join(userDataDir, "credentials.env"),
169
+ path.join(resolvedUserDataDir, "credentials.env"),
160
170
  path.join(os.homedir(), ".agentlas", "credentials.env"),
161
171
  ];
162
172
  for (const file of files) {
@@ -193,7 +203,7 @@ function collectSystemMcpInventory(db, options = {}) {
193
203
  rows = [];
194
204
  registryStatus = "unavailable";
195
205
  }
196
- const credentialNames = readCredentialNames(options.userDataDir || "", options.env || process.env);
206
+ const credentialNames = readCredentialNames(options.userDataDir, options.env || process.env);
197
207
  const inventory = [];
198
208
  const seen = new Set();
199
209
  for (const row of rows) {
@@ -279,7 +289,7 @@ function materializeTrustedSystemMcpServer(row, options = {}) {
279
289
  });
280
290
  if (options.createRuntimeHome !== false) {
281
291
  Object.defineProperty(server, "mcpRuntimeHome", {
282
- value: mcpRuntimeHome(options.userDataDir, `${catalogId}\u0000${row.id}`),
292
+ value: mcpRuntimeHome(resolvedMcpUserDataDir(options.userDataDir), `${catalogId}\u0000${row.id}`),
283
293
  enumerable: false,
284
294
  });
285
295
  }
@@ -58,7 +58,11 @@ function lexicalRequirementHintIds(request, inventory) {
58
58
  const text = String(request || "").toLowerCase();
59
59
  const ids = [];
60
60
  for (const item of inventory) {
61
- const direct = text.includes(String(item.catalogId).toLowerCase()) || text.includes(String(item.name || "").toLowerCase());
61
+ const catalogId = String(item.catalogId || "").trim().toLowerCase();
62
+ const name = String(item.name || "").trim().toLowerCase();
63
+ // String#includes("") is always true. A registry row whose optional display
64
+ // name is empty must not become a hint for every unrelated build request.
65
+ const direct = Boolean((catalogId && text.includes(catalogId)) || (name && text.includes(name)));
62
66
  const heuristic = HEURISTIC_GROUPS.some(([nameRe, taskRe]) => nameRe.test(`${item.catalogId} ${item.name}`) && taskRe.test(text));
63
67
  if (direct || heuristic) ids.push(item.catalogId);
64
68
  }
@@ -30,6 +30,7 @@ function probeSystemMcpServerConnection(server, options = {}) {
30
30
  let abortHandler = null;
31
31
  let forceKillTimer = null;
32
32
  let childClosed = false;
33
+ let writeMessage = () => false;
33
34
  const terminateChild = (signal) => {
34
35
  const pid = Number(child?.pid);
35
36
  // detached 자식은 프로세스 그룹(-pid)째로 종료해야 패키지 매니저가 띄운
@@ -63,12 +64,8 @@ function probeSystemMcpServerConnection(server, options = {}) {
63
64
  if (message.id === 1) {
64
65
  if (message.error || !message.result) return finish(false, "initialize_failed");
65
66
  initialized = true;
66
- try {
67
- child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`);
68
- child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })}\n`);
69
- } catch {
70
- finish(false, "connection_failed");
71
- }
67
+ if (!writeMessage({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })) return;
68
+ writeMessage({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
72
69
  } else if (message.id === 2 && initialized) {
73
70
  finish(
74
71
  !message.error && Boolean(message.result),
@@ -100,8 +97,13 @@ function probeSystemMcpServerConnection(server, options = {}) {
100
97
  }
101
98
  };
102
99
  const timer = setTimeout(() => finish(false, "connection_timeout"), timeoutMs);
100
+ const runtimeArgs = parseRuntimeServerArgs(server.args_json);
101
+ if (!runtimeArgs) {
102
+ finish(false, "invalid_runtime_args");
103
+ return;
104
+ }
103
105
  try {
104
- child = spawnImpl(server.command, parseRuntimeServerArgs(server.args_json) || [], {
106
+ child = spawnImpl(server.command, runtimeArgs, {
105
107
  cwd: options.cwd || process.cwd(),
106
108
  env: buildMcpChildEnv(options.env || process.env, server.credentialKeyNames || [], {
107
109
  runtimeHome: server.mcpRuntimeHome || mcpRuntimeHome(options.userDataDir, server.catalog_id || server.id || server.command),
@@ -110,6 +112,7 @@ function probeSystemMcpServerConnection(server, options = {}) {
110
112
  stdio: ["pipe", "pipe", "ignore"],
111
113
  });
112
114
  child.once("error", () => finish(false, "connection_failed"));
115
+ child.stdin.on?.("error", () => finish(false, "connection_failed"));
113
116
  child.once("close", () => {
114
117
  childClosed = true;
115
118
  if (forceKillTimer) clearTimeout(forceKillTimer);
@@ -122,7 +125,19 @@ function probeSystemMcpServerConnection(server, options = {}) {
122
125
  buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
123
126
  drain();
124
127
  });
125
- child.stdin.write(`${JSON.stringify({
128
+ writeMessage = (message) => {
129
+ if (settled) return false;
130
+ try {
131
+ child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
132
+ if (error) finish(false, "connection_failed");
133
+ });
134
+ return true;
135
+ } catch {
136
+ finish(false, "connection_failed");
137
+ return false;
138
+ }
139
+ };
140
+ writeMessage({
126
141
  jsonrpc: "2.0",
127
142
  id: 1,
128
143
  method: "initialize",
@@ -131,7 +146,7 @@ function probeSystemMcpServerConnection(server, options = {}) {
131
146
  capabilities: {},
132
147
  clientInfo: { name: "agentlas-terminal-build", version: "1" },
133
148
  },
134
- })}\n`);
149
+ });
135
150
  if (options.signal) {
136
151
  abortHandler = () => finish(false, "connection_timeout");
137
152
  if (options.signal.aborted) abortHandler();
@@ -19,6 +19,11 @@ const fs = require("node:fs");
19
19
  const path = require("node:path");
20
20
  const { tableExists, columnExists } = require("../core/db.cjs");
21
21
 
22
+ const MAX_MEMORY_EVENTS = 32;
23
+ const MAX_MEMORY_CONTENT_CHARS = 900;
24
+ const MAX_MEMORY_LOG_BYTES = 16 * 1024 * 1024;
25
+ const MAX_MEMORY_LOG_RECORD_BYTES = 16 * 1024;
26
+
22
27
  // 앱과 동일한 컴파일된 manifest — 빌트인 에이전트 + 메모리 아키텍처 상수.
23
28
  let _arch = null;
24
29
  function loadArch() {
@@ -45,13 +50,43 @@ const SECRET_RE = [/\b(?:sk|pk|rk)-[A-Za-z0-9]{16,}/, /AKIA[0-9A-Z]{16}/, /ghp_[
45
50
  * 이 모듈이 위장하지 않는다.
46
51
  */
47
52
  function logCli(projectPath, rec) {
48
- if (!projectPath) return;
53
+ if (!projectPath) return false;
54
+ let fd = null;
49
55
  try {
50
56
  const arch = loadArch();
51
- const dir = path.join(projectPath, arch.memoryDir);
52
- fs.mkdirSync(dir, { recursive: true });
53
- fs.appendFileSync(path.join(dir, arch.logFile), JSON.stringify(rec) + "\n", "utf8");
54
- } catch { /* ignore */ }
57
+ // Memory curation is not project initialization. Only an existing private
58
+ // .agentlas directory may receive the best-effort decision log.
59
+ const root = fs.realpathSync(path.resolve(projectPath));
60
+ const dir = path.join(root, arch.memoryDir || ".agentlas");
61
+ const dirStat = fs.lstatSync(dir);
62
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return false;
63
+ if (process.platform !== "win32" && (dirStat.mode & 0o077) !== 0) return false;
64
+
65
+ const line = Buffer.from(JSON.stringify(rec) + "\n", "utf8");
66
+ if (line.length <= 1 || line.length > MAX_MEMORY_LOG_RECORD_BYTES) return false;
67
+ const file = path.join(dir, arch.logFile || "memory-log.jsonl");
68
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
69
+ fd = fs.openSync(
70
+ file,
71
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | noFollow,
72
+ 0o600,
73
+ );
74
+ const opened = fs.fstatSync(fd);
75
+ const listed = fs.lstatSync(file);
76
+ if (
77
+ !opened.isFile() || opened.nlink !== 1 ||
78
+ !listed.isFile() || listed.isSymbolicLink() || listed.nlink !== 1 ||
79
+ opened.dev !== listed.dev || opened.ino !== listed.ino ||
80
+ opened.size + line.length > MAX_MEMORY_LOG_BYTES
81
+ ) return false;
82
+ const written = fs.writeSync(fd, line, 0, line.length, null);
83
+ if (written !== line.length) return false;
84
+ fs.fsyncSync(fd);
85
+ fs.fchmodSync(fd, 0o600);
86
+ const after = fs.fstatSync(fd);
87
+ return after.isFile() && after.nlink === 1 && after.dev === opened.dev && after.ino === opened.ino;
88
+ } catch { return false; }
89
+ finally { if (fd != null) try { fs.closeSync(fd); } catch { /* best effort */ } }
55
90
  }
56
91
 
57
92
  function coerceText(v, max) {
@@ -128,9 +163,9 @@ function curateCliReply(db, text, ctx) {
128
163
  if (!ctx || !Array.isArray(ctx.curatedMemories) || !memory) return;
129
164
  if (!ctx.curatedMemories.some((item) => item.id === memory.id)) ctx.curatedMemories.push(memory);
130
165
  };
131
- for (const ev of events) {
166
+ for (const ev of events.slice(0, MAX_MEMORY_EVENTS)) {
132
167
  const content = ev && typeof ev.content === "string" ? ev.content.trim() : "";
133
- if (!content) continue;
168
+ if (!content || content.length > MAX_MEMORY_CONTENT_CHARS) continue;
134
169
  if (ev.sensitivity === "secret" || SECRET_RE.some((re) => re.test(content))) continue;
135
170
  const kind = arch.kinds.includes(ev.memory_kind) ? ev.memory_kind : "fact";
136
171
  let scope = ev.suggested_scope === "agent_team"
@@ -146,17 +181,37 @@ function curateCliReply(db, text, ctx) {
146
181
  const scopedAgentId = scope === "agent_repo" ? (ctx.agentId || null) : null;
147
182
  const requestContext = normalizeRequestContext(ev, ctx, ppath);
148
183
  try {
149
- const dup = db.prepare("SELECT id,scope,kind,content,confidence,sensitivity,context_json FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) AND (agent_id IS ? OR agent_id=?) LIMIT 1").get(scope, kind, content.toLowerCase(), ppath, ppath, scopedAgentId, scopedAgentId);
150
- if (dup) {
151
- rememberCurated({ ...dup, requestContext });
184
+ const memoryId = randomUUID();
185
+ const confidence = ["low", "medium", "high"].includes(ev.confidence) ? ev.confidence : "medium";
186
+ const sensitivity = ["public", "internal", "private"].includes(ev.sensitivity) ? ev.sensitivity : "internal";
187
+ const evidence = Array.isArray(ev.evidence_refs)
188
+ ? ev.evidence_refs.filter((item) => typeof item === "string").slice(0, 16).map((item) => item.slice(0, 240))
189
+ : [];
190
+ // One statement owns both the duplicate observation and insertion. A
191
+ // SELECT followed by INSERT allowed two Terminal processes to observe
192
+ // absence and persist the same memory under different UUIDs.
193
+ const inserted = db.prepare(`
194
+ INSERT INTO memory_entries
195
+ (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at)
196
+ SELECT ?,?,?,?,?,?,?,?,?,?,?,?,NULL,?
197
+ WHERE NOT EXISTS (
198
+ SELECT 1 FROM memory_entries
199
+ WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL
200
+ AND (project_path IS ? OR project_path=?)
201
+ AND (agent_id IS ? OR agent_id=?)
202
+ )
203
+ `).run(
204
+ memoryId, scope, kind, content, ctx.projectId || null, ppath, scopedAgentId, null,
205
+ confidence, sensitivity, JSON.stringify(evidence), JSON.stringify(requestContext), now,
206
+ scope, kind, content.toLowerCase(), ppath, ppath, scopedAgentId, scopedAgentId,
207
+ );
208
+ if ((inserted.changes ?? inserted.rowsAffected ?? 0) > 0) {
209
+ rememberCurated({ id: memoryId, scope, kind, content, confidence, sensitivity, requestContext });
210
+ logCli(ctx.projectPath, { action: "written", scope, kind, content, request_context: requestContext, at: now });
152
211
  continue;
153
212
  }
154
- const memoryId = randomUUID();
155
- const confidence = ev.confidence || "medium";
156
- const sensitivity = ev.sensitivity || "internal";
157
- db.prepare("INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)").run(memoryId, scope, kind, content, ctx.projectId || null, ppath, scopedAgentId, null, confidence, sensitivity, JSON.stringify(Array.isArray(ev.evidence_refs) ? ev.evidence_refs : []), JSON.stringify(requestContext), now);
158
- rememberCurated({ id: memoryId, scope, kind, content, confidence, sensitivity, requestContext });
159
- logCli(ctx.projectPath, { action: "written", scope, kind, content, request_context: requestContext, at: now });
213
+ const dup = db.prepare("SELECT id,scope,kind,content,confidence,sensitivity,context_json FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) AND (agent_id IS ? OR agent_id=?) LIMIT 1").get(scope, kind, content.toLowerCase(), ppath, ppath, scopedAgentId, scopedAgentId);
214
+ if (dup) rememberCurated({ ...dup, requestContext });
160
215
  } catch { /* ignore */ }
161
216
  }
162
217
  return style.sanitizeAssistantText(cleaned);
@@ -170,4 +225,7 @@ module.exports = {
170
225
  normalizeRequestContext,
171
226
  parseMemoryEventsCli,
172
227
  curateCliReply,
228
+ MAX_MEMORY_EVENTS,
229
+ MAX_MEMORY_CONTENT_CHARS,
230
+ MAX_MEMORY_LOG_BYTES,
173
231
  };
@@ -21,22 +21,47 @@ function fail(msg, code) {
21
21
  throw new OberonFail(String(msg ?? ""), code);
22
22
  }
23
23
 
24
- // v1 oberonParseFlags 그대로: `--key value` / `--key`(불리언) / 나머지는 위치 인자.
25
- // 다음 토큰이 `--`로 시작하면 값이 아니라 다른 플래그로 본다.
26
- function parseFlags(args) {
24
+ // 타입 스키마가 있으면 없는/중복/값 누락 플래그를 fail-closed 한다.
25
+ // `--key=value`와 위치 인자용 `--` sentinel도 지원한다.
26
+ function parseFlags(args, schema = null) {
27
27
  const flags = {};
28
28
  const rest = [];
29
29
  for (let i = 0; i < args.length; i++) {
30
- const a = args[i];
31
- if (a.startsWith("--")) {
32
- const key = a.slice(2);
30
+ const a = String(args[i]);
31
+ if (a === "--") {
32
+ rest.push(...args.slice(i + 1).map(String));
33
+ break;
34
+ }
35
+ if (!a.startsWith("--")) { rest.push(a); continue; }
36
+ const equals = a.indexOf("=");
37
+ const key = a.slice(2, equals >= 0 ? equals : undefined);
38
+ const inlineValue = equals >= 0 ? a.slice(equals + 1) : null;
39
+ if (!key) fail("Oberon option name is empty");
40
+ if (Object.prototype.hasOwnProperty.call(flags, key)) fail(`Duplicate Oberon option: --${key}`);
41
+ const type = schema && schema[key];
42
+ if (schema && !type) fail(`Unknown Oberon option: --${key}`);
43
+ if (!schema) {
33
44
  const next = args[i + 1];
34
- if (next === undefined || next.startsWith("--")) flags[key] = true;
35
- else {
36
- flags[key] = next;
37
- i++;
38
- }
39
- } else rest.push(a);
45
+ if (inlineValue != null) flags[key] = inlineValue;
46
+ else if (next === undefined || String(next).startsWith("--")) flags[key] = true;
47
+ else { flags[key] = String(next); i += 1; }
48
+ continue;
49
+ }
50
+ if (type === "boolean") {
51
+ if (inlineValue != null) fail(`Boolean Oberon option does not take a value: --${key}`);
52
+ flags[key] = true;
53
+ continue;
54
+ }
55
+ if (type !== "value") fail(`Invalid Oberon option schema: --${key}`);
56
+ if (inlineValue != null) {
57
+ if (!inlineValue) fail(`Oberon option requires a value: --${key}`);
58
+ flags[key] = inlineValue;
59
+ continue;
60
+ }
61
+ const next = args[i + 1];
62
+ if (next === undefined || String(next).startsWith("--")) fail(`Oberon option requires a value: --${key}`);
63
+ flags[key] = String(next);
64
+ i += 1;
40
65
  }
41
66
  return { flags, rest };
42
67
  }