agentlas 0.6.0 → 0.7.0
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/README.md +73 -0
- package/engine/agentlas-capabilities.cjs +34 -3
- package/engine/agentlas-experience-exchange.cjs +1401 -0
- package/engine/agentlas-experience-mcp.cjs +1147 -0
- package/engine/agentlas-repl.cjs +21 -13
- package/engine/agentlas.cjs +281 -50
- package/package.json +1 -1
- package/test/cloud-save-publish.cjs +34 -0
- package/test/engine-hardening-regression.cjs +74 -0
- package/test/experience-exchange-contract.cjs +569 -0
- package/test/experience-mcp-contract.cjs +391 -0
- package/test/fixtures/portable-experience-bundle-v1-golden.json +124 -0
- package/test/route-regression.cjs +244 -8
- package/test/runtime-env-protection.cjs +45 -1
- package/test/smoke.sh +3 -0
- package/test/terminal-ui-regression.cjs +7 -2
|
@@ -0,0 +1,1147 @@
|
|
|
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 fs = require("node:fs");
|
|
18
|
+
const os = require("node:os");
|
|
19
|
+
const path = require("node:path");
|
|
20
|
+
const readline = require("node:readline");
|
|
21
|
+
|
|
22
|
+
const TOKEN_BUDGET = Object.freeze({
|
|
23
|
+
coreMemoryMaxTokens: 150,
|
|
24
|
+
experienceRetrievalMaxTokens: 800,
|
|
25
|
+
experienceRetrievalMaxItems: 8,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{2,255}$/;
|
|
29
|
+
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
30
|
+
const ENV_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
31
|
+
const EXPERIENCE_STATE_SCHEMA = "agentlas.terminal-experience-intents.v1";
|
|
32
|
+
const EXPERIENCE_INTENT_SCHEMA = "agentlas.terminal-experience-intent.v1";
|
|
33
|
+
const MAX_JSON_BYTES = 2 * 1024 * 1024;
|
|
34
|
+
const MAX_BUILD_DIRECTIVE_CHARS = 1400;
|
|
35
|
+
const MAX_APPROVED_MCP_PER_BUILD = 8;
|
|
36
|
+
const EXPERIENCE_LOCK_STALE_MS = 30_000;
|
|
37
|
+
const EXPERIENCE_LOCK_WAIT_MS = 2_000;
|
|
38
|
+
|
|
39
|
+
const EXPERIENCE_PACK_REQUIRED = [
|
|
40
|
+
"schemaVersion", "kind", "experiencePackId", "releaseId", "ownerRef", "version",
|
|
41
|
+
"baseCompatibility", "itemIds", "evidenceReceiptIds", "mcpRequirements",
|
|
42
|
+
"containsBasePackageMaterial", "contentHash", "visibility", "status",
|
|
43
|
+
];
|
|
44
|
+
const EXPERIENCE_PACK_ALLOWED = new Set([...EXPERIENCE_PACK_REQUIRED, "createdAt", "releasedAt", "withdrawnAt"]);
|
|
45
|
+
const MCP_REQUIREMENT_REQUIRED = [
|
|
46
|
+
"schemaVersion", "kind", "requirementId", "catalogId", "reason", "capabilities",
|
|
47
|
+
"required", "requiresKey", "priority", "permissions", "alternatives", "unavailablePolicy",
|
|
48
|
+
];
|
|
49
|
+
const MCP_REQUIREMENT_ALLOWED = new Set([...MCP_REQUIREMENT_REQUIRED, "credentialMetadata"]);
|
|
50
|
+
|
|
51
|
+
// Public contract text must be compact, value-free, and instruction-safe.
|
|
52
|
+
const UNSAFE_TEXT_PATTERNS = [
|
|
53
|
+
{ code: "openai-secret", re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
|
|
54
|
+
{ code: "github-secret", re: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
|
|
55
|
+
{ code: "aws-secret", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
56
|
+
{ code: "private-key", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i },
|
|
57
|
+
{ code: "credential", re: /(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|password|private[_ -]?key|authorization)\s*[:=]\s*\S+/i },
|
|
58
|
+
{ code: "bearer", re: /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i },
|
|
59
|
+
{ code: "private-path", re: /(?:file:\/\/|(?:^|\s)(?:~\/|\/(?:Users|home|private|Volumes|var\/folders)\/|[A-Za-z]:\\(?:Users|Documents|Desktop)\\))/i },
|
|
60
|
+
{ code: "email", re: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i },
|
|
61
|
+
{ code: "phone", re: /(?:\+?\d[\d .()-]{8,}\d)/ },
|
|
62
|
+
{ 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 },
|
|
63
|
+
{ 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 },
|
|
64
|
+
{ code: "prompt-injection", re: /(?:ignore|disregard|override)[\s_-]+(?:all[\s_-]+)?(?:previous|prior|system|developer|hidden)[\s_-]+(?:instructions?|prompts?|rules?|directives?)/i },
|
|
65
|
+
{ 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 },
|
|
66
|
+
{ 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 },
|
|
67
|
+
{ code: "opaque-blob", re: /\b(?:[A-Fa-f0-9]{128,}|[A-Za-z0-9+/]{124,}={0,2})\b/ },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function assertObject(value, label) {
|
|
71
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertExactKeys(value, allowed, required, label) {
|
|
76
|
+
assertObject(value, label);
|
|
77
|
+
for (const key of Object.keys(value)) {
|
|
78
|
+
if (!allowed.has(key)) throw new Error(`${label} has an unsupported field: ${key}`);
|
|
79
|
+
}
|
|
80
|
+
for (const key of required) {
|
|
81
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) throw new Error(`${label} is missing: ${key}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assertId(value, label) {
|
|
86
|
+
if (!ID_RE.test(String(value || ""))) throw new Error(`${label} is not a valid Agentlas id`);
|
|
87
|
+
return String(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertUniqueIds(value, label, options = {}) {
|
|
91
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
92
|
+
if (options.min && value.length < options.min) throw new Error(`${label} must have at least ${options.min} item(s)`);
|
|
93
|
+
if (options.max && value.length > options.max) throw new Error(`${label} has too many items`);
|
|
94
|
+
const items = value.map((item, index) => assertId(item, `${label}[${index}]`));
|
|
95
|
+
if (new Set(items).size !== items.length) throw new Error(`${label} must be unique`);
|
|
96
|
+
return items;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertSafeText(value, label, max = 300) {
|
|
100
|
+
const text = String(value || "").trim();
|
|
101
|
+
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`);
|
|
102
|
+
const unsafe = UNSAFE_TEXT_PATTERNS.find((pattern) => pattern.re.test(text));
|
|
103
|
+
if (unsafe) throw new Error(`${label} is not public-safe (${unsafe.code})`);
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function assertIsoDateOrNull(value, label) {
|
|
108
|
+
if (value == null) return null;
|
|
109
|
+
if (typeof value !== "string" || !value || !Number.isFinite(Date.parse(value))) throw new Error(`${label} must be an ISO date-time or null`);
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateCredentialMetadata(value, label) {
|
|
114
|
+
const allowed = new Set(["provider", "env", "allowedHosts", "scopes", "setupUrl", "brokerMode"]);
|
|
115
|
+
assertExactKeys(value, allowed, ["provider", "env"], label);
|
|
116
|
+
assertId(value.provider, `${label}.provider`);
|
|
117
|
+
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)))) {
|
|
118
|
+
throw new Error(`${label}.env must contain unique uppercase environment names`);
|
|
119
|
+
}
|
|
120
|
+
assertSafeText(value.provider, `${label}.provider`, 255);
|
|
121
|
+
value.env.forEach((key, index) => assertSafeText(key, `${label}.env[${index}]`, 255));
|
|
122
|
+
if (value.allowedHosts != null) {
|
|
123
|
+
if (!Array.isArray(value.allowedHosts) || !value.allowedHosts.length || new Set(value.allowedHosts).size !== value.allowedHosts.length) {
|
|
124
|
+
throw new Error(`${label}.allowedHosts must be a non-empty unique list`);
|
|
125
|
+
}
|
|
126
|
+
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])?)*$/;
|
|
127
|
+
for (const host of value.allowedHosts) {
|
|
128
|
+
if (typeof host !== "string" || host.length > 255 || !hostRe.test(host)) throw new Error(`${label}.allowedHosts contains an invalid host`);
|
|
129
|
+
assertSafeText(host, `${label}.allowedHosts`, 255);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (value.scopes != null) {
|
|
133
|
+
if (!Array.isArray(value.scopes) || !value.scopes.length || new Set(value.scopes).size !== value.scopes.length) {
|
|
134
|
+
throw new Error(`${label}.scopes must be a non-empty unique list`);
|
|
135
|
+
}
|
|
136
|
+
for (const scope of value.scopes) {
|
|
137
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/.test(String(scope))) throw new Error(`${label}.scopes contains an invalid scope`);
|
|
138
|
+
assertSafeText(scope, `${label}.scopes`, 128);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (value.setupUrl != null) {
|
|
142
|
+
let parsed;
|
|
143
|
+
try { parsed = new URL(value.setupUrl); } catch { throw new Error(`${label}.setupUrl must be a safe HTTPS provider page`); }
|
|
144
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port || parsed.search || parsed.hash) {
|
|
145
|
+
throw new Error(`${label}.setupUrl must be HTTPS without userinfo, custom port, query, or fragment`);
|
|
146
|
+
}
|
|
147
|
+
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)) {
|
|
148
|
+
throw new Error(`${label}.setupUrl hostname is invalid`);
|
|
149
|
+
}
|
|
150
|
+
assertSafeText(value.setupUrl, `${label}.setupUrl`, 2048);
|
|
151
|
+
}
|
|
152
|
+
if (value.brokerMode != null && !["host-bound-broker", "runtime-env-injection", "provider-managed-oauth", "manual-provider-page"].includes(value.brokerMode)) {
|
|
153
|
+
throw new Error(`${label}.brokerMode is invalid`);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateMcpRequirement(value, label = "mcpRequirement") {
|
|
159
|
+
assertExactKeys(value, MCP_REQUIREMENT_ALLOWED, MCP_REQUIREMENT_REQUIRED, label);
|
|
160
|
+
if (value.schemaVersion !== "agentlas.mcp-requirement.v1" || value.kind !== "agentlas-mcp-requirement") {
|
|
161
|
+
throw new Error(`${label} has an unsupported schema`);
|
|
162
|
+
}
|
|
163
|
+
assertId(value.requirementId, `${label}.requirementId`);
|
|
164
|
+
assertId(value.catalogId, `${label}.catalogId`);
|
|
165
|
+
assertSafeText(value.reason, `${label}.reason`, 300);
|
|
166
|
+
const capabilities = assertUniqueIds(value.capabilities, `${label}.capabilities`, { min: 1 });
|
|
167
|
+
if (typeof value.required !== "boolean" || typeof value.requiresKey !== "boolean") throw new Error(`${label} required/requiresKey must be boolean`);
|
|
168
|
+
if (!Number.isInteger(value.priority) || value.priority < 1 || value.priority > 1000) throw new Error(`${label}.priority is invalid`);
|
|
169
|
+
const permissions = assertUniqueIds(value.permissions, `${label}.permissions`);
|
|
170
|
+
const alternatives = assertUniqueIds(value.alternatives, `${label}.alternatives`);
|
|
171
|
+
if (alternatives.includes(value.catalogId)) throw new Error(`${label}.alternatives must not contain the primary catalogId`);
|
|
172
|
+
[...capabilities, ...permissions, ...alternatives].forEach((text, index) => assertSafeText(text, `${label}.publicText[${index}]`, 255));
|
|
173
|
+
const unavailable = assertObject(value.unavailablePolicy, `${label}.unavailablePolicy`);
|
|
174
|
+
assertExactKeys(unavailable, new Set(["build", "rental", "execution"]), ["build", "rental", "execution"], `${label}.unavailablePolicy`);
|
|
175
|
+
if (unavailable.build !== "degrade") throw new Error(`${label} must degrade rather than abort a build`);
|
|
176
|
+
const expectedRental = value.required ? "exclude-variant" : "continue-degraded";
|
|
177
|
+
if (unavailable.rental !== expectedRental) throw new Error(`${label} rental policy must be ${expectedRental}`);
|
|
178
|
+
if (!["use-alternative", "disable-capability", "continue-degraded"].includes(unavailable.execution)) throw new Error(`${label} execution policy is invalid`);
|
|
179
|
+
if (value.credentialMetadata != null) validateCredentialMetadata(value.credentialMetadata, `${label}.credentialMetadata`);
|
|
180
|
+
if (value.requiresKey && value.credentialMetadata == null) throw new Error(`${label} requires credential metadata`);
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validateExperiencePack(value) {
|
|
185
|
+
assertExactKeys(value, EXPERIENCE_PACK_ALLOWED, EXPERIENCE_PACK_REQUIRED, "experience pack");
|
|
186
|
+
if (value.schemaVersion !== "agentlas.experience-pack.v1" || value.kind !== "agentlas-experience-pack") {
|
|
187
|
+
throw new Error("experience pack has an unsupported schema");
|
|
188
|
+
}
|
|
189
|
+
for (const key of ["experiencePackId", "releaseId", "ownerRef", "version"]) assertId(value[key], `experience pack.${key}`);
|
|
190
|
+
const base = assertObject(value.baseCompatibility, "experience pack.baseCompatibility");
|
|
191
|
+
assertExactKeys(base, new Set(["agentDefinitionId", "compatibleBaseReleaseIds"]), ["agentDefinitionId", "compatibleBaseReleaseIds"], "experience pack.baseCompatibility");
|
|
192
|
+
assertId(base.agentDefinitionId, "experience pack.baseCompatibility.agentDefinitionId");
|
|
193
|
+
assertUniqueIds(base.compatibleBaseReleaseIds, "experience pack.baseCompatibility.compatibleBaseReleaseIds", { min: 1 });
|
|
194
|
+
assertUniqueIds(value.itemIds, "experience pack.itemIds", { min: value.status === "active" ? 1 : 0 });
|
|
195
|
+
assertUniqueIds(value.evidenceReceiptIds, "experience pack.evidenceReceiptIds");
|
|
196
|
+
if (!Array.isArray(value.mcpRequirements) || value.mcpRequirements.length > 64) throw new Error("experience pack.mcpRequirements is invalid");
|
|
197
|
+
value.mcpRequirements.forEach((requirement, index) => validateMcpRequirement(requirement, `experience pack.mcpRequirements[${index}]`));
|
|
198
|
+
if (value.containsBasePackageMaterial !== false) throw new Error("experience pack must reference the base release; copied base material is forbidden");
|
|
199
|
+
if (!HASH_RE.test(String(value.contentHash || ""))) throw new Error("experience pack.contentHash is invalid");
|
|
200
|
+
if (!["private", "unlisted", "public"].includes(value.visibility)) throw new Error("experience pack.visibility is invalid");
|
|
201
|
+
if (!["draft", "active", "suspended", "withdrawn", "deleted"].includes(value.status)) throw new Error("experience pack.status is invalid");
|
|
202
|
+
for (const key of ["createdAt", "releasedAt", "withdrawnAt"]) if (Object.prototype.hasOwnProperty.call(value, key)) assertIsoDateOrNull(value[key], `experience pack.${key}`);
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readJsonFile(filePath, label) {
|
|
207
|
+
const absolute = path.resolve(filePath);
|
|
208
|
+
const stat = fs.lstatSync(absolute);
|
|
209
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${label} must be a regular file (symlinks are not accepted)`);
|
|
210
|
+
if (stat.size <= 0 || stat.size > MAX_JSON_BYTES) throw new Error(`${label} has an invalid size`);
|
|
211
|
+
let value;
|
|
212
|
+
try { value = JSON.parse(fs.readFileSync(absolute, "utf8")); } catch (error) { throw new Error(`${label} is not valid JSON: ${error.message}`); }
|
|
213
|
+
return { absolute, value };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function writePrivateJsonAtomic(filePath, value) {
|
|
217
|
+
const dir = path.dirname(filePath);
|
|
218
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
219
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
220
|
+
const temp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
221
|
+
try {
|
|
222
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
223
|
+
fs.renameSync(temp, filePath);
|
|
224
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* best effort */ }
|
|
225
|
+
} finally {
|
|
226
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* noop */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function experienceStatePath(userDataDir) {
|
|
231
|
+
return path.join(userDataDir, "terminal", "experience-intents-v1.json");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function waitSync(milliseconds) {
|
|
235
|
+
// Atomics.wait is a bounded, non-spinning sleep available in supported Node 20+.
|
|
236
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
237
|
+
Atomics.wait(signal, 0, 0, milliseconds);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function withExperienceStateLock(userDataDir, action) {
|
|
241
|
+
const stateFile = experienceStatePath(userDataDir);
|
|
242
|
+
const dir = path.dirname(stateFile);
|
|
243
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
244
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
245
|
+
const lockFile = `${stateFile}.lock`;
|
|
246
|
+
const deadline = Date.now() + EXPERIENCE_LOCK_WAIT_MS;
|
|
247
|
+
let descriptor = null;
|
|
248
|
+
while (descriptor == null) {
|
|
249
|
+
try {
|
|
250
|
+
descriptor = fs.openSync(lockFile, "wx", 0o600);
|
|
251
|
+
fs.writeFileSync(descriptor, `${process.pid}\n${new Date().toISOString()}\n`, "utf8");
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!error || error.code !== "EEXIST") throw error;
|
|
254
|
+
try {
|
|
255
|
+
const stat = fs.lstatSync(lockFile);
|
|
256
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Terminal experience lock is unsafe");
|
|
257
|
+
if (Date.now() - stat.mtimeMs > EXPERIENCE_LOCK_STALE_MS) {
|
|
258
|
+
fs.unlinkSync(lockFile);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
} catch (statError) {
|
|
262
|
+
if (statError && statError.code === "ENOENT") continue;
|
|
263
|
+
throw statError;
|
|
264
|
+
}
|
|
265
|
+
if (Date.now() >= deadline) throw new Error("Terminal experience state is busy; retry the command");
|
|
266
|
+
waitSync(25);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
return action();
|
|
271
|
+
} finally {
|
|
272
|
+
try { fs.closeSync(descriptor); } catch { /* noop */ }
|
|
273
|
+
try { fs.unlinkSync(lockFile); } catch { /* crash recovery handles leftovers */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function emptyExperienceState() {
|
|
278
|
+
return { schemaVersion: EXPERIENCE_STATE_SCHEMA, updatedAt: null, intents: [] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function loadExperienceState(userDataDir) {
|
|
282
|
+
const file = experienceStatePath(userDataDir);
|
|
283
|
+
if (!fs.existsSync(file)) return emptyExperienceState();
|
|
284
|
+
const stat = fs.lstatSync(file);
|
|
285
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_JSON_BYTES) throw new Error("Terminal experience state is unsafe or too large");
|
|
286
|
+
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
287
|
+
assertExactKeys(state, new Set(["schemaVersion", "updatedAt", "intents"]), ["schemaVersion", "updatedAt", "intents"], "Terminal experience state");
|
|
288
|
+
if (state.schemaVersion !== EXPERIENCE_STATE_SCHEMA || !Array.isArray(state.intents)) throw new Error("Terminal experience state schema is invalid");
|
|
289
|
+
assertIsoDateOrNull(state.updatedAt, "Terminal experience state.updatedAt");
|
|
290
|
+
state.intents.forEach((intent, index) => validateStoredExperienceIntent(intent, index));
|
|
291
|
+
return state;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateStoredExperienceIntent(intent, index) {
|
|
295
|
+
const label = `Terminal experience state.intents[${index}]`;
|
|
296
|
+
const required = [
|
|
297
|
+
"schemaVersion", "intentId", "experiencePackId", "releaseId", "ownerRef", "version", "contentHash",
|
|
298
|
+
"compatibleBaseReleaseIds", "mcpRequirementIds", "sourcePath", "desiredAction", "localState", "hubReceipt",
|
|
299
|
+
"contractValidatedAt", "contentVerified", "updatedAt",
|
|
300
|
+
];
|
|
301
|
+
assertExactKeys(intent, new Set(required), required, label);
|
|
302
|
+
if (intent.schemaVersion !== EXPERIENCE_INTENT_SCHEMA) throw new Error(`${label}.schemaVersion is invalid`);
|
|
303
|
+
for (const key of ["intentId", "experiencePackId", "releaseId", "ownerRef", "version"]) assertId(intent[key], `${label}.${key}`);
|
|
304
|
+
if (!HASH_RE.test(String(intent.contentHash || ""))) throw new Error(`${label}.contentHash is invalid`);
|
|
305
|
+
assertUniqueIds(intent.compatibleBaseReleaseIds, `${label}.compatibleBaseReleaseIds`, { min: 1 });
|
|
306
|
+
assertUniqueIds(intent.mcpRequirementIds, `${label}.mcpRequirementIds`);
|
|
307
|
+
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`);
|
|
308
|
+
if (!['publish', 'unpublish'].includes(intent.desiredAction)) throw new Error(`${label}.desiredAction is invalid`);
|
|
309
|
+
const expectedState = intent.desiredAction === "publish" ? "publish-requested" : "unpublish-requested";
|
|
310
|
+
if (intent.localState !== expectedState) throw new Error(`${label}.localState is inconsistent`);
|
|
311
|
+
if (intent.hubReceipt !== null) throw new Error(`${label}.hubReceipt cannot be synthesized locally`);
|
|
312
|
+
if (intent.contentVerified !== false) throw new Error(`${label}.contentVerified cannot be asserted from a declaration alone`);
|
|
313
|
+
assertIsoDateOrNull(intent.contractValidatedAt, `${label}.contractValidatedAt`);
|
|
314
|
+
assertIsoDateOrNull(intent.updatedAt, `${label}.updatedAt`);
|
|
315
|
+
return intent;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function saveExperienceState(userDataDir, state) {
|
|
319
|
+
state.updatedAt = new Date().toISOString();
|
|
320
|
+
writePrivateJsonAtomic(experienceStatePath(userDataDir), state);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function publicExperienceIntent(intent) {
|
|
324
|
+
return {
|
|
325
|
+
schemaVersion: intent.schemaVersion,
|
|
326
|
+
intentId: intent.intentId,
|
|
327
|
+
experiencePackId: intent.experiencePackId,
|
|
328
|
+
releaseId: intent.releaseId,
|
|
329
|
+
ownerRef: intent.ownerRef,
|
|
330
|
+
version: intent.version,
|
|
331
|
+
contentHash: intent.contentHash,
|
|
332
|
+
compatibleBaseReleaseIds: intent.compatibleBaseReleaseIds,
|
|
333
|
+
desiredAction: intent.desiredAction,
|
|
334
|
+
localState: intent.localState,
|
|
335
|
+
hubPublication: { status: "not-submitted", receiptPresent: false },
|
|
336
|
+
updatedAt: intent.updatedAt,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function parseSimpleFlags(args) {
|
|
341
|
+
const flags = { _: [] };
|
|
342
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
343
|
+
const token = String(args[index]);
|
|
344
|
+
if (token === "--json") flags.json = true;
|
|
345
|
+
else if (token.startsWith("--") && token.includes("=")) {
|
|
346
|
+
const at = token.indexOf("=");
|
|
347
|
+
flags[token.slice(2, at)] = token.slice(at + 1);
|
|
348
|
+
} else if (token.startsWith("--")) {
|
|
349
|
+
const key = token.slice(2);
|
|
350
|
+
if (index + 1 < args.length && !String(args[index + 1]).startsWith("--")) flags[key] = String(args[++index]);
|
|
351
|
+
else flags[key] = true;
|
|
352
|
+
} else flags._.push(token);
|
|
353
|
+
}
|
|
354
|
+
return flags;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function renderExperienceList(intents) {
|
|
358
|
+
if (!intents.length) return "No local Experience Pack publication intents.\nHub publication: not attempted.";
|
|
359
|
+
const lines = ["LOCAL EXPERIENCE INTENTS (Terminal-owned; not Hub publication)"];
|
|
360
|
+
for (const intent of intents) lines.push(`- ${intent.experiencePackId}@${intent.version} · ${intent.localState} · Hub receipt: none`);
|
|
361
|
+
return lines.join("\n");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function findIntent(state, ref) {
|
|
365
|
+
const matches = state.intents.filter((intent) => [intent.intentId, intent.experiencePackId, intent.releaseId].includes(ref));
|
|
366
|
+
if (!matches.length) return null;
|
|
367
|
+
return matches.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function publishExperienceIntent(userDataDir, sourcePath, cwd) {
|
|
371
|
+
if (!sourcePath) throw new Error("usage: agentlas experience publish <experience-pack.json>");
|
|
372
|
+
const source = path.resolve(cwd || process.cwd(), sourcePath);
|
|
373
|
+
const { absolute, value } = readJsonFile(source, "experience pack");
|
|
374
|
+
const pack = validateExperiencePack(value);
|
|
375
|
+
const now = new Date().toISOString();
|
|
376
|
+
const intentId = `experience-intent:${crypto.createHash("sha256").update(`${pack.releaseId}\0${pack.contentHash}`).digest("hex").slice(0, 32)}`;
|
|
377
|
+
const intent = {
|
|
378
|
+
schemaVersion: EXPERIENCE_INTENT_SCHEMA,
|
|
379
|
+
intentId,
|
|
380
|
+
experiencePackId: pack.experiencePackId,
|
|
381
|
+
releaseId: pack.releaseId,
|
|
382
|
+
ownerRef: pack.ownerRef,
|
|
383
|
+
version: pack.version,
|
|
384
|
+
contentHash: pack.contentHash,
|
|
385
|
+
compatibleBaseReleaseIds: [...pack.baseCompatibility.compatibleBaseReleaseIds],
|
|
386
|
+
mcpRequirementIds: pack.mcpRequirements.map((requirement) => requirement.requirementId),
|
|
387
|
+
sourcePath: absolute,
|
|
388
|
+
desiredAction: "publish",
|
|
389
|
+
localState: "publish-requested",
|
|
390
|
+
hubReceipt: null,
|
|
391
|
+
contractValidatedAt: now,
|
|
392
|
+
contentVerified: false,
|
|
393
|
+
updatedAt: now,
|
|
394
|
+
};
|
|
395
|
+
withExperienceStateLock(userDataDir, () => {
|
|
396
|
+
const state = loadExperienceState(userDataDir);
|
|
397
|
+
const existing = state.intents.findIndex((row) => row.intentId === intentId);
|
|
398
|
+
if (existing >= 0) state.intents[existing] = intent;
|
|
399
|
+
else state.intents.push(intent);
|
|
400
|
+
saveExperienceState(userDataDir, state);
|
|
401
|
+
});
|
|
402
|
+
return publicExperienceIntent(intent);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function unpublishExperienceIntent(userDataDir, ref) {
|
|
406
|
+
if (!ref) throw new Error("usage: agentlas experience unpublish <pack-id|release-id|intent-id>");
|
|
407
|
+
let intent;
|
|
408
|
+
withExperienceStateLock(userDataDir, () => {
|
|
409
|
+
const state = loadExperienceState(userDataDir);
|
|
410
|
+
intent = findIntent(state, ref);
|
|
411
|
+
if (!intent) throw new Error(`local Experience Pack intent not found: ${ref}`);
|
|
412
|
+
intent.desiredAction = "unpublish";
|
|
413
|
+
intent.localState = "unpublish-requested";
|
|
414
|
+
intent.hubReceipt = null;
|
|
415
|
+
intent.updatedAt = new Date().toISOString();
|
|
416
|
+
saveExperienceState(userDataDir, state);
|
|
417
|
+
});
|
|
418
|
+
return publicExperienceIntent(intent);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function cmdExperience(options) {
|
|
422
|
+
const args = options.args || [];
|
|
423
|
+
const sub = args[0] || "list";
|
|
424
|
+
const flags = parseSimpleFlags(args.slice(1));
|
|
425
|
+
const emit = options.out || console.log;
|
|
426
|
+
const userData = options.userDataDir;
|
|
427
|
+
if (!userData) throw new Error("Terminal userData path is required");
|
|
428
|
+
if (sub === "list" || sub === "ls") {
|
|
429
|
+
const list = loadExperienceState(userData).intents.map(publicExperienceIntent);
|
|
430
|
+
emit(flags.json ? JSON.stringify({ localOnly: true, hubPublicationAttempted: false, intents: list }, null, 2) : renderExperienceList(list));
|
|
431
|
+
return list;
|
|
432
|
+
}
|
|
433
|
+
if (sub === "inspect" || sub === "show") {
|
|
434
|
+
const ref = flags._[0];
|
|
435
|
+
if (!ref) throw new Error("usage: agentlas experience inspect <pack-id|release-id|intent-id>");
|
|
436
|
+
const intent = findIntent(loadExperienceState(userData), ref);
|
|
437
|
+
if (!intent) throw new Error(`local Experience Pack intent not found: ${ref}`);
|
|
438
|
+
const projected = publicExperienceIntent(intent);
|
|
439
|
+
emit(flags.json ? JSON.stringify(projected, null, 2) : [
|
|
440
|
+
`${projected.experiencePackId}@${projected.version}`,
|
|
441
|
+
`release: ${projected.releaseId}`,
|
|
442
|
+
`local intent: ${projected.desiredAction} (${projected.localState})`,
|
|
443
|
+
"Hub publication: not submitted · server receipt: none",
|
|
444
|
+
"base package: referenced only (not copied)",
|
|
445
|
+
].join("\n"));
|
|
446
|
+
return projected;
|
|
447
|
+
}
|
|
448
|
+
if (sub === "publish") {
|
|
449
|
+
const intent = publishExperienceIntent(userData, flags._[0], options.cwd);
|
|
450
|
+
emit(flags.json ? JSON.stringify(intent, null, 2) : [
|
|
451
|
+
`Local publish intent saved: ${intent.experiencePackId}@${intent.version}`,
|
|
452
|
+
"Hub publication: NOT performed · server receipt: none",
|
|
453
|
+
"Use the Hub API/UI later; this command does not claim remote publication.",
|
|
454
|
+
].join("\n"));
|
|
455
|
+
return intent;
|
|
456
|
+
}
|
|
457
|
+
if (sub === "unpublish") {
|
|
458
|
+
const intent = unpublishExperienceIntent(userData, flags._[0]);
|
|
459
|
+
emit(flags.json ? JSON.stringify(intent, null, 2) : [
|
|
460
|
+
`Local unpublish intent saved: ${intent.experiencePackId}@${intent.version}`,
|
|
461
|
+
"Hub state: unchanged · no server request or receipt was created.",
|
|
462
|
+
].join("\n"));
|
|
463
|
+
return intent;
|
|
464
|
+
}
|
|
465
|
+
throw new Error(`unknown experience subcommand: ${sub} (list|inspect|publish|unpublish)`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function readCredentialNames(userDataDir, env = process.env) {
|
|
469
|
+
const names = new Set(Object.keys(env || {}).filter((key) => ENV_RE.test(key) && env[key]));
|
|
470
|
+
const files = [
|
|
471
|
+
path.join(userDataDir, "credentials.env"),
|
|
472
|
+
path.join(os.homedir(), ".agentlas", "credentials.env"),
|
|
473
|
+
];
|
|
474
|
+
for (const file of files) {
|
|
475
|
+
try {
|
|
476
|
+
const stat = fs.statSync(file);
|
|
477
|
+
if (!stat.isFile() || stat.size > 512 * 1024) continue;
|
|
478
|
+
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
479
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
480
|
+
if (!match) continue;
|
|
481
|
+
let observed = match[2];
|
|
482
|
+
if ((observed.startsWith('"') && observed.endsWith('"')) || (observed.startsWith("'") && observed.endsWith("'"))) observed = observed.slice(1, -1);
|
|
483
|
+
if (observed) names.add(match[1]);
|
|
484
|
+
}
|
|
485
|
+
} catch { /* absent/unreadable means no observed key */ }
|
|
486
|
+
}
|
|
487
|
+
return names;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function safeCatalogId(value) {
|
|
491
|
+
const text = String(value || "").trim();
|
|
492
|
+
return ID_RE.test(text) && !UNSAFE_TEXT_PATTERNS.some((pattern) => pattern.re.test(text)) ? text : null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function safeDisplayName(value, fallback) {
|
|
496
|
+
const text = String(value || "").replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, " ").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
497
|
+
if (!text || UNSAFE_TEXT_PATTERNS.some((pattern) => pattern.re.test(text))) return fallback;
|
|
498
|
+
return text;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function collectSystemMcpInventory(db, options = {}) {
|
|
502
|
+
let rows = [];
|
|
503
|
+
let registryStatus = "complete";
|
|
504
|
+
try {
|
|
505
|
+
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();
|
|
506
|
+
} catch {
|
|
507
|
+
// An unreadable registry is not the same fact as a readable empty registry.
|
|
508
|
+
// Both fail closed to empty-MCP, but the user-facing plan preserves the cause.
|
|
509
|
+
registryStatus = "unavailable";
|
|
510
|
+
}
|
|
511
|
+
if (!Array.isArray(rows)) {
|
|
512
|
+
rows = [];
|
|
513
|
+
registryStatus = "unavailable";
|
|
514
|
+
}
|
|
515
|
+
if (rows.length > 1024) {
|
|
516
|
+
rows = [];
|
|
517
|
+
registryStatus = "unavailable";
|
|
518
|
+
}
|
|
519
|
+
const credentialNames = readCredentialNames(options.userDataDir || "", options.env || process.env);
|
|
520
|
+
const inventory = [];
|
|
521
|
+
const seen = new Set();
|
|
522
|
+
for (const row of rows) {
|
|
523
|
+
if (!row || Number(row.enabled) === 0) continue;
|
|
524
|
+
const catalogId = safeCatalogId(row.catalog_id) || safeCatalogId(row.id);
|
|
525
|
+
if (!catalogId || seen.has(catalogId)) continue;
|
|
526
|
+
seen.add(catalogId);
|
|
527
|
+
let keyNames = [];
|
|
528
|
+
let credentialMetadataStatus = "complete";
|
|
529
|
+
try {
|
|
530
|
+
if (String(row.env_keys_json || "[]").length > 64 * 1024) throw new Error("credential metadata too large");
|
|
531
|
+
const parsed = JSON.parse(row.env_keys_json || "[]");
|
|
532
|
+
if (!Array.isArray(parsed) || parsed.some((key) => !ENV_RE.test(String(key)))) credentialMetadataStatus = "unavailable";
|
|
533
|
+
else keyNames = [...new Set(parsed.map(String))];
|
|
534
|
+
} catch { credentialMetadataStatus = "unavailable"; }
|
|
535
|
+
const item = {
|
|
536
|
+
catalogId,
|
|
537
|
+
name: safeDisplayName(row.name || row.name_en, catalogId),
|
|
538
|
+
source: "system-global",
|
|
539
|
+
enabled: true,
|
|
540
|
+
keyRequired: credentialMetadataStatus !== "complete" || keyNames.length > 0,
|
|
541
|
+
keyPresent: credentialMetadataStatus === "complete" && (keyNames.length === 0 || keyNames.every((key) => credentialNames.has(key))),
|
|
542
|
+
credentialMetadataStatus,
|
|
543
|
+
};
|
|
544
|
+
Object.defineProperty(item, "credentialKeyNames", { value: keyNames, enumerable: false });
|
|
545
|
+
inventory.push(item);
|
|
546
|
+
}
|
|
547
|
+
Object.defineProperty(inventory, "registryStatus", { value: registryStatus, enumerable: false });
|
|
548
|
+
return inventory;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function validateMcpPolicy(value) {
|
|
552
|
+
const required = [
|
|
553
|
+
"schemaVersion", "kind", "registryResolutionOrder", "consentMode", "serverDefinitionsFromPackage",
|
|
554
|
+
"credentialValuesAllowed", "failureIsolation", "permissionWidening", "toolSchemaLoading", "skillLoading",
|
|
555
|
+
"contextBudget", "requirements",
|
|
556
|
+
];
|
|
557
|
+
assertExactKeys(value, new Set(required), required, "MCP policy");
|
|
558
|
+
if (value.schemaVersion !== "agentlas.mcp-policy.v1" || value.kind !== "agentlas-mcp-policy") throw new Error("MCP policy schema is invalid");
|
|
559
|
+
if (!Array.isArray(value.registryResolutionOrder) || value.registryResolutionOrder[0] !== "system-global") throw new Error("MCP policy must resolve system-global inventory first");
|
|
560
|
+
const allowedLayers = new Set(["system-global", "project-local", "catalog-recommendation"]);
|
|
561
|
+
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");
|
|
562
|
+
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");
|
|
563
|
+
if (value.permissionWidening !== "ask" || value.toolSchemaLoading !== "selected-tools-only" || value.skillLoading !== "triggered-only") throw new Error("MCP policy loading/permission mode is invalid");
|
|
564
|
+
const budget = assertObject(value.contextBudget, "MCP policy.contextBudget");
|
|
565
|
+
assertExactKeys(budget, new Set(Object.keys(TOKEN_BUDGET)), Object.keys(TOKEN_BUDGET), "MCP policy.contextBudget");
|
|
566
|
+
for (const [key, max] of Object.entries(TOKEN_BUDGET)) {
|
|
567
|
+
if (!Number.isInteger(budget[key]) || budget[key] < 0 || budget[key] > max) throw new Error(`MCP policy.contextBudget.${key} exceeds the frozen maximum`);
|
|
568
|
+
}
|
|
569
|
+
if (!Array.isArray(value.requirements) || value.requirements.length > 64) throw new Error("MCP policy requirements are invalid");
|
|
570
|
+
value.requirements.forEach((requirement, index) => validateMcpRequirement(requirement, `MCP policy.requirements[${index}]`));
|
|
571
|
+
const requirementIds = value.requirements.map((requirement) => requirement.requirementId);
|
|
572
|
+
if (new Set(requirementIds).size !== requirementIds.length) throw new Error("MCP policy requirementId values must be unique");
|
|
573
|
+
return value;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function loadProjectMcpPolicy(cwd) {
|
|
577
|
+
const file = path.join(cwd || process.cwd(), ".agentlas", "mcp-policy.json");
|
|
578
|
+
if (!fs.existsSync(file)) return null;
|
|
579
|
+
const { value } = readJsonFile(file, "MCP policy");
|
|
580
|
+
return validateMcpPolicy(value);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function syntheticRequirement(catalogId, required, priority) {
|
|
584
|
+
const suffix = crypto.createHash("sha256").update(catalogId).digest("hex").slice(0, 24);
|
|
585
|
+
return {
|
|
586
|
+
schemaVersion: "agentlas.mcp-requirement.v1",
|
|
587
|
+
kind: "agentlas-mcp-requirement",
|
|
588
|
+
requirementId: `terminal-requirement:${suffix}`,
|
|
589
|
+
catalogId,
|
|
590
|
+
reason: required ? "Explicitly required for this Terminal build" : "Explicitly recommended for this Terminal build",
|
|
591
|
+
capabilities: [`terminal-mcp:${suffix}`],
|
|
592
|
+
required,
|
|
593
|
+
requiresKey: false,
|
|
594
|
+
priority,
|
|
595
|
+
permissions: [],
|
|
596
|
+
alternatives: [],
|
|
597
|
+
unavailablePolicy: {
|
|
598
|
+
build: "degrade",
|
|
599
|
+
rental: required ? "exclude-variant" : "continue-degraded",
|
|
600
|
+
execution: required ? "use-alternative" : "continue-degraded",
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const HEURISTIC_GROUPS = [
|
|
606
|
+
[/(browser|playwright|chrome|web)/i, /(?:browser|website|web page|웹|브라우저|사이트|페이지|로그인)/i],
|
|
607
|
+
[/(github|gitlab|source)/i, /(?:github|gitlab|repository|pull request|issue|깃허브|레포|저장소)/i],
|
|
608
|
+
[/(figma|design)/i, /(?:figma|mockup|design|ui|ux|피그마|디자인)/i],
|
|
609
|
+
[/(postgres|mysql|sqlite|database|mongo)/i, /(?:database|sql|query|schema|db|데이터베이스|쿼리)/i],
|
|
610
|
+
[/(notion|docs|drive)/i, /(?:notion|document|docs|drive|노션|문서|드라이브)/i],
|
|
611
|
+
[/(slack|teams|discord)/i, /(?:slack|teams|discord|message|슬랙|메시지)/i],
|
|
612
|
+
[/(search|research)/i, /(?:search|research|lookup|검색|리서치|조사)/i],
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
function inferRequirements(request, inventory) {
|
|
616
|
+
const text = String(request || "");
|
|
617
|
+
const results = [];
|
|
618
|
+
for (const item of inventory) {
|
|
619
|
+
const direct = text.toLowerCase().includes(item.catalogId.toLowerCase()) || text.toLowerCase().includes(item.name.toLowerCase());
|
|
620
|
+
const heuristic = HEURISTIC_GROUPS.some(([nameRe, taskRe]) => nameRe.test(`${item.catalogId} ${item.name}`) && taskRe.test(text));
|
|
621
|
+
if (direct || heuristic) results.push(syntheticRequirement(item.catalogId, false, results.length + 100));
|
|
622
|
+
}
|
|
623
|
+
return results.slice(0, 8);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function indexInventory(inventory) {
|
|
627
|
+
return new Map((inventory || []).map((item) => [item.catalogId, item]));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function resolveMcpRequirement(requirement, inventoryById) {
|
|
631
|
+
const order = [requirement.catalogId, ...(requirement.alternatives || [])];
|
|
632
|
+
const attempted = [];
|
|
633
|
+
for (const catalogId of order) {
|
|
634
|
+
const item = inventoryById.get(catalogId);
|
|
635
|
+
if (!item) {
|
|
636
|
+
attempted.push({ catalogId, status: "unavailable" });
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const keyRequired = requirement.requiresKey || item.keyRequired;
|
|
640
|
+
// The trusted registry owns credential mapping. A package cannot turn an
|
|
641
|
+
// uncredentialed registry row into "key present" merely by declaring env metadata.
|
|
642
|
+
const keyPresent = keyRequired ? (item.keyRequired && item.keyPresent) : true;
|
|
643
|
+
if (!keyPresent) {
|
|
644
|
+
attempted.push({ catalogId, status: "missing-key" });
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
return { selected: item, status: "available", attempted, keyRequired, keyPresent: true };
|
|
648
|
+
}
|
|
649
|
+
const primary = inventoryById.get(requirement.catalogId);
|
|
650
|
+
const keyRequired = requirement.requiresKey || Boolean(primary && primary.keyRequired);
|
|
651
|
+
const missingKey = attempted.some((attempt) => attempt.status === "missing-key");
|
|
652
|
+
return { selected: null, status: missingKey ? "missing-key" : "unavailable", attempted, keyRequired, keyPresent: false };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function buildMcpPlan(options) {
|
|
656
|
+
const inventory = options.inventory || [];
|
|
657
|
+
const inventoryById = indexInventory(inventory);
|
|
658
|
+
const policyRequirements = options.policy ? options.policy.requirements : [];
|
|
659
|
+
const requirements = [...policyRequirements];
|
|
660
|
+
const known = new Set(requirements.map((requirement) => requirement.catalogId));
|
|
661
|
+
for (const catalogId of options.requiredIds || []) {
|
|
662
|
+
assertId(catalogId, "--require-mcp");
|
|
663
|
+
if (!known.has(catalogId)) { requirements.push(syntheticRequirement(catalogId, true, 1)); known.add(catalogId); }
|
|
664
|
+
}
|
|
665
|
+
for (const catalogId of options.recommendedIds || []) {
|
|
666
|
+
assertId(catalogId, "--recommend-mcp");
|
|
667
|
+
if (!known.has(catalogId)) { requirements.push(syntheticRequirement(catalogId, false, 500)); known.add(catalogId); }
|
|
668
|
+
}
|
|
669
|
+
if (!requirements.length) requirements.push(...inferRequirements(options.request, inventory));
|
|
670
|
+
const entries = requirements
|
|
671
|
+
.map((requirement) => {
|
|
672
|
+
const resolution = resolveMcpRequirement(requirement, inventoryById);
|
|
673
|
+
return {
|
|
674
|
+
requirementId: requirement.requirementId,
|
|
675
|
+
requestedCatalogId: requirement.catalogId,
|
|
676
|
+
resolvedCatalogId: resolution.selected ? resolution.selected.catalogId : null,
|
|
677
|
+
name: resolution.selected ? resolution.selected.name : requirement.catalogId,
|
|
678
|
+
source: resolution.selected ? resolution.selected.source : null,
|
|
679
|
+
required: requirement.required,
|
|
680
|
+
priority: requirement.priority,
|
|
681
|
+
reason: requirement.reason,
|
|
682
|
+
status: resolution.status,
|
|
683
|
+
keyRequired: resolution.keyRequired,
|
|
684
|
+
keyPresent: resolution.keyRequired ? resolution.keyPresent : null,
|
|
685
|
+
permissions: [...(requirement.permissions || [])],
|
|
686
|
+
permissionBasis: "package-declared",
|
|
687
|
+
permissionEnforced: false,
|
|
688
|
+
alternativesTried: resolution.attempted.map((attempt) => ({ catalogId: attempt.catalogId, status: attempt.status })),
|
|
689
|
+
unavailableBuildPolicy: "degrade",
|
|
690
|
+
};
|
|
691
|
+
})
|
|
692
|
+
.sort((a, b) => Number(b.required) - Number(a.required) || a.priority - b.priority || a.requestedCatalogId.localeCompare(b.requestedCatalogId));
|
|
693
|
+
return {
|
|
694
|
+
schemaVersion: "agentlas.terminal-mcp-build-plan.v1",
|
|
695
|
+
registryStatus: options.registryStatus || inventory.registryStatus || "complete",
|
|
696
|
+
registryResolutionOrder: options.policy ? [...options.policy.registryResolutionOrder] : ["system-global"],
|
|
697
|
+
discoveryNetworkUsed: false,
|
|
698
|
+
consentMode: "one-pass",
|
|
699
|
+
entries,
|
|
700
|
+
availableCatalogIds: [...new Set(entries.filter((entry) => entry.status === "available").map((entry) => entry.resolvedCatalogId))],
|
|
701
|
+
maxApprovedMcp: MAX_APPROVED_MCP_PER_BUILD,
|
|
702
|
+
shortages: entries.filter((entry) => entry.status !== "available").map((entry) => ({
|
|
703
|
+
requirementId: entry.requirementId,
|
|
704
|
+
catalogId: entry.requestedCatalogId,
|
|
705
|
+
required: entry.required,
|
|
706
|
+
status: entry.status,
|
|
707
|
+
effect: "build-degraded-only",
|
|
708
|
+
})),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function parseIdList(value) {
|
|
713
|
+
if (!value || value === true) return [];
|
|
714
|
+
return [...new Set(String(value).split(",").map((item) => item.trim()).filter(Boolean))];
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function tokenizeBuildCommandLine(value) {
|
|
718
|
+
const tokens = [];
|
|
719
|
+
let current = "";
|
|
720
|
+
let quote = null;
|
|
721
|
+
const source = String(value || "");
|
|
722
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
723
|
+
const char = source[index];
|
|
724
|
+
if (char === "\\" && quote !== "'") {
|
|
725
|
+
const next = source[index + 1];
|
|
726
|
+
if (next && (/\s/.test(next) || next === "\\" || next === '"' || next === "'")) {
|
|
727
|
+
current += next;
|
|
728
|
+
index += 1;
|
|
729
|
+
} else {
|
|
730
|
+
// Preserve Windows paths and ordinary backslashes. This parser only
|
|
731
|
+
// consumes escapes needed to group command-line tokens; it never
|
|
732
|
+
// applies shell expansion or command substitution.
|
|
733
|
+
current += "\\";
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (quote) {
|
|
738
|
+
if (char === quote) quote = null;
|
|
739
|
+
else current += char;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (char === '"' || char === "'") {
|
|
743
|
+
quote = char;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (/\s/.test(char)) {
|
|
747
|
+
if (current) tokens.push(current), current = "";
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
current += char;
|
|
751
|
+
}
|
|
752
|
+
if (quote) throw new Error("unterminated quote in /build command");
|
|
753
|
+
if (current) tokens.push(current);
|
|
754
|
+
return tokens;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function parseBuildArgs(args) {
|
|
758
|
+
const buildArgs = args;
|
|
759
|
+
const options = {
|
|
760
|
+
task: [], requiredIds: [], recommendedIds: [], approvedIds: [],
|
|
761
|
+
experienceTaskSignatures: [], experienceEnvironmentTags: [],
|
|
762
|
+
experienceBaseReleaseId: null, experienceAgentDefinitionId: null,
|
|
763
|
+
approveAll: false, noMcp: false, noExperience: false, planOnly: false, json: false,
|
|
764
|
+
};
|
|
765
|
+
for (let index = 0; index < buildArgs.length; index += 1) {
|
|
766
|
+
const token = String(buildArgs[index]);
|
|
767
|
+
const take = () => (index + 1 < buildArgs.length ? String(buildArgs[++index]) : "");
|
|
768
|
+
if (token === "--mcp-plan-only") options.planOnly = true;
|
|
769
|
+
else if (token === "--mcp-json") options.json = true;
|
|
770
|
+
else if (token === "--approve-all-mcp") options.approveAll = true;
|
|
771
|
+
else if (token === "--no-mcp") options.noMcp = true;
|
|
772
|
+
else if (token === "--no-experience") options.noExperience = true;
|
|
773
|
+
else if (token === "--experience-base-release") options.experienceBaseReleaseId = take();
|
|
774
|
+
else if (token.startsWith("--experience-base-release=")) options.experienceBaseReleaseId = token.slice(26);
|
|
775
|
+
else if (token === "--experience-agent-definition") options.experienceAgentDefinitionId = take();
|
|
776
|
+
else if (token.startsWith("--experience-agent-definition=")) options.experienceAgentDefinitionId = token.slice(30);
|
|
777
|
+
else if (token === "--experience-task-signature") options.experienceTaskSignatures.push(...parseIdList(take()));
|
|
778
|
+
else if (token.startsWith("--experience-task-signature=")) options.experienceTaskSignatures.push(...parseIdList(token.slice(28)));
|
|
779
|
+
else if (token === "--experience-environment") options.experienceEnvironmentTags.push(...parseIdList(take()));
|
|
780
|
+
else if (token.startsWith("--experience-environment=")) options.experienceEnvironmentTags.push(...parseIdList(token.slice(25)));
|
|
781
|
+
else if (token === "--approve-mcp") options.approvedIds.push(...parseIdList(take()));
|
|
782
|
+
else if (token.startsWith("--approve-mcp=")) options.approvedIds.push(...parseIdList(token.slice(14)));
|
|
783
|
+
else if (token === "--require-mcp") options.requiredIds.push(...parseIdList(take()));
|
|
784
|
+
else if (token.startsWith("--require-mcp=")) options.requiredIds.push(...parseIdList(token.slice(14)));
|
|
785
|
+
else if (token === "--recommend-mcp") options.recommendedIds.push(...parseIdList(take()));
|
|
786
|
+
else if (token.startsWith("--recommend-mcp=")) options.recommendedIds.push(...parseIdList(token.slice(16)));
|
|
787
|
+
else options.task.push(token);
|
|
788
|
+
}
|
|
789
|
+
options.request = options.task.join(" ").trim();
|
|
790
|
+
options.requiredIds = [...new Set(options.requiredIds)];
|
|
791
|
+
options.recommendedIds = [...new Set(options.recommendedIds)];
|
|
792
|
+
options.approvedIds = [...new Set(options.approvedIds)];
|
|
793
|
+
options.experienceTaskSignatures = [...new Set(options.experienceTaskSignatures)];
|
|
794
|
+
options.experienceEnvironmentTags = [...new Set(options.experienceEnvironmentTags)];
|
|
795
|
+
if (options.experienceBaseReleaseId) assertId(options.experienceBaseReleaseId, "--experience-base-release");
|
|
796
|
+
if (options.experienceAgentDefinitionId) assertId(options.experienceAgentDefinitionId, "--experience-agent-definition");
|
|
797
|
+
return options;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function renderMcpPlan(plan) {
|
|
801
|
+
const lines = [`MCP BUILD PLAN · system-global registry first · registry: ${plan.registryStatus} · no network discovery`];
|
|
802
|
+
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.");
|
|
803
|
+
if (!plan.entries.length) lines.push("- No relevant MCP recommended. Build continues in empty-MCP mode.");
|
|
804
|
+
for (const entry of plan.entries) {
|
|
805
|
+
const key = entry.keyRequired ? (entry.keyPresent ? "key: present" : "key: missing") : "key: not needed";
|
|
806
|
+
const requirement = entry.required ? "required" : "optional";
|
|
807
|
+
const permissions = entry.permissions.length ? entry.permissions.join(",") : "none";
|
|
808
|
+
lines.push(`- P${entry.priority} ${entry.name} [${entry.resolvedCatalogId || entry.requestedCatalogId}] · ${requirement} · ${entry.status} · ${key}`);
|
|
809
|
+
lines.push(` ${entry.reason}`);
|
|
810
|
+
lines.push(` permissions: ${permissions} · declared only; host enforcement not yet verified`);
|
|
811
|
+
}
|
|
812
|
+
if (plan.shortages.length) lines.push(`Shortages are isolated: ${plan.shortages.length} requirement(s) degrade only; the build does not abort.`);
|
|
813
|
+
return lines.join("\n");
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function normalizeConsentAnswer(answer, availableIds) {
|
|
817
|
+
const text = String(answer || "").trim();
|
|
818
|
+
if (/^(?:y|yes|all|전체)$/i.test(text)) return [...availableIds];
|
|
819
|
+
if (!text || /^(?:n|no|none|없이|아니)$/i.test(text)) return [];
|
|
820
|
+
const requested = parseIdList(text);
|
|
821
|
+
const allowed = new Set(availableIds);
|
|
822
|
+
return requested.filter((id) => allowed.has(id));
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function askMcpConsentOnce(plan, options = {}) {
|
|
826
|
+
const input = options.input || process.stdin;
|
|
827
|
+
const output = options.output || process.stderr;
|
|
828
|
+
if (!input.isTTY || !output.isTTY || !plan.availableCatalogIds.length) return Promise.resolve([]);
|
|
829
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
830
|
+
return new Promise((resolve) => {
|
|
831
|
+
rl.question("Attach the available MCP recommendations? [y=all / n=none / comma-separated ids] ", (answer) => {
|
|
832
|
+
rl.close();
|
|
833
|
+
resolve(normalizeConsentAnswer(answer, plan.availableCatalogIds));
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function buildMcpDirective(plan, approvedIds) {
|
|
839
|
+
const approved = fitApprovedMcpIds(plan, approvedIds);
|
|
840
|
+
const shortages = plan.shortages.map((item) => item.catalogId);
|
|
841
|
+
const base = [
|
|
842
|
+
"[AGENTLAS_MCP_BUILD_CONTEXT v1]",
|
|
843
|
+
"Resolve MCP only from the host system-global registry; package IDs/requirements only, never server definitions or credentials.",
|
|
844
|
+
].join(" ");
|
|
845
|
+
const approvedClause = `Approved catalog IDs: ${approved.length ? approved.join(",") : "none"}.`;
|
|
846
|
+
const shortagePrefix = "Unavailable or missing-key IDs: ";
|
|
847
|
+
const shortageSuffix = "; degrade each capability independently and continue the build.";
|
|
848
|
+
const fittedShortages = [];
|
|
849
|
+
for (const id of shortages.slice(0, 16)) {
|
|
850
|
+
const omitted = shortages.length - fittedShortages.length - 1;
|
|
851
|
+
const proposal = `${shortagePrefix}${fittedShortages.concat(id).join(",")}${omitted > 0 ? ` (+${omitted} more)` : ""}${shortageSuffix}`;
|
|
852
|
+
if (`${base} ${approvedClause} ${proposal}`.length > MAX_BUILD_DIRECTIVE_CHARS) break;
|
|
853
|
+
fittedShortages.push(id);
|
|
854
|
+
}
|
|
855
|
+
const omittedShortages = shortages.length - fittedShortages.length;
|
|
856
|
+
const shortageValue = shortages.length === 0
|
|
857
|
+
? "none"
|
|
858
|
+
: fittedShortages.length
|
|
859
|
+
? `${fittedShortages.join(",")}${omittedShortages > 0 ? ` (+${omittedShortages} more)` : ""}`
|
|
860
|
+
: `${shortages.length} unresolved (IDs omitted from prompt; declared policy remains source)`;
|
|
861
|
+
const shortageClause = `${shortagePrefix}${shortageValue}${shortageSuffix}`;
|
|
862
|
+
const line = `${base} ${approvedClause} ${shortageClause}`;
|
|
863
|
+
if (line.length > MAX_BUILD_DIRECTIVE_CHARS) throw new Error("internal MCP builder directive exceeded its context limit");
|
|
864
|
+
return line;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function fitApprovedMcpIds(plan, requestedIds) {
|
|
868
|
+
const available = new Set(plan.availableCatalogIds || []);
|
|
869
|
+
const requested = [...new Set((requestedIds || []).filter((id) => available.has(id)))].slice(0, MAX_APPROVED_MCP_PER_BUILD);
|
|
870
|
+
const accepted = [];
|
|
871
|
+
const fixedReserve = 520; // frozen instruction + minimum shortage/degrade clause
|
|
872
|
+
for (const id of requested) {
|
|
873
|
+
const clause = `Approved catalog IDs: ${accepted.concat(id).join(",")}.`;
|
|
874
|
+
if (clause.length + fixedReserve > MAX_BUILD_DIRECTIVE_CHARS) break;
|
|
875
|
+
accepted.push(id);
|
|
876
|
+
}
|
|
877
|
+
return accepted;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function renderBuildMcpResult(plan, approvedIds) {
|
|
881
|
+
const approved = new Set(approvedIds || []);
|
|
882
|
+
const lines = ["MCP BUILD RESULT"];
|
|
883
|
+
for (const entry of plan.entries) {
|
|
884
|
+
let status = entry.status;
|
|
885
|
+
if (entry.status === "available") status = approved.has(entry.resolvedCatalogId) ? "approved-for-builder-resolution" : "skipped";
|
|
886
|
+
lines.push(`- ${entry.resolvedCatalogId || entry.requestedCatalogId}: ${status}`);
|
|
887
|
+
}
|
|
888
|
+
if (!plan.entries.length || !approved.size) lines.push("- Build continued in empty-MCP mode.");
|
|
889
|
+
lines.push("Connection/tool-call success was not claimed; only the trusted host can emit that receipt.");
|
|
890
|
+
return lines.join("\n");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async function cmdBuild(options) {
|
|
894
|
+
const parsed = parseBuildArgs(options.args || []);
|
|
895
|
+
const emit = options.out || console.log;
|
|
896
|
+
const inventory = collectSystemMcpInventory(options.db, { userDataDir: options.userDataDir, env: options.env || process.env });
|
|
897
|
+
const policy = loadProjectMcpPolicy(options.cwd || process.cwd());
|
|
898
|
+
const plan = buildMcpPlan({
|
|
899
|
+
inventory, policy, request: parsed.request,
|
|
900
|
+
requiredIds: parsed.requiredIds, recommendedIds: parsed.recommendedIds,
|
|
901
|
+
});
|
|
902
|
+
emit(parsed.json ? JSON.stringify(plan, null, 2) : renderMcpPlan(plan));
|
|
903
|
+
if (parsed.planOnly) return { plan, approvedIds: [], invoked: false };
|
|
904
|
+
|
|
905
|
+
let approvedIds = [];
|
|
906
|
+
if (!parsed.noMcp) {
|
|
907
|
+
if (parsed.approveAll) approvedIds = [...plan.availableCatalogIds];
|
|
908
|
+
else if (parsed.approvedIds.length) approvedIds = normalizeConsentAnswer(parsed.approvedIds.join(","), plan.availableCatalogIds);
|
|
909
|
+
else approvedIds = await askMcpConsentOnce(plan, { input: options.input, output: options.promptOutput });
|
|
910
|
+
}
|
|
911
|
+
approvedIds = fitApprovedMcpIds(plan, approvedIds);
|
|
912
|
+
const directive = buildMcpDirective(plan, approvedIds);
|
|
913
|
+
let experienceContext = { text: "", itemIds: [], estimatedTokens: 0, authority: "local-advisory", serverRentalResolutionReceiptPresent: false };
|
|
914
|
+
if (!parsed.noExperience && parsed.experienceBaseReleaseId && parsed.experienceTaskSignatures.length) {
|
|
915
|
+
const exchange = require("./agentlas-experience-exchange.cjs");
|
|
916
|
+
experienceContext = exchange.buildLocalExperienceAdvisory({
|
|
917
|
+
userDataDir: options.userDataDir,
|
|
918
|
+
cwd: options.cwd || process.cwd(),
|
|
919
|
+
baseAgentReleaseId: parsed.experienceBaseReleaseId,
|
|
920
|
+
agentDefinitionId: parsed.experienceAgentDefinitionId,
|
|
921
|
+
taskSignatures: parsed.experienceTaskSignatures,
|
|
922
|
+
environmentTags: parsed.experienceEnvironmentTags.length
|
|
923
|
+
? exchange.defaultEnvironmentTags({ extra: parsed.experienceEnvironmentTags })
|
|
924
|
+
: exchange.defaultEnvironmentTags(),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
const builderRequest = [parsed.request, directive, experienceContext.text].filter(Boolean).join("\n\n");
|
|
928
|
+
if (typeof options.invokeBuild === "function") await options.invokeBuild(builderRequest, { plan, approvedIds, experienceContext });
|
|
929
|
+
emit(renderBuildMcpResult(plan, approvedIds));
|
|
930
|
+
if (experienceContext.itemIds.length) emit(`Local Experience advisory attached: ${experienceContext.itemIds.length} item(s), ~${experienceContext.estimatedTokens} tokens · no server rental-resolution receipt.`);
|
|
931
|
+
return { plan, approvedIds, experienceContext, invoked: typeof options.invokeBuild === "function" };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function validateVariantCandidate(candidate, index) {
|
|
935
|
+
const allowed = new Set(["variantId", "baseAgentReleaseId", "experiencePackReleaseId", "status", "compatibilityStatus", "score", "mcpRequirements"]);
|
|
936
|
+
assertExactKeys(candidate, allowed, ["variantId", "baseAgentReleaseId", "experiencePackReleaseId", "status", "compatibilityStatus", "score", "mcpRequirements"], `candidate[${index}]`);
|
|
937
|
+
for (const key of ["variantId", "baseAgentReleaseId", "experiencePackReleaseId"]) assertId(candidate[key], `candidate[${index}].${key}`);
|
|
938
|
+
const requirements = candidate.mcpRequirements == null ? [] : candidate.mcpRequirements;
|
|
939
|
+
if (!Array.isArray(requirements) || requirements.length > 64) throw new Error(`candidate[${index}].mcpRequirements is invalid`);
|
|
940
|
+
requirements.forEach((requirement, requirementIndex) => validateMcpRequirement(requirement, `candidate[${index}].mcpRequirements[${requirementIndex}]`));
|
|
941
|
+
const score = Number(candidate.score);
|
|
942
|
+
if (!Number.isFinite(score) || score < 0 || score > 1_000_000) throw new Error(`candidate[${index}].score is outside the local-preview range`);
|
|
943
|
+
return {
|
|
944
|
+
variantId: candidate.variantId,
|
|
945
|
+
baseAgentReleaseId: candidate.baseAgentReleaseId,
|
|
946
|
+
experiencePackReleaseId: candidate.experiencePackReleaseId,
|
|
947
|
+
status: String(candidate.status || "draft"),
|
|
948
|
+
compatibilityStatus: String(candidate.compatibilityStatus || "unverified"),
|
|
949
|
+
score,
|
|
950
|
+
mcpRequirements: requirements,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function resolveVariantCandidates(options) {
|
|
955
|
+
const candidates = (options.candidates || []).map(validateVariantCandidate);
|
|
956
|
+
const inventoryById = indexInventory(options.inventory || []);
|
|
957
|
+
if (!options.baseAgentReleaseId) {
|
|
958
|
+
return {
|
|
959
|
+
schemaVersion: "agentlas.terminal-variant-resolution.v1",
|
|
960
|
+
authority: "local-advisory",
|
|
961
|
+
executionAuthorized: false,
|
|
962
|
+
reputationAccepted: false,
|
|
963
|
+
serverResolutionReceiptPresent: false,
|
|
964
|
+
decision: "error",
|
|
965
|
+
code: "EXACT_BASE_RELEASE_REQUIRED",
|
|
966
|
+
selectedVariantId: null,
|
|
967
|
+
baseAgentReleaseId: null,
|
|
968
|
+
experiencePackReleaseId: null,
|
|
969
|
+
fallbackOrder: [],
|
|
970
|
+
degradedMcpIds: [],
|
|
971
|
+
excluded: [],
|
|
972
|
+
requiredMcpFailureScope: "variant-only",
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
const excluded = [];
|
|
976
|
+
const eligible = [];
|
|
977
|
+
for (const candidate of candidates) {
|
|
978
|
+
const reasons = [];
|
|
979
|
+
if (candidate.status !== "active") reasons.push(`variant-status:${candidate.status}`);
|
|
980
|
+
if (candidate.compatibilityStatus !== "verified") reasons.push(`compatibility:${candidate.compatibilityStatus}`);
|
|
981
|
+
if (options.baseAgentReleaseId && candidate.baseAgentReleaseId !== options.baseAgentReleaseId) reasons.push("base-release-mismatch");
|
|
982
|
+
const degradedMcpIds = [];
|
|
983
|
+
for (const requirement of candidate.mcpRequirements) {
|
|
984
|
+
const resolution = resolveMcpRequirement(requirement, inventoryById);
|
|
985
|
+
if (resolution.status !== "available" && requirement.required) reasons.push(`required-mcp-${resolution.status}:${requirement.catalogId}`);
|
|
986
|
+
else if (resolution.status !== "available") degradedMcpIds.push(requirement.catalogId);
|
|
987
|
+
}
|
|
988
|
+
if (reasons.length) excluded.push({ variantId: candidate.variantId, reasons });
|
|
989
|
+
else eligible.push({ ...candidate, degradedMcpIds });
|
|
990
|
+
}
|
|
991
|
+
eligible.sort((a, b) => b.score - a.score || a.variantId.localeCompare(b.variantId));
|
|
992
|
+
const selected = eligible[0] || null;
|
|
993
|
+
const baseRelease = options.baseAgentReleaseId;
|
|
994
|
+
if (selected) {
|
|
995
|
+
const decision = options.preferredVariantId && options.preferredVariantId !== selected.variantId ? "fallback" : "selected";
|
|
996
|
+
return {
|
|
997
|
+
schemaVersion: "agentlas.terminal-variant-resolution.v1",
|
|
998
|
+
authority: "local-advisory",
|
|
999
|
+
executionAuthorized: false,
|
|
1000
|
+
reputationAccepted: false,
|
|
1001
|
+
serverResolutionReceiptPresent: false,
|
|
1002
|
+
decision,
|
|
1003
|
+
selectedVariantId: selected.variantId,
|
|
1004
|
+
baseAgentReleaseId: selected.baseAgentReleaseId,
|
|
1005
|
+
experiencePackReleaseId: selected.experiencePackReleaseId,
|
|
1006
|
+
fallbackOrder: eligible.slice(1).map((candidate) => candidate.variantId),
|
|
1007
|
+
degradedMcpIds: selected.degradedMcpIds,
|
|
1008
|
+
excluded,
|
|
1009
|
+
requiredMcpFailureScope: "variant-only",
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
if (options.allowBaseOnly !== false && baseRelease) {
|
|
1013
|
+
return {
|
|
1014
|
+
schemaVersion: "agentlas.terminal-variant-resolution.v1",
|
|
1015
|
+
authority: "local-advisory",
|
|
1016
|
+
executionAuthorized: false,
|
|
1017
|
+
reputationAccepted: false,
|
|
1018
|
+
serverResolutionReceiptPresent: false,
|
|
1019
|
+
decision: "base-only",
|
|
1020
|
+
selectedVariantId: null,
|
|
1021
|
+
baseAgentReleaseId: baseRelease,
|
|
1022
|
+
experiencePackReleaseId: null,
|
|
1023
|
+
fallbackOrder: [],
|
|
1024
|
+
degradedMcpIds: [],
|
|
1025
|
+
excluded,
|
|
1026
|
+
requiredMcpFailureScope: "variant-only",
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
return {
|
|
1030
|
+
schemaVersion: "agentlas.terminal-variant-resolution.v1",
|
|
1031
|
+
authority: "local-advisory",
|
|
1032
|
+
executionAuthorized: false,
|
|
1033
|
+
reputationAccepted: false,
|
|
1034
|
+
serverResolutionReceiptPresent: false,
|
|
1035
|
+
decision: "error",
|
|
1036
|
+
code: "NO_ELIGIBLE_VARIANT_AND_NO_BASE_FALLBACK",
|
|
1037
|
+
selectedVariantId: null,
|
|
1038
|
+
baseAgentReleaseId: baseRelease,
|
|
1039
|
+
experiencePackReleaseId: null,
|
|
1040
|
+
fallbackOrder: [],
|
|
1041
|
+
degradedMcpIds: [],
|
|
1042
|
+
excluded,
|
|
1043
|
+
requiredMcpFailureScope: "variant-only",
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function renderVariantResolution(result) {
|
|
1048
|
+
const lines = [
|
|
1049
|
+
`VARIANT RESOLUTION: ${result.decision}`,
|
|
1050
|
+
"Local compatibility preview only; Hub rental requires a Web server resolution receipt.",
|
|
1051
|
+
"Candidate score/verified claims are not accepted as reputation, payment, rental, or execution authority.",
|
|
1052
|
+
];
|
|
1053
|
+
if (result.decision === "selected") lines.push(`selected: ${result.selectedVariantId}`);
|
|
1054
|
+
else if (result.decision === "fallback") lines.push(`fallback selected: ${result.selectedVariantId}`);
|
|
1055
|
+
else if (result.decision === "base-only") lines.push(`base-only: ${result.baseAgentReleaseId} (no Experience Pack attached)`);
|
|
1056
|
+
else lines.push(`error: ${result.code}`);
|
|
1057
|
+
if (result.fallbackOrder.length) lines.push(`next fallbacks: ${result.fallbackOrder.join(", ")}`);
|
|
1058
|
+
for (const excluded of result.excluded) lines.push(`excluded only ${excluded.variantId}: ${excluded.reasons.join(", ")}`);
|
|
1059
|
+
lines.push("Required MCP shortages exclude only the affected variant; they never create an agent-wide shortage.");
|
|
1060
|
+
return lines.join("\n");
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function cmdVariant(options) {
|
|
1064
|
+
const args = options.args || [];
|
|
1065
|
+
const sub = args[0] || "resolve";
|
|
1066
|
+
if (sub !== "resolve") throw new Error(`unknown variant subcommand: ${sub} (resolve)`);
|
|
1067
|
+
const flags = parseSimpleFlags(args.slice(1));
|
|
1068
|
+
let candidates = [];
|
|
1069
|
+
let baseAgentReleaseId = flags["base-release"] || null;
|
|
1070
|
+
const candidateFile = flags.candidates || flags._[0];
|
|
1071
|
+
if (candidateFile) {
|
|
1072
|
+
const { value } = readJsonFile(path.resolve(options.cwd || process.cwd(), candidateFile), "variant candidates");
|
|
1073
|
+
if (Array.isArray(value)) candidates = value;
|
|
1074
|
+
else {
|
|
1075
|
+
assertObject(value, "variant candidate document");
|
|
1076
|
+
if (!Array.isArray(value.candidates)) throw new Error("variant candidate document must contain candidates[]");
|
|
1077
|
+
candidates = value.candidates;
|
|
1078
|
+
if (!baseAgentReleaseId && value.baseAgentReleaseId) baseAgentReleaseId = value.baseAgentReleaseId;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (baseAgentReleaseId) assertId(baseAgentReleaseId, "--base-release");
|
|
1082
|
+
const inventory = collectSystemMcpInventory(options.db, { userDataDir: options.userDataDir, env: options.env || process.env });
|
|
1083
|
+
const result = resolveVariantCandidates({
|
|
1084
|
+
candidates,
|
|
1085
|
+
inventory,
|
|
1086
|
+
baseAgentReleaseId,
|
|
1087
|
+
preferredVariantId: flags.prefer || null,
|
|
1088
|
+
allowBaseOnly: flags["no-base-only"] !== true,
|
|
1089
|
+
});
|
|
1090
|
+
const emit = options.out || console.log;
|
|
1091
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : renderVariantResolution(result));
|
|
1092
|
+
if (result.decision === "error") (options.setExitCode || ((code) => { process.exitCode = code; }))(2);
|
|
1093
|
+
return result;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function estimateTokens(text) {
|
|
1097
|
+
// UTF-8 bytes / 3 is deliberately conservative for Korean and mixed code.
|
|
1098
|
+
return Math.ceil(Buffer.byteLength(String(text || ""), "utf8") / 3);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function buildExperienceContext(items, options = {}) {
|
|
1102
|
+
const maxItems = Math.min(TOKEN_BUDGET.experienceRetrievalMaxItems, Math.max(0, Number(options.maxItems ?? TOKEN_BUDGET.experienceRetrievalMaxItems)));
|
|
1103
|
+
const maxTokens = Math.min(TOKEN_BUDGET.experienceRetrievalMaxTokens, Math.max(0, Number(options.maxTokens ?? TOKEN_BUDGET.experienceRetrievalMaxTokens)));
|
|
1104
|
+
const relevant = (items || [])
|
|
1105
|
+
.filter((item) => item && item.relevant === true && item.status === "promoted")
|
|
1106
|
+
.sort((a, b) => Number(b.relevance || 0) - Number(a.relevance || 0) || String(a.id).localeCompare(String(b.id)));
|
|
1107
|
+
if (!relevant.length || !maxItems || !maxTokens) return { text: "", itemIds: [], estimatedTokens: 0 };
|
|
1108
|
+
let text = "EXPERIENCE (verified relevant items only):";
|
|
1109
|
+
const itemIds = [];
|
|
1110
|
+
for (const item of relevant.slice(0, maxItems)) {
|
|
1111
|
+
const id = assertId(item.id, "experience context item.id");
|
|
1112
|
+
const summary = assertSafeText(item.summary, "experience context item.summary", 320);
|
|
1113
|
+
const next = `${text}\n- [${id}] ${summary}`;
|
|
1114
|
+
if (estimateTokens(next) > maxTokens) continue;
|
|
1115
|
+
text = next;
|
|
1116
|
+
itemIds.push(id);
|
|
1117
|
+
}
|
|
1118
|
+
if (!itemIds.length) return { text: "", itemIds: [], estimatedTokens: 0 };
|
|
1119
|
+
return { text, itemIds, estimatedTokens: estimateTokens(text) };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
module.exports = {
|
|
1123
|
+
TOKEN_BUDGET,
|
|
1124
|
+
validateExperiencePack,
|
|
1125
|
+
validateMcpRequirement,
|
|
1126
|
+
validateMcpPolicy,
|
|
1127
|
+
experienceStatePath,
|
|
1128
|
+
loadExperienceState,
|
|
1129
|
+
publishExperienceIntent,
|
|
1130
|
+
unpublishExperienceIntent,
|
|
1131
|
+
cmdExperience,
|
|
1132
|
+
collectSystemMcpInventory,
|
|
1133
|
+
loadProjectMcpPolicy,
|
|
1134
|
+
resolveMcpRequirement,
|
|
1135
|
+
buildMcpPlan,
|
|
1136
|
+
parseBuildArgs,
|
|
1137
|
+
tokenizeBuildCommandLine,
|
|
1138
|
+
normalizeConsentAnswer,
|
|
1139
|
+
askMcpConsentOnce,
|
|
1140
|
+
fitApprovedMcpIds,
|
|
1141
|
+
buildMcpDirective,
|
|
1142
|
+
cmdBuild,
|
|
1143
|
+
resolveVariantCandidates,
|
|
1144
|
+
cmdVariant,
|
|
1145
|
+
estimateTokens,
|
|
1146
|
+
buildExperienceContext,
|
|
1147
|
+
};
|