agentlas 1.0.60 → 1.0.62
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/CHANGELOG.md +18 -1
- package/engine/acp/server.cjs +47 -17
- package/engine/agentlas-evolution.cjs +85 -36
- package/engine/agentlas-experience-intake.cjs +21 -8
- package/engine/agentlas-memory-governance.cjs +88 -10
- package/engine/agentlas-permissions.cjs +95 -1
- package/engine/agentlas-tools.cjs +45 -2
- package/engine/agentlas.cjs +6 -3
- package/engine/agents/builder.cjs +4 -0
- package/engine/architecture.data.json +1 -1
- package/engine/automation/daemon.cjs +36 -3
- package/engine/automation/store.cjs +13 -11
- package/engine/bootstrap-schema.sql +216 -24
- package/engine/cloud/auth.cjs +34 -8
- package/engine/cloud-assets/cas.cjs +27 -1
- package/engine/cloud-assets/package.cjs +126 -0
- package/engine/cloud-assets/upload-scan-catalog.generated.cjs +13 -0
- package/engine/commands/career-graph.cjs +1 -1
- package/engine/commands/connect.cjs +34 -6
- package/engine/commands/graph.cjs +6 -2
- package/engine/commands/index.cjs +4 -1
- package/engine/commands/oberon.cjs +1 -1
- package/engine/commands/one.cjs +307 -0
- package/engine/commands/ontology.cjs +2 -2
- package/engine/commands/plugin.cjs +61 -16
- package/engine/commands/uninstall.cjs +43 -13
- package/engine/commands/update.cjs +25 -4
- package/engine/core/capability-grants.cjs +204 -0
- package/engine/core/desktop-core-fetch.cjs +94 -21
- package/engine/core/desktop-core.cjs +22 -0
- package/engine/experience/build.cjs +8 -0
- package/engine/graph/node-effect.cjs +56 -0
- package/engine/graph/package.cjs +6 -1
- package/engine/graph/vocabulary.generated.cjs +1 -1
- package/engine/hephaestus/local-core.cjs +44 -9
- package/engine/hub/install.cjs +7 -3
- package/engine/hub/plugins.cjs +165 -0
- package/engine/mcp/consent.cjs +140 -12
- package/engine/mcp/index.cjs +1 -0
- package/engine/mcp/plan.cjs +63 -8
- package/engine/memory-cli/curate.cjs +5 -2
- package/engine/oberon/outputs.cjs +10 -3
- package/engine/project/career-graph.cjs +9 -12
- package/engine/project/memory-context.cjs +9 -6
- package/engine/project/ontology.cjs +88 -36
- package/engine/runtimes/acp-driver.cjs +42 -3
- package/engine/sessions/memory-turn.cjs +39 -0
- package/engine/sessions/orchestrator.cjs +53 -4
- package/engine/sessions/session.cjs +28 -2
- package/engine/sessions/store.cjs +48 -12
- package/engine/telegram/connect.cjs +31 -10
- package/engine/ui/commands-catalog.cjs +1 -0
- package/engine/ui/repl.cjs +3 -1
- package/engine/ui/screens.cjs +30 -6
- package/engine/vendor/desktop-core.manifest.json +5 -5
- package/package.json +6 -3
|
@@ -11,7 +11,11 @@ const {
|
|
|
11
11
|
planPluginMcpInstall,
|
|
12
12
|
installPluginMcpRows,
|
|
13
13
|
listHubPlugins,
|
|
14
|
+
planPluginSkillInstall,
|
|
15
|
+
installPluginSkills,
|
|
16
|
+
listInstalledLocalPlugins,
|
|
14
17
|
} = require("../hub/plugins.cjs");
|
|
18
|
+
const { webBaseUrl } = require("../cloud/hub-client.cjs");
|
|
15
19
|
const { terminalProjectCandidateCli, initializedAgentlasProjectPathCli } = require("../project/state.cjs");
|
|
16
20
|
|
|
17
21
|
async function pluginAdd(ctx, slug) {
|
|
@@ -31,43 +35,80 @@ async function pluginAdd(ctx, slug) {
|
|
|
31
35
|
return 1;
|
|
32
36
|
}
|
|
33
37
|
const { rows, refused } = planPluginMcpInstall(slug, manifest);
|
|
38
|
+
// 스킬 번들 절반 — 실콘텐츠(files[])가 실린 스킬은 ~/.agentlas/plugins/<slug>/ 에
|
|
39
|
+
// 파일로 착지한다(오너 결정 2026-08-20: 플러그인 = MCP와 별개의 능력 패키지).
|
|
40
|
+
const skillPlan = planPluginSkillInstall(slug, manifest);
|
|
34
41
|
const docsLink = manifest.docs || manifest.source?.repo || manifest.source?.homepage || null;
|
|
35
|
-
if (!rows.length) {
|
|
36
|
-
const reasonLines =
|
|
42
|
+
if (!rows.length && !skillPlan.skills.length) {
|
|
43
|
+
const reasonLines = [
|
|
44
|
+
...refused.map((item) => ` ✗ ${item.name}: ${item.reason}${item.source ? ` (${item.source})` : ""}`),
|
|
45
|
+
...skillPlan.refused.map((item) => ` ✗ skill ${item.name}: ${item.reason}`),
|
|
46
|
+
...(skillPlan.declaredOnly.length
|
|
47
|
+
? [` ✗ skills declared without content payloads: ${skillPlan.declaredOnly.join(", ")}`]
|
|
48
|
+
: []),
|
|
49
|
+
];
|
|
37
50
|
ctx.err(
|
|
38
51
|
[
|
|
39
|
-
`${slug} ships no machine-connectable MCP endpoint
|
|
52
|
+
`${slug} ships no machine-connectable MCP endpoint and no installable skill payload. Nothing was installed.`,
|
|
40
53
|
...reasonLines,
|
|
41
54
|
docsLink ? ` docs: ${docsLink} (upstream project page — not an MCP endpoint)` : null,
|
|
42
|
-
" When the catalog gains verified connection info for this plugin, re-run: agentlas plugin add " + slug,
|
|
55
|
+
" When the catalog gains verified connection info or skill payloads for this plugin, re-run: agentlas plugin add " + slug,
|
|
43
56
|
].filter(Boolean).join("\n"),
|
|
44
57
|
);
|
|
45
58
|
return 1;
|
|
46
59
|
}
|
|
47
|
-
let installed, reused, needsApproval;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
let installed = 0, reused = 0, needsApproval = [];
|
|
61
|
+
if (rows.length) {
|
|
62
|
+
try {
|
|
63
|
+
({ installed, reused, needsApproval } = installPluginMcpRows(ctx.db(), rows));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
ctx.err(String((e && e.message) || e));
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let skillResult = null;
|
|
70
|
+
if (skillPlan.skills.length) {
|
|
71
|
+
skillResult = installPluginSkills(slug, skillPlan, {
|
|
72
|
+
manifestUrl: `${webBaseUrl()}/api/plugins/${encodeURIComponent(slug)}`,
|
|
73
|
+
meta: { name: manifest.name, family: manifest.family, version: manifest.version },
|
|
74
|
+
});
|
|
75
|
+
if (!skillResult.installed.length && !rows.length) {
|
|
76
|
+
// 스킬만 실린 매니페스트인데 하나도 못 썼다 — 조용한 성공 금지.
|
|
77
|
+
for (const item of skillResult.failed) ctx.err(` ✗ skill ${item.name}: ${item.reason}`);
|
|
78
|
+
ctx.err(`${slug}: no skill could be installed.`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
53
81
|
}
|
|
54
82
|
ctx.out(`${ctx.ui.green("✓")} Plugin installed ${ctx.ui.accent(manifest.slug)} — ${manifest.name}`);
|
|
55
83
|
for (const item of refused) {
|
|
56
84
|
ctx.out(` ⚠ skipped ${item.name}: ${item.reason}${item.source ? ` (${item.source})` : ""}`);
|
|
57
85
|
}
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
if (rows.length) {
|
|
87
|
+
ctx.out(` MCP servers: ${installed} added${reused ? `, ${reused} already present` : ""}`);
|
|
88
|
+
}
|
|
89
|
+
// 데스크탑 hub-plugin-bridge.ts 동형: stdio는 비활성 등록 + 승인 필요 표면화.
|
|
60
90
|
for (const name of needsApproval || []) {
|
|
61
91
|
ctx.out(` ⚠ needs-approval ${name}: local execution requires one-click approval in MCP settings`);
|
|
62
92
|
}
|
|
93
|
+
if (skillResult) {
|
|
94
|
+
ctx.out(` skills installed: ${skillResult.installed.join(", ")} → ${skillResult.dir}`);
|
|
95
|
+
if (!skillResult.verified) {
|
|
96
|
+
ctx.out(ctx.ui.dim(" (no content hash declared — manifest URL recorded as provenance)"));
|
|
97
|
+
}
|
|
98
|
+
for (const item of skillResult.failed) {
|
|
99
|
+
ctx.out(` ⚠ skipped skill ${item.name}: ${item.reason}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (skillPlan.declaredOnly.length) {
|
|
103
|
+
ctx.out(` skills declared (no payload yet): ${skillPlan.declaredOnly.join(", ")}`);
|
|
104
|
+
}
|
|
63
105
|
const authKind = manifest.auth?.kind;
|
|
64
106
|
if (authKind && authKind !== "none") {
|
|
65
107
|
ctx.out(` ⚠ Requires ${authKind} — set credentials before use (agentlas creds).`);
|
|
66
108
|
}
|
|
67
|
-
if (
|
|
68
|
-
ctx.out(
|
|
109
|
+
if (rows.length) {
|
|
110
|
+
ctx.out(ctx.ui.dim(" Only full-access turns wire active stdio servers into the runtime (agentlas mcp)."));
|
|
69
111
|
}
|
|
70
|
-
ctx.out(ctx.ui.dim(" Only full-access turns wire active stdio servers into the runtime (agentlas mcp)."));
|
|
71
112
|
return 0;
|
|
72
113
|
}
|
|
73
114
|
|
|
@@ -83,8 +124,12 @@ async function pluginList(ctx) {
|
|
|
83
124
|
ctx.out("No Hub plugins are available.");
|
|
84
125
|
return 0;
|
|
85
126
|
}
|
|
127
|
+
// 설치 여부는 ~/.agentlas/plugins/<slug>/plugin.json 마커(3채널 공유 규약)로 판정한다.
|
|
128
|
+
const installedSlugs = new Set(listInstalledLocalPlugins().map((entry) => entry.slug.toLowerCase()));
|
|
86
129
|
for (const plugin of plugins.slice(0, 60)) {
|
|
87
|
-
|
|
130
|
+
const slugText = String(plugin.slug || "");
|
|
131
|
+
const installedMark = installedSlugs.has(slugText.toLowerCase()) ? ctx.ui.green(" [installed]") : "";
|
|
132
|
+
ctx.out(`${ctx.ui.accent(slugText.padEnd(32).slice(0, 32))} ${String(plugin.name || "").slice(0, 44)}${installedMark}`);
|
|
88
133
|
}
|
|
89
134
|
ctx.out("");
|
|
90
135
|
ctx.out(ctx.ui.dim("Install: agentlas plugin add <slug>"));
|
|
@@ -9,6 +9,26 @@ const { findAgent } = require("../agents/registry.cjs");
|
|
|
9
9
|
const { routesMap, saveRoutes } = require("../agents/routes.cjs");
|
|
10
10
|
const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 이 DB 에서 에이전트를 지우면 그 대화도 함께 사라지는가.
|
|
14
|
+
*
|
|
15
|
+
* `chats.agent_id` 의 삭제 동작이 답이다. 좌석-세션 이전 스키마는 `CASCADE`(대화도 삭제),
|
|
16
|
+
* 이후는 `SET NULL`(자리만 비고 대화는 보존). 이 CLI 는 데스크탑과 같은 파일을 쓰고 그
|
|
17
|
+
* 파일의 사다리 위치는 기기마다 다르므로, 스키마 번호나 코드가 쓰인 시점이 아니라
|
|
18
|
+
* **열려 있는 파일**에 물어야 한다.
|
|
19
|
+
*/
|
|
20
|
+
function chatDeletionCascadesFromAgent(db) {
|
|
21
|
+
try {
|
|
22
|
+
const rows = db.prepare("SELECT \"table\" AS parent, \"from\" AS child, \"on_delete\" AS onDelete FROM pragma_foreign_key_list('chats')").all();
|
|
23
|
+
const link = rows.find((row) => row.child === "agent_id" && row.parent === "installed_agents");
|
|
24
|
+
// 관계가 없으면 지워도 대화가 따라가지 않는다 — 파괴를 예고하지 않는다.
|
|
25
|
+
return String(link && link.onDelete || "").toUpperCase() === "CASCADE";
|
|
26
|
+
} catch {
|
|
27
|
+
// 물어볼 수 없으면 보수적으로 파괴한다고 본다 — 동의를 한 번 더 묻는 쪽이 안전하다.
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
function run(ctx, args) {
|
|
13
33
|
const ko = ctx.lang === "ko";
|
|
14
34
|
// 동의 플래그를 슬러그보다 앞에 써도 되도록 첫 번째 비플래그 인자를 대상으로 본다.
|
|
@@ -47,17 +67,24 @@ function run(ctx, args) {
|
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
/*
|
|
50
|
-
* 대화 파괴 게이트 —
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* (
|
|
58
|
-
*
|
|
70
|
+
* 대화 파괴 게이트 — **파괴가 실제로 일어나는 스키마에서만.**
|
|
71
|
+
*
|
|
72
|
+
* 예전에는 `chats.agent_id` 가 `ON DELETE CASCADE` 였다. 그래서 에이전트 한 행을 지우면
|
|
73
|
+
* 그 대화와 메시지가 함께 사라졌고, 이 명령은 그 사실을 한 줄도 알리지 않고 `✓ 제거됨`만
|
|
74
|
+
* 찍었다 — 되돌릴 수 없는 삭제가 무고지로 일어나던 자리였고, 그래서 이 게이트가 생겼다.
|
|
75
|
+
*
|
|
76
|
+
* 좌석-세션 이후 그 제약은 `ON DELETE SET NULL` 로 내려갔다. 에이전트를 지우는 것은
|
|
77
|
+
* 이제 **자리를 비우는 일**이고 대화는 그대로 남는다(오너 정본: 봇 삭제 = 자리 비우기).
|
|
78
|
+
* 그런데 이 명령은 계속 "영구 삭제합니다" 라고 경고하고 "삭제됨" 이라고 보고했다 —
|
|
79
|
+
* 지켜지지 않는 약속의 반대, 즉 **일어나지 않은 파괴를 보고하는 거짓말**이다. 사용자는
|
|
80
|
+
* 남아 있는 이력을 잃었다고 믿고, 잃지 않아도 될 동의를 강요받는다.
|
|
81
|
+
*
|
|
82
|
+
* 그래서 문구를 새 스키마에 맞춰 바꿔 쓰지 않는다. 이 CLI 는 데스크탑과 **같은 파일**을
|
|
83
|
+
* 쓰고 그 파일의 스키마는 기기마다 다르다 — 아직 옛 사다리에 있는 기기에서는 파괴가
|
|
84
|
+
* 여전히 사실이다. 판단은 열려 있는 DB 의 외래키에서 읽는다.
|
|
59
85
|
*/
|
|
60
86
|
const consented = args.some((a) => a === "--yes" || a === "-y" || a === "--force");
|
|
87
|
+
const cascades = ctx.tableExists(db, "chats") && chatDeletionCascadesFromAgent(db);
|
|
61
88
|
let chatCount = 0;
|
|
62
89
|
let messageCount = 0;
|
|
63
90
|
if (ctx.tableExists(db, "chats")) {
|
|
@@ -68,7 +95,7 @@ function run(ctx, args) {
|
|
|
68
95
|
).get(agent.id).n;
|
|
69
96
|
}
|
|
70
97
|
}
|
|
71
|
-
if (chatCount && !consented) {
|
|
98
|
+
if (chatCount && cascades && !consented) {
|
|
72
99
|
ctx.err(ko
|
|
73
100
|
? `"${agent.slug}" 제거는 대화 ${chatCount}개와 메시지 ${messageCount}개를 함께 영구 삭제합니다 (데스크탑 앱과 공유하는 DB이며 되돌릴 수 없습니다).\n계속하려면: agentlas uninstall ${agent.slug} --yes`
|
|
74
101
|
: `Uninstalling "${agent.slug}" will also permanently delete ${chatCount} chat(s) and ${messageCount} message(s) (shared with the Desktop app; this cannot be undone).\nRe-run to confirm: agentlas uninstall ${agent.slug} --yes`);
|
|
@@ -89,9 +116,12 @@ function run(ctx, args) {
|
|
|
89
116
|
}
|
|
90
117
|
} catch { /* 라우트 정리는 best-effort */ }
|
|
91
118
|
// 실제로 무엇이 사라졌는지 성공 줄에 남긴다 — 조용한 파괴 금지.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
119
|
+
// 실제로 무엇이 일어났는지만 적는다 — 지웠으면 지웠다고, 남겼으면 남겼다고.
|
|
120
|
+
const cascade = !chatCount
|
|
121
|
+
? ""
|
|
122
|
+
: cascades
|
|
123
|
+
? (ko ? ` (대화 ${chatCount}개 · 메시지 ${messageCount}개 삭제됨)` : ` (deleted ${chatCount} chat(s), ${messageCount} message(s))`)
|
|
124
|
+
: (ko ? ` (대화 ${chatCount}개 · 메시지 ${messageCount}개는 그대로 남습니다 — 자리만 비었습니다)` : ` (kept ${chatCount} chat(s) and ${messageCount} message(s) — the seat is now empty)`);
|
|
95
125
|
ctx.out(`${ctx.ui.green("✓")} ${ko ? "제거됨" : "Uninstalled"}: ${agent.slug}${cascade}`);
|
|
96
126
|
return 0;
|
|
97
127
|
}
|
|
@@ -10,14 +10,35 @@
|
|
|
10
10
|
const { compareSemVer } = require("../semver.cjs");
|
|
11
11
|
const { readVersion } = require("../agentlas-banner.cjs");
|
|
12
12
|
|
|
13
|
-
async function checkNpmLatest({ fetch: fetchImpl } = {}) {
|
|
13
|
+
async function checkNpmLatest({ fetch: fetchImpl, timeoutMs = 8_000 } = {}) {
|
|
14
14
|
const f = fetchImpl || globalThis.fetch;
|
|
15
15
|
const currentVersion = readVersion();
|
|
16
16
|
let latestVersion = null;
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const boundedTimeoutMs = Math.min(60_000, Math.max(10, Number(timeoutMs) || 8_000));
|
|
19
|
+
let timeout;
|
|
17
20
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
// Bound both the connection and body parse. Some injected/custom fetch
|
|
22
|
+
// implementations ignore AbortSignal, so the explicit race is required to
|
|
23
|
+
// keep `agentlas update` from hanging the entire CLI indefinitely.
|
|
24
|
+
const request = Promise.resolve().then(async () => {
|
|
25
|
+
const resp = await f("https://registry.npmjs.org/agentlas/latest", {
|
|
26
|
+
headers: { accept: "application/json" },
|
|
27
|
+
signal: controller.signal,
|
|
28
|
+
});
|
|
29
|
+
return resp.ok ? String((await resp.json()).version || "") : null;
|
|
30
|
+
});
|
|
31
|
+
const expired = new Promise((_, reject) => {
|
|
32
|
+
timeout = setTimeout(() => {
|
|
33
|
+
const error = new Error(`npm update check timed out after ${boundedTimeoutMs}ms`);
|
|
34
|
+
error.code = "AGENTLAS_UPDATE_TIMEOUT";
|
|
35
|
+
try { controller.abort(error); } catch { controller.abort(); }
|
|
36
|
+
reject(error);
|
|
37
|
+
}, boundedTimeoutMs);
|
|
38
|
+
});
|
|
39
|
+
latestVersion = await Promise.race([request, expired]);
|
|
40
|
+
} catch { /* offline/timeout 등 — 호출부에서 안내 */ }
|
|
41
|
+
finally { if (timeout) clearTimeout(timeout); }
|
|
21
42
|
const comparison = latestVersion ? compareSemVer(currentVersion, latestVersion) : null;
|
|
22
43
|
return {
|
|
23
44
|
currentVersion,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* core/capability-grants — 데스크탑이 소유한 통합 능력 승인(capability_grants)을
|
|
4
|
+
* 터미널이 **읽고 쓴다**.
|
|
5
|
+
*
|
|
6
|
+
* 오너 결정(2026-08-20): 승인은 에이전트별·채널별이 아니라 **행동 기준**이고 공유된다.
|
|
7
|
+
* 데스크탑에서 "항상 허용"을 누른 행동은 터미널에서도 다시 묻지 않아야 하고, 데스크탑에서
|
|
8
|
+
* 영구 거부된 행동은 터미널의 어떤 권한 등급으로도 뚫려서는 안 된다. 표는 하나(공유
|
|
9
|
+
* agentlas.sqlite, v98)이므로 규칙도 하나다 — 터미널이 자기 사본을 들면 그 순간 갈라진다.
|
|
10
|
+
*
|
|
11
|
+
* 정본 구현: agentlas_desktop/electron/store/capability-grants.ts.
|
|
12
|
+
* 이 파일은 그 **판정 규칙을 그대로** 옮긴 것이다(키 후보 3종, 스코프 구체성 내림차순,
|
|
13
|
+
* 같은 스코프 안에서 deny > allow, 프리픽스 패턴). 규칙이 갈리면 같은 머신의 두 제품이
|
|
14
|
+
* 같은 행동에 다른 답을 준다 — 바꿀 때는 반드시 양쪽을 함께 바꿀 것.
|
|
15
|
+
*
|
|
16
|
+
* ★터미널은 마이그레이션 follower 다(core/db.cjs 참조). 표가 없으면 **만들지 않는다** —
|
|
17
|
+
* 조용히 기존 동작(터미널 자체 동의/권한 규칙)으로 폴백하고, 왜 폴백했는지 사유를
|
|
18
|
+
* 호출부에 돌려준다. 부재를 성공으로 위장하지 않는다.
|
|
19
|
+
*/
|
|
20
|
+
const { tableExists } = require("./db.cjs");
|
|
21
|
+
|
|
22
|
+
const TABLE = "capability_grants";
|
|
23
|
+
|
|
24
|
+
/** 표가 없는(구버전) 공유 DB 에서 호출부가 사용자에게 그대로 보여줄 수 있는 사유. */
|
|
25
|
+
const UNAVAILABLE_REASON =
|
|
26
|
+
"shared database has no capability_grants table (older schema) — " +
|
|
27
|
+
"Terminal fell back to its own consent/permission rules. " +
|
|
28
|
+
"Launch the Agentlas Desktop app once to migrate the shared store.";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 능력 클래스 — "항상 허용"이 영구 부여하는 단위.
|
|
32
|
+
* 정본: desktop electron/runtime/tool-approval.ts capabilityClassFor().
|
|
33
|
+
*/
|
|
34
|
+
function capabilityClassFor(kind, tool) {
|
|
35
|
+
if (kind === "execute" || tool === "bash") return "execute";
|
|
36
|
+
if (kind === "delete") return "delete";
|
|
37
|
+
if (kind === "edit") return "edit";
|
|
38
|
+
if (kind === "fetch" || kind === "network") return "network";
|
|
39
|
+
return "other";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* "항상 허용"이 저장할 인자 패턴 — Claude Code 프리픽스 규칙과 같은 일반화.
|
|
44
|
+
* 정본: desktop tool-approval.ts generalizeDetailPattern().
|
|
45
|
+
*/
|
|
46
|
+
function generalizeDetailPattern(detail) {
|
|
47
|
+
if (!detail) return null;
|
|
48
|
+
const tokens = String(detail).trim().split(/\s+/);
|
|
49
|
+
if (tokens.length <= 2) return String(detail).trim();
|
|
50
|
+
return `${tokens[0]} ${tokens[1]} *`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** "git push *" 스타일 프리픽스 패턴. NULL/빈 패턴은 인자 무관 매치. */
|
|
54
|
+
function patternMatches(pattern, detail) {
|
|
55
|
+
if (pattern === null || pattern === undefined || pattern === "") return true;
|
|
56
|
+
if (!detail) return false;
|
|
57
|
+
const text = String(pattern);
|
|
58
|
+
if (text.endsWith("*")) return String(detail).startsWith(text.slice(0, -1).trimEnd());
|
|
59
|
+
return String(detail) === text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 구체성 내림차순 — 먼저 맞은 스코프가 이긴다(chat > agent > global). */
|
|
63
|
+
function scopesFor(query) {
|
|
64
|
+
const scopes = [];
|
|
65
|
+
if (query && query.chatId) scopes.push(`chat:${query.chatId}`);
|
|
66
|
+
if (query && query.agentId) scopes.push(`agent:${query.agentId}`);
|
|
67
|
+
scopes.push("global");
|
|
68
|
+
return scopes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 표가 실제로 있는가. 만들지 않는다 — 확인만 한다. */
|
|
72
|
+
function capabilityGrantsAvailable(db) {
|
|
73
|
+
if (!db) return false;
|
|
74
|
+
return tableExists(db, TABLE);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 저장된 규칙으로 결정을 찾는다.
|
|
79
|
+
* @returns {{decision: "allow"|"deny"|null, available: boolean, reason: string|null}}
|
|
80
|
+
* decision === null 은 "규칙 없음" — 호출부가 기존 동작(질문/권한 등급)으로 간다.
|
|
81
|
+
*/
|
|
82
|
+
function readCapabilityDecision(db, query) {
|
|
83
|
+
if (!capabilityGrantsAvailable(db)) {
|
|
84
|
+
return { decision: null, available: false, reason: UNAVAILABLE_REASON };
|
|
85
|
+
}
|
|
86
|
+
const capability = String((query && query.capability) || "other");
|
|
87
|
+
const keys = [capability];
|
|
88
|
+
if (query && query.tool) keys.push(`tool:${query.tool}`);
|
|
89
|
+
keys.push("*");
|
|
90
|
+
let rows;
|
|
91
|
+
try {
|
|
92
|
+
rows = db
|
|
93
|
+
.prepare(
|
|
94
|
+
`SELECT capability, pattern, decision, scope FROM ${TABLE} ` +
|
|
95
|
+
`WHERE capability IN (${keys.map(() => "?").join(",")})`,
|
|
96
|
+
)
|
|
97
|
+
.all(...keys);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// 표는 있는데 열 모양이 우리가 아는 것과 다르다 — 추측하지 않고 폴백한다.
|
|
100
|
+
return {
|
|
101
|
+
decision: null,
|
|
102
|
+
available: false,
|
|
103
|
+
reason: `capability_grants is present but unreadable (${(error && error.message) || error}) — Terminal fell back to its own rules.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (!rows || rows.length === 0) return { decision: null, available: true, reason: null };
|
|
107
|
+
for (const scope of scopesFor(query)) {
|
|
108
|
+
const inScope = rows.filter(
|
|
109
|
+
(row) => row.scope === scope && patternMatches(row.pattern, query && query.detail),
|
|
110
|
+
);
|
|
111
|
+
if (inScope.length === 0) continue;
|
|
112
|
+
if (inScope.some((row) => row.decision === "deny")) return { decision: "deny", available: true, reason: null };
|
|
113
|
+
return { decision: "allow", available: true, reason: null };
|
|
114
|
+
}
|
|
115
|
+
return { decision: null, available: true, reason: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 규칙을 영속한다(같은 (capability, pattern, scope)는 마지막 결정으로 덮는다).
|
|
120
|
+
* 데스크탑의 recordCapabilityGrant 와 **같은 행**을 쓴다 — 터미널에서 고른
|
|
121
|
+
* "항상 허용"이 데스크탑에도 그대로 보인다.
|
|
122
|
+
*
|
|
123
|
+
* 표가 없으면 만들지 않고 정직하게 실패를 알린다(ok:false + reason).
|
|
124
|
+
*/
|
|
125
|
+
function recordCapabilityGrant(db, input) {
|
|
126
|
+
if (!capabilityGrantsAvailable(db)) {
|
|
127
|
+
return { ok: false, available: false, reason: UNAVAILABLE_REASON };
|
|
128
|
+
}
|
|
129
|
+
const decision = input && input.decision === "deny" ? "deny" : "allow";
|
|
130
|
+
try {
|
|
131
|
+
db.prepare(
|
|
132
|
+
`INSERT INTO ${TABLE} (capability, pattern, decision, scope, source, created_at)
|
|
133
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
134
|
+
ON CONFLICT(capability, pattern, scope)
|
|
135
|
+
DO UPDATE SET decision = excluded.decision, source = excluded.source, created_at = excluded.created_at`,
|
|
136
|
+
).run(
|
|
137
|
+
String((input && input.capability) || "other"),
|
|
138
|
+
input && input.pattern != null && input.pattern !== "" ? String(input.pattern) : null,
|
|
139
|
+
decision,
|
|
140
|
+
String((input && input.scope) || "global"),
|
|
141
|
+
String((input && input.source) || "terminal"),
|
|
142
|
+
new Date().toISOString(),
|
|
143
|
+
);
|
|
144
|
+
return { ok: true, available: true, reason: null };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
available: true,
|
|
149
|
+
reason: `capability_grants write failed: ${(error && error.message) || error}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 조회용(도구·게이트가 사람에게 보여줄 때). 표가 없으면 빈 배열 + available:false. */
|
|
155
|
+
function listCapabilityGrants(db, scope) {
|
|
156
|
+
if (!capabilityGrantsAvailable(db)) return { rows: [], available: false, reason: UNAVAILABLE_REASON };
|
|
157
|
+
try {
|
|
158
|
+
const rows = scope
|
|
159
|
+
? db.prepare(`SELECT * FROM ${TABLE} WHERE scope = ? ORDER BY id`).all(String(scope))
|
|
160
|
+
: db.prepare(`SELECT * FROM ${TABLE} ORDER BY id`).all();
|
|
161
|
+
return {
|
|
162
|
+
rows: (rows || []).map((row) => ({
|
|
163
|
+
id: Number(row.id),
|
|
164
|
+
capability: String(row.capability),
|
|
165
|
+
pattern: row.pattern == null ? null : String(row.pattern),
|
|
166
|
+
decision: row.decision === "deny" ? "deny" : "allow",
|
|
167
|
+
scope: String(row.scope),
|
|
168
|
+
source: String(row.source == null ? "chip" : row.source),
|
|
169
|
+
createdAt: String(row.created_at == null ? "" : row.created_at),
|
|
170
|
+
})),
|
|
171
|
+
available: true,
|
|
172
|
+
reason: null,
|
|
173
|
+
};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { rows: [], available: false, reason: `capability_grants read failed: ${(error && error.message) || error}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 대화 전체 통과("항상 승인" 대화) — 데스크탑 isChatAlwaysApproved 와 같은 행. */
|
|
180
|
+
function isChatAlwaysApproved(db, chatId) {
|
|
181
|
+
if (!capabilityGrantsAvailable(db) || !chatId) return false;
|
|
182
|
+
try {
|
|
183
|
+
const row = db
|
|
184
|
+
.prepare(`SELECT decision FROM ${TABLE} WHERE capability = '*' AND scope = ? ORDER BY id DESC LIMIT 1`)
|
|
185
|
+
.get(`chat:${chatId}`);
|
|
186
|
+
return !!row && row.decision === "allow";
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
TABLE,
|
|
194
|
+
UNAVAILABLE_REASON,
|
|
195
|
+
capabilityClassFor,
|
|
196
|
+
generalizeDetailPattern,
|
|
197
|
+
patternMatches,
|
|
198
|
+
scopesFor,
|
|
199
|
+
capabilityGrantsAvailable,
|
|
200
|
+
readCapabilityDecision,
|
|
201
|
+
recordCapabilityGrant,
|
|
202
|
+
listCapabilityGrants,
|
|
203
|
+
isChatAlwaysApproved,
|
|
204
|
+
};
|
|
@@ -27,7 +27,17 @@ function readManifest() {
|
|
|
27
27
|
try { return JSON.parse(fs.readFileSync(manifestPath(), "utf8")); } catch { return null; }
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function
|
|
30
|
+
function cacheRoot() { return path.join(userDataDir(), "desktop-core-cache"); }
|
|
31
|
+
function normalizedCacheVersion(version) {
|
|
32
|
+
const value = String(version ?? "").trim();
|
|
33
|
+
if (!value || value === "." || value === "..") return null;
|
|
34
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ? value : null;
|
|
35
|
+
}
|
|
36
|
+
function cacheDir(version) {
|
|
37
|
+
const normalized = normalizedCacheVersion(version);
|
|
38
|
+
if (!normalized) throw new TypeError("Desktop core manifest has an unsafe cache version");
|
|
39
|
+
return path.join(cacheRoot(), normalized);
|
|
40
|
+
}
|
|
31
41
|
function cacheDistDir(version) { return path.join(cacheDir(version), "dist"); }
|
|
32
42
|
|
|
33
43
|
function sha256File(file) {
|
|
@@ -38,13 +48,48 @@ function sha256File(file) {
|
|
|
38
48
|
|
|
39
49
|
/** 이미 캐시에 온전히 풀려 있으면 그 dist 경로, 아니면 null. */
|
|
40
50
|
function cachedCoreRoot(manifest = readManifest()) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
51
|
+
const version = normalizedCacheVersion(manifest?.version);
|
|
52
|
+
if (!version || !manifest?.sha256) return null;
|
|
53
|
+
const dist = cacheDistDir(version);
|
|
54
|
+
const marker = path.join(cacheDir(version), ".complete");
|
|
55
|
+
if (!fs.existsSync(path.join(dist, "electron", "workflow", "run-graph.js"))) return null;
|
|
56
|
+
try {
|
|
57
|
+
const completed = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
58
|
+
if (
|
|
59
|
+
completed?.schemaVersion === 2
|
|
60
|
+
&& String(completed.version) === version
|
|
61
|
+
&& completed.sha256 === manifest.sha256
|
|
62
|
+
) return dist;
|
|
63
|
+
} catch {
|
|
64
|
+
// Timestamp-only and malformed markers predate the content-bound cache
|
|
65
|
+
// contract. They must be refreshed instead of trusted as executable code.
|
|
66
|
+
}
|
|
45
67
|
return null;
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Remove only cache entries that are recognizably old engine downloads.
|
|
72
|
+
* Unknown files under the dedicated root are preserved: cleanup must never
|
|
73
|
+
* widen from a versioned engine cache into arbitrary user data.
|
|
74
|
+
*/
|
|
75
|
+
function pruneStaleCaches(keepVersion) {
|
|
76
|
+
const root = cacheRoot();
|
|
77
|
+
let removed = 0;
|
|
78
|
+
let entries = [];
|
|
79
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return removed; }
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.isDirectory() || entry.name === String(keepVersion) || entry.name.includes(".partial-")) continue;
|
|
82
|
+
const dir = path.join(root, entry.name);
|
|
83
|
+
const recognizable = fs.existsSync(path.join(dir, ".complete"))
|
|
84
|
+
|| fs.existsSync(path.join(dir, "desktop-core.tar.gz"))
|
|
85
|
+
|| fs.existsSync(path.join(dir, "dist", "electron", "workflow", "run-graph.js"));
|
|
86
|
+
if (!recognizable) continue;
|
|
87
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
removed += 1;
|
|
89
|
+
}
|
|
90
|
+
return removed;
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
/**
|
|
49
94
|
* 매니페스트가 가리키는 데스크탑 코어를 내려받아 캐시에 푼다.
|
|
50
95
|
* onNotice(text): 사용자에게 보여줄 안내(무엇을·왜 받는지) — 조용히 받지 않는다.
|
|
@@ -52,47 +97,75 @@ function cachedCoreRoot(manifest = readManifest()) {
|
|
|
52
97
|
*/
|
|
53
98
|
async function fetchDesktopCore({ onNotice } = {}) {
|
|
54
99
|
const manifest = readManifest();
|
|
55
|
-
|
|
100
|
+
const version = normalizedCacheVersion(manifest?.version);
|
|
101
|
+
if (!manifest || !version || !manifest.url || !manifest.sha256) return null;
|
|
56
102
|
|
|
57
|
-
const existing = cachedCoreRoot(manifest);
|
|
103
|
+
const existing = cachedCoreRoot({ ...manifest, version });
|
|
58
104
|
if (existing) return existing;
|
|
59
105
|
|
|
60
106
|
const say = (t) => { if (typeof onNotice === "function") onNotice(t); };
|
|
61
107
|
say(`Downloading the graph-execution engine (${manifest.sizeBytes ? Math.round(manifest.sizeBytes / 1024 / 1024) + " MB" : "one-time"}) from ${manifest.url} …`);
|
|
62
108
|
|
|
63
|
-
const dir = cacheDir(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
109
|
+
const dir = cacheDir(version);
|
|
110
|
+
const partialDir = path.join(
|
|
111
|
+
cacheRoot(),
|
|
112
|
+
`${version}.partial-${process.pid}-${crypto.randomBytes(6).toString("hex")}`,
|
|
113
|
+
);
|
|
114
|
+
fs.mkdirSync(partialDir, { recursive: true });
|
|
115
|
+
const tarPath = path.join(partialDir, "desktop-core.tar.gz");
|
|
67
116
|
|
|
68
|
-
|
|
69
|
-
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetch(manifest.url);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
say(`Download failed: ${error?.message || error}`);
|
|
122
|
+
fs.rmSync(partialDir, { recursive: true, force: true });
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
say(`Download failed: HTTP ${res.status}`);
|
|
127
|
+
fs.rmSync(partialDir, { recursive: true, force: true });
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
70
130
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
71
131
|
fs.writeFileSync(tarPath, buf);
|
|
72
132
|
|
|
73
133
|
const digest = sha256File(tarPath);
|
|
74
134
|
if (digest !== manifest.sha256) {
|
|
75
135
|
say(`Checksum mismatch (expected ${manifest.sha256.slice(0, 12)}…, got ${digest.slice(0, 12)}…) — refusing to use it.`);
|
|
76
|
-
fs.rmSync(
|
|
136
|
+
fs.rmSync(partialDir, { recursive: true, force: true });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (Number.isFinite(Number(manifest.sizeBytes)) && Number(manifest.sizeBytes) !== buf.length) {
|
|
140
|
+
say(`Size mismatch (expected ${manifest.sizeBytes} bytes, got ${buf.length}) — refusing to use it.`);
|
|
141
|
+
fs.rmSync(partialDir, { recursive: true, force: true });
|
|
77
142
|
return null;
|
|
78
143
|
}
|
|
79
144
|
|
|
80
|
-
const extract = spawnSync("tar", ["-xzf", tarPath, "-C",
|
|
145
|
+
const extract = spawnSync("tar", ["-xzf", tarPath, "-C", partialDir], { encoding: "utf8" });
|
|
81
146
|
if (extract.status !== 0) {
|
|
82
147
|
say(`Extraction failed: ${extract.stderr || extract.error || "unknown error"}`);
|
|
83
|
-
fs.rmSync(
|
|
148
|
+
fs.rmSync(partialDir, { recursive: true, force: true });
|
|
84
149
|
return null;
|
|
85
150
|
}
|
|
86
151
|
fs.rmSync(tarPath);
|
|
87
152
|
|
|
88
|
-
if (!fs.existsSync(path.join(
|
|
153
|
+
if (!fs.existsSync(path.join(partialDir, "dist", "electron", "workflow", "run-graph.js"))) {
|
|
89
154
|
say("Downloaded archive did not contain the expected engine files.");
|
|
90
|
-
fs.rmSync(
|
|
155
|
+
fs.rmSync(partialDir, { recursive: true, force: true });
|
|
91
156
|
return null;
|
|
92
157
|
}
|
|
93
|
-
fs.writeFileSync(path.join(
|
|
158
|
+
fs.writeFileSync(path.join(partialDir, ".complete"), JSON.stringify({
|
|
159
|
+
schemaVersion: 2,
|
|
160
|
+
version,
|
|
161
|
+
sha256: manifest.sha256,
|
|
162
|
+
completedAt: new Date().toISOString(),
|
|
163
|
+
}) + "\n");
|
|
164
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
fs.renameSync(partialDir, dir);
|
|
166
|
+
pruneStaleCaches(version);
|
|
94
167
|
say("Engine ready.");
|
|
95
|
-
return cacheDistDir(
|
|
168
|
+
return cacheDistDir(version);
|
|
96
169
|
}
|
|
97
170
|
|
|
98
|
-
module.exports = { readManifest, cachedCoreRoot, fetchDesktopCore, cacheDistDir };
|
|
171
|
+
module.exports = { readManifest, cachedCoreRoot, fetchDesktopCore, cacheDistDir, pruneStaleCaches };
|
|
@@ -249,6 +249,28 @@ function loadDesktopCore(options = {}) {
|
|
|
249
249
|
try { return require(path.join(root, "shared", "graph-execution-digest.js")).graphExecutionDigest; }
|
|
250
250
|
catch { return null; }
|
|
251
251
|
})(),
|
|
252
|
+
/*
|
|
253
|
+
* ★"이 노드가 바깥을 바꾸나" 는 판정이 **한 곳**에서만 나와야 한다. 벤더에는 이미
|
|
254
|
+
* shared/graph-node-protocol.js 가 실려 있었는데 이 표면에 없어서, 터미널은
|
|
255
|
+
* 부를 수가 없었고 그래서 자기 사본을 들고 있었다(engine/graph/package.cjs,
|
|
256
|
+
* engine/commands/graph.cjs). 사본은 게으름이 아니라 **정본에 못 닿아서** 생긴다.
|
|
257
|
+
* 옛 코어를 쓰는 동안에는 없을 수 있으므로 정직하게 null 로 둔다 — 부르는 쪽이
|
|
258
|
+
* 없으면 자기 판단을 쓰되, 그 사실이 보이게.
|
|
259
|
+
*/
|
|
260
|
+
nodeEffectJudgments: (() => {
|
|
261
|
+
try {
|
|
262
|
+
const mod = require(path.join(root, "shared", "graph-node-protocol.js"));
|
|
263
|
+
return typeof mod.nodeCouldHaveActedOutside === "function" ? {
|
|
264
|
+
resolveNodeEffect: mod.resolveNodeEffect,
|
|
265
|
+
nodeDeclaresOutwardEffect: mod.nodeDeclaresOutwardEffect,
|
|
266
|
+
nodeCouldHaveActedOutside: mod.nodeCouldHaveActedOutside,
|
|
267
|
+
} : null;
|
|
268
|
+
} catch { return null; }
|
|
269
|
+
})(),
|
|
270
|
+
couldHaveChangedTheOutsideWorld: (() => {
|
|
271
|
+
try { return require(path.join(root, "shared", "tool-activity.js")).couldHaveChangedTheOutsideWorld; }
|
|
272
|
+
catch { return null; }
|
|
273
|
+
})(),
|
|
252
274
|
graphFailureOf: kernel.graphFailureOf,
|
|
253
275
|
planGraphLoops: kernel.planGraphLoops,
|
|
254
276
|
/*
|