agentlas 0.9.10 → 1.0.2

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 (145) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +319 -341
  3. package/bin/agentlas.cjs +32 -9
  4. package/engine/agentlas-banner.cjs +24 -3
  5. package/engine/agentlas-composer.cjs +239 -31
  6. package/engine/agentlas-config.cjs +103 -7
  7. package/engine/agentlas-core-harness.cjs +48 -4
  8. package/engine/agentlas-evolution.cjs +3 -4
  9. package/engine/agentlas-i18n.cjs +88 -32
  10. package/engine/agentlas-input.cjs +234 -18
  11. package/engine/agentlas-memory-governance.cjs +3 -5
  12. package/engine/agentlas-memory-import.cjs +3 -3
  13. package/engine/agentlas-native-host.cjs +200 -9
  14. package/engine/agentlas-onboard.cjs +112 -20
  15. package/engine/agentlas-permissions.cjs +14 -7
  16. package/engine/agentlas-sqlite-policy.cjs +34 -0
  17. package/engine/agentlas-ui.cjs +93 -42
  18. package/engine/agentlas-workforce.cjs +472 -58
  19. package/engine/agentlas-workload-routing.cjs +8 -3
  20. package/engine/agentlas.cjs +140 -12367
  21. package/engine/agents/files.cjs +61 -0
  22. package/engine/agents/import-local.cjs +237 -0
  23. package/engine/agents/registry.cjs +158 -0
  24. package/engine/agents/router.cjs +565 -0
  25. package/engine/agents/routes.cjs +43 -0
  26. package/engine/architecture.data.json +2 -2
  27. package/engine/automation/daemon.cjs +335 -0
  28. package/engine/automation/schedule.cjs +181 -0
  29. package/engine/automation/store.cjs +209 -0
  30. package/engine/cloud/auth.cjs +279 -0
  31. package/engine/cloud/hub-client.cjs +239 -0
  32. package/engine/cloud-assets/cargo.cjs +49 -0
  33. package/engine/cloud-assets/cas.cjs +235 -0
  34. package/engine/cloud-assets/commands.cjs +273 -0
  35. package/engine/cloud-assets/package.cjs +936 -0
  36. package/engine/cloud-assets/restore.cjs +172 -0
  37. package/engine/cloud-assets/state.cjs +268 -0
  38. package/engine/commands/automation.cjs +195 -0
  39. package/engine/commands/billing.cjs +96 -0
  40. package/engine/commands/browser.cjs +20 -0
  41. package/engine/commands/build.cjs +33 -0
  42. package/engine/commands/call.cjs +24 -0
  43. package/engine/commands/career-graph.cjs +51 -0
  44. package/engine/commands/cd.cjs +22 -0
  45. package/engine/commands/chat.cjs +12 -0
  46. package/engine/commands/chats.cjs +28 -0
  47. package/engine/commands/cloud.cjs +17 -0
  48. package/engine/commands/connect.cjs +20 -0
  49. package/engine/commands/context.cjs +66 -0
  50. package/engine/commands/creds.cjs +203 -0
  51. package/engine/commands/doctor.cjs +68 -0
  52. package/engine/commands/env.cjs +33 -0
  53. package/engine/commands/evolve.cjs +23 -0
  54. package/engine/commands/experience.cjs +34 -0
  55. package/engine/commands/film.cjs +8 -0
  56. package/engine/commands/firm.cjs +115 -0
  57. package/engine/commands/help.cjs +65 -0
  58. package/engine/commands/hep.cjs +23 -0
  59. package/engine/commands/import.cjs +35 -0
  60. package/engine/commands/index.cjs +136 -0
  61. package/engine/commands/install.cjs +27 -0
  62. package/engine/commands/journal.cjs +31 -0
  63. package/engine/commands/legacy-network.cjs +29 -0
  64. package/engine/commands/list.cjs +49 -0
  65. package/engine/commands/login.cjs +68 -0
  66. package/engine/commands/logout.cjs +28 -0
  67. package/engine/commands/mcp.cjs +91 -0
  68. package/engine/commands/memory.cjs +21 -0
  69. package/engine/commands/multimodal.cjs +91 -0
  70. package/engine/commands/native.cjs +34 -0
  71. package/engine/commands/netadmin.cjs +31 -0
  72. package/engine/commands/oberon.cjs +70 -0
  73. package/engine/commands/ontology.cjs +24 -0
  74. package/engine/commands/open.cjs +48 -0
  75. package/engine/commands/plugin.cjs +101 -0
  76. package/engine/commands/project.cjs +47 -0
  77. package/engine/commands/research.cjs +36 -0
  78. package/engine/commands/route.cjs +37 -0
  79. package/engine/commands/run.cjs +149 -0
  80. package/engine/commands/search.cjs +55 -0
  81. package/engine/commands/setup.cjs +45 -0
  82. package/engine/commands/storm.cjs +75 -0
  83. package/engine/commands/swarm.cjs +75 -0
  84. package/engine/commands/telegram.cjs +32 -0
  85. package/engine/commands/uninstall.cjs +68 -0
  86. package/engine/commands/update.cjs +54 -0
  87. package/engine/commands/upload.cjs +18 -0
  88. package/engine/commands/usage.cjs +35 -0
  89. package/engine/commands/variant.cjs +25 -0
  90. package/engine/commands/version.cjs +9 -0
  91. package/engine/commands/whoami.cjs +38 -0
  92. package/engine/commands/workforce.cjs +103 -0
  93. package/engine/core/db.cjs +160 -0
  94. package/engine/core/paths.cjs +35 -0
  95. package/engine/experience/build.cjs +181 -0
  96. package/engine/experience/intents.cjs +492 -0
  97. package/engine/experience/runtime.cjs +242 -0
  98. package/engine/experience/variant.cjs +196 -0
  99. package/engine/firms/orchestrate.cjs +333 -0
  100. package/engine/hephaestus/runtime.cjs +697 -0
  101. package/engine/hub/install.cjs +872 -0
  102. package/engine/hub/plugins.cjs +213 -0
  103. package/engine/mcp/consent.cjs +289 -0
  104. package/engine/mcp/contract.cjs +202 -0
  105. package/engine/mcp/index.cjs +43 -0
  106. package/engine/mcp/inventory.cjs +322 -0
  107. package/engine/mcp/plan.cjs +286 -0
  108. package/engine/mcp/probe.cjs +151 -0
  109. package/engine/memory-cli/curate.cjs +163 -0
  110. package/engine/oberon/common.cjs +69 -0
  111. package/engine/oberon/manifest.cjs +164 -0
  112. package/engine/oberon/outputs.cjs +70 -0
  113. package/engine/oberon/render.cjs +164 -0
  114. package/engine/project/career-graph.cjs +249 -0
  115. package/engine/project/credentials.cjs +262 -0
  116. package/engine/project/env-file.cjs +46 -0
  117. package/engine/project/index.cjs +27 -0
  118. package/engine/project/memory-context.cjs +453 -0
  119. package/engine/project/ontology.cjs +467 -0
  120. package/engine/project/paths.cjs +39 -0
  121. package/engine/project/seed.cjs +200 -0
  122. package/engine/project/state.cjs +403 -0
  123. package/engine/project/super-ontology-seed.json +3288 -0
  124. package/engine/runtimes/detect.cjs +54 -0
  125. package/engine/runtimes/overrides.cjs +139 -0
  126. package/engine/runtimes/resolve.cjs +64 -0
  127. package/engine/sessions/apply-fences.cjs +188 -0
  128. package/engine/sessions/fences.cjs +362 -0
  129. package/engine/sessions/orchestrator.cjs +170 -0
  130. package/engine/sessions/prompt.cjs +212 -0
  131. package/engine/sessions/session.cjs +245 -0
  132. package/engine/sessions/sink.cjs +54 -0
  133. package/engine/sessions/store.cjs +79 -0
  134. package/engine/storm/deps.cjs +88 -0
  135. package/engine/storm/storm.cjs +218 -0
  136. package/engine/storm/swarm.cjs +422 -0
  137. package/engine/ui/palette.cjs +105 -0
  138. package/engine/ui/renderer.cjs +85 -0
  139. package/engine/ui/repl.cjs +444 -0
  140. package/engine/workforce/capture.cjs +701 -0
  141. package/engine/workforce/deps.cjs +472 -0
  142. package/package.json +2 -6
  143. package/engine/agentlas-experience-mcp.cjs +0 -1709
  144. package/engine/agentlas-parity.cjs +0 -1499
  145. package/engine/agentlas-repl.cjs +0 -1780
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ /*
3
+ * automation/store — automations 테이블 DB 표면 (목록/추가/토글/삭제 + 리스 + 실행 기록).
4
+ *
5
+ * 스키마 소유권: 정본은 데스크탑 앱. 이 모듈은 base 열(id, name, schedule,
6
+ * target_type, target_id, prompt_template, enabled, created_at, next_run_at,
7
+ * last_run_at)만 전제하고, 그 뒤에 추가된 열(timezone, trigger_type, tool_mode,
8
+ * hub_mode, run_count, claimed_at, lease_owner, …)은 columnExists 로 방어한다.
9
+ *
10
+ * 리스 계약(절대 약화 금지): claimed_at TTL 15분 — 앱 store/automations.ts 의
11
+ * LEASE_TTL_MS 와 동일. Desktop 스케줄러와 터미널 데몬이 같은 SQLite 를 보므로,
12
+ * 리스를 잡지 못한 쪽은 절대 실행하지 않는다(중복 실행 = codex 자동화 전멸급 사고).
13
+ * 리스 열이 없는 구형 DB에서는 배타성을 증명할 수 없으므로 fail-closed(실행 거부).
14
+ */
15
+ const crypto = require("node:crypto");
16
+ const os = require("node:os");
17
+ const { columnExists, tableExists } = require("../core/db.cjs");
18
+ const { nextAutomationRun } = require("./schedule.cjs");
19
+
20
+ const LEASE_TTL_MS = 15 * 60 * 1000; // 앱 store/automations.ts LEASE_TTL_MS와 동일
21
+ const LEASE_OWNER = `cli:${os.hostname()}:${process.pid}`;
22
+
23
+ function leaseSupported(db) {
24
+ return columnExists(db, "automations", "claimed_at") && columnExists(db, "automations", "lease_owner");
25
+ }
26
+
27
+ function listAutomations(db) {
28
+ if (!tableExists(db, "automations")) return [];
29
+ return db.prepare("SELECT * FROM automations ORDER BY created_at DESC").all();
30
+ }
31
+
32
+ function getAutomationByPrefix(db, idPrefix) {
33
+ if (!tableExists(db, "automations")) return null;
34
+ return db.prepare("SELECT * FROM automations WHERE id LIKE ?").get(String(idPrefix) + "%") || null;
35
+ }
36
+
37
+ /**
38
+ * 자동화 추가. spec: { name, targetType, targetId, cron, prompt, tz?, disabled? }
39
+ * next_run_at 은 호출자가 검증한 nextCronRun 결과(Date)를 받는다.
40
+ * @returns {string} 새 automation id
41
+ */
42
+ function addAutomation(db, spec, next) {
43
+ const id = crypto.randomUUID();
44
+ const now = new Date().toISOString();
45
+ const cols = ["id", "name", "schedule", "target_type", "target_id", "prompt_template", "enabled", "created_at", "next_run_at"];
46
+ const vals = [
47
+ id,
48
+ spec.name,
49
+ spec.cron,
50
+ spec.targetType,
51
+ spec.targetId,
52
+ spec.prompt,
53
+ spec.disabled ? 0 : 1,
54
+ now,
55
+ next ? next.toISOString() : null,
56
+ ];
57
+ // 데스크탑이 나중에 추가한 열 — 있으면 v1과 동일한 값으로 채운다.
58
+ const optional = {
59
+ created_by: "cli",
60
+ timezone: spec.tz || null,
61
+ trigger_type: "schedule",
62
+ tool_mode: "auto",
63
+ hub_mode: "hub-allowed",
64
+ run_count: 0,
65
+ };
66
+ for (const [col, v] of Object.entries(optional)) {
67
+ if (columnExists(db, "automations", col)) {
68
+ cols.push(col);
69
+ vals.push(v);
70
+ }
71
+ }
72
+ db.prepare(`INSERT INTO automations (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`).run(...vals);
73
+ return id;
74
+ }
75
+
76
+ /** on: next_run_at 재계산 후 활성화. off: 비활성화(스케줄 보존). */
77
+ function setEnabled(db, row, enabled) {
78
+ if (enabled) {
79
+ 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;
82
+ }
83
+ db.prepare("UPDATE automations SET enabled=0 WHERE id=?").run(row.id);
84
+ return null;
85
+ }
86
+
87
+ function removeAutomation(db, id) {
88
+ db.prepare("DELETE FROM automations WHERE id=?").run(id);
89
+ }
90
+
91
+ /**
92
+ * 리스 획득 — 앱 스케줄러와 같은 SQLite 리스(claimed_at TTL 15분)로 중복 실행 방지.
93
+ * 원자적 조건부 UPDATE 하나로 판정한다(SELECT 후 UPDATE 금지 — 레이스).
94
+ * @returns {boolean} true = 이 프로세스가 배타 실행권을 가짐
95
+ */
96
+ function claimAutomation(db, id, now = new Date(), owner = LEASE_OWNER) {
97
+ // 리스 열이 없으면 배타성을 증명할 수 없다 — 중복 실행 위험을 감수하지 않고 거부.
98
+ if (!leaseSupported(db)) return false;
99
+ const cutoff = new Date(now.getTime() - LEASE_TTL_MS).toISOString();
100
+ const result = db
101
+ .prepare(
102
+ "UPDATE automations SET claimed_at = ?, lease_owner = ? WHERE id = ? AND enabled = 1 AND (claimed_at IS NULL OR claimed_at < ?)",
103
+ )
104
+ .run(now.toISOString(), owner, id, cutoff);
105
+ return (result.changes ?? result.rowsAffected ?? 0) > 0;
106
+ }
107
+
108
+ function releaseAutomation(db, id) {
109
+ try {
110
+ db.prepare("UPDATE automations SET claimed_at = NULL, lease_owner = NULL WHERE id = ?").run(id);
111
+ } catch { /* best-effort */ }
112
+ }
113
+
114
+ /**
115
+ * 실행 기록 — run_history(v1/Desktop 스케줄러 패리티) + automation_runs(있으면) 양쪽.
116
+ * best-effort: 기록 실패가 자동화 실행 자체를 죽이면 안 된다.
117
+ */
118
+ function recordRun(db, automationId, status, error, scheduledFor) {
119
+ const nowIso = new Date().toISOString();
120
+ try {
121
+ if (tableExists(db, "run_history")) {
122
+ db.prepare(
123
+ "INSERT INTO run_history (id, automation_id, scheduled_for, ran_at, status, skipped_count, error) VALUES (?,?,?,?,?,0,?)",
124
+ ).run(crypto.randomUUID(), automationId, scheduledFor || null, nowIso, status, error || null);
125
+ }
126
+ } catch { /* best-effort */ }
127
+ try {
128
+ if (tableExists(db, "automation_runs")) {
129
+ db.prepare(
130
+ "INSERT INTO automation_runs (id, automation_id, started_at, status, node_states_json) VALUES (?,?,?,?,NULL)",
131
+ ).run(crypto.randomUUID(), automationId, nowIso, status);
132
+ }
133
+ } catch { /* best-effort */ }
134
+ }
135
+
136
+ function listRuns(db, limit = 15) {
137
+ if (!tableExists(db, "run_history")) return [];
138
+ return db.prepare(
139
+ `SELECT h.ran_at, h.status, h.error, a.name FROM run_history h
140
+ LEFT JOIN automations a ON a.id = h.automation_id
141
+ ORDER BY h.ran_at DESC LIMIT ?`,
142
+ ).all(Math.max(1, limit));
143
+ }
144
+
145
+ /**
146
+ * 실행 후 스케줄 북키핑 — v1 runAutomationOnce 의 성공/실패 분기와 동일:
147
+ * - 성공: run_count 증가. 실패: last_run_at 만 갱신(성공 카운트 오염 금지).
148
+ * - advanceSchedule(데몬 경로)일 때만 next_run_at 전진; 다음 시각이 없으면 비활성화.
149
+ * - run-now(advanceSchedule=false)는 스케줄을 건드리지 않는다(앱 advanceSchedule=false와 동일).
150
+ */
151
+ function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date() } = {}) {
152
+ const hasRunCount = columnExists(db, "automations", "run_count");
153
+ const shouldAdvance = !!advanceSchedule && (row.trigger_type || "schedule") === "schedule";
154
+ const advance = shouldAdvance ? nextAutomationRun(row, ranAt) : null;
155
+ if (ok) {
156
+ if (shouldAdvance) {
157
+ if (hasRunCount) {
158
+ db.prepare(
159
+ "UPDATE automations SET last_run_at = ?, run_count = run_count + 1, next_run_at = ?, enabled = ? WHERE id = ?",
160
+ ).run(ranAt.toISOString(), advance ? advance.toISOString() : null, advance ? row.enabled : 0, row.id);
161
+ } else {
162
+ db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?")
163
+ .run(ranAt.toISOString(), advance ? advance.toISOString() : null, advance ? row.enabled : 0, row.id);
164
+ }
165
+ } else if (hasRunCount) {
166
+ db.prepare("UPDATE automations SET last_run_at = ?, run_count = run_count + 1 WHERE id = ?")
167
+ .run(ranAt.toISOString(), row.id);
168
+ } else {
169
+ db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(ranAt.toISOString(), row.id);
170
+ }
171
+ // max_runs 도달 시 비활성화 (앱과 동일한 종료 조건).
172
+ if (row.max_runs && (row.run_count || 0) + 1 >= row.max_runs) {
173
+ db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(row.id);
174
+ return { advance, maxRunsReached: true };
175
+ }
176
+ } else if (shouldAdvance) {
177
+ db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?")
178
+ .run(ranAt.toISOString(), advance ? advance.toISOString() : null, advance ? row.enabled : 0, row.id);
179
+ } else {
180
+ db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(ranAt.toISOString(), row.id);
181
+ }
182
+ return { advance, maxRunsReached: false };
183
+ }
184
+
185
+ /** 데몬 폴링: 활성 + 스케줄 트리거 + next_run_at 도래분. trigger_type 열은 방어적. */
186
+ function dueAutomations(db, nowIso = new Date().toISOString(), limit = 5) {
187
+ if (!tableExists(db, "automations")) return [];
188
+ const triggerFilter = columnExists(db, "automations", "trigger_type") ? "AND trigger_type = 'schedule'" : "";
189
+ return db.prepare(
190
+ `SELECT * FROM automations WHERE enabled = 1 ${triggerFilter} AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT ?`,
191
+ ).all(nowIso, limit);
192
+ }
193
+
194
+ module.exports = {
195
+ LEASE_TTL_MS,
196
+ LEASE_OWNER,
197
+ leaseSupported,
198
+ listAutomations,
199
+ getAutomationByPrefix,
200
+ addAutomation,
201
+ setEnabled,
202
+ removeAutomation,
203
+ claimAutomation,
204
+ releaseAutomation,
205
+ recordRun,
206
+ listRuns,
207
+ advanceAfterRun,
208
+ dueAutomations,
209
+ };
@@ -0,0 +1,279 @@
1
+ "use strict";
2
+ /*
3
+ * cloud/auth — Agentlas Cloud 로그인 세션. 단일 관심사:
4
+ * loopback OAuth 로그인 플로우 + CLI 세션 영속화 + whoami 조회.
5
+ *
6
+ * 보안 계약 (test/login-loopback-security.cjs 가 전부 검증한다 — 바꾸면 테스트부터):
7
+ * - state 는 32바이트 crypto 난수(base64url, 43자). 비교는 timingSafeEqual.
8
+ * - `/callback` GET 1회로 transaction 소비 — 성공/실패 무관. 잘못된 state 뒤에
9
+ * 공격자 세션을 재주입하거나 성공 URL 재생으로 세션을 덮어쓸 수 없다.
10
+ * - 서버는 127.0.0.1 임시 포트만 listen, GET 외 405, 무관 경로 404(미소비),
11
+ * 모든 응답에 no-store + 'none' CSP + nosniff.
12
+ * - OAuth error 코드는 화이트리스트 정규식 통과분만 에러 메시지에 반영(반사 방지).
13
+ *
14
+ * 세션 파일은 평문·0600 (`<userData>/auth/cli-session.v1.json`) — 데스크탑의
15
+ * safeStorage 파일과 별개다. 쿠키 해석 순서: AGENTLAS_SESSION env → 세션 파일.
16
+ */
17
+ const fs = require("node:fs");
18
+ const path = require("node:path");
19
+ const crypto = require("node:crypto");
20
+ const { spawn } = require("node:child_process");
21
+ const { userDataDir } = require("../core/paths.cjs");
22
+
23
+ const LOGIN_CALLBACK_PATH = "/callback";
24
+ const LOGIN_TIMEOUT_MS = 180_000;
25
+ const MAX_LOGIN_SESSION_BYTES = 16 * 1024;
26
+
27
+ function webBaseUrl() {
28
+ return (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
29
+ }
30
+
31
+ function createLoginState(randomBytes = crypto.randomBytes) {
32
+ const bytes = Buffer.from(randomBytes(32));
33
+ if (bytes.length !== 32) throw new Error("Could not create the login state.");
34
+ return bytes.toString("base64url");
35
+ }
36
+
37
+ function loginStatesMatch(actual, expected) {
38
+ const left = Buffer.from(String(actual || ""), "utf8");
39
+ const right = Buffer.from(String(expected || ""), "utf8");
40
+ return left.length === right.length && left.length > 0 && crypto.timingSafeEqual(left, right);
41
+ }
42
+
43
+ /**
44
+ * OAuth loopback callback의 1회용 state guard. `/callback` GET이 도착하면 성공/실패와
45
+ * 무관하게 transaction을 소비한다. 따라서 잘못된 state 뒤에 공격자 세션을 재주입하거나,
46
+ * 성공 URL을 재생해 다른 세션으로 덮어쓸 수 없다.
47
+ */
48
+ function createLoginCallbackGuard(expectedState) {
49
+ let consumed = false;
50
+ return {
51
+ consume(rawUrl, method = "GET") {
52
+ let url;
53
+ try {
54
+ url = new URL(String(rawUrl || "/"), "http://127.0.0.1");
55
+ } catch {
56
+ return { handled: true, final: false, ok: false, statusCode: 400, message: "Invalid login callback." };
57
+ }
58
+ if (url.pathname !== LOGIN_CALLBACK_PATH) {
59
+ return { handled: false, final: false, ok: false, statusCode: 404, message: "not found" };
60
+ }
61
+ if (method !== "GET") {
62
+ return { handled: true, final: false, ok: false, statusCode: 405, message: "method not allowed" };
63
+ }
64
+ if (consumed) {
65
+ return { handled: true, final: false, ok: false, statusCode: 410, message: "Login callback has already been used." };
66
+ }
67
+ consumed = true;
68
+
69
+ if (!loginStatesMatch(url.searchParams.get("state"), expectedState)) {
70
+ return {
71
+ handled: true,
72
+ final: true,
73
+ ok: false,
74
+ statusCode: 400,
75
+ message: "Login callback state validation failed. Run agentlas login again.",
76
+ };
77
+ }
78
+ const oauthError = url.searchParams.get("error");
79
+ if (oauthError) {
80
+ // 에러 코드는 화이트리스트 통과분만 반영 — error_description 등 자유 텍스트는 절대 반사하지 않는다.
81
+ const safeCode = /^[A-Za-z0-9_.-]{1,80}$/.test(oauthError) ? oauthError : "oauth_error";
82
+ return {
83
+ handled: true,
84
+ final: true,
85
+ ok: false,
86
+ statusCode: 400,
87
+ message: `Agentlas login denied: ${safeCode}`,
88
+ };
89
+ }
90
+ const value = url.searchParams.get("session") || url.searchParams.get("token") || "";
91
+ if (!value) {
92
+ return {
93
+ handled: true,
94
+ final: true,
95
+ ok: false,
96
+ statusCode: 400,
97
+ message: "The callback did not include a session value.",
98
+ };
99
+ }
100
+ if (Buffer.byteLength(value, "utf8") > MAX_LOGIN_SESSION_BYTES) {
101
+ return {
102
+ handled: true,
103
+ final: true,
104
+ ok: false,
105
+ statusCode: 400,
106
+ message: "The login session value is too large.",
107
+ };
108
+ }
109
+ return { handled: true, final: true, ok: true, statusCode: 200, value, message: "Agentlas login complete" };
110
+ },
111
+ isConsumed() { return consumed; },
112
+ };
113
+ }
114
+
115
+ // URL 출력은 이미 호출자가 했다는 전제 — 브라우저를 못 열어도 조용히 넘어간다(수동으로 열면 된다).
116
+ function openInBrowser(url) {
117
+ const argv =
118
+ process.platform === "darwin" ? ["open", url]
119
+ : process.platform === "win32" ? ["cmd", "/c", "start", "", url]
120
+ : ["xdg-open", url];
121
+ try {
122
+ const child = spawn(argv[0], argv.slice(1), { stdio: "ignore", detached: true });
123
+ child.unref();
124
+ } catch { /* ignore */ }
125
+ }
126
+
127
+ function loginCallbackHtml(ok) {
128
+ const title = ok ? "Agentlas login complete" : "Agentlas login failed";
129
+ const body = ok
130
+ ? "Return to the terminal. You can close this window."
131
+ : "Return to the terminal and run agentlas login again.";
132
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Agentlas</title></head><body style="font-family:-apple-system,system-ui,sans-serif;padding:40px"><h3>${title}</h3><p>${body}</p></body></html>`;
133
+ }
134
+
135
+ /**
136
+ * 127.0.0.1 임시 포트에 1회용 loopback 서버를 열고, 웹 `/account?desktop=1&callback=…`
137
+ * 로그인 흐름이 state 를 보존한 채 세션을 되돌려줄 때까지 기다린다.
138
+ * options: { baseUrl?, timeoutMs?, onLoginUrl?, http?, randomBytes? }
139
+ */
140
+ function waitForLoopbackSession(options = {}) {
141
+ const http = options.http || require("node:http");
142
+ const timeoutCandidate = Number(options.timeoutMs);
143
+ const timeoutMs = Number.isFinite(timeoutCandidate) && timeoutCandidate > 0 ? timeoutCandidate : LOGIN_TIMEOUT_MS;
144
+ const state = createLoginState(options.randomBytes || crypto.randomBytes);
145
+ const guard = createLoginCallbackGuard(state);
146
+ const onLoginUrl = options.onLoginUrl || ((url) => {
147
+ process.stdout.write("Sign in to Agentlas in the browser (opening automatically):\n " + url + "\n");
148
+ openInBrowser(url);
149
+ });
150
+
151
+ return new Promise((resolve, reject) => {
152
+ let settled = false;
153
+ let timer = null;
154
+ let server;
155
+ const finish = (error, value) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ if (timer) clearTimeout(timer);
159
+ try { if (server) server.close(); } catch { /* ignore */ }
160
+ if (error) reject(error);
161
+ else resolve(value);
162
+ };
163
+
164
+ server = http.createServer((req, res) => {
165
+ const result = guard.consume(req.url, req.method || "GET");
166
+ const headers = {
167
+ "content-type": result.handled ? "text/html; charset=utf-8" : "text/plain; charset=utf-8",
168
+ "cache-control": "no-store",
169
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
170
+ "x-content-type-options": "nosniff",
171
+ connection: "close",
172
+ };
173
+ if (result.statusCode === 405) headers.allow = "GET";
174
+ res.writeHead(result.statusCode, headers);
175
+ res.end(result.handled ? loginCallbackHtml(result.ok) : result.message);
176
+ if (!result.final) return;
177
+ if (result.ok) finish(null, result.value);
178
+ else finish(new Error(result.message));
179
+ });
180
+ server.on("error", (error) => finish(error));
181
+ server.listen(0, "127.0.0.1", () => {
182
+ const address = server.address();
183
+ const port = address && typeof address === "object" ? address.port : 0;
184
+ if (!port) {
185
+ finish(new Error("Could not open the login loopback port."));
186
+ return;
187
+ }
188
+ const callback = new URL(`http://127.0.0.1:${port}${LOGIN_CALLBACK_PATH}`);
189
+ callback.searchParams.set("state", state);
190
+ let loginUrl;
191
+ try {
192
+ loginUrl = new URL("/account", `${options.baseUrl || webBaseUrl()}/`);
193
+ } catch {
194
+ finish(new Error("The Agentlas login URL is invalid."));
195
+ return;
196
+ }
197
+ loginUrl.searchParams.set("desktop", "1");
198
+ loginUrl.searchParams.set("callback", callback.toString());
199
+ timer = setTimeout(
200
+ () => finish(new Error(`Login timed out after ${Math.ceil(timeoutMs / 1000)} seconds. Try: agentlas login`)),
201
+ timeoutMs,
202
+ );
203
+ if (timer.unref) timer.unref();
204
+ try {
205
+ const notified = onLoginUrl(loginUrl.toString());
206
+ if (notified && typeof notified.then === "function") {
207
+ void notified.catch((error) => finish(error));
208
+ }
209
+ } catch (error) {
210
+ finish(error);
211
+ }
212
+ });
213
+ });
214
+ }
215
+
216
+ // ── CLI 세션 영속화 (v1 포맷 그대로: { version: 1, value, updatedAt }) ──
217
+ function cliSessionPath() {
218
+ return path.join(userDataDir(), "auth", "cli-session.v1.json");
219
+ }
220
+
221
+ function readCliSessionValue() {
222
+ try {
223
+ const j = JSON.parse(fs.readFileSync(cliSessionPath(), "utf8"));
224
+ return (j && typeof j.value === "string" && j.value) || null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ function saveCliSession(value) {
231
+ const p = cliSessionPath();
232
+ // 디렉터리 0700 + 파일 0600 — 기본 umask(0644)로 세션이 world-readable 이 되는 것을 막는다.
233
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
234
+ fs.writeFileSync(p, JSON.stringify({ version: 1, value, updatedAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
235
+ try { fs.chmodSync(p, 0o600); } catch { /* win32 */ }
236
+ return p;
237
+ }
238
+
239
+ function deleteCliSession() {
240
+ const p = cliSessionPath();
241
+ if (!fs.existsSync(p)) return { existed: false, path: p };
242
+ fs.rmSync(p);
243
+ return { existed: true, path: p };
244
+ }
245
+
246
+ // 쿠키 해석 순서 계약: AGENTLAS_SESSION env → 세션 파일. (v1의 keytar 폴백은
247
+ // 데스크탑이 세션을 keytar에 두지 않아 항상 비어 있었다 — v2에서는 싣지 않는다.)
248
+ function cloudSessionCookie() {
249
+ if (process.env.AGENTLAS_SESSION) return `agentlas_session=${process.env.AGENTLAS_SESSION}`;
250
+ const fileValue = readCliSessionValue();
251
+ if (fileValue) return `agentlas_session=${fileValue}`;
252
+ return null;
253
+ }
254
+
255
+ async function fetchSessionMeta(cookie) {
256
+ const resp = await fetch(`${webBaseUrl()}/api/auth/session`, { headers: { cookie }, signal: AbortSignal.timeout(8000) });
257
+ if (!resp.ok) throw new Error(`Session check returned ${resp.status}`);
258
+ return resp.json();
259
+ }
260
+
261
+ // 레거시 테스트 호환 표면 (v1 agentlas-parity.cjs 의 create(deps) 모양).
262
+ // v2 명령은 위의 모듈 함수들을 직접 쓰고, 이 create 는 아무 deps 도 요구하지 않는다.
263
+ function create() {
264
+ return { waitForLoopbackSession };
265
+ }
266
+
267
+ module.exports = {
268
+ create,
269
+ webBaseUrl,
270
+ openInBrowser,
271
+ waitForLoopbackSession,
272
+ cliSessionPath,
273
+ readCliSessionValue,
274
+ saveCliSession,
275
+ deleteCliSession,
276
+ cloudSessionCookie,
277
+ fetchSessionMeta,
278
+ _test: { createLoginState, createLoginCallbackGuard },
279
+ };