agentlas 0.5.2 → 0.6.0

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 +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +112 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +136 -12
  10. package/engine/agentlas-input.cjs +118 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +239 -70
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +352 -23
  18. package/engine/agentlas.cjs +2819 -351
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/route-regression.cjs +121 -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 +20 -0
  37. package/test/sqlite-driver-probe.cjs +22 -0
  38. package/test/terminal-ui-regression.cjs +472 -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
@@ -30,6 +30,93 @@ function isInternalAgentSlug(slug) {
30
30
  return /^researcher-\d+/.test(s) || s === "research-intelligence-desk" || s.startsWith("hephaestus-");
31
31
  }
32
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
+
33
120
  function create(deps) {
34
121
  const D = deps;
35
122
 
@@ -484,7 +571,45 @@ function create(deps) {
484
571
  return set;
485
572
  }
486
573
 
487
- 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) {
488
613
  const parts = String(cron).trim().split(/\s+/);
489
614
  if (parts.length !== 5) return null;
490
615
  const [minS, hourS, domS, monS, dowS] = parts;
@@ -495,22 +620,93 @@ function create(deps) {
495
620
  const dows = cronField(dowS, 0, 7);
496
621
  if (!mins || !hours || !doms || !mons || !dows) return null;
497
622
  if (dows.has(7)) dows.add(0);
623
+ try {
624
+ if (timezone) zonedDateParts(from, timezone);
625
+ } catch {
626
+ return null;
627
+ }
498
628
  const t = new Date(from.getTime());
499
629
  t.setSeconds(0, 0);
500
630
  t.setMinutes(t.getMinutes() + 1);
501
631
  for (let i = 0; i < 366 * 24 * 60; i++) {
502
- const domOk = doms.has(t.getDate());
503
- 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);
504
635
  // 표준 cron: dom/dow 둘 다 제한이면 OR, 아니면 AND
505
636
  const domRestricted = domS !== "*";
506
637
  const dowRestricted = dowS !== "*";
507
638
  const dayOk = domRestricted && dowRestricted ? domOk || dowOk : domOk && dowOk;
508
- 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;
509
640
  t.setMinutes(t.getMinutes() + 1);
510
641
  }
511
642
  return null;
512
643
  }
513
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
+
514
710
  // ── automation — 등록/목록/토글/실행 (run·daemon은 로컬 실행기) ──
515
711
  async function cmdAutomation(db, args, runtimeOverride) {
516
712
  const sub = args[0] || "list";
@@ -569,7 +765,7 @@ function create(deps) {
569
765
  targetId = f.id;
570
766
  targetLabel = f.name;
571
767
  }
572
- const next = nextCronRun(flags.cron);
768
+ const next = nextCronRun(flags.cron, new Date(), flags.tz || null);
573
769
  if (!next) return D.fail(`cron 표현식을 해석할 수 없습니다: "${flags.cron}" (5필드: 분 시 일 월 요일)`);
574
770
  const id = crypto.randomUUID();
575
771
  db.prepare(
@@ -600,10 +796,10 @@ function create(deps) {
600
796
  if (sub === "on" || sub === "off") {
601
797
  const idPrefix = args[1];
602
798
  if (!idPrefix) return D.fail(`usage: agentlas automation ${sub} <id>`);
603
- 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 + "%");
604
800
  if (!row) return D.fail(`자동화를 찾을 수 없습니다: ${idPrefix}`);
605
801
  if (sub === "on") {
606
- const next = nextCronRun(row.schedule) || null;
802
+ const next = nextAutomationRun(row) || null;
607
803
  db.prepare("UPDATE automations SET enabled=1, next_run_at=? WHERE id=?").run(next ? next.toISOString() : null, row.id);
608
804
  } else {
609
805
  db.prepare("UPDATE automations SET enabled=0 WHERE id=?").run(row.id);
@@ -730,10 +926,17 @@ function create(deps) {
730
926
  ui.markdown(String(text).trim().slice(0, 4000));
731
927
 
732
928
  recordAutomationRun(db, row.id, "ok", null, ctx.scheduledFor);
733
- const advance = ctx.advanceSchedule && row.schedule ? nextCronRun(row.schedule) : null;
734
- db.prepare(
735
- "UPDATE automations SET last_run_at = ?, run_count = run_count + 1" + (advance ? ", next_run_at = ?" : "") + " WHERE id = ?",
736
- ).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
+ }
737
940
  // max_runs 도달 시 비활성화 (앱과 동일한 종료 조건).
738
941
  if (row.max_runs && row.run_count + 1 >= row.max_runs) {
739
942
  db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(row.id);
@@ -745,7 +948,15 @@ function create(deps) {
745
948
  const msg = String((e && e.message) || e).slice(0, 500);
746
949
  ui.error(msg);
747
950
  recordAutomationRun(db, row.id, "error", msg, ctx.scheduledFor);
748
- 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
+ }
749
960
  return { ok: false };
750
961
  } finally {
751
962
  releaseAutomation(db, row.id);
@@ -778,7 +989,7 @@ function create(deps) {
778
989
  if (stopping) break;
779
990
  await runAutomationOnce(db, row, { ui, advanceSchedule: true, scheduledFor: row.next_run_at });
780
991
  // 스케줄이 없는(1회성) 행이 남으면 재발화 방지.
781
- if (!row.schedule || !nextCronRun(row.schedule)) {
992
+ if (!row.schedule || !nextAutomationRun(row)) {
782
993
  db.prepare("UPDATE automations SET enabled = 0 WHERE id = ? AND (schedule IS NULL OR schedule = '')").run(row.id);
783
994
  }
784
995
  }
@@ -801,7 +1012,7 @@ function create(deps) {
801
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)}`);
802
1013
  }
803
1014
  D.out("");
804
- D.out("write/full 턴에서 활성(●) stdio 서버가 런타임에 배선됩니다. REPL에서는 /mcp.");
1015
+ D.out("full 턴에서만 활성(●) stdio 서버가 런타임에 배선됩니다. REPL에서는 /mcp.");
805
1016
  }
806
1017
 
807
1018
  function cmdChats(db, args) {
@@ -891,6 +1102,91 @@ function create(deps) {
891
1102
  return resp.json();
892
1103
  }
893
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
+
894
1190
  async function cmdWhoami() {
895
1191
  const cookie = await D.cloudSessionCookieCli();
896
1192
  if (!cookie) {
@@ -913,8 +1209,8 @@ function create(deps) {
913
1209
  }
914
1210
  }
915
1211
 
916
- // 웹 /account?desktop=1&callback=<loopback> 이 유효 세션이면 <callback>?session=<value> 로 302 —
917
- // 데스크탑 signInWithBrowser(electron/auth.ts)와 동일한 프로토콜을 순수 Node http로 구현.
1212
+ // 웹 /account?desktop=1&callback=<loopback+state> 이 유효 세션이면 callback state를
1213
+ // 보존한 session을 추가해 302한다. Terminal은 state를 1회 검증한 뒤에만 저장한다.
918
1214
  async function cmdLogin(args = []) {
919
1215
  const force = args.includes("--force");
920
1216
  if (!force) {
@@ -930,35 +1226,9 @@ function create(deps) {
930
1226
  }
931
1227
  }
932
1228
 
933
- const http = require("node:http");
934
1229
  let value;
935
1230
  try {
936
- value = await new Promise((resolve, reject) => {
937
- let settled = false;
938
- const done = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
939
- const server = http.createServer((req, res) => {
940
- let u;
941
- try { u = new URL(req.url, "http://127.0.0.1"); } catch { res.writeHead(400); res.end(); return; }
942
- if (!u.pathname.startsWith("/callback")) { res.writeHead(404); res.end("not found"); return; }
943
- const v = u.searchParams.get("session") || u.searchParams.get("token");
944
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
945
- res.end("<html><body style=\"font-family:-apple-system,sans-serif;padding:40px\"><h3>Agentlas 로그인 완료</h3><p>터미널로 돌아가세요. 이 창은 닫아도 됩니다.</p></body></html>");
946
- server.close();
947
- if (v) done(resolve, v);
948
- else done(reject, new Error("콜백에 session 값이 없습니다."));
949
- });
950
- server.on("error", (e) => done(reject, e));
951
- server.listen(0, "127.0.0.1", () => {
952
- const port = server.address().port;
953
- const cb = encodeURIComponent(`http://127.0.0.1:${port}/callback`);
954
- const url = `${webBaseUrl()}/account?desktop=1&callback=${cb}`;
955
- D.out("브라우저에서 Agentlas에 로그인하세요 (자동으로 열립니다):");
956
- D.out(" " + url);
957
- openInBrowser(url);
958
- });
959
- const t = setTimeout(() => { try { server.close(); } catch { /* ignore */ } done(reject, new Error("로그인 대기 시간(180초)이 지났습니다. 다시 시도: agentlas login")); }, 180_000);
960
- if (t.unref) t.unref();
961
- });
1231
+ value = await waitForLoopbackSession();
962
1232
  } catch (e) {
963
1233
  return D.fail(String((e && e.message) || e));
964
1234
  }
@@ -1037,8 +1307,8 @@ function create(deps) {
1037
1307
  return {
1038
1308
  cmdStorm, stormRun, cmdSwarm, swarmRun, cmdAutomation, cmdUsage, cmdTelegram, cloudSearch,
1039
1309
  cmdLogin, cmdLogout, cmdWhoami, cmdHep, runHephaestusInteractive, cmdMcp, cmdChats,
1040
- nextCronRun, parseSwarmOutput,
1310
+ nextCronRun, nextAutomationRun, runAutomationOnce, parseSwarmOutput, waitForLoopbackSession,
1041
1311
  };
1042
1312
  }
1043
1313
 
1044
- 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 };