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.
Files changed (49) hide show
  1. package/CHANGELOG.md +199 -0
  2. package/README.md +161 -18
  3. package/bin/agentlas.cjs +8 -8
  4. package/engine/agentlas-core-harness.cjs +212 -0
  5. package/engine/agentlas-desktop-loadout.cjs +527 -0
  6. package/engine/agentlas-doctor.cjs +1 -1
  7. package/engine/agentlas-experience-exchange.cjs +835 -85
  8. package/engine/agentlas-experience-intake.cjs +444 -0
  9. package/engine/agentlas-experience-mcp.cjs +580 -18
  10. package/engine/agentlas-i18n.cjs +10 -10
  11. package/engine/agentlas-input.cjs +5 -4
  12. package/engine/agentlas-mcp-env.cjs +219 -0
  13. package/engine/agentlas-mcp-wrapper.cjs +51 -0
  14. package/engine/agentlas-memory-governance.cjs +1029 -0
  15. package/engine/agentlas-native-host.cjs +129 -39
  16. package/engine/agentlas-parity.cjs +339 -154
  17. package/engine/agentlas-repl.cjs +306 -31
  18. package/engine/agentlas-workforce.cjs +2991 -0
  19. package/engine/agentlas-workload-routing.cjs +523 -0
  20. package/engine/agentlas.cjs +1619 -234
  21. package/engine/bootstrap-schema.sql +1 -1
  22. package/engine/experience-taxonomy-v1.json +49 -0
  23. package/package.json +8 -4
  24. package/scripts/gen-bootstrap-schema.sh +0 -23
  25. package/test/bootstrap-race.cjs +0 -47
  26. package/test/capture-runtime-guard.cjs +0 -122
  27. package/test/cloud-asset-restore.cjs +0 -423
  28. package/test/cloud-cas-client.cjs +0 -333
  29. package/test/cloud-owner-restore.cjs +0 -183
  30. package/test/cloud-runtime-paths.cjs +0 -40
  31. package/test/cloud-save-publish.cjs +0 -487
  32. package/test/credential-env-regression.cjs +0 -52
  33. package/test/engine-hardening-regression.cjs +0 -74
  34. package/test/experience-exchange-contract.cjs +0 -569
  35. package/test/experience-mcp-contract.cjs +0 -391
  36. package/test/fixtures/portable-experience-bundle-v1-golden.json +0 -124
  37. package/test/login-loopback-security.cjs +0 -115
  38. package/test/mcp-config-isolation.cjs +0 -36
  39. package/test/permission-mapping.cjs +0 -180
  40. package/test/route-regression.cjs +0 -357
  41. package/test/run-api-regression.cjs +0 -322
  42. package/test/runtime-env-protection.cjs +0 -89
  43. package/test/semver-precedence.cjs +0 -39
  44. package/test/smoke.sh +0 -93
  45. package/test/sqlite-driver-probe.cjs +0 -22
  46. package/test/terminal-ui-regression.cjs +0 -477
  47. package/test/timeout-regression.cjs +0 -218
  48. package/test/tool-workspace-boundary.cjs +0 -165
  49. 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:\/\/|(?:^|\s)(?:~\/|\/(?:Users|home|private|Volumes|var\/folders)\/|[A-Za-z]:\\(?:Users|Documents|Desktop)\\))/i },
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
- if (!Array.isArray(parsed) || parsed.some((key) => !ENV_RE.test(String(key)))) credentialMetadataStatus = "unavailable";
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
- return { selected: item, status: "available", attempted, keyRequired, keyPresent: true };
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
- return {
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.filter((entry) => entry.status === "available").map((entry) => entry.resolvedCatalogId))],
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") status = approved.has(entry.resolvedCatalogId) ? "approved-for-builder-resolution" : "skipped";
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 (!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.");
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 directive = buildMcpDirective(plan, approvedIds);
1439
+ const runtimeAllowlist = await resolveApprovedMcpRuntimeAllowlist({
1440
+ db: options.db,
1441
+ plan,
1442
+ approvedIds,
1443
+ cwd: options.cwd || process.cwd(),
1444
+ userDataDir: options.userDataDir,
1445
+ env: options.runtimeEnv || options.env || process.env,
1446
+ probeServer: options.probeMcpServer,
1447
+ });
1448
+ const attachedIds = runtimeAllowlist.attached.map((item) => item.catalogId);
1449
+ const directive = buildMcpDirective(plan, attachedIds);
913
1450
  let experienceContext = { text: "", itemIds: [], estimatedTokens: 0, authority: "local-advisory", serverRentalResolutionReceiptPresent: false };
914
- if (!parsed.noExperience && parsed.experienceBaseReleaseId && parsed.experienceTaskSignatures.length) {
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
- ? exchange.defaultEnvironmentTags({ extra: parsed.experienceEnvironmentTags })
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") await options.invokeBuild(builderRequest, { plan, approvedIds, experienceContext });
929
- emit(renderBuildMcpResult(plan, approvedIds));
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,