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
@@ -14,6 +14,53 @@
14
14
  const i18n = require("../agentlas-i18n.cjs");
15
15
 
16
16
  const NOOP_PALETTE = new Proxy({}, { get: () => (s) => String(s) });
17
+ const STATUS_TEXT_MAX = 8 * 1024;
18
+ const EVENT_TEXT_MAX = 64 * 1024;
19
+ const TOOL_NAME_MAX = 256;
20
+ const TOOL_SUMMARY_MAX = 16 * 1024;
21
+ const TASK_TOOL_INPUTS = new Set(["todowrite", "write_todos", "taskcreate", "taskupdate"]);
22
+ const TASK_TOOL_RESULTS = new Set(["taskcreate", "taskupdate", "tasklist"]);
23
+ const TASK_RECORD_KEYS = new Set([
24
+ "id", "taskId", "content", "subject", "description", "text", "title", "activeForm", "status", "completed",
25
+ ]);
26
+
27
+ function taskScalar(key, value) {
28
+ if (typeof value === "boolean" || typeof value === "number") return value;
29
+ if (typeof value !== "string") return undefined;
30
+ const max = key === "id" || key === "taskId" ? 256 : key === "status" ? 64 : 2_000;
31
+ return Array.from(value).slice(0, max).join("");
32
+ }
33
+
34
+ function taskRecord(value) {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
36
+ const projected = {};
37
+ for (const [key, child] of Object.entries(value)) {
38
+ if (!TASK_RECORD_KEYS.has(key)) continue;
39
+ const safe = taskScalar(key, child);
40
+ if (safe !== undefined) projected[key] = safe;
41
+ }
42
+ return projected;
43
+ }
44
+
45
+ function taskPayload(value) {
46
+ if (Array.isArray(value)) return value.map(taskRecord).filter(Boolean).slice(0, 500);
47
+ const projected = taskRecord(value);
48
+ if (!projected) return null;
49
+ for (const key of ["todos", "items", "tasks"]) {
50
+ if (Array.isArray(value[key])) projected[key] = value[key].map(taskRecord).filter(Boolean).slice(0, 500);
51
+ }
52
+ if (value.task && typeof value.task === "object" && !Array.isArray(value.task)) {
53
+ projected.task = taskRecord(value.task);
54
+ }
55
+ return projected;
56
+ }
57
+
58
+ function boundedText(value, max, suffix = "… [truncated]") {
59
+ const text = String(value || "");
60
+ if (text.length <= max) return text;
61
+ const keep = Math.max(0, max - suffix.length);
62
+ return text.slice(0, keep) + suffix;
63
+ }
17
64
 
18
65
  class EventSink {
19
66
  constructor({ lang = "en", onEvent, onPrivateEvidence } = {}) {
@@ -29,36 +76,55 @@ class EventSink {
29
76
  this._onEvent({ type, at: Date.now(), ...data });
30
77
  }
31
78
 
32
- status(text) { this._emit("status", { text: String(text || "") }); }
33
- warn(text) { this._emit("warn", { text: String(text || "") }); }
79
+ status(text) { this._emit("status", { text: boundedText(text, STATUS_TEXT_MAX) }); }
80
+ warn(text) { this._emit("warn", { text: boundedText(text, STATUS_TEXT_MAX) }); }
34
81
  // Runtime/provider failures are recovery evidence, never presentation copy.
35
82
  // Session owns the recovery loop and may give this evidence to the controller;
36
83
  // Renderer must never receive it first.
37
- error(text) { this._onPrivateEvidence(String(text || "")); }
38
- line(text) { this._emit("line", { text: String(text || "") }); }
39
- tool(name, summary) { this._emit("tool", { name: String(name || "tool"), summary: String(summary || "") }); }
84
+ error(text) { this._onPrivateEvidence(boundedText(text, EVENT_TEXT_MAX)); }
85
+ line(text) { this._emit("line", { text: boundedText(text, EVENT_TEXT_MAX) }); }
86
+ tool(name, summary) {
87
+ this._emit("tool", {
88
+ name: boundedText(name || "tool", TOOL_NAME_MAX),
89
+ summary: boundedText(summary, TOOL_SUMMARY_MAX),
90
+ });
91
+ }
40
92
  toolResult(text, ok) {
41
93
  if (ok === false) {
42
- this._onPrivateEvidence(String(text || ""));
94
+ this._onPrivateEvidence(boundedText(text, EVENT_TEXT_MAX));
43
95
  return;
44
96
  }
45
- this._emit("tool-result", { text: String(text || ""), ok: true });
97
+ this._emit("tool-result", { text: boundedText(text, EVENT_TEXT_MAX), ok: true });
46
98
  }
47
99
  streamStart() {
48
100
  if (!this._streamOpen) { this._streamOpen = true; this._emit("stream-start", {}); }
49
101
  }
50
102
  streamDelta(text) {
51
103
  if (!this._streamOpen) this.streamStart();
52
- this._emit("stream-delta", { text: String(text || "") });
104
+ const value = String(text || "");
105
+ if (!value) { this._emit("stream-delta", { text: "" }); return; }
106
+ // Preserve live output byte-for-byte while ensuring no single retained
107
+ // ring event can own an unbounded provider/MCP payload.
108
+ for (let offset = 0; offset < value.length; offset += EVENT_TEXT_MAX) {
109
+ this._emit("stream-delta", { text: value.slice(offset, offset + EVENT_TEXT_MAX) });
110
+ }
53
111
  }
54
112
  streamEnd() {
55
113
  if (this._streamOpen) { this._streamOpen = false; this._emit("stream-end", {}); }
56
114
  }
57
- applyTaskTool(name, input, id) { this._emit("task-tool", { name, input, id }); }
58
- applyTaskResult(name, result, id) { this._emit("task-result", { name, result, id }); }
59
- info(text) { this._emit("line", { text: String(text || "") }); }
115
+ applyTaskTool(name, input, id) {
116
+ const tool = String(name || "").toLowerCase();
117
+ if (!TASK_TOOL_INPUTS.has(tool)) return;
118
+ this._emit("task-tool", { name: tool, input: taskPayload(input), id: taskScalar("id", String(id || "")) });
119
+ }
120
+ applyTaskResult(name, result, id) {
121
+ const tool = String(name || "").toLowerCase();
122
+ if (!TASK_TOOL_RESULTS.has(tool)) return;
123
+ this._emit("task-result", { name: tool, result: taskPayload(result), id: taskScalar("id", String(id || "")) });
124
+ }
125
+ info(text) { this._emit("line", { text: boundedText(text, EVENT_TEXT_MAX) }); }
60
126
  cost(usage) { this._emit("cost", { usage }); }
61
127
  stopSpinner() { /* 스피너는 렌더러 소관 — 이벤트 불필요 */ }
62
128
  }
63
129
 
64
- module.exports = { EventSink };
130
+ module.exports = { EventSink, taskPayload, boundedText, EVENT_TEXT_MAX };
@@ -84,24 +84,46 @@ function loadRuntimeSession(db, chatId, kind, fingerprint, agentId) {
84
84
  return {};
85
85
  }
86
86
 
87
- function saveRuntimeSession(db, chatId, kind, session, fingerprint, agentId) {
87
+ function saveRuntimeSession(db, chatId, kind, session, fingerprint, agentId, expectedSessionId = null) {
88
88
  const sessionId = session && session.id ? String(session.id) : "";
89
89
  if (!sessionId) return false;
90
90
  const agent = normalizeRuntimeAgentId(agentId);
91
+ const expected = typeof expectedSessionId === "string" ? expectedSessionId : "";
91
92
  try {
92
- runWriteTransaction(db, () => {
93
- db.prepare(
94
- "INSERT OR REPLACE INTO chat_runtime_sessions (chat_id, kind, agent_id, session_id, fingerprint, updated_at) VALUES (?,?,?,?,?,?)",
95
- ).run(chatId, kind, agent, sessionId, fingerprint, nowIso());
93
+ const saved = runWriteTransaction(db, () => {
94
+ const current = db.prepare(
95
+ "SELECT session_id, fingerprint FROM chat_runtime_sessions WHERE chat_id=? AND kind=? AND agent_id=?",
96
+ ).get(chatId, kind, agent);
97
+ let changed = 0;
98
+ if (!current) {
99
+ const inserted = db.prepare(
100
+ "INSERT OR IGNORE INTO chat_runtime_sessions (chat_id, kind, agent_id, session_id, fingerprint, updated_at) VALUES (?,?,?,?,?,?)",
101
+ ).run(chatId, kind, agent, sessionId, fingerprint, nowIso());
102
+ changed = inserted.changes ?? inserted.rowsAffected ?? 0;
103
+ } else if (
104
+ (expected && current.session_id === expected && current.fingerprint === fingerprint) ||
105
+ (!expected && current.fingerprint !== fingerprint)
106
+ ) {
107
+ // Compare-and-swap: two sessions that began from the same provider id
108
+ // cannot finish out of order and overwrite the newer continuation.
109
+ const updated = db.prepare(
110
+ "UPDATE chat_runtime_sessions SET session_id=?, fingerprint=?, updated_at=? WHERE chat_id=? AND kind=? AND agent_id=? AND session_id=? AND fingerprint=?",
111
+ ).run(sessionId, fingerprint, nowIso(), chatId, kind, agent, current.session_id, current.fingerprint);
112
+ changed = updated.changes ?? updated.rowsAffected ?? 0;
113
+ } else if (current.session_id === sessionId && current.fingerprint === fingerprint) {
114
+ changed = 1; // idempotent retry of the already-persisted continuation
115
+ }
116
+ if (!changed) return false;
96
117
  // 레거시 행을 승계했다면 이제 새 키가 정본이다 — 같은 세션을 가리키는 '' 행을
97
118
  // 정리해 다음 점유자가 이 봇의 세션을 승계 후보로 오인하지 않게 한다.
98
119
  if (agent !== "") {
99
120
  db.prepare(
100
121
  "DELETE FROM chat_runtime_sessions WHERE chat_id=? AND kind=? AND agent_id='' AND session_id=?",
101
- ).run(chatId, kind, sessionId);
122
+ ).run(chatId, kind, expected || sessionId);
102
123
  }
124
+ return true;
103
125
  });
104
- return true;
126
+ return saved === true;
105
127
  } catch (error) {
106
128
  // 턴 자체는 유효하므로 던지지 않는다. 다만 조용히 지나가지도 않는다 — 이 자리의
107
129
  // 침묵이 resume 유실을 여러 판 동안 숨겼다.
@@ -208,10 +208,33 @@ function create(deps) {
208
208
  cwd: executionContext.cwd || (typeof D.projectCwd === "function" ? D.projectCwd() : D.runCwd()),
209
209
  runtimeOverride,
210
210
  };
211
+ const seen = new Set();
212
+ let passthrough = false;
211
213
  for (let i = 0; i < args.length; i++) {
212
- if (args[i] === "--research" || args[i] === "--research-evidence") ctx.research = true;
213
- else if (args[i] === "--background") ctx.background = true;
214
- else rest.push(args[i]);
214
+ const token = String(args[i]);
215
+ if (passthrough) { rest.push(token); continue; }
216
+ if (token === "--") { passthrough = true; continue; }
217
+ if (token === "--research" || token === "--research-evidence") {
218
+ if (seen.has("research")) {
219
+ const ui = executionContext.ui || newUi();
220
+ ui.error("duplicate option: --research", { reveal: true });
221
+ return { ok: false, error: "duplicate-option" };
222
+ }
223
+ seen.add("research");
224
+ ctx.research = true;
225
+ } else if (token === "--background") {
226
+ if (seen.has("background")) {
227
+ const ui = executionContext.ui || newUi();
228
+ ui.error("duplicate option: --background", { reveal: true });
229
+ return { ok: false, error: "duplicate-option" };
230
+ }
231
+ seen.add("background");
232
+ ctx.background = true;
233
+ } else if (token.startsWith("-")) {
234
+ const ui = executionContext.ui || newUi();
235
+ ui.error(`unknown option ${token} — storm accepts: --research | --background | --runtime <kind>`, { reveal: true });
236
+ return { ok: false, error: "unknown-option" };
237
+ } else rest.push(token);
215
238
  }
216
239
  const r = await stormRun(db, rest.join(" "), ctx);
217
240
  if (!r.ok) process.exitCode = 1;
@@ -429,16 +429,44 @@ function create(deps) {
429
429
  async function cmdSwarm(db, args, runtimeOverride, executionContext = {}) {
430
430
  const rest = [];
431
431
  let concurrency;
432
+ let parallelError = null;
433
+ let seenParallel = false;
434
+ let passthrough = false;
435
+ let strayFlag = null;
432
436
  for (let i = 0; i < args.length; i++) {
433
- if (args[i] === "--parallel" || args[i] === "-n") concurrency = Number(args[++i]);
434
- else rest.push(args[i]);
437
+ const token = String(args[i]);
438
+ if (passthrough) { rest.push(token); continue; }
439
+ if (token === "--") { passthrough = true; continue; }
440
+ if (token === "--parallel" || token === "-n" || token.startsWith("--parallel=")) {
441
+ if (seenParallel) {
442
+ parallelError = "duplicate option";
443
+ continue;
444
+ }
445
+ seenParallel = true;
446
+ const inline = token.startsWith("--parallel=");
447
+ const raw = inline ? token.slice(11) : args[++i];
448
+ const parsed = Number(raw);
449
+ if (raw === undefined || raw === "" || (!inline && String(raw).startsWith("--")) || !Number.isInteger(parsed) || parsed < 1) {
450
+ parallelError = raw === undefined ? "missing value" : String(raw);
451
+ } else {
452
+ concurrency = Math.min(8, parsed);
453
+ }
454
+ } else {
455
+ if (token.startsWith("-") && strayFlag === null) strayFlag = token;
456
+ rest.push(token);
457
+ }
458
+ }
459
+ if (parallelError !== null) {
460
+ const ui = executionContext.ui || newUi();
461
+ ui.error(`invalid --parallel value ${parallelError} — use a positive integer (maximum 8)`, { reveal: true });
462
+ process.exitCode = 1;
463
+ return { ok: false, error: "invalid-parallel" };
435
464
  }
436
465
  // storm.cjs stormRun 의 leading-dash 가드와 동일 계약. 여기서 안 막으면 미지원·
437
466
  // 오타 플래그(--permission, --model …)가 rest 에 남아 rest.join(" ") 로 목표가
438
467
  // 되어버린다: 유료 플래너가 플래그 텍스트를 목표로 실제 실행되고, 플래그가
439
468
  // 의도한 동작(예: 권한 상승)은 조용히 일어나지 않는다. 목표는 프롬프트까지
440
469
  // 오염된다. 그래서 소비되지 않은 대시 토큰은 실행 전에 fail-closed 로 거절한다.
441
- const strayFlag = rest.find((token) => String(token).startsWith("-"));
442
470
  if (strayFlag) {
443
471
  const ui = executionContext.ui || newUi();
444
472
  ui.error(`unknown option ${strayFlag} — swarm accepts: --parallel N | -n N, --runtime <kind>`, { reveal: true });
@@ -20,16 +20,34 @@ const { userDataDir } = require("../core/paths.cjs");
20
20
  const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
21
21
 
22
22
  const TELEGRAM_REQUEST_TIMEOUT_MS = 20_000;
23
+ const TELEGRAM_TOKEN_MAX_BYTES = 1024;
24
+ const TELEGRAM_BINDING_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
25
+
26
+ function assertTelegramBindingId(id) {
27
+ const value = String(id || "");
28
+ if (!TELEGRAM_BINDING_ID_RE.test(value)) throw new Error("invalid Telegram binding id");
29
+ return value;
30
+ }
23
31
 
24
32
  /** 순수 HTTPS. fetch 주입 가능(테스트). */
25
33
  async function telegramApi(token, method, payload, { fetchImpl } = {}) {
26
34
  const doFetch = fetchImpl || globalThis.fetch;
27
35
  if (typeof doFetch !== "function") throw new Error("this runtime has no fetch");
28
- const longPoll = method === "getUpdates" && typeof payload.timeout === "number" ? Math.max(0, payload.timeout) : 0;
36
+ const safeToken = String(token || "");
37
+ const safeMethod = String(method || "");
38
+ if (!safeToken || Buffer.byteLength(safeToken, "utf8") > TELEGRAM_TOKEN_MAX_BYTES || !/^[A-Za-z0-9:_-]+$/.test(safeToken)) {
39
+ throw new Error("Telegram bot token has an unsafe format");
40
+ }
41
+ if (!/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(safeMethod)) throw new Error("Telegram API method is invalid");
42
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Telegram API payload must be an object");
43
+ const requestedLongPoll = safeMethod === "getUpdates" && Number.isFinite(Number(payload.timeout))
44
+ ? Number(payload.timeout)
45
+ : 0;
46
+ const longPoll = Math.max(0, Math.min(50, requestedLongPoll));
29
47
  const controller = new AbortController();
30
- const timer = setTimeout(() => controller.abort(new Error(`Telegram ${method} timed out`)), TELEGRAM_REQUEST_TIMEOUT_MS + longPoll * 1000);
48
+ const timer = setTimeout(() => controller.abort(new Error(`Telegram ${safeMethod} timed out`)), TELEGRAM_REQUEST_TIMEOUT_MS + longPoll * 1000);
31
49
  try {
32
- const res = await doFetch(`https://api.telegram.org/bot${token}/${method}`, {
50
+ const res = await doFetch(`https://api.telegram.org/bot${safeToken}/${safeMethod}`, {
33
51
  method: "POST",
34
52
  headers: { "content-type": "application/json" },
35
53
  body: JSON.stringify(payload),
@@ -37,7 +55,11 @@ async function telegramApi(token, method, payload, { fetchImpl } = {}) {
37
55
  });
38
56
  const json = await res.json().catch(() => null);
39
57
  if (!res.ok || !json || json.ok !== true) {
40
- throw new Error((json && json.description) || `Telegram ${method} failed (${res.status})`);
58
+ const raw = (json && json.description) || `Telegram ${safeMethod} failed (${res.status})`;
59
+ const message = String(raw).replaceAll(safeToken, "[redacted]").replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 1000);
60
+ const error = new Error(message || `Telegram ${safeMethod} failed`);
61
+ error.telegramStatus = Number(res.status) || null;
62
+ throw error;
41
63
  }
42
64
  return json.result;
43
65
  } finally {
@@ -52,24 +74,124 @@ async function verifyBotToken(token, opts) {
52
74
  }
53
75
 
54
76
  // ── 토큰 비밀 저장 (0600 파일) ─────────────────────────────────────────────
55
- function tokenDir() {
77
+ function tokenDir({ create = true } = {}) {
56
78
  const dir = path.join(userDataDir(), "telegram");
57
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
79
+ if (create) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
80
+ let stat;
81
+ try { stat = fs.lstatSync(dir); }
82
+ catch (error) {
83
+ if (error && error.code === "ENOENT" && !create) return dir;
84
+ throw error;
85
+ }
86
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Telegram token directory must be a real directory");
58
87
  if (process.platform !== "win32") fs.chmodSync(dir, 0o700);
59
88
  return dir;
60
89
  }
61
- function tokenFile(id) { return path.join(tokenDir(), `${id}.token`); }
90
+ function tokenFile(id, options) {
91
+ const bindingId = assertTelegramBindingId(id);
92
+ return path.join(tokenDir(options), `${bindingId}.token`);
93
+ }
62
94
  function saveToken(id, token) {
63
95
  const file = tokenFile(id);
64
- fs.writeFileSync(file, token, { encoding: "utf8", mode: 0o600 });
65
- if (process.platform !== "win32") fs.chmodSync(file, 0o600);
96
+ const value = String(token || "");
97
+ if (!value || Buffer.byteLength(value, "utf8") > TELEGRAM_TOKEN_MAX_BYTES || !/^[A-Za-z0-9:_-]+$/.test(value)) {
98
+ throw new Error("Telegram bot token has an unsafe format");
99
+ }
100
+ let before = null;
101
+ try {
102
+ before = fs.lstatSync(file);
103
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) throw new Error("Telegram token target must be a regular private file");
104
+ } catch (error) {
105
+ if (!error || error.code !== "ENOENT") throw error;
106
+ }
107
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
108
+ try {
109
+ fs.writeFileSync(temp, value, { encoding: "utf8", mode: 0o600, flag: "wx" });
110
+ if (before) {
111
+ const current = fs.lstatSync(file);
112
+ if (
113
+ !current.isFile() || current.isSymbolicLink() || current.nlink !== 1 ||
114
+ current.dev !== before.dev || current.ino !== before.ino || current.size !== before.size || current.mtimeMs !== before.mtimeMs
115
+ ) throw new Error("Telegram token target changed before replacement");
116
+ } else {
117
+ try {
118
+ fs.lstatSync(file);
119
+ throw new Error("Telegram token target appeared before replacement");
120
+ } catch (error) {
121
+ if (!error || error.code !== "ENOENT") throw error;
122
+ }
123
+ }
124
+ try {
125
+ fs.renameSync(temp, file);
126
+ } catch (error) {
127
+ if (
128
+ process.platform !== "win32" || !before ||
129
+ !["EEXIST", "EPERM", "EACCES"].includes(error && error.code)
130
+ ) throw error;
131
+ const backup = `${file}.${process.pid}.${crypto.randomUUID()}.bak`;
132
+ fs.renameSync(file, backup);
133
+ try {
134
+ fs.renameSync(temp, file);
135
+ } catch (replaceError) {
136
+ try { if (!fs.existsSync(file)) fs.renameSync(backup, file); } catch { /* leave recoverable backup */ }
137
+ throw replaceError;
138
+ }
139
+ try { fs.rmSync(backup, { force: true }); } catch { /* committed target is authoritative */ }
140
+ }
141
+ if (process.platform !== "win32") fs.chmodSync(file, 0o600);
142
+ } finally {
143
+ try { fs.rmSync(temp, { force: true }); } catch { /* best-effort cleanup */ }
144
+ }
66
145
  }
67
146
  function readToken(id) {
68
- try { return fs.readFileSync(tokenFile(id), "utf8").trim() || null; } catch { return null; }
147
+ const file = tokenFile(id, { create: false });
148
+ let fd;
149
+ try {
150
+ const before = fs.lstatSync(file);
151
+ if (
152
+ !before.isFile() || before.isSymbolicLink() || before.nlink !== 1 ||
153
+ before.size <= 0 || before.size > TELEGRAM_TOKEN_MAX_BYTES ||
154
+ (process.platform !== "win32" && (before.mode & 0o077) !== 0)
155
+ ) throw new Error("Telegram token file is unsafe");
156
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
157
+ const opened = fs.fstatSync(fd);
158
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) {
159
+ throw new Error("Telegram token file changed while opening");
160
+ }
161
+ const token = fs.readFileSync(fd, "utf8").trim();
162
+ if (!token || !/^[A-Za-z0-9:_-]+$/.test(token)) throw new Error("Telegram token file has an unsafe format");
163
+ return token;
164
+ } catch (error) {
165
+ if (error && error.code === "ENOENT") return null;
166
+ throw error;
167
+ } finally {
168
+ if (fd !== undefined) fs.closeSync(fd);
169
+ }
170
+ }
171
+ function deleteToken(id) {
172
+ const file = tokenFile(id, { create: false });
173
+ try {
174
+ const stat = fs.lstatSync(file);
175
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Telegram token target is unsafe");
176
+ fs.unlinkSync(file);
177
+ return true;
178
+ } catch (error) {
179
+ if (error && error.code === "ENOENT") return false;
180
+ throw error;
181
+ }
69
182
  }
70
- function deleteToken(id) { try { fs.rmSync(tokenFile(id)); } catch { /* gone */ } }
71
183
  function tokenFingerprint(token) { return crypto.createHash("sha256").update(token).digest("hex").slice(0, 24); }
72
184
 
185
+ function readBindingToken(row) {
186
+ if (!row) throw new Error("binding not found");
187
+ const token = readToken(row.id);
188
+ if (!token) throw new Error("no Terminal-owned token for this binding");
189
+ if (row.token_fingerprint && tokenFingerprint(token) !== row.token_fingerprint) {
190
+ throw new Error("stored Telegram token does not match the binding fingerprint");
191
+ }
192
+ return token;
193
+ }
194
+
73
195
  // ── 바인딩 ─────────────────────────────────────────────────────────────────
74
196
  function listBindings(db) {
75
197
  try { return db.prepare("SELECT * FROM telegram_bindings ORDER BY rowid DESC").all(); } catch { return []; }
@@ -80,6 +202,10 @@ function getBinding(db, id) {
80
202
 
81
203
  /** 봇 토큰으로 바인딩을 만든다(방 미페어링). 반환: {id, botUsername}. */
82
204
  async function startConnection(db, targetKind, targetId, token, opts) {
205
+ if (!new Set(["agent", "firm", "one"]).has(String(targetKind))) throw new Error("invalid Telegram binding target kind");
206
+ if (typeof targetId !== "string" || !targetId.trim() || targetId.length > 512 || /[\u0000\r\n]/.test(targetId)) {
207
+ throw new Error("invalid Telegram binding target id");
208
+ }
83
209
  const me = await verifyBotToken(token, opts);
84
210
  const id = crypto.randomUUID();
85
211
  const now = new Date().toISOString();
@@ -95,7 +221,7 @@ async function startConnection(db, targetKind, targetId, token, opts) {
95
221
  db.prepare("UPDATE telegram_bindings SET token_saved=1, updated_at=? WHERE id=?").run(new Date().toISOString(), id);
96
222
  });
97
223
  } catch (error) {
98
- deleteToken(id);
224
+ try { deleteToken(id); } catch { /* preserve the original storage failure */ }
99
225
  try {
100
226
  runWriteTransaction(db, () => db.prepare("DELETE FROM telegram_bindings WHERE id=?").run(id));
101
227
  } catch { /* preserve the original storage failure */ }
@@ -113,16 +239,26 @@ async function startConnection(db, targetKind, targetId, token, opts) {
113
239
  * 두 Terminal 프로세스가 같은 바인딩을 동시에 덮어쓰지 못하게 한다.
114
240
  */
115
241
  async function pairByPolling(db, id, { timeoutMs = 120_000, opts, onWait } = {}) {
116
- const token = readToken(id);
117
- if (!token) throw new Error("no stored token for this binding");
242
+ assertTelegramBindingId(id);
243
+ const initialRow = getBinding(db, id);
244
+ if (!initialRow) throw new Error("binding not found");
245
+ const token = readBindingToken(initialRow);
118
246
  const deadline = Date.now() + timeoutMs;
119
- let offset = (getBinding(db, id) || {}).last_update_id || 0;
247
+ let offset = Number.isSafeInteger(Number(initialRow.last_update_id)) && Number(initialRow.last_update_id) >= 0
248
+ ? Number(initialRow.last_update_id)
249
+ : 0;
250
+ let lastApiError = null;
120
251
  while (Date.now() < deadline) {
121
252
  if (typeof onWait === "function") onWait();
122
253
  let updates;
123
254
  try {
124
255
  updates = await telegramApi(token, "getUpdates", { offset: offset + 1, timeout: 25, allowed_updates: ["message"] }, opts);
125
- } catch { updates = []; }
256
+ if (!Array.isArray(updates)) throw new Error("Telegram getUpdates returned an invalid result");
257
+ lastApiError = null;
258
+ } catch (error) {
259
+ lastApiError = error;
260
+ updates = [];
261
+ }
126
262
  for (const update of updates || []) {
127
263
  if (typeof update.update_id === "number") offset = Math.max(offset, update.update_id);
128
264
  const message = update.message;
@@ -150,25 +286,52 @@ async function pairByPolling(db, id, { timeoutMs = 120_000, opts, onWait } = {})
150
286
  });
151
287
  }
152
288
  }
289
+ if (lastApiError) throw lastApiError;
153
290
  return null;
154
291
  }
155
292
 
156
293
  /** 페어링된 방에 확인 메시지를 보낸다. */
157
294
  async function sendTest(db, id, text, opts) {
295
+ assertTelegramBindingId(id);
158
296
  const row = getBinding(db, id);
159
297
  if (!row) throw new Error("binding not found");
160
298
  if (!row.telegram_chat_id) throw new Error("this binding is not paired to a chat yet");
161
- const token = readToken(id);
162
- if (!token) throw new Error("no stored token for this binding");
299
+ const token = readBindingToken(row);
163
300
  await telegramApi(token, "sendMessage", { chat_id: row.telegram_chat_id, text }, opts);
164
301
  return true;
165
302
  }
166
303
 
167
304
  function removeBinding(db, id) {
168
- runWriteTransaction(db, () => {
169
- db.prepare("DELETE FROM telegram_bindings WHERE id=?").run(id);
170
- });
171
- deleteToken(id);
305
+ const bindingId = assertTelegramBindingId(id);
306
+ const row = getBinding(db, bindingId);
307
+ if (!row) throw new Error("binding not found");
308
+ const file = tokenFile(bindingId, { create: false });
309
+ let tokenExists = false;
310
+ try {
311
+ const stat = fs.lstatSync(file);
312
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Telegram token target is unsafe");
313
+ tokenExists = true;
314
+ } catch (error) {
315
+ if (!error || error.code !== "ENOENT") throw error;
316
+ }
317
+ if (Number(row.token_saved) === 1 && !tokenExists) {
318
+ throw new Error("this binding secret is not Terminal-owned; remove it from Agentlas Desktop so its keychain secret is cleaned up");
319
+ }
320
+ const tombstone = `${file}.${process.pid}.${crypto.randomUUID()}.delete`;
321
+ if (tokenExists) fs.renameSync(file, tombstone);
322
+ try {
323
+ const removed = runWriteTransaction(db, () => {
324
+ return db.prepare("DELETE FROM telegram_bindings WHERE id=?").run(bindingId).changes === 1;
325
+ });
326
+ if (!removed) throw new Error("binding disappeared before removal");
327
+ } catch (error) {
328
+ if (tokenExists) {
329
+ try { if (!fs.existsSync(file)) fs.renameSync(tombstone, file); } catch { /* leave recoverable tombstone */ }
330
+ }
331
+ throw error;
332
+ }
333
+ if (tokenExists) fs.unlinkSync(tombstone);
334
+ return true;
172
335
  }
173
336
 
174
337
  /*
@@ -118,7 +118,7 @@ const CATALOG = [
118
118
  { name: "telegram", group: "advanced", tier: "more", surfaces: BOTH, args: "[sub]", ko: "텔레그램 연결", en: "Telegram bindings" },
119
119
  { name: "connect", group: "advanced", tier: "more", surfaces: BOTH, aliases: ["hep-connect"], args: "<target>", argsKo: "<대상>", ko: "에이전트·팀 연결", en: "Connect an agent or team" },
120
120
  { name: "browser", group: "advanced", tier: "more", surfaces: BOTH, aliases: ["hep-browser"], args: "<url|status|sites|login>", ko: "브라우저 하드포인트", en: "Browser hardpoint" },
121
- { name: "research", group: "advanced", tier: "more", surfaces: BOTH, args: "<sub>", ko: "리서치", en: "Research" },
121
+ { name: "research", group: "advanced", tier: "more", surfaces: BOTH, args: "<subcommand> [args]", argsKo: "<하위-명령> [인자]", ko: "Research Engine (전체 목록: research --help)", en: "Research Engine (full list: research --help)" },
122
122
  { name: "document", group: "advanced", tier: "more", surfaces: BOTH, args: "pdf <html|url>", ko: "문서 PDF 내보내기", en: "Export a document to PDF" },
123
123
  { name: "oberon", group: "advanced", tier: "more", surfaces: BOTH, aliases: ["film"], args: "<scaffold|render|list|open>", ko: "AI 필름 렌더", en: "AI film render" },
124
124
  { name: "variant", group: "advanced", tier: "more", surfaces: BOTH, args: "resolve --base-release", ko: "로컬 변형 선택", en: "Local variant selection" },
@@ -34,11 +34,21 @@ const EFFORT_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max
34
34
  const PERM_LEVELS = ["read", "write", "full"];
35
35
  // 세션 인자를 받는 명령 — 완성 후보를 살아있는 세션 키(s1, s2…)로 채운다.
36
36
  const SESSION_ARG_COMMANDS = new Set(["/s", "/switch", "/kill", "/rm"]);
37
+ // DB-backed agent/firm/session lists must not freeze readline or allocate an
38
+ // unbounded candidate array when a local store contains many rows.
39
+ const MAX_COMPLETION_CANDIDATES = 100;
37
40
 
38
41
  function uniqStartsWith(list, prefix) {
39
42
  const p = String(prefix || "");
40
- const hits = [...new Set(list)].filter((v) => v.startsWith(p));
41
- return hits.length ? hits : [];
43
+ const hits = [];
44
+ const seen = new Set();
45
+ for (const value of Array.isArray(list) ? list : []) {
46
+ if (typeof value !== "string" || !value.startsWith(p) || seen.has(value)) continue;
47
+ seen.add(value);
48
+ hits.push(value);
49
+ if (hits.length >= MAX_COMPLETION_CANDIDATES) break;
50
+ }
51
+ return hits;
42
52
  }
43
53
 
44
54
  /**