agentlas 0.6.0 → 0.9.1
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 +190 -0
- package/README.md +220 -4
- package/bin/agentlas.cjs +8 -8
- package/engine/agentlas-capabilities.cjs +34 -3
- package/engine/agentlas-core-harness.cjs +205 -0
- package/engine/agentlas-desktop-loadout.cjs +527 -0
- package/engine/agentlas-doctor.cjs +1 -1
- package/engine/agentlas-experience-exchange.cjs +2151 -0
- package/engine/agentlas-experience-intake.cjs +444 -0
- package/engine/agentlas-experience-mcp.cjs +1709 -0
- package/engine/agentlas-i18n.cjs +10 -10
- package/engine/agentlas-input.cjs +5 -4
- package/engine/agentlas-mcp-env.cjs +219 -0
- package/engine/agentlas-mcp-wrapper.cjs +51 -0
- package/engine/agentlas-memory-governance.cjs +1029 -0
- package/engine/agentlas-native-host.cjs +129 -39
- package/engine/agentlas-parity.cjs +339 -154
- package/engine/agentlas-repl.cjs +327 -44
- package/engine/agentlas-workforce.cjs +2991 -0
- package/engine/agentlas-workload-routing.cjs +523 -0
- package/engine/agentlas.cjs +1886 -270
- package/engine/bootstrap-schema.sql +1 -1
- package/engine/experience-taxonomy-v1.json +49 -0
- package/package.json +8 -4
- package/scripts/gen-bootstrap-schema.sh +0 -23
- package/test/bootstrap-race.cjs +0 -47
- package/test/capture-runtime-guard.cjs +0 -122
- package/test/cloud-asset-restore.cjs +0 -423
- package/test/cloud-cas-client.cjs +0 -333
- package/test/cloud-owner-restore.cjs +0 -183
- package/test/cloud-runtime-paths.cjs +0 -40
- package/test/cloud-save-publish.cjs +0 -453
- package/test/credential-env-regression.cjs +0 -52
- package/test/login-loopback-security.cjs +0 -115
- package/test/mcp-config-isolation.cjs +0 -36
- package/test/permission-mapping.cjs +0 -180
- package/test/route-regression.cjs +0 -121
- package/test/run-api-regression.cjs +0 -322
- package/test/runtime-env-protection.cjs +0 -45
- package/test/semver-precedence.cjs +0 -39
- package/test/smoke.sh +0 -90
- package/test/sqlite-driver-probe.cjs +0 -22
- package/test/terminal-ui-regression.cjs +0 -472
- package/test/timeout-regression.cjs +0 -218
- package/test/tool-workspace-boundary.cjs +0 -165
- package/test/update-safety.cjs +0 -376
|
@@ -0,0 +1,1709 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Terminal-owned Experience/MCP v1 surface.
|
|
5
|
+
*
|
|
6
|
+
* Boundaries:
|
|
7
|
+
* - Experience publication commands persist local intent only. They never call
|
|
8
|
+
* the Hub and never manufacture a server receipt.
|
|
9
|
+
* - MCP discovery reads Agentlas' trusted system-global registry metadata. It
|
|
10
|
+
* never executes, connects, downloads, or copies server definitions.
|
|
11
|
+
* - Credential values, MCP commands/args/URLs, and base-agent bytes never enter
|
|
12
|
+
* the public projection or the builder directive.
|
|
13
|
+
* - This store is private Terminal state, not a Desktop SQLite mirror.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require("node:crypto");
|
|
17
|
+
const { spawn } = require("node:child_process");
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const os = require("node:os");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
const readline = require("node:readline");
|
|
22
|
+
const {
|
|
23
|
+
buildMcpChildEnv,
|
|
24
|
+
hasSensitiveRuntimeArgument,
|
|
25
|
+
mcpRuntimeHome,
|
|
26
|
+
normalizeCredentialKeyNames,
|
|
27
|
+
} = require("./agentlas-mcp-env.cjs");
|
|
28
|
+
|
|
29
|
+
const TOKEN_BUDGET = Object.freeze({
|
|
30
|
+
coreMemoryMaxTokens: 150,
|
|
31
|
+
experienceRetrievalMaxTokens: 800,
|
|
32
|
+
experienceRetrievalMaxItems: 8,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{2,255}$/;
|
|
36
|
+
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
37
|
+
const ENV_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
38
|
+
const EXPERIENCE_STATE_SCHEMA = "agentlas.terminal-experience-intents.v1";
|
|
39
|
+
const EXPERIENCE_INTENT_SCHEMA = "agentlas.terminal-experience-intent.v1";
|
|
40
|
+
const MCP_CONSENT_STATE_SCHEMA = "agentlas.terminal-mcp-consents.v1";
|
|
41
|
+
const MCP_CONSENT_RECEIPT_SCHEMA = "agentlas.terminal-mcp-consent.v1";
|
|
42
|
+
const MAX_JSON_BYTES = 2 * 1024 * 1024;
|
|
43
|
+
const MAX_BUILD_DIRECTIVE_CHARS = 1400;
|
|
44
|
+
const MAX_APPROVED_MCP_PER_BUILD = 8;
|
|
45
|
+
const MCP_PROBE_CONCURRENCY = 3;
|
|
46
|
+
const MCP_PROBE_PER_SERVER_TIMEOUT_MS = 8_000;
|
|
47
|
+
const MCP_PROBE_TOTAL_TIMEOUT_MS = 12_000;
|
|
48
|
+
const EXPERIENCE_LOCK_STALE_MS = 30_000;
|
|
49
|
+
const EXPERIENCE_LOCK_WAIT_MS = 2_000;
|
|
50
|
+
|
|
51
|
+
const EXPERIENCE_PACK_REQUIRED = [
|
|
52
|
+
"schemaVersion", "kind", "experiencePackId", "releaseId", "ownerRef", "version",
|
|
53
|
+
"baseCompatibility", "itemIds", "evidenceReceiptIds", "mcpRequirements",
|
|
54
|
+
"containsBasePackageMaterial", "contentHash", "visibility", "status",
|
|
55
|
+
];
|
|
56
|
+
const EXPERIENCE_PACK_ALLOWED = new Set([...EXPERIENCE_PACK_REQUIRED, "createdAt", "releasedAt", "withdrawnAt"]);
|
|
57
|
+
const MCP_REQUIREMENT_REQUIRED = [
|
|
58
|
+
"schemaVersion", "kind", "requirementId", "catalogId", "reason", "capabilities",
|
|
59
|
+
"required", "requiresKey", "priority", "permissions", "alternatives", "unavailablePolicy",
|
|
60
|
+
];
|
|
61
|
+
const MCP_REQUIREMENT_ALLOWED = new Set([...MCP_REQUIREMENT_REQUIRED, "credentialMetadata"]);
|
|
62
|
+
|
|
63
|
+
// Public contract text must be compact, value-free, and instruction-safe.
|
|
64
|
+
const UNSAFE_TEXT_PATTERNS = [
|
|
65
|
+
{ code: "openai-secret", re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
|
|
66
|
+
{ code: "github-secret", re: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
|
|
67
|
+
{ code: "aws-secret", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
68
|
+
{ code: "private-key", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i },
|
|
69
|
+
{ code: "credential", re: /(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|password|private[_ -]?key|authorization)\s*[:=]\s*\S+/i },
|
|
70
|
+
{ code: "bearer", re: /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i },
|
|
71
|
+
{ code: "private-path", re: /(?:file:\/\/|(?:^|[\s"'`()\[\]{}=:,;])(?:\.\.[/\\]|~[/\\]|\/(?!\/|\s)(?:[^/\s"'`<>]+\/)*[^/\s"'`<>]+|[A-Za-z]:[/\\]\S+|\\\\[^\\/\s]+[\\/][^\\/\s]+))/i },
|
|
72
|
+
{ code: "email", re: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i },
|
|
73
|
+
{ code: "phone", re: /(?:\+?\d[\d .()-]{8,}\d)/ },
|
|
74
|
+
{ code: "account-id", re: /\b(?:account|customer|client|user)[ _-]?(?:id|number|no)\s*[:=#]?\s*[A-Za-z0-9_-]{4,}\b|(?:계정|고객|사용자)[ _-]?(?:id|아이디|번호)\s*[:=#]?\s*[A-Za-z0-9_-]{4,}/i },
|
|
75
|
+
{ code: "raw-prompt", re: /(?:raw[_ -]?prompt|full[_ -]?transcript|conversation[_ -]?dump|system[_ -]?prompt|(?:^|\n)\s*(?:system|assistant|user|tool)\s*:|(?:AGENTS|CLAUDE|GEMINI)\.md|\.agentlas[\\/])/i },
|
|
76
|
+
{ code: "prompt-injection", re: /(?:ignore|disregard|override)[\s_-]+(?:all[\s_-]+)?(?:previous|prior|system|developer|hidden)[\s_-]+(?:instructions?|prompts?|rules?|directives?)/i },
|
|
77
|
+
{ code: "exfiltration", re: /(?:reveal|show|print|dump|expose|leak|send|upload|exfiltrate|steal)[\s_-]+(?:(?:the|all)[\s_-]+)?(?:secret|credential|token|cookie|password|private[\s_-]?key|api[\s_-]?key)/i },
|
|
78
|
+
{ code: "safety-bypass", re: /(?:disable|bypass|skip|remove|turn[\s_-]+off)[\s_-]+(?:(?:the|all)[\s_-]+)?(?:safety|guardrails?|approval|consent|permission[\s_-]?checks?|security[\s_-]?checks?)/i },
|
|
79
|
+
{ code: "opaque-blob", re: /\b(?:[A-Fa-f0-9]{128,}|[A-Za-z0-9+/]{124,}={0,2})\b/ },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
function assertObject(value, label) {
|
|
83
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertExactKeys(value, allowed, required, label) {
|
|
88
|
+
assertObject(value, label);
|
|
89
|
+
for (const key of Object.keys(value)) {
|
|
90
|
+
if (!allowed.has(key)) throw new Error(`${label} has an unsupported field: ${key}`);
|
|
91
|
+
}
|
|
92
|
+
for (const key of required) {
|
|
93
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) throw new Error(`${label} is missing: ${key}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function assertId(value, label) {
|
|
98
|
+
if (!ID_RE.test(String(value || ""))) throw new Error(`${label} is not a valid Agentlas id`);
|
|
99
|
+
return String(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertUniqueIds(value, label, options = {}) {
|
|
103
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
104
|
+
if (options.min && value.length < options.min) throw new Error(`${label} must have at least ${options.min} item(s)`);
|
|
105
|
+
if (options.max && value.length > options.max) throw new Error(`${label} has too many items`);
|
|
106
|
+
const items = value.map((item, index) => assertId(item, `${label}[${index}]`));
|
|
107
|
+
if (new Set(items).size !== items.length) throw new Error(`${label} must be unique`);
|
|
108
|
+
return items;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertSafeText(value, label, max = 300) {
|
|
112
|
+
const text = String(value || "").trim();
|
|
113
|
+
if (!text || text.length > max || /[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/i.test(text)) throw new Error(`${label} must be compact single-line text`);
|
|
114
|
+
const unsafe = UNSAFE_TEXT_PATTERNS.find((pattern) => pattern.re.test(text));
|
|
115
|
+
if (unsafe) throw new Error(`${label} is not public-safe (${unsafe.code})`);
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function assertIsoDateOrNull(value, label) {
|
|
120
|
+
if (value == null) return null;
|
|
121
|
+
if (typeof value !== "string" || !value || !Number.isFinite(Date.parse(value))) throw new Error(`${label} must be an ISO date-time or null`);
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function validateCredentialMetadata(value, label) {
|
|
126
|
+
const allowed = new Set(["provider", "env", "allowedHosts", "scopes", "setupUrl", "brokerMode"]);
|
|
127
|
+
assertExactKeys(value, allowed, ["provider", "env"], label);
|
|
128
|
+
assertId(value.provider, `${label}.provider`);
|
|
129
|
+
if (!Array.isArray(value.env) || !value.env.length || new Set(value.env).size !== value.env.length || value.env.some((key) => !ENV_RE.test(String(key)))) {
|
|
130
|
+
throw new Error(`${label}.env must contain unique uppercase environment names`);
|
|
131
|
+
}
|
|
132
|
+
assertSafeText(value.provider, `${label}.provider`, 255);
|
|
133
|
+
value.env.forEach((key, index) => assertSafeText(key, `${label}.env[${index}]`, 255));
|
|
134
|
+
if (value.allowedHosts != null) {
|
|
135
|
+
if (!Array.isArray(value.allowedHosts) || !value.allowedHosts.length || new Set(value.allowedHosts).size !== value.allowedHosts.length) {
|
|
136
|
+
throw new Error(`${label}.allowedHosts must be a non-empty unique list`);
|
|
137
|
+
}
|
|
138
|
+
const hostRe = /^(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
|
|
139
|
+
for (const host of value.allowedHosts) {
|
|
140
|
+
if (typeof host !== "string" || host.length > 255 || !hostRe.test(host)) throw new Error(`${label}.allowedHosts contains an invalid host`);
|
|
141
|
+
assertSafeText(host, `${label}.allowedHosts`, 255);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (value.scopes != null) {
|
|
145
|
+
if (!Array.isArray(value.scopes) || !value.scopes.length || new Set(value.scopes).size !== value.scopes.length) {
|
|
146
|
+
throw new Error(`${label}.scopes must be a non-empty unique list`);
|
|
147
|
+
}
|
|
148
|
+
for (const scope of value.scopes) {
|
|
149
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/.test(String(scope))) throw new Error(`${label}.scopes contains an invalid scope`);
|
|
150
|
+
assertSafeText(scope, `${label}.scopes`, 128);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (value.setupUrl != null) {
|
|
154
|
+
let parsed;
|
|
155
|
+
try { parsed = new URL(value.setupUrl); } catch { throw new Error(`${label}.setupUrl must be a safe HTTPS provider page`); }
|
|
156
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port || parsed.search || parsed.hash) {
|
|
157
|
+
throw new Error(`${label}.setupUrl must be HTTPS without userinfo, custom port, query, or fragment`);
|
|
158
|
+
}
|
|
159
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/.test(parsed.hostname)) {
|
|
160
|
+
throw new Error(`${label}.setupUrl hostname is invalid`);
|
|
161
|
+
}
|
|
162
|
+
assertSafeText(value.setupUrl, `${label}.setupUrl`, 2048);
|
|
163
|
+
}
|
|
164
|
+
if (value.brokerMode != null && !["host-bound-broker", "runtime-env-injection", "provider-managed-oauth", "manual-provider-page"].includes(value.brokerMode)) {
|
|
165
|
+
throw new Error(`${label}.brokerMode is invalid`);
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateMcpRequirement(value, label = "mcpRequirement") {
|
|
171
|
+
assertExactKeys(value, MCP_REQUIREMENT_ALLOWED, MCP_REQUIREMENT_REQUIRED, label);
|
|
172
|
+
if (value.schemaVersion !== "agentlas.mcp-requirement.v1" || value.kind !== "agentlas-mcp-requirement") {
|
|
173
|
+
throw new Error(`${label} has an unsupported schema`);
|
|
174
|
+
}
|
|
175
|
+
assertId(value.requirementId, `${label}.requirementId`);
|
|
176
|
+
assertId(value.catalogId, `${label}.catalogId`);
|
|
177
|
+
assertSafeText(value.reason, `${label}.reason`, 300);
|
|
178
|
+
const capabilities = assertUniqueIds(value.capabilities, `${label}.capabilities`, { min: 1 });
|
|
179
|
+
if (typeof value.required !== "boolean" || typeof value.requiresKey !== "boolean") throw new Error(`${label} required/requiresKey must be boolean`);
|
|
180
|
+
if (!Number.isInteger(value.priority) || value.priority < 1 || value.priority > 1000) throw new Error(`${label}.priority is invalid`);
|
|
181
|
+
const permissions = assertUniqueIds(value.permissions, `${label}.permissions`);
|
|
182
|
+
const alternatives = assertUniqueIds(value.alternatives, `${label}.alternatives`);
|
|
183
|
+
if (alternatives.includes(value.catalogId)) throw new Error(`${label}.alternatives must not contain the primary catalogId`);
|
|
184
|
+
[...capabilities, ...permissions, ...alternatives].forEach((text, index) => assertSafeText(text, `${label}.publicText[${index}]`, 255));
|
|
185
|
+
const unavailable = assertObject(value.unavailablePolicy, `${label}.unavailablePolicy`);
|
|
186
|
+
assertExactKeys(unavailable, new Set(["build", "rental", "execution"]), ["build", "rental", "execution"], `${label}.unavailablePolicy`);
|
|
187
|
+
if (unavailable.build !== "degrade") throw new Error(`${label} must degrade rather than abort a build`);
|
|
188
|
+
const expectedRental = value.required ? "exclude-variant" : "continue-degraded";
|
|
189
|
+
if (unavailable.rental !== expectedRental) throw new Error(`${label} rental policy must be ${expectedRental}`);
|
|
190
|
+
if (!["use-alternative", "disable-capability", "continue-degraded"].includes(unavailable.execution)) throw new Error(`${label} execution policy is invalid`);
|
|
191
|
+
if (value.credentialMetadata != null) validateCredentialMetadata(value.credentialMetadata, `${label}.credentialMetadata`);
|
|
192
|
+
if (value.requiresKey && value.credentialMetadata == null) throw new Error(`${label} requires credential metadata`);
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateExperiencePack(value) {
|
|
197
|
+
assertExactKeys(value, EXPERIENCE_PACK_ALLOWED, EXPERIENCE_PACK_REQUIRED, "experience pack");
|
|
198
|
+
if (value.schemaVersion !== "agentlas.experience-pack.v1" || value.kind !== "agentlas-experience-pack") {
|
|
199
|
+
throw new Error("experience pack has an unsupported schema");
|
|
200
|
+
}
|
|
201
|
+
for (const key of ["experiencePackId", "releaseId", "ownerRef", "version"]) assertId(value[key], `experience pack.${key}`);
|
|
202
|
+
const base = assertObject(value.baseCompatibility, "experience pack.baseCompatibility");
|
|
203
|
+
assertExactKeys(base, new Set(["agentDefinitionId", "compatibleBaseReleaseIds"]), ["agentDefinitionId", "compatibleBaseReleaseIds"], "experience pack.baseCompatibility");
|
|
204
|
+
assertId(base.agentDefinitionId, "experience pack.baseCompatibility.agentDefinitionId");
|
|
205
|
+
assertUniqueIds(base.compatibleBaseReleaseIds, "experience pack.baseCompatibility.compatibleBaseReleaseIds", { min: 1 });
|
|
206
|
+
assertUniqueIds(value.itemIds, "experience pack.itemIds", { min: value.status === "active" ? 1 : 0 });
|
|
207
|
+
assertUniqueIds(value.evidenceReceiptIds, "experience pack.evidenceReceiptIds");
|
|
208
|
+
if (!Array.isArray(value.mcpRequirements) || value.mcpRequirements.length > 64) throw new Error("experience pack.mcpRequirements is invalid");
|
|
209
|
+
value.mcpRequirements.forEach((requirement, index) => validateMcpRequirement(requirement, `experience pack.mcpRequirements[${index}]`));
|
|
210
|
+
if (value.containsBasePackageMaterial !== false) throw new Error("experience pack must reference the base release; copied base material is forbidden");
|
|
211
|
+
if (!HASH_RE.test(String(value.contentHash || ""))) throw new Error("experience pack.contentHash is invalid");
|
|
212
|
+
if (!["private", "unlisted", "public"].includes(value.visibility)) throw new Error("experience pack.visibility is invalid");
|
|
213
|
+
if (!["draft", "active", "suspended", "withdrawn", "deleted"].includes(value.status)) throw new Error("experience pack.status is invalid");
|
|
214
|
+
for (const key of ["createdAt", "releasedAt", "withdrawnAt"]) if (Object.prototype.hasOwnProperty.call(value, key)) assertIsoDateOrNull(value[key], `experience pack.${key}`);
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readJsonFile(filePath, label) {
|
|
219
|
+
const absolute = path.resolve(filePath);
|
|
220
|
+
const stat = fs.lstatSync(absolute);
|
|
221
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${label} must be a regular file (symlinks are not accepted)`);
|
|
222
|
+
if (stat.size <= 0 || stat.size > MAX_JSON_BYTES) throw new Error(`${label} has an invalid size`);
|
|
223
|
+
let value;
|
|
224
|
+
try { value = JSON.parse(fs.readFileSync(absolute, "utf8")); } catch (error) { throw new Error(`${label} is not valid JSON: ${error.message}`); }
|
|
225
|
+
return { absolute, value };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function writePrivateJsonAtomic(filePath, value) {
|
|
229
|
+
const dir = path.dirname(filePath);
|
|
230
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
231
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
232
|
+
const temp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
233
|
+
try {
|
|
234
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
235
|
+
fs.renameSync(temp, filePath);
|
|
236
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* best effort */ }
|
|
237
|
+
} finally {
|
|
238
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* noop */ }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function experienceStatePath(userDataDir) {
|
|
243
|
+
return path.join(userDataDir, "terminal", "experience-intents-v1.json");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function mcpConsentStatePath(userDataDir) {
|
|
247
|
+
return path.join(userDataDir, "terminal", "mcp-consents-v1.json");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function waitSync(milliseconds) {
|
|
251
|
+
// Atomics.wait is a bounded, non-spinning sleep available in supported Node 20+.
|
|
252
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
253
|
+
Atomics.wait(signal, 0, 0, milliseconds);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function emptyMcpConsentState() {
|
|
257
|
+
return { schemaVersion: MCP_CONSENT_STATE_SCHEMA, updatedAt: null, receipts: [] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function validateMcpConsentReceipt(receipt, index) {
|
|
261
|
+
const label = `Terminal MCP consent.receipts[${index}]`;
|
|
262
|
+
const keys = ["schemaVersion", "catalogId", "registryServerId", "consentFingerprint", "source", "consentedAt"];
|
|
263
|
+
assertExactKeys(receipt, new Set(keys), keys, label);
|
|
264
|
+
if (receipt.schemaVersion !== MCP_CONSENT_RECEIPT_SCHEMA) throw new Error(`${label}.schemaVersion is invalid`);
|
|
265
|
+
assertId(receipt.catalogId, `${label}.catalogId`);
|
|
266
|
+
assertId(receipt.registryServerId, `${label}.registryServerId`);
|
|
267
|
+
if (!/^[0-9a-f]{64}$/.test(String(receipt.consentFingerprint || ""))) throw new Error(`${label}.consentFingerprint is invalid`);
|
|
268
|
+
if (receipt.source !== "terminal-build-one-pass") throw new Error(`${label}.source is invalid`);
|
|
269
|
+
if (typeof receipt.consentedAt !== "string" || !receipt.consentedAt) throw new Error(`${label}.consentedAt is invalid`);
|
|
270
|
+
assertIsoDateOrNull(receipt.consentedAt, `${label}.consentedAt`);
|
|
271
|
+
return receipt;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function loadMcpConsentState(userDataDir) {
|
|
275
|
+
const file = mcpConsentStatePath(userDataDir);
|
|
276
|
+
if (!fs.existsSync(file)) return emptyMcpConsentState();
|
|
277
|
+
const stat = fs.lstatSync(file);
|
|
278
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > MAX_JSON_BYTES) {
|
|
279
|
+
throw new Error("Terminal MCP consent state is unsafe or too large");
|
|
280
|
+
}
|
|
281
|
+
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
282
|
+
assertExactKeys(state, new Set(["schemaVersion", "updatedAt", "receipts"]), ["schemaVersion", "updatedAt", "receipts"], "Terminal MCP consent state");
|
|
283
|
+
if (state.schemaVersion !== MCP_CONSENT_STATE_SCHEMA || !Array.isArray(state.receipts) || state.receipts.length > 256) {
|
|
284
|
+
throw new Error("Terminal MCP consent state schema is invalid");
|
|
285
|
+
}
|
|
286
|
+
assertIsoDateOrNull(state.updatedAt, "Terminal MCP consent state.updatedAt");
|
|
287
|
+
state.receipts.forEach(validateMcpConsentReceipt);
|
|
288
|
+
return state;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function withMcpConsentStateLock(userDataDir, action) {
|
|
292
|
+
const stateFile = mcpConsentStatePath(userDataDir);
|
|
293
|
+
const dir = path.dirname(stateFile);
|
|
294
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
295
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
296
|
+
const lockFile = `${stateFile}.lock`;
|
|
297
|
+
const deadline = Date.now() + EXPERIENCE_LOCK_WAIT_MS;
|
|
298
|
+
let descriptor = null;
|
|
299
|
+
while (descriptor == null) {
|
|
300
|
+
try {
|
|
301
|
+
descriptor = fs.openSync(lockFile, "wx", 0o600);
|
|
302
|
+
fs.writeFileSync(descriptor, `${process.pid}\n${new Date().toISOString()}\n`, "utf8");
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (!error || error.code !== "EEXIST") throw error;
|
|
305
|
+
try {
|
|
306
|
+
const stat = fs.lstatSync(lockFile);
|
|
307
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Terminal MCP consent lock is unsafe");
|
|
308
|
+
if (Date.now() - stat.mtimeMs > EXPERIENCE_LOCK_STALE_MS) {
|
|
309
|
+
fs.unlinkSync(lockFile);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
} catch (statError) {
|
|
313
|
+
if (statError && statError.code === "ENOENT") continue;
|
|
314
|
+
throw statError;
|
|
315
|
+
}
|
|
316
|
+
if (Date.now() >= deadline) throw new Error("Terminal MCP consent state is busy; retry the command");
|
|
317
|
+
waitSync(25);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
return action();
|
|
322
|
+
} finally {
|
|
323
|
+
try { fs.closeSync(descriptor); } catch { /* noop */ }
|
|
324
|
+
try { fs.unlinkSync(lockFile); } catch { /* crash recovery handles leftovers */ }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function withExperienceStateLock(userDataDir, action) {
|
|
329
|
+
const stateFile = experienceStatePath(userDataDir);
|
|
330
|
+
const dir = path.dirname(stateFile);
|
|
331
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
332
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
333
|
+
const lockFile = `${stateFile}.lock`;
|
|
334
|
+
const deadline = Date.now() + EXPERIENCE_LOCK_WAIT_MS;
|
|
335
|
+
let descriptor = null;
|
|
336
|
+
while (descriptor == null) {
|
|
337
|
+
try {
|
|
338
|
+
descriptor = fs.openSync(lockFile, "wx", 0o600);
|
|
339
|
+
fs.writeFileSync(descriptor, `${process.pid}\n${new Date().toISOString()}\n`, "utf8");
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (!error || error.code !== "EEXIST") throw error;
|
|
342
|
+
try {
|
|
343
|
+
const stat = fs.lstatSync(lockFile);
|
|
344
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Terminal experience lock is unsafe");
|
|
345
|
+
if (Date.now() - stat.mtimeMs > EXPERIENCE_LOCK_STALE_MS) {
|
|
346
|
+
fs.unlinkSync(lockFile);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
} catch (statError) {
|
|
350
|
+
if (statError && statError.code === "ENOENT") continue;
|
|
351
|
+
throw statError;
|
|
352
|
+
}
|
|
353
|
+
if (Date.now() >= deadline) throw new Error("Terminal experience state is busy; retry the command");
|
|
354
|
+
waitSync(25);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
return action();
|
|
359
|
+
} finally {
|
|
360
|
+
try { fs.closeSync(descriptor); } catch { /* noop */ }
|
|
361
|
+
try { fs.unlinkSync(lockFile); } catch { /* crash recovery handles leftovers */ }
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function emptyExperienceState() {
|
|
366
|
+
return { schemaVersion: EXPERIENCE_STATE_SCHEMA, updatedAt: null, intents: [] };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function loadExperienceState(userDataDir) {
|
|
370
|
+
const file = experienceStatePath(userDataDir);
|
|
371
|
+
if (!fs.existsSync(file)) return emptyExperienceState();
|
|
372
|
+
const stat = fs.lstatSync(file);
|
|
373
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_JSON_BYTES) throw new Error("Terminal experience state is unsafe or too large");
|
|
374
|
+
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
375
|
+
assertExactKeys(state, new Set(["schemaVersion", "updatedAt", "intents"]), ["schemaVersion", "updatedAt", "intents"], "Terminal experience state");
|
|
376
|
+
if (state.schemaVersion !== EXPERIENCE_STATE_SCHEMA || !Array.isArray(state.intents)) throw new Error("Terminal experience state schema is invalid");
|
|
377
|
+
assertIsoDateOrNull(state.updatedAt, "Terminal experience state.updatedAt");
|
|
378
|
+
state.intents.forEach((intent, index) => validateStoredExperienceIntent(intent, index));
|
|
379
|
+
return state;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function validateStoredExperienceIntent(intent, index) {
|
|
383
|
+
const label = `Terminal experience state.intents[${index}]`;
|
|
384
|
+
const required = [
|
|
385
|
+
"schemaVersion", "intentId", "experiencePackId", "releaseId", "ownerRef", "version", "contentHash",
|
|
386
|
+
"compatibleBaseReleaseIds", "mcpRequirementIds", "sourcePath", "desiredAction", "localState", "hubReceipt",
|
|
387
|
+
"contractValidatedAt", "contentVerified", "updatedAt",
|
|
388
|
+
];
|
|
389
|
+
assertExactKeys(intent, new Set(required), required, label);
|
|
390
|
+
if (intent.schemaVersion !== EXPERIENCE_INTENT_SCHEMA) throw new Error(`${label}.schemaVersion is invalid`);
|
|
391
|
+
for (const key of ["intentId", "experiencePackId", "releaseId", "ownerRef", "version"]) assertId(intent[key], `${label}.${key}`);
|
|
392
|
+
if (!HASH_RE.test(String(intent.contentHash || ""))) throw new Error(`${label}.contentHash is invalid`);
|
|
393
|
+
assertUniqueIds(intent.compatibleBaseReleaseIds, `${label}.compatibleBaseReleaseIds`, { min: 1 });
|
|
394
|
+
assertUniqueIds(intent.mcpRequirementIds, `${label}.mcpRequirementIds`);
|
|
395
|
+
if (typeof intent.sourcePath !== "string" || !path.isAbsolute(intent.sourcePath) || intent.sourcePath.length > 4096 || /[\0\r\n]/.test(intent.sourcePath)) throw new Error(`${label}.sourcePath is invalid`);
|
|
396
|
+
if (!['publish', 'unpublish'].includes(intent.desiredAction)) throw new Error(`${label}.desiredAction is invalid`);
|
|
397
|
+
const expectedState = intent.desiredAction === "publish" ? "publish-requested" : "unpublish-requested";
|
|
398
|
+
if (intent.localState !== expectedState) throw new Error(`${label}.localState is inconsistent`);
|
|
399
|
+
if (intent.hubReceipt !== null) throw new Error(`${label}.hubReceipt cannot be synthesized locally`);
|
|
400
|
+
if (intent.contentVerified !== false) throw new Error(`${label}.contentVerified cannot be asserted from a declaration alone`);
|
|
401
|
+
assertIsoDateOrNull(intent.contractValidatedAt, `${label}.contractValidatedAt`);
|
|
402
|
+
assertIsoDateOrNull(intent.updatedAt, `${label}.updatedAt`);
|
|
403
|
+
return intent;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function saveExperienceState(userDataDir, state) {
|
|
407
|
+
state.updatedAt = new Date().toISOString();
|
|
408
|
+
writePrivateJsonAtomic(experienceStatePath(userDataDir), state);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function publicExperienceIntent(intent) {
|
|
412
|
+
return {
|
|
413
|
+
schemaVersion: intent.schemaVersion,
|
|
414
|
+
intentId: intent.intentId,
|
|
415
|
+
experiencePackId: intent.experiencePackId,
|
|
416
|
+
releaseId: intent.releaseId,
|
|
417
|
+
ownerRef: intent.ownerRef,
|
|
418
|
+
version: intent.version,
|
|
419
|
+
contentHash: intent.contentHash,
|
|
420
|
+
compatibleBaseReleaseIds: intent.compatibleBaseReleaseIds,
|
|
421
|
+
desiredAction: intent.desiredAction,
|
|
422
|
+
localState: intent.localState,
|
|
423
|
+
hubPublication: { status: "not-submitted", receiptPresent: false },
|
|
424
|
+
updatedAt: intent.updatedAt,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function parseSimpleFlags(args) {
|
|
429
|
+
const flags = { _: [] };
|
|
430
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
431
|
+
const token = String(args[index]);
|
|
432
|
+
if (token === "--json") flags.json = true;
|
|
433
|
+
else if (token.startsWith("--") && token.includes("=")) {
|
|
434
|
+
const at = token.indexOf("=");
|
|
435
|
+
flags[token.slice(2, at)] = token.slice(at + 1);
|
|
436
|
+
} else if (token.startsWith("--")) {
|
|
437
|
+
const key = token.slice(2);
|
|
438
|
+
if (index + 1 < args.length && !String(args[index + 1]).startsWith("--")) flags[key] = String(args[++index]);
|
|
439
|
+
else flags[key] = true;
|
|
440
|
+
} else flags._.push(token);
|
|
441
|
+
}
|
|
442
|
+
return flags;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function renderExperienceList(intents) {
|
|
446
|
+
if (!intents.length) return "No local Experience Pack publication intents.\nHub publication: not attempted.";
|
|
447
|
+
const lines = ["LOCAL EXPERIENCE INTENTS (Terminal-owned; not Hub publication)"];
|
|
448
|
+
for (const intent of intents) lines.push(`- ${intent.experiencePackId}@${intent.version} · ${intent.localState} · Hub receipt: none`);
|
|
449
|
+
return lines.join("\n");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function findIntent(state, ref) {
|
|
453
|
+
const matches = state.intents.filter((intent) => [intent.intentId, intent.experiencePackId, intent.releaseId].includes(ref));
|
|
454
|
+
if (!matches.length) return null;
|
|
455
|
+
return matches.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function publishExperienceIntent(userDataDir, sourcePath, cwd) {
|
|
459
|
+
if (!sourcePath) throw new Error("usage: agentlas experience publish <experience-pack.json>");
|
|
460
|
+
const source = path.resolve(cwd || process.cwd(), sourcePath);
|
|
461
|
+
const { absolute, value } = readJsonFile(source, "experience pack");
|
|
462
|
+
const pack = validateExperiencePack(value);
|
|
463
|
+
const now = new Date().toISOString();
|
|
464
|
+
const intentId = `experience-intent:${crypto.createHash("sha256").update(`${pack.releaseId}\0${pack.contentHash}`).digest("hex").slice(0, 32)}`;
|
|
465
|
+
const intent = {
|
|
466
|
+
schemaVersion: EXPERIENCE_INTENT_SCHEMA,
|
|
467
|
+
intentId,
|
|
468
|
+
experiencePackId: pack.experiencePackId,
|
|
469
|
+
releaseId: pack.releaseId,
|
|
470
|
+
ownerRef: pack.ownerRef,
|
|
471
|
+
version: pack.version,
|
|
472
|
+
contentHash: pack.contentHash,
|
|
473
|
+
compatibleBaseReleaseIds: [...pack.baseCompatibility.compatibleBaseReleaseIds],
|
|
474
|
+
mcpRequirementIds: pack.mcpRequirements.map((requirement) => requirement.requirementId),
|
|
475
|
+
sourcePath: absolute,
|
|
476
|
+
desiredAction: "publish",
|
|
477
|
+
localState: "publish-requested",
|
|
478
|
+
hubReceipt: null,
|
|
479
|
+
contractValidatedAt: now,
|
|
480
|
+
contentVerified: false,
|
|
481
|
+
updatedAt: now,
|
|
482
|
+
};
|
|
483
|
+
withExperienceStateLock(userDataDir, () => {
|
|
484
|
+
const state = loadExperienceState(userDataDir);
|
|
485
|
+
const existing = state.intents.findIndex((row) => row.intentId === intentId);
|
|
486
|
+
if (existing >= 0) state.intents[existing] = intent;
|
|
487
|
+
else state.intents.push(intent);
|
|
488
|
+
saveExperienceState(userDataDir, state);
|
|
489
|
+
});
|
|
490
|
+
return publicExperienceIntent(intent);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function unpublishExperienceIntent(userDataDir, ref) {
|
|
494
|
+
if (!ref) throw new Error("usage: agentlas experience unpublish <pack-id|release-id|intent-id>");
|
|
495
|
+
let intent;
|
|
496
|
+
withExperienceStateLock(userDataDir, () => {
|
|
497
|
+
const state = loadExperienceState(userDataDir);
|
|
498
|
+
intent = findIntent(state, ref);
|
|
499
|
+
if (!intent) throw new Error(`local Experience Pack intent not found: ${ref}`);
|
|
500
|
+
intent.desiredAction = "unpublish";
|
|
501
|
+
intent.localState = "unpublish-requested";
|
|
502
|
+
intent.hubReceipt = null;
|
|
503
|
+
intent.updatedAt = new Date().toISOString();
|
|
504
|
+
saveExperienceState(userDataDir, state);
|
|
505
|
+
});
|
|
506
|
+
return publicExperienceIntent(intent);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function cmdExperience(options) {
|
|
510
|
+
const args = options.args || [];
|
|
511
|
+
const sub = args[0] || "list";
|
|
512
|
+
const flags = parseSimpleFlags(args.slice(1));
|
|
513
|
+
const emit = options.out || console.log;
|
|
514
|
+
const userData = options.userDataDir;
|
|
515
|
+
if (!userData) throw new Error("Terminal userData path is required");
|
|
516
|
+
if (sub === "list" || sub === "ls") {
|
|
517
|
+
const list = loadExperienceState(userData).intents.map(publicExperienceIntent);
|
|
518
|
+
emit(flags.json ? JSON.stringify({ localOnly: true, hubPublicationAttempted: false, intents: list }, null, 2) : renderExperienceList(list));
|
|
519
|
+
return list;
|
|
520
|
+
}
|
|
521
|
+
if (sub === "inspect" || sub === "show") {
|
|
522
|
+
const ref = flags._[0];
|
|
523
|
+
if (!ref) throw new Error("usage: agentlas experience inspect <pack-id|release-id|intent-id>");
|
|
524
|
+
const intent = findIntent(loadExperienceState(userData), ref);
|
|
525
|
+
if (!intent) throw new Error(`local Experience Pack intent not found: ${ref}`);
|
|
526
|
+
const projected = publicExperienceIntent(intent);
|
|
527
|
+
emit(flags.json ? JSON.stringify(projected, null, 2) : [
|
|
528
|
+
`${projected.experiencePackId}@${projected.version}`,
|
|
529
|
+
`release: ${projected.releaseId}`,
|
|
530
|
+
`local intent: ${projected.desiredAction} (${projected.localState})`,
|
|
531
|
+
"Hub publication: not submitted · server receipt: none",
|
|
532
|
+
"base package: referenced only (not copied)",
|
|
533
|
+
].join("\n"));
|
|
534
|
+
return projected;
|
|
535
|
+
}
|
|
536
|
+
if (sub === "publish") {
|
|
537
|
+
const intent = publishExperienceIntent(userData, flags._[0], options.cwd);
|
|
538
|
+
emit(flags.json ? JSON.stringify(intent, null, 2) : [
|
|
539
|
+
`Local publish intent saved: ${intent.experiencePackId}@${intent.version}`,
|
|
540
|
+
"Hub publication: NOT performed · server receipt: none",
|
|
541
|
+
"Use the Hub API/UI later; this command does not claim remote publication.",
|
|
542
|
+
].join("\n"));
|
|
543
|
+
return intent;
|
|
544
|
+
}
|
|
545
|
+
if (sub === "unpublish") {
|
|
546
|
+
const intent = unpublishExperienceIntent(userData, flags._[0]);
|
|
547
|
+
emit(flags.json ? JSON.stringify(intent, null, 2) : [
|
|
548
|
+
`Local unpublish intent saved: ${intent.experiencePackId}@${intent.version}`,
|
|
549
|
+
"Hub state: unchanged · no server request or receipt was created.",
|
|
550
|
+
].join("\n"));
|
|
551
|
+
return intent;
|
|
552
|
+
}
|
|
553
|
+
throw new Error(`unknown experience subcommand: ${sub} (list|inspect|publish|unpublish)`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function readCredentialNames(userDataDir, env = process.env) {
|
|
557
|
+
const names = new Set(Object.keys(env || {}).filter((key) => ENV_RE.test(key) && env[key]));
|
|
558
|
+
const files = [
|
|
559
|
+
path.join(userDataDir, "credentials.env"),
|
|
560
|
+
path.join(os.homedir(), ".agentlas", "credentials.env"),
|
|
561
|
+
];
|
|
562
|
+
for (const file of files) {
|
|
563
|
+
try {
|
|
564
|
+
const stat = fs.statSync(file);
|
|
565
|
+
if (!stat.isFile() || stat.size > 512 * 1024) continue;
|
|
566
|
+
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
567
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
568
|
+
if (!match) continue;
|
|
569
|
+
let observed = match[2];
|
|
570
|
+
if ((observed.startsWith('"') && observed.endsWith('"')) || (observed.startsWith("'") && observed.endsWith("'"))) observed = observed.slice(1, -1);
|
|
571
|
+
if (observed) names.add(match[1]);
|
|
572
|
+
}
|
|
573
|
+
} catch { /* absent/unreadable means no observed key */ }
|
|
574
|
+
}
|
|
575
|
+
return names;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function safeCatalogId(value) {
|
|
579
|
+
const text = String(value || "").trim();
|
|
580
|
+
return ID_RE.test(text) && !UNSAFE_TEXT_PATTERNS.some((pattern) => pattern.re.test(text)) ? text : null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function safeDisplayName(value, fallback) {
|
|
584
|
+
const text = String(value || "").replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, " ").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
585
|
+
if (!text || UNSAFE_TEXT_PATTERNS.some((pattern) => pattern.re.test(text))) return fallback;
|
|
586
|
+
return text;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function collectSystemMcpInventory(db, options = {}) {
|
|
590
|
+
let rows = [];
|
|
591
|
+
let registryStatus = "complete";
|
|
592
|
+
try {
|
|
593
|
+
rows = db.prepare("SELECT id, catalog_id, name, name_en, transport, env_keys_json, enabled FROM mcp_servers ORDER BY installed_at ASC LIMIT 1025").all();
|
|
594
|
+
} catch {
|
|
595
|
+
// An unreadable registry is not the same fact as a readable empty registry.
|
|
596
|
+
// Both fail closed to empty-MCP, but the user-facing plan preserves the cause.
|
|
597
|
+
registryStatus = "unavailable";
|
|
598
|
+
}
|
|
599
|
+
if (!Array.isArray(rows)) {
|
|
600
|
+
rows = [];
|
|
601
|
+
registryStatus = "unavailable";
|
|
602
|
+
}
|
|
603
|
+
if (rows.length > 1024) {
|
|
604
|
+
rows = [];
|
|
605
|
+
registryStatus = "unavailable";
|
|
606
|
+
}
|
|
607
|
+
const credentialNames = readCredentialNames(options.userDataDir || "", options.env || process.env);
|
|
608
|
+
const inventory = [];
|
|
609
|
+
const seen = new Set();
|
|
610
|
+
for (const row of rows) {
|
|
611
|
+
if (!row || Number(row.enabled) === 0) continue;
|
|
612
|
+
const catalogId = safeCatalogId(row.catalog_id) || safeCatalogId(row.id);
|
|
613
|
+
if (!catalogId || seen.has(catalogId)) continue;
|
|
614
|
+
seen.add(catalogId);
|
|
615
|
+
let keyNames = [];
|
|
616
|
+
let credentialMetadataStatus = "complete";
|
|
617
|
+
try {
|
|
618
|
+
if (String(row.env_keys_json || "[]").length > 64 * 1024) throw new Error("credential metadata too large");
|
|
619
|
+
const parsed = JSON.parse(row.env_keys_json || "[]");
|
|
620
|
+
keyNames = normalizeCredentialKeyNames(parsed);
|
|
621
|
+
} catch { credentialMetadataStatus = "unavailable"; }
|
|
622
|
+
const item = {
|
|
623
|
+
catalogId,
|
|
624
|
+
name: safeDisplayName(row.name || row.name_en, catalogId),
|
|
625
|
+
source: "system-global",
|
|
626
|
+
enabled: true,
|
|
627
|
+
keyRequired: credentialMetadataStatus !== "complete" || keyNames.length > 0,
|
|
628
|
+
keyPresent: credentialMetadataStatus === "complete" && (keyNames.length === 0 || keyNames.every((key) => credentialNames.has(key))),
|
|
629
|
+
credentialMetadataStatus,
|
|
630
|
+
};
|
|
631
|
+
Object.defineProperty(item, "registryServerId", { value: String(row.id), enumerable: false });
|
|
632
|
+
Object.defineProperty(item, "transport", { value: String(row.transport || ""), enumerable: false });
|
|
633
|
+
Object.defineProperty(item, "credentialKeyNames", { value: keyNames, enumerable: false });
|
|
634
|
+
Object.defineProperty(item, "credentialKeyFingerprint", {
|
|
635
|
+
value: crypto.createHash("sha256").update(JSON.stringify(keyNames), "utf8").digest("hex"),
|
|
636
|
+
enumerable: false,
|
|
637
|
+
});
|
|
638
|
+
inventory.push(item);
|
|
639
|
+
}
|
|
640
|
+
Object.defineProperty(inventory, "registryStatus", { value: registryStatus, enumerable: false });
|
|
641
|
+
return inventory;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function validateMcpPolicy(value) {
|
|
645
|
+
const required = [
|
|
646
|
+
"schemaVersion", "kind", "registryResolutionOrder", "consentMode", "serverDefinitionsFromPackage",
|
|
647
|
+
"credentialValuesAllowed", "failureIsolation", "permissionWidening", "toolSchemaLoading", "skillLoading",
|
|
648
|
+
"contextBudget", "requirements",
|
|
649
|
+
];
|
|
650
|
+
assertExactKeys(value, new Set(required), required, "MCP policy");
|
|
651
|
+
if (value.schemaVersion !== "agentlas.mcp-policy.v1" || value.kind !== "agentlas-mcp-policy") throw new Error("MCP policy schema is invalid");
|
|
652
|
+
if (!Array.isArray(value.registryResolutionOrder) || value.registryResolutionOrder[0] !== "system-global") throw new Error("MCP policy must resolve system-global inventory first");
|
|
653
|
+
const allowedLayers = new Set(["system-global", "project-local", "catalog-recommendation"]);
|
|
654
|
+
if (new Set(value.registryResolutionOrder).size !== value.registryResolutionOrder.length || value.registryResolutionOrder.some((layer) => !allowedLayers.has(layer))) throw new Error("MCP policy registry order is invalid");
|
|
655
|
+
if (value.consentMode !== "one-pass" || value.serverDefinitionsFromPackage !== false || value.credentialValuesAllowed !== false || value.failureIsolation !== "per-requirement") throw new Error("MCP policy weakens the frozen safety boundary");
|
|
656
|
+
if (value.permissionWidening !== "ask" || value.toolSchemaLoading !== "selected-tools-only" || value.skillLoading !== "triggered-only") throw new Error("MCP policy loading/permission mode is invalid");
|
|
657
|
+
const budget = assertObject(value.contextBudget, "MCP policy.contextBudget");
|
|
658
|
+
assertExactKeys(budget, new Set(Object.keys(TOKEN_BUDGET)), Object.keys(TOKEN_BUDGET), "MCP policy.contextBudget");
|
|
659
|
+
for (const [key, max] of Object.entries(TOKEN_BUDGET)) {
|
|
660
|
+
if (!Number.isInteger(budget[key]) || budget[key] < 0 || budget[key] > max) throw new Error(`MCP policy.contextBudget.${key} exceeds the frozen maximum`);
|
|
661
|
+
}
|
|
662
|
+
if (!Array.isArray(value.requirements) || value.requirements.length > 64) throw new Error("MCP policy requirements are invalid");
|
|
663
|
+
value.requirements.forEach((requirement, index) => validateMcpRequirement(requirement, `MCP policy.requirements[${index}]`));
|
|
664
|
+
const requirementIds = value.requirements.map((requirement) => requirement.requirementId);
|
|
665
|
+
if (new Set(requirementIds).size !== requirementIds.length) throw new Error("MCP policy requirementId values must be unique");
|
|
666
|
+
return value;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function loadProjectMcpPolicy(cwd) {
|
|
670
|
+
const file = path.join(cwd || process.cwd(), ".agentlas", "mcp-policy.json");
|
|
671
|
+
if (!fs.existsSync(file)) return null;
|
|
672
|
+
const { value } = readJsonFile(file, "MCP policy");
|
|
673
|
+
return validateMcpPolicy(value);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function syntheticRequirement(catalogId, required, priority) {
|
|
677
|
+
const suffix = crypto.createHash("sha256").update(catalogId).digest("hex").slice(0, 24);
|
|
678
|
+
return {
|
|
679
|
+
schemaVersion: "agentlas.mcp-requirement.v1",
|
|
680
|
+
kind: "agentlas-mcp-requirement",
|
|
681
|
+
requirementId: `terminal-requirement:${suffix}`,
|
|
682
|
+
catalogId,
|
|
683
|
+
reason: required ? "Explicitly required for this Terminal build" : "Explicitly recommended for this Terminal build",
|
|
684
|
+
capabilities: [`terminal-mcp:${suffix}`],
|
|
685
|
+
required,
|
|
686
|
+
requiresKey: false,
|
|
687
|
+
priority,
|
|
688
|
+
permissions: [],
|
|
689
|
+
alternatives: [],
|
|
690
|
+
unavailablePolicy: {
|
|
691
|
+
build: "degrade",
|
|
692
|
+
rental: required ? "exclude-variant" : "continue-degraded",
|
|
693
|
+
execution: required ? "use-alternative" : "continue-degraded",
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const HEURISTIC_GROUPS = [
|
|
699
|
+
[/(browser|playwright|chrome|web)/i, /(?:browser|website|web page|웹|브라우저|사이트|페이지|로그인)/i],
|
|
700
|
+
[/(github|gitlab|source)/i, /(?:github|gitlab|repository|pull request|issue|깃허브|레포|저장소)/i],
|
|
701
|
+
[/(figma|design)/i, /(?:figma|mockup|design|ui|ux|피그마|디자인)/i],
|
|
702
|
+
[/(postgres|mysql|sqlite|database|mongo)/i, /(?:database|sql|query|schema|db|데이터베이스|쿼리)/i],
|
|
703
|
+
[/(notion|docs|drive)/i, /(?:notion|document|docs|drive|노션|문서|드라이브)/i],
|
|
704
|
+
[/(slack|teams|discord)/i, /(?:slack|teams|discord|message|슬랙|메시지)/i],
|
|
705
|
+
[/(search|research)/i, /(?:search|research|lookup|검색|리서치|조사)/i],
|
|
706
|
+
];
|
|
707
|
+
|
|
708
|
+
function inferRequirements(request, inventory) {
|
|
709
|
+
const text = String(request || "");
|
|
710
|
+
const results = [];
|
|
711
|
+
for (const item of inventory) {
|
|
712
|
+
const direct = text.toLowerCase().includes(item.catalogId.toLowerCase()) || text.toLowerCase().includes(item.name.toLowerCase());
|
|
713
|
+
const heuristic = HEURISTIC_GROUPS.some(([nameRe, taskRe]) => nameRe.test(`${item.catalogId} ${item.name}`) && taskRe.test(text));
|
|
714
|
+
if (direct || heuristic) results.push(syntheticRequirement(item.catalogId, false, results.length + 100));
|
|
715
|
+
}
|
|
716
|
+
return results.slice(0, 8);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function indexInventory(inventory) {
|
|
720
|
+
return new Map((inventory || []).map((item) => [item.catalogId, item]));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function resolveMcpRequirement(requirement, inventoryById) {
|
|
724
|
+
const order = [requirement.catalogId, ...(requirement.alternatives || [])];
|
|
725
|
+
const attempted = [];
|
|
726
|
+
const candidates = [];
|
|
727
|
+
for (const catalogId of order) {
|
|
728
|
+
const item = inventoryById.get(catalogId);
|
|
729
|
+
if (!item) {
|
|
730
|
+
attempted.push({ catalogId, status: "unavailable" });
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (item.transport !== "stdio") {
|
|
734
|
+
attempted.push({ catalogId, status: "runtime-incompatible" });
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const keyRequired = requirement.requiresKey || item.keyRequired;
|
|
738
|
+
// The trusted registry owns credential mapping. A package cannot turn an
|
|
739
|
+
// uncredentialed registry row into "key present" merely by declaring env metadata.
|
|
740
|
+
const keyPresent = keyRequired ? (item.keyRequired && item.keyPresent) : true;
|
|
741
|
+
if (!keyPresent) {
|
|
742
|
+
attempted.push({ catalogId, status: "missing-key" });
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
candidates.push({ item, keyRequired, keyPresent: true });
|
|
746
|
+
}
|
|
747
|
+
if (candidates.length) {
|
|
748
|
+
return {
|
|
749
|
+
selected: candidates[0].item,
|
|
750
|
+
candidates,
|
|
751
|
+
status: "available",
|
|
752
|
+
attempted,
|
|
753
|
+
keyRequired: candidates[0].keyRequired,
|
|
754
|
+
keyPresent: true,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
const primary = inventoryById.get(requirement.catalogId);
|
|
758
|
+
const keyRequired = requirement.requiresKey || Boolean(primary && primary.keyRequired);
|
|
759
|
+
const missingKey = attempted.some((attempt) => attempt.status === "missing-key");
|
|
760
|
+
return { selected: null, candidates: [], status: missingKey ? "missing-key" : "unavailable", attempted, keyRequired, keyPresent: false };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function buildMcpPlan(options) {
|
|
764
|
+
const inventory = options.inventory || [];
|
|
765
|
+
const inventoryById = indexInventory(inventory);
|
|
766
|
+
const policyRequirements = options.policy ? options.policy.requirements : [];
|
|
767
|
+
const requirements = [...policyRequirements];
|
|
768
|
+
const known = new Set(requirements.map((requirement) => requirement.catalogId));
|
|
769
|
+
for (const catalogId of options.requiredIds || []) {
|
|
770
|
+
assertId(catalogId, "--require-mcp");
|
|
771
|
+
if (!known.has(catalogId)) { requirements.push(syntheticRequirement(catalogId, true, 1)); known.add(catalogId); }
|
|
772
|
+
}
|
|
773
|
+
for (const catalogId of options.recommendedIds || []) {
|
|
774
|
+
assertId(catalogId, "--recommend-mcp");
|
|
775
|
+
if (!known.has(catalogId)) { requirements.push(syntheticRequirement(catalogId, false, 500)); known.add(catalogId); }
|
|
776
|
+
}
|
|
777
|
+
if (!requirements.length) requirements.push(...inferRequirements(options.request, inventory));
|
|
778
|
+
const entries = requirements
|
|
779
|
+
.map((requirement) => {
|
|
780
|
+
const resolution = resolveMcpRequirement(requirement, inventoryById);
|
|
781
|
+
const entry = {
|
|
782
|
+
requirementId: requirement.requirementId,
|
|
783
|
+
requestedCatalogId: requirement.catalogId,
|
|
784
|
+
resolvedCatalogId: resolution.selected ? resolution.selected.catalogId : null,
|
|
785
|
+
name: resolution.selected ? resolution.selected.name : requirement.catalogId,
|
|
786
|
+
source: resolution.selected ? resolution.selected.source : null,
|
|
787
|
+
required: requirement.required,
|
|
788
|
+
priority: requirement.priority,
|
|
789
|
+
reason: requirement.reason,
|
|
790
|
+
status: resolution.status,
|
|
791
|
+
keyRequired: resolution.keyRequired,
|
|
792
|
+
keyPresent: resolution.keyRequired ? resolution.keyPresent : null,
|
|
793
|
+
permissions: [...(requirement.permissions || [])],
|
|
794
|
+
permissionBasis: "package-declared",
|
|
795
|
+
permissionEnforced: false,
|
|
796
|
+
fallbackCatalogIds: resolution.candidates.slice(1).map((candidate) => candidate.item.catalogId),
|
|
797
|
+
alternativesTried: resolution.attempted.map((attempt) => ({ catalogId: attempt.catalogId, status: attempt.status })),
|
|
798
|
+
unavailableBuildPolicy: "degrade",
|
|
799
|
+
};
|
|
800
|
+
Object.defineProperty(entry, "registryServerId", {
|
|
801
|
+
value: resolution.selected?.registryServerId || null,
|
|
802
|
+
enumerable: false,
|
|
803
|
+
});
|
|
804
|
+
Object.defineProperty(entry, "credentialKeyFingerprint", {
|
|
805
|
+
value: resolution.selected?.credentialKeyFingerprint || null,
|
|
806
|
+
enumerable: false,
|
|
807
|
+
});
|
|
808
|
+
Object.defineProperty(entry, "runtimeCandidates", {
|
|
809
|
+
value: resolution.candidates.map((candidate) => ({
|
|
810
|
+
resolvedCatalogId: candidate.item.catalogId,
|
|
811
|
+
registryServerId: candidate.item.registryServerId || null,
|
|
812
|
+
credentialKeyFingerprint: candidate.item.credentialKeyFingerprint || null,
|
|
813
|
+
})),
|
|
814
|
+
enumerable: false,
|
|
815
|
+
});
|
|
816
|
+
return entry;
|
|
817
|
+
})
|
|
818
|
+
.sort((a, b) => Number(b.required) - Number(a.required) || a.priority - b.priority || a.requestedCatalogId.localeCompare(b.requestedCatalogId));
|
|
819
|
+
return {
|
|
820
|
+
schemaVersion: "agentlas.terminal-mcp-build-plan.v1",
|
|
821
|
+
planId: crypto.randomUUID(),
|
|
822
|
+
registryStatus: options.registryStatus || inventory.registryStatus || "complete",
|
|
823
|
+
registryResolutionOrder: options.policy ? [...options.policy.registryResolutionOrder] : ["system-global"],
|
|
824
|
+
discoveryNetworkUsed: false,
|
|
825
|
+
consentMode: "one-pass",
|
|
826
|
+
entries,
|
|
827
|
+
availableCatalogIds: [...new Set(entries.flatMap((entry) =>
|
|
828
|
+
entry.status === "available" ? (entry.runtimeCandidates || []).map((candidate) => candidate.resolvedCatalogId) : []
|
|
829
|
+
))],
|
|
830
|
+
maxApprovedMcp: MAX_APPROVED_MCP_PER_BUILD,
|
|
831
|
+
shortages: entries.filter((entry) => entry.status !== "available").map((entry) => ({
|
|
832
|
+
requirementId: entry.requirementId,
|
|
833
|
+
catalogId: entry.requestedCatalogId,
|
|
834
|
+
required: entry.required,
|
|
835
|
+
status: entry.status,
|
|
836
|
+
effect: "build-degraded-only",
|
|
837
|
+
})),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function parseRuntimeServerArgs(value) {
|
|
842
|
+
if (typeof value !== "string" || value.length > 64 * 1024) return null;
|
|
843
|
+
try {
|
|
844
|
+
const parsed = JSON.parse(value || "[]");
|
|
845
|
+
if (!Array.isArray(parsed) || parsed.length > 128 || parsed.some((item) => typeof item !== "string" || item.length > 4096 || /[\u0000\r\n]/.test(item))) return null;
|
|
846
|
+
return parsed;
|
|
847
|
+
} catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function materializeTrustedSystemMcpServer(row, options = {}) {
|
|
853
|
+
const catalogId = safeCatalogId(row?.catalog_id) || safeCatalogId(row?.id);
|
|
854
|
+
const registryServerId = String(row?.id || "");
|
|
855
|
+
const args = parseRuntimeServerArgs(row?.args_json || "[]");
|
|
856
|
+
let credentialKeyNames = null;
|
|
857
|
+
try {
|
|
858
|
+
if (String(row?.env_keys_json || "[]").length > 64 * 1024) throw new Error("credential metadata too large");
|
|
859
|
+
credentialKeyNames = normalizeCredentialKeyNames(JSON.parse(row?.env_keys_json || "[]"));
|
|
860
|
+
} catch { /* fail closed below */ }
|
|
861
|
+
if (
|
|
862
|
+
!row || !catalogId || !ID_RE.test(registryServerId) || Number(row.enabled) === 0 || row.transport !== "stdio" || typeof row.command !== "string" ||
|
|
863
|
+
!row.command.trim() || row.command.length > 4096 || /[\u0000\r\n]/.test(row.command) || !args ||
|
|
864
|
+
!credentialKeyNames || hasSensitiveRuntimeArgument(row.command, args)
|
|
865
|
+
) return null;
|
|
866
|
+
const server = {
|
|
867
|
+
id: registryServerId,
|
|
868
|
+
catalog_id: catalogId,
|
|
869
|
+
name: catalogId,
|
|
870
|
+
transport: "stdio",
|
|
871
|
+
command: row.command,
|
|
872
|
+
args_json: JSON.stringify(args),
|
|
873
|
+
enabled: 1,
|
|
874
|
+
};
|
|
875
|
+
Object.defineProperty(server, "credentialKeyNames", { value: credentialKeyNames, enumerable: false });
|
|
876
|
+
Object.defineProperty(server, "credentialKeyFingerprint", {
|
|
877
|
+
value: crypto.createHash("sha256").update(JSON.stringify(credentialKeyNames), "utf8").digest("hex"),
|
|
878
|
+
enumerable: false,
|
|
879
|
+
});
|
|
880
|
+
Object.defineProperty(server, "consentFingerprint", {
|
|
881
|
+
value: crypto.createHash("sha256").update(JSON.stringify({
|
|
882
|
+
schemaVersion: "agentlas.terminal-mcp-consent-fingerprint.v1",
|
|
883
|
+
registryServerId,
|
|
884
|
+
catalogId,
|
|
885
|
+
transport: "stdio",
|
|
886
|
+
command: row.command,
|
|
887
|
+
args,
|
|
888
|
+
credentialKeyNames,
|
|
889
|
+
}), "utf8").digest("hex"),
|
|
890
|
+
enumerable: false,
|
|
891
|
+
});
|
|
892
|
+
if (options.createRuntimeHome !== false) {
|
|
893
|
+
Object.defineProperty(server, "mcpRuntimeHome", {
|
|
894
|
+
value: mcpRuntimeHome(options.userDataDir, `${catalogId}\0${row.id}`),
|
|
895
|
+
enumerable: false,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return server;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function persistMcpConsentReceipts(userDataDir, servers) {
|
|
902
|
+
if (!userDataDir || !(servers || []).length) return false;
|
|
903
|
+
withMcpConsentStateLock(userDataDir, () => {
|
|
904
|
+
const state = loadMcpConsentState(userDataDir);
|
|
905
|
+
const now = new Date().toISOString();
|
|
906
|
+
for (const server of servers) {
|
|
907
|
+
if (!server || !ID_RE.test(String(server.id || "")) || !safeCatalogId(server.catalog_id) || !/^[0-9a-f]{64}$/.test(String(server.consentFingerprint || ""))) continue;
|
|
908
|
+
const receipt = {
|
|
909
|
+
schemaVersion: MCP_CONSENT_RECEIPT_SCHEMA,
|
|
910
|
+
catalogId: server.catalog_id,
|
|
911
|
+
registryServerId: server.id,
|
|
912
|
+
consentFingerprint: server.consentFingerprint,
|
|
913
|
+
source: "terminal-build-one-pass",
|
|
914
|
+
consentedAt: now,
|
|
915
|
+
};
|
|
916
|
+
const existing = state.receipts.findIndex((item) => item.catalogId === receipt.catalogId && item.registryServerId === receipt.registryServerId);
|
|
917
|
+
if (existing >= 0) state.receipts[existing] = receipt;
|
|
918
|
+
else state.receipts.push(receipt);
|
|
919
|
+
}
|
|
920
|
+
state.receipts.sort((a, b) => String(b.consentedAt).localeCompare(String(a.consentedAt)) || a.catalogId.localeCompare(b.catalogId));
|
|
921
|
+
state.receipts = state.receipts.slice(0, 256);
|
|
922
|
+
state.updatedAt = now;
|
|
923
|
+
writePrivateJsonAtomic(mcpConsentStatePath(userDataDir), state);
|
|
924
|
+
});
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function readConsentedSystemMcpServers(db, options = {}) {
|
|
929
|
+
let state;
|
|
930
|
+
try { state = loadMcpConsentState(options.userDataDir); }
|
|
931
|
+
catch { return []; }
|
|
932
|
+
const servers = [];
|
|
933
|
+
const seen = new Set();
|
|
934
|
+
for (const receipt of state.receipts) {
|
|
935
|
+
if (seen.has(receipt.catalogId)) continue;
|
|
936
|
+
let row = null;
|
|
937
|
+
try {
|
|
938
|
+
row = db.prepare(
|
|
939
|
+
"SELECT id, catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers WHERE id=? LIMIT 1",
|
|
940
|
+
).get(receipt.registryServerId);
|
|
941
|
+
} catch { continue; }
|
|
942
|
+
const server = materializeTrustedSystemMcpServer(row, options);
|
|
943
|
+
if (
|
|
944
|
+
!server || server.id !== receipt.registryServerId || server.catalog_id !== receipt.catalogId ||
|
|
945
|
+
server.consentFingerprint !== receipt.consentFingerprint
|
|
946
|
+
) continue;
|
|
947
|
+
seen.add(receipt.catalogId);
|
|
948
|
+
servers.push(server);
|
|
949
|
+
}
|
|
950
|
+
return servers;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Post-consent only. Re-read the exact trusted system-global row with executable
|
|
955
|
+
* fields; no package/catalog content can supply this material.
|
|
956
|
+
*/
|
|
957
|
+
function readApprovedSystemMcpServer(db, entry, options = {}) {
|
|
958
|
+
if (!entry?.registryServerId || !entry.resolvedCatalogId) return null;
|
|
959
|
+
let row = null;
|
|
960
|
+
try {
|
|
961
|
+
row = db.prepare(
|
|
962
|
+
"SELECT id, catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers WHERE id=? LIMIT 1",
|
|
963
|
+
).get(entry.registryServerId);
|
|
964
|
+
} catch {
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
const server = materializeTrustedSystemMcpServer(row, options);
|
|
968
|
+
if (
|
|
969
|
+
!server || String(row.id) !== entry.registryServerId || server.catalog_id !== entry.resolvedCatalogId ||
|
|
970
|
+
!entry.credentialKeyFingerprint || server.credentialKeyFingerprint !== entry.credentialKeyFingerprint
|
|
971
|
+
) return null;
|
|
972
|
+
return server;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function probeSystemMcpServerConnection(server, options = {}) {
|
|
976
|
+
const requestedTimeout = Number(options.timeoutMs);
|
|
977
|
+
const timeoutMs = Number.isFinite(requestedTimeout)
|
|
978
|
+
? Math.max(50, Math.min(30_000, Math.trunc(requestedTimeout)))
|
|
979
|
+
: MCP_PROBE_PER_SERVER_TIMEOUT_MS;
|
|
980
|
+
const spawnImpl = options.spawn || spawn;
|
|
981
|
+
return new Promise((resolve) => {
|
|
982
|
+
let child = null;
|
|
983
|
+
let settled = false;
|
|
984
|
+
let buffer = Buffer.alloc(0);
|
|
985
|
+
let totalBytes = 0;
|
|
986
|
+
let initialized = false;
|
|
987
|
+
let abortHandler = null;
|
|
988
|
+
let forceKillTimer = null;
|
|
989
|
+
let childClosed = false;
|
|
990
|
+
const terminateChild = (signal) => {
|
|
991
|
+
const pid = Number(child?.pid);
|
|
992
|
+
if (process.platform !== "win32" && Number.isInteger(pid) && pid > 1) {
|
|
993
|
+
try { process.kill(-pid, signal); return; } catch { /* fall through */ }
|
|
994
|
+
}
|
|
995
|
+
try { child?.kill(signal); } catch { /* noop */ }
|
|
996
|
+
};
|
|
997
|
+
const finish = (connected, reason, tools = []) => {
|
|
998
|
+
if (settled) return;
|
|
999
|
+
settled = true;
|
|
1000
|
+
clearTimeout(timer);
|
|
1001
|
+
if (options.signal && abortHandler) options.signal.removeEventListener?.("abort", abortHandler);
|
|
1002
|
+
try { child?.stdin?.end(); } catch { /* noop */ }
|
|
1003
|
+
if (!childClosed) {
|
|
1004
|
+
terminateChild("SIGTERM");
|
|
1005
|
+
forceKillTimer = setTimeout(() => terminateChild("SIGKILL"), 250);
|
|
1006
|
+
forceKillTimer.unref?.();
|
|
1007
|
+
}
|
|
1008
|
+
const result = { connected, reason };
|
|
1009
|
+
Object.defineProperty(result, "tools", {
|
|
1010
|
+
value: Array.isArray(tools) ? tools : [],
|
|
1011
|
+
enumerable: false,
|
|
1012
|
+
});
|
|
1013
|
+
resolve(result);
|
|
1014
|
+
};
|
|
1015
|
+
const onMessage = (message) => {
|
|
1016
|
+
if (!message || message.jsonrpc !== "2.0") return;
|
|
1017
|
+
if (message.id === 1) {
|
|
1018
|
+
if (message.error || !message.result) return finish(false, "initialize_failed");
|
|
1019
|
+
initialized = true;
|
|
1020
|
+
try {
|
|
1021
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`);
|
|
1022
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })}\n`);
|
|
1023
|
+
} catch {
|
|
1024
|
+
finish(false, "connection_failed");
|
|
1025
|
+
}
|
|
1026
|
+
} else if (message.id === 2 && initialized) {
|
|
1027
|
+
finish(
|
|
1028
|
+
!message.error && Boolean(message.result),
|
|
1029
|
+
message.error ? "tools_list_failed" : "connected",
|
|
1030
|
+
message.result?.tools,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
const drain = () => {
|
|
1035
|
+
while (buffer.length) {
|
|
1036
|
+
const header = buffer.toString("ascii", 0, Math.min(buffer.length, 64 * 1024)).match(/^Content-Length:\s*(\d+)\r?\n\r?\n/i);
|
|
1037
|
+
if (header) {
|
|
1038
|
+
const headerBytes = Buffer.byteLength(header[0], "ascii");
|
|
1039
|
+
const bodyBytes = Number(header[1]);
|
|
1040
|
+
if (!Number.isSafeInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > 1024 * 1024) return finish(false, "invalid_protocol_frame");
|
|
1041
|
+
if (buffer.length < headerBytes + bodyBytes) return;
|
|
1042
|
+
const body = buffer.subarray(headerBytes, headerBytes + bodyBytes).toString("utf8");
|
|
1043
|
+
buffer = buffer.subarray(headerBytes + bodyBytes);
|
|
1044
|
+
try { onMessage(JSON.parse(body)); } catch { /* ignore non-JSON noise */ }
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const newline = buffer.indexOf(0x0a);
|
|
1048
|
+
if (newline < 0) return;
|
|
1049
|
+
const line = buffer.subarray(0, newline).toString("utf8").trim();
|
|
1050
|
+
buffer = buffer.subarray(newline + 1);
|
|
1051
|
+
if (!line || /^Content-Length:/i.test(line)) continue;
|
|
1052
|
+
try { onMessage(JSON.parse(line)); } catch { /* ignore banners */ }
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
const timer = setTimeout(() => finish(false, "connection_timeout"), timeoutMs);
|
|
1056
|
+
try {
|
|
1057
|
+
child = spawnImpl(server.command, parseRuntimeServerArgs(server.args_json) || [], {
|
|
1058
|
+
cwd: options.cwd || process.cwd(),
|
|
1059
|
+
env: buildMcpChildEnv(options.env || process.env, server.credentialKeyNames || [], {
|
|
1060
|
+
runtimeHome: server.mcpRuntimeHome || mcpRuntimeHome(options.userDataDir, server.catalog_id || server.id || server.command),
|
|
1061
|
+
}),
|
|
1062
|
+
detached: process.platform !== "win32",
|
|
1063
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1064
|
+
});
|
|
1065
|
+
child.once("error", () => finish(false, "connection_failed"));
|
|
1066
|
+
child.once("close", () => {
|
|
1067
|
+
childClosed = true;
|
|
1068
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
1069
|
+
forceKillTimer = null;
|
|
1070
|
+
finish(false, "connection_closed");
|
|
1071
|
+
});
|
|
1072
|
+
child.stdout.on("data", (chunk) => {
|
|
1073
|
+
totalBytes += chunk.length;
|
|
1074
|
+
if (totalBytes > 1024 * 1024) return finish(false, "protocol_output_limit");
|
|
1075
|
+
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
1076
|
+
drain();
|
|
1077
|
+
});
|
|
1078
|
+
child.stdin.write(`${JSON.stringify({
|
|
1079
|
+
jsonrpc: "2.0",
|
|
1080
|
+
id: 1,
|
|
1081
|
+
method: "initialize",
|
|
1082
|
+
params: {
|
|
1083
|
+
protocolVersion: "2024-11-05",
|
|
1084
|
+
capabilities: {},
|
|
1085
|
+
clientInfo: { name: "agentlas-terminal-build", version: "1" },
|
|
1086
|
+
},
|
|
1087
|
+
})}\n`);
|
|
1088
|
+
if (options.signal) {
|
|
1089
|
+
abortHandler = () => finish(false, "connection_timeout");
|
|
1090
|
+
if (options.signal.aborted) abortHandler();
|
|
1091
|
+
else options.signal.addEventListener?.("abort", abortHandler, { once: true });
|
|
1092
|
+
}
|
|
1093
|
+
} catch {
|
|
1094
|
+
finish(false, "connection_failed");
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async function resolveApprovedMcpRuntimeAllowlist(options) {
|
|
1100
|
+
const approved = new Set(options.approvedIds || []);
|
|
1101
|
+
const selectedGroups = (options.plan?.entries || []).map((entry) => {
|
|
1102
|
+
if (entry.status !== "available") return null;
|
|
1103
|
+
const candidates = Array.isArray(entry.runtimeCandidates) && entry.runtimeCandidates.length
|
|
1104
|
+
? entry.runtimeCandidates
|
|
1105
|
+
: [{
|
|
1106
|
+
resolvedCatalogId: entry.resolvedCatalogId,
|
|
1107
|
+
registryServerId: entry.registryServerId,
|
|
1108
|
+
credentialKeyFingerprint: entry.credentialKeyFingerprint,
|
|
1109
|
+
}];
|
|
1110
|
+
const approvedCandidates = candidates.filter(
|
|
1111
|
+
(candidate) => candidate.resolvedCatalogId && approved.has(candidate.resolvedCatalogId),
|
|
1112
|
+
);
|
|
1113
|
+
return approvedCandidates.length ? { entry, candidates: approvedCandidates } : null;
|
|
1114
|
+
}).filter(Boolean);
|
|
1115
|
+
const probe = options.probeServer || ((server, probeOptions = {}) => probeSystemMcpServerConnection(server, {
|
|
1116
|
+
cwd: options.cwd,
|
|
1117
|
+
env: options.env,
|
|
1118
|
+
userDataDir: options.userDataDir,
|
|
1119
|
+
timeoutMs: probeOptions.timeoutMs,
|
|
1120
|
+
signal: probeOptions.signal,
|
|
1121
|
+
}));
|
|
1122
|
+
const bounded = (value, fallback, min, max) => {
|
|
1123
|
+
const parsed = Number(value);
|
|
1124
|
+
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, Math.trunc(parsed))) : fallback;
|
|
1125
|
+
};
|
|
1126
|
+
const concurrency = bounded(options.probeConcurrency, MCP_PROBE_CONCURRENCY, 1, MAX_APPROVED_MCP_PER_BUILD);
|
|
1127
|
+
const perServerTimeoutMs = bounded(options.probeTimeoutMs, MCP_PROBE_PER_SERVER_TIMEOUT_MS, 50, 30_000);
|
|
1128
|
+
const totalTimeoutMs = bounded(options.totalProbeTimeoutMs, MCP_PROBE_TOTAL_TIMEOUT_MS, 50, 60_000);
|
|
1129
|
+
const deadline = Date.now() + totalTimeoutMs;
|
|
1130
|
+
const outcomes = new Array(selectedGroups.length);
|
|
1131
|
+
let nextIndex = 0;
|
|
1132
|
+
|
|
1133
|
+
const probeCandidate = async (candidate) => {
|
|
1134
|
+
let server = null;
|
|
1135
|
+
try { server = readApprovedSystemMcpServer(options.db, candidate, { userDataDir: options.userDataDir }); }
|
|
1136
|
+
catch { /* one unsafe/unwritable runtime boundary excludes only this server */ }
|
|
1137
|
+
if (!server) return { candidate, server: null, status: { connected: false, reason: "registry_row_unavailable" } };
|
|
1138
|
+
const remainingMs = deadline - Date.now();
|
|
1139
|
+
if (remainingMs <= 0) return { candidate, server, status: { connected: false, reason: "probe_total_deadline" } };
|
|
1140
|
+
const timeoutMs = Math.min(perServerTimeoutMs, remainingMs);
|
|
1141
|
+
const controller = new AbortController();
|
|
1142
|
+
let timer = null;
|
|
1143
|
+
let status;
|
|
1144
|
+
try {
|
|
1145
|
+
status = await Promise.race([
|
|
1146
|
+
Promise.resolve(probe(server, { timeoutMs, signal: controller.signal })),
|
|
1147
|
+
new Promise((resolve) => {
|
|
1148
|
+
timer = setTimeout(() => {
|
|
1149
|
+
controller.abort();
|
|
1150
|
+
resolve({ connected: false, reason: "connection_timeout" });
|
|
1151
|
+
}, timeoutMs);
|
|
1152
|
+
}),
|
|
1153
|
+
]);
|
|
1154
|
+
} catch {
|
|
1155
|
+
status = { connected: false, reason: "connection_failed" };
|
|
1156
|
+
} finally {
|
|
1157
|
+
if (timer) clearTimeout(timer);
|
|
1158
|
+
}
|
|
1159
|
+
return { candidate, server, status };
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// Different requirements use a small worker pool. Alternatives for one
|
|
1163
|
+
// requirement are deliberately sequential so a failed primary cannot fan
|
|
1164
|
+
// out package-manager processes or affect unrelated server groups.
|
|
1165
|
+
const work = async () => {
|
|
1166
|
+
while (true) {
|
|
1167
|
+
const index = nextIndex++;
|
|
1168
|
+
if (index >= selectedGroups.length) return;
|
|
1169
|
+
const group = selectedGroups[index];
|
|
1170
|
+
const attempts = [];
|
|
1171
|
+
for (const candidate of group.candidates) {
|
|
1172
|
+
const outcome = await probeCandidate(candidate);
|
|
1173
|
+
attempts.push(outcome);
|
|
1174
|
+
if (outcome.status?.connected) break;
|
|
1175
|
+
}
|
|
1176
|
+
outcomes[index] = { entry: group.entry, attempts };
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, selectedGroups.length) }, () => work()));
|
|
1180
|
+
|
|
1181
|
+
const attached = [];
|
|
1182
|
+
const failed = [];
|
|
1183
|
+
const servers = [];
|
|
1184
|
+
for (let index = 0; index < outcomes.length; index += 1) {
|
|
1185
|
+
const outcome = outcomes[index] || {
|
|
1186
|
+
entry: selectedGroups[index]?.entry,
|
|
1187
|
+
attempts: [],
|
|
1188
|
+
};
|
|
1189
|
+
for (const attempt of outcome.attempts) {
|
|
1190
|
+
const catalogId = attempt.candidate.resolvedCatalogId;
|
|
1191
|
+
if (!attempt.status?.connected) {
|
|
1192
|
+
failed.push({ catalogId, reason: safeCatalogId(attempt.status?.reason) || "connection_failed" });
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
attached.push({ catalogId, registryServerId: attempt.server.id, status: "connected" });
|
|
1196
|
+
servers.push(attempt.server);
|
|
1197
|
+
break;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
let consentPersisted = servers.length === 0;
|
|
1201
|
+
if (servers.length) {
|
|
1202
|
+
try { consentPersisted = persistMcpConsentReceipts(options.userDataDir, servers); }
|
|
1203
|
+
catch { consentPersisted = false; }
|
|
1204
|
+
}
|
|
1205
|
+
const receipt = {
|
|
1206
|
+
schemaVersion: "agentlas.terminal-mcp-runtime-allowlist.v1",
|
|
1207
|
+
planId: options.plan?.planId || null,
|
|
1208
|
+
approvedCatalogIds: [...approved].sort(),
|
|
1209
|
+
attached,
|
|
1210
|
+
failed,
|
|
1211
|
+
emptyMode: attached.length === 0,
|
|
1212
|
+
consentPersisted,
|
|
1213
|
+
};
|
|
1214
|
+
Object.defineProperty(receipt, "servers", { value: servers, enumerable: false });
|
|
1215
|
+
return receipt;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function parseIdList(value) {
|
|
1219
|
+
if (!value || value === true) return [];
|
|
1220
|
+
return [...new Set(String(value).split(",").map((item) => item.trim()).filter(Boolean))];
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function tokenizeBuildCommandLine(value) {
|
|
1224
|
+
const tokens = [];
|
|
1225
|
+
let current = "";
|
|
1226
|
+
let quote = null;
|
|
1227
|
+
const source = String(value || "");
|
|
1228
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1229
|
+
const char = source[index];
|
|
1230
|
+
if (char === "\\" && quote !== "'") {
|
|
1231
|
+
const next = source[index + 1];
|
|
1232
|
+
if (next && (/\s/.test(next) || next === "\\" || next === '"' || next === "'")) {
|
|
1233
|
+
current += next;
|
|
1234
|
+
index += 1;
|
|
1235
|
+
} else {
|
|
1236
|
+
// Preserve Windows paths and ordinary backslashes. This parser only
|
|
1237
|
+
// consumes escapes needed to group command-line tokens; it never
|
|
1238
|
+
// applies shell expansion or command substitution.
|
|
1239
|
+
current += "\\";
|
|
1240
|
+
}
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
if (quote) {
|
|
1244
|
+
if (char === quote) quote = null;
|
|
1245
|
+
else current += char;
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
if (char === '"' || char === "'") {
|
|
1249
|
+
quote = char;
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
if (/\s/.test(char)) {
|
|
1253
|
+
if (current) tokens.push(current), current = "";
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
current += char;
|
|
1257
|
+
}
|
|
1258
|
+
if (quote) throw new Error("unterminated quote in /build command");
|
|
1259
|
+
if (current) tokens.push(current);
|
|
1260
|
+
return tokens;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function parseBuildArgs(args) {
|
|
1264
|
+
const buildArgs = args;
|
|
1265
|
+
const options = {
|
|
1266
|
+
task: [], requiredIds: [], recommendedIds: [], approvedIds: [],
|
|
1267
|
+
experienceTaskSignatures: [], experienceEnvironmentTags: [],
|
|
1268
|
+
experiencePackReleaseIds: [], experienceBaseReleaseId: null, experienceAgentDefinitionId: null,
|
|
1269
|
+
approveAll: false, noMcp: false, noExperience: false, planOnly: false, json: false,
|
|
1270
|
+
};
|
|
1271
|
+
for (let index = 0; index < buildArgs.length; index += 1) {
|
|
1272
|
+
const token = String(buildArgs[index]);
|
|
1273
|
+
const take = () => (index + 1 < buildArgs.length ? String(buildArgs[++index]) : "");
|
|
1274
|
+
if (token === "--mcp-plan-only") options.planOnly = true;
|
|
1275
|
+
else if (token === "--mcp-json") options.json = true;
|
|
1276
|
+
else if (token === "--approve-all-mcp") options.approveAll = true;
|
|
1277
|
+
else if (token === "--no-mcp") options.noMcp = true;
|
|
1278
|
+
else if (token === "--no-experience") options.noExperience = true;
|
|
1279
|
+
else if (token === "--experience-base-release") options.experienceBaseReleaseId = take();
|
|
1280
|
+
else if (token.startsWith("--experience-base-release=")) options.experienceBaseReleaseId = token.slice(26);
|
|
1281
|
+
else if (token === "--experience-pack-release") options.experiencePackReleaseIds.push(...parseIdList(take()));
|
|
1282
|
+
else if (token.startsWith("--experience-pack-release=")) options.experiencePackReleaseIds.push(...parseIdList(token.slice(26)));
|
|
1283
|
+
else if (token === "--experience-agent-definition") options.experienceAgentDefinitionId = take();
|
|
1284
|
+
else if (token.startsWith("--experience-agent-definition=")) options.experienceAgentDefinitionId = token.slice(30);
|
|
1285
|
+
else if (token === "--experience-task-signature") options.experienceTaskSignatures.push(...parseIdList(take()));
|
|
1286
|
+
else if (token.startsWith("--experience-task-signature=")) options.experienceTaskSignatures.push(...parseIdList(token.slice(28)));
|
|
1287
|
+
else if (token === "--experience-environment") options.experienceEnvironmentTags.push(...parseIdList(take()));
|
|
1288
|
+
else if (token.startsWith("--experience-environment=")) options.experienceEnvironmentTags.push(...parseIdList(token.slice(25)));
|
|
1289
|
+
else if (token === "--approve-mcp") options.approvedIds.push(...parseIdList(take()));
|
|
1290
|
+
else if (token.startsWith("--approve-mcp=")) options.approvedIds.push(...parseIdList(token.slice(14)));
|
|
1291
|
+
else if (token === "--require-mcp") options.requiredIds.push(...parseIdList(take()));
|
|
1292
|
+
else if (token.startsWith("--require-mcp=")) options.requiredIds.push(...parseIdList(token.slice(14)));
|
|
1293
|
+
else if (token === "--recommend-mcp") options.recommendedIds.push(...parseIdList(take()));
|
|
1294
|
+
else if (token.startsWith("--recommend-mcp=")) options.recommendedIds.push(...parseIdList(token.slice(16)));
|
|
1295
|
+
else options.task.push(token);
|
|
1296
|
+
}
|
|
1297
|
+
options.request = options.task.join(" ").trim();
|
|
1298
|
+
options.requiredIds = [...new Set(options.requiredIds)];
|
|
1299
|
+
options.recommendedIds = [...new Set(options.recommendedIds)];
|
|
1300
|
+
options.approvedIds = [...new Set(options.approvedIds)];
|
|
1301
|
+
options.experienceTaskSignatures = [...new Set(options.experienceTaskSignatures)];
|
|
1302
|
+
options.experienceEnvironmentTags = [...new Set(options.experienceEnvironmentTags)];
|
|
1303
|
+
options.experiencePackReleaseIds = [...new Set(options.experiencePackReleaseIds)];
|
|
1304
|
+
if (options.experienceBaseReleaseId) assertId(options.experienceBaseReleaseId, "--experience-base-release");
|
|
1305
|
+
if (options.experienceAgentDefinitionId) assertId(options.experienceAgentDefinitionId, "--experience-agent-definition");
|
|
1306
|
+
options.experiencePackReleaseIds.forEach((id) => assertId(id, "--experience-pack-release"));
|
|
1307
|
+
return options;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function renderMcpPlan(plan) {
|
|
1311
|
+
const lines = [`MCP BUILD PLAN · system-global registry first · registry: ${plan.registryStatus} · no network discovery`];
|
|
1312
|
+
if (plan.registryStatus === "unavailable") lines.push("- System-global registry could not be read. Build continues safely in empty-MCP mode; no install/network fallback was attempted.");
|
|
1313
|
+
if (!plan.entries.length) lines.push("- No relevant MCP recommended. Build continues in empty-MCP mode.");
|
|
1314
|
+
for (const entry of plan.entries) {
|
|
1315
|
+
const key = entry.keyRequired ? (entry.keyPresent ? "key: present" : "key: missing") : "key: not needed";
|
|
1316
|
+
const requirement = entry.required ? "required" : "optional";
|
|
1317
|
+
const permissions = entry.permissions.length ? entry.permissions.join(",") : "none";
|
|
1318
|
+
lines.push(`- P${entry.priority} ${entry.name} [${entry.resolvedCatalogId || entry.requestedCatalogId}] · ${requirement} · ${entry.status} · ${key}`);
|
|
1319
|
+
lines.push(` ${entry.reason}`);
|
|
1320
|
+
if (entry.fallbackCatalogIds?.length) lines.push(` approved fallback order: ${entry.fallbackCatalogIds.join(",")}`);
|
|
1321
|
+
lines.push(` permissions: ${permissions} · declared only; host enforcement not yet verified`);
|
|
1322
|
+
}
|
|
1323
|
+
if (plan.shortages.length) lines.push(`Shortages are isolated: ${plan.shortages.length} requirement(s) degrade only; the build does not abort.`);
|
|
1324
|
+
lines.push("Recommendation only: no MCP is attached until one explicit consent; this plan performs no network key probe or install.");
|
|
1325
|
+
return lines.join("\n");
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function normalizeConsentAnswer(answer, availableIds) {
|
|
1329
|
+
const text = String(answer || "").trim();
|
|
1330
|
+
if (/^(?:y|yes|all|전체)$/i.test(text)) return [...availableIds];
|
|
1331
|
+
if (!text || /^(?:n|no|none|없이|아니)$/i.test(text)) return [];
|
|
1332
|
+
const requested = parseIdList(text);
|
|
1333
|
+
const allowed = new Set(availableIds);
|
|
1334
|
+
return requested.filter((id) => allowed.has(id));
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function askMcpConsentOnce(plan, options = {}) {
|
|
1338
|
+
const input = options.input || process.stdin;
|
|
1339
|
+
const output = options.output || process.stderr;
|
|
1340
|
+
if (!input.isTTY || !output.isTTY || !plan.availableCatalogIds.length) return Promise.resolve([]);
|
|
1341
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
1342
|
+
return new Promise((resolve) => {
|
|
1343
|
+
rl.question("Attach the available MCP recommendations? [y=all / n=none / comma-separated ids] ", (answer) => {
|
|
1344
|
+
rl.close();
|
|
1345
|
+
resolve(normalizeConsentAnswer(answer, plan.availableCatalogIds));
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function buildMcpDirective(plan, approvedIds) {
|
|
1351
|
+
const approved = fitApprovedMcpIds(plan, approvedIds);
|
|
1352
|
+
const shortages = plan.shortages.map((item) => item.catalogId);
|
|
1353
|
+
const base = [
|
|
1354
|
+
"[AGENTLAS_MCP_BUILD_CONTEXT v1]",
|
|
1355
|
+
"Resolve MCP only from the host system-global registry; package IDs/requirements only, never server definitions or credentials.",
|
|
1356
|
+
].join(" ");
|
|
1357
|
+
const approvedClause = `Approved catalog IDs: ${approved.length ? approved.join(",") : "none"}.`;
|
|
1358
|
+
const shortagePrefix = "Unavailable or missing-key IDs: ";
|
|
1359
|
+
const shortageSuffix = "; degrade each capability independently and continue the build.";
|
|
1360
|
+
const fittedShortages = [];
|
|
1361
|
+
for (const id of shortages.slice(0, 16)) {
|
|
1362
|
+
const omitted = shortages.length - fittedShortages.length - 1;
|
|
1363
|
+
const proposal = `${shortagePrefix}${fittedShortages.concat(id).join(",")}${omitted > 0 ? ` (+${omitted} more)` : ""}${shortageSuffix}`;
|
|
1364
|
+
if (`${base} ${approvedClause} ${proposal}`.length > MAX_BUILD_DIRECTIVE_CHARS) break;
|
|
1365
|
+
fittedShortages.push(id);
|
|
1366
|
+
}
|
|
1367
|
+
const omittedShortages = shortages.length - fittedShortages.length;
|
|
1368
|
+
const shortageValue = shortages.length === 0
|
|
1369
|
+
? "none"
|
|
1370
|
+
: fittedShortages.length
|
|
1371
|
+
? `${fittedShortages.join(",")}${omittedShortages > 0 ? ` (+${omittedShortages} more)` : ""}`
|
|
1372
|
+
: `${shortages.length} unresolved (IDs omitted from prompt; declared policy remains source)`;
|
|
1373
|
+
const shortageClause = `${shortagePrefix}${shortageValue}${shortageSuffix}`;
|
|
1374
|
+
const line = `${base} ${approvedClause} ${shortageClause}`;
|
|
1375
|
+
if (line.length > MAX_BUILD_DIRECTIVE_CHARS) throw new Error("internal MCP builder directive exceeded its context limit");
|
|
1376
|
+
return line;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function fitApprovedMcpIds(plan, requestedIds) {
|
|
1380
|
+
const available = new Set(plan.availableCatalogIds || []);
|
|
1381
|
+
const requested = [...new Set((requestedIds || []).filter((id) => available.has(id)))].slice(0, MAX_APPROVED_MCP_PER_BUILD);
|
|
1382
|
+
const accepted = [];
|
|
1383
|
+
const fixedReserve = 520; // frozen instruction + minimum shortage/degrade clause
|
|
1384
|
+
for (const id of requested) {
|
|
1385
|
+
const clause = `Approved catalog IDs: ${accepted.concat(id).join(",")}.`;
|
|
1386
|
+
if (clause.length + fixedReserve > MAX_BUILD_DIRECTIVE_CHARS) break;
|
|
1387
|
+
accepted.push(id);
|
|
1388
|
+
}
|
|
1389
|
+
return accepted;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function renderBuildMcpResult(plan, approvedIds, runtimeAllowlist = null) {
|
|
1393
|
+
const approved = new Set(approvedIds || []);
|
|
1394
|
+
const attached = new Set((runtimeAllowlist?.attached || []).map((item) => item.catalogId));
|
|
1395
|
+
const failed = new Map((runtimeAllowlist?.failed || []).map((item) => [item.catalogId, item.reason]));
|
|
1396
|
+
const lines = ["MCP BUILD RESULT"];
|
|
1397
|
+
for (const entry of plan.entries) {
|
|
1398
|
+
let status = entry.status;
|
|
1399
|
+
if (entry.status === "available") {
|
|
1400
|
+
const candidateIds = [entry.resolvedCatalogId, ...(entry.fallbackCatalogIds || [])].filter(Boolean);
|
|
1401
|
+
const attachedId = candidateIds.find((id) => attached.has(id));
|
|
1402
|
+
const approvedId = candidateIds.find((id) => approved.has(id));
|
|
1403
|
+
const failedId = candidateIds.find((id) => failed.has(id));
|
|
1404
|
+
status = attachedId
|
|
1405
|
+
? attachedId === entry.resolvedCatalogId ? "connected-and-allowlisted" : `fallback-connected-and-allowlisted:${attachedId}`
|
|
1406
|
+
: failedId
|
|
1407
|
+
? `failed-isolated:${failedId}:${failed.get(failedId)}`
|
|
1408
|
+
: approvedId
|
|
1409
|
+
? "approved-but-not-attached"
|
|
1410
|
+
: "skipped";
|
|
1411
|
+
}
|
|
1412
|
+
lines.push(`- ${entry.resolvedCatalogId || entry.requestedCatalogId}: ${status}`);
|
|
1413
|
+
}
|
|
1414
|
+
if (!runtimeAllowlist || runtimeAllowlist.emptyMode) lines.push("- Build continued in empty-MCP mode.");
|
|
1415
|
+
if (runtimeAllowlist && runtimeAllowlist.consentPersisted === false) lines.push("- Runtime consent was one-pass only because its local fingerprint receipt could not be saved.");
|
|
1416
|
+
lines.push("Only the post-consent host allowlist reached the builder; tool-call success is not implied by connection readiness.");
|
|
1417
|
+
return lines.join("\n");
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
async function cmdBuild(options) {
|
|
1421
|
+
const parsed = parseBuildArgs(options.args || []);
|
|
1422
|
+
const emit = options.out || console.log;
|
|
1423
|
+
const inventory = collectSystemMcpInventory(options.db, { userDataDir: options.userDataDir, env: options.env || process.env });
|
|
1424
|
+
const policy = loadProjectMcpPolicy(options.cwd || process.cwd());
|
|
1425
|
+
const plan = buildMcpPlan({
|
|
1426
|
+
inventory, policy, request: parsed.request,
|
|
1427
|
+
requiredIds: parsed.requiredIds, recommendedIds: parsed.recommendedIds,
|
|
1428
|
+
});
|
|
1429
|
+
emit(parsed.json ? JSON.stringify(plan, null, 2) : renderMcpPlan(plan));
|
|
1430
|
+
if (parsed.planOnly) return { plan, approvedIds: [], invoked: false };
|
|
1431
|
+
|
|
1432
|
+
let approvedIds = [];
|
|
1433
|
+
if (!parsed.noMcp) {
|
|
1434
|
+
if (parsed.approveAll) approvedIds = [...plan.availableCatalogIds];
|
|
1435
|
+
else if (parsed.approvedIds.length) approvedIds = normalizeConsentAnswer(parsed.approvedIds.join(","), plan.availableCatalogIds);
|
|
1436
|
+
else approvedIds = await askMcpConsentOnce(plan, { input: options.input, output: options.promptOutput });
|
|
1437
|
+
}
|
|
1438
|
+
approvedIds = fitApprovedMcpIds(plan, approvedIds);
|
|
1439
|
+
const runtimeAllowlist = await resolveApprovedMcpRuntimeAllowlist({
|
|
1440
|
+
db: options.db,
|
|
1441
|
+
plan,
|
|
1442
|
+
approvedIds,
|
|
1443
|
+
cwd: options.cwd || process.cwd(),
|
|
1444
|
+
userDataDir: options.userDataDir,
|
|
1445
|
+
env: options.runtimeEnv || options.env || process.env,
|
|
1446
|
+
probeServer: options.probeMcpServer,
|
|
1447
|
+
});
|
|
1448
|
+
const attachedIds = runtimeAllowlist.attached.map((item) => item.catalogId);
|
|
1449
|
+
const directive = buildMcpDirective(plan, attachedIds);
|
|
1450
|
+
let experienceContext = { text: "", itemIds: [], estimatedTokens: 0, authority: "local-advisory", serverRentalResolutionReceiptPresent: false };
|
|
1451
|
+
if (
|
|
1452
|
+
!parsed.noExperience &&
|
|
1453
|
+
parsed.experienceBaseReleaseId &&
|
|
1454
|
+
parsed.experienceTaskSignatures.length &&
|
|
1455
|
+
parsed.experiencePackReleaseIds.length === 1
|
|
1456
|
+
) {
|
|
1457
|
+
const exchange = require("./agentlas-experience-exchange.cjs");
|
|
1458
|
+
experienceContext = exchange.buildLocalExperienceAdvisory({
|
|
1459
|
+
userDataDir: options.userDataDir,
|
|
1460
|
+
cwd: options.cwd || process.cwd(),
|
|
1461
|
+
baseAgentReleaseId: parsed.experienceBaseReleaseId,
|
|
1462
|
+
agentDefinitionId: parsed.experienceAgentDefinitionId,
|
|
1463
|
+
experiencePackReleaseIds: parsed.experiencePackReleaseIds,
|
|
1464
|
+
taskSignatures: parsed.experienceTaskSignatures,
|
|
1465
|
+
environmentTags: parsed.experienceEnvironmentTags.length
|
|
1466
|
+
? parsed.experienceEnvironmentTags
|
|
1467
|
+
: exchange.defaultEnvironmentTags(),
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
const builderRequest = [parsed.request, directive, experienceContext.text].filter(Boolean).join("\n\n");
|
|
1471
|
+
if (typeof options.invokeBuild === "function") {
|
|
1472
|
+
await options.invokeBuild(builderRequest, {
|
|
1473
|
+
plan,
|
|
1474
|
+
approvedIds,
|
|
1475
|
+
experienceContext,
|
|
1476
|
+
mcpRuntimeAllowlist: runtimeAllowlist,
|
|
1477
|
+
mcpServers: runtimeAllowlist.servers,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
emit(renderBuildMcpResult(plan, approvedIds, runtimeAllowlist));
|
|
1481
|
+
if (experienceContext.itemIds.length) emit(`Local Experience advisory attached: ${experienceContext.itemIds.length} item(s), ~${experienceContext.estimatedTokens} tokens · no server rental-resolution receipt.`);
|
|
1482
|
+
return { plan, approvedIds, mcpRuntimeAllowlist: runtimeAllowlist, experienceContext, invoked: typeof options.invokeBuild === "function" };
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function validateVariantCandidate(candidate, index) {
|
|
1486
|
+
const allowed = new Set(["variantId", "baseAgentReleaseId", "experiencePackReleaseId", "status", "compatibilityStatus", "score", "mcpRequirements"]);
|
|
1487
|
+
assertExactKeys(candidate, allowed, ["variantId", "baseAgentReleaseId", "experiencePackReleaseId", "status", "compatibilityStatus", "score", "mcpRequirements"], `candidate[${index}]`);
|
|
1488
|
+
for (const key of ["variantId", "baseAgentReleaseId", "experiencePackReleaseId"]) assertId(candidate[key], `candidate[${index}].${key}`);
|
|
1489
|
+
const requirements = candidate.mcpRequirements == null ? [] : candidate.mcpRequirements;
|
|
1490
|
+
if (!Array.isArray(requirements) || requirements.length > 64) throw new Error(`candidate[${index}].mcpRequirements is invalid`);
|
|
1491
|
+
requirements.forEach((requirement, requirementIndex) => validateMcpRequirement(requirement, `candidate[${index}].mcpRequirements[${requirementIndex}]`));
|
|
1492
|
+
const score = Number(candidate.score);
|
|
1493
|
+
if (!Number.isFinite(score) || score < 0 || score > 1_000_000) throw new Error(`candidate[${index}].score is outside the local-preview range`);
|
|
1494
|
+
return {
|
|
1495
|
+
variantId: candidate.variantId,
|
|
1496
|
+
baseAgentReleaseId: candidate.baseAgentReleaseId,
|
|
1497
|
+
experiencePackReleaseId: candidate.experiencePackReleaseId,
|
|
1498
|
+
status: String(candidate.status || "draft"),
|
|
1499
|
+
compatibilityStatus: String(candidate.compatibilityStatus || "unverified"),
|
|
1500
|
+
score,
|
|
1501
|
+
mcpRequirements: requirements,
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function resolveVariantCandidates(options) {
|
|
1506
|
+
const candidates = (options.candidates || []).map(validateVariantCandidate);
|
|
1507
|
+
const inventoryById = indexInventory(options.inventory || []);
|
|
1508
|
+
if (!options.baseAgentReleaseId) {
|
|
1509
|
+
return {
|
|
1510
|
+
schemaVersion: "agentlas.terminal-variant-resolution.v1",
|
|
1511
|
+
authority: "local-advisory",
|
|
1512
|
+
executionAuthorized: false,
|
|
1513
|
+
reputationAccepted: false,
|
|
1514
|
+
serverResolutionReceiptPresent: false,
|
|
1515
|
+
decision: "error",
|
|
1516
|
+
code: "EXACT_BASE_RELEASE_REQUIRED",
|
|
1517
|
+
selectedVariantId: null,
|
|
1518
|
+
baseAgentReleaseId: null,
|
|
1519
|
+
experiencePackReleaseId: null,
|
|
1520
|
+
fallbackOrder: [],
|
|
1521
|
+
degradedMcpIds: [],
|
|
1522
|
+
excluded: [],
|
|
1523
|
+
requiredMcpFailureScope: "variant-only",
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
const excluded = [];
|
|
1527
|
+
const eligible = [];
|
|
1528
|
+
for (const candidate of candidates) {
|
|
1529
|
+
const reasons = [];
|
|
1530
|
+
if (candidate.status !== "active") reasons.push(`variant-status:${candidate.status}`);
|
|
1531
|
+
if (candidate.compatibilityStatus !== "verified") reasons.push(`compatibility:${candidate.compatibilityStatus}`);
|
|
1532
|
+
if (options.baseAgentReleaseId && candidate.baseAgentReleaseId !== options.baseAgentReleaseId) reasons.push("base-release-mismatch");
|
|
1533
|
+
const degradedMcpIds = [];
|
|
1534
|
+
for (const requirement of candidate.mcpRequirements) {
|
|
1535
|
+
const resolution = resolveMcpRequirement(requirement, inventoryById);
|
|
1536
|
+
if (resolution.status !== "available" && requirement.required) reasons.push(`required-mcp-${resolution.status}:${requirement.catalogId}`);
|
|
1537
|
+
else if (resolution.status !== "available") degradedMcpIds.push(requirement.catalogId);
|
|
1538
|
+
}
|
|
1539
|
+
if (reasons.length) excluded.push({ variantId: candidate.variantId, reasons });
|
|
1540
|
+
else eligible.push({ ...candidate, degradedMcpIds });
|
|
1541
|
+
}
|
|
1542
|
+
eligible.sort((a, b) => b.score - a.score || a.variantId.localeCompare(b.variantId));
|
|
1543
|
+
const selected = eligible[0] || null;
|
|
1544
|
+
const baseRelease = options.baseAgentReleaseId;
|
|
1545
|
+
if (selected) {
|
|
1546
|
+
const decision = options.preferredVariantId && options.preferredVariantId !== selected.variantId ? "fallback" : "selected";
|
|
1547
|
+
return {
|
|
1548
|
+
schemaVersion: "agentlas.terminal-variant-resolution.v1",
|
|
1549
|
+
authority: "local-advisory",
|
|
1550
|
+
executionAuthorized: false,
|
|
1551
|
+
reputationAccepted: false,
|
|
1552
|
+
serverResolutionReceiptPresent: false,
|
|
1553
|
+
decision,
|
|
1554
|
+
selectedVariantId: selected.variantId,
|
|
1555
|
+
baseAgentReleaseId: selected.baseAgentReleaseId,
|
|
1556
|
+
experiencePackReleaseId: selected.experiencePackReleaseId,
|
|
1557
|
+
fallbackOrder: eligible.slice(1).map((candidate) => candidate.variantId),
|
|
1558
|
+
degradedMcpIds: selected.degradedMcpIds,
|
|
1559
|
+
excluded,
|
|
1560
|
+
requiredMcpFailureScope: "variant-only",
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
if (options.allowBaseOnly !== false && baseRelease) {
|
|
1564
|
+
return {
|
|
1565
|
+
schemaVersion: "agentlas.terminal-variant-resolution.v1",
|
|
1566
|
+
authority: "local-advisory",
|
|
1567
|
+
executionAuthorized: false,
|
|
1568
|
+
reputationAccepted: false,
|
|
1569
|
+
serverResolutionReceiptPresent: false,
|
|
1570
|
+
decision: "base-only",
|
|
1571
|
+
selectedVariantId: null,
|
|
1572
|
+
baseAgentReleaseId: baseRelease,
|
|
1573
|
+
experiencePackReleaseId: null,
|
|
1574
|
+
fallbackOrder: [],
|
|
1575
|
+
degradedMcpIds: [],
|
|
1576
|
+
excluded,
|
|
1577
|
+
requiredMcpFailureScope: "variant-only",
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
return {
|
|
1581
|
+
schemaVersion: "agentlas.terminal-variant-resolution.v1",
|
|
1582
|
+
authority: "local-advisory",
|
|
1583
|
+
executionAuthorized: false,
|
|
1584
|
+
reputationAccepted: false,
|
|
1585
|
+
serverResolutionReceiptPresent: false,
|
|
1586
|
+
decision: "error",
|
|
1587
|
+
code: "NO_ELIGIBLE_VARIANT_AND_NO_BASE_FALLBACK",
|
|
1588
|
+
selectedVariantId: null,
|
|
1589
|
+
baseAgentReleaseId: baseRelease,
|
|
1590
|
+
experiencePackReleaseId: null,
|
|
1591
|
+
fallbackOrder: [],
|
|
1592
|
+
degradedMcpIds: [],
|
|
1593
|
+
excluded,
|
|
1594
|
+
requiredMcpFailureScope: "variant-only",
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function renderVariantResolution(result) {
|
|
1599
|
+
const lines = [
|
|
1600
|
+
`VARIANT RESOLUTION: ${result.decision}`,
|
|
1601
|
+
"Local compatibility preview only; Hub rental requires a Web server resolution receipt.",
|
|
1602
|
+
"Candidate score/verified claims are not accepted as reputation, payment, rental, or execution authority.",
|
|
1603
|
+
];
|
|
1604
|
+
if (result.decision === "selected") lines.push(`selected: ${result.selectedVariantId}`);
|
|
1605
|
+
else if (result.decision === "fallback") lines.push(`fallback selected: ${result.selectedVariantId}`);
|
|
1606
|
+
else if (result.decision === "base-only") lines.push(`base-only: ${result.baseAgentReleaseId} (no Experience Pack attached)`);
|
|
1607
|
+
else lines.push(`error: ${result.code}`);
|
|
1608
|
+
if (result.fallbackOrder.length) lines.push(`next fallbacks: ${result.fallbackOrder.join(", ")}`);
|
|
1609
|
+
for (const excluded of result.excluded) lines.push(`excluded only ${excluded.variantId}: ${excluded.reasons.join(", ")}`);
|
|
1610
|
+
lines.push("Required MCP shortages exclude only the affected variant; they never create an agent-wide shortage.");
|
|
1611
|
+
return lines.join("\n");
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function cmdVariant(options) {
|
|
1615
|
+
const args = options.args || [];
|
|
1616
|
+
const sub = args[0] || "resolve";
|
|
1617
|
+
if (sub !== "resolve") throw new Error(`unknown variant subcommand: ${sub} (resolve)`);
|
|
1618
|
+
const flags = parseSimpleFlags(args.slice(1));
|
|
1619
|
+
let candidates = [];
|
|
1620
|
+
let baseAgentReleaseId = flags["base-release"] || null;
|
|
1621
|
+
const candidateFile = flags.candidates || flags._[0];
|
|
1622
|
+
if (candidateFile) {
|
|
1623
|
+
const { value } = readJsonFile(path.resolve(options.cwd || process.cwd(), candidateFile), "variant candidates");
|
|
1624
|
+
if (Array.isArray(value)) candidates = value;
|
|
1625
|
+
else {
|
|
1626
|
+
assertObject(value, "variant candidate document");
|
|
1627
|
+
if (!Array.isArray(value.candidates)) throw new Error("variant candidate document must contain candidates[]");
|
|
1628
|
+
candidates = value.candidates;
|
|
1629
|
+
if (!baseAgentReleaseId && value.baseAgentReleaseId) baseAgentReleaseId = value.baseAgentReleaseId;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
if (baseAgentReleaseId) assertId(baseAgentReleaseId, "--base-release");
|
|
1633
|
+
const inventory = collectSystemMcpInventory(options.db, { userDataDir: options.userDataDir, env: options.env || process.env });
|
|
1634
|
+
const result = resolveVariantCandidates({
|
|
1635
|
+
candidates,
|
|
1636
|
+
inventory,
|
|
1637
|
+
baseAgentReleaseId,
|
|
1638
|
+
preferredVariantId: flags.prefer || null,
|
|
1639
|
+
allowBaseOnly: flags["no-base-only"] !== true,
|
|
1640
|
+
});
|
|
1641
|
+
const emit = options.out || console.log;
|
|
1642
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : renderVariantResolution(result));
|
|
1643
|
+
if (result.decision === "error") (options.setExitCode || ((code) => { process.exitCode = code; }))(2);
|
|
1644
|
+
return result;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function estimateTokens(text) {
|
|
1648
|
+
// UTF-8 bytes / 3 is deliberately conservative for Korean and mixed code.
|
|
1649
|
+
return Math.ceil(Buffer.byteLength(String(text || ""), "utf8") / 3);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function buildExperienceContext(items, options = {}) {
|
|
1653
|
+
const maxItems = Math.min(TOKEN_BUDGET.experienceRetrievalMaxItems, Math.max(0, Number(options.maxItems ?? TOKEN_BUDGET.experienceRetrievalMaxItems)));
|
|
1654
|
+
const maxTokens = Math.min(TOKEN_BUDGET.experienceRetrievalMaxTokens, Math.max(0, Number(options.maxTokens ?? TOKEN_BUDGET.experienceRetrievalMaxTokens)));
|
|
1655
|
+
const relevant = (items || [])
|
|
1656
|
+
.filter((item) => item && item.relevant === true && item.status === "promoted")
|
|
1657
|
+
.sort((a, b) => Number(b.relevance || 0) - Number(a.relevance || 0) || String(a.id).localeCompare(String(b.id)));
|
|
1658
|
+
if (!relevant.length || !maxItems || !maxTokens) return { text: "", itemIds: [], estimatedTokens: 0 };
|
|
1659
|
+
let text = "EXPERIENCE (verified relevant items only):";
|
|
1660
|
+
const itemIds = [];
|
|
1661
|
+
for (const item of relevant.slice(0, maxItems)) {
|
|
1662
|
+
const id = assertId(item.id, "experience context item.id");
|
|
1663
|
+
const summary = assertSafeText(item.summary, "experience context item.summary", 320);
|
|
1664
|
+
const next = `${text}\n- [${id}] ${summary}`;
|
|
1665
|
+
if (estimateTokens(next) > maxTokens) continue;
|
|
1666
|
+
text = next;
|
|
1667
|
+
itemIds.push(id);
|
|
1668
|
+
}
|
|
1669
|
+
if (!itemIds.length) return { text: "", itemIds: [], estimatedTokens: 0 };
|
|
1670
|
+
return { text, itemIds, estimatedTokens: estimateTokens(text) };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
module.exports = {
|
|
1674
|
+
TOKEN_BUDGET,
|
|
1675
|
+
MCP_PROBE_CONCURRENCY,
|
|
1676
|
+
MCP_PROBE_PER_SERVER_TIMEOUT_MS,
|
|
1677
|
+
MCP_PROBE_TOTAL_TIMEOUT_MS,
|
|
1678
|
+
validateExperiencePack,
|
|
1679
|
+
validateMcpRequirement,
|
|
1680
|
+
validateMcpPolicy,
|
|
1681
|
+
experienceStatePath,
|
|
1682
|
+
loadExperienceState,
|
|
1683
|
+
publishExperienceIntent,
|
|
1684
|
+
unpublishExperienceIntent,
|
|
1685
|
+
cmdExperience,
|
|
1686
|
+
collectSystemMcpInventory,
|
|
1687
|
+
loadProjectMcpPolicy,
|
|
1688
|
+
resolveMcpRequirement,
|
|
1689
|
+
buildMcpPlan,
|
|
1690
|
+
parseBuildArgs,
|
|
1691
|
+
tokenizeBuildCommandLine,
|
|
1692
|
+
normalizeConsentAnswer,
|
|
1693
|
+
askMcpConsentOnce,
|
|
1694
|
+
fitApprovedMcpIds,
|
|
1695
|
+
buildMcpDirective,
|
|
1696
|
+
mcpConsentStatePath,
|
|
1697
|
+
loadMcpConsentState,
|
|
1698
|
+
materializeTrustedSystemMcpServer,
|
|
1699
|
+
persistMcpConsentReceipts,
|
|
1700
|
+
readConsentedSystemMcpServers,
|
|
1701
|
+
readApprovedSystemMcpServer,
|
|
1702
|
+
probeSystemMcpServerConnection,
|
|
1703
|
+
resolveApprovedMcpRuntimeAllowlist,
|
|
1704
|
+
cmdBuild,
|
|
1705
|
+
resolveVariantCandidates,
|
|
1706
|
+
cmdVariant,
|
|
1707
|
+
estimateTokens,
|
|
1708
|
+
buildExperienceContext,
|
|
1709
|
+
};
|