agentlas 1.0.29 → 1.0.36

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 (50) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/bin/agentlas.cjs +15 -0
  3. package/engine/agentlas-cloud-runtime.cjs +54 -4
  4. package/engine/agentlas-input.cjs +10 -1
  5. package/engine/agentlas-judgment.cjs +0 -0
  6. package/engine/agentlas-native-host.cjs +69 -4
  7. package/engine/agentlas-ui.cjs +2 -0
  8. package/engine/agentlas-workforce.cjs +28 -5
  9. package/engine/agentlas.cjs +64 -2
  10. package/engine/agents/builder.cjs +84 -0
  11. package/engine/agents/router.cjs +8 -2
  12. package/engine/automation/launchd.cjs +131 -0
  13. package/engine/bootstrap-schema.sql +6 -44
  14. package/engine/browser/cdp.cjs +188 -0
  15. package/engine/browser/vault.cjs +124 -0
  16. package/engine/cli-output.cjs +262 -0
  17. package/engine/cloud-assets/package.cjs +5 -1
  18. package/engine/commands/automation.cjs +37 -1
  19. package/engine/commands/browser.cjs +166 -8
  20. package/engine/commands/build.cjs +101 -20
  21. package/engine/commands/connect.cjs +162 -9
  22. package/engine/commands/creds.cjs +16 -2
  23. package/engine/commands/doctor.cjs +20 -1
  24. package/engine/commands/document.cjs +79 -0
  25. package/engine/commands/graph.cjs +50 -10
  26. package/engine/commands/help.cjs +8 -6
  27. package/engine/commands/index.cjs +1 -0
  28. package/engine/commands/list.cjs +10 -1
  29. package/engine/commands/project.cjs +79 -16
  30. package/engine/commands/roles.cjs +10 -2
  31. package/engine/commands/telegram.cjs +23 -16
  32. package/engine/core/desktop-core-fetch.cjs +98 -0
  33. package/engine/core/desktop-core.cjs +170 -0
  34. package/engine/graph/ask-model.cjs +29 -1
  35. package/engine/graph/interview.cjs +105 -20
  36. package/engine/graph/layout.cjs +36 -34
  37. package/engine/graph/vocabulary.generated.cjs +1 -1
  38. package/engine/hephaestus/runtime.cjs +10 -2
  39. package/engine/project/controller.cjs +8 -8
  40. package/engine/project/team.cjs +99 -0
  41. package/engine/runtime-refusal.cjs +71 -0
  42. package/engine/runtimes/detect.cjs +3 -0
  43. package/engine/runtimes/resolve.cjs +1 -1
  44. package/engine/sessions/session.cjs +15 -2
  45. package/engine/telegram/connect.cjs +202 -0
  46. package/engine/ui/palette.cjs +1 -0
  47. package/engine/ui/repl.cjs +112 -1
  48. package/engine/vendor/desktop-core.manifest.json +7 -0
  49. package/engine/workforce/capture.cjs +51 -1
  50. package/package.json +3 -2
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ /*
3
+ * automation/launchd — 앱/창이 꺼져 있어도 자동화를 돌리는 macOS 영속성 (2026-08-06).
4
+ *
5
+ * 배경(오너: "터미널인데 모든 기능이 다 돼야"): 터미널 automation daemon 은 포그라운드
6
+ * setInterval 이라 셸 창을 닫으면 멈춘다 — "데스크탑 없이 자동화가 발동"이 실제론 안 됐다.
7
+ * 데스크탑 electron/launchd/agent.ts 와 같은 방식으로 ~/Library/LaunchAgents 에 plist 를 써서
8
+ * launchctl 로 로드한다. plist 는 coarse StartInterval(기본 300s)마다 `agentlas automation tick`
9
+ * (1회 due 스윕 후 종료)을 poke 한다. DB 가 스케줄 권위이고 plist 는 poke 만 하므로 자동화별
10
+ * plist 동기화가 필요 없다 — 데스크탑과 정확히 같은 계약.
11
+ *
12
+ * ★Label 은 데스크탑("ai.agentlas.automations")과 다르게 둔다("ai.agentlas.cli.automations").
13
+ * 둘 다 설치돼 있어도 공유 DB 의 lease(claimDue)가 이중 실행을 막으므로 공존은 안전하고,
14
+ * 서로의 plist 를 install/uninstall 로 덮지 않게 하려는 것.
15
+ *
16
+ * macOS 전용(launchd). 다른 OS 는 supported:false 로 정직하게 알린다(자동화는 포그라운드
17
+ * `automation daemon` 으로만 — 조용히 안 되는 척하지 않는다).
18
+ */
19
+ const { spawnSync } = require("node:child_process");
20
+ const fs = require("node:fs");
21
+ const os = require("node:os");
22
+ const path = require("node:path");
23
+ const { userDataDir } = require("../core/paths.cjs");
24
+
25
+ const LABEL = "ai.agentlas.cli.automations";
26
+
27
+ function isSupported() { return process.platform === "darwin"; }
28
+ function plistPath() { return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`); }
29
+ function domainTarget() { return `gui/${os.userInfo().uid}`; }
30
+
31
+ /** launchd 가 poke 할 CLI 진입점(절대경로). 전역 설치본이든 체크아웃이든 이 파일 기준으로 해석. */
32
+ function cliEntry() { return path.resolve(__dirname, "..", "..", "bin", "agentlas.cjs"); }
33
+
34
+ function logPath() {
35
+ const dir = path.join(userDataDir(), "logs");
36
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* best-effort */ }
37
+ return path.join(dir, "launchd-automations.log");
38
+ }
39
+
40
+ function plistXml(intervalSec = 300) {
41
+ const node = process.execPath;
42
+ const entry = cliEntry();
43
+ const log = logPath();
44
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45
+ return [
46
+ '<?xml version="1.0" encoding="UTF-8"?>',
47
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
48
+ '<plist version="1.0">',
49
+ "<dict>",
50
+ " <key>Label</key>",
51
+ ` <string>${LABEL}</string>`,
52
+ " <key>ProgramArguments</key>",
53
+ " <array>",
54
+ ` <string>${esc(node)}</string>`,
55
+ ` <string>${esc(entry)}</string>`,
56
+ " <string>automation</string>",
57
+ " <string>tick</string>",
58
+ " </array>",
59
+ " <key>StartInterval</key>",
60
+ ` <integer>${Math.max(30, Math.floor(intervalSec))}</integer>`,
61
+ " <key>RunAtLoad</key>",
62
+ " <true/>",
63
+ " <key>ProcessType</key>",
64
+ " <string>Background</string>",
65
+ " <key>LowPriorityIO</key>",
66
+ " <true/>",
67
+ " <key>StandardOutPath</key>",
68
+ ` <string>${esc(log)}</string>`,
69
+ " <key>StandardErrorPath</key>",
70
+ ` <string>${esc(log)}</string>`,
71
+ "</dict>",
72
+ "</plist>",
73
+ "",
74
+ ].join("\n");
75
+ }
76
+
77
+ /** launchctl 실행 — throw 하지 않고 {code, stderr} 반환(상태 함수가 판정). */
78
+ function launchctl(args) {
79
+ const res = spawnSync("launchctl", args, { encoding: "utf8" });
80
+ return { code: res.status ?? -1, stderr: (res.stderr || "").trim() };
81
+ }
82
+
83
+ function isLoaded() {
84
+ if (!isSupported()) return false;
85
+ return launchctl(["print", `${domainTarget()}/${LABEL}`]).code === 0;
86
+ }
87
+
88
+ function launchdStatus() {
89
+ const supported = isSupported();
90
+ return {
91
+ supported,
92
+ installed: supported && fs.existsSync(plistPath()),
93
+ loaded: supported && isLoaded(),
94
+ plistPath: plistPath(),
95
+ label: LABEL,
96
+ entry: cliEntry(),
97
+ };
98
+ }
99
+
100
+ /** plist 작성 + launchctl bootstrap 로드(멱등 — 이미 로드면 bootout 후 재로드). */
101
+ function enableLaunchd({ intervalSec = 300 } = {}) {
102
+ if (!isSupported()) return { ...launchdStatus(), error: "launchd persistence is macOS-only." };
103
+ const p = plistPath();
104
+ try {
105
+ fs.mkdirSync(path.dirname(p), { recursive: true });
106
+ fs.writeFileSync(p, plistXml(intervalSec), "utf8");
107
+ } catch (err) {
108
+ return { ...launchdStatus(), error: `failed to write plist: ${String(err)}` };
109
+ }
110
+ if (isLoaded()) launchctl(["bootout", `${domainTarget()}/${LABEL}`]);
111
+ const res = launchctl(["bootstrap", domainTarget(), p]);
112
+ if (res.code !== 0 && !isLoaded()) {
113
+ return { ...launchdStatus(), error: res.stderr || "launchctl bootstrap failed." };
114
+ }
115
+ return launchdStatus();
116
+ }
117
+
118
+ /** launchctl bootout + plist 삭제. */
119
+ function disableLaunchd() {
120
+ if (!isSupported()) return launchdStatus();
121
+ if (isLoaded()) launchctl(["bootout", `${domainTarget()}/${LABEL}`]);
122
+ const p = plistPath();
123
+ try { if (fs.existsSync(p)) fs.rmSync(p); }
124
+ catch (err) { return { ...launchdStatus(), error: `failed to remove plist: ${String(err)}` }; }
125
+ return launchdStatus();
126
+ }
127
+
128
+ module.exports = {
129
+ LABEL, plistPath, plistXml, cliEntry, isSupported,
130
+ launchdStatus, enableLaunchd, disableLaunchd,
131
+ };
@@ -412,50 +412,12 @@ CREATE TABLE automation_runs (
412
412
  , last_activity_at TEXT, occurrence_id TEXT, graph_digest TEXT, checkpoint_json TEXT, resume_of_run_id TEXT);
413
413
  CREATE INDEX idx_automation_runs_auto
414
414
  ON automation_runs(automation_id, started_at);
415
- CREATE TRIGGER agentlas_auto_cua_social_insert
416
- AFTER INSERT ON automations
417
- WHEN NEW.tool_mode = 'auto' AND (
418
- lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%reddit%'
419
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%instagram%'
420
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%threads%'
421
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%twitter%'
422
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%x.com%'
423
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%linkedin%'
424
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%facebook%'
425
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%tiktok%'
426
- OR (lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%browser%' AND lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%post%')
427
- OR (lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%web%' AND lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%login%')
428
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%레딧%'
429
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%인스타%'
430
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%댓글%'
431
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%게시%'
432
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%로그인%'
433
- )
434
- BEGIN
435
- UPDATE automations SET tool_mode = 'computer-use' WHERE id = NEW.id;
436
- END;
437
- CREATE TRIGGER agentlas_auto_cua_social_update
438
- AFTER UPDATE OF name, prompt_template, tool_mode ON automations
439
- WHEN NEW.tool_mode = 'auto' AND (
440
- lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%reddit%'
441
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%instagram%'
442
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%threads%'
443
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%twitter%'
444
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%x.com%'
445
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%linkedin%'
446
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%facebook%'
447
- OR lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%tiktok%'
448
- OR (lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%browser%' AND lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%post%')
449
- OR (lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%web%' AND lower(coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'')) LIKE '%login%')
450
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%레딧%'
451
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%인스타%'
452
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%댓글%'
453
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%게시%'
454
- OR coalesce(NEW.name,'') || ' ' || coalesce(NEW.prompt_template,'') LIKE '%로그인%'
455
- )
456
- BEGIN
457
- UPDATE automations SET tool_mode = 'computer-use' WHERE id = NEW.id;
458
- END;
415
+ -- (제거됨 2026-08-06) agentlas_auto_cua_social_insert/update 트리거가 여기 있었다.
416
+ -- 소셜 키워드 목록("twitter/인스타/댓글/게시/로그인"…)으로 tool_mode를 computer-use로
417
+ -- 강제 되돌리던 DB 차원 단어목록 판정 — 코드의 단어목록을 LLM 판정으로 대체할 때
418
+ -- 트리거만 살아남아, 코드 리뷰가 수 없는 곳에서 toolMode 도출 규칙을 무효화했다
419
+ -- (실측: UPDATE tool_mode='auto' 같은 연결에서 즉시 되돌아왔다). 판정을 DB 트리거로
420
+ -- 만들지 않는다 판정은 코드·게이트가 보는 곳에만 산다.
459
421
  CREATE TABLE agent_evolution_proposals (
460
422
  id TEXT PRIMARY KEY,
461
423
  agent_id TEXT NOT NULL,
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ /*
3
+ * browser/cdp — 터미널 명령적 브라우저 조종 (2026-08-06).
4
+ *
5
+ * 배경(오너): 데스크탑은 Electron BrowserWindow.executeJavaScript 로 페이지를
6
+ * 명령적으로 조종한다(navigate → innerText 읽기 → 타이핑 → 토큰 파싱). 터미널
7
+ * `agentlas browser` 는 리서치용(URL 읽기·검색)이라 그 명령적 조종이 없었다.
8
+ * 공유 CDP 엔진(browser-cdp-launcher.js)이 이미 Agentlas 전용 Chrome 을 원격
9
+ * 디버깅 포트(기본 9222)로 띄우므로, 그 포트에 raw CDP(DevTools Protocol)로 붙어
10
+ * navigate/evaluate/waitFor 를 제공한다. Node 22+ 의 global WebSocket·fetch 만
11
+ * 쓴다(의존성 0).
12
+ *
13
+ * evaluate(js) 는 데스크탑 executeJavaScript 와 동형(Runtime.evaluate,
14
+ * returnByValue + awaitPromise). BotFather 자동생성 같은 데스크탑 흐름이 이 위에
15
+ * 얹힌다.
16
+ *
17
+ * 안전: Agentlas 전용 프로필 Chrome 에만 붙는다(개인 크롬 아님). 되돌릴 수 없는
18
+ * 행동은 호출자가 판단한다 — 이 모듈은 조종 primitive 만 제공한다.
19
+ */
20
+ const http = require("node:http");
21
+
22
+ const DEFAULT_PORT = Number(process.env.AGENTLAS_CDP_PORT || 9222);
23
+
24
+ function httpJson(port, path, { timeout = 3000 } = {}) {
25
+ return new Promise((resolve, reject) => {
26
+ const req = http.get({ host: "127.0.0.1", port, path, timeout }, (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); }
31
+ });
32
+ });
33
+ req.on("error", reject);
34
+ req.on("timeout", () => { req.destroy(); reject(new Error(`CDP ${path} timed out`)); });
35
+ });
36
+ }
37
+
38
+ /** 9222 가 살아있나(Agentlas 전용 Chrome). */
39
+ async function cdpReady(port = DEFAULT_PORT) {
40
+ try { await httpJson(port, "/json/version"); return true; } catch { return false; }
41
+ }
42
+
43
+ /** 조종할 page 타겟 하나를 고른다(없으면 새로 연다). 반환: webSocketDebuggerUrl. */
44
+ async function pickPageTarget(port = DEFAULT_PORT) {
45
+ const targets = await httpJson(port, "/json");
46
+ let page = (Array.isArray(targets) ? targets : []).find((t) => t.type === "page" && t.webSocketDebuggerUrl);
47
+ if (!page) {
48
+ // 새 탭을 연다(DevTools HTTP: PUT /json/new).
49
+ page = await httpJson(port, "/json/new?about:blank");
50
+ }
51
+ if (!page || !page.webSocketDebuggerUrl) throw new Error("no CDP page target available");
52
+ return page.webSocketDebuggerUrl;
53
+ }
54
+
55
+ /**
56
+ * 페이지 하나에 CDP 로 붙는다. 반환: { navigate, evaluate, waitFor, close }.
57
+ * primitive:
58
+ * navigate(url) Page.navigate + load 대기(간이)
59
+ * evaluate(js, {awaitPromise}) Runtime.evaluate(returnByValue) — 값 반환
60
+ * waitFor(jsPredicate, {timeoutMs, pollMs}) predicate 가 truthy 될 때까지
61
+ */
62
+ async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
63
+ const url = wsUrl || (await pickPageTarget(port));
64
+ const ws = new WebSocket(url);
65
+ await new Promise((resolve, reject) => {
66
+ ws.addEventListener("open", resolve, { once: true });
67
+ ws.addEventListener("error", () => reject(new Error("CDP websocket failed to open")), { once: true });
68
+ });
69
+
70
+ let nextId = 0;
71
+ const pending = new Map();
72
+ ws.addEventListener("message", (event) => {
73
+ let msg;
74
+ try { msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)); } catch { return; }
75
+ if (msg.id != null && pending.has(msg.id)) {
76
+ const { resolve, reject } = pending.get(msg.id);
77
+ pending.delete(msg.id);
78
+ if (msg.error) reject(Object.assign(new Error(msg.error.message || "CDP error"), { cdp: msg.error }));
79
+ else resolve(msg.result);
80
+ }
81
+ });
82
+
83
+ function send(method, params = {}, { timeout = 30000 } = {}) {
84
+ const id = ++nextId;
85
+ return new Promise((resolve, reject) => {
86
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP ${method} timed out`)); }, timeout);
87
+ pending.set(id, {
88
+ resolve: (r) => { clearTimeout(timer); resolve(r); },
89
+ reject: (e) => { clearTimeout(timer); reject(e); },
90
+ });
91
+ ws.send(JSON.stringify({ id, method, params }));
92
+ });
93
+ }
94
+
95
+ await send("Page.enable").catch(() => {});
96
+ await send("Runtime.enable").catch(() => {});
97
+
98
+ async function evaluate(expression, { awaitPromise = true } = {}) {
99
+ const res = await send("Runtime.evaluate", {
100
+ expression: `(() => { ${expression} })()`,
101
+ returnByValue: true,
102
+ awaitPromise,
103
+ });
104
+ if (res && res.exceptionDetails) {
105
+ throw new Error("page evaluate threw: " + (res.exceptionDetails.text || JSON.stringify(res.exceptionDetails)));
106
+ }
107
+ return res && res.result ? res.result.value : undefined;
108
+ }
109
+
110
+ // 편의: 표현식(값) 하나를 평가.
111
+ async function evalExpr(expression, opts) {
112
+ return evaluate(`return (${expression});`, opts);
113
+ }
114
+
115
+ async function navigate(target, { waitMs = 1500 } = {}) {
116
+ await send("Page.navigate", { url: target });
117
+ // 간이 로드 대기 — 필요하면 waitFor 로 정밀 대기.
118
+ await new Promise((r) => setTimeout(r, waitMs));
119
+ }
120
+
121
+ async function waitFor(predicateExpr, { timeoutMs = 60000, pollMs = 800 } = {}) {
122
+ const deadline = Date.now() + timeoutMs;
123
+ while (Date.now() < deadline) {
124
+ let ok = false;
125
+ try { ok = await evalExpr(predicateExpr); } catch { ok = false; }
126
+ if (ok) return true;
127
+ await new Promise((r) => setTimeout(r, pollMs));
128
+ }
129
+ return false;
130
+ }
131
+
132
+ /*
133
+ * 진짜 조종(읽기만이 아니라 몰기). 데스크탑은 Electron sendInputEvent 로 키·클릭을
134
+ * 넣는다. 여기서는 CDP Input.* 로 같은 일을 한다:
135
+ * focusSelector(sel) 해당 요소에 포커스(evaluate)
136
+ * typeInto(sel, text) 요소에 포커스 후 Input.insertText 로 문자열 삽입
137
+ * pressKey("Enter") Input.dispatchKeyEvent (keyDown+keyUp)
138
+ * clickSelector(sel) 요소를 클릭(evaluate element.click — SPA에 안정적)
139
+ * BotFather /newbot 같은 데스크탑 흐름이 이 위에 얹힌다.
140
+ */
141
+ const KEYS = { Enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" } };
142
+
143
+ async function focusSelector(selector) {
144
+ const ok = await evaluate(`const el=document.querySelector(${JSON.stringify(selector)}); if(!el) return false; el.focus(); return true;`);
145
+ if (!ok) throw new Error(`no element matches ${selector}`);
146
+ return true;
147
+ }
148
+
149
+ async function typeInto(selector, text) {
150
+ await focusSelector(selector);
151
+ await send("Input.insertText", { text: String(text) });
152
+ return true;
153
+ }
154
+
155
+ async function pressKey(name) {
156
+ const k = KEYS[name];
157
+ if (!k) throw new Error(`unsupported key: ${name}`);
158
+ await send("Input.dispatchKeyEvent", { type: "keyDown", ...k });
159
+ await send("Input.dispatchKeyEvent", { type: "keyUp", ...k });
160
+ return true;
161
+ }
162
+
163
+ async function clickSelector(selector) {
164
+ const ok = await evaluate(`const el=document.querySelector(${JSON.stringify(selector)}); if(!el) return false; el.click(); return true;`);
165
+ if (!ok) throw new Error(`no element matches ${selector}`);
166
+ return true;
167
+ }
168
+
169
+ /*
170
+ * 현재 페이지를 PDF 로 인쇄한다(데스크탑 document/export-pdf.ts 의 offscreen
171
+ * BrowserWindow.printToPDF 와 같은 메커니즘 — 여기서는 CDP Page.printToPDF).
172
+ * base64 를 디코드해 outPath 에 쓴다. 반환: {path, bytes}.
173
+ */
174
+ async function printPdf(outPath, { landscape = false, printBackground = true, scale = 1 } = {}) {
175
+ const res = await send("Page.printToPDF", { landscape, printBackground, scale, transferMode: "ReturnAsBase64" }, { timeout: 60000 });
176
+ const b64 = res && res.data;
177
+ if (!b64) throw new Error("Page.printToPDF returned no data");
178
+ const buf = Buffer.from(b64, "base64");
179
+ require("node:fs").writeFileSync(outPath, buf);
180
+ return { path: outPath, bytes: buf.length };
181
+ }
182
+
183
+ function close() { try { ws.close(); } catch { /* already closed */ } }
184
+
185
+ return { navigate, evaluate, evalExpr, waitFor, focusSelector, typeInto, pressKey, clickSelector, printPdf, close, send };
186
+ }
187
+
188
+ module.exports = { cdpReady, pickPageTarget, attachPage, DEFAULT_PORT };
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ /*
3
+ * browser/vault — 터미널 독립 브라우저 볼트 (2026-08-06).
4
+ *
5
+ * 배경(오너: "조종을 다른 흐름으로 확장"): 데스크탑 electron/store/browser-vault.ts 는
6
+ * 사이트별 전용-프로필 로그인/세션 상태를 관리한다(사이트 카드 · 세션 valid/expired/none ·
7
+ * 권한 · 행동 로그). 저장 테이블(browser_sites/browser_sessions/…)은 이미 터미널 부트스트랩
8
+ * 스키마에 있어 **데스크탑과 그대로 공유**된다. 그 핵심 CRUD 만 이식한다.
9
+ *
10
+ * 보안(데스크탑과 동일): 사이트 비밀번호를 받거나 자동 입력하지 않는다. 로그인은 제공자
11
+ * 페이지에서 사용자가 직접 하고, 터미널은 페이지를 열어 주고(조종) 세션 상태만 기록한다.
12
+ * has_password 는 항상 0 — CLI 로는 어떤 자격증명도 볼트에 들어가지 않는다.
13
+ */
14
+ const crypto = require("node:crypto");
15
+ const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
16
+
17
+ function nowIso() { return new Date().toISOString(); }
18
+
19
+ /** 입력을 host 로 정규화한다(데스크탑 normalizeSite 와 같은 규칙 — userinfo 는 거부). */
20
+ function normalizeSite(input) {
21
+ const raw = String(input || "").trim();
22
+ if (!raw) return "";
23
+ let u;
24
+ try { u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return ""; }
25
+ if (u.username || u.password) return ""; // 자격증명은 사이트 키에 절대 들이지 않는다
26
+ const host = u.host.toLowerCase().replace(/^www\./, "");
27
+ return host && !/\s/.test(host) ? host : "";
28
+ }
29
+
30
+ function listBrowserSites(db) {
31
+ let rows;
32
+ try {
33
+ rows = db.prepare(
34
+ `SELECT s.id, s.site, s.label, s.username, s.created_at, s.updated_at,
35
+ se.status AS sess_status, se.captured_at AS sess_captured
36
+ FROM browser_sites s
37
+ LEFT JOIN browser_sessions se ON se.site = s.site
38
+ ORDER BY s.updated_at DESC`,
39
+ ).all();
40
+ } catch { return []; }
41
+ return rows.map((r) => ({
42
+ id: String(r.id),
43
+ site: String(r.site),
44
+ label: r.label ?? null,
45
+ username: r.username ?? null,
46
+ session: { status: r.sess_status ?? "none", capturedAt: r.sess_captured ?? null },
47
+ createdAt: String(r.created_at),
48
+ updatedAt: String(r.updated_at),
49
+ }));
50
+ }
51
+
52
+ function getBrowserSite(db, site) {
53
+ const norm = normalizeSite(site);
54
+ return listBrowserSites(db).find((s) => s.site === norm) || null;
55
+ }
56
+
57
+ /** 사이트 카드를 만들거나 갱신한다(비밀번호 없음 — has_password 항상 0). */
58
+ function upsertBrowserSite(db, input) {
59
+ const site = normalizeSite(input.site);
60
+ if (!site) throw new Error("site address is empty or malformed");
61
+ const now = nowIso();
62
+ runWriteTransaction(db, () => {
63
+ const existing = db.prepare("SELECT id FROM browser_sites WHERE site = ?").get(site);
64
+ if (existing) {
65
+ db.prepare(
66
+ "UPDATE browser_sites SET label = COALESCE(?, label), username = COALESCE(?, username), updated_at = ? WHERE site = ?",
67
+ ).run(input.label ?? null, input.username ?? null, now, site);
68
+ } else {
69
+ db.prepare(
70
+ "INSERT INTO browser_sites (id, site, label, username, has_password, created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
71
+ ).run(crypto.randomUUID(), site, input.label ?? null, input.username ?? null, 0, now, now);
72
+ db.prepare(
73
+ "INSERT OR IGNORE INTO browser_sessions (id, site, status, captured_at) VALUES (?, ?, 'none', NULL)",
74
+ ).run(crypto.randomUUID(), site);
75
+ }
76
+ });
77
+ return getBrowserSite(db, site);
78
+ }
79
+
80
+ function deleteBrowserSite(db, site) {
81
+ const norm = normalizeSite(site) || String(site || "").trim();
82
+ runWriteTransaction(db, () => {
83
+ db.prepare("DELETE FROM browser_sessions WHERE site = ?").run(norm);
84
+ db.prepare("DELETE FROM browser_permissions WHERE site = ?").run(norm);
85
+ db.prepare("DELETE FROM browser_sites WHERE site = ?").run(norm);
86
+ });
87
+ return { ok: true };
88
+ }
89
+
90
+ /** 세션 상태를 기록한다(valid 면 captured_at 을 지금으로, 아니면 비운다). 데스크탑과 동일. */
91
+ function setBrowserSession(db, site, status) {
92
+ const norm = normalizeSite(site);
93
+ if (!norm) throw new Error("site address is empty or malformed");
94
+ if (status !== "valid" && status !== "expired" && status !== "none") {
95
+ throw new Error(`status must be valid | expired | none, got ${status}`);
96
+ }
97
+ const captured = status === "valid" ? nowIso() : null;
98
+ runWriteTransaction(db, () => {
99
+ const existing = db.prepare("SELECT id FROM browser_sessions WHERE site = ?").get(norm);
100
+ if (existing) {
101
+ db.prepare("UPDATE browser_sessions SET status = ?, captured_at = ? WHERE site = ?").run(status, captured, norm);
102
+ } else {
103
+ db.prepare("INSERT INTO browser_sessions (id, site, status, captured_at) VALUES (?,?,?,?)")
104
+ .run(crypto.randomUUID(), norm, status, captured);
105
+ }
106
+ });
107
+ return getBrowserSite(db, norm);
108
+ }
109
+
110
+ /** 되돌릴 수 없는/외부로 나가는 행동을 날짜 로그에 남긴다(데스크탑과 같은 테이블). */
111
+ function logBrowserAction(db, { site = null, action, target = null, result = null, approval = null, meta = null } = {}) {
112
+ try {
113
+ runWriteTransaction(db, () => {
114
+ db.prepare(
115
+ "INSERT INTO browser_action_logs (id, ts, site, action, target, result, approval, meta) VALUES (?,?,?,?,?,?,?,?)",
116
+ ).run(crypto.randomUUID(), nowIso(), site, action, target, result, approval, meta ? JSON.stringify(meta) : null);
117
+ });
118
+ } catch { /* 로그 실패는 치명적이지 않다 */ }
119
+ }
120
+
121
+ module.exports = {
122
+ normalizeSite, listBrowserSites, getBrowserSite,
123
+ upsertBrowserSite, deleteBrowserSite, setBrowserSession, logBrowserAction,
124
+ };