agentlas 0.7.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +199 -0
- package/README.md +161 -18
- package/bin/agentlas.cjs +8 -8
- package/engine/agentlas-core-harness.cjs +212 -0
- package/engine/agentlas-desktop-loadout.cjs +527 -0
- package/engine/agentlas-doctor.cjs +1 -1
- package/engine/agentlas-experience-exchange.cjs +835 -85
- package/engine/agentlas-experience-intake.cjs +444 -0
- package/engine/agentlas-experience-mcp.cjs +580 -18
- 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 +306 -31
- package/engine/agentlas-workforce.cjs +2991 -0
- package/engine/agentlas-workload-routing.cjs +523 -0
- package/engine/agentlas.cjs +1619 -234
- 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 -487
- package/test/credential-env-regression.cjs +0 -52
- package/test/engine-hardening-regression.cjs +0 -74
- package/test/experience-exchange-contract.cjs +0 -569
- package/test/experience-mcp-contract.cjs +0 -391
- package/test/fixtures/portable-experience-bundle-v1-golden.json +0 -124
- 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 -357
- package/test/run-api-regression.cjs +0 -322
- package/test/runtime-env-protection.cjs +0 -89
- package/test/semver-precedence.cjs +0 -39
- package/test/smoke.sh +0 -93
- package/test/sqlite-driver-probe.cjs +0 -22
- package/test/terminal-ui-regression.cjs +0 -477
- package/test/timeout-regression.cjs +0 -218
- package/test/tool-workspace-boundary.cjs +0 -165
- package/test/update-safety.cjs +0 -376
|
@@ -14,10 +14,17 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const crypto = require("node:crypto");
|
|
17
|
+
const { spawn } = require("node:child_process");
|
|
17
18
|
const fs = require("node:fs");
|
|
18
19
|
const os = require("node:os");
|
|
19
20
|
const path = require("node:path");
|
|
20
21
|
const readline = require("node:readline");
|
|
22
|
+
const {
|
|
23
|
+
buildMcpChildEnv,
|
|
24
|
+
hasSensitiveRuntimeArgument,
|
|
25
|
+
mcpRuntimeHome,
|
|
26
|
+
normalizeCredentialKeyNames,
|
|
27
|
+
} = require("./agentlas-mcp-env.cjs");
|
|
21
28
|
|
|
22
29
|
const TOKEN_BUDGET = Object.freeze({
|
|
23
30
|
coreMemoryMaxTokens: 150,
|
|
@@ -30,9 +37,14 @@ const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
|
30
37
|
const ENV_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
31
38
|
const EXPERIENCE_STATE_SCHEMA = "agentlas.terminal-experience-intents.v1";
|
|
32
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";
|
|
33
42
|
const MAX_JSON_BYTES = 2 * 1024 * 1024;
|
|
34
43
|
const MAX_BUILD_DIRECTIVE_CHARS = 1400;
|
|
35
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;
|
|
36
48
|
const EXPERIENCE_LOCK_STALE_MS = 30_000;
|
|
37
49
|
const EXPERIENCE_LOCK_WAIT_MS = 2_000;
|
|
38
50
|
|
|
@@ -56,7 +68,7 @@ const UNSAFE_TEXT_PATTERNS = [
|
|
|
56
68
|
{ code: "private-key", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i },
|
|
57
69
|
{ code: "credential", re: /(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|password|private[_ -]?key|authorization)\s*[:=]\s*\S+/i },
|
|
58
70
|
{ code: "bearer", re: /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i },
|
|
59
|
-
{ code: "private-path", re: /(?:file:\/\/|(
|
|
71
|
+
{ code: "private-path", re: /(?:file:\/\/|(?:^|[\s"'`()\[\]{}=:,;])(?:\.\.[/\\]|~[/\\]|\/(?!\/|\s)(?:[^/\s"'`<>]+\/)*[^/\s"'`<>]+|[A-Za-z]:[/\\]\S+|\\\\[^\\/\s]+[\\/][^\\/\s]+))/i },
|
|
60
72
|
{ code: "email", re: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i },
|
|
61
73
|
{ code: "phone", re: /(?:\+?\d[\d .()-]{8,}\d)/ },
|
|
62
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 },
|
|
@@ -231,12 +243,88 @@ function experienceStatePath(userDataDir) {
|
|
|
231
243
|
return path.join(userDataDir, "terminal", "experience-intents-v1.json");
|
|
232
244
|
}
|
|
233
245
|
|
|
246
|
+
function mcpConsentStatePath(userDataDir) {
|
|
247
|
+
return path.join(userDataDir, "terminal", "mcp-consents-v1.json");
|
|
248
|
+
}
|
|
249
|
+
|
|
234
250
|
function waitSync(milliseconds) {
|
|
235
251
|
// Atomics.wait is a bounded, non-spinning sleep available in supported Node 20+.
|
|
236
252
|
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
237
253
|
Atomics.wait(signal, 0, 0, milliseconds);
|
|
238
254
|
}
|
|
239
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
|
+
|
|
240
328
|
function withExperienceStateLock(userDataDir, action) {
|
|
241
329
|
const stateFile = experienceStatePath(userDataDir);
|
|
242
330
|
const dir = path.dirname(stateFile);
|
|
@@ -529,8 +617,7 @@ function collectSystemMcpInventory(db, options = {}) {
|
|
|
529
617
|
try {
|
|
530
618
|
if (String(row.env_keys_json || "[]").length > 64 * 1024) throw new Error("credential metadata too large");
|
|
531
619
|
const parsed = JSON.parse(row.env_keys_json || "[]");
|
|
532
|
-
|
|
533
|
-
else keyNames = [...new Set(parsed.map(String))];
|
|
620
|
+
keyNames = normalizeCredentialKeyNames(parsed);
|
|
534
621
|
} catch { credentialMetadataStatus = "unavailable"; }
|
|
535
622
|
const item = {
|
|
536
623
|
catalogId,
|
|
@@ -541,7 +628,13 @@ function collectSystemMcpInventory(db, options = {}) {
|
|
|
541
628
|
keyPresent: credentialMetadataStatus === "complete" && (keyNames.length === 0 || keyNames.every((key) => credentialNames.has(key))),
|
|
542
629
|
credentialMetadataStatus,
|
|
543
630
|
};
|
|
631
|
+
Object.defineProperty(item, "registryServerId", { value: String(row.id), enumerable: false });
|
|
632
|
+
Object.defineProperty(item, "transport", { value: String(row.transport || ""), enumerable: false });
|
|
544
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
|
+
});
|
|
545
638
|
inventory.push(item);
|
|
546
639
|
}
|
|
547
640
|
Object.defineProperty(inventory, "registryStatus", { value: registryStatus, enumerable: false });
|
|
@@ -630,12 +723,17 @@ function indexInventory(inventory) {
|
|
|
630
723
|
function resolveMcpRequirement(requirement, inventoryById) {
|
|
631
724
|
const order = [requirement.catalogId, ...(requirement.alternatives || [])];
|
|
632
725
|
const attempted = [];
|
|
726
|
+
const candidates = [];
|
|
633
727
|
for (const catalogId of order) {
|
|
634
728
|
const item = inventoryById.get(catalogId);
|
|
635
729
|
if (!item) {
|
|
636
730
|
attempted.push({ catalogId, status: "unavailable" });
|
|
637
731
|
continue;
|
|
638
732
|
}
|
|
733
|
+
if (item.transport !== "stdio") {
|
|
734
|
+
attempted.push({ catalogId, status: "runtime-incompatible" });
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
639
737
|
const keyRequired = requirement.requiresKey || item.keyRequired;
|
|
640
738
|
// The trusted registry owns credential mapping. A package cannot turn an
|
|
641
739
|
// uncredentialed registry row into "key present" merely by declaring env metadata.
|
|
@@ -644,12 +742,22 @@ function resolveMcpRequirement(requirement, inventoryById) {
|
|
|
644
742
|
attempted.push({ catalogId, status: "missing-key" });
|
|
645
743
|
continue;
|
|
646
744
|
}
|
|
647
|
-
|
|
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
|
+
};
|
|
648
756
|
}
|
|
649
757
|
const primary = inventoryById.get(requirement.catalogId);
|
|
650
758
|
const keyRequired = requirement.requiresKey || Boolean(primary && primary.keyRequired);
|
|
651
759
|
const missingKey = attempted.some((attempt) => attempt.status === "missing-key");
|
|
652
|
-
return { selected: null, status: missingKey ? "missing-key" : "unavailable", attempted, keyRequired, keyPresent: false };
|
|
760
|
+
return { selected: null, candidates: [], status: missingKey ? "missing-key" : "unavailable", attempted, keyRequired, keyPresent: false };
|
|
653
761
|
}
|
|
654
762
|
|
|
655
763
|
function buildMcpPlan(options) {
|
|
@@ -670,7 +778,7 @@ function buildMcpPlan(options) {
|
|
|
670
778
|
const entries = requirements
|
|
671
779
|
.map((requirement) => {
|
|
672
780
|
const resolution = resolveMcpRequirement(requirement, inventoryById);
|
|
673
|
-
|
|
781
|
+
const entry = {
|
|
674
782
|
requirementId: requirement.requirementId,
|
|
675
783
|
requestedCatalogId: requirement.catalogId,
|
|
676
784
|
resolvedCatalogId: resolution.selected ? resolution.selected.catalogId : null,
|
|
@@ -685,19 +793,40 @@ function buildMcpPlan(options) {
|
|
|
685
793
|
permissions: [...(requirement.permissions || [])],
|
|
686
794
|
permissionBasis: "package-declared",
|
|
687
795
|
permissionEnforced: false,
|
|
796
|
+
fallbackCatalogIds: resolution.candidates.slice(1).map((candidate) => candidate.item.catalogId),
|
|
688
797
|
alternativesTried: resolution.attempted.map((attempt) => ({ catalogId: attempt.catalogId, status: attempt.status })),
|
|
689
798
|
unavailableBuildPolicy: "degrade",
|
|
690
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;
|
|
691
817
|
})
|
|
692
818
|
.sort((a, b) => Number(b.required) - Number(a.required) || a.priority - b.priority || a.requestedCatalogId.localeCompare(b.requestedCatalogId));
|
|
693
819
|
return {
|
|
694
820
|
schemaVersion: "agentlas.terminal-mcp-build-plan.v1",
|
|
821
|
+
planId: crypto.randomUUID(),
|
|
695
822
|
registryStatus: options.registryStatus || inventory.registryStatus || "complete",
|
|
696
823
|
registryResolutionOrder: options.policy ? [...options.policy.registryResolutionOrder] : ["system-global"],
|
|
697
824
|
discoveryNetworkUsed: false,
|
|
698
825
|
consentMode: "one-pass",
|
|
699
826
|
entries,
|
|
700
|
-
availableCatalogIds: [...new Set(entries.
|
|
827
|
+
availableCatalogIds: [...new Set(entries.flatMap((entry) =>
|
|
828
|
+
entry.status === "available" ? (entry.runtimeCandidates || []).map((candidate) => candidate.resolvedCatalogId) : []
|
|
829
|
+
))],
|
|
701
830
|
maxApprovedMcp: MAX_APPROVED_MCP_PER_BUILD,
|
|
702
831
|
shortages: entries.filter((entry) => entry.status !== "available").map((entry) => ({
|
|
703
832
|
requirementId: entry.requirementId,
|
|
@@ -709,6 +838,383 @@ function buildMcpPlan(options) {
|
|
|
709
838
|
};
|
|
710
839
|
}
|
|
711
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
|
+
|
|
712
1218
|
function parseIdList(value) {
|
|
713
1219
|
if (!value || value === true) return [];
|
|
714
1220
|
return [...new Set(String(value).split(",").map((item) => item.trim()).filter(Boolean))];
|
|
@@ -759,7 +1265,7 @@ function parseBuildArgs(args) {
|
|
|
759
1265
|
const options = {
|
|
760
1266
|
task: [], requiredIds: [], recommendedIds: [], approvedIds: [],
|
|
761
1267
|
experienceTaskSignatures: [], experienceEnvironmentTags: [],
|
|
762
|
-
experienceBaseReleaseId: null, experienceAgentDefinitionId: null,
|
|
1268
|
+
experiencePackReleaseIds: [], experienceBaseReleaseId: null, experienceAgentDefinitionId: null,
|
|
763
1269
|
approveAll: false, noMcp: false, noExperience: false, planOnly: false, json: false,
|
|
764
1270
|
};
|
|
765
1271
|
for (let index = 0; index < buildArgs.length; index += 1) {
|
|
@@ -772,6 +1278,8 @@ function parseBuildArgs(args) {
|
|
|
772
1278
|
else if (token === "--no-experience") options.noExperience = true;
|
|
773
1279
|
else if (token === "--experience-base-release") options.experienceBaseReleaseId = take();
|
|
774
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)));
|
|
775
1283
|
else if (token === "--experience-agent-definition") options.experienceAgentDefinitionId = take();
|
|
776
1284
|
else if (token.startsWith("--experience-agent-definition=")) options.experienceAgentDefinitionId = token.slice(30);
|
|
777
1285
|
else if (token === "--experience-task-signature") options.experienceTaskSignatures.push(...parseIdList(take()));
|
|
@@ -792,8 +1300,10 @@ function parseBuildArgs(args) {
|
|
|
792
1300
|
options.approvedIds = [...new Set(options.approvedIds)];
|
|
793
1301
|
options.experienceTaskSignatures = [...new Set(options.experienceTaskSignatures)];
|
|
794
1302
|
options.experienceEnvironmentTags = [...new Set(options.experienceEnvironmentTags)];
|
|
1303
|
+
options.experiencePackReleaseIds = [...new Set(options.experiencePackReleaseIds)];
|
|
795
1304
|
if (options.experienceBaseReleaseId) assertId(options.experienceBaseReleaseId, "--experience-base-release");
|
|
796
1305
|
if (options.experienceAgentDefinitionId) assertId(options.experienceAgentDefinitionId, "--experience-agent-definition");
|
|
1306
|
+
options.experiencePackReleaseIds.forEach((id) => assertId(id, "--experience-pack-release"));
|
|
797
1307
|
return options;
|
|
798
1308
|
}
|
|
799
1309
|
|
|
@@ -807,9 +1317,11 @@ function renderMcpPlan(plan) {
|
|
|
807
1317
|
const permissions = entry.permissions.length ? entry.permissions.join(",") : "none";
|
|
808
1318
|
lines.push(`- P${entry.priority} ${entry.name} [${entry.resolvedCatalogId || entry.requestedCatalogId}] · ${requirement} · ${entry.status} · ${key}`);
|
|
809
1319
|
lines.push(` ${entry.reason}`);
|
|
1320
|
+
if (entry.fallbackCatalogIds?.length) lines.push(` approved fallback order: ${entry.fallbackCatalogIds.join(",")}`);
|
|
810
1321
|
lines.push(` permissions: ${permissions} · declared only; host enforcement not yet verified`);
|
|
811
1322
|
}
|
|
812
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.");
|
|
813
1325
|
return lines.join("\n");
|
|
814
1326
|
}
|
|
815
1327
|
|
|
@@ -877,16 +1389,31 @@ function fitApprovedMcpIds(plan, requestedIds) {
|
|
|
877
1389
|
return accepted;
|
|
878
1390
|
}
|
|
879
1391
|
|
|
880
|
-
function renderBuildMcpResult(plan, approvedIds) {
|
|
1392
|
+
function renderBuildMcpResult(plan, approvedIds, runtimeAllowlist = null) {
|
|
881
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]));
|
|
882
1396
|
const lines = ["MCP BUILD RESULT"];
|
|
883
1397
|
for (const entry of plan.entries) {
|
|
884
1398
|
let status = entry.status;
|
|
885
|
-
if (entry.status === "available")
|
|
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
|
+
}
|
|
886
1412
|
lines.push(`- ${entry.resolvedCatalogId || entry.requestedCatalogId}: ${status}`);
|
|
887
1413
|
}
|
|
888
|
-
if (!
|
|
889
|
-
lines.push("
|
|
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.");
|
|
890
1417
|
return lines.join("\n");
|
|
891
1418
|
}
|
|
892
1419
|
|
|
@@ -909,26 +1436,50 @@ async function cmdBuild(options) {
|
|
|
909
1436
|
else approvedIds = await askMcpConsentOnce(plan, { input: options.input, output: options.promptOutput });
|
|
910
1437
|
}
|
|
911
1438
|
approvedIds = fitApprovedMcpIds(plan, approvedIds);
|
|
912
|
-
const
|
|
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);
|
|
913
1450
|
let experienceContext = { text: "", itemIds: [], estimatedTokens: 0, authority: "local-advisory", serverRentalResolutionReceiptPresent: false };
|
|
914
|
-
if (
|
|
1451
|
+
if (
|
|
1452
|
+
!parsed.noExperience &&
|
|
1453
|
+
parsed.experienceBaseReleaseId &&
|
|
1454
|
+
parsed.experienceTaskSignatures.length &&
|
|
1455
|
+
parsed.experiencePackReleaseIds.length === 1
|
|
1456
|
+
) {
|
|
915
1457
|
const exchange = require("./agentlas-experience-exchange.cjs");
|
|
916
1458
|
experienceContext = exchange.buildLocalExperienceAdvisory({
|
|
917
1459
|
userDataDir: options.userDataDir,
|
|
918
1460
|
cwd: options.cwd || process.cwd(),
|
|
919
1461
|
baseAgentReleaseId: parsed.experienceBaseReleaseId,
|
|
920
1462
|
agentDefinitionId: parsed.experienceAgentDefinitionId,
|
|
1463
|
+
experiencePackReleaseIds: parsed.experiencePackReleaseIds,
|
|
921
1464
|
taskSignatures: parsed.experienceTaskSignatures,
|
|
922
1465
|
environmentTags: parsed.experienceEnvironmentTags.length
|
|
923
|
-
?
|
|
1466
|
+
? parsed.experienceEnvironmentTags
|
|
924
1467
|
: exchange.defaultEnvironmentTags(),
|
|
925
1468
|
});
|
|
926
1469
|
}
|
|
927
1470
|
const builderRequest = [parsed.request, directive, experienceContext.text].filter(Boolean).join("\n\n");
|
|
928
|
-
if (typeof options.invokeBuild === "function")
|
|
929
|
-
|
|
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));
|
|
930
1481
|
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" };
|
|
1482
|
+
return { plan, approvedIds, mcpRuntimeAllowlist: runtimeAllowlist, experienceContext, invoked: typeof options.invokeBuild === "function" };
|
|
932
1483
|
}
|
|
933
1484
|
|
|
934
1485
|
function validateVariantCandidate(candidate, index) {
|
|
@@ -1121,6 +1672,9 @@ function buildExperienceContext(items, options = {}) {
|
|
|
1121
1672
|
|
|
1122
1673
|
module.exports = {
|
|
1123
1674
|
TOKEN_BUDGET,
|
|
1675
|
+
MCP_PROBE_CONCURRENCY,
|
|
1676
|
+
MCP_PROBE_PER_SERVER_TIMEOUT_MS,
|
|
1677
|
+
MCP_PROBE_TOTAL_TIMEOUT_MS,
|
|
1124
1678
|
validateExperiencePack,
|
|
1125
1679
|
validateMcpRequirement,
|
|
1126
1680
|
validateMcpPolicy,
|
|
@@ -1139,6 +1693,14 @@ module.exports = {
|
|
|
1139
1693
|
askMcpConsentOnce,
|
|
1140
1694
|
fitApprovedMcpIds,
|
|
1141
1695
|
buildMcpDirective,
|
|
1696
|
+
mcpConsentStatePath,
|
|
1697
|
+
loadMcpConsentState,
|
|
1698
|
+
materializeTrustedSystemMcpServer,
|
|
1699
|
+
persistMcpConsentReceipts,
|
|
1700
|
+
readConsentedSystemMcpServers,
|
|
1701
|
+
readApprovedSystemMcpServer,
|
|
1702
|
+
probeSystemMcpServerConnection,
|
|
1703
|
+
resolveApprovedMcpRuntimeAllowlist,
|
|
1142
1704
|
cmdBuild,
|
|
1143
1705
|
resolveVariantCandidates,
|
|
1144
1706
|
cmdVariant,
|