agentlas 0.4.0 → 0.5.5

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 (41) hide show
  1. package/README.md +112 -23
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +66 -51
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +109 -44
  8. package/engine/agentlas-doctor.cjs +65 -14
  9. package/engine/agentlas-i18n.cjs +132 -12
  10. package/engine/agentlas-input.cjs +123 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +373 -53
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +149 -47
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +349 -24
  18. package/engine/agentlas.cjs +3074 -379
  19. package/engine/architecture.data.json +5 -1
  20. package/engine/semver.cjs +64 -0
  21. package/package.json +1 -1
  22. package/test/bootstrap-race.cjs +47 -0
  23. package/test/capture-runtime-guard.cjs +122 -0
  24. package/test/cloud-asset-restore.cjs +423 -0
  25. package/test/cloud-cas-client.cjs +333 -0
  26. package/test/cloud-owner-restore.cjs +183 -0
  27. package/test/cloud-runtime-paths.cjs +40 -0
  28. package/test/cloud-save-publish.cjs +453 -0
  29. package/test/credential-env-regression.cjs +52 -0
  30. package/test/login-loopback-security.cjs +115 -0
  31. package/test/mcp-config-isolation.cjs +36 -0
  32. package/test/permission-mapping.cjs +180 -0
  33. package/test/run-api-regression.cjs +322 -0
  34. package/test/runtime-env-protection.cjs +45 -0
  35. package/test/semver-precedence.cjs +39 -0
  36. package/test/smoke.sh +33 -0
  37. package/test/sqlite-driver-probe.cjs +22 -0
  38. package/test/terminal-ui-regression.cjs +454 -0
  39. package/test/timeout-regression.cjs +218 -0
  40. package/test/tool-workspace-boundary.cjs +165 -0
  41. package/test/update-safety.cjs +376 -0
@@ -23,6 +23,100 @@ const { Ui } = require("./agentlas-ui.cjs");
23
23
  const SWARM_MAX_TASKS = 24;
24
24
  const SWARM_SPAWN_PER_TURN = 12;
25
25
 
26
+ // Agentlas-OS/Hephaestus 내부 시스템 에이전트(마켓 제품 아님) — 검색/목록에서 숨긴다.
27
+ // 데스크탑 electron/agents/hired-agents.ts isInternalAgentSlug 와 규칙 동일.
28
+ function isInternalAgentSlug(slug) {
29
+ const s = String(slug || "").toLowerCase();
30
+ return /^researcher-\d+/.test(s) || s === "research-intelligence-desk" || s.startsWith("hephaestus-");
31
+ }
32
+
33
+ const LOGIN_CALLBACK_PATH = "/callback";
34
+ const LOGIN_TIMEOUT_MS = 180_000;
35
+ const MAX_LOGIN_SESSION_BYTES = 16 * 1024;
36
+
37
+ function createLoginState(randomBytes = crypto.randomBytes) {
38
+ const bytes = Buffer.from(randomBytes(32));
39
+ if (bytes.length !== 32) throw new Error("로그인 state 생성에 실패했습니다.");
40
+ return bytes.toString("base64url");
41
+ }
42
+
43
+ function loginStatesMatch(actual, expected) {
44
+ const left = Buffer.from(String(actual || ""), "utf8");
45
+ const right = Buffer.from(String(expected || ""), "utf8");
46
+ return left.length === right.length && left.length > 0 && crypto.timingSafeEqual(left, right);
47
+ }
48
+
49
+ /**
50
+ * OAuth loopback callback의 1회용 state guard. `/callback` GET이 도착하면 성공/실패와
51
+ * 무관하게 transaction을 소비한다. 따라서 잘못된 state 뒤에 공격자 세션을 재주입하거나,
52
+ * 성공 URL을 재생해 다른 세션으로 덮어쓸 수 없다.
53
+ */
54
+ function createLoginCallbackGuard(expectedState) {
55
+ let consumed = false;
56
+ return {
57
+ consume(rawUrl, method = "GET") {
58
+ let url;
59
+ try {
60
+ url = new URL(String(rawUrl || "/"), "http://127.0.0.1");
61
+ } catch {
62
+ return { handled: true, final: false, ok: false, statusCode: 400, message: "잘못된 로그인 콜백입니다." };
63
+ }
64
+ if (url.pathname !== LOGIN_CALLBACK_PATH) {
65
+ return { handled: false, final: false, ok: false, statusCode: 404, message: "not found" };
66
+ }
67
+ if (method !== "GET") {
68
+ return { handled: true, final: false, ok: false, statusCode: 405, message: "method not allowed" };
69
+ }
70
+ if (consumed) {
71
+ return { handled: true, final: false, ok: false, statusCode: 410, message: "이미 사용된 로그인 콜백입니다." };
72
+ }
73
+ consumed = true;
74
+
75
+ if (!loginStatesMatch(url.searchParams.get("state"), expectedState)) {
76
+ return {
77
+ handled: true,
78
+ final: true,
79
+ ok: false,
80
+ statusCode: 400,
81
+ message: "로그인 콜백 state 검증에 실패했습니다. agentlas login을 다시 실행하세요.",
82
+ };
83
+ }
84
+ const oauthError = url.searchParams.get("error");
85
+ if (oauthError) {
86
+ const safeCode = /^[A-Za-z0-9_.-]{1,80}$/.test(oauthError) ? oauthError : "oauth_error";
87
+ return {
88
+ handled: true,
89
+ final: true,
90
+ ok: false,
91
+ statusCode: 400,
92
+ message: `Agentlas 로그인 거부: ${safeCode}`,
93
+ };
94
+ }
95
+ const value = url.searchParams.get("session") || url.searchParams.get("token") || "";
96
+ if (!value) {
97
+ return {
98
+ handled: true,
99
+ final: true,
100
+ ok: false,
101
+ statusCode: 400,
102
+ message: "콜백에 session 값이 없습니다.",
103
+ };
104
+ }
105
+ if (Buffer.byteLength(value, "utf8") > MAX_LOGIN_SESSION_BYTES) {
106
+ return {
107
+ handled: true,
108
+ final: true,
109
+ ok: false,
110
+ statusCode: 400,
111
+ message: "로그인 session 값이 허용 크기를 초과했습니다.",
112
+ };
113
+ }
114
+ return { handled: true, final: true, ok: true, statusCode: 200, value, message: "Agentlas 로그인 완료" };
115
+ },
116
+ isConsumed() { return consumed; },
117
+ };
118
+ }
119
+
26
120
  function create(deps) {
27
121
  const D = deps;
28
122
 
@@ -56,6 +150,35 @@ function create(deps) {
56
150
  return null;
57
151
  }
58
152
 
153
+ function careerGraphRuntime() {
154
+ const binCandidates = [
155
+ process.env.HEPHAESTUS_CAREER_GRAPH_BIN,
156
+ process.env.HEPHAESTUS_BIN ? path.join(path.dirname(process.env.HEPHAESTUS_BIN), "career-graph") : null,
157
+ path.join(os.homedir(), ".agentlas", "runtime", "current", "bin", "career-graph"),
158
+ ];
159
+ for (const c of binCandidates) {
160
+ try {
161
+ if (c && fs.existsSync(c)) {
162
+ fs.accessSync(c, fs.constants.X_OK);
163
+ return { kind: "bin", exec: c };
164
+ }
165
+ } catch { /* 다음 후보 */ }
166
+ }
167
+ const roots = [
168
+ process.env.HEPHAESTUS_RUNTIME_ROOT,
169
+ path.join(os.homedir(), ".agentlas", "runtime", "current"),
170
+ ];
171
+ if (process.resourcesPath) roots.push(path.join(process.resourcesPath, "Hephaestus"));
172
+ if (process.platform === "darwin") roots.push("/Applications/Agentlas.app/Contents/Resources/Hephaestus");
173
+ roots.push(path.resolve(__dirname, "..", "..", "agentlas_desktop", "Hephaestus"));
174
+ for (const root of roots) {
175
+ try {
176
+ if (root && fs.existsSync(path.join(root, "career_graph", "__main__.py"))) return { kind: "python", root };
177
+ } catch { /* 다음 후보 */ }
178
+ }
179
+ return null;
180
+ }
181
+
59
182
  const PY_BOOTSTRAP =
60
183
  "import os, runpy, sys; " +
61
184
  'cwd=os.getcwd(); root=os.environ["HEPHAESTUS_RUNTIME_ROOT"]; ' +
@@ -189,17 +312,28 @@ function create(deps) {
189
312
  // ── Hephaestus 네이티브 패스스루 — 엔진 전 기능을 터미널 1급으로 노출 ──
190
313
  // stdio inherit 로 돌려 색/프롬프트/스트리밍이 네이티브 그대로 나온다.
191
314
  function runHephaestusInteractive(args, opts = {}) {
192
- const found = hephaestusBin();
315
+ const isCareerGraph = args[0] === "career-graph" || args[0] === "career_graph";
316
+ const found = isCareerGraph ? careerGraphRuntime() : hephaestusBin();
193
317
  if (!found) {
194
- process.stderr.write("Hephaestus 런타임이 없습니다 — 데스크탑 앱 설치 또는 Hephaestus 인스톨러 실행 후 다시 시도하세요.\n");
195
- process.stderr.write("설치: https://agentlas.cloud · 또는 HEPHAESTUS_BIN=<경로> 지정\n");
318
+ process.stderr.write(
319
+ isCareerGraph
320
+ ? "Career Graph 런타임이 없습니다 — 최신 Agentlas OS / Hephaestus 설치 후 다시 시도하세요.\n"
321
+ : "Hephaestus 런타임이 없습니다 — 데스크탑 앱 설치 또는 Hephaestus 인스톨러 실행 후 다시 시도하세요.\n",
322
+ );
323
+ process.stderr.write(
324
+ isCareerGraph
325
+ ? "설치 또는 지정: HEPHAESTUS_CAREER_GRAPH_BIN=<경로> 또는 HEPHAESTUS_RUNTIME_ROOT=<경로>\n"
326
+ : "설치: https://agentlas.cloud · 또는 HEPHAESTUS_BIN=<경로> 지정\n",
327
+ );
196
328
  return Promise.resolve(1);
197
329
  }
198
330
  const cwd = opts.cwd || D.runCwd();
331
+ const moduleName = found.kind === "python" && isCareerGraph ? "career_graph" : "agentlas_cloud";
332
+ const moduleArgs = moduleName === "career_graph" ? args.slice(1) : args;
199
333
  const child =
200
334
  found.kind === "bin"
201
- ? spawn(found.exec, args, { cwd, stdio: "inherit" })
202
- : spawn("python3", ["-c", PY_BOOTSTRAP, "agentlas_cloud", ...args], {
335
+ ? spawn(found.exec, isCareerGraph ? args.slice(1) : args, { cwd, stdio: "inherit" })
336
+ : spawn("python3", ["-c", PY_BOOTSTRAP, moduleName, ...moduleArgs], {
203
337
  cwd,
204
338
  stdio: "inherit",
205
339
  env: { ...process.env, HEPHAESTUS_RUNTIME_ROOT: found.root },
@@ -437,7 +571,45 @@ function create(deps) {
437
571
  return set;
438
572
  }
439
573
 
440
- function nextCronRun(cron, from = new Date()) {
574
+ const WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
575
+ const zonedFormatterCache = new Map();
576
+
577
+ function zonedDateParts(date, timezone) {
578
+ if (!timezone) {
579
+ return {
580
+ minute: date.getMinutes(),
581
+ hour: date.getHours(),
582
+ day: date.getDate(),
583
+ month: date.getMonth() + 1,
584
+ weekday: date.getDay(),
585
+ };
586
+ }
587
+ let formatter = zonedFormatterCache.get(timezone);
588
+ if (!formatter) {
589
+ formatter = new Intl.DateTimeFormat("en-US", {
590
+ timeZone: timezone,
591
+ hourCycle: "h23",
592
+ minute: "2-digit",
593
+ hour: "2-digit",
594
+ day: "2-digit",
595
+ month: "2-digit",
596
+ weekday: "short",
597
+ });
598
+ zonedFormatterCache.set(timezone, formatter);
599
+ }
600
+ const parts = Object.fromEntries(
601
+ formatter.formatToParts(date).filter((part) => part.type !== "literal").map((part) => [part.type, part.value]),
602
+ );
603
+ return {
604
+ minute: Number(parts.minute),
605
+ hour: Number(parts.hour),
606
+ day: Number(parts.day),
607
+ month: Number(parts.month),
608
+ weekday: WEEKDAY_INDEX[parts.weekday],
609
+ };
610
+ }
611
+
612
+ function nextCronRun(cron, from = new Date(), timezone = null) {
441
613
  const parts = String(cron).trim().split(/\s+/);
442
614
  if (parts.length !== 5) return null;
443
615
  const [minS, hourS, domS, monS, dowS] = parts;
@@ -448,22 +620,93 @@ function create(deps) {
448
620
  const dows = cronField(dowS, 0, 7);
449
621
  if (!mins || !hours || !doms || !mons || !dows) return null;
450
622
  if (dows.has(7)) dows.add(0);
623
+ try {
624
+ if (timezone) zonedDateParts(from, timezone);
625
+ } catch {
626
+ return null;
627
+ }
451
628
  const t = new Date(from.getTime());
452
629
  t.setSeconds(0, 0);
453
630
  t.setMinutes(t.getMinutes() + 1);
454
631
  for (let i = 0; i < 366 * 24 * 60; i++) {
455
- const domOk = doms.has(t.getDate());
456
- const dowOk = dows.has(t.getDay());
632
+ const local = zonedDateParts(t, timezone);
633
+ const domOk = doms.has(local.day);
634
+ const dowOk = dows.has(local.weekday);
457
635
  // 표준 cron: dom/dow 둘 다 제한이면 OR, 아니면 AND
458
636
  const domRestricted = domS !== "*";
459
637
  const dowRestricted = dowS !== "*";
460
638
  const dayOk = domRestricted && dowRestricted ? domOk || dowOk : domOk && dowOk;
461
- if (mons.has(t.getMonth() + 1) && dayOk && hours.has(t.getHours()) && mins.has(t.getMinutes())) return t;
639
+ if (mons.has(local.month) && dayOk && hours.has(local.hour) && mins.has(local.minute)) return t;
462
640
  t.setMinutes(t.getMinutes() + 1);
463
641
  }
464
642
  return null;
465
643
  }
466
644
 
645
+ function localTimezone() {
646
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; }
647
+ }
648
+
649
+ function legacyScheduleSpec(raw, timezone) {
650
+ const value = String(raw || "").trim();
651
+ if (!value) return null;
652
+ if (value.startsWith("cron:")) {
653
+ const expr = value.slice(5).trim();
654
+ return expr ? { kind: "cron", expr, tz: timezone } : null;
655
+ }
656
+ if (value.split(/\s+/).length === 5) return { kind: "cron", expr: value, tz: timezone };
657
+ if (value === "hourly") return { kind: "interval", everyMs: 60 * 60 * 1000, anchor: "lastRun" };
658
+ const every = value.match(/^every-(\d+)(m|h)$/);
659
+ if (every) {
660
+ const amount = Number(every[1]);
661
+ if (amount > 0) return { kind: "interval", everyMs: amount * (every[2] === "h" ? 3600000 : 60000), anchor: "lastRun" };
662
+ }
663
+ let match = value.match(/^daily-(\d{1,2}):(\d{2})$/);
664
+ if (match) return { kind: "cron", expr: `${Number(match[2])} ${Number(match[1])} * * *`, tz: timezone };
665
+ match = value.match(/^weekday-(\d{1,2}):(\d{2})$/);
666
+ if (match) return { kind: "cron", expr: `${Number(match[2])} ${Number(match[1])} * * 1-5`, tz: timezone };
667
+ match = value.match(/^weekly-(sun|mon|tue|wed|thu|fri|sat)-(\d{1,2}):(\d{2})$/i);
668
+ if (match) {
669
+ const dow = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }[match[1].toLowerCase()];
670
+ return { kind: "cron", expr: `${Number(match[3])} ${Number(match[2])} * * ${dow}`, tz: timezone };
671
+ }
672
+ match = value.match(/^monthly-(\d{1,2})-(\d{1,2}):(\d{2})$/);
673
+ if (match && Number(match[1]) >= 1 && Number(match[1]) <= 31) {
674
+ return { kind: "cron", expr: `${Number(match[3])} ${Number(match[2])} ${Number(match[1])} * *`, tz: timezone };
675
+ }
676
+ return null;
677
+ }
678
+
679
+ /** Desktop schedule_json + legacy mirror token parity, including IANA timezone. */
680
+ function nextAutomationRun(row, from = new Date()) {
681
+ const timezone = row.timezone || localTimezone();
682
+ let spec = null;
683
+ if (row.schedule_json && String(row.schedule_json).trim()) {
684
+ try {
685
+ const parsed = JSON.parse(row.schedule_json);
686
+ if (parsed && typeof parsed.kind === "string") spec = parsed;
687
+ } catch { /* fall through to legacy schedule */ }
688
+ }
689
+ if (!spec) spec = legacyScheduleSpec(row.schedule, timezone);
690
+ if (!spec) {
691
+ // Desktop computeNextRun preserves unknown legacy schedules with a 24h
692
+ // fallback. More importantly, never leave a due row at the same instant.
693
+ return row.schedule ? new Date(from.getTime() + 24 * 3600 * 1000) : null;
694
+ }
695
+ if (spec.kind === "cron") return nextCronRun(spec.expr, from, spec.tz || timezone);
696
+ if (spec.kind === "interval") {
697
+ const every = Number(spec.everyMs);
698
+ if (!Number.isFinite(every) || every <= 0) return null;
699
+ return spec.anchor === "wallclock"
700
+ ? new Date(Math.ceil((from.getTime() + 1) / every) * every)
701
+ : new Date(from.getTime() + every);
702
+ }
703
+ if (spec.kind === "once") {
704
+ const at = new Date(spec.atIso);
705
+ return at.getTime() > from.getTime() ? at : null;
706
+ }
707
+ return null;
708
+ }
709
+
467
710
  // ── automation — 등록/목록/토글/실행 (run·daemon은 로컬 실행기) ──
468
711
  async function cmdAutomation(db, args, runtimeOverride) {
469
712
  const sub = args[0] || "list";
@@ -522,7 +765,7 @@ function create(deps) {
522
765
  targetId = f.id;
523
766
  targetLabel = f.name;
524
767
  }
525
- const next = nextCronRun(flags.cron);
768
+ const next = nextCronRun(flags.cron, new Date(), flags.tz || null);
526
769
  if (!next) return D.fail(`cron 표현식을 해석할 수 없습니다: "${flags.cron}" (5필드: 분 시 일 월 요일)`);
527
770
  const id = crypto.randomUUID();
528
771
  db.prepare(
@@ -553,10 +796,10 @@ function create(deps) {
553
796
  if (sub === "on" || sub === "off") {
554
797
  const idPrefix = args[1];
555
798
  if (!idPrefix) return D.fail(`usage: agentlas automation ${sub} <id>`);
556
- const row = db.prepare("SELECT id, name, schedule FROM automations WHERE id LIKE ?").get(idPrefix + "%");
799
+ const row = db.prepare("SELECT id, name, schedule, schedule_json, timezone FROM automations WHERE id LIKE ?").get(idPrefix + "%");
557
800
  if (!row) return D.fail(`자동화를 찾을 수 없습니다: ${idPrefix}`);
558
801
  if (sub === "on") {
559
- const next = nextCronRun(row.schedule) || null;
802
+ const next = nextAutomationRun(row) || null;
560
803
  db.prepare("UPDATE automations SET enabled=1, next_run_at=? WHERE id=?").run(next ? next.toISOString() : null, row.id);
561
804
  } else {
562
805
  db.prepare("UPDATE automations SET enabled=0 WHERE id=?").run(row.id);
@@ -683,10 +926,17 @@ function create(deps) {
683
926
  ui.markdown(String(text).trim().slice(0, 4000));
684
927
 
685
928
  recordAutomationRun(db, row.id, "ok", null, ctx.scheduledFor);
686
- const advance = ctx.advanceSchedule && row.schedule ? nextCronRun(row.schedule) : null;
687
- db.prepare(
688
- "UPDATE automations SET last_run_at = ?, run_count = run_count + 1" + (advance ? ", next_run_at = ?" : "") + " WHERE id = ?",
689
- ).run(...(advance ? [new Date().toISOString(), advance.toISOString(), row.id] : [new Date().toISOString(), row.id]));
929
+ const ranAt = new Date();
930
+ const shouldAdvance = !!ctx.advanceSchedule && (row.trigger_type || "schedule") === "schedule";
931
+ const advance = shouldAdvance ? nextAutomationRun(row, ranAt) : null;
932
+ if (shouldAdvance) {
933
+ db.prepare(
934
+ "UPDATE automations SET last_run_at = ?, run_count = run_count + 1, next_run_at = ?, enabled = ? WHERE id = ?",
935
+ ).run(ranAt.toISOString(), advance ? advance.toISOString() : null, advance ? row.enabled : 0, row.id);
936
+ } else {
937
+ db.prepare("UPDATE automations SET last_run_at = ?, run_count = run_count + 1 WHERE id = ?")
938
+ .run(ranAt.toISOString(), row.id);
939
+ }
690
940
  // max_runs 도달 시 비활성화 (앱과 동일한 종료 조건).
691
941
  if (row.max_runs && row.run_count + 1 >= row.max_runs) {
692
942
  db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(row.id);
@@ -698,7 +948,15 @@ function create(deps) {
698
948
  const msg = String((e && e.message) || e).slice(0, 500);
699
949
  ui.error(msg);
700
950
  recordAutomationRun(db, row.id, "error", msg, ctx.scheduledFor);
701
- db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(new Date().toISOString(), row.id);
951
+ const ranAt = new Date();
952
+ const shouldAdvance = !!ctx.advanceSchedule && (row.trigger_type || "schedule") === "schedule";
953
+ const advance = shouldAdvance ? nextAutomationRun(row, ranAt) : null;
954
+ if (shouldAdvance) {
955
+ db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?")
956
+ .run(ranAt.toISOString(), advance ? advance.toISOString() : null, advance ? row.enabled : 0, row.id);
957
+ } else {
958
+ db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(ranAt.toISOString(), row.id);
959
+ }
702
960
  return { ok: false };
703
961
  } finally {
704
962
  releaseAutomation(db, row.id);
@@ -731,7 +989,7 @@ function create(deps) {
731
989
  if (stopping) break;
732
990
  await runAutomationOnce(db, row, { ui, advanceSchedule: true, scheduledFor: row.next_run_at });
733
991
  // 스케줄이 없는(1회성) 행이 남으면 재발화 방지.
734
- if (!row.schedule || !nextCronRun(row.schedule)) {
992
+ if (!row.schedule || !nextAutomationRun(row)) {
735
993
  db.prepare("UPDATE automations SET enabled = 0 WHERE id = ? AND (schedule IS NULL OR schedule = '')").run(row.id);
736
994
  }
737
995
  }
@@ -754,7 +1012,7 @@ function create(deps) {
754
1012
  D.out(`${r.enabled ? "●" : "○"} ${String(r.name || r.name_en || r.id).padEnd(28).slice(0, 28)} ${String(r.transport || "stdio").padEnd(8)} ${String(r.id).slice(0, 12)}`);
755
1013
  }
756
1014
  D.out("");
757
- D.out("write/full 턴에서 활성(●) stdio 서버가 런타임에 배선됩니다. REPL에서는 /mcp.");
1015
+ D.out("full 턴에서만 활성(●) stdio 서버가 런타임에 배선됩니다. REPL에서는 /mcp.");
758
1016
  }
759
1017
 
760
1018
  function cmdChats(db, args) {
@@ -844,6 +1102,91 @@ function create(deps) {
844
1102
  return resp.json();
845
1103
  }
846
1104
 
1105
+ function loginCallbackHtml(ok) {
1106
+ const title = ok ? "Agentlas 로그인 완료" : "Agentlas 로그인 실패";
1107
+ const body = ok
1108
+ ? "터미널로 돌아가세요. 이 창은 닫아도 됩니다."
1109
+ : "터미널로 돌아가 agentlas login을 다시 실행하세요.";
1110
+ 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>`;
1111
+ }
1112
+
1113
+ function waitForLoopbackSession(options = {}) {
1114
+ const http = options.http || require("node:http");
1115
+ const timeoutCandidate = Number(options.timeoutMs);
1116
+ const timeoutMs = Number.isFinite(timeoutCandidate) && timeoutCandidate > 0 ? timeoutCandidate : LOGIN_TIMEOUT_MS;
1117
+ const state = createLoginState(options.randomBytes || crypto.randomBytes);
1118
+ const guard = createLoginCallbackGuard(state);
1119
+ const onLoginUrl = options.onLoginUrl || ((url) => {
1120
+ D.out("브라우저에서 Agentlas에 로그인하세요 (자동으로 열립니다):");
1121
+ D.out(" " + url);
1122
+ openInBrowser(url);
1123
+ });
1124
+
1125
+ return new Promise((resolve, reject) => {
1126
+ let settled = false;
1127
+ let timer = null;
1128
+ let server;
1129
+ const finish = (error, value) => {
1130
+ if (settled) return;
1131
+ settled = true;
1132
+ if (timer) clearTimeout(timer);
1133
+ try { if (server) server.close(); } catch { /* ignore */ }
1134
+ if (error) reject(error);
1135
+ else resolve(value);
1136
+ };
1137
+
1138
+ server = http.createServer((req, res) => {
1139
+ const result = guard.consume(req.url, req.method || "GET");
1140
+ const headers = {
1141
+ "content-type": result.handled ? "text/html; charset=utf-8" : "text/plain; charset=utf-8",
1142
+ "cache-control": "no-store",
1143
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
1144
+ "x-content-type-options": "nosniff",
1145
+ connection: "close",
1146
+ };
1147
+ if (result.statusCode === 405) headers.allow = "GET";
1148
+ res.writeHead(result.statusCode, headers);
1149
+ res.end(result.handled ? loginCallbackHtml(result.ok) : result.message);
1150
+ if (!result.final) return;
1151
+ if (result.ok) finish(null, result.value);
1152
+ else finish(new Error(result.message));
1153
+ });
1154
+ server.on("error", (error) => finish(error));
1155
+ server.listen(0, "127.0.0.1", () => {
1156
+ const address = server.address();
1157
+ const port = address && typeof address === "object" ? address.port : 0;
1158
+ if (!port) {
1159
+ finish(new Error("로그인 loopback 포트를 열지 못했습니다."));
1160
+ return;
1161
+ }
1162
+ const callback = new URL(`http://127.0.0.1:${port}${LOGIN_CALLBACK_PATH}`);
1163
+ callback.searchParams.set("state", state);
1164
+ let loginUrl;
1165
+ try {
1166
+ loginUrl = new URL("/account", `${options.baseUrl || webBaseUrl()}/`);
1167
+ } catch {
1168
+ finish(new Error("Agentlas 로그인 URL이 올바르지 않습니다."));
1169
+ return;
1170
+ }
1171
+ loginUrl.searchParams.set("desktop", "1");
1172
+ loginUrl.searchParams.set("callback", callback.toString());
1173
+ timer = setTimeout(
1174
+ () => finish(new Error(`로그인 대기 시간(${Math.ceil(timeoutMs / 1000)}초)이 지났습니다. 다시 시도: agentlas login`)),
1175
+ timeoutMs,
1176
+ );
1177
+ if (timer.unref) timer.unref();
1178
+ try {
1179
+ const notified = onLoginUrl(loginUrl.toString());
1180
+ if (notified && typeof notified.then === "function") {
1181
+ void notified.catch((error) => finish(error));
1182
+ }
1183
+ } catch (error) {
1184
+ finish(error);
1185
+ }
1186
+ });
1187
+ });
1188
+ }
1189
+
847
1190
  async function cmdWhoami() {
848
1191
  const cookie = await D.cloudSessionCookieCli();
849
1192
  if (!cookie) {
@@ -866,8 +1209,8 @@ function create(deps) {
866
1209
  }
867
1210
  }
868
1211
 
869
- // 웹 /account?desktop=1&callback=<loopback> 이 유효 세션이면 <callback>?session=<value> 로 302 —
870
- // 데스크탑 signInWithBrowser(electron/auth.ts)와 동일한 프로토콜을 순수 Node http로 구현.
1212
+ // 웹 /account?desktop=1&callback=<loopback+state> 이 유효 세션이면 callback state를
1213
+ // 보존한 session을 추가해 302한다. Terminal은 state를 1회 검증한 뒤에만 저장한다.
871
1214
  async function cmdLogin(args = []) {
872
1215
  const force = args.includes("--force");
873
1216
  if (!force) {
@@ -883,35 +1226,9 @@ function create(deps) {
883
1226
  }
884
1227
  }
885
1228
 
886
- const http = require("node:http");
887
1229
  let value;
888
1230
  try {
889
- value = await new Promise((resolve, reject) => {
890
- let settled = false;
891
- const done = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
892
- const server = http.createServer((req, res) => {
893
- let u;
894
- try { u = new URL(req.url, "http://127.0.0.1"); } catch { res.writeHead(400); res.end(); return; }
895
- if (!u.pathname.startsWith("/callback")) { res.writeHead(404); res.end("not found"); return; }
896
- const v = u.searchParams.get("session") || u.searchParams.get("token");
897
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
898
- res.end("<html><body style=\"font-family:-apple-system,sans-serif;padding:40px\"><h3>Agentlas 로그인 완료</h3><p>터미널로 돌아가세요. 이 창은 닫아도 됩니다.</p></body></html>");
899
- server.close();
900
- if (v) done(resolve, v);
901
- else done(reject, new Error("콜백에 session 값이 없습니다."));
902
- });
903
- server.on("error", (e) => done(reject, e));
904
- server.listen(0, "127.0.0.1", () => {
905
- const port = server.address().port;
906
- const cb = encodeURIComponent(`http://127.0.0.1:${port}/callback`);
907
- const url = `${webBaseUrl()}/account?desktop=1&callback=${cb}`;
908
- D.out("브라우저에서 Agentlas에 로그인하세요 (자동으로 열립니다):");
909
- D.out(" " + url);
910
- openInBrowser(url);
911
- });
912
- const t = setTimeout(() => { try { server.close(); } catch { /* ignore */ } done(reject, new Error("로그인 대기 시간(180초)이 지났습니다. 다시 시도: agentlas login")); }, 180_000);
913
- if (t.unref) t.unref();
914
- });
1231
+ value = await waitForLoopbackSession();
915
1232
  } catch (e) {
916
1233
  return D.fail(String((e && e.message) || e));
917
1234
  }
@@ -958,7 +1275,8 @@ function create(deps) {
958
1275
  headers,
959
1276
  body: JSON.stringify({
960
1277
  method: "marketplace.search_agents",
961
- params: { name: "marketplace.search_agents", arguments: { query, limit } },
1278
+ // Hub는 파라미터 이름이 `q` `query`만 보내면 무시하고 기본 목록을 준다(데스크탑 mcp-source.ts와 동일하게 둘 다 전송).
1279
+ params: { name: "marketplace.search_agents", arguments: { q: query, query, limit } },
962
1280
  }),
963
1281
  });
964
1282
  } catch (e) {
@@ -968,7 +1286,9 @@ function create(deps) {
968
1286
  const json = await resp.json();
969
1287
  if (json.error) return D.fail(json.error.message || "marketplace error");
970
1288
  const result = json.result || {};
971
- const items = result.results || result.agents || result.items || (Array.isArray(result) ? result : null);
1289
+ const rawItems = result.results || result.agents || result.items || (Array.isArray(result) ? result : null);
1290
+ // 엔진 내부 에이전트(researcher-<n>, research-intelligence-desk, hephaestus-*)는 제품이 아니므로 숨긴다.
1291
+ const items = Array.isArray(rawItems) ? rawItems.filter((it) => !isInternalAgentSlug(it && (it.slug || it.id))) : rawItems;
972
1292
  if (!Array.isArray(items) || !items.length) {
973
1293
  D.out(`검색 결과 없음: "${query}"`);
974
1294
  return;
@@ -981,14 +1301,14 @@ function create(deps) {
981
1301
  D.out(`${String(slug).padEnd(34).slice(0, 34)} ${String(name).slice(0, 26).padEnd(27)} ${String(kind).padEnd(14)} ${String(tagline).slice(0, 60)}`);
982
1302
  }
983
1303
  D.out("");
984
- D.out("설치: agentlas cloud install <slug>");
1304
+ D.out("설치: agentlas install <slug>");
985
1305
  }
986
1306
 
987
1307
  return {
988
1308
  cmdStorm, stormRun, cmdSwarm, swarmRun, cmdAutomation, cmdUsage, cmdTelegram, cloudSearch,
989
1309
  cmdLogin, cmdLogout, cmdWhoami, cmdHep, runHephaestusInteractive, cmdMcp, cmdChats,
990
- nextCronRun, parseSwarmOutput,
1310
+ nextCronRun, nextAutomationRun, runAutomationOnce, parseSwarmOutput, waitForLoopbackSession,
991
1311
  };
992
1312
  }
993
1313
 
994
- module.exports = { create };
1314
+ module.exports = { create, _test: { createLoginState, createLoginCallbackGuard } };
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+
3
+ /*
4
+ * One permission vocabulary for every Agentlas terminal surface.
5
+ *
6
+ * The host adapters still own their exact CLI flags, but they all normalize through
7
+ * this module so a corrupt preference or an unknown value fails closed to `read`.
8
+ */
9
+
10
+ const LEVELS = ["read", "write", "full"];
11
+
12
+ const COPY = {
13
+ en: {
14
+ read: {
15
+ label: "read only",
16
+ short: "inspect only",
17
+ description: "inspect files and reason; runtime tools cannot change the workspace",
18
+ },
19
+ write: {
20
+ label: "workspace write",
21
+ short: "edit workspace",
22
+ description: "read and edit the current workspace inside the runtime sandbox",
23
+ },
24
+ full: {
25
+ label: "unrestricted",
26
+ short: "unrestricted",
27
+ description: "bypass runtime approvals and sandboxing; use only in a trusted environment",
28
+ },
29
+ },
30
+ ko: {
31
+ read: {
32
+ label: "읽기 전용",
33
+ short: "조회만",
34
+ description: "파일을 읽고 판단하지만 런타임 도구가 작업 공간을 변경할 수 없음",
35
+ },
36
+ write: {
37
+ label: "작업 공간 쓰기",
38
+ short: "작업 공간 편집",
39
+ description: "런타임 샌드박스 안에서 현재 작업 공간을 읽고 편집",
40
+ },
41
+ full: {
42
+ label: "무제한 권한",
43
+ short: "무제한",
44
+ description: "런타임 승인과 샌드박스를 우회함; 신뢰할 수 있는 환경에서만 사용",
45
+ },
46
+ },
47
+ };
48
+
49
+ function normalize(value, fallback = "read") {
50
+ const level = String(value || "").trim().toLowerCase();
51
+ if (LEVELS.includes(level)) return level;
52
+ return LEVELS.includes(fallback) ? fallback : "read";
53
+ }
54
+
55
+ function next(value) {
56
+ const current = normalize(value);
57
+ return LEVELS[(LEVELS.indexOf(current) + 1) % LEVELS.length];
58
+ }
59
+
60
+ function copy(value, lang = "en") {
61
+ const level = normalize(value);
62
+ const table = COPY[lang] || COPY.en;
63
+ return { level, ...table[level] };
64
+ }
65
+
66
+ function createCycleController(options = {}) {
67
+ const now = options.now || Date.now;
68
+ const armMs = Number(options.armMs) > 0 ? Number(options.armMs) : 5_000;
69
+ let fullArmedUntil = 0;
70
+ return {
71
+ step(value) {
72
+ const level = normalize(value);
73
+ const at = now();
74
+ if (level === "write" && at >= fullArmedUntil) {
75
+ fullArmedUntil = at + armMs;
76
+ return { level, armed: true, enteredFull: false };
77
+ }
78
+ if (level === "write") {
79
+ fullArmedUntil = 0;
80
+ return { level: "full", armed: false, enteredFull: true };
81
+ }
82
+ fullArmedUntil = 0;
83
+ return { level: next(level), armed: false, enteredFull: false };
84
+ },
85
+ cancel() { fullArmedUntil = 0; },
86
+ armed() { return now() < fullArmedUntil; },
87
+ };
88
+ }
89
+
90
+ module.exports = { LEVELS, normalize, next, copy, createCycleController };