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,7 +14,7 @@
14
14
  */
15
15
  const crypto = require("node:crypto");
16
16
  const os = require("node:os");
17
- const { columnExists, tableExists } = require("../core/db.cjs");
17
+ const { columnExists, tableExists, runWriteTransaction } = require("../core/db.cjs");
18
18
  const { nextAutomationRun } = require("./schedule.cjs");
19
19
 
20
20
  const LEASE_TTL_MS = 15 * 60 * 1000; // 앱 store/automations.ts LEASE_TTL_MS와 동일
@@ -31,7 +31,49 @@ function listAutomations(db) {
31
31
 
32
32
  function getAutomationByPrefix(db, idPrefix) {
33
33
  if (!tableExists(db, "automations")) return null;
34
- return db.prepare("SELECT * FROM automations WHERE id LIKE ?").get(String(idPrefix) + "%") || null;
34
+ const prefix = String(idPrefix || "").trim();
35
+ if (!prefix) return null;
36
+ const exact = db.prepare("SELECT * FROM automations WHERE id = ?").get(prefix);
37
+ if (exact) return exact;
38
+ // LIKE를 쓰면 사용자가 입력한 %/_가 와일드카드가 되어 전혀 다른 행을 고른다.
39
+ // 두 행 이상이면 임의의 첫 행을 mutation 대상으로 삼지 않고 전체 ID를 요구한다.
40
+ const matches = db.prepare(
41
+ "SELECT * FROM automations WHERE substr(id, 1, length(?)) = ? ORDER BY id LIMIT 2",
42
+ ).all(prefix, prefix);
43
+ if (matches.length > 1) throw new Error(`automation id prefix is ambiguous: ${prefix}`);
44
+ return matches[0] || null;
45
+ }
46
+
47
+ function resultChanges(result) {
48
+ return Number(result && (result.changes ?? result.rowsAffected) || 0);
49
+ }
50
+
51
+ function validLease(lease) {
52
+ return Boolean(
53
+ lease && typeof lease.owner === "string" && lease.owner &&
54
+ typeof lease.claimedAt === "string" && lease.claimedAt,
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Build the fencing predicate for a post-claim write. When a legacy caller has
60
+ * no lease token, only an actually-unclaimed row may be updated; an active
61
+ * successor lease is never touched. The daemon always supplies the exact
62
+ * owner+claimed_at pair returned by claimAutomationLease().
63
+ */
64
+ function leaseFence(db, id, lease = null, row = null) {
65
+ const parts = ["id = ?"];
66
+ const params = [id];
67
+ if (!leaseSupported(db)) return { sql: parts.join(" AND "), params };
68
+ if (validLease(lease)) {
69
+ parts.push("lease_owner = ?", "claimed_at = ?");
70
+ params.push(lease.owner, lease.claimedAt);
71
+ } else if (row && row.lease_owner == null && row.claimed_at == null) {
72
+ parts.push("lease_owner IS NULL", "claimed_at IS NULL");
73
+ } else {
74
+ parts.push("0");
75
+ }
76
+ return { sql: parts.join(" AND "), params };
35
77
  }
36
78
 
37
79
  /**
@@ -75,17 +117,21 @@ function addAutomation(db, spec, next) {
75
117
 
76
118
  /** on: next_run_at 재계산 후 활성화. off: 비활성화(스케줄 보존). */
77
119
  function setEnabled(db, row, enabled) {
120
+ return setEnabledChecked(db, row, enabled).next;
121
+ }
122
+
123
+ function setEnabledChecked(db, row, enabled) {
78
124
  if (enabled) {
79
125
  const next = nextAutomationRun(row) || null;
80
- db.prepare("UPDATE automations SET enabled=1, next_run_at=? WHERE id=?").run(next ? next.toISOString() : null, row.id);
81
- return next;
126
+ const result = db.prepare("UPDATE automations SET enabled=1, next_run_at=? WHERE id=?").run(next ? next.toISOString() : null, row.id);
127
+ return { changed: resultChanges(result) > 0, next };
82
128
  }
83
- db.prepare("UPDATE automations SET enabled=0 WHERE id=?").run(row.id);
84
- return null;
129
+ const result = db.prepare("UPDATE automations SET enabled=0 WHERE id=?").run(row.id);
130
+ return { changed: resultChanges(result) > 0, next: null };
85
131
  }
86
132
 
87
133
  function removeAutomation(db, id) {
88
- db.prepare("DELETE FROM automations WHERE id=?").run(id);
134
+ return resultChanges(db.prepare("DELETE FROM automations WHERE id=?").run(id)) > 0;
89
135
  }
90
136
 
91
137
  /**
@@ -94,15 +140,21 @@ function removeAutomation(db, id) {
94
140
  * @returns {boolean} true = 이 프로세스가 배타 실행권을 가짐
95
141
  */
96
142
  function claimAutomation(db, id, now = new Date(), owner = LEASE_OWNER) {
143
+ return Boolean(claimAutomationLease(db, id, now, owner));
144
+ }
145
+
146
+ /** Claim and return the exact owner+claimed_at fence used by this run. */
147
+ function claimAutomationLease(db, id, now = new Date(), owner = LEASE_OWNER) {
97
148
  // 리스 열이 없으면 배타성을 증명할 수 없다 — 중복 실행 위험을 감수하지 않고 거부.
98
- if (!leaseSupported(db)) return false;
149
+ if (!leaseSupported(db)) return null;
99
150
  const cutoff = new Date(now.getTime() - LEASE_TTL_MS).toISOString();
151
+ const claimedAt = now.toISOString();
100
152
  const result = db
101
153
  .prepare(
102
154
  "UPDATE automations SET claimed_at = ?, lease_owner = ? WHERE id = ? AND enabled = 1 AND (claimed_at IS NULL OR claimed_at < ?)",
103
155
  )
104
- .run(now.toISOString(), owner, id, cutoff);
105
- return (result.changes ?? result.rowsAffected ?? 0) > 0;
156
+ .run(claimedAt, owner, id, cutoff);
157
+ return resultChanges(result) > 0 ? { owner, claimedAt } : null;
106
158
  }
107
159
 
108
160
  /**
@@ -115,6 +167,7 @@ function claimAutomation(db, id, now = new Date(), owner = LEASE_OWNER) {
115
167
  * 리스를 되빼앗지는 않는다.
116
168
  */
117
169
  function renewAutomationLease(db, id, now = new Date(), owner = LEASE_OWNER) {
170
+ if (arguments.length >= 5) return Boolean(renewAutomationLeaseToken(db, id, now, { owner, claimedAt: arguments[4] }));
118
171
  if (!leaseSupported(db)) return false;
119
172
  // false means definitive ownership loss. SQLite busy/I/O errors must throw
120
173
  // so the caller can retry instead of killing a valid run as if a peer had
@@ -122,15 +175,30 @@ function renewAutomationLease(db, id, now = new Date(), owner = LEASE_OWNER) {
122
175
  const result = db
123
176
  .prepare("UPDATE automations SET claimed_at = ? WHERE id = ? AND lease_owner = ? AND claimed_at IS NOT NULL")
124
177
  .run(now.toISOString(), id, owner);
125
- return (result.changes ?? result.rowsAffected ?? 0) > 0;
178
+ return resultChanges(result) > 0;
179
+ }
180
+
181
+ /** Renew an exact fence and return its new claimed_at value. */
182
+ function renewAutomationLeaseToken(db, id, now = new Date(), lease) {
183
+ if (!leaseSupported(db) || !validLease(lease)) return null;
184
+ const claimedAt = now.toISOString();
185
+ const result = db
186
+ .prepare("UPDATE automations SET claimed_at = ? WHERE id = ? AND lease_owner = ? AND claimed_at = ?")
187
+ .run(claimedAt, id, lease.owner, lease.claimedAt);
188
+ return resultChanges(result) > 0 ? { owner: lease.owner, claimedAt } : null;
126
189
  }
127
190
 
128
- function releaseAutomation(db, id, owner = LEASE_OWNER) {
191
+ function releaseAutomation(db, id, owner = LEASE_OWNER, claimedAt = null) {
129
192
  try {
193
+ if (owner && typeof owner === "object") {
194
+ claimedAt = owner.claimedAt || null;
195
+ owner = owner.owner || LEASE_OWNER;
196
+ }
197
+ const token = typeof claimedAt === "string" && claimedAt ? " AND claimed_at = ?" : "";
130
198
  const result = db
131
- .prepare("UPDATE automations SET claimed_at = NULL, lease_owner = NULL WHERE id = ? AND lease_owner = ?")
132
- .run(id, owner);
133
- return (result.changes ?? result.rowsAffected ?? 0) > 0;
199
+ .prepare(`UPDATE automations SET claimed_at = NULL, lease_owner = NULL WHERE id = ? AND lease_owner = ?${token}`)
200
+ .run(...(token ? [id, owner, claimedAt] : [id, owner]));
201
+ return resultChanges(result) > 0;
134
202
  } catch { return false; }
135
203
  }
136
204
 
@@ -138,22 +206,33 @@ function releaseAutomation(db, id, owner = LEASE_OWNER) {
138
206
  * 실행 기록 — run_history(v1/Desktop 스케줄러 패리티) + automation_runs(있으면) 양쪽.
139
207
  * best-effort: 기록 실패가 자동화 실행 자체를 죽이면 안 된다.
140
208
  */
141
- function recordRun(db, automationId, status, error, scheduledFor) {
209
+ function recordRun(db, automationId, status, error, scheduledFor, lease) {
210
+ if (!leaseSupported(db) || !validLease(lease)) return { ok: false, leaseLost: true, recorded: false };
142
211
  const nowIso = new Date().toISOString();
143
212
  try {
144
- if (tableExists(db, "run_history")) {
145
- db.prepare(
146
- "INSERT INTO run_history (id, automation_id, scheduled_for, ran_at, status, skipped_count, error) VALUES (?,?,?,?,?,0,?)",
147
- ).run(crypto.randomUUID(), automationId, scheduledFor || null, nowIso, status, error || null);
148
- }
149
- } catch { /* best-effort */ }
150
- try {
151
- if (tableExists(db, "automation_runs")) {
152
- db.prepare(
153
- "INSERT INTO automation_runs (id, automation_id, started_at, status, node_states_json) VALUES (?,?,?,?,NULL)",
154
- ).run(crypto.randomUUID(), automationId, nowIso, status);
155
- }
156
- } catch { /* best-effort */ }
213
+ return runWriteTransaction(db, () => {
214
+ const fence = leaseFence(db, automationId, lease);
215
+ let recorded = false;
216
+ if (tableExists(db, "run_history")) {
217
+ const result = db.prepare(
218
+ `INSERT INTO run_history (id, automation_id, scheduled_for, ran_at, status, skipped_count, error)
219
+ SELECT ?,?,?,?,?,0,? FROM automations WHERE ${fence.sql}`,
220
+ ).run(crypto.randomUUID(), automationId, scheduledFor || null, nowIso, status, error || null, ...fence.params);
221
+ recorded = recorded || resultChanges(result) > 0;
222
+ }
223
+ if (tableExists(db, "automation_runs")) {
224
+ const result = db.prepare(
225
+ `INSERT INTO automation_runs (id, automation_id, started_at, status, node_states_json)
226
+ SELECT ?,?,?,?,NULL FROM automations WHERE ${fence.sql}`,
227
+ ).run(crypto.randomUUID(), automationId, nowIso, status, ...fence.params);
228
+ recorded = recorded || resultChanges(result) > 0;
229
+ }
230
+ const stillOwned = db.prepare(`SELECT 1 FROM automations WHERE ${fence.sql}`).get(...fence.params);
231
+ return { ok: Boolean(stillOwned), leaseLost: !stillOwned, recorded };
232
+ });
233
+ } catch (error) {
234
+ return { ok: false, leaseLost: false, recorded: false, error: String((error && error.message) || error) };
235
+ }
157
236
  }
158
237
 
159
238
  function listRuns(db, limit = 15) {
@@ -174,8 +253,9 @@ function listRuns(db, limit = 15) {
174
253
  * ("Disabled: … morning" 을 보고 exit 0 인데도) 다음 예정 시각에 또 발화한다.
175
254
  * 그래서 enabled 는 "켜기"로는 절대 쓰지 않고, 종료 조건일 때만 0 으로 내린다.
176
255
  */
177
- function disableExhausted(db, id) {
178
- db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(id);
256
+ function disableExhausted(db, id, lease = null, row = null) {
257
+ const fence = leaseFence(db, id, lease, row);
258
+ return resultChanges(db.prepare(`UPDATE automations SET enabled = 0 WHERE ${fence.sql}`).run(...fence.params)) > 0;
179
259
  }
180
260
 
181
261
  /**
@@ -188,40 +268,61 @@ function disableExhausted(db, id) {
188
268
  * (같은 이유로 성공/실패 두 분기 모두 같은 규칙을 따라야 한다: 한쪽만 고치면
189
269
  * 실패로 끝난 실행이 여전히 끈 자동화를 부활시킨다.)
190
270
  */
191
- function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date() } = {}) {
271
+ function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date(), lease = null } = {}) {
192
272
  const hasRunCount = columnExists(db, "automations", "run_count");
193
273
  const shouldAdvance = !!advanceSchedule && (row.trigger_type || "schedule") === "schedule";
194
274
  const advance = shouldAdvance ? nextAutomationRun(row, ranAt) : null;
195
- if (ok) {
196
- if (shouldAdvance) {
197
- if (hasRunCount) {
198
- db.prepare(
199
- "UPDATE automations SET last_run_at = ?, run_count = run_count + 1, next_run_at = ? WHERE id = ?",
200
- ).run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
275
+ try {
276
+ return runWriteTransaction(db, () => {
277
+ const fence = leaseFence(db, row.id, lease, row);
278
+ let result;
279
+ if (ok) {
280
+ if (shouldAdvance) {
281
+ const sql = hasRunCount
282
+ ? "UPDATE automations SET last_run_at = ?, run_count = run_count + 1, next_run_at = ?"
283
+ : "UPDATE automations SET last_run_at = ?, next_run_at = ?";
284
+ result = db.prepare(`${sql} WHERE ${fence.sql}`).run(
285
+ ranAt.toISOString(), advance ? advance.toISOString() : null, ...fence.params,
286
+ );
287
+ } else if (hasRunCount) {
288
+ result = db.prepare(`UPDATE automations SET last_run_at = ?, run_count = run_count + 1 WHERE ${fence.sql}`)
289
+ .run(ranAt.toISOString(), ...fence.params);
290
+ } else {
291
+ result = db.prepare(`UPDATE automations SET last_run_at = ? WHERE ${fence.sql}`).run(ranAt.toISOString(), ...fence.params);
292
+ }
293
+ } else if (shouldAdvance) {
294
+ result = db.prepare(`UPDATE automations SET last_run_at = ?, next_run_at = ? WHERE ${fence.sql}`)
295
+ .run(ranAt.toISOString(), advance ? advance.toISOString() : null, ...fence.params);
201
296
  } else {
202
- db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ? WHERE id = ?")
203
- .run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
297
+ result = db.prepare(`UPDATE automations SET last_run_at = ? WHERE ${fence.sql}`).run(ranAt.toISOString(), ...fence.params);
204
298
  }
205
- if (!advance) disableExhausted(db, row.id);
206
- } else if (hasRunCount) {
207
- db.prepare("UPDATE automations SET last_run_at = ?, run_count = run_count + 1 WHERE id = ?")
208
- .run(ranAt.toISOString(), row.id);
209
- } else {
210
- db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(ranAt.toISOString(), row.id);
211
- }
212
- // max_runs 도달 시 비활성화 (앱과 동일한 종료 조건).
213
- if (row.max_runs && (row.run_count || 0) + 1 >= row.max_runs) {
214
- disableExhausted(db, row.id);
215
- return { advance, maxRunsReached: true };
216
- }
217
- } else if (shouldAdvance) {
218
- db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ? WHERE id = ?")
219
- .run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
220
- if (!advance) disableExhausted(db, row.id);
221
- } else {
222
- db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(ranAt.toISOString(), row.id);
299
+ if (resultChanges(result) === 0) return { advance, maxRunsReached: false, updated: false, leaseLost: true };
300
+
301
+ let maxRunsReached = false;
302
+ if (shouldAdvance && !advance) {
303
+ if (!disableExhausted(db, row.id, lease, row)) {
304
+ return { advance, maxRunsReached: false, updated: false, leaseLost: true };
305
+ }
306
+ maxRunsReached = true;
307
+ }
308
+ // max_runs 도달 시 비활성화 (앱과 동일한 종료 조건).
309
+ if (ok && row.max_runs && (row.run_count || 0) + 1 >= row.max_runs) {
310
+ if (!disableExhausted(db, row.id, lease, row)) {
311
+ return { advance, maxRunsReached: false, updated: false, leaseLost: true };
312
+ }
313
+ maxRunsReached = true;
314
+ }
315
+ return { advance, maxRunsReached, updated: true, leaseLost: false };
316
+ });
317
+ } catch (error) {
318
+ return {
319
+ advance,
320
+ maxRunsReached: false,
321
+ updated: false,
322
+ leaseLost: false,
323
+ error: String((error && error.message) || error),
324
+ };
223
325
  }
224
- return { advance, maxRunsReached: false };
225
326
  }
226
327
 
227
328
  /**
@@ -279,9 +380,12 @@ module.exports = {
279
380
  getAutomationByPrefix,
280
381
  addAutomation,
281
382
  setEnabled,
383
+ setEnabledChecked,
282
384
  removeAutomation,
283
385
  claimAutomation,
386
+ claimAutomationLease,
284
387
  renewAutomationLease,
388
+ renewAutomationLeaseToken,
285
389
  releaseAutomation,
286
390
  recordRun,
287
391
  listRuns,
@@ -17,24 +17,83 @@
17
17
  * 안전: Agentlas 전용 프로필 Chrome 에만 붙는다(개인 크롬 아님). 되돌릴 수 없는
18
18
  * 행동은 호출자가 판단한다 — 이 모듈은 조종 primitive 만 제공한다.
19
19
  */
20
+ const crypto = require("node:crypto");
21
+ const fs = require("node:fs");
20
22
  const http = require("node:http");
23
+ const path = require("node:path");
21
24
 
22
25
  const DEFAULT_PORT = Number(process.env.AGENTLAS_CDP_PORT || 9222);
23
26
  const DEFAULT_WS_OPEN_TIMEOUT_MS = 5_000;
27
+ const CDP_HTTP_TIMEOUT_MS = 3_000;
28
+ const CDP_HTTP_MAX_BYTES = 8 * 1024 * 1024;
29
+ const CDP_PDF_MAX_BYTES = 64 * 1024 * 1024;
24
30
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
25
31
 
26
- function httpJson(port, path, { timeout = 3000, method = "GET" } = {}) {
32
+ function httpJson(port, requestPath, { timeout = CDP_HTTP_TIMEOUT_MS, method = "GET" } = {}) {
33
+ const limit = Number.isFinite(Number(timeout)) && Number(timeout) > 0
34
+ ? Number(timeout)
35
+ : CDP_HTTP_TIMEOUT_MS;
27
36
  return new Promise((resolve, reject) => {
28
- const req = http.request({ host: "127.0.0.1", port, path, timeout, method }, (res) => {
29
- let body = "";
30
- res.on("data", (d) => { body += d; });
31
- res.on("end", () => {
32
- try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
37
+ let req = null;
38
+ let response = null;
39
+ let timer = null;
40
+ let settled = false;
41
+ const finish = (error, value) => {
42
+ if (settled) return;
43
+ settled = true;
44
+ if (timer) clearTimeout(timer);
45
+ if (error) {
46
+ try { response?.destroy(); } catch { /* already closed */ }
47
+ try { req?.destroy(); } catch { /* already closed */ }
48
+ reject(error);
49
+ } else {
50
+ resolve(value);
51
+ }
52
+ };
53
+ const timeoutError = () => {
54
+ const error = new Error(`CDP ${requestPath} timed out`);
55
+ error.code = "CDP_HTTP_TIMEOUT";
56
+ return error;
57
+ };
58
+ try {
59
+ req = http.request({ host: "127.0.0.1", port, path: requestPath, timeout: limit, method }, (res) => {
60
+ response = res;
61
+ const chunks = [];
62
+ let bodyBytes = 0;
63
+ res.on("data", (chunk) => {
64
+ if (settled) return;
65
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
66
+ bodyBytes += bytes.length;
67
+ if (bodyBytes > CDP_HTTP_MAX_BYTES) {
68
+ const error = new Error(`CDP ${requestPath} response exceeded ${CDP_HTTP_MAX_BYTES} bytes`);
69
+ error.code = "CDP_HTTP_BODY_LIMIT";
70
+ finish(error);
71
+ return;
72
+ }
73
+ chunks.push(bytes);
74
+ });
75
+ res.on("aborted", () => finish(new Error(`CDP ${requestPath} response aborted`)));
76
+ res.on("error", (error) => finish(error));
77
+ res.on("end", () => {
78
+ if (settled) return;
79
+ try {
80
+ finish(null, JSON.parse(Buffer.concat(chunks, bodyBytes).toString("utf8")));
81
+ } catch (error) {
82
+ finish(error);
83
+ }
84
+ });
33
85
  });
34
- });
35
- req.on("error", reject);
36
- req.on("timeout", () => { req.destroy(); reject(new Error(`CDP ${path} timed out`)); });
37
- req.end();
86
+ req.on("error", (error) => finish(error));
87
+ // Keep the inactivity timeout as a fast-path, but also enforce the
88
+ // absolute deadline below so a peer cannot keep this request alive by
89
+ // dripping one byte before every inactivity interval.
90
+ req.on("timeout", () => finish(timeoutError()));
91
+ timer = setTimeout(() => finish(timeoutError()), limit);
92
+ timer.unref?.();
93
+ req.end();
94
+ } catch (error) {
95
+ finish(error);
96
+ }
38
97
  });
39
98
  }
40
99
 
@@ -96,21 +155,115 @@ async function pickPageTarget(port = DEFAULT_PORT, options = {}) {
96
155
 
97
156
  function closePageTarget(port, targetId) {
98
157
  return new Promise((resolve) => {
158
+ let settled = false;
159
+ let timer = null;
160
+ const finish = () => {
161
+ if (settled) return;
162
+ settled = true;
163
+ if (timer) clearTimeout(timer);
164
+ resolve();
165
+ };
99
166
  const req = http.request({
100
167
  host: "127.0.0.1",
101
168
  port,
102
169
  path: `/json/close/${encodeURIComponent(String(targetId))}`,
103
- timeout: 3_000,
170
+ timeout: CDP_HTTP_TIMEOUT_MS,
104
171
  }, (res) => {
105
172
  res.resume();
106
- res.once("end", resolve);
173
+ res.once("end", finish);
174
+ res.once("aborted", finish);
175
+ res.once("error", finish);
107
176
  });
108
- req.once("error", resolve);
109
- req.once("timeout", () => { req.destroy(); resolve(); });
177
+ req.once("error", finish);
178
+ req.once("timeout", () => { req.destroy(); finish(); });
179
+ timer = setTimeout(() => { req.destroy(); finish(); }, CDP_HTTP_TIMEOUT_MS);
180
+ timer.unref?.();
110
181
  req.end();
111
182
  });
112
183
  }
113
184
 
185
+ function sameFileIdentity(left, right) {
186
+ return left && right && String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
187
+ }
188
+
189
+ function writeAtomicRegularFile(outPath, data) {
190
+ const target = path.resolve(String(outPath));
191
+ let original = null;
192
+ try {
193
+ original = fs.lstatSync(target);
194
+ if (!original.isFile() || original.nlink !== 1) {
195
+ const error = new Error(`CDP output path must be a regular file with one link: ${target}`);
196
+ error.code = "CDP_UNSAFE_OUTPUT_PATH";
197
+ throw error;
198
+ }
199
+ } catch (error) {
200
+ if (error && error.code !== "ENOENT") throw error;
201
+ }
202
+
203
+ const dir = path.dirname(target);
204
+ const base = path.basename(target);
205
+ const flags = fs.constants.O_WRONLY
206
+ | fs.constants.O_CREAT
207
+ | fs.constants.O_EXCL
208
+ | (fs.constants.O_NOFOLLOW || 0);
209
+ let temp = null;
210
+ let fd = null;
211
+ try {
212
+ for (let attempt = 0; attempt < 3; attempt += 1) {
213
+ temp = path.join(dir, `.${base}.${process.pid}.${crypto.randomUUID()}.tmp`);
214
+ try {
215
+ fd = fs.openSync(temp, flags, 0o600);
216
+ break;
217
+ } catch (error) {
218
+ temp = null;
219
+ if (!error || error.code !== "EEXIST" || attempt === 2) throw error;
220
+ }
221
+ }
222
+ if (fd == null || !temp) throw new Error("could not create a temporary CDP output file");
223
+ let offset = 0;
224
+ while (offset < data.length) {
225
+ const written = fs.writeSync(fd, data, offset, data.length - offset);
226
+ if (!written) throw new Error("could not write the CDP output file");
227
+ offset += written;
228
+ }
229
+ fs.fsyncSync(fd);
230
+ fs.closeSync(fd);
231
+ fd = null;
232
+
233
+ // Refuse a path that changed identity while the temporary file was being
234
+ // written. The final rename itself never follows a symlink or hardlink.
235
+ let current = null;
236
+ try { current = fs.lstatSync(target); } catch (error) {
237
+ if (!error || error.code !== "ENOENT") throw error;
238
+ }
239
+ if (original) {
240
+ if (!current || !current.isFile() || current.nlink !== 1 || !sameFileIdentity(original, current)) {
241
+ const error = new Error(`CDP output path changed while writing: ${target}`);
242
+ error.code = "CDP_OUTPUT_IDENTITY_CHANGED";
243
+ throw error;
244
+ }
245
+ } else if (current) {
246
+ // The caller observed an absent path. A file appearing before commit is
247
+ // a competing writer, even when it is otherwise a safe regular file.
248
+ // Never overwrite a file whose identity this operation did not capture.
249
+ const error = new Error(`CDP output path appeared while writing: ${target}`);
250
+ error.code = current.isFile() && current.nlink === 1
251
+ ? "CDP_OUTPUT_IDENTITY_CHANGED"
252
+ : "CDP_UNSAFE_OUTPUT_PATH";
253
+ throw error;
254
+ }
255
+ fs.renameSync(temp, target);
256
+ temp = null;
257
+ } finally {
258
+ if (fd != null) {
259
+ try { fs.closeSync(fd); } catch { /* already closed */ }
260
+ }
261
+ if (temp) {
262
+ try { fs.unlinkSync(temp); } catch { /* best-effort cleanup */ }
263
+ }
264
+ }
265
+ }
266
+
114
267
  function waitForWebSocketOpen(ws, timeoutMs) {
115
268
  return new Promise((resolve, reject) => {
116
269
  let settled = false;
@@ -280,9 +433,19 @@ async function attachPage({ port = DEFAULT_PORT, wsUrl, selection = "new", targe
280
433
  async function printPdf(outPath, { landscape = false, printBackground = true, scale = 1 } = {}) {
281
434
  const res = await send("Page.printToPDF", { landscape, printBackground, scale, transferMode: "ReturnAsBase64" }, { timeout: 60000 });
282
435
  const b64 = res && res.data;
283
- if (!b64) throw new Error("Page.printToPDF returned no data");
436
+ if (typeof b64 !== "string" || !b64) throw new Error("Page.printToPDF returned no data");
437
+ if (b64.length > Math.ceil(CDP_PDF_MAX_BYTES / 3) * 4) {
438
+ const error = new Error(`Page.printToPDF response exceeded ${CDP_PDF_MAX_BYTES} bytes`);
439
+ error.code = "CDP_PDF_BODY_LIMIT";
440
+ throw error;
441
+ }
284
442
  const buf = Buffer.from(b64, "base64");
285
- require("node:fs").writeFileSync(outPath, buf);
443
+ if (buf.length > CDP_PDF_MAX_BYTES) {
444
+ const error = new Error(`Page.printToPDF response exceeded ${CDP_PDF_MAX_BYTES} bytes`);
445
+ error.code = "CDP_PDF_BODY_LIMIT";
446
+ throw error;
447
+ }
448
+ writeAtomicRegularFile(outPath, buf);
286
449
  return { path: outPath, bytes: buf.length };
287
450
  }
288
451
 
@@ -301,4 +464,14 @@ async function attachPage({ port = DEFAULT_PORT, wsUrl, selection = "new", targe
301
464
  };
302
465
  }
303
466
 
304
- module.exports = { cdpReady, pickPageTarget, attachPage, DEFAULT_PORT, DEFAULT_WS_OPEN_TIMEOUT_MS };
467
+ module.exports = {
468
+ cdpReady,
469
+ pickPageTarget,
470
+ attachPage,
471
+ DEFAULT_PORT,
472
+ DEFAULT_WS_OPEN_TIMEOUT_MS,
473
+ CDP_HTTP_TIMEOUT_MS,
474
+ CDP_HTTP_MAX_BYTES,
475
+ CDP_PDF_MAX_BYTES,
476
+ writeAtomicRegularFile,
477
+ };
@@ -22,6 +22,7 @@ function normalizeSite(input) {
22
22
  if (!raw) return "";
23
23
  let u;
24
24
  try { u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return ""; }
25
+ if (u.protocol !== "http:" && u.protocol !== "https:") return "";
25
26
  if (u.username || u.password) return ""; // 자격증명은 사이트 키에 절대 들이지 않는다
26
27
  const host = u.host.toLowerCase().replace(/^www\./, "");
27
28
  return host && !/\s/.test(host) ? host : "";