agentlas 0.5.2 → 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.
- package/README.md +48 -6
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +40 -56
- package/engine/agentlas-capabilities.cjs +3 -0
- package/engine/agentlas-cloud-runtime.cjs +65 -11
- package/engine/agentlas-composer.cjs +109 -44
- package/engine/agentlas-doctor.cjs +40 -12
- package/engine/agentlas-i18n.cjs +120 -12
- package/engine/agentlas-input.cjs +116 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +315 -45
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +99 -45
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +348 -23
- package/engine/agentlas.cjs +2742 -338
- package/engine/semver.cjs +64 -0
- package/package.json +1 -1
- package/test/bootstrap-race.cjs +47 -0
- package/test/capture-runtime-guard.cjs +122 -0
- package/test/cloud-asset-restore.cjs +423 -0
- package/test/cloud-cas-client.cjs +333 -0
- package/test/cloud-owner-restore.cjs +183 -0
- package/test/cloud-runtime-paths.cjs +40 -0
- package/test/cloud-save-publish.cjs +453 -0
- package/test/credential-env-regression.cjs +52 -0
- package/test/login-loopback-security.cjs +115 -0
- package/test/mcp-config-isolation.cjs +36 -0
- package/test/permission-mapping.cjs +180 -0
- package/test/run-api-regression.cjs +322 -0
- package/test/runtime-env-protection.cjs +45 -0
- package/test/semver-precedence.cjs +39 -0
- package/test/smoke.sh +19 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +454 -0
- package/test/timeout-regression.cjs +218 -0
- package/test/tool-workspace-boundary.cjs +165 -0
- 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
|
-
|
|
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
|
|
503
|
-
const
|
|
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(
|
|
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 =
|
|
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
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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 || !
|
|
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("
|
|
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> 이 유효 세션이면
|
|
917
|
-
//
|
|
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
|
|
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 };
|