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.
Files changed (56) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/engine/acp/server.cjs +47 -17
  3. package/engine/agentlas-evolution.cjs +85 -36
  4. package/engine/agentlas-experience-intake.cjs +21 -8
  5. package/engine/agentlas-memory-governance.cjs +88 -10
  6. package/engine/agentlas-permissions.cjs +95 -1
  7. package/engine/agentlas-tools.cjs +45 -2
  8. package/engine/agentlas.cjs +6 -3
  9. package/engine/agents/builder.cjs +4 -0
  10. package/engine/architecture.data.json +1 -1
  11. package/engine/automation/daemon.cjs +36 -3
  12. package/engine/automation/store.cjs +13 -11
  13. package/engine/bootstrap-schema.sql +216 -24
  14. package/engine/cloud/auth.cjs +34 -8
  15. package/engine/cloud-assets/cas.cjs +27 -1
  16. package/engine/cloud-assets/package.cjs +126 -0
  17. package/engine/cloud-assets/upload-scan-catalog.generated.cjs +13 -0
  18. package/engine/commands/career-graph.cjs +1 -1
  19. package/engine/commands/connect.cjs +34 -6
  20. package/engine/commands/graph.cjs +6 -2
  21. package/engine/commands/index.cjs +4 -1
  22. package/engine/commands/oberon.cjs +1 -1
  23. package/engine/commands/one.cjs +307 -0
  24. package/engine/commands/ontology.cjs +2 -2
  25. package/engine/commands/plugin.cjs +61 -16
  26. package/engine/commands/uninstall.cjs +43 -13
  27. package/engine/commands/update.cjs +25 -4
  28. package/engine/core/capability-grants.cjs +204 -0
  29. package/engine/core/desktop-core-fetch.cjs +94 -21
  30. package/engine/core/desktop-core.cjs +22 -0
  31. package/engine/experience/build.cjs +8 -0
  32. package/engine/graph/node-effect.cjs +56 -0
  33. package/engine/graph/package.cjs +6 -1
  34. package/engine/graph/vocabulary.generated.cjs +1 -1
  35. package/engine/hephaestus/local-core.cjs +44 -9
  36. package/engine/hub/install.cjs +7 -3
  37. package/engine/hub/plugins.cjs +165 -0
  38. package/engine/mcp/consent.cjs +140 -12
  39. package/engine/mcp/index.cjs +1 -0
  40. package/engine/mcp/plan.cjs +63 -8
  41. package/engine/memory-cli/curate.cjs +5 -2
  42. package/engine/oberon/outputs.cjs +10 -3
  43. package/engine/project/career-graph.cjs +9 -12
  44. package/engine/project/memory-context.cjs +9 -6
  45. package/engine/project/ontology.cjs +88 -36
  46. package/engine/runtimes/acp-driver.cjs +42 -3
  47. package/engine/sessions/memory-turn.cjs +39 -0
  48. package/engine/sessions/orchestrator.cjs +53 -4
  49. package/engine/sessions/session.cjs +28 -2
  50. package/engine/sessions/store.cjs +48 -12
  51. package/engine/telegram/connect.cjs +31 -10
  52. package/engine/ui/commands-catalog.cjs +1 -0
  53. package/engine/ui/repl.cjs +3 -1
  54. package/engine/ui/screens.cjs +30 -6
  55. package/engine/vendor/desktop-core.manifest.json +5 -5
  56. 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
+ };
@@ -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.config?.effect === "mutation")
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 && fs.existsSync(candidate)) return 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
- child.on("exit", () => {
75
+ const failPending = (code, message) => {
76
+ if (exited) return;
73
77
  exited = true;
74
78
  for (const [, entry] of pending) {
75
- entry.reject(coreError("local_core_exited", "the local core process exited before responding"));
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
- child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
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 = rpc("initialize", {
113
- protocolVersion: "2024-11-05",
114
- capabilities: {},
115
- clientInfo: { name: "agentlas-terminal", version: "2" },
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
  }
@@ -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
- const CLOUD_MAX_TOTAL_BYTES = 3 * 1024 * 1024;
31
- const CLOUD_MAX_FILE_BYTES = 512 * 1024;
32
- const CLOUD_MAX_FILES = 400;
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";
@@ -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
  };
@@ -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
- function normalizeConsentAnswer(answer, availableIds) {
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 (/^(?:y|yes|all|전체)$/i.test(text)) return [...availableIds];
140
- if (!text || /^(?:n|no|none|없이|아니)$/i.test(text)) return [];
141
- const requested = parseIdList(text);
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
- // TTY가 아니면(파이프/자동화) 묻지 않고 빈 승인 — 조용한 전체 승인 금지.
150
- if (!input.isTTY || !output.isTTY || !plan.availableCatalogIds.length) return Promise.resolve([]);
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("Attach the available MCP recommendations? [y=all / n=none / comma-separated ids] ", (answer) => {
154
- rl.close();
155
- resolve(normalizeConsentAnswer(answer, plan.availableCatalogIds));
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
  };
@@ -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,