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
@@ -10,9 +10,257 @@
10
10
  */
11
11
  const fs = require("node:fs");
12
12
  const path = require("node:path");
13
+ const crypto = require("node:crypto");
13
14
  const { loadArch } = require("../core/db.cjs");
14
15
  const { runCwd } = require("./paths.cjs");
15
16
 
17
+ const MAX_MANAGED_CREDENTIAL_METADATA_BYTES = 1024 * 1024;
18
+ const MAX_CREDENTIAL_FILE_BYTES = 16 * 1024 * 1024;
19
+ const CREDENTIAL_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
20
+ const credentialDestinationBindings = new Map();
21
+
22
+ function credentialSameDirectoryIdentity(left, right) {
23
+ return Boolean(
24
+ left && right && left.isDirectory() && right.isDirectory() &&
25
+ !left.isSymbolicLink() && !right.isSymbolicLink() &&
26
+ left.dev === right.dev && left.ino === right.ino,
27
+ );
28
+ }
29
+
30
+ function credentialDirectoryAnchor(target, label, containedBy = null) {
31
+ const stat = fs.lstatSync(target);
32
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} must be a real managed directory`);
33
+ const realpath = fs.realpathSync.native(target);
34
+ if (containedBy && !(
35
+ realpath === containedBy.realpath || realpath.startsWith(`${containedBy.realpath}${path.sep}`)
36
+ )) throw new Error(`${label} escaped the managed project`);
37
+ return { path: target, realpath, dev: stat.dev, ino: stat.ino, stat };
38
+ }
39
+
40
+ function credentialAssertDirectoryAnchor(anchor, label, containedBy = null) {
41
+ const current = credentialDirectoryAnchor(anchor.path, label, containedBy);
42
+ if (!credentialSameDirectoryIdentity(anchor.stat || anchor, current.stat || current) || current.realpath !== anchor.realpath) {
43
+ throw new Error(`${label} changed while it was being used`);
44
+ }
45
+ return current;
46
+ }
47
+
48
+ function credentialSameFileIdentity(left, right) {
49
+ return Boolean(
50
+ left && right && left.isFile() && right.isFile() &&
51
+ !left.isSymbolicLink() && !right.isSymbolicLink() &&
52
+ left.dev === right.dev && left.ino === right.ino,
53
+ );
54
+ }
55
+
56
+ function credentialSameFileSnapshot(left, right) {
57
+ return credentialSameFileIdentity(left, right) && left.nlink === right.nlink &&
58
+ left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
59
+ }
60
+
61
+ function credentialFileSnapshot(file, label, { allowMissing = false, allowHardLinks = false } = {}) {
62
+ let stat;
63
+ try { stat = fs.lstatSync(file); }
64
+ catch (error) {
65
+ if (allowMissing && error && error.code === "ENOENT") return null;
66
+ throw error;
67
+ }
68
+ if (!stat.isFile() || stat.isSymbolicLink() || (!allowHardLinks && stat.nlink !== 1) || stat.size > MAX_CREDENTIAL_FILE_BYTES) {
69
+ throw new Error(`${label} must be a bounded regular non-symbolic-link file`);
70
+ }
71
+ return stat;
72
+ }
73
+
74
+ function credentialRemoveOwnedFile(file, expected, { allowLinked = false } = {}) {
75
+ try {
76
+ const current = fs.lstatSync(file);
77
+ if (credentialSameFileIdentity(current, expected) && (current.nlink === 1 || (allowLinked && current.nlink >= 2))) {
78
+ fs.unlinkSync(file);
79
+ }
80
+ } catch { /* leave unknown successors and recovery artifacts untouched */ }
81
+ }
82
+
83
+ function credentialRestoreBackup(backup, target, expected, parent) {
84
+ try {
85
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
86
+ if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) return false;
87
+ const current = credentialFileSnapshot(backup, "credential destination backup");
88
+ if (!current || !credentialSameFileIdentity(current, expected) || current.nlink !== 1) return false;
89
+ fs.linkSync(backup, target);
90
+ const restored = credentialFileSnapshot(target, "credential destination restored", { allowHardLinks: true });
91
+ if (!restored || !credentialSameFileIdentity(restored, expected) || restored.nlink < 2) return false;
92
+ fs.unlinkSync(backup);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function credentialPublishFile(parent, targetName, temporary, expected) {
100
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
101
+ const target = path.join(parent.realpath, targetName);
102
+ const current = credentialFileSnapshot(target, "credential destination", { allowMissing: true });
103
+ if ((expected && (!current || !credentialSameFileSnapshot(current, expected))) || (!expected && current)) {
104
+ throw new Error("credential destination changed before replacement");
105
+ }
106
+ let backup = null;
107
+ let linked = false;
108
+ try {
109
+ if (expected) {
110
+ backup = `${target}.previous-${process.pid}-${crypto.randomUUID()}`;
111
+ fs.renameSync(target, backup);
112
+ const moved = credentialFileSnapshot(backup, "credential destination backup");
113
+ if (!moved || !credentialSameFileIdentity(moved, expected) || moved.nlink !== 1) {
114
+ credentialRestoreBackup(backup, target, expected, parent);
115
+ throw new Error("credential destination changed before replacement");
116
+ }
117
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
118
+ if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) {
119
+ throw new Error("credential destination successor appeared during replacement");
120
+ }
121
+ }
122
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
123
+ if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) {
124
+ throw new Error("credential destination successor appeared during replacement");
125
+ }
126
+ fs.linkSync(temporary.path, target);
127
+ linked = true;
128
+ const linkedTarget = credentialFileSnapshot(target, "credential destination", { allowHardLinks: true });
129
+ if (!linkedTarget || !credentialSameFileIdentity(linkedTarget, temporary.stat) || linkedTarget.nlink < 2) {
130
+ throw new Error("credential publication produced an unsafe target");
131
+ }
132
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
133
+ credentialRemoveOwnedFile(temporary.path, temporary.stat, { allowLinked: true });
134
+ const installed = credentialFileSnapshot(target, "credential destination");
135
+ if (!installed || !credentialSameFileIdentity(installed, temporary.stat) || installed.nlink !== 1) {
136
+ throw new Error("credential publication identity changed");
137
+ }
138
+ try { fs.chmodSync(target, 0o600); } catch { /* Windows/best-effort */ }
139
+ const final = credentialFileSnapshot(target, "credential destination");
140
+ if (!final || !credentialSameFileIdentity(final, temporary.stat) || final.nlink !== 1 ||
141
+ (process.platform !== "win32" && (final.mode & 0o777) !== 0o600)) {
142
+ throw new Error("credential publication mode or identity changed");
143
+ }
144
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
145
+ if (backup) {
146
+ const backupStat = credentialFileSnapshot(backup, "credential destination backup", { allowMissing: true });
147
+ if (backupStat && credentialSameFileIdentity(backupStat, expected) && backupStat.nlink === 1) fs.unlinkSync(backup);
148
+ }
149
+ return target;
150
+ } catch (error) {
151
+ if (linked) credentialRemoveOwnedFile(target, temporary.stat);
152
+ credentialRemoveOwnedFile(temporary.path, temporary.stat);
153
+ if (backup) credentialRestoreBackup(backup, target, expected, parent);
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ function credentialProjectRootCli(projectPath) {
159
+ const requested = path.resolve(projectPath || "");
160
+ const root = fs.realpathSync.native(requested);
161
+ const stat = fs.statSync(root);
162
+ if (!stat.isDirectory()) throw new Error("credential project must be a real directory");
163
+ return root;
164
+ }
165
+
166
+ function ensureManagedDirectoryCli(root, relativePath, mode = 0o700) {
167
+ let current = root;
168
+ for (const segment of String(relativePath || "").split(/[\\/]+/).filter(Boolean)) {
169
+ current = path.join(current, segment);
170
+ try {
171
+ const stat = fs.lstatSync(current);
172
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("credential store directories must not be symbolic links");
173
+ } catch (error) {
174
+ if (!error || error.code !== "ENOENT") throw error;
175
+ fs.mkdirSync(current, { recursive: false, mode });
176
+ }
177
+ }
178
+ return current;
179
+ }
180
+
181
+ function readManagedTextSnapshotCli(filePath, maxBytes = MAX_MANAGED_CREDENTIAL_METADATA_BYTES) {
182
+ let before;
183
+ try { before = fs.lstatSync(filePath); }
184
+ catch (error) {
185
+ if (error && error.code === "ENOENT") return { exists: false, text: "", mode: 0o600 };
186
+ throw error;
187
+ }
188
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes) {
189
+ throw new Error("credential store file must be a bounded regular non-symbolic-link file");
190
+ }
191
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
192
+ try {
193
+ const opened = fs.fstatSync(fd);
194
+ if (!opened.isFile() || opened.size > maxBytes || opened.dev !== before.dev || opened.ino !== before.ino) {
195
+ throw new Error("credential store file changed while opening");
196
+ }
197
+ return {
198
+ exists: true,
199
+ text: fs.readFileSync(fd, "utf8"),
200
+ mode: before.mode & 0o777,
201
+ stat: before,
202
+ };
203
+ } finally {
204
+ fs.closeSync(fd);
205
+ }
206
+ }
207
+
208
+ function assertManagedSnapshotUnchangedCli(filePath, snapshot) {
209
+ let current;
210
+ try { current = fs.lstatSync(filePath); }
211
+ catch (error) {
212
+ if (error && error.code === "ENOENT" && !snapshot.exists) return;
213
+ throw new Error("credential store file changed before replacement");
214
+ }
215
+ if (
216
+ !snapshot.exists || !current.isFile() || current.isSymbolicLink() || !snapshot.stat ||
217
+ current.dev !== snapshot.stat.dev || current.ino !== snapshot.stat.ino ||
218
+ current.size !== snapshot.stat.size || current.mtimeMs !== snapshot.stat.mtimeMs
219
+ ) throw new Error("credential store file changed before replacement");
220
+ }
221
+
222
+ function replaceManagedFileCli(tempPath, filePath, snapshot) {
223
+ assertManagedSnapshotUnchangedCli(filePath, snapshot);
224
+ try {
225
+ fs.renameSync(tempPath, filePath);
226
+ return;
227
+ } catch (error) {
228
+ if (
229
+ process.platform !== "win32" || !snapshot.exists ||
230
+ !["EEXIST", "EPERM", "EACCES"].includes(error && error.code)
231
+ ) throw error;
232
+ }
233
+ assertManagedSnapshotUnchangedCli(filePath, snapshot);
234
+ const backup = `${filePath}.agentlas-${process.pid}-${crypto.randomUUID()}.bak`;
235
+ fs.renameSync(filePath, backup);
236
+ try {
237
+ fs.renameSync(tempPath, filePath);
238
+ } catch (error) {
239
+ try { if (!fs.existsSync(filePath)) fs.renameSync(backup, filePath); } catch { /* leave recoverable backup */ }
240
+ throw error;
241
+ }
242
+ try { fs.rmSync(backup, { force: true }); } catch { /* committed target is authoritative */ }
243
+ }
244
+
245
+ function writeManagedTextAtomicCli(filePath, text, options = {}) {
246
+ const snapshot = options.snapshot || readManagedTextSnapshotCli(filePath, options.maxBytes);
247
+ const bytes = Buffer.byteLength(text, "utf8");
248
+ if (bytes > (options.maxBytes || MAX_MANAGED_CREDENTIAL_METADATA_BYTES)) throw new Error("credential store file exceeds its safety limit");
249
+ const temp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
250
+ try {
251
+ fs.writeFileSync(temp, text, { encoding: "utf8", mode: options.mode || snapshot.mode || 0o600, flag: "wx" });
252
+ replaceManagedFileCli(temp, filePath, snapshot);
253
+ } finally {
254
+ try { fs.rmSync(temp, { force: true }); } catch { /* best-effort cleanup */ }
255
+ }
256
+ }
257
+
258
+ function ensureManagedTextFileCli(filePath, content, mode = 0o600) {
259
+ const snapshot = readManagedTextSnapshotCli(filePath);
260
+ if (!snapshot.exists) writeManagedTextAtomicCli(filePath, content, { snapshot, mode });
261
+ return snapshot.exists ? snapshot.text : content;
262
+ }
263
+
16
264
  function localCredentialConfigCli(arch) {
17
265
  return {
18
266
  mapFile: arch.localCredentialsMapFile || "local-credentials.map.json",
@@ -94,24 +342,24 @@ ${credentialIndexSectionContentCli(arch)}
94
342
  `;
95
343
  }
96
344
  function ensureSoulCredentialIndexCli(projectPath, projectName, arch) {
345
+ const root = credentialProjectRootCli(projectPath);
97
346
  const memoryDir = (arch && arch.memoryDir) || ".agentlas";
98
347
  const soulFile = (arch && arch.soulFile) || "project-soul-memory.md";
99
- const dir = path.join(projectPath, memoryDir);
348
+ const dir = ensureManagedDirectoryCli(root, memoryDir);
100
349
  const soul = path.join(dir, soulFile);
101
- fs.mkdirSync(dir, { recursive: true });
102
- if (!fs.existsSync(soul)) {
103
- fs.writeFileSync(soul, projectSoulTemplateCli(projectName, arch), "utf8");
350
+ const snapshot = readManagedTextSnapshotCli(soul);
351
+ if (!snapshot.exists) {
352
+ writeManagedTextAtomicCli(soul, projectSoulTemplateCli(projectName, arch), { snapshot, mode: 0o600 });
104
353
  return soul;
105
354
  }
106
- let content = "";
107
- try { content = fs.readFileSync(soul, "utf8"); } catch { content = ""; }
355
+ const content = snapshot.text;
108
356
  if (!content.includes(CREDENTIAL_INDEX_SECTION_CLI)) {
109
357
  const section = credentialIndexSectionContentCli(arch);
110
358
  const marker = "\n## Project Purpose";
111
359
  const next = content.includes(marker)
112
360
  ? content.replace(marker, `\n${section}\n## Project Purpose`)
113
361
  : `${content.trimEnd()}\n\n${section}\n`;
114
- fs.writeFileSync(soul, next.endsWith("\n") ? next : next + "\n", "utf8");
362
+ writeManagedTextAtomicCli(soul, next.endsWith("\n") ? next : next + "\n", { snapshot, mode: 0o600 });
115
363
  }
116
364
  return soul;
117
365
  }
@@ -157,7 +405,8 @@ Do not commit files from this folder.
157
405
  `;
158
406
  }
159
407
  function ensureAgentlasCredentialIgnoreCli(projectPath, cfg) {
160
- const gitignorePath = path.join(projectPath, ".gitignore");
408
+ const root = credentialProjectRootCli(projectPath);
409
+ const gitignorePath = path.join(root, ".gitignore");
161
410
  const marker = "# Agentlas local credentials";
162
411
  const block = `${marker}
163
412
  .env
@@ -171,45 +420,62 @@ ${cfg.credentialsDir}/*
171
420
  !${cfg.credentialsDir}/
172
421
  !${cfg.credentialsDir}/${cfg.readmeFile}
173
422
  `;
174
- let existing = "";
175
- try { existing = fs.readFileSync(gitignorePath, "utf8"); } catch { existing = ""; }
423
+ const snapshot = readManagedTextSnapshotCli(gitignorePath);
424
+ const existing = snapshot.text;
176
425
  if (existing.includes(marker)) {
177
- if (!/^\._\*$/m.test(existing)) fs.writeFileSync(gitignorePath, `${existing.trimEnd()}\n._*\n`, "utf8");
426
+ if (!/^\._\*$/m.test(existing)) {
427
+ writeManagedTextAtomicCli(gitignorePath, `${existing.trimEnd()}\n._*\n`, { snapshot, mode: snapshot.mode || 0o644 });
428
+ }
178
429
  return;
179
430
  }
180
431
  const next = existing.trimEnd() ? `${existing.trimEnd()}\n\n${block}` : block;
181
- fs.writeFileSync(gitignorePath, next.endsWith("\n") ? next : next + "\n", "utf8");
432
+ writeManagedTextAtomicCli(gitignorePath, next.endsWith("\n") ? next : next + "\n", {
433
+ snapshot,
434
+ mode: snapshot.exists ? snapshot.mode : 0o644,
435
+ });
182
436
  }
183
437
  function ensureLocalCredentialStoreCli(projectPath, projectName, arch) {
438
+ const root = credentialProjectRootCli(projectPath);
184
439
  const cfg = localCredentialConfigCli(arch || loadArch());
185
- const dir = path.join(projectPath, (arch && arch.memoryDir) || ".agentlas");
186
- fs.mkdirSync(dir, { recursive: true });
187
- fs.mkdirSync(path.join(projectPath, cfg.signingDir), { recursive: true });
188
- fs.mkdirSync(path.join(projectPath, cfg.credentialsDir), { recursive: true });
189
- const envExample = path.join(projectPath, cfg.envExampleFile);
190
- if (!fs.existsSync(envExample)) fs.writeFileSync(envExample, envExampleContentCli(cfg), "utf8");
191
- const signingReadme = path.join(projectPath, cfg.signingDir, cfg.readmeFile);
192
- if (!fs.existsSync(signingReadme)) fs.writeFileSync(signingReadme, signingReadmeContentCli(cfg), "utf8");
193
- const credentialsReadme = path.join(projectPath, cfg.credentialsDir, cfg.readmeFile);
194
- if (!fs.existsSync(credentialsReadme)) fs.writeFileSync(credentialsReadme, credentialsReadmeContentCli(cfg), "utf8");
440
+ const dir = ensureManagedDirectoryCli(root, (arch && arch.memoryDir) || ".agentlas");
441
+ const signingDir = ensureManagedDirectoryCli(root, cfg.signingDir);
442
+ const credentialsDir = ensureManagedDirectoryCli(root, cfg.credentialsDir);
443
+ const envExample = path.join(root, cfg.envExampleFile);
444
+ ensureManagedTextFileCli(envExample, envExampleContentCli(cfg), 0o600);
445
+ const signingReadme = path.join(signingDir, cfg.readmeFile);
446
+ ensureManagedTextFileCli(signingReadme, signingReadmeContentCli(cfg), 0o600);
447
+ const credentialsReadme = path.join(credentialsDir, cfg.readmeFile);
448
+ ensureManagedTextFileCli(credentialsReadme, credentialsReadmeContentCli(cfg), 0o600);
195
449
  const mapPath = path.join(dir, cfg.mapFile);
196
- if (!fs.existsSync(mapPath)) {
197
- fs.writeFileSync(mapPath, JSON.stringify(localCredentialsMapSkeletonCli(projectPath, projectName, cfg), null, 2) + "\n", "utf8");
450
+ const mapText = ensureManagedTextFileCli(
451
+ mapPath,
452
+ JSON.stringify(localCredentialsMapSkeletonCli(root, projectName, cfg), null, 2) + "\n",
453
+ 0o600,
454
+ );
455
+ let mapValue;
456
+ try { mapValue = JSON.parse(mapText); }
457
+ catch (error) { throw new Error(`credential map is invalid JSON: ${String((error && error.message) || error)}`); }
458
+ if (!mapValue || typeof mapValue !== "object" || Array.isArray(mapValue)) {
459
+ throw new Error("credential map must contain a JSON object");
198
460
  }
199
- ensureAgentlasCredentialIgnoreCli(projectPath, cfg);
200
- return { cfg, mapPath };
461
+ ensureAgentlasCredentialIgnoreCli(root, cfg);
462
+ return { cfg, mapPath, projectRoot: root };
201
463
  }
202
- function readJsonObjectCli(file, fallback) {
464
+ function readJsonObjectCli(file, fallback, options = {}) {
203
465
  try {
204
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
205
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
206
- } catch {
466
+ const parsed = JSON.parse(readManagedTextSnapshotCli(file).text);
467
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
468
+ if (options.strict) throw new Error("credential map must contain a JSON object");
469
+ return fallback;
470
+ } catch (error) {
471
+ if (options.strict) throw new Error(`credential map is unreadable or invalid: ${String((error && error.message) || error)}`);
207
472
  return fallback;
208
473
  }
209
474
  }
210
475
  function upsertLocalCredentialMapCli(projectPath, projectName, arch, entry) {
211
- const { cfg, mapPath } = ensureLocalCredentialStoreCli(projectPath, projectName, arch || loadArch());
212
- const data = readJsonObjectCli(mapPath, localCredentialsMapSkeletonCli(projectPath, projectName, cfg));
476
+ const { cfg, mapPath, projectRoot } = ensureLocalCredentialStoreCli(projectPath, projectName, arch || loadArch());
477
+ const snapshot = readManagedTextSnapshotCli(mapPath);
478
+ const data = readJsonObjectCli(mapPath, localCredentialsMapSkeletonCli(projectPath, projectName, cfg), { strict: true });
213
479
  const now = new Date().toISOString();
214
480
  data.updatedAt = now;
215
481
  if (!Array.isArray(data.entries)) data.entries = [];
@@ -230,17 +496,96 @@ function upsertLocalCredentialMapCli(projectPath, projectName, arch, entry) {
230
496
  const idx = data.entries.findIndex((row) => row && row.id === id);
231
497
  if (idx >= 0) data.entries[idx] = { ...data.entries[idx], ...clean };
232
498
  else data.entries.push(clean);
233
- fs.writeFileSync(mapPath, JSON.stringify(data, null, 2) + "\n", "utf8");
499
+ data.projectRoot = projectRoot;
500
+ writeManagedTextAtomicCli(mapPath, JSON.stringify(data, null, 2) + "\n", { snapshot, mode: 0o600 });
234
501
  }
235
502
  // v1은 fail()로 즉시 종료했다 — v2 규칙은 throw → 명령이 ctx.err + return 1 로 변환.
236
503
  function safeCredentialDestRelCli(destRel) {
237
504
  const rel = path.normalize(String(destRel || "")).replace(/\\/g, "/");
238
- if (!rel || path.isAbsolute(rel) || rel === "." || rel.startsWith("../") || rel.includes("\0")) {
505
+ if (!rel || path.isAbsolute(rel) || rel === "." || rel.startsWith("../") || /[\u0000-\u001f\u007f]/.test(rel)) {
239
506
  throw new Error("credential destination must be a relative path inside the project");
240
507
  }
241
508
  return rel;
242
509
  }
243
510
 
511
+ function resolveCredentialDestinationCli(projectPath, destRel) {
512
+ const root = credentialProjectRootCli(projectPath);
513
+ const rel = safeCredentialDestRelCli(destRel);
514
+ const rootAnchor = credentialDirectoryAnchor(root, "credential project root");
515
+ const parentRel = path.dirname(rel);
516
+ const parent = parentRel === "." ? root : ensureManagedDirectoryCli(root, parentRel);
517
+ const realParent = fs.realpathSync.native(parent);
518
+ const relativeParent = path.relative(root, realParent);
519
+ if (path.isAbsolute(relativeParent) || relativeParent === ".." || relativeParent.startsWith(`..${path.sep}`)) {
520
+ throw new Error("credential destination escaped the project");
521
+ }
522
+ const parentAnchor = credentialDirectoryAnchor(realParent, "credential destination directory", rootAnchor);
523
+ const destination = path.join(parentAnchor.realpath, path.basename(rel));
524
+ credentialDestinationBindings.set(path.resolve(destination), {
525
+ root: rootAnchor,
526
+ parent: parentAnchor,
527
+ });
528
+ return destination;
529
+ }
530
+
531
+ function copyCredentialFileAtomicCli(sourcePath, destinationPath, options = {}) {
532
+ const resolvedDestination = path.resolve(destinationPath);
533
+ const binding = credentialDestinationBindings.get(resolvedDestination) || null;
534
+ try {
535
+ const source = fs.realpathSync.native(path.resolve(sourcePath));
536
+ const sourceStat = fs.statSync(source);
537
+ if (!sourceStat.isFile() || sourceStat.size <= 0 || sourceStat.size > MAX_CREDENTIAL_FILE_BYTES) {
538
+ throw new Error("credential source must be a non-empty regular file no larger than 16 MiB");
539
+ }
540
+ const root = binding ? credentialAssertDirectoryAnchor(binding.root, "credential project root") : null;
541
+ const parent = binding
542
+ ? credentialAssertDirectoryAnchor(binding.parent, "credential destination directory", root)
543
+ : credentialDirectoryAnchor(path.dirname(resolvedDestination), "credential destination directory");
544
+ if (path.dirname(resolvedDestination) !== parent.path || path.basename(resolvedDestination).includes(path.sep)) {
545
+ throw new Error("credential destination path is not anchored to its managed directory");
546
+ }
547
+ const destination = path.join(parent.realpath, path.basename(resolvedDestination));
548
+ const snapshot = (() => {
549
+ try {
550
+ const stat = credentialFileSnapshot(destination, "credential destination");
551
+ if (!options.force) throw new Error("credential destination already exists (use --force to replace)");
552
+ return { exists: true, stat };
553
+ } catch (error) {
554
+ if (error && error.code === "ENOENT") return { exists: false };
555
+ throw error;
556
+ }
557
+ })();
558
+ const temp = path.join(parent.realpath, `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`);
559
+ let tempStat = null;
560
+ try {
561
+ credentialAssertDirectoryAnchor(parent, "credential destination directory", root);
562
+ fs.copyFileSync(source, temp, fs.constants.COPYFILE_EXCL);
563
+ tempStat = credentialFileSnapshot(temp, "credential temporary file");
564
+ if (!tempStat || tempStat.size !== sourceStat.size) {
565
+ throw new Error("credential copy did not produce the exact regular file");
566
+ }
567
+ let readFd;
568
+ try {
569
+ readFd = fs.openSync(temp, fs.constants.O_RDONLY | CREDENTIAL_NOFOLLOW);
570
+ const opened = fs.fstatSync(readFd);
571
+ if (!credentialSameFileSnapshot(opened, tempStat) || opened.size !== sourceStat.size) {
572
+ throw new Error("credential temporary file changed while opening");
573
+ }
574
+ } finally {
575
+ if (readFd !== undefined) try { fs.closeSync(readFd); } catch { /* preserve original failure */ }
576
+ }
577
+ try { fs.chmodSync(temp, 0o600); } catch { /* Windows/best-effort */ }
578
+ credentialAssertDirectoryAnchor(parent, "credential destination directory", root);
579
+ credentialPublishFile(parent, path.basename(destination), { path: temp, stat: tempStat }, snapshot.exists ? snapshot.stat : null);
580
+ } finally {
581
+ if (tempStat) credentialRemoveOwnedFile(temp, tempStat);
582
+ }
583
+ return destinationPath;
584
+ } finally {
585
+ if (binding) credentialDestinationBindings.delete(resolvedDestination);
586
+ }
587
+ }
588
+
244
589
  module.exports = {
245
590
  CREDENTIAL_INDEX_SECTION_CLI,
246
591
  localCredentialConfigCli,
@@ -259,4 +604,7 @@ module.exports = {
259
604
  readJsonObjectCli,
260
605
  upsertLocalCredentialMapCli,
261
606
  safeCredentialDestRelCli,
607
+ credentialProjectRootCli,
608
+ resolveCredentialDestinationCli,
609
+ copyCredentialFileAtomicCli,
262
610
  };
@@ -8,13 +8,85 @@
8
8
  */
9
9
  const fs = require("node:fs");
10
10
  const path = require("node:path");
11
+ const crypto = require("node:crypto");
12
+
13
+ const MAX_DOT_ENV_BYTES = 512 * 1024;
14
+
15
+ function readEnvSnapshot(file) {
16
+ let before;
17
+ try {
18
+ before = fs.lstatSync(file);
19
+ } catch (error) {
20
+ if (error && error.code === "ENOENT") return { exists: false, body: "" };
21
+ throw error;
22
+ }
23
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_DOT_ENV_BYTES) {
24
+ throw new Error("credential env target must be a bounded regular non-symbolic-link file");
25
+ }
26
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
27
+ try {
28
+ const opened = fs.fstatSync(fd);
29
+ if (
30
+ !opened.isFile() || opened.size > MAX_DOT_ENV_BYTES ||
31
+ opened.dev !== before.dev || opened.ino !== before.ino
32
+ ) throw new Error("credential env target changed while opening");
33
+ return {
34
+ exists: true,
35
+ body: fs.readFileSync(fd, "utf8"),
36
+ dev: opened.dev,
37
+ ino: opened.ino,
38
+ size: opened.size,
39
+ mtimeMs: opened.mtimeMs,
40
+ ctimeMs: opened.ctimeMs,
41
+ };
42
+ } finally {
43
+ fs.closeSync(fd);
44
+ }
45
+ }
46
+
47
+ function assertEnvSnapshotUnchanged(file, snapshot) {
48
+ let current;
49
+ try { current = fs.lstatSync(file); }
50
+ catch (error) {
51
+ if (error && error.code === "ENOENT" && !snapshot.exists) return;
52
+ throw new Error("credential env target changed before replacement");
53
+ }
54
+ if (
55
+ !snapshot.exists || !current.isFile() || current.isSymbolicLink() ||
56
+ current.dev !== snapshot.dev || current.ino !== snapshot.ino ||
57
+ current.size !== snapshot.size || current.mtimeMs !== snapshot.mtimeMs || current.ctimeMs !== snapshot.ctimeMs
58
+ ) throw new Error("credential env target changed before replacement");
59
+ }
60
+
61
+ function replaceEnvFileAtomic(temp, file, snapshot) {
62
+ assertEnvSnapshotUnchanged(file, snapshot);
63
+ try {
64
+ fs.renameSync(temp, file);
65
+ return;
66
+ } catch (error) {
67
+ if (
68
+ process.platform !== "win32" || !snapshot.exists ||
69
+ !["EEXIST", "EPERM", "EACCES"].includes(error && error.code)
70
+ ) throw error;
71
+ }
72
+ assertEnvSnapshotUnchanged(file, snapshot);
73
+ const backup = `${file}.agentlas-${process.pid}-${crypto.randomUUID()}.bak`;
74
+ fs.renameSync(file, backup);
75
+ try {
76
+ fs.renameSync(temp, file);
77
+ } catch (error) {
78
+ try { if (!fs.existsSync(file)) fs.renameSync(backup, file); } catch { /* leave the backup recoverable */ }
79
+ throw error;
80
+ }
81
+ try { fs.rmSync(backup, { force: true }); } catch { /* committed target is authoritative */ }
82
+ }
11
83
 
12
84
  /** .env 파싱 — 값 보존 없이 키만 필요할 때도 같은 파서를 쓴다 (KEY=VALUE, # 주석). */
13
85
  function readDotEnvFile(file) {
14
86
  const result = {};
15
87
  let raw;
16
88
  try {
17
- raw = fs.readFileSync(file, "utf8");
89
+ raw = readEnvSnapshot(file).body;
18
90
  } catch {
19
91
  return result;
20
92
  }
@@ -30,17 +102,30 @@ function readDotEnvFile(file) {
30
102
  }
31
103
 
32
104
  function upsertEnvLine(file, key, value) {
33
- let body = "";
34
- try { body = fs.readFileSync(file, "utf8"); } catch { /* new file */ }
35
- const line = `${key}=${value}`;
105
+ if (!/^[A-Z][A-Z0-9_]*$/.test(String(key || ""))) throw new Error("credential env key must look like ENV_NAME");
106
+ const text = String(value == null ? "" : value);
107
+ if (/[\u0000\r\n]/.test(text)) throw new Error("credential env value must be a single line; use `agentlas creds file` for multiline credentials");
108
+ const snapshot = readEnvSnapshot(file);
109
+ let body = snapshot.body;
110
+ const line = `${key}=${text}`;
36
111
  const re = new RegExp("^" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "=.*$", "m");
37
- if (re.test(body)) body = body.replace(re, line);
112
+ // Replacement strings interpret `$&`, `$1`, and similar tokens. Credentials
113
+ // are opaque bytes, so use a function replacement and preserve them verbatim.
114
+ if (re.test(body)) body = body.replace(re, () => line);
38
115
  else body = body ? body.replace(/\n?$/, "\n") + line + "\n" : line + "\n";
39
- fs.mkdirSync(path.dirname(file), { recursive: true });
116
+ if (Buffer.byteLength(body, "utf8") > MAX_DOT_ENV_BYTES) throw new Error("credential env file exceeds the 512 KiB safety limit");
117
+ const directory = path.dirname(file);
118
+ fs.mkdirSync(directory, { recursive: true });
119
+ const temp = path.join(directory, `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
120
+ try {
121
+ fs.writeFileSync(temp, body, { encoding: "utf8", mode: 0o600, flag: "wx" });
122
+ replaceEnvFileAtomic(temp, file, snapshot);
123
+ } finally {
124
+ try { fs.rmSync(temp, { force: true }); } catch { /* best-effort cleanup */ }
125
+ }
40
126
  // 이 헬퍼의 모든 호출자는 credential 값/경로를 기록한다. 새 파일뿐 아니라 기존 0644
41
127
  // 파일도 매번 0600으로 수렴시켜 같은 머신의 다른 계정이 읽지 못하게 한다.
42
- fs.writeFileSync(file, body, { encoding: "utf8", mode: 0o600 });
43
128
  try { fs.chmodSync(file, 0o600); } catch { /* Windows/읽기전용 FS best-effort */ }
44
129
  }
45
130
 
46
- module.exports = { readDotEnvFile, upsertEnvLine };
131
+ module.exports = { MAX_DOT_ENV_BYTES, readDotEnvFile, upsertEnvLine };