agentlas 0.9.10 → 1.0.2
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 +106 -0
- package/README.md +319 -341
- package/bin/agentlas.cjs +32 -9
- package/engine/agentlas-banner.cjs +24 -3
- package/engine/agentlas-composer.cjs +239 -31
- package/engine/agentlas-config.cjs +103 -7
- package/engine/agentlas-core-harness.cjs +48 -4
- package/engine/agentlas-evolution.cjs +3 -4
- package/engine/agentlas-i18n.cjs +88 -32
- package/engine/agentlas-input.cjs +234 -18
- package/engine/agentlas-memory-governance.cjs +3 -5
- package/engine/agentlas-memory-import.cjs +3 -3
- package/engine/agentlas-native-host.cjs +200 -9
- package/engine/agentlas-onboard.cjs +112 -20
- package/engine/agentlas-permissions.cjs +14 -7
- package/engine/agentlas-sqlite-policy.cjs +34 -0
- package/engine/agentlas-ui.cjs +93 -42
- package/engine/agentlas-workforce.cjs +472 -58
- package/engine/agentlas-workload-routing.cjs +8 -3
- package/engine/agentlas.cjs +140 -12367
- package/engine/agents/files.cjs +61 -0
- package/engine/agents/import-local.cjs +237 -0
- package/engine/agents/registry.cjs +158 -0
- package/engine/agents/router.cjs +565 -0
- package/engine/agents/routes.cjs +43 -0
- package/engine/architecture.data.json +2 -2
- package/engine/automation/daemon.cjs +335 -0
- package/engine/automation/schedule.cjs +181 -0
- package/engine/automation/store.cjs +209 -0
- package/engine/cloud/auth.cjs +279 -0
- package/engine/cloud/hub-client.cjs +239 -0
- package/engine/cloud-assets/cargo.cjs +49 -0
- package/engine/cloud-assets/cas.cjs +235 -0
- package/engine/cloud-assets/commands.cjs +273 -0
- package/engine/cloud-assets/package.cjs +936 -0
- package/engine/cloud-assets/restore.cjs +172 -0
- package/engine/cloud-assets/state.cjs +268 -0
- package/engine/commands/automation.cjs +195 -0
- package/engine/commands/billing.cjs +96 -0
- package/engine/commands/browser.cjs +20 -0
- package/engine/commands/build.cjs +33 -0
- package/engine/commands/call.cjs +24 -0
- package/engine/commands/career-graph.cjs +51 -0
- package/engine/commands/cd.cjs +22 -0
- package/engine/commands/chat.cjs +12 -0
- package/engine/commands/chats.cjs +28 -0
- package/engine/commands/cloud.cjs +17 -0
- package/engine/commands/connect.cjs +20 -0
- package/engine/commands/context.cjs +66 -0
- package/engine/commands/creds.cjs +203 -0
- package/engine/commands/doctor.cjs +68 -0
- package/engine/commands/env.cjs +33 -0
- package/engine/commands/evolve.cjs +23 -0
- package/engine/commands/experience.cjs +34 -0
- package/engine/commands/film.cjs +8 -0
- package/engine/commands/firm.cjs +115 -0
- package/engine/commands/help.cjs +65 -0
- package/engine/commands/hep.cjs +23 -0
- package/engine/commands/import.cjs +35 -0
- package/engine/commands/index.cjs +136 -0
- package/engine/commands/install.cjs +27 -0
- package/engine/commands/journal.cjs +31 -0
- package/engine/commands/legacy-network.cjs +29 -0
- package/engine/commands/list.cjs +49 -0
- package/engine/commands/login.cjs +68 -0
- package/engine/commands/logout.cjs +28 -0
- package/engine/commands/mcp.cjs +91 -0
- package/engine/commands/memory.cjs +21 -0
- package/engine/commands/multimodal.cjs +91 -0
- package/engine/commands/native.cjs +34 -0
- package/engine/commands/netadmin.cjs +31 -0
- package/engine/commands/oberon.cjs +70 -0
- package/engine/commands/ontology.cjs +24 -0
- package/engine/commands/open.cjs +48 -0
- package/engine/commands/plugin.cjs +101 -0
- package/engine/commands/project.cjs +47 -0
- package/engine/commands/research.cjs +36 -0
- package/engine/commands/route.cjs +37 -0
- package/engine/commands/run.cjs +149 -0
- package/engine/commands/search.cjs +55 -0
- package/engine/commands/setup.cjs +45 -0
- package/engine/commands/storm.cjs +75 -0
- package/engine/commands/swarm.cjs +75 -0
- package/engine/commands/telegram.cjs +32 -0
- package/engine/commands/uninstall.cjs +68 -0
- package/engine/commands/update.cjs +54 -0
- package/engine/commands/upload.cjs +18 -0
- package/engine/commands/usage.cjs +35 -0
- package/engine/commands/variant.cjs +25 -0
- package/engine/commands/version.cjs +9 -0
- package/engine/commands/whoami.cjs +38 -0
- package/engine/commands/workforce.cjs +103 -0
- package/engine/core/db.cjs +160 -0
- package/engine/core/paths.cjs +35 -0
- package/engine/experience/build.cjs +181 -0
- package/engine/experience/intents.cjs +492 -0
- package/engine/experience/runtime.cjs +242 -0
- package/engine/experience/variant.cjs +196 -0
- package/engine/firms/orchestrate.cjs +333 -0
- package/engine/hephaestus/runtime.cjs +697 -0
- package/engine/hub/install.cjs +872 -0
- package/engine/hub/plugins.cjs +213 -0
- package/engine/mcp/consent.cjs +289 -0
- package/engine/mcp/contract.cjs +202 -0
- package/engine/mcp/index.cjs +43 -0
- package/engine/mcp/inventory.cjs +322 -0
- package/engine/mcp/plan.cjs +286 -0
- package/engine/mcp/probe.cjs +151 -0
- package/engine/memory-cli/curate.cjs +163 -0
- package/engine/oberon/common.cjs +69 -0
- package/engine/oberon/manifest.cjs +164 -0
- package/engine/oberon/outputs.cjs +70 -0
- package/engine/oberon/render.cjs +164 -0
- package/engine/project/career-graph.cjs +249 -0
- package/engine/project/credentials.cjs +262 -0
- package/engine/project/env-file.cjs +46 -0
- package/engine/project/index.cjs +27 -0
- package/engine/project/memory-context.cjs +453 -0
- package/engine/project/ontology.cjs +467 -0
- package/engine/project/paths.cjs +39 -0
- package/engine/project/seed.cjs +200 -0
- package/engine/project/state.cjs +403 -0
- package/engine/project/super-ontology-seed.json +3288 -0
- package/engine/runtimes/detect.cjs +54 -0
- package/engine/runtimes/overrides.cjs +139 -0
- package/engine/runtimes/resolve.cjs +64 -0
- package/engine/sessions/apply-fences.cjs +188 -0
- package/engine/sessions/fences.cjs +362 -0
- package/engine/sessions/orchestrator.cjs +170 -0
- package/engine/sessions/prompt.cjs +212 -0
- package/engine/sessions/session.cjs +245 -0
- package/engine/sessions/sink.cjs +54 -0
- package/engine/sessions/store.cjs +79 -0
- package/engine/storm/deps.cjs +88 -0
- package/engine/storm/storm.cjs +218 -0
- package/engine/storm/swarm.cjs +422 -0
- package/engine/ui/palette.cjs +105 -0
- package/engine/ui/renderer.cjs +85 -0
- package/engine/ui/repl.cjs +444 -0
- package/engine/workforce/capture.cjs +701 -0
- package/engine/workforce/deps.cjs +472 -0
- package/package.json +2 -6
- package/engine/agentlas-experience-mcp.cjs +0 -1709
- package/engine/agentlas-parity.cjs +0 -1499
- package/engine/agentlas-repl.cjs +0 -1780
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* hub/plugins — Hub 플러그인 매니페스트 조회 + MCP 행 정규화 + 멱등 설치.
|
|
4
|
+
*
|
|
5
|
+
* 배경(v1 주석 승계): 서버는 처음부터 준비돼 있었다 — /api/plugins/<slug>가
|
|
6
|
+
* agentlas.plugin/v1 매니페스트를 주고, 그 라우트 주석이 이 CLI
|
|
7
|
+
* (`agentlas plugin add <slug>`)를 소비자로 지목한다. 그런데 이 명령이 구현된 적이
|
|
8
|
+
* 없어서, 카탈로그 146개가 전부 "존재하지 않는 설치 명령"을 광고하고 있었다.
|
|
9
|
+
* (`agentlas install`은 marketplace.get_manifest{kind:"agent"} 고정이라 플러그인엔 안 먹는다.)
|
|
10
|
+
*
|
|
11
|
+
* 이 파일의 transport 정규화 규칙은 런타임 전멸 사고들의 근본수리다 — 약화 금지:
|
|
12
|
+
* - 2026-07-23: 레포/홈페이지 HTML URL이 transport:"http" MCP 행으로 등록되어
|
|
13
|
+
* "절대 연결될 수 없는 MCP 서버"가 생기던 사고.
|
|
14
|
+
* - codex config.toml: 원격은 URL, stdio는 실행 커맨드. 둘을 섞으면 스키마 위반으로
|
|
15
|
+
* 런타임이 통째로 죽는다(Runtime Doctor가 반복해서 잡던 사고 계열).
|
|
16
|
+
*/
|
|
17
|
+
const crypto = require("node:crypto");
|
|
18
|
+
const { callHubTool, fetchHub, parseHubJson, webBaseUrl } = require("../cloud/hub-client.cjs");
|
|
19
|
+
|
|
20
|
+
// 레포/홈페이지 HTML 페이지는 문서지 MCP 연결이 아니다. 이 URL들을 transport:"http"로
|
|
21
|
+
// 등록하면 "절대 연결될 수 없는 MCP 서버"가 생긴다(2026-07-23 근본수리 계열).
|
|
22
|
+
const PLUGIN_CODE_HOSTING_HTML_RE = /^https?:\/\/(www\.)?(github\.com|gitlab\.com|bitbucket\.org)\//i;
|
|
23
|
+
|
|
24
|
+
// 데스크탑 hub-plugin-bridge.ts:38 REPO_PAGE_HOSTS 동형 — 저장소/패키지 HTML 페이지
|
|
25
|
+
// 호스트. 데스크탑은 npm/pypi 패키지 페이지도 엔드포인트가 아니라고 본다.
|
|
26
|
+
const PLUGIN_REPO_PAGE_HOSTS = new Set([
|
|
27
|
+
"github.com",
|
|
28
|
+
"www.github.com",
|
|
29
|
+
"gitlab.com",
|
|
30
|
+
"www.gitlab.com",
|
|
31
|
+
"bitbucket.org",
|
|
32
|
+
"www.bitbucket.org",
|
|
33
|
+
"npmjs.com",
|
|
34
|
+
"www.npmjs.com",
|
|
35
|
+
"pypi.org",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 데스크탑 isLikelyRemoteMcpEndpoint 동형 (electron/mcp-tools/hub-plugin-bridge.ts:61):
|
|
40
|
+
* https 필수, 저장소/패키지 HTML 페이지 호스트는 거부. v1 터미널은 http://도 원격
|
|
41
|
+
* 엔드포인트로 수용했지만 현 데스크탑 모델은 평문 원격 MCP 등록을 허용하지 않는다.
|
|
42
|
+
*/
|
|
43
|
+
function pluginIsLikelyRemoteMcpEndpoint(rawUrl) {
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(String(rawUrl || ""));
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (url.protocol !== "https:") return false;
|
|
51
|
+
if (PLUGIN_REPO_PAGE_HOSTS.has(url.hostname.toLowerCase())) return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Hub 플러그인 매니페스트 조회. 404 → null, 스키마 불일치/HTTP 오류 → throw. */
|
|
56
|
+
async function fetchPluginManifest(slug, { fetch: fetchImpl } = {}) {
|
|
57
|
+
const resp = await fetchHub(`${webBaseUrl()}/api/plugins/${encodeURIComponent(slug)}`, {
|
|
58
|
+
headers: { accept: "application/json" },
|
|
59
|
+
}, { fetch: fetchImpl });
|
|
60
|
+
if (resp.status === 404) return null;
|
|
61
|
+
if (!resp.ok) throw new Error(`plugin lookup failed with HTTP ${resp.status}`);
|
|
62
|
+
const manifest = parseHubJson(resp, "plugin manifest");
|
|
63
|
+
if (!manifest || manifest.schema !== "agentlas.plugin/v1") {
|
|
64
|
+
throw new Error(`Unexpected plugin manifest schema for ${slug}.`);
|
|
65
|
+
}
|
|
66
|
+
return manifest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 휴리스틱: 명시적 transport 선언이 없는 레거시 source URL이 진짜 MCP 엔드포인트로 보이는가.
|
|
71
|
+
* 데스크탑 hub-plugin-bridge.ts:94 동형 — https 엔드포인트이면서 경로가 명시적으로
|
|
72
|
+
* MCP(/mcp, /sse)를 가리킬 때만 신뢰한다. v1의 "mcp.* 호스트면 경로 없이 수용" 완화는
|
|
73
|
+
* 데스크탑 모델에 없다(호스트명만으로 엔드포인트를 추정하지 않는다).
|
|
74
|
+
*/
|
|
75
|
+
function pluginLooksLikeMcpEndpoint(rawUrl) {
|
|
76
|
+
if (!pluginIsLikelyRemoteMcpEndpoint(rawUrl)) return false;
|
|
77
|
+
return /\/(mcp|sse)(?:$|[/?#])/.test(String(rawUrl));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 매니페스트의 mcp[] 항목을 mcp_servers 행으로 정규화. stdio(command)와 remote(url)를 구분한다.
|
|
82
|
+
* 반환: { row } | { refused: { name, source, reason } } | null(빈 항목).
|
|
83
|
+
*
|
|
84
|
+
* 규칙(근본수리): 레포 URL은 어떤 경우에도 transport:"http" 행으로 쓰이지 않는다.
|
|
85
|
+
* - transport:"stdio" + command 명시 → stdio 행 (mcp_servers는 command/args_json을 이미 지원).
|
|
86
|
+
* - transport:"http" + url 명시 → http 행. 단 코드호스팅 HTML 페이지(github.com/…)면 거부.
|
|
87
|
+
* - 레거시 {name, source}: http(s)면 MCP 엔드포인트로 보일 때만(…/mcp, …/sse, mcp.* 호스트) 수용,
|
|
88
|
+
* 아니면 거부. 비-URL 문자열은 기존대로 stdio 실행 커맨드로 해석.
|
|
89
|
+
*/
|
|
90
|
+
function pluginMcpRow(slug, entry, index) {
|
|
91
|
+
const name = (typeof entry?.name === "string" && entry.name.trim()) || `${slug}-${index + 1}`;
|
|
92
|
+
const source = typeof entry?.source === "string" ? entry.source.trim() : "";
|
|
93
|
+
const transport = typeof entry?.transport === "string" ? entry.transport.trim().toLowerCase() : "";
|
|
94
|
+
const envKeys = Array.isArray(entry?.envKeys)
|
|
95
|
+
? entry.envKeys.filter((key) => typeof key === "string")
|
|
96
|
+
: entry?.env && typeof entry.env === "object"
|
|
97
|
+
? Object.keys(entry.env)
|
|
98
|
+
: [];
|
|
99
|
+
const makeRow = (fields) => ({
|
|
100
|
+
row: {
|
|
101
|
+
id: crypto.randomUUID(),
|
|
102
|
+
catalogId: `hub:${slug}:${name}`,
|
|
103
|
+
name,
|
|
104
|
+
envKeysJson: JSON.stringify(envKeys),
|
|
105
|
+
...fields,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const refuse = (reason) => ({ refused: { name, source: source || (typeof entry?.url === "string" ? entry.url : ""), reason } });
|
|
109
|
+
|
|
110
|
+
if (transport === "stdio") {
|
|
111
|
+
const command = typeof entry?.command === "string" ? entry.command.trim() : "";
|
|
112
|
+
if (!command) return refuse("stdio row without a launch command");
|
|
113
|
+
const args = Array.isArray(entry?.args) ? entry.args.filter((a) => typeof a === "string") : [];
|
|
114
|
+
return makeRow({ transport: "stdio", command, argsJson: JSON.stringify(args), url: null });
|
|
115
|
+
}
|
|
116
|
+
if (transport === "http" || transport === "sse") {
|
|
117
|
+
const url = typeof entry?.url === "string" && entry.url.trim() ? entry.url.trim() : source;
|
|
118
|
+
if (!/^https?:\/\//i.test(url)) return refuse("http row without a usable endpoint URL");
|
|
119
|
+
if (PLUGIN_CODE_HOSTING_HTML_RE.test(url)) {
|
|
120
|
+
return refuse("URL is a code-hosting HTML page (docs), not an MCP endpoint");
|
|
121
|
+
}
|
|
122
|
+
// 명시 선언이어도 데스크탑과 동일하게 isLikelyRemoteMcpEndpoint를 통과해야 한다
|
|
123
|
+
// (hub-plugin-bridge.ts:90) — https 필수, 저장소/패키지 페이지 호스트 거부.
|
|
124
|
+
if (!pluginIsLikelyRemoteMcpEndpoint(url)) {
|
|
125
|
+
return refuse("URL is not a usable remote MCP endpoint (https required; repo/package pages are docs)");
|
|
126
|
+
}
|
|
127
|
+
return makeRow({ transport: "http", command: null, argsJson: "[]", url });
|
|
128
|
+
}
|
|
129
|
+
if (transport) return refuse(`unsupported transport "${transport}"`);
|
|
130
|
+
|
|
131
|
+
// ── 레거시 {name, source} 행 ──
|
|
132
|
+
if (!source) return null;
|
|
133
|
+
if (/^https?:\/\//i.test(source)) {
|
|
134
|
+
if (!pluginLooksLikeMcpEndpoint(source)) {
|
|
135
|
+
return refuse(
|
|
136
|
+
PLUGIN_CODE_HOSTING_HTML_RE.test(source)
|
|
137
|
+
? "URL is a code-hosting HTML page (docs), not an MCP endpoint"
|
|
138
|
+
: "URL does not look like an MCP endpoint (no /mcp, /sse, or mcp.* host, and no declared transport)",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return makeRow({ transport: "http", command: null, argsJson: "[]", url: source });
|
|
142
|
+
}
|
|
143
|
+
// 원격은 URL, stdio는 실행 커맨드다. 둘을 섞으면 codex config.toml 스키마 위반으로
|
|
144
|
+
// 런타임이 통째로 죽는다(Runtime Doctor가 반복해서 잡던 사고 계열).
|
|
145
|
+
const argv = source.split(/\s+/).filter(Boolean);
|
|
146
|
+
return makeRow({ transport: "stdio", command: argv[0] ?? null, argsJson: JSON.stringify(argv.slice(1)), url: null });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 매니페스트 전체를 설치 계획으로 정규화: 등록할 행과, 정직하게 거부한 항목을 분리한다. */
|
|
150
|
+
function planPluginMcpInstall(slug, manifest) {
|
|
151
|
+
const entries = Array.isArray(manifest?.mcp) ? manifest.mcp : [];
|
|
152
|
+
const rows = [];
|
|
153
|
+
const refused = [];
|
|
154
|
+
entries.forEach((entry, index) => {
|
|
155
|
+
const normalized = pluginMcpRow(slug, entry, index);
|
|
156
|
+
if (!normalized) return;
|
|
157
|
+
if (normalized.row) rows.push(normalized.row);
|
|
158
|
+
else if (normalized.refused) refused.push(normalized.refused);
|
|
159
|
+
});
|
|
160
|
+
return { rows, refused };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 정규화된 행들을 로컬 mcp_servers 스키마에 멱등 삽입.
|
|
165
|
+
* { installed, reused, needsApproval } 반환.
|
|
166
|
+
*
|
|
167
|
+
* 데스크탑 hub-plugin-bridge.ts:209-228 동형(자율 경계, 2026-07-23 재설계):
|
|
168
|
+
* stdio(로컬 프로세스 실행 = 원격 메타데이터발 코드 실행)는 절대 자동 활성화하지
|
|
169
|
+
* 않는다 — enabled=0으로 등록하고 승인 필요로 정직하게 표면화한다. 이 테이블은
|
|
170
|
+
* 데스크탑과 공유되므로, 터미널이 enabled=1로 넣으면 데스크탑 런타임 구성이
|
|
171
|
+
* 승인 게이트 없이 그 로컬 프로세스를 실행하게 된다. http/sse 원격 연결만
|
|
172
|
+
* 자동 활성화한다(네트워크 연결일 뿐 로컬 실행이 없다).
|
|
173
|
+
*/
|
|
174
|
+
function installPluginMcpRows(db, rows) {
|
|
175
|
+
let installed = 0;
|
|
176
|
+
let reused = 0;
|
|
177
|
+
const needsApproval = [];
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
const stdioNeedsApproval = row.transport === "stdio";
|
|
180
|
+
const existing = db.prepare("SELECT id FROM mcp_servers WHERE catalog_id = ? LIMIT 1").get(row.catalogId);
|
|
181
|
+
if (existing) { reused += 1; continue; } // 멱등: 재설치가 중복 행을 만들지 않는다
|
|
182
|
+
db.prepare(
|
|
183
|
+
`INSERT INTO mcp_servers (id, catalog_id, name, name_en, transport, command, args_json, url, env_keys_json, enabled, installed_at)
|
|
184
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
185
|
+
).run(
|
|
186
|
+
row.id, row.catalogId, row.name, row.name, row.transport,
|
|
187
|
+
row.command, row.argsJson, row.url, row.envKeysJson,
|
|
188
|
+
stdioNeedsApproval ? 0 : 1, new Date().toISOString(),
|
|
189
|
+
);
|
|
190
|
+
installed += 1;
|
|
191
|
+
if (stdioNeedsApproval) needsApproval.push(row.name);
|
|
192
|
+
}
|
|
193
|
+
return { installed, reused, needsApproval };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Hub 플러그인 카탈로그 목록 (marketplace.list_plugins). 실패는 그대로 throw — 폴백 카탈로그 금지. */
|
|
197
|
+
async function listHubPlugins({ callTool } = {}) {
|
|
198
|
+
const call = callTool || callHubTool;
|
|
199
|
+
const result = await call("marketplace.list_plugins", {});
|
|
200
|
+
return (result && (result.plugins || result.results)) || [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
PLUGIN_CODE_HOSTING_HTML_RE,
|
|
205
|
+
PLUGIN_REPO_PAGE_HOSTS,
|
|
206
|
+
fetchPluginManifest,
|
|
207
|
+
pluginIsLikelyRemoteMcpEndpoint,
|
|
208
|
+
pluginLooksLikeMcpEndpoint,
|
|
209
|
+
pluginMcpRow,
|
|
210
|
+
planPluginMcpInstall,
|
|
211
|
+
installPluginMcpRows,
|
|
212
|
+
listHubPlugins,
|
|
213
|
+
};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* mcp/consent — 1회(one-pass) 동의 프롬프트 + 동의 영수증 저장소 + 런타임 allowlist 해소.
|
|
4
|
+
*
|
|
5
|
+
* 계약(v1 그대로):
|
|
6
|
+
* - 동의 상태(<userData>/terminal/mcp-consents-v1.json)에는 정체성과 지문만 담는다.
|
|
7
|
+
* 명령/인자/자격증명 등 실행 재료는 절대 저장하지 않는다.
|
|
8
|
+
* - "enabled" 레지스트리 행이라는 사실만으로는 ordinary turn에 attach할 권한이
|
|
9
|
+
* 생기지 않는다 — 정확한 consentFingerprint가 일치하는 영수증이 있어야 한다.
|
|
10
|
+
* - 런타임 정의가 조금이라도 드리프트하면(명령/인자/키이름 변경) 지문이 갈리고
|
|
11
|
+
* 기존 동의는 자동 무효가 된다. 손상된 동의 상태는 fail-closed(빈 목록).
|
|
12
|
+
*/
|
|
13
|
+
const readline = require("node:readline");
|
|
14
|
+
const fs = require("node:fs");
|
|
15
|
+
const path = require("node:path");
|
|
16
|
+
const {
|
|
17
|
+
MAX_APPROVED_MCP_PER_BUILD,
|
|
18
|
+
MAX_JSON_BYTES,
|
|
19
|
+
ID_RE,
|
|
20
|
+
assertExactKeys,
|
|
21
|
+
assertId,
|
|
22
|
+
assertIsoDateOrNull,
|
|
23
|
+
safeCatalogId,
|
|
24
|
+
writePrivateJsonAtomic,
|
|
25
|
+
withPrivateStateLock,
|
|
26
|
+
parseIdList,
|
|
27
|
+
} = require("./contract.cjs");
|
|
28
|
+
const { materializeTrustedSystemMcpServer, readApprovedSystemMcpServer } = require("./inventory.cjs");
|
|
29
|
+
const {
|
|
30
|
+
MCP_PROBE_CONCURRENCY,
|
|
31
|
+
MCP_PROBE_PER_SERVER_TIMEOUT_MS,
|
|
32
|
+
MCP_PROBE_TOTAL_TIMEOUT_MS,
|
|
33
|
+
probeSystemMcpServerConnection,
|
|
34
|
+
} = require("./probe.cjs");
|
|
35
|
+
|
|
36
|
+
const MCP_CONSENT_STATE_SCHEMA = "agentlas.terminal-mcp-consents.v1";
|
|
37
|
+
const MCP_CONSENT_RECEIPT_SCHEMA = "agentlas.terminal-mcp-consent.v1";
|
|
38
|
+
|
|
39
|
+
function mcpConsentStatePath(userDataDir) {
|
|
40
|
+
return path.join(userDataDir, "terminal", "mcp-consents-v1.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emptyMcpConsentState() {
|
|
44
|
+
return { schemaVersion: MCP_CONSENT_STATE_SCHEMA, updatedAt: null, receipts: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateMcpConsentReceipt(receipt, index) {
|
|
48
|
+
const label = `Terminal MCP consent.receipts[${index}]`;
|
|
49
|
+
const keys = ["schemaVersion", "catalogId", "registryServerId", "consentFingerprint", "source", "consentedAt"];
|
|
50
|
+
assertExactKeys(receipt, new Set(keys), keys, label);
|
|
51
|
+
if (receipt.schemaVersion !== MCP_CONSENT_RECEIPT_SCHEMA) throw new Error(`${label}.schemaVersion is invalid`);
|
|
52
|
+
assertId(receipt.catalogId, `${label}.catalogId`);
|
|
53
|
+
assertId(receipt.registryServerId, `${label}.registryServerId`);
|
|
54
|
+
if (!/^[0-9a-f]{64}$/.test(String(receipt.consentFingerprint || ""))) throw new Error(`${label}.consentFingerprint is invalid`);
|
|
55
|
+
if (receipt.source !== "terminal-build-one-pass") throw new Error(`${label}.source is invalid`);
|
|
56
|
+
if (typeof receipt.consentedAt !== "string" || !receipt.consentedAt) throw new Error(`${label}.consentedAt is invalid`);
|
|
57
|
+
assertIsoDateOrNull(receipt.consentedAt, `${label}.consentedAt`);
|
|
58
|
+
return receipt;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadMcpConsentState(userDataDir) {
|
|
62
|
+
const file = mcpConsentStatePath(userDataDir);
|
|
63
|
+
if (!fs.existsSync(file)) return emptyMcpConsentState();
|
|
64
|
+
const stat = fs.lstatSync(file);
|
|
65
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > MAX_JSON_BYTES) {
|
|
66
|
+
throw new Error("Terminal MCP consent state is unsafe or too large");
|
|
67
|
+
}
|
|
68
|
+
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
69
|
+
assertExactKeys(state, new Set(["schemaVersion", "updatedAt", "receipts"]), ["schemaVersion", "updatedAt", "receipts"], "Terminal MCP consent state");
|
|
70
|
+
if (state.schemaVersion !== MCP_CONSENT_STATE_SCHEMA || !Array.isArray(state.receipts) || state.receipts.length > 256) {
|
|
71
|
+
throw new Error("Terminal MCP consent state schema is invalid");
|
|
72
|
+
}
|
|
73
|
+
assertIsoDateOrNull(state.updatedAt, "Terminal MCP consent state.updatedAt");
|
|
74
|
+
state.receipts.forEach(validateMcpConsentReceipt);
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function withMcpConsentStateLock(userDataDir, action) {
|
|
79
|
+
return withPrivateStateLock(mcpConsentStatePath(userDataDir), {
|
|
80
|
+
unsafe: "Terminal MCP consent lock is unsafe",
|
|
81
|
+
busy: "Terminal MCP consent state is busy; retry the command",
|
|
82
|
+
}, action);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function persistMcpConsentReceipts(userDataDir, servers) {
|
|
86
|
+
if (!userDataDir || !(servers || []).length) return false;
|
|
87
|
+
withMcpConsentStateLock(userDataDir, () => {
|
|
88
|
+
const state = loadMcpConsentState(userDataDir);
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
for (const server of servers) {
|
|
91
|
+
if (!server || !ID_RE.test(String(server.id || "")) || !safeCatalogId(server.catalog_id) || !/^[0-9a-f]{64}$/.test(String(server.consentFingerprint || ""))) continue;
|
|
92
|
+
const receipt = {
|
|
93
|
+
schemaVersion: MCP_CONSENT_RECEIPT_SCHEMA,
|
|
94
|
+
catalogId: server.catalog_id,
|
|
95
|
+
registryServerId: server.id,
|
|
96
|
+
consentFingerprint: server.consentFingerprint,
|
|
97
|
+
source: "terminal-build-one-pass",
|
|
98
|
+
consentedAt: now,
|
|
99
|
+
};
|
|
100
|
+
const existing = state.receipts.findIndex((item) => item.catalogId === receipt.catalogId && item.registryServerId === receipt.registryServerId);
|
|
101
|
+
if (existing >= 0) state.receipts[existing] = receipt;
|
|
102
|
+
else state.receipts.push(receipt);
|
|
103
|
+
}
|
|
104
|
+
state.receipts.sort((a, b) => String(b.consentedAt).localeCompare(String(a.consentedAt)) || a.catalogId.localeCompare(b.catalogId));
|
|
105
|
+
state.receipts = state.receipts.slice(0, 256);
|
|
106
|
+
state.updatedAt = now;
|
|
107
|
+
writePrivateJsonAtomic(mcpConsentStatePath(userDataDir), state);
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readConsentedSystemMcpServers(db, options = {}) {
|
|
113
|
+
let state;
|
|
114
|
+
try { state = loadMcpConsentState(options.userDataDir); }
|
|
115
|
+
catch { return []; }
|
|
116
|
+
const servers = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const receipt of state.receipts) {
|
|
119
|
+
if (seen.has(receipt.catalogId)) continue;
|
|
120
|
+
let row = null;
|
|
121
|
+
try {
|
|
122
|
+
row = db.prepare(
|
|
123
|
+
"SELECT id, catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers WHERE id=? LIMIT 1",
|
|
124
|
+
).get(receipt.registryServerId);
|
|
125
|
+
} catch { continue; }
|
|
126
|
+
const server = materializeTrustedSystemMcpServer(row, options);
|
|
127
|
+
if (
|
|
128
|
+
!server || server.id !== receipt.registryServerId || server.catalog_id !== receipt.catalogId ||
|
|
129
|
+
server.consentFingerprint !== receipt.consentFingerprint
|
|
130
|
+
) continue;
|
|
131
|
+
seen.add(receipt.catalogId);
|
|
132
|
+
servers.push(server);
|
|
133
|
+
}
|
|
134
|
+
return servers;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeConsentAnswer(answer, availableIds) {
|
|
138
|
+
const text = String(answer || "").trim();
|
|
139
|
+
if (/^(?:y|yes|all|전체)$/i.test(text)) return [...availableIds];
|
|
140
|
+
if (!text || /^(?:n|no|none|없이|아니)$/i.test(text)) return [];
|
|
141
|
+
const requested = parseIdList(text);
|
|
142
|
+
const allowed = new Set(availableIds);
|
|
143
|
+
return requested.filter((id) => allowed.has(id));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function askMcpConsentOnce(plan, options = {}) {
|
|
147
|
+
const input = options.input || process.stdin;
|
|
148
|
+
const output = options.output || process.stderr;
|
|
149
|
+
// TTY가 아니면(파이프/자동화) 묻지 않고 빈 승인 — 조용한 전체 승인 금지.
|
|
150
|
+
if (!input.isTTY || !output.isTTY || !plan.availableCatalogIds.length) return Promise.resolve([]);
|
|
151
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
rl.question("Attach the available MCP recommendations? [y=all / n=none / comma-separated ids] ", (answer) => {
|
|
154
|
+
rl.close();
|
|
155
|
+
resolve(normalizeConsentAnswer(answer, plan.availableCatalogIds));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function resolveApprovedMcpRuntimeAllowlist(options) {
|
|
161
|
+
const approved = new Set(options.approvedIds || []);
|
|
162
|
+
const selectedGroups = (options.plan?.entries || []).map((entry) => {
|
|
163
|
+
if (entry.status !== "available") return null;
|
|
164
|
+
const candidates = Array.isArray(entry.runtimeCandidates) && entry.runtimeCandidates.length
|
|
165
|
+
? entry.runtimeCandidates
|
|
166
|
+
: [{
|
|
167
|
+
resolvedCatalogId: entry.resolvedCatalogId,
|
|
168
|
+
registryServerId: entry.registryServerId,
|
|
169
|
+
credentialKeyFingerprint: entry.credentialKeyFingerprint,
|
|
170
|
+
}];
|
|
171
|
+
const approvedCandidates = candidates.filter(
|
|
172
|
+
(candidate) => candidate.resolvedCatalogId && approved.has(candidate.resolvedCatalogId),
|
|
173
|
+
);
|
|
174
|
+
return approvedCandidates.length ? { entry, candidates: approvedCandidates } : null;
|
|
175
|
+
}).filter(Boolean);
|
|
176
|
+
const probe = options.probeServer || ((server, probeOptions = {}) => probeSystemMcpServerConnection(server, {
|
|
177
|
+
cwd: options.cwd,
|
|
178
|
+
env: options.env,
|
|
179
|
+
userDataDir: options.userDataDir,
|
|
180
|
+
timeoutMs: probeOptions.timeoutMs,
|
|
181
|
+
signal: probeOptions.signal,
|
|
182
|
+
}));
|
|
183
|
+
const bounded = (value, fallback, min, max) => {
|
|
184
|
+
const parsed = Number(value);
|
|
185
|
+
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, Math.trunc(parsed))) : fallback;
|
|
186
|
+
};
|
|
187
|
+
const concurrency = bounded(options.probeConcurrency, MCP_PROBE_CONCURRENCY, 1, MAX_APPROVED_MCP_PER_BUILD);
|
|
188
|
+
const perServerTimeoutMs = bounded(options.probeTimeoutMs, MCP_PROBE_PER_SERVER_TIMEOUT_MS, 50, 30_000);
|
|
189
|
+
const totalTimeoutMs = bounded(options.totalProbeTimeoutMs, MCP_PROBE_TOTAL_TIMEOUT_MS, 50, 60_000);
|
|
190
|
+
const deadline = Date.now() + totalTimeoutMs;
|
|
191
|
+
const outcomes = new Array(selectedGroups.length);
|
|
192
|
+
let nextIndex = 0;
|
|
193
|
+
|
|
194
|
+
const probeCandidate = async (candidate) => {
|
|
195
|
+
let server = null;
|
|
196
|
+
try { server = readApprovedSystemMcpServer(options.db, candidate, { userDataDir: options.userDataDir }); }
|
|
197
|
+
catch { /* one unsafe/unwritable runtime boundary excludes only this server */ }
|
|
198
|
+
if (!server) return { candidate, server: null, status: { connected: false, reason: "registry_row_unavailable" } };
|
|
199
|
+
const remainingMs = deadline - Date.now();
|
|
200
|
+
if (remainingMs <= 0) return { candidate, server, status: { connected: false, reason: "probe_total_deadline" } };
|
|
201
|
+
const timeoutMs = Math.min(perServerTimeoutMs, remainingMs);
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
let timer = null;
|
|
204
|
+
let status;
|
|
205
|
+
try {
|
|
206
|
+
status = await Promise.race([
|
|
207
|
+
Promise.resolve(probe(server, { timeoutMs, signal: controller.signal })),
|
|
208
|
+
new Promise((resolve) => {
|
|
209
|
+
timer = setTimeout(() => {
|
|
210
|
+
controller.abort();
|
|
211
|
+
resolve({ connected: false, reason: "connection_timeout" });
|
|
212
|
+
}, timeoutMs);
|
|
213
|
+
}),
|
|
214
|
+
]);
|
|
215
|
+
} catch {
|
|
216
|
+
status = { connected: false, reason: "connection_failed" };
|
|
217
|
+
} finally {
|
|
218
|
+
if (timer) clearTimeout(timer);
|
|
219
|
+
}
|
|
220
|
+
return { candidate, server, status };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Different requirements use a small worker pool. Alternatives for one
|
|
224
|
+
// requirement are deliberately sequential so a failed primary cannot fan
|
|
225
|
+
// out package-manager processes or affect unrelated server groups.
|
|
226
|
+
const work = async () => {
|
|
227
|
+
while (true) {
|
|
228
|
+
const index = nextIndex++;
|
|
229
|
+
if (index >= selectedGroups.length) return;
|
|
230
|
+
const group = selectedGroups[index];
|
|
231
|
+
const attempts = [];
|
|
232
|
+
for (const candidate of group.candidates) {
|
|
233
|
+
const outcome = await probeCandidate(candidate);
|
|
234
|
+
attempts.push(outcome);
|
|
235
|
+
if (outcome.status?.connected) break;
|
|
236
|
+
}
|
|
237
|
+
outcomes[index] = { entry: group.entry, attempts };
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, selectedGroups.length) }, () => work()));
|
|
241
|
+
|
|
242
|
+
const attached = [];
|
|
243
|
+
const failed = [];
|
|
244
|
+
const servers = [];
|
|
245
|
+
for (let index = 0; index < outcomes.length; index += 1) {
|
|
246
|
+
const outcome = outcomes[index] || {
|
|
247
|
+
entry: selectedGroups[index]?.entry,
|
|
248
|
+
attempts: [],
|
|
249
|
+
};
|
|
250
|
+
for (const attempt of outcome.attempts) {
|
|
251
|
+
const catalogId = attempt.candidate.resolvedCatalogId;
|
|
252
|
+
if (!attempt.status?.connected) {
|
|
253
|
+
failed.push({ catalogId, reason: safeCatalogId(attempt.status?.reason) || "connection_failed" });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
attached.push({ catalogId, registryServerId: attempt.server.id, status: "connected" });
|
|
257
|
+
servers.push(attempt.server);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
let consentPersisted = servers.length === 0;
|
|
262
|
+
if (servers.length) {
|
|
263
|
+
try { consentPersisted = persistMcpConsentReceipts(options.userDataDir, servers); }
|
|
264
|
+
catch { consentPersisted = false; }
|
|
265
|
+
}
|
|
266
|
+
const receipt = {
|
|
267
|
+
schemaVersion: "agentlas.terminal-mcp-runtime-allowlist.v1",
|
|
268
|
+
planId: options.plan?.planId || null,
|
|
269
|
+
approvedCatalogIds: [...approved].sort(),
|
|
270
|
+
attached,
|
|
271
|
+
failed,
|
|
272
|
+
emptyMode: attached.length === 0,
|
|
273
|
+
consentPersisted,
|
|
274
|
+
};
|
|
275
|
+
Object.defineProperty(receipt, "servers", { value: servers, enumerable: false });
|
|
276
|
+
return receipt;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
MCP_CONSENT_STATE_SCHEMA,
|
|
281
|
+
MCP_CONSENT_RECEIPT_SCHEMA,
|
|
282
|
+
mcpConsentStatePath,
|
|
283
|
+
loadMcpConsentState,
|
|
284
|
+
persistMcpConsentReceipts,
|
|
285
|
+
readConsentedSystemMcpServers,
|
|
286
|
+
normalizeConsentAnswer,
|
|
287
|
+
askMcpConsentOnce,
|
|
288
|
+
resolveApprovedMcpRuntimeAllowlist,
|
|
289
|
+
};
|