agentlas 1.0.65 → 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 (163) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +6 -4
  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/browser/cdp.cjs +190 -17
  43. package/engine/browser/vault.cjs +1 -0
  44. package/engine/cli-output.cjs +80 -28
  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 +308 -113
  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 +42 -12
  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 +8 -2
  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 +29 -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 +230 -61
  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 +15 -5
  118. package/engine/mcp/plan.cjs +5 -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 +212 -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
@@ -67,12 +67,23 @@ function textOf(value) {
67
67
  return JSON.stringify(value);
68
68
  }
69
69
 
70
+ function terminalTextOf(value, maxLength = Number.POSITIVE_INFINITY) {
71
+ const text = textOf(value);
72
+ if (typeof text !== "string") return "";
73
+ return text
74
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
75
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
76
+ .replace(/\u001b[@-_]/g, "")
77
+ .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ")
78
+ .slice(0, maxLength);
79
+ }
80
+
70
81
  /**
71
82
  * 표시 폭 — ANSI 를 벗기고, CJK 전각을 2칸으로 센다.
72
83
  * 한글 표가 어긋나던 이유가 이것이다(폭을 문자 수로 세면 열이 밀린다).
73
84
  */
74
85
  function displayWidth(value) {
75
- const plain = String(value).replace(/\[[0-9;]*m/g, "");
86
+ const plain = terminalTextOf(value);
76
87
  let width = 0;
77
88
  for (const char of plain) {
78
89
  const code = char.codePointAt(0) ?? 0;
@@ -102,7 +113,7 @@ function padTo(value, width, align) {
102
113
  function renderQuiet(result) {
103
114
  const idField = result.schema.idField;
104
115
  return rowsOf(result)
105
- .map((item) => textOf(readField(item, idField)))
116
+ .map((item) => terminalTextOf(readField(item, idField)))
106
117
  .filter((line) => line.length > 0)
107
118
  .join("\n");
108
119
  }
@@ -122,31 +133,65 @@ function renderYaml(result) {
122
133
  if (value === null || value === undefined) return "null";
123
134
  if (typeof value === "number" || typeof value === "boolean") return String(value);
124
135
  const text = textOf(value);
125
- return /^[\w./:@-]+$/.test(text) ? text : JSON.stringify(text);
136
+ // YAML implicitly retypes unquoted strings such as yes/null/001/dates.
137
+ // Machine output must round-trip the command result without changing a
138
+ // string into a boolean, null, number, or timestamp. Conservatively leave
139
+ // only unambiguous word/path-like strings plain; JSON quoting is valid YAML.
140
+ const ambiguousWord = /^(?:~|null|true|false|yes|no|on|off|[-+]?\.?(?:inf|nan))$/i.test(text);
141
+ return /^[A-Za-z_./][\w./:@-]*$/.test(text) && !ambiguousWord ? text : JSON.stringify(text);
142
+ };
143
+ const isObject = (value) => value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
144
+ const assertBoundedTree = (root) => {
145
+ let nodes = 0;
146
+ const visit = (value, ancestors, depth) => {
147
+ if ((!isObject(value) && !Array.isArray(value)) || value instanceof Date) return;
148
+ if (depth > 64) throw new TypeError("YAML output exceeds the maximum nesting depth");
149
+ if (ancestors.has(value)) throw new TypeError("YAML output contains a circular reference");
150
+ nodes += 1;
151
+ if (nodes > 100_000) throw new TypeError("YAML output contains too many nodes");
152
+ const next = new Set(ancestors);
153
+ next.add(value);
154
+ for (const child of Array.isArray(value) ? value : Object.values(value)) visit(child, next, depth + 1);
155
+ };
156
+ visit(root, new Set(), 0);
126
157
  };
127
158
  const objectLines = (object, indent) => {
128
159
  const pad = " ".repeat(indent);
129
- return Object.entries(object).map(([key, value]) => {
130
- if (value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)) {
131
- return `${pad}${key}:\n${objectLines(value, indent + 2).join("\n")}`;
132
- }
133
- if (Array.isArray(value)) {
134
- if (value.length === 0) return `${pad}${key}: []`;
135
- return `${pad}${key}:\n${value.map((entry) => `${pad} - ${scalar(entry)}`).join("\n")}`;
160
+ const entries = Object.entries(object);
161
+ if (!entries.length) return [`${pad}{}`];
162
+ return entries.flatMap(([key, value]) => {
163
+ const renderedKey = scalar(key);
164
+ if (isObject(value) || Array.isArray(value)) {
165
+ const nested = value.length === 0 ? [`${pad} ${Array.isArray(value) ? "[]" : "{}"}`] : valueLines(value, indent + 2);
166
+ if (nested.length === 1 && /^(?:\{\}|\[\])$/.test(nested[0].trim())) return [`${pad}${renderedKey}: ${nested[0].trim()}`];
167
+ return [`${pad}${renderedKey}:`, ...nested];
136
168
  }
137
- return `${pad}${key}: ${scalar(value)}`;
169
+ return [`${pad}${renderedKey}: ${scalar(value)}`];
138
170
  });
139
171
  };
140
- if (result.type === "list") {
141
- if (result.data.length === 0) return "[]";
142
- return result.data
143
- .map((item) => {
144
- const lines = objectLines(project(item), 2);
145
- return `- ${lines.join("\n").trimStart()}`;
146
- })
147
- .join("\n");
148
- }
149
- return objectLines(project(result.data), 0).join("\n");
172
+ const valueLines = (value, indent) => {
173
+ const pad = " ".repeat(indent);
174
+ if (Array.isArray(value)) {
175
+ if (!value.length) return [`${pad}[]`];
176
+ return value.flatMap((entry) => {
177
+ if (isObject(entry)) {
178
+ const nested = objectLines(entry, indent + 2);
179
+ return [`${pad}- ${nested[0].trimStart()}`, ...nested.slice(1)];
180
+ }
181
+ if (Array.isArray(entry)) {
182
+ const nested = valueLines(entry, indent + 2);
183
+ return [`${pad}-`, ...nested];
184
+ }
185
+ return [`${pad}- ${scalar(entry)}`];
186
+ });
187
+ }
188
+ if (isObject(value)) return objectLines(value, indent);
189
+ return [`${pad}${scalar(value)}`];
190
+ };
191
+ const payload = result.type === "list" ? result.data.map(project) : project(result.data);
192
+ assertBoundedTree(payload);
193
+ if (result.type === "list" && result.data.length === 0) return "[]";
194
+ return valueLines(payload, 0).join("\n");
150
195
  }
151
196
 
152
197
  function renderTable(result, options) {
@@ -154,11 +199,11 @@ function renderTable(result, options) {
154
199
  const rows = rowsOf(result);
155
200
  if (rows.length === 0) return "";
156
201
  const cells = rows.map((item) =>
157
- columns.map((column) => textOf(readField(item, column.field))),
202
+ columns.map((column) => terminalTextOf(readField(item, column.field))),
158
203
  );
159
204
  const widths = columns.map((column, index) =>
160
205
  Math.max(
161
- options.noHeaders ? 0 : displayWidth(column.header),
206
+ options.noHeaders ? 0 : displayWidth(terminalTextOf(column.header)),
162
207
  ...cells.map((row) => displayWidth(row[index] ?? "")),
163
208
  ),
164
209
  );
@@ -166,7 +211,7 @@ function renderTable(result, options) {
166
211
  if (!options.noHeaders) {
167
212
  lines.push(
168
213
  columns
169
- .map((column, index) => padTo(column.header.toUpperCase(), widths[index], column.align))
214
+ .map((column, index) => padTo(terminalTextOf(column.header).toUpperCase(), widths[index], column.align))
170
215
  .join(" ")
171
216
  .trimEnd(),
172
217
  );
@@ -212,12 +257,13 @@ function renderError(error, options = {}) {
212
257
  const commandError = toCommandError(error);
213
258
  if (opts.format === "json") return JSON.stringify({ error: commandError }, null, 2);
214
259
  if (opts.format === "yaml") {
215
- return ["error:", ` code: ${commandError.code}`, ` message: ${JSON.stringify(commandError.message)}`].join("\n");
260
+ return ["error:", ` code: ${JSON.stringify(commandError.code)}`, ` message: ${JSON.stringify(commandError.message)}`].join("\n");
216
261
  }
217
262
  const prefix = opts.noColor ? "Error: " : "Error: ";
263
+ const safeMessage = terminalTextOf(commandError.message, 8192);
218
264
  return commandError.details && typeof commandError.details === "string" && opts.format === "table"
219
- ? `${prefix}${commandError.message}`
220
- : `${prefix}${commandError.message}`;
265
+ ? `${prefix}${safeMessage}`
266
+ : `${prefix}${safeMessage}`;
221
267
  }
222
268
 
223
269
  /**
@@ -230,7 +276,12 @@ function renderError(error, options = {}) {
230
276
  function parseOutputFlags(argv, env = process.env, isTty = Boolean(process.stdout.isTTY)) {
231
277
  const rest = [];
232
278
  const options = { ...DEFAULT_OPTIONS };
233
- for (const token of argv) {
279
+ for (let index = 0; index < argv.length; index += 1) {
280
+ const token = argv[index];
281
+ if (token === "--") {
282
+ rest.push(...argv.slice(index + 1));
283
+ break;
284
+ }
234
285
  if (token === "--json") options.format = "json";
235
286
  else if (token === "--yaml") options.format = "yaml";
236
287
  else if (token === "--quiet" || token === "-q") options.quiet = true;
@@ -259,4 +310,5 @@ module.exports = {
259
310
  parseOutputFlags,
260
311
  isRichUi,
261
312
  displayWidth,
313
+ terminalTextOf,
262
314
  };
@@ -23,6 +23,95 @@ const { userDataDir } = require("../core/paths.cjs");
23
23
  const LOGIN_CALLBACK_PATH = "/callback";
24
24
  const LOGIN_TIMEOUT_MS = 180_000;
25
25
  const MAX_LOGIN_SESSION_BYTES = 16 * 1024;
26
+ const MAX_LOGIN_SESSION_FILE_BYTES = MAX_LOGIN_SESSION_BYTES * 2;
27
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
28
+
29
+ function sameFileIdentity(left, right) {
30
+ return !!left && !!right
31
+ && left.dev === right.dev
32
+ && left.ino === right.ino
33
+ && left.nlink === right.nlink;
34
+ }
35
+
36
+ function sameDirectoryIdentity(info) {
37
+ try {
38
+ const current = fs.lstatSync(info.path);
39
+ return current.isDirectory()
40
+ && !current.isSymbolicLink()
41
+ && current.dev === info.stat.dev
42
+ && current.ino === info.stat.ino;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function ensureRealDirectory(directory, { create = false } = {}) {
49
+ const resolved = path.resolve(String(directory || ""));
50
+ if (create) fs.mkdirSync(resolved, { recursive: true, mode: 0o700 });
51
+ const stat = fs.lstatSync(resolved);
52
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
53
+ throw new Error("Refusing to use a symbolic-link Agentlas session directory.");
54
+ }
55
+ return { path: resolved, stat, realpath: fs.realpathSync.native(resolved) };
56
+ }
57
+
58
+ function ensureAuthDirectory({ create = false } = {}) {
59
+ const root = ensureRealDirectory(userDataDir(), { create });
60
+ const info = ensureRealDirectory(path.join(root.path, "auth"), { create });
61
+ // The auth directory must be a child of the configured user-data directory,
62
+ // not a mounted/symlinked redirect that merely passes the final lstat.
63
+ if (path.dirname(info.realpath) !== root.realpath) {
64
+ throw new Error("Refusing to use a redirected Agentlas session directory.");
65
+ }
66
+ return info;
67
+ }
68
+
69
+ function readBoundedFd(fd, maxBytes) {
70
+ const chunks = [];
71
+ let total = 0;
72
+ while (total <= maxBytes) {
73
+ const want = Math.min(8192, maxBytes - total + 1);
74
+ const buffer = Buffer.allocUnsafe(want);
75
+ const count = fs.readSync(fd, buffer, 0, want, null);
76
+ if (!count) break;
77
+ total += count;
78
+ if (total > maxBytes) return null;
79
+ chunks.push(buffer.subarray(0, count));
80
+ }
81
+ return Buffer.concat(chunks, total);
82
+ }
83
+
84
+ function readPrivateBoundedFile(filePath, maxBytes) {
85
+ let before;
86
+ let fd = null;
87
+ try {
88
+ before = fs.lstatSync(filePath);
89
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 || before.size > maxBytes) return null;
90
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW);
91
+ const opened = fs.fstatSync(fd);
92
+ if (!opened.isFile() || opened.nlink !== 1
93
+ || opened.size > maxBytes || !sameFileIdentity(before, opened)) return null;
94
+ const contents = readBoundedFd(fd, maxBytes);
95
+ const after = fs.fstatSync(fd);
96
+ if (
97
+ contents === null || !after.isFile() || after.nlink !== 1 || !sameFileIdentity(opened, after) ||
98
+ after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs ||
99
+ after.size !== contents.length
100
+ ) return null;
101
+ return contents.toString("utf8");
102
+ } catch {
103
+ return null;
104
+ } finally {
105
+ if (fd !== null) {
106
+ try { fs.closeSync(fd); } catch { /* ignore */ }
107
+ }
108
+ }
109
+ }
110
+
111
+ function writeAll(fd, buffer) {
112
+ let offset = 0;
113
+ while (offset < buffer.length) offset += fs.writeSync(fd, buffer, offset, buffer.length - offset);
114
+ }
26
115
 
27
116
  function validLoginSessionValue(value) {
28
117
  return typeof value === "string" && value.length > 0 &&
@@ -230,9 +319,11 @@ function cliSessionPath() {
230
319
  function readCliSessionValue() {
231
320
  try {
232
321
  const p = cliSessionPath();
233
- const stat = fs.lstatSync(p);
234
- if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_LOGIN_SESSION_BYTES * 2) return null;
235
- const j = JSON.parse(fs.readFileSync(p, "utf8"));
322
+ const authDir = ensureAuthDirectory();
323
+ const raw = readPrivateBoundedFile(p, MAX_LOGIN_SESSION_FILE_BYTES);
324
+ if (raw === null) return null;
325
+ if (!sameDirectoryIdentity(authDir)) return null;
326
+ const j = JSON.parse(raw);
236
327
  return j && validLoginSessionValue(j.value) ? j.value : null;
237
328
  } catch {
238
329
  return null;
@@ -243,29 +334,56 @@ function saveCliSession(value) {
243
334
  if (!validLoginSessionValue(value)) throw new Error("Refusing to save an invalid Agentlas login session value.");
244
335
  const p = cliSessionPath();
245
336
  // 디렉터리 0700 + 파일 0600 — 기본 umask(0644)로 세션이 world-readable 이 되는 것을 막는다.
246
- const dir = path.dirname(p);
247
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
337
+ const authDir = ensureAuthDirectory({ create: true });
338
+ const dir = authDir.path;
248
339
  try { fs.chmodSync(dir, 0o700); } catch { /* win32 */ }
249
340
  const temp = path.join(dir, `.cli-session.${process.pid}.${crypto.randomUUID()}.tmp`);
341
+ const payload = Buffer.from(JSON.stringify({ version: 1, value, updatedAt: new Date().toISOString() }, null, 2) + "\n", "utf8");
342
+ if (payload.length > MAX_LOGIN_SESSION_FILE_BYTES) throw new Error("Refusing to save an oversized Agentlas login session.");
343
+ let fd = null;
250
344
  try {
251
- fs.writeFileSync(temp, JSON.stringify({ version: 1, value, updatedAt: new Date().toISOString() }, null, 2) + "\n", {
252
- encoding: "utf8",
253
- mode: 0o600,
254
- flag: "wx",
255
- });
345
+ fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
346
+ const created = fs.fstatSync(fd);
347
+ if (!created.isFile() || created.nlink !== 1) throw new Error("Refusing to create an unsafe Agentlas login session file.");
348
+ writeAll(fd, payload);
349
+ fs.fsyncSync(fd);
350
+ const written = fs.fstatSync(fd);
351
+ if (!sameFileIdentity(created, written) || written.size !== payload.length) throw new Error("Agentlas login session changed while saving.");
352
+ fs.closeSync(fd);
353
+ fd = null;
354
+ if (!sameDirectoryIdentity(authDir)) throw new Error("Agentlas login session directory changed while saving.");
256
355
  fs.renameSync(temp, p);
356
+ const installed = fs.lstatSync(p);
357
+ if (installed.isSymbolicLink() || !installed.isFile() || installed.nlink !== 1) throw new Error("Refusing to install an unsafe Agentlas login session file.");
257
358
  } catch (error) {
359
+ if (fd !== null) {
360
+ try { fs.closeSync(fd); } catch { /* preserve original */ }
361
+ }
258
362
  try { fs.rmSync(temp, { force: true }); } catch { /* preserve original */ }
259
363
  throw error;
260
364
  }
261
- try { fs.chmodSync(p, 0o600); } catch { /* win32 */ }
262
365
  return p;
263
366
  }
264
367
 
265
368
  function deleteCliSession() {
266
369
  const p = cliSessionPath();
267
- if (!fs.existsSync(p)) return { existed: false, path: p };
268
- fs.rmSync(p);
370
+ let authDir;
371
+ try { authDir = ensureAuthDirectory(); } catch (error) {
372
+ if (error?.code === "ENOENT") return { existed: false, path: p };
373
+ throw error;
374
+ }
375
+ let stat;
376
+ try { stat = fs.lstatSync(p); } catch (error) {
377
+ if (error?.code === "ENOENT") return { existed: false, path: p };
378
+ throw error;
379
+ }
380
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) {
381
+ throw new Error("Refusing to delete an unsafe Agentlas login session file.");
382
+ }
383
+ if (!sameDirectoryIdentity(authDir)) throw new Error("Agentlas login session directory changed while deleting.");
384
+ const current = fs.lstatSync(p);
385
+ if (!sameFileIdentity(stat, current)) throw new Error("Agentlas login session changed while deleting.");
386
+ fs.unlinkSync(p);
269
387
  return { existed: true, path: p };
270
388
  }
271
389
 
@@ -8,9 +8,9 @@
8
8
  * - 업스트림 AbortSignal 전파
9
9
  * 세션 쿠키 해석 순서: AGENTLAS_SESSION env → auth/cli-session.v1.json → (레거시) keytar.
10
10
  */
11
- const fs = require("node:fs");
12
11
  const path = require("node:path");
13
12
  const { userDataDir } = require("../core/paths.cjs");
13
+ const { readJsonFile } = require("../mcp/contract.cjs");
14
14
 
15
15
  const HUB_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
16
16
  const HUB_TIMEOUT_DEFAULTS = Object.freeze({ connectMs: 15_000, idleMs: 30_000, totalMs: 180_000 });
@@ -69,6 +69,7 @@ async function fetchHub(url, init = {}, options = {}) {
69
69
  let idleTimer = null;
70
70
  let totalTimer = null;
71
71
  let reader = null;
72
+ let iterator = null;
72
73
  let terminalError = null;
73
74
  let rejectTerminal;
74
75
  const terminal = new Promise((_, reject) => { rejectTerminal = reject; });
@@ -106,27 +107,46 @@ async function fetchHub(url, init = {}, options = {}) {
106
107
  const chunks = [];
107
108
  let bytes = 0;
108
109
  armIdle();
110
+ const acceptChunk = (value) => {
111
+ armIdle();
112
+ const chunk = Buffer.from(value || []);
113
+ bytes += chunk.length;
114
+ if (bytes > HUB_RESPONSE_MAX_BYTES) {
115
+ const error = new Error(`Hub 응답이 허용 크기(${HUB_RESPONSE_MAX_BYTES} bytes)를 초과했습니다.`);
116
+ error.code = "AGENTLAS_HUB_RESPONSE_TOO_LARGE";
117
+ stop(error);
118
+ throw error;
119
+ }
120
+ chunks.push(chunk);
121
+ };
109
122
  if (response.body && typeof response.body.getReader === "function") {
110
123
  reader = response.body.getReader();
111
124
  while (true) {
112
125
  const part = await Promise.race([reader.read(), terminal]);
113
126
  if (part.done) break;
114
- armIdle();
115
- const chunk = Buffer.from(part.value || []);
116
- bytes += chunk.length;
117
- if (bytes > HUB_RESPONSE_MAX_BYTES) {
118
- const error = new Error(`Hub 응답이 허용 크기(${HUB_RESPONSE_MAX_BYTES} bytes)를 초과했습니다.`);
119
- error.code = "AGENTLAS_HUB_RESPONSE_TOO_LARGE";
120
- stop(error);
121
- throw error;
122
- }
123
- chunks.push(chunk);
127
+ acceptChunk(part.value);
124
128
  }
129
+ } else if (response.body && typeof response.body[Symbol.asyncIterator] === "function") {
130
+ iterator = response.body[Symbol.asyncIterator]();
131
+ while (true) {
132
+ // Node streams and test adapters can expose only an async iterator. A
133
+ // plain `for await` leaves us stuck inside next() after idle/total expiry,
134
+ // even though the request's AbortController has already fired.
135
+ const part = await Promise.race([
136
+ Promise.resolve().then(() => iterator.next()),
137
+ terminal,
138
+ ]);
139
+ if (part.done) break;
140
+ acceptChunk(part.value);
141
+ }
142
+ } else if (response.body == null) {
143
+ // Standards-compliant empty/HEAD/204 response. There is no body to read.
125
144
  } else {
126
- const raw = Buffer.from(await Promise.race([response.arrayBuffer(), terminal]));
127
- bytes = raw.length;
128
- if (bytes > HUB_RESPONSE_MAX_BYTES) throw new Error(`Hub response exceeds the allowed size (${HUB_RESPONSE_MAX_BYTES} bytes).`);
129
- chunks.push(raw);
145
+ // arrayBuffer()/text() allocates the complete attacker-controlled body
146
+ // before a size check can run, defeating the 16 MiB contract.
147
+ const error = new Error("Hub response body is not stream-readable; refusing an unbounded buffered read.");
148
+ error.code = "AGENTLAS_HUB_RESPONSE_NOT_STREAMABLE";
149
+ throw error;
130
150
  }
131
151
  if (idleTimer) clearTimeout(idleTimer);
132
152
  idleTimer = null;
@@ -143,6 +163,11 @@ async function fetchHub(url, init = {}, options = {}) {
143
163
  if (reader && terminalError) {
144
164
  try { await reader.cancel(terminalError); } catch { /* ignore */ }
145
165
  }
166
+ if (iterator && terminalError && typeof iterator.return === "function") {
167
+ // Do not await an untrusted iterator's cleanup hook: the timeout must
168
+ // remain a hard upper bound even when return() itself never settles.
169
+ Promise.resolve().then(() => iterator.return()).catch(() => {});
170
+ }
146
171
  }
147
172
  }
148
173
 
@@ -161,7 +186,7 @@ function cliSessionPath() {
161
186
 
162
187
  function readCliSessionValue() {
163
188
  try {
164
- const j = JSON.parse(fs.readFileSync(cliSessionPath(), "utf8"));
189
+ const { value: j } = readJsonFile(cliSessionPath(), "Agentlas CLI session");
165
190
  return (j && typeof j.value === "string" && j.value) || null;
166
191
  } catch {
167
192
  return null;
@@ -224,18 +224,27 @@ async function deleteCloudAgent(slug, options = {}) {
224
224
  ) {
225
225
  throw new Error("Agentlas Cloud delete returned an invalid or mismatched deletion receipt.");
226
226
  }
227
- const stateValue = state.readCloudAssetState();
228
227
  const key = state.cloudDescriptorKey(descriptor);
229
- const roots = stateValue.assets[key]?.sourceRoots || [];
228
+ // The server delete used this exact observed descriptor. Keep its roots even
229
+ // if another local process advances the same key before the local journal
230
+ // update; tombstones authenticate the deleted revision, not a later one.
231
+ const roots = [...(localEntry.sourceRoots || [])];
230
232
  const warnings = [];
231
- for (const rootPath of roots) {
232
- // 톰스톤: 삭제된 베이스는 재저장 시 절대 If-Match 베이스로 재사용되지 않는다.
233
- stateValue.deletedBases.push({ rootPath, slug: descriptor.slug, scope: descriptor.scope, cloudId: descriptor.cloudId, revision: descriptor.revision });
234
- }
235
- delete stateValue.assets[key];
236
- stateValue.deletedBases = stateValue.deletedBases.slice(-256);
237
233
  try {
238
- state.writeCloudAssetState(stateValue);
234
+ state.updateCloudAssetState((stateValue) => {
235
+ for (const rootPath of roots) {
236
+ // 톰스톤: 삭제된 베이스는 재저장 시 절대 If-Match 베이스로 재사용되지 않는다.
237
+ stateValue.deletedBases.push({ rootPath, slug: descriptor.slug, scope: descriptor.scope, cloudId: descriptor.cloudId, revision: descriptor.revision });
238
+ }
239
+ const current = stateValue.assets[key];
240
+ if (
241
+ current && current.descriptor.cloudId === descriptor.cloudId &&
242
+ current.descriptor.revision === descriptor.revision
243
+ ) {
244
+ delete stateValue.assets[key];
245
+ }
246
+ stateValue.deletedBases = stateValue.deletedBases.slice(-256);
247
+ });
239
248
  } catch (error) {
240
249
  const stateError = new Error(
241
250
  `Cloud delete committed on the server, but this machine could not persist the deletion tombstone. ` +