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
|
@@ -114,9 +114,17 @@ async function cmdBuild(options) {
|
|
|
114
114
|
const emit = options.out || console.log;
|
|
115
115
|
const inventory = mcp.collectSystemMcpInventory(options.db, { userDataDir: options.userDataDir, env: options.env || process.env });
|
|
116
116
|
const policy = mcp.loadProjectMcpPolicy(options.cwd || process.cwd());
|
|
117
|
+
// 명시적 요구(정책/플래그)가 하나도 없을 때만 추론 — 판정기(연결 모델) 경유이며,
|
|
118
|
+
// 판정 불가면 빈 목록(중립)이다. 휴리스틱 정규식은 판정 힌트로만 실린다.
|
|
119
|
+
const explicitRequirementCount =
|
|
120
|
+
((policy && policy.requirements) || []).length + parsed.requiredIds.length + parsed.recommendedIds.length;
|
|
121
|
+
const inferredRequirements = explicitRequirementCount === 0
|
|
122
|
+
? await mcp.inferRequirements(parsed.request, inventory)
|
|
123
|
+
: [];
|
|
117
124
|
const plan = mcp.buildMcpPlan({
|
|
118
125
|
inventory, policy, request: parsed.request,
|
|
119
126
|
requiredIds: parsed.requiredIds, recommendedIds: parsed.recommendedIds,
|
|
127
|
+
inferredRequirements,
|
|
120
128
|
});
|
|
121
129
|
emit(parsed.json ? JSON.stringify(plan, null, 2) : mcp.renderMcpPlan(plan));
|
|
122
130
|
if (parsed.planOnly) return { plan, approvedIds: [], invoked: false };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* "이 노드가 바깥을 바꾸나" — 터미널 쪽 **거울 하나**.
|
|
4
|
+
*
|
|
5
|
+
* 정본은 데스크탑의 `shared/graph-node-protocol.ts` 다. 터미널이 그걸 직접 부르지
|
|
6
|
+
* 못하는 이유는 하나뿐이다: 그 판정은 `.filter()` 안에서 **동기로** 필요한데, 엔진을
|
|
7
|
+
* 얻는 길(`acquireCore`)은 비동기다(새 설치는 아직 내려받지 않았을 수 있다).
|
|
8
|
+
* 동기 로더로 우회하면 새로 설치한 사람에게만 조용히 다른 답이 나온다 —
|
|
9
|
+
* `verify-engine-reachable` 게이트가 정확히 그걸 막는다.
|
|
10
|
+
*
|
|
11
|
+
* 그래서 규칙을 여기 한 번 편다. 대신 **같은 답을 내는지 게이트가 증명한다**
|
|
12
|
+
* (`scripts/verify-node-effect-parity.cjs`). 거울이 허용되는 조건은 그 증명뿐이다.
|
|
13
|
+
*
|
|
14
|
+
* 왜 이 판정이 중요한가 (실측 2026-08-20):
|
|
15
|
+
* `config.effect === "mutation"` 만 보면 emitter 가 만든 출력 노드(effect 칸이
|
|
16
|
+
* 아예 없음)가 "바깥에 안 나감"으로 읽힌다. 그 노드의 기본값은 나가는 것이다.
|
|
17
|
+
* 데스크탑에서 같은 구멍이 다섯 곳에 있었다 — 도구 모드·패키지 경고·권한 유도·
|
|
18
|
+
* 발행 심사·패치 승인. 터미널도 세 곳에 있었다.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 안 적힌 효과의 기본값. 출력 블록은 "바깥으로 내보내기"다(레지스트리 선언). */
|
|
22
|
+
function defaultNodeEffect(nodeType) {
|
|
23
|
+
return nodeType === "output" ? "mutation" : "read";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 이 노드의 효과. 선언된 것이 있으면 그것을 믿고, 없으면 종류의 기본값이다. */
|
|
27
|
+
function resolveNodeEffect(node) {
|
|
28
|
+
const declared = typeof node?.config?.effect === "string" ? node.config.effect.trim() : "";
|
|
29
|
+
if (declared === "mutation" || declared === "read" || declared === "pure") return declared;
|
|
30
|
+
return defaultNodeEffect(String(node?.type ?? ""));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ① 바깥으로 나간다고 **선언돼 있는가** — 사람에게 "이 단계는 발행한다"고 말할 근거.
|
|
35
|
+
* 패키지 경고·발행 고지가 쓴다.
|
|
36
|
+
*/
|
|
37
|
+
function nodeDeclaresOutwardEffect(node) {
|
|
38
|
+
return resolveNodeEffect(node) === "mutation";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* ② 바깥에 뭔가 **했을 수 있는가** — 재개가 묻는 다른 질문. ①의 상위집합이다.
|
|
43
|
+
* 모델을 부르는 단계는 선언이 read 여도 도구를 부를 수 있다.
|
|
44
|
+
* (정본이 이 둘을 갈라 놓은 이유는 shared/graph-node-protocol.ts 주석에 있다.)
|
|
45
|
+
*/
|
|
46
|
+
function nodeCouldHaveActedOutside(node) {
|
|
47
|
+
if (nodeDeclaresOutwardEffect(node)) return true;
|
|
48
|
+
return node?.type === "agent" || node?.type === "action" || node?.type === "output";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
defaultNodeEffect,
|
|
53
|
+
resolveNodeEffect,
|
|
54
|
+
nodeDeclaresOutwardEffect,
|
|
55
|
+
nodeCouldHaveActedOutside,
|
|
56
|
+
};
|
package/engine/graph/package.cjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
|
|
3
|
+
const { nodeDeclaresOutwardEffect: reachesOutside } = require("./node-effect.cjs");
|
|
2
4
|
/*
|
|
3
5
|
* .agentgraph 패키징 — 그래프를 남에게 줄 수 있는 형태로 만든다.
|
|
4
6
|
*
|
|
@@ -31,6 +33,8 @@ const SECRET_KEY_RE = /(token|secret|password|passwd|apikey|api_key|credential|p
|
|
|
31
33
|
/** 로컬 사용자 경로 — 남의 기계에서 의미가 없고, 계정명이 그대로 드러난다. */
|
|
32
34
|
const PERSONAL_PATH_RE = /(\/Users\/[^/\s"']+|\/home\/[^/\s"']+|C:\\Users\\[^\\\s"']+)/g;
|
|
33
35
|
|
|
36
|
+
|
|
37
|
+
|
|
34
38
|
function vaultKeyFor(nodeId, key) {
|
|
35
39
|
return `${String(key).replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`;
|
|
36
40
|
}
|
|
@@ -113,6 +117,7 @@ function buildPackage(input) {
|
|
|
113
117
|
// 에이전트 참조는 핀으로 남긴다 — 받는 사람이 무엇을 빌려야 하는지 알아야 한다.
|
|
114
118
|
// 노드가 ref를 선언하지 않으면 자동화의 대상 에이전트를 상속한다(제품의 실제 동작).
|
|
115
119
|
// 그 경우를 빼면 패키지가 "채울 것 없음"이라고 거짓말한다.
|
|
120
|
+
// judgment-exempt: 이건 "바깥을 바꾸나"가 아니라 "이 단계가 에이전트를 굴리나"다.
|
|
116
121
|
const isAgentish = node.type === "agent" || node.type === "action" || node.type === "output";
|
|
117
122
|
const ref = typeof node.config?.ref === "string" && node.config.ref ? node.config.ref : null;
|
|
118
123
|
const inheritedSlug = automation.target_id || null;
|
|
@@ -139,7 +144,7 @@ function buildPackage(input) {
|
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
const mutationNodes = nodes
|
|
142
|
-
.filter((n) => n
|
|
147
|
+
.filter((n) => reachesOutside(n))
|
|
143
148
|
.map((n) => ({ nodeId: n.id, label: n.label || n.id }));
|
|
144
149
|
|
|
145
150
|
const scrubbedGraph = { version: graph.version ?? 1, nodes, edges: graph.edges || [] };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"use strict";
|
|
8
8
|
|
|
9
9
|
const GRAPH_WIRE = "graph/1";
|
|
10
|
-
const GRAPH_ERROR_CODES = ["APPROVAL_REQUIRED","APPROVAL_TIMED_OUT","ARCHITECT_NO_CHANGE","ARCHITECT_NO_REQUEST","ARCHITECT_OUTPUT_MALFORMED","ARCHITECT_OUTPUT_TOO_LARGE","ARCHITECT_OUTPUT_UNREADABLE","ARCHITECT_UNAVAILABLE","AUTOMATION_NOT_CONNECTED","BUDGET_EXHAUSTED","CODE_DEPENDENCY_MISSING","CODE_NODE_EMPTY","CODE_PRODUCED_NOTHING","CODE_STEP_FAILED","CREATE_INPUT_INVALID","EDGE_CONDITION_UNRESOLVED","EVAL_FAILED","EVAL_INCOMPLETE","EVAL_STUCK","EVAL_UNAVAILABLE","INTERVIEW_MODEL_UNAVAILABLE","INTERVIEW_OUTPUT_UNREADABLE","INTERVIEW_REPEATED_QUESTIONS","INTERVIEW_SELF_CORRECTION_EXHAUSTED","INTERVIEW_STATE_INVALID","LOOP_BOUND_INVALID","LOOP_BOUND_UNDECLARED","LOOP_LIMIT_REACHED","LOOP_WITHOUT_EXIT","MUTATION_UNVERIFIED","NODE_CLAIMED_WITHOUT_TOOLS","NODE_FAILED","NODE_INPUT_MISSING","NODE_NEVER_REACHED","NODE_NO_RESULT","NODE_TIMEOUT","NODE_TYPE_UNSUPPORTED","NO_MATCHING_EDGE","OUTPUT_NODE_EMPTY","PATCH_CODE_EMPTY","PATCH_EDGE_CONFLICT","PATCH_EDGE_DANGLING","PATCH_EDGE_HANDLE_MISSING","PATCH_EDGE_MISSING","PATCH_EMPTY","PATCH_LOOP_BOUND_MISSING","PATCH_NODE_CONFLICT","PATCH_NODE_MISSING","PATCH_NO_GRAPH","PATCH_OP_UNKNOWN","REDUCER_MERGE_CONFLICT","REDUCER_WRITE_CONFLICT","RESUME_CONFLICT","RUN_REQUEST_DISABLED","RUN_REQUEST_INPUT_REQUIRED","RUN_REQUEST_NOT_FOUND","RUN_REQUEST_QUEUE_UNAVAILABLE","RUN_REQUEST_REF_AMBIGUOUS","RUN_REQUEST_REF_MISSING","SUBGRAPH_DEPTH_EXCEEDED","SUBGRAPH_FAILED","SUBGRAPH_NOT_FOUND","SUBGRAPH_NO_RESULT","SUBGRAPH_SELF_CALL","SWAP_CAPABILITY_MISMATCH","SWAP_HUB_RELEASE_UNPINNED","SWAP_NODE_NOT_FOUND","SWAP_NOT_AGENT_NODE","SWAP_NO_MATCH","SWAP_UNKNOWN_PROVIDER","TOOL_BROKER_CALL_UNREADABLE","TOOL_BROKER_MUTATION_IN_SIMULATION","TOOL_BROKER_PLAN_UNREADABLE","TOOL_BROKER_TOOL_NOT_DECLARED","TOOL_NODE_UNATTACHED","TOOL_NODE_UNCONFIGURED","TRANSFORM_MODE_UNKNOWN","TRANSFORM_NODE_UNCONFIGURED"];
|
|
10
|
+
const GRAPH_ERROR_CODES = ["APPROVAL_REQUIRED","APPROVAL_TIMED_OUT","ARCHITECT_NO_CHANGE","ARCHITECT_NO_REQUEST","ARCHITECT_OUTPUT_MALFORMED","ARCHITECT_OUTPUT_TOO_LARGE","ARCHITECT_OUTPUT_UNREADABLE","ARCHITECT_UNAVAILABLE","AUTOMATION_NOT_CONNECTED","BUDGET_EXHAUSTED","CODE_DEPENDENCY_MISSING","CODE_NODE_EMPTY","CODE_PRODUCED_NOTHING","CODE_STEP_FAILED","CREATE_INPUT_INVALID","EDGE_CONDITION_UNRESOLVED","EVAL_CONTRADICTS_BRANCH","EVAL_FAILED","EVAL_INCOMPLETE","EVAL_STUCK","EVAL_UNAVAILABLE","INTERVIEW_MODEL_UNAVAILABLE","INTERVIEW_OUTPUT_UNREADABLE","INTERVIEW_REPEATED_QUESTIONS","INTERVIEW_SELF_CORRECTION_EXHAUSTED","INTERVIEW_STATE_INVALID","LOOP_BOUND_INVALID","LOOP_BOUND_UNDECLARED","LOOP_LIMIT_REACHED","LOOP_WITHOUT_EXIT","MUTATION_UNVERIFIED","NODE_CLAIMED_WITHOUT_TOOLS","NODE_FAILED","NODE_INPUT_MISSING","NODE_NEVER_REACHED","NODE_NO_RESULT","NODE_TIMEOUT","NODE_TYPE_UNSUPPORTED","NO_MATCHING_EDGE","OUTPUT_NODE_EMPTY","PATCH_CODE_EMPTY","PATCH_EDGE_CONFLICT","PATCH_EDGE_DANGLING","PATCH_EDGE_HANDLE_MISSING","PATCH_EDGE_MISSING","PATCH_EMPTY","PATCH_LOOP_BOUND_MISSING","PATCH_NODE_CONFLICT","PATCH_NODE_MISSING","PATCH_NO_GRAPH","PATCH_OP_UNKNOWN","REDUCER_MERGE_CONFLICT","REDUCER_WRITE_CONFLICT","RESUME_CONFLICT","RUN_REQUEST_DISABLED","RUN_REQUEST_INPUT_REQUIRED","RUN_REQUEST_NOT_FOUND","RUN_REQUEST_QUEUE_UNAVAILABLE","RUN_REQUEST_REF_AMBIGUOUS","RUN_REQUEST_REF_MISSING","SUBGRAPH_DEPTH_EXCEEDED","SUBGRAPH_FAILED","SUBGRAPH_NOT_FOUND","SUBGRAPH_NO_RESULT","SUBGRAPH_SELF_CALL","SWAP_CAPABILITY_MISMATCH","SWAP_HUB_RELEASE_UNPINNED","SWAP_NODE_NOT_FOUND","SWAP_NOT_AGENT_NODE","SWAP_NO_MATCH","SWAP_UNKNOWN_PROVIDER","TOOL_BROKER_CALL_UNREADABLE","TOOL_BROKER_MUTATION_IN_SIMULATION","TOOL_BROKER_PLAN_UNREADABLE","TOOL_BROKER_TOOL_NOT_DECLARED","TOOL_NODE_UNATTACHED","TOOL_NODE_UNCONFIGURED","TRANSFORM_MODE_UNKNOWN","TRANSFORM_NODE_UNCONFIGURED"];
|
|
11
11
|
const GRAPH_JOURNAL_KINDS = ["blob_externalized","node_failed","node_intent","node_reserved","node_retry","node_routed","node_settled","resumed","run_completed","run_created","run_failed","run_validated","suspended"];
|
|
12
12
|
const GRAPH_NODE_KINDS = ["action","agent","code","condition","eval","output","subgraph","tool","transform","trigger"];
|
|
13
13
|
const GRAPH_BLOCK_UI = {"trigger":{"section":"none","placeable":false,"placeReason":"그래프마다 하나뿐이고 처음 만들 때 함께 지어진다"},"agent":{"section":"inventory","placeable":true},"eval":{"section":"flow","placeable":true},"condition":{"section":"flow","placeable":true},"transform":{"section":"flow","placeable":true},"code":{"section":"flow","placeable":true},"tool":{"section":"inventory","placeable":true},"action":{"section":"actions","placeable":true},"output":{"section":"flow","placeable":true},"loop":{"section":"none","placeable":false,"placeReason":"노드가 아니라 되돌아가는 연결의 성질이다 — 엣지를 이어서 만든다"},"subgraph":{"section":"flow","placeable":true}};
|
|
@@ -30,7 +30,10 @@ function localCoreBin() {
|
|
|
30
30
|
if (process.platform === "win32") return null;
|
|
31
31
|
for (const candidate of candidates) {
|
|
32
32
|
try {
|
|
33
|
-
if (candidate
|
|
33
|
+
if (!candidate) continue;
|
|
34
|
+
const stat = fs.statSync(candidate);
|
|
35
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
36
|
+
if (stat.isFile()) return candidate;
|
|
34
37
|
} catch { /* keep looking */ }
|
|
35
38
|
}
|
|
36
39
|
return null;
|
|
@@ -69,12 +72,25 @@ function createLocalCoreClient({ cwd, timeoutMs = 60_000 } = {}) {
|
|
|
69
72
|
const pending = new Map();
|
|
70
73
|
let exited = false;
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
const failPending = (code, message) => {
|
|
76
|
+
if (exited) return;
|
|
73
77
|
exited = true;
|
|
74
78
|
for (const [, entry] of pending) {
|
|
75
|
-
entry.reject(coreError(
|
|
79
|
+
entry.reject(coreError(code, message));
|
|
76
80
|
}
|
|
77
81
|
pending.clear();
|
|
82
|
+
};
|
|
83
|
+
child.once("error", (error) => {
|
|
84
|
+
failPending("local_core_spawn_failed", `the local core process could not start: ${(error && error.message) || error}`);
|
|
85
|
+
});
|
|
86
|
+
child.on("exit", () => {
|
|
87
|
+
failPending("local_core_exited", "the local core process exited before responding");
|
|
88
|
+
});
|
|
89
|
+
// Always drain stderr. Leaving the pipe unread lets a verbose Core fill the
|
|
90
|
+
// kernel buffer and deadlock an otherwise healthy tools/call.
|
|
91
|
+
child.stderr.on("data", () => {});
|
|
92
|
+
child.stdin.on("error", (error) => {
|
|
93
|
+
failPending("local_core_transport_error", `the local core input stream failed: ${(error && error.message) || error}`);
|
|
78
94
|
});
|
|
79
95
|
child.stdout.on("data", (chunk) => {
|
|
80
96
|
buffer += chunk;
|
|
@@ -104,16 +120,35 @@ function createLocalCoreClient({ cwd, timeoutMs = 60_000 } = {}) {
|
|
|
104
120
|
resolve: (message) => { clearTimeout(timer); resolve(message); },
|
|
105
121
|
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
106
122
|
});
|
|
107
|
-
|
|
123
|
+
try {
|
|
124
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, (error) => {
|
|
125
|
+
if (!error) return;
|
|
126
|
+
const entry = pending.get(id);
|
|
127
|
+
if (!entry) return;
|
|
128
|
+
pending.delete(id);
|
|
129
|
+
entry.reject(coreError("local_core_transport_error", `${method} could not be written to the local core: ${error.message}`));
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const entry = pending.get(id);
|
|
133
|
+
pending.delete(id);
|
|
134
|
+
if (entry) entry.reject(coreError("local_core_transport_error", `${method} could not be written to the local core: ${error.message}`));
|
|
135
|
+
}
|
|
108
136
|
});
|
|
109
137
|
|
|
110
138
|
async function ensureInitialized() {
|
|
111
139
|
if (!initialized) {
|
|
112
|
-
initialized =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
initialized = (async () => {
|
|
141
|
+
const response = await rpc("initialize", {
|
|
142
|
+
protocolVersion: "2024-11-05",
|
|
143
|
+
capabilities: {},
|
|
144
|
+
clientInfo: { name: "agentlas-terminal", version: "2" },
|
|
145
|
+
});
|
|
146
|
+
if (response.error) {
|
|
147
|
+
throw coreError("local_core_initialize_failed", `initialize: ${response.error.message || JSON.stringify(response.error)}`);
|
|
148
|
+
}
|
|
149
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`);
|
|
150
|
+
return response;
|
|
151
|
+
})();
|
|
117
152
|
}
|
|
118
153
|
return initialized;
|
|
119
154
|
}
|
package/engine/hub/install.cjs
CHANGED
|
@@ -27,9 +27,13 @@ const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
|
|
|
27
27
|
const { callHubTool } = require("../cloud/hub-client.cjs");
|
|
28
28
|
|
|
29
29
|
// ── 패키지 상한/식별 상수 (서버 package-contract와 동일) ──
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
30
|
+
// 상한은 정본 하나(upload-scan-catalog.json)에서 생성돼 내려온다. 여기서 다시
|
|
31
|
+
// 적으면 서버·엔진·데스크탑과 어긋나고, 어긋난 쪽은 파일 이름도 없는 코드로 거절한다.
|
|
32
|
+
const {
|
|
33
|
+
PACKAGE_MAX_TOTAL_BYTES: CLOUD_MAX_TOTAL_BYTES,
|
|
34
|
+
PACKAGE_MAX_FILE_BYTES: CLOUD_MAX_FILE_BYTES,
|
|
35
|
+
PACKAGE_MAX_FILES: CLOUD_MAX_FILES,
|
|
36
|
+
} = require("../cloud-assets/upload-scan-catalog.generated.cjs");
|
|
33
37
|
const CLOUD_PACKAGE_HASH_V1 = "path-sha256-v1";
|
|
34
38
|
const CLOUD_PACKAGE_HASH_V2 = "path-sha256-executable-v2";
|
|
35
39
|
const CLOUD_RESTORE_MARKER_PATH = ".agentlas-cloud-package.json";
|
package/engine/hub/plugins.cjs
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* 런타임이 통째로 죽는다(Runtime Doctor가 반복해서 잡던 사고 계열).
|
|
16
16
|
*/
|
|
17
17
|
const crypto = require("node:crypto");
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const os = require("node:os");
|
|
20
|
+
const path = require("node:path");
|
|
18
21
|
const { callHubTool, fetchHub, parseHubJson, webBaseUrl } = require("../cloud/hub-client.cjs");
|
|
19
22
|
|
|
20
23
|
// 레포/홈페이지 HTML 페이지는 문서지 MCP 연결이 아니다. 이 URL들을 transport:"http"로
|
|
@@ -193,6 +196,163 @@ function installPluginMcpRows(db, rows) {
|
|
|
193
196
|
return { installed, reused, needsApproval };
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
// ── 스킬 번들 설치 (플러그인 = MCP와 별개의 능력 패키지, 오너 결정 2026-08-20) ──
|
|
200
|
+
//
|
|
201
|
+
// manifest.skills 행이 files[]에 실콘텐츠를 실으면 ~/.agentlas/plugins/<slug>/ 아래에
|
|
202
|
+
// 파일로 착지시키고 plugin.json 마커(schema agentlas.local-plugin/v1)를 남긴다.
|
|
203
|
+
// 이 규약은 데스크탑 electron/mcp-tools/hub-plugin-bridge.ts(installSkillBundle)와
|
|
204
|
+
// Agentlas-OS agentlas_cloud/plugin_discovery.py 스캔이 공유한다 — mcp_servers 등록이
|
|
205
|
+
// 아니라 파일시스템이 채널 간 공유 지점이다.
|
|
206
|
+
|
|
207
|
+
const PLUGIN_SKILL_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
208
|
+
const PLUGIN_SKILL_FILE_MAX_BYTES = 512 * 1024;
|
|
209
|
+
|
|
210
|
+
/** 세 채널이 공유하는 로컬 플러그인 저장소 루트. homeDir 주입은 테스트 격리용. */
|
|
211
|
+
function agentlasPluginsDir({ homeDir } = {}) {
|
|
212
|
+
return path.join(homeDir || os.homedir(), ".agentlas", "plugins");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 스킬 파일 상대 경로 검증 — 절대경로·상위 탈출·널바이트·백슬래시 거부 (데스크탑 동형). */
|
|
216
|
+
function pluginSkillSafeRelativePath(value) {
|
|
217
|
+
if (typeof value !== "string" || !value || value.length > 260) return false;
|
|
218
|
+
if (value.includes("\0") || value.includes("\\")) return false;
|
|
219
|
+
if (value.startsWith("/") || value.endsWith("/")) return false;
|
|
220
|
+
return value.split("/").every((part) => part.length > 0 && part !== "." && part !== ".." && !part.startsWith("~"));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* manifest.skills 를 설치 계획으로 정규화: 실콘텐츠가 실린 스킬과 정직하게 거른 항목 분리.
|
|
225
|
+
* 이름뿐인 레거시 행({name}만)은 refused가 아니라 declaredOnly로 남긴다 — 결함이 아니라
|
|
226
|
+
* 과거 스키마의 정상 모양이다.
|
|
227
|
+
*/
|
|
228
|
+
function planPluginSkillInstall(slug, manifest) {
|
|
229
|
+
const entries = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
|
230
|
+
const skills = [];
|
|
231
|
+
const declaredOnly = [];
|
|
232
|
+
const refused = [];
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
const name = typeof entry?.name === "string" ? entry.name.trim() : "";
|
|
235
|
+
if (!name) continue;
|
|
236
|
+
const rawFiles = Array.isArray(entry?.files) ? entry.files : [];
|
|
237
|
+
if (rawFiles.length === 0) {
|
|
238
|
+
declaredOnly.push(name);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!PLUGIN_SKILL_SLUG_RE.test(name)) {
|
|
242
|
+
refused.push({ name, reason: "invalid skill name" });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const files = [];
|
|
246
|
+
let bad = null;
|
|
247
|
+
for (const file of rawFiles) {
|
|
248
|
+
const filePath = typeof file?.path === "string" ? file.path.trim() : "";
|
|
249
|
+
const content = typeof file?.content === "string" ? file.content : "";
|
|
250
|
+
if (!pluginSkillSafeRelativePath(filePath)) { bad = `unsafe file path "${filePath}"`; break; }
|
|
251
|
+
if (!content.trim()) { bad = `empty content for ${filePath}`; break; }
|
|
252
|
+
if (Buffer.byteLength(content, "utf8") > PLUGIN_SKILL_FILE_MAX_BYTES) { bad = `${filePath} exceeds the file size cap`; break; }
|
|
253
|
+
const sha256 = typeof file?.sha256 === "string" && /^[0-9a-f]{64}$/i.test(file.sha256)
|
|
254
|
+
? file.sha256.toLowerCase()
|
|
255
|
+
: null;
|
|
256
|
+
files.push({ path: filePath, content, sha256 });
|
|
257
|
+
}
|
|
258
|
+
if (bad) { refused.push({ name, reason: bad }); continue; }
|
|
259
|
+
skills.push({ name, description: typeof entry?.description === "string" ? entry.description : null, files });
|
|
260
|
+
}
|
|
261
|
+
return { skills, declaredOnly, refused };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 계획된 스킬들을 ~/.agentlas/plugins/<slug>/skills/<name>/ 에 쓴다.
|
|
266
|
+
*
|
|
267
|
+
* 무결성: 행이 sha256을 선언하면 쓰기 전에 검증하고, 불일치 스킬은 설치하지 않는다.
|
|
268
|
+
* 해시와 콘텐츠가 같은 매니페스트 응답으로 오므로 이 검증은 전송 무결성이지 발행자
|
|
269
|
+
* 서명이 아니다 — 마커의 source.manifestUrl이 출처 기록이다(정직한 한계).
|
|
270
|
+
*/
|
|
271
|
+
function installPluginSkills(slug, plan, { homeDir, manifestUrl, meta } = {}) {
|
|
272
|
+
if (!PLUGIN_SKILL_SLUG_RE.test(String(slug || ""))) {
|
|
273
|
+
return { dir: "", installed: [], failed: [{ name: String(slug || ""), reason: "invalid plugin slug" }], verified: false };
|
|
274
|
+
}
|
|
275
|
+
const pluginDir = path.join(agentlasPluginsDir({ homeDir }), slug);
|
|
276
|
+
const installed = [];
|
|
277
|
+
const failed = [];
|
|
278
|
+
const markerSkills = [];
|
|
279
|
+
let allDeclared = true;
|
|
280
|
+
for (const skill of plan.skills || []) {
|
|
281
|
+
const written = [];
|
|
282
|
+
let mismatch = null;
|
|
283
|
+
for (const file of skill.files) {
|
|
284
|
+
const actual = crypto.createHash("sha256").update(file.content, "utf8").digest("hex");
|
|
285
|
+
if (file.sha256 && file.sha256 !== actual) { mismatch = `sha256 mismatch for ${file.path}`; break; }
|
|
286
|
+
if (!file.sha256) allDeclared = false;
|
|
287
|
+
written.push({ path: file.path, sha256: actual, verified: Boolean(file.sha256) });
|
|
288
|
+
}
|
|
289
|
+
if (mismatch) { failed.push({ name: skill.name, reason: mismatch }); continue; }
|
|
290
|
+
try {
|
|
291
|
+
const skillDir = path.join(pluginDir, "skills", skill.name);
|
|
292
|
+
for (const file of skill.files) {
|
|
293
|
+
const target = path.join(skillDir, file.path);
|
|
294
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
295
|
+
fs.writeFileSync(target, file.content, "utf8");
|
|
296
|
+
}
|
|
297
|
+
installed.push(skill.name);
|
|
298
|
+
markerSkills.push({ name: skill.name, files: written });
|
|
299
|
+
} catch (e) {
|
|
300
|
+
failed.push({ name: skill.name, reason: String((e && e.message) || e).slice(0, 160) });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const verified = installed.length > 0 && allDeclared;
|
|
304
|
+
if (installed.length > 0) {
|
|
305
|
+
// 마커는 마지막에 쓴다 — 마커가 있으면 스킬 파일도 있다는 뜻이어야 한다.
|
|
306
|
+
const marker = {
|
|
307
|
+
schema: "agentlas.local-plugin/v1",
|
|
308
|
+
slug,
|
|
309
|
+
name: (meta && meta.name) || slug,
|
|
310
|
+
family: (meta && meta.family) || null,
|
|
311
|
+
version: (meta && meta.version) || null,
|
|
312
|
+
installedAt: new Date().toISOString(),
|
|
313
|
+
installedBy: "agentlas-terminal",
|
|
314
|
+
source: { manifestUrl: manifestUrl || null, contentVerification: verified ? "manifest-sha256" : "none" },
|
|
315
|
+
skills: markerSkills,
|
|
316
|
+
};
|
|
317
|
+
try {
|
|
318
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
319
|
+
fs.writeFileSync(path.join(pluginDir, "plugin.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
|
|
320
|
+
} catch (e) {
|
|
321
|
+
failed.push({ name: "plugin.json", reason: String((e && e.message) || e).slice(0, 160) });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { dir: pluginDir, installed, failed, verified };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** ~/.agentlas/plugins/<slug>/plugin.json 마커들을 읽는다 — list의 설치 여부 표시용. */
|
|
328
|
+
function listInstalledLocalPlugins({ homeDir } = {}) {
|
|
329
|
+
const root = agentlasPluginsDir({ homeDir });
|
|
330
|
+
let names;
|
|
331
|
+
try {
|
|
332
|
+
names = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
333
|
+
} catch {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const out = [];
|
|
337
|
+
for (const name of names) {
|
|
338
|
+
if (name.startsWith(".")) continue;
|
|
339
|
+
try {
|
|
340
|
+
const marker = JSON.parse(fs.readFileSync(path.join(root, name, "plugin.json"), "utf8"));
|
|
341
|
+
out.push({
|
|
342
|
+
slug: String(marker.slug || name),
|
|
343
|
+
name: String(marker.name || name),
|
|
344
|
+
installedAt: marker.installedAt || null,
|
|
345
|
+
installedBy: marker.installedBy || null,
|
|
346
|
+
skills: Array.isArray(marker.skills) ? marker.skills.map((s) => String(s?.name || "")).filter(Boolean) : [],
|
|
347
|
+
dir: path.join(root, name),
|
|
348
|
+
});
|
|
349
|
+
} catch {
|
|
350
|
+
// 마커 없는 디렉터리는 다른 도구의 산출물일 수 있다 — 조용히 건너뛴다.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
196
356
|
/** Hub 플러그인 카탈로그 목록 (marketplace.list_plugins). 실패는 그대로 throw — 폴백 카탈로그 금지. */
|
|
197
357
|
async function listHubPlugins({ callTool } = {}) {
|
|
198
358
|
const call = callTool || callHubTool;
|
|
@@ -210,4 +370,9 @@ module.exports = {
|
|
|
210
370
|
planPluginMcpInstall,
|
|
211
371
|
installPluginMcpRows,
|
|
212
372
|
listHubPlugins,
|
|
373
|
+
agentlasPluginsDir,
|
|
374
|
+
pluginSkillSafeRelativePath,
|
|
375
|
+
planPluginSkillInstall,
|
|
376
|
+
installPluginSkills,
|
|
377
|
+
listInstalledLocalPlugins,
|
|
213
378
|
};
|
package/engine/mcp/consent.cjs
CHANGED
|
@@ -36,6 +36,82 @@ const {
|
|
|
36
36
|
const MCP_CONSENT_STATE_SCHEMA = "agentlas.terminal-mcp-consents.v1";
|
|
37
37
|
const MCP_CONSENT_RECEIPT_SCHEMA = "agentlas.terminal-mcp-consent.v1";
|
|
38
38
|
|
|
39
|
+
/*
|
|
40
|
+
* ── 통합 능력 승인(데스크탑 capability_grants)과의 합류 ───────────────────────
|
|
41
|
+
*
|
|
42
|
+
* 이 파일의 v1 계약(지문 일치 영수증)은 그대로다. 그 **위에** 공유 능력 규칙이 얹힌다:
|
|
43
|
+
* · 규칙이 deny 면 영수증이 있어도 붙이지 않는다(영구 거부는 어디서도 뚫리지 않는다).
|
|
44
|
+
* · 규칙이 allow 면 다시 묻지 않는다(데스크탑에서 누른 "항상 허용"이 여기서도 항상).
|
|
45
|
+
* · 규칙이 없으면 종전대로 1회 동의 프롬프트가 돈다.
|
|
46
|
+
* 터미널에서 "항상"을 고르면 같은 표에 써서 데스크탑에도 반영된다.
|
|
47
|
+
*
|
|
48
|
+
* MCP 서버를 붙이는 것은 외부 프로세스를 띄우는 일이라 능력 클래스는 execute 다.
|
|
49
|
+
* 규칙 키는 `tool:mcp:<catalogId>` + 패턴 없음(그 서버 전체) — 데스크탑의 도구 규칙과
|
|
50
|
+
* 같은 표·같은 판정 함수를 쓴다.
|
|
51
|
+
*/
|
|
52
|
+
const MCP_CAPABILITY_CLASS = "execute";
|
|
53
|
+
|
|
54
|
+
function mcpCapabilityQuery(catalogId) {
|
|
55
|
+
return { capability: MCP_CAPABILITY_CLASS, tool: `mcp:${String(catalogId)}`, detail: String(catalogId) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function capabilityGrantsModule() {
|
|
59
|
+
return require("../core/capability-grants.cjs");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 계획의 후보들을 공유 능력 규칙으로 미리 가른다.
|
|
64
|
+
* @returns {{available:string[], preApproved:string[], denied:string[],
|
|
65
|
+
* grantsAvailable:boolean, fallbackReason:string|null}}
|
|
66
|
+
* grantsAvailable=false 는 표가 없는 구버전 공유 DB — 사유를 담아 돌려주고 기존 동작으로 간다.
|
|
67
|
+
*/
|
|
68
|
+
function partitionMcpConsentByCapabilityGrants(db, catalogIds) {
|
|
69
|
+
const ids = [...new Set((catalogIds || []).map((id) => String(id)).filter(Boolean))];
|
|
70
|
+
const grants = capabilityGrantsModule();
|
|
71
|
+
if (!db || !grants.capabilityGrantsAvailable(db)) {
|
|
72
|
+
return {
|
|
73
|
+
available: ids,
|
|
74
|
+
preApproved: [],
|
|
75
|
+
denied: [],
|
|
76
|
+
grantsAvailable: false,
|
|
77
|
+
fallbackReason: db ? grants.UNAVAILABLE_REASON : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const available = [];
|
|
81
|
+
const preApproved = [];
|
|
82
|
+
const denied = [];
|
|
83
|
+
let fallbackReason = null;
|
|
84
|
+
for (const id of ids) {
|
|
85
|
+
let ruling;
|
|
86
|
+
try {
|
|
87
|
+
ruling = grants.readCapabilityDecision(db, mcpCapabilityQuery(id));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
fallbackReason = `capability_grants read failed: ${(error && error.message) || error}`;
|
|
90
|
+
available.push(id);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!ruling.available) {
|
|
94
|
+
fallbackReason = fallbackReason || ruling.reason;
|
|
95
|
+
available.push(id);
|
|
96
|
+
} else if (ruling.decision === "deny") denied.push(id);
|
|
97
|
+
else if (ruling.decision === "allow") preApproved.push(id);
|
|
98
|
+
else available.push(id);
|
|
99
|
+
}
|
|
100
|
+
return { available, preApproved, denied, grantsAvailable: true, fallbackReason };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 터미널에서 고른 "항상"을 데스크탑과 같은 표에 남긴다. 표가 없으면 정직한 실패. */
|
|
104
|
+
function rememberMcpCapabilityGrant(db, catalogId, decision = "allow") {
|
|
105
|
+
const grants = capabilityGrantsModule();
|
|
106
|
+
return grants.recordCapabilityGrant(db, {
|
|
107
|
+
capability: `tool:mcp:${String(catalogId)}`,
|
|
108
|
+
pattern: null,
|
|
109
|
+
decision: decision === "deny" ? "deny" : "allow",
|
|
110
|
+
scope: "global",
|
|
111
|
+
source: "terminal-mcp-consent",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
39
115
|
function mcpConsentStatePath(userDataDir) {
|
|
40
116
|
return path.join(userDataDir, "terminal", "mcp-consents-v1.json");
|
|
41
117
|
}
|
|
@@ -113,10 +189,16 @@ function readConsentedSystemMcpServers(db, options = {}) {
|
|
|
113
189
|
let state;
|
|
114
190
|
try { state = loadMcpConsentState(options.userDataDir); }
|
|
115
191
|
catch { return []; }
|
|
192
|
+
// 영구 거부(deny)는 옛 동의 영수증을 이긴다 — 규칙이 영수증보다 위다.
|
|
193
|
+
const partition = partitionMcpConsentByCapabilityGrants(
|
|
194
|
+
db,
|
|
195
|
+
state.receipts.map((receipt) => receipt.catalogId),
|
|
196
|
+
);
|
|
197
|
+
const denied = new Set(partition.denied);
|
|
116
198
|
const servers = [];
|
|
117
199
|
const seen = new Set();
|
|
118
200
|
for (const receipt of state.receipts) {
|
|
119
|
-
if (seen.has(receipt.catalogId)) continue;
|
|
201
|
+
if (seen.has(receipt.catalogId) || denied.has(receipt.catalogId)) continue;
|
|
120
202
|
let row = null;
|
|
121
203
|
try {
|
|
122
204
|
row = db.prepare(
|
|
@@ -134,26 +216,66 @@ function readConsentedSystemMcpServers(db, options = {}) {
|
|
|
134
216
|
return servers;
|
|
135
217
|
}
|
|
136
218
|
|
|
137
|
-
|
|
219
|
+
/**
|
|
220
|
+
* 답 한 줄을 해석한다. `always`/`a` 는 "전부 붙이고 **다시는 묻지 마라**" — 그 선택만
|
|
221
|
+
* 공유 능력 규칙(capability_grants)에 영구 기록된다. y/n/ids 는 종전 그대로 1회 한정이다.
|
|
222
|
+
*/
|
|
223
|
+
function parseConsentAnswer(answer, availableIds) {
|
|
138
224
|
const text = String(answer || "").trim();
|
|
139
|
-
if (/^(?:
|
|
140
|
-
if (
|
|
141
|
-
|
|
225
|
+
if (/^(?:a|always|항상)$/i.test(text)) return { ids: [...availableIds], always: true };
|
|
226
|
+
if (/^(?:y|yes|all|전체)$/i.test(text)) return { ids: [...availableIds], always: false };
|
|
227
|
+
if (!text || /^(?:n|no|none|없이|아니)$/i.test(text)) return { ids: [], always: false };
|
|
228
|
+
const requested = parseIdList(text.replace(/^always\s+/i, ""));
|
|
142
229
|
const allowed = new Set(availableIds);
|
|
143
|
-
return requested.filter((id) => allowed.has(id));
|
|
230
|
+
return { ids: requested.filter((id) => allowed.has(id)), always: /^always\s+/i.test(text) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeConsentAnswer(answer, availableIds) {
|
|
234
|
+
return parseConsentAnswer(answer, availableIds).ids;
|
|
144
235
|
}
|
|
145
236
|
|
|
146
237
|
function askMcpConsentOnce(plan, options = {}) {
|
|
147
238
|
const input = options.input || process.stdin;
|
|
148
239
|
const output = options.output || process.stderr;
|
|
149
|
-
|
|
150
|
-
|
|
240
|
+
/*
|
|
241
|
+
* 공유 능력 규칙을 먼저 본다(오너 결정 2026-08-20):
|
|
242
|
+
* deny → 후보에서 제외하고 묻지 않는다. allow → 묻지 않고 통과.
|
|
243
|
+
* 남은 후보만 사람에게 묻는다. 규칙이 없으면 목록이 그대로라 종전 동작과 동일하다.
|
|
244
|
+
*/
|
|
245
|
+
const partition = partitionMcpConsentByCapabilityGrants(options.db, plan.availableCatalogIds || []);
|
|
246
|
+
const askable = partition.available;
|
|
247
|
+
const preApproved = partition.preApproved;
|
|
248
|
+
const notify = typeof options.onNotice === "function" ? options.onNotice : null;
|
|
249
|
+
if (notify) {
|
|
250
|
+
if (partition.denied.length) {
|
|
251
|
+
notify(`MCP blocked by a shared capability rule (Desktop/Terminal): ${partition.denied.join(", ")}`);
|
|
252
|
+
}
|
|
253
|
+
if (preApproved.length) {
|
|
254
|
+
notify(`MCP already allowed always (shared capability rule): ${preApproved.join(", ")}`);
|
|
255
|
+
}
|
|
256
|
+
if (partition.fallbackReason) notify(partition.fallbackReason);
|
|
257
|
+
}
|
|
258
|
+
// TTY가 아니면(파이프/자동화) 묻지 않는다 — 조용한 전체 승인 금지.
|
|
259
|
+
// 이미 "항상 허용"된 것만은 사람에게 물을 필요가 없으므로 그대로 통과시킨다.
|
|
260
|
+
if (!input.isTTY || !output.isTTY || !askable.length) return Promise.resolve([...preApproved]);
|
|
151
261
|
const rl = readline.createInterface({ input, output, terminal: true });
|
|
152
262
|
return new Promise((resolve) => {
|
|
153
|
-
rl.question(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
263
|
+
rl.question(
|
|
264
|
+
"Attach the available MCP recommendations? [y=once / a=always / n=none / comma-separated ids] ",
|
|
265
|
+
(answer) => {
|
|
266
|
+
rl.close();
|
|
267
|
+
const parsed = parseConsentAnswer(answer, askable);
|
|
268
|
+
if (parsed.always && parsed.ids.length && options.db) {
|
|
269
|
+
for (const id of parsed.ids) {
|
|
270
|
+
const written = rememberMcpCapabilityGrant(options.db, id, "allow");
|
|
271
|
+
if (!written.ok && notify) notify(`"always" was not persisted for ${id}: ${written.reason}`);
|
|
272
|
+
}
|
|
273
|
+
} else if (parsed.always && parsed.ids.length && notify) {
|
|
274
|
+
notify("\"always\" was not persisted: no shared database handle was given to the consent prompt.");
|
|
275
|
+
}
|
|
276
|
+
resolve([...new Set([...preApproved, ...parsed.ids])]);
|
|
277
|
+
},
|
|
278
|
+
);
|
|
157
279
|
});
|
|
158
280
|
}
|
|
159
281
|
|
|
@@ -284,6 +406,12 @@ module.exports = {
|
|
|
284
406
|
persistMcpConsentReceipts,
|
|
285
407
|
readConsentedSystemMcpServers,
|
|
286
408
|
normalizeConsentAnswer,
|
|
409
|
+
parseConsentAnswer,
|
|
287
410
|
askMcpConsentOnce,
|
|
288
411
|
resolveApprovedMcpRuntimeAllowlist,
|
|
412
|
+
// 공유 능력 승인(데스크탑 capability_grants) 합류 표면.
|
|
413
|
+
MCP_CAPABILITY_CLASS,
|
|
414
|
+
mcpCapabilityQuery,
|
|
415
|
+
partitionMcpConsentByCapabilityGrants,
|
|
416
|
+
rememberMcpCapabilityGrant,
|
|
289
417
|
};
|
package/engine/mcp/index.cjs
CHANGED
|
@@ -27,6 +27,7 @@ module.exports = {
|
|
|
27
27
|
probeSystemMcpServerConnection: probe.probeSystemMcpServerConnection,
|
|
28
28
|
// plan — 요구사항 해소 + 빌드 플랜 + 빌더 지시문
|
|
29
29
|
resolveMcpRequirement: plan.resolveMcpRequirement,
|
|
30
|
+
inferRequirements: plan.inferRequirements,
|
|
30
31
|
buildMcpPlan: plan.buildMcpPlan,
|
|
31
32
|
renderMcpPlan: plan.renderMcpPlan,
|
|
32
33
|
fitApprovedMcpIds: plan.fitApprovedMcpIds,
|