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.
- package/README.md +112 -23
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +66 -51
- 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 +65 -14
- package/engine/agentlas-i18n.cjs +132 -12
- package/engine/agentlas-input.cjs +123 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +373 -53
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +149 -47
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +349 -24
- package/engine/agentlas.cjs +3074 -379
- package/engine/architecture.data.json +5 -1
- 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 +33 -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
|
@@ -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
|
|
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(
|
|
195
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
456
|
-
const
|
|
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(
|
|
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 =
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
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 || !
|
|
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("
|
|
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> 이 유효 세션이면
|
|
870
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 };
|