agentlas 1.0.64 → 1.0.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +8 -6
  3. package/bin/agentlas.cjs +341 -16
  4. package/engine/acp/server.cjs +27 -1
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +29 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +10 -3
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/bootstrap-schema.sql +4 -4
  43. package/engine/browser/cdp.cjs +315 -32
  44. package/engine/browser/vault.cjs +1 -0
  45. package/engine/cli-output.cjs +80 -28
  46. package/engine/cloud/auth.cjs +131 -13
  47. package/engine/cloud/hub-client.cjs +41 -16
  48. package/engine/cloud-assets/cas.cjs +18 -9
  49. package/engine/cloud-assets/commands.cjs +108 -30
  50. package/engine/cloud-assets/package.cjs +66 -3
  51. package/engine/cloud-assets/restore.cjs +12 -9
  52. package/engine/cloud-assets/state.cjs +308 -113
  53. package/engine/commands/acp.cjs +13 -2
  54. package/engine/commands/automation.cjs +75 -24
  55. package/engine/commands/billing.cjs +6 -1
  56. package/engine/commands/browser.cjs +67 -34
  57. package/engine/commands/build.cjs +42 -12
  58. package/engine/commands/call.cjs +1 -1
  59. package/engine/commands/cd.cjs +1 -1
  60. package/engine/commands/cloud.cjs +2 -1
  61. package/engine/commands/connect.cjs +38 -13
  62. package/engine/commands/creds.cjs +74 -29
  63. package/engine/commands/doctor.cjs +17 -1
  64. package/engine/commands/document.cjs +40 -12
  65. package/engine/commands/env.cjs +6 -1
  66. package/engine/commands/firm.cjs +42 -12
  67. package/engine/commands/graph.cjs +36 -32
  68. package/engine/commands/help.cjs +11 -1
  69. package/engine/commands/hep.cjs +1 -1
  70. package/engine/commands/import.cjs +5 -4
  71. package/engine/commands/index.cjs +1 -1
  72. package/engine/commands/install.cjs +6 -4
  73. package/engine/commands/list.cjs +56 -16
  74. package/engine/commands/login.cjs +5 -0
  75. package/engine/commands/logout.cjs +6 -1
  76. package/engine/commands/mcp.cjs +8 -2
  77. package/engine/commands/memory.cjs +3 -2
  78. package/engine/commands/multimodal.cjs +12 -0
  79. package/engine/commands/native.cjs +5 -1
  80. package/engine/commands/one.cjs +78 -33
  81. package/engine/commands/plugin.cjs +13 -2
  82. package/engine/commands/project.cjs +15 -12
  83. package/engine/commands/research.cjs +18 -7
  84. package/engine/commands/roles.cjs +104 -28
  85. package/engine/commands/route.cjs +2 -2
  86. package/engine/commands/run.cjs +42 -9
  87. package/engine/commands/search.cjs +23 -2
  88. package/engine/commands/setup.cjs +6 -1
  89. package/engine/commands/storm.cjs +44 -6
  90. package/engine/commands/swarm.cjs +47 -9
  91. package/engine/commands/telegram.cjs +6 -1
  92. package/engine/commands/uninstall.cjs +12 -6
  93. package/engine/commands/update.cjs +5 -0
  94. package/engine/commands/upload.cjs +2 -1
  95. package/engine/commands/usage.cjs +6 -1
  96. package/engine/commands/version.cjs +6 -1
  97. package/engine/commands/whoami.cjs +6 -1
  98. package/engine/commands/workforce.cjs +60 -16
  99. package/engine/core/capability-grants.cjs +7 -2
  100. package/engine/core/daemon-client.cjs +36 -7
  101. package/engine/core/db.cjs +10 -2
  102. package/engine/core/desktop-core-fetch.cjs +477 -77
  103. package/engine/core/schema-ensure.cjs +23 -11
  104. package/engine/experience/build.cjs +5 -1
  105. package/engine/experience/intents.cjs +29 -82
  106. package/engine/experience/runtime.cjs +7 -1
  107. package/engine/experience/variant.cjs +58 -4
  108. package/engine/firms/orchestrate.cjs +9 -6
  109. package/engine/graph/ask-model.cjs +22 -11
  110. package/engine/graph/interview.cjs +74 -23
  111. package/engine/graph/package.cjs +63 -8
  112. package/engine/hephaestus/local-core.cjs +18 -5
  113. package/engine/hephaestus/runtime.cjs +48 -43
  114. package/engine/hub/install.cjs +230 -61
  115. package/engine/hub/plugins.cjs +391 -29
  116. package/engine/mcp/consent.cjs +25 -17
  117. package/engine/mcp/contract.cjs +229 -24
  118. package/engine/mcp/inventory.cjs +15 -5
  119. package/engine/mcp/plan.cjs +5 -1
  120. package/engine/mcp/probe.cjs +24 -9
  121. package/engine/memory-cli/curate.cjs +74 -16
  122. package/engine/oberon/common.cjs +37 -12
  123. package/engine/oberon/manifest.cjs +25 -2
  124. package/engine/oberon/outputs.cjs +49 -19
  125. package/engine/oberon/render.cjs +665 -40
  126. package/engine/project/career-graph.cjs +15 -2
  127. package/engine/project/controller.cjs +133 -15
  128. package/engine/project/credentials.cjs +212 -34
  129. package/engine/project/env-file.cjs +93 -8
  130. package/engine/project/memory-context.cjs +11 -57
  131. package/engine/project/ontology.cjs +66 -25
  132. package/engine/project/paths.cjs +42 -8
  133. package/engine/project/seed.cjs +88 -51
  134. package/engine/project/state.cjs +41 -16
  135. package/engine/project/team.cjs +26 -1
  136. package/engine/runtime-refusal.cjs +4 -1
  137. package/engine/runtimes/acp-driver.cjs +56 -9
  138. package/engine/runtimes/auth-evidence.cjs +21 -2
  139. package/engine/runtimes/detect.cjs +28 -8
  140. package/engine/runtimes/kinds.cjs +12 -4
  141. package/engine/runtimes/overrides.cjs +4 -3
  142. package/engine/runtimes/resolve.cjs +8 -7
  143. package/engine/runtimes/roles.cjs +7 -5
  144. package/engine/sessions/apply-fences.cjs +396 -56
  145. package/engine/sessions/fences.cjs +111 -27
  146. package/engine/sessions/memory-turn.cjs +62 -3
  147. package/engine/sessions/orchestrator.cjs +11 -1
  148. package/engine/sessions/prompt.cjs +66 -9
  149. package/engine/sessions/session.cjs +70 -10
  150. package/engine/sessions/sink.cjs +78 -12
  151. package/engine/sessions/store.cjs +29 -7
  152. package/engine/storm/storm.cjs +26 -3
  153. package/engine/storm/swarm.cjs +31 -3
  154. package/engine/telegram/connect.cjs +187 -24
  155. package/engine/ui/commands-catalog.cjs +1 -1
  156. package/engine/ui/palette.cjs +12 -2
  157. package/engine/ui/repl.cjs +57 -9
  158. package/engine/ui/screens.cjs +2 -4
  159. package/engine/vendor/desktop-core.manifest.json +5 -5
  160. package/engine/workforce/capture.cjs +9 -27
  161. package/engine/workforce/deps.cjs +18 -6
  162. package/engine/workforce/local-core-transport.cjs +68 -5
  163. package/package.json +1 -1
  164. package/engine/commands/career-graph.cjs +0 -51
@@ -17,22 +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);
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;
30
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
23
31
 
24
- 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;
25
36
  return new Promise((resolve, reject) => {
26
- const req = http.request({ host: "127.0.0.1", port, path, timeout, method }, (res) => {
27
- let body = "";
28
- res.on("data", (d) => { body += d; });
29
- res.on("end", () => {
30
- 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
+ });
31
85
  });
32
- });
33
- req.on("error", reject);
34
- req.on("timeout", () => { req.destroy(); reject(new Error(`CDP ${path} timed out`)); });
35
- 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
+ }
36
97
  });
37
98
  }
38
99
 
@@ -46,16 +107,184 @@ async function cdpReady(port = DEFAULT_PORT) {
46
107
  } catch { return false; }
47
108
  }
48
109
 
49
- /** 조종할 page 타겟 하나를 고른다(없으면 새로 연다). 반환: webSocketDebuggerUrl. */
50
- async function pickPageTarget(port = DEFAULT_PORT) {
51
- const targets = await httpJson(port, "/json");
52
- let page = (Array.isArray(targets) ? targets : []).find((t) => t.type === "page" && t.webSocketDebuggerUrl);
53
- if (!page) {
54
- // 탭을 연다(DevTools HTTP: PUT /json/new).
55
- page = await httpJson(port, "/json/new?about:blank", { method: "PUT" });
110
+ function verifiedPageTarget(target, port, ownsTarget = false) {
111
+ if (!target || target.type !== "page" || typeof target.id !== "string" || typeof target.webSocketDebuggerUrl !== "string") return null;
112
+ try {
113
+ const socket = new URL(target.webSocketDebuggerUrl);
114
+ if (socket.protocol !== "ws:" || !LOOPBACK_HOSTS.has(socket.hostname.toLowerCase()) || Number(socket.port) !== port) return null;
115
+ } catch {
116
+ return null;
56
117
  }
57
- if (!page || !page.webSocketDebuggerUrl) throw new Error("no CDP page target available");
58
- return page.webSocketDebuggerUrl;
118
+ return { id: target.id, webSocketDebuggerUrl: target.webSocketDebuggerUrl, ownsTarget };
119
+ }
120
+
121
+ async function resolvePageTarget(port, { selection = "new", targetId = null } = {}) {
122
+ if (selection !== "new" && selection !== "existing") {
123
+ throw new Error(`unsupported CDP target selection: ${selection}`);
124
+ }
125
+ const rawTargets = await httpJson(port, "/json");
126
+ const pages = (Array.isArray(rawTargets) ? rawTargets : [])
127
+ .map((target) => verifiedPageTarget(target, port))
128
+ .filter(Boolean);
129
+
130
+ if (selection === "existing") {
131
+ if (!targetId) throw new Error("existing CDP target selection requires an explicit targetId");
132
+ const exact = pages.find((target) => target.id === String(targetId));
133
+ if (!exact) throw new Error(`no CDP page target matches targetId ${targetId}`);
134
+ return exact;
135
+ }
136
+
137
+ // Never reuse an arbitrary first page. It may be a user's active tab, and
138
+ // browser/document/Telegram flows must not navigate it behind their back.
139
+ // Create a target we own, then return its socket identity to the caller.
140
+ const created = await httpJson(port, `/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" });
141
+ const exact = verifiedPageTarget(created, port, true);
142
+ if (!exact) throw new Error("CDP did not return a verified page target for the new tab");
143
+ return exact;
144
+ }
145
+
146
+ /**
147
+ * Pick a page target without guessing ownership.
148
+ *
149
+ * The safe default is a fresh target. Reusing a page requires selection:
150
+ * `pickPageTarget(port, { selection: "existing", targetId })`.
151
+ */
152
+ async function pickPageTarget(port = DEFAULT_PORT, options = {}) {
153
+ return (await resolvePageTarget(port, options)).webSocketDebuggerUrl;
154
+ }
155
+
156
+ function closePageTarget(port, targetId) {
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
+ };
166
+ const req = http.request({
167
+ host: "127.0.0.1",
168
+ port,
169
+ path: `/json/close/${encodeURIComponent(String(targetId))}`,
170
+ timeout: CDP_HTTP_TIMEOUT_MS,
171
+ }, (res) => {
172
+ res.resume();
173
+ res.once("end", finish);
174
+ res.once("aborted", finish);
175
+ res.once("error", finish);
176
+ });
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?.();
181
+ req.end();
182
+ });
183
+ }
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
+
267
+ function waitForWebSocketOpen(ws, timeoutMs) {
268
+ return new Promise((resolve, reject) => {
269
+ let settled = false;
270
+ let timer;
271
+ const finish = (error) => {
272
+ if (settled) return;
273
+ settled = true;
274
+ clearTimeout(timer);
275
+ if (error) reject(error);
276
+ else resolve();
277
+ };
278
+ timer = setTimeout(() => {
279
+ const error = new Error(`CDP websocket open timed out after ${timeoutMs}ms`);
280
+ error.code = "CDP_WEBSOCKET_OPEN_TIMEOUT";
281
+ try { ws.close(); } catch { /* already closed */ }
282
+ finish(error);
283
+ }, timeoutMs);
284
+ ws.addEventListener("open", () => finish(), { once: true });
285
+ ws.addEventListener("error", () => finish(new Error("CDP websocket failed to open")), { once: true });
286
+ ws.addEventListener("close", () => finish(new Error("CDP websocket closed before opening")), { once: true });
287
+ });
59
288
  }
60
289
 
61
290
  /**
@@ -65,16 +294,31 @@ async function pickPageTarget(port = DEFAULT_PORT) {
65
294
  * evaluate(js, {awaitPromise}) Runtime.evaluate(returnByValue) — 값 반환
66
295
  * waitFor(jsPredicate, {timeoutMs, pollMs}) predicate 가 truthy 될 때까지
67
296
  */
68
- async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
69
- const url = wsUrl || (await pickPageTarget(port));
70
- const ws = new WebSocket(url);
71
- await new Promise((resolve, reject) => {
72
- ws.addEventListener("open", resolve, { once: true });
73
- ws.addEventListener("error", () => reject(new Error("CDP websocket failed to open")), { once: true });
74
- });
297
+ async function attachPage({ port = DEFAULT_PORT, wsUrl, selection = "new", targetId, connectTimeoutMs = DEFAULT_WS_OPEN_TIMEOUT_MS } = {}) {
298
+ const target = wsUrl
299
+ ? (() => {
300
+ try {
301
+ const socket = new URL(wsUrl);
302
+ if (socket.protocol !== "ws:" || !LOOPBACK_HOSTS.has(socket.hostname.toLowerCase()) || Number(socket.port) !== port) return null;
303
+ } catch { return null; }
304
+ return { id: null, webSocketDebuggerUrl: wsUrl, ownsTarget: false };
305
+ })()
306
+ : await resolvePageTarget(port, { selection, targetId });
307
+ if (!target) throw new Error(`invalid CDP websocket target for port ${port}`);
308
+
309
+ const url = target.webSocketDebuggerUrl;
310
+ let ws;
311
+ try {
312
+ ws = new WebSocket(url);
313
+ await waitForWebSocketOpen(ws, connectTimeoutMs);
314
+ } catch (error) {
315
+ if (target.ownsTarget) await closePageTarget(port, target.id);
316
+ throw error;
317
+ }
75
318
 
76
319
  let nextId = 0;
77
320
  const pending = new Map();
321
+ let closed = false;
78
322
  ws.addEventListener("message", (event) => {
79
323
  let msg;
80
324
  try { msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)); } catch { return; }
@@ -85,8 +329,16 @@ async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
85
329
  else resolve(msg.result);
86
330
  }
87
331
  });
332
+ ws.addEventListener("close", () => {
333
+ for (const { reject, timer } of pending.values()) {
334
+ clearTimeout(timer);
335
+ reject(new Error("CDP websocket closed"));
336
+ }
337
+ pending.clear();
338
+ }, { once: true });
88
339
 
89
340
  function send(method, params = {}, { timeout = 30000 } = {}) {
341
+ if (closed) return Promise.reject(new Error("CDP page is closed"));
90
342
  const id = ++nextId;
91
343
  return new Promise((resolve, reject) => {
92
344
  const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP ${method} timed out`)); }, timeout);
@@ -94,7 +346,8 @@ async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
94
346
  resolve: (r) => { clearTimeout(timer); resolve(r); },
95
347
  reject: (e) => { clearTimeout(timer); reject(e); },
96
348
  });
97
- ws.send(JSON.stringify({ id, method, params }));
349
+ try { ws.send(JSON.stringify({ id, method, params })); }
350
+ catch (error) { pending.delete(id); clearTimeout(timer); reject(error); }
98
351
  });
99
352
  }
100
353
 
@@ -180,15 +433,45 @@ async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
180
433
  async function printPdf(outPath, { landscape = false, printBackground = true, scale = 1 } = {}) {
181
434
  const res = await send("Page.printToPDF", { landscape, printBackground, scale, transferMode: "ReturnAsBase64" }, { timeout: 60000 });
182
435
  const b64 = res && res.data;
183
- 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
+ }
184
442
  const buf = Buffer.from(b64, "base64");
185
- 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);
186
449
  return { path: outPath, bytes: buf.length };
187
450
  }
188
451
 
189
- function close() { try { ws.close(); } catch { /* already closed */ } }
452
+ async function close({ closeTarget = false } = {}) {
453
+ if (closed) return;
454
+ closed = true;
455
+ try { ws.close(); } catch { /* already closed */ }
456
+ if (closeTarget && target.ownsTarget && target.id) await closePageTarget(port, target.id);
457
+ }
190
458
 
191
- return { navigate, evaluate, evalExpr, waitFor, focusSelector, typeInto, pressKey, clickSelector, printPdf, close, send };
459
+ return {
460
+ navigate, evaluate, evalExpr, waitFor, focusSelector, typeInto, pressKey,
461
+ clickSelector, printPdf, close, send,
462
+ targetId: target.id,
463
+ ownsTarget: target.ownsTarget,
464
+ };
192
465
  }
193
466
 
194
- module.exports = { cdpReady, pickPageTarget, attachPage, DEFAULT_PORT };
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 : "";
@@ -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
  };